Files
stream-recorder/bin/frontend/static/index.html
T

1786 lines
74 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Stream-Recorder</title>
<link rel="icon" type="image/png" href="/favicon.png">
<style>
:root {
--bg: #0f1117;
--bg-card: #1a1d27;
--bg-card-hover: #222636;
--bg-input: #12141c;
--border: #2a2e3d;
--text: #e4e6ed;
--text-dim: #8b8fa3;
--accent: #6c7bff;
--accent-hover: #8490ff;
--success: #34d399;
--warning: #fbbf24;
--error: #f87171;
--recording: #ef4444;
--radius: 8px;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.5;
min-height: 100vh;
padding-bottom: 52px;
display: flex;
flex-direction: column;
}
.app { max-width: 1100px; margin: 0 auto; padding: 16px; flex: 1; width: 100%; }
header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 0;
border-bottom: 1px solid var(--border);
margin-bottom: 24px;
}
header h1 {
font-size: 1.3rem;
font-weight: 600;
display: flex;
align-items: center;
gap: 10px;
}
header h1 .header-logo {
width: 28px; height: 28px;
border-radius: 50%;
transition: filter 0.3s;
}
.rec-dot {
display: inline-block;
width: 10px; height: 10px;
background: var(--recording);
border-radius: 50%;
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
.tabs {
display: flex;
gap: 4px;
background: var(--bg-card);
padding: 4px;
border-radius: var(--radius);
}
.tab {
padding: 8px 16px;
border: none;
background: transparent;
color: var(--text-dim);
cursor: pointer;
border-radius: 6px;
font-size: 0.85rem;
font-weight: 500;
transition: all 0.15s;
}
.tab:hover { color: var(--text); }
.tab.active { background: var(--accent); color: #fff; }
.panel { display: none; }
.panel.active { display: block; }
.section-title {
font-size: 0.8rem;
font-weight: 600;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 12px;
}
.card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 16px;
margin-bottom: 12px;
transition: background 0.15s;
}
.card:hover { background: var(--bg-card-hover); }
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.card-title {
font-weight: 600;
font-size: 1rem;
display: flex;
align-items: center;
gap: 8px;
}
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: 12px;
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
}
.badge-running { background: rgba(239,68,68,0.15); color: var(--recording); }
.badge-completed { background: rgba(52,211,153,0.15); color: var(--success); }
.badge-stopped { background: rgba(139,143,163,0.15); color: var(--text-dim); }
.badge-error { background: rgba(248,113,113,0.15); color: var(--error); }
.badge-starting { background: rgba(251,191,36,0.15); color: var(--warning); }
.badge-scheduled { background: rgba(108,123,255,0.15); color: var(--accent); }
.meta {
display: flex;
flex-wrap: wrap;
gap: 16px;
font-size: 0.8rem;
color: var(--text-dim);
}
.meta-item { display: flex; align-items: center; gap: 4px; }
.actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.btn {
padding: 6px 14px;
border: 1px solid var(--border);
background: var(--bg-card);
color: var(--text);
border-radius: 6px;
cursor: pointer;
font-size: 0.8rem;
font-weight: 500;
transition: all 0.15s;
white-space: nowrap;
}
.btn:hover { background: var(--bg-card-hover); border-color: var(--accent); }
.btn-primary { background: var(--accent); color: #fff; border-color: var(--accent); }
.btn-primary:hover { background: var(--accent-hover); }
.btn-danger { border-color: var(--error); color: var(--error); }
.btn-danger:hover { background: rgba(248,113,113,0.1); }
.btn-success { border-color: var(--success); color: var(--success); }
.btn-success:hover { background: rgba(52,211,153,0.1); }
.btn-sm { padding: 4px 10px; font-size: 0.75rem; }
.empty {
text-align: center;
padding: 48px 16px;
color: var(--text-dim);
}
.empty-icon { font-size: 2rem; margin-bottom: 8px; }
/* Forms */
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.form-group { margin-bottom: 12px; }
.form-group.full { grid-column: 1 / -1; }
.form-group label {
display: block;
font-size: 0.8rem;
font-weight: 500;
color: var(--text-dim);
margin-bottom: 4px;
}
input, select, textarea {
width: 100%;
padding: 8px 12px;
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
font-size: 0.85rem;
font-family: inherit;
outline: none;
transition: border-color 0.15s;
}
input:focus, select:focus, textarea:focus { border-color: var(--accent); }
.checkbox-group {
display: flex;
align-items: center;
gap: 8px;
}
.checkbox-group input[type="checkbox"] { width: auto; accent-color: var(--accent); }
/* Logs */
.log-container {
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 12px;
max-height: 500px;
overflow-y: auto;
font-family: 'SF Mono', 'Fira Code', monospace;
font-size: 0.75rem;
line-height: 1.8;
}
.log-entry { display: flex; gap: 8px; }
.log-time { color: var(--text-dim); white-space: nowrap; }
.log-level { font-weight: 600; min-width: 42px; }
.log-level.INFO { color: var(--accent); }
.log-level.WARN, .log-level.WARNING { color: var(--warning); }
.log-level.ERROR { color: var(--error); }
.log-level.DEBUG { color: var(--text-dim); }
.log-msg { color: var(--text); word-break: break-all; }
/* Modal */
.modal-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.6);
z-index: 100;
align-items: center;
justify-content: center;
}
.modal-overlay.active { display: flex; }
.modal {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 12px;
padding: 24px;
width: 90%;
max-width: 600px;
max-height: 85vh;
overflow-y: auto;
}
.modal-title {
font-size: 1.1rem;
font-weight: 600;
margin-bottom: 16px;
}
.modal-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
margin-top: 20px;
}
/* Extend popup */
.extend-options {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-top: 8px;
}
/* Duration display */
.remaining {
font-size: 1.2rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.remaining.low { color: var(--warning); }
.remaining.critical { color: var(--error); }
/* Burger menu */
.burger {
display: none;
background: none;
border: none;
color: var(--text);
font-size: 1.5rem;
cursor: pointer;
padding: 4px 8px;
border-radius: 6px;
transition: background 0.15s;
line-height: 1;
}
.burger:hover { background: var(--bg-card); }
/* Responsive */
@media (max-width: 640px) {
.app { padding: 10px; }
.form-grid { grid-template-columns: 1fr; }
.burger { display: block; }
.tabs {
display: none;
flex-direction: column;
position: absolute;
top: 100%;
left: 0;
right: 0;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 0 0 var(--radius) var(--radius);
padding: 8px;
z-index: 90;
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
}
.tabs.open { display: flex; }
.tab { padding: 10px 16px; font-size: 0.9rem; text-align: left; border-radius: 6px; }
header {
flex-direction: row;
align-items: center;
justify-content: space-between;
position: relative;
}
header h1 { font-size: 1.1rem; }
.card { padding: 12px; }
.card-header { flex-direction: column; align-items: flex-start; gap: 8px; }
.card-title { font-size: 0.9rem; }
.meta { gap: 8px; font-size: 0.75rem; }
.meta-item { word-break: break-all; }
.actions { width: 100%; }
.actions .btn { flex: 1; text-align: center; }
.modal { width: 95%; padding: 16px; max-height: 90vh; }
.extend-options { justify-content: center; }
.remaining { font-size: 1rem; }
.settings-section .form-grid { grid-template-columns: 1fr; }
}
/* Settings section */
.settings-section {
margin-bottom: 24px;
}
.settings-section h3 {
font-size: 0.9rem;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid var(--border);
}
.card-recent { cursor: pointer; }
.card-recent:hover { border-color: var(--accent); }
.recent-meta { display: flex; gap: 0; font-size: 0.8rem; color: var(--text-dim); font-variant-numeric: tabular-nums; }
.recent-meta .rm-dur { min-width: 100px; text-align: right; }
.recent-meta .rm-size { min-width: 90px; text-align: right; }
.recent-meta .rm-date { min-width: 150px; text-align: right; }
@media (max-width: 640px) {
.recent-meta { flex-wrap: wrap; gap: 4px; }
.recent-meta .rm-dur, .recent-meta .rm-size, .recent-meta .rm-date { min-width: auto; text-align: left; }
}
.rec-detail-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px 24px; }
.rec-detail-item { display: flex; flex-direction: column; gap: 2px; }
.rec-detail-item.full { grid-column: 1 / -1; }
.rec-detail-label { font-size: 0.7rem; font-weight: 600; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.04em; }
.rec-detail-value { font-size: 0.9rem; color: var(--text); word-break: break-all; }
.rec-detail-value a { color: var(--accent); text-decoration: none; }
.rec-detail-value a:hover { text-decoration: underline; }
.rec-plik-link { display: inline-flex; align-items: center; gap: 6px; padding: 8px 14px; background: rgba(108,123,255,0.1); border: 1px solid var(--accent); border-radius: 6px; color: var(--accent); font-weight: 500; font-size: 0.85rem; text-decoration: none; transition: background 0.15s; }
.rec-plik-link:hover { background: rgba(108,123,255,0.2); text-decoration: none; }
.rec-detail-divider { grid-column: 1 / -1; border: none; border-top: 1px solid var(--border); margin: 4px 0; }
@media (max-width: 640px) { .rec-detail-grid { grid-template-columns: 1fr; } }
.site-footer {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 50;
text-align: center;
padding: 12px 16px;
background: rgba(15, 17, 23, 0.96);
border-top: 1px solid var(--border);
color: var(--text-dim);
font-size: 0.75rem;
}
.site-footer a {
color: var(--text-dim);
}
.site-footer a:last-child {
color: var(--accent);
}
</style>
</head>
<body>
<div class="app">
<header>
<h1><img src="/logo.png" alt="Logo" class="header-logo" id="headerLogo"> Stream-Recorder</h1>
<button class="burger" id="burgerBtn" onclick="toggleBurger()" aria-label="Menü">&#9776;</button>
<div class="tabs" id="navTabs">
<button class="tab active" data-tab="dashboard" onclick="switchTab('dashboard')">Dashboard</button>
<button class="tab" data-tab="jobs" onclick="switchTab('jobs')">Jobs</button>
<button class="tab" data-tab="stations" onclick="switchTab('stations')">Sender</button>
<button class="tab" data-tab="logs" onclick="switchTab('logs')">Logs</button>
<button class="tab" data-tab="settings" onclick="switchTab('settings')">Einstellungen</button>
</div>
</header>
<!-- Dashboard -->
<div class="panel active" id="panel-dashboard">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px">
<p class="section-title" style="margin:0">Aktive Aufnahmen</p>
<button class="btn btn-primary btn-sm" onclick="openQuickRecord()">+ Schnellaufnahme</button>
</div>
<div id="activeRecordings"></div>
<p class="section-title" style="margin-top:24px">Letzte Aufnahmen</p>
<div id="recentRecordings"></div>
</div>
<!-- Jobs -->
<div class="panel" id="panel-jobs">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px">
<p class="section-title" style="margin:0">Geplante Jobs</p>
<button class="btn btn-primary btn-sm" onclick="openJobModal()">+ Neuer Job</button>
</div>
<div id="jobsList"></div>
</div>
<!-- Stations -->
<div class="panel" id="panel-stations">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px">
<p class="section-title" style="margin:0">Senderbibliothek</p>
<button class="btn btn-primary btn-sm" onclick="openStationModal()">+ Neuer Sender</button>
</div>
<div id="stationsList"></div>
</div>
<!-- Logs -->
<div class="panel" id="panel-logs">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px">
<p class="section-title" style="margin:0">Protokoll</p>
<div style="display:flex;gap:8px">
<select id="logLevel" onchange="loadLogs()" style="width:auto">
<option value="">Alle Level</option>
<option value="ERROR">Error</option>
<option value="WARN">Warn</option>
<option value="INFO">Info</option>
<option value="DEBUG">Debug</option>
</select>
<button class="btn btn-sm" onclick="loadLogs()">Aktualisieren</button>
</div>
</div>
<div class="log-container" id="logContainer"></div>
</div>
<!-- Settings -->
<div class="panel" id="panel-settings">
<p class="section-title">Konfiguration</p>
<div id="settingsForm"></div>
</div>
</div>
<!-- Quick Record Modal -->
<div class="modal-overlay" id="quickRecordModal">
<div class="modal">
<div class="modal-title">Schnellaufnahme</div>
<div class="form-group">
<label>Senderbibliothek</label>
<select id="qr-station" onchange="applyStationToQuickRecord()">
<option value="">URL manuell eingeben</option>
</select>
</div>
<div class="form-group">
<label>Stream-URL *</label>
<input type="text" id="qr-url" placeholder="rtmp://... oder https://...m3u8">
</div>
<div class="form-grid">
<div class="form-group">
<label>Name (optional)</label>
<input type="text" id="qr-name" placeholder="Meine Aufnahme">
</div>
<div class="form-group">
<label>Typ</label>
<select id="qr-type">
<option value="auto">Automatisch</option>
<option value="rtmp">RTMP</option>
<option value="hls">HLS (m3u8)</option>
<option value="mp3">MP3</option>
<option value="aac">AAC</option>
<option value="ogg">OGG</option>
<option value="http">HTTP</option>
</select>
</div>
</div>
<div class="form-group">
<label>Maximale Dauer (Minuten, leer = unbegrenzt)</label>
<input type="number" id="qr-duration" placeholder="z.B. 120">
</div>
<div class="modal-actions">
<button class="btn" onclick="closeModal('quickRecordModal')">Abbrechen</button>
<button class="btn btn-primary" onclick="startQuickRecord()">Aufnahme starten</button>
</div>
</div>
</div>
<!-- Job Modal -->
<div class="modal-overlay" id="jobModal">
<div class="modal">
<div class="modal-title" id="jobModalTitle">Neuer Job</div>
<input type="hidden" id="job-edit-id">
<div class="form-grid">
<div class="form-group full">
<label>Senderbibliothek</label>
<select id="job-station" onchange="applyStationToJob()">
<option value="">URL manuell eingeben</option>
</select>
</div>
<div class="form-group full" id="jobUrlGroup">
<label>Stream-URL *</label>
<input type="text" id="job-url" placeholder="rtmp://... oder https://...m3u8">
</div>
<div class="form-group">
<label>Name *</label>
<input type="text" id="job-name" placeholder="Mein Stream">
</div>
<div class="form-group">
<label>Beschreibung (optional)</label>
<input type="text" id="job-description" placeholder="z.B. Freitagssendung, Live-Event...">
</div>
<div class="form-group">
<label>Typ</label>
<select id="job-type">
<option value="auto">Automatisch</option>
<option value="rtmp">RTMP</option>
<option value="hls">HLS</option>
<option value="mp3">MP3</option>
<option value="aac">AAC</option>
<option value="ogg">OGG</option>
<option value="http">HTTP</option>
</select>
</div>
<div class="form-group">
<label>Ausgabeformat</label>
<input type="text" id="job-format" placeholder="leer = automatisch (mp4, mp3, ...)">
</div>
<div class="form-group" id="durationGroup">
<label>Max. Dauer (Minuten)</label>
<input type="number" id="job-duration" placeholder="leer = unbegrenzt">
</div>
<div class="form-group" id="durationAutoGroup" style="display:none">
<label>Aufnahmedauer</label>
<div style="padding:8px 12px;background:var(--bg-input);border:1px solid var(--border);border-radius:6px;color:var(--text-dim);font-size:0.85rem" id="durationAutoText">wird aus Zeitplan berechnet</div>
</div>
<div class="form-group">
<label>Segment-Dauer (Minuten)</label>
<input type="number" id="job-segment" placeholder="leer = deaktiviert">
</div>
<div class="form-group">
<label>Extra ffmpeg Argumente</label>
<input type="text" id="job-ffmpeg" placeholder="z.B. -b:a 192k">
</div>
</div>
<div class="job-section" style="margin-top:16px; border-top:1px solid var(--border); padding-top:12px">
<div class="checkbox-group" style="margin-bottom:12px">
<input type="checkbox" id="job-schedule-enabled" onchange="toggleSchedule()">
<label for="job-schedule-enabled" style="margin:0">Zeitplan</label>
</div>
<div id="scheduleFields" style="display:none">
<div class="form-grid">
<div class="form-group">
<div class="checkbox-group">
<input type="checkbox" id="job-schedule-once" onchange="toggleScheduleOnce()">
<label for="job-schedule-once" style="margin:0">Einmalig</label>
</div>
</div>
<div class="form-group" id="scheduleDateGroup" style="display:none">
<label>Startdatum</label>
<input type="date" id="job-schedule-date">
</div>
<div class="form-group" id="scheduleEndDateGroup" style="display:none">
<label>Enddatum (leer = gleicher Tag)</label>
<input type="date" id="job-schedule-end-date" oninput="updateDurationField()">
</div>
<div class="form-group" id="scheduleDaysGroup">
<label>Tage (z.B. Mo-Fr, Sa,So, *)</label>
<input type="text" id="job-schedule-days" value="*" placeholder="*">
</div>
<div class="form-group">
<label>Startzeit</label>
<input type="time" id="job-schedule-start" oninput="updateDurationField()">
</div>
<div class="form-group">
<label>Endzeit</label>
<input type="time" id="job-schedule-stop" oninput="updateDurationField()">
</div>
</div>
<div style="margin-top:8px; padding:8px 12px; background:var(--bg-input); border:1px solid var(--border); border-radius:6px">
<div style="font-size:0.8rem; color:var(--text-dim); margin-bottom:6px">Zeitpuffer</div>
<div style="display:flex; gap:16px; flex-wrap:wrap">
<div class="checkbox-group">
<input type="checkbox" id="job-pre-buffer-enabled" onchange="togglePreBuffer()">
<label for="job-pre-buffer-enabled" style="margin:0">Vorlaufzeit</label>
<input type="number" id="job-pre-buffer" min="1" max="120" style="width:60px; display:none" oninput="updateDurationField()">
<span id="job-pre-buffer-unit" style="font-size:0.8rem; color:var(--text-dim); display:none">Min.</span>
</div>
<div class="checkbox-group">
<input type="checkbox" id="job-post-buffer-enabled" onchange="togglePostBuffer()">
<label for="job-post-buffer-enabled" style="margin:0">Nachlaufzeit</label>
<input type="number" id="job-post-buffer" min="1" max="120" style="width:60px; display:none" oninput="updateDurationField()">
<span id="job-post-buffer-unit" style="font-size:0.8rem; color:var(--text-dim); display:none">Min.</span>
</div>
</div>
</div>
</div>
</div>
<div class="job-section" style="margin-top:12px; border-top:1px solid var(--border); padding-top:12px">
<div class="checkbox-group" style="margin-bottom:8px">
<input type="checkbox" id="job-ntfy-enabled">
<label for="job-ntfy-enabled" style="margin:0">Benachrichtigungen (NTFY)</label>
</div>
<div style="font-size:12px; color:var(--text-dim); margin-left:24px">Start, Stop und Fehler per Push-Nachricht</div>
</div>
<div class="job-section" style="margin-top:12px; border-top:1px solid var(--border); padding-top:12px">
<div class="checkbox-group" style="margin-bottom:8px">
<input type="checkbox" id="job-plik-enabled" onchange="togglePlik()">
<label for="job-plik-enabled" style="margin:0">Plik-Upload</label>
</div>
<div id="plikFields" style="display:none; margin-top:8px; margin-left:24px">
<div class="checkbox-group">
<input type="checkbox" id="job-delete-after">
<label for="job-delete-after" style="margin:0">File nach Upload lokal löschen</label>
</div>
</div>
</div>
<div class="modal-actions">
<button class="btn" onclick="closeModal('jobModal')">Abbrechen</button>
<button class="btn btn-primary" onclick="saveJob()">Speichern</button>
</div>
</div>
</div>
<!-- Station Modal -->
<div class="modal-overlay" id="stationModal">
<div class="modal">
<div class="modal-title" id="stationModalTitle">Neuer Sender</div>
<input type="hidden" id="station-edit-id">
<div class="form-grid">
<div class="form-group">
<label>Name *</label>
<input type="text" id="station-name" placeholder="Sendername">
</div>
<div class="form-group">
<label>Typ</label>
<select id="station-type">
<option value="auto">Automatisch</option>
<option value="rtmp">RTMP</option>
<option value="hls">HLS</option>
<option value="mp3">MP3</option>
<option value="aac">AAC</option>
<option value="ogg">OGG</option>
<option value="http">HTTP</option>
</select>
</div>
<div class="form-group full">
<label>Stream-URL *</label>
<input type="text" id="station-url" placeholder="rtmp://... oder https://...m3u8">
</div>
<div class="form-group full">
<label>Beschreibung (optional)</label>
<textarea id="station-description" rows="3" placeholder="z.B. Hauptstream, Wochenendprogramm..."></textarea>
</div>
</div>
<div class="modal-actions">
<button class="btn" onclick="closeModal('stationModal')">Abbrechen</button>
<button class="btn btn-primary" onclick="saveStation()">Speichern</button>
</div>
</div>
</div>
<!-- Extend Modal -->
<div class="modal-overlay" id="extendModal">
<div class="modal" style="max-width:400px">
<div class="modal-title">Aufnahme verlängern</div>
<input type="hidden" id="extend-rec-id">
<p id="extend-question" style="color:var(--text-dim);font-size:0.85rem;margin-bottom:12px">Wie lange soll die Aufnahme verlängert werden?</p>
<p id="extend-unlimited-info" style="display:none;color:var(--text-dim);font-size:0.85rem;margin-bottom:12px">&#8734; Diese Aufnahme ist bereits unbegrenzt und kann nicht weiter verlängert werden.</p>
<div class="extend-options" id="extend-options">
<button class="btn" onclick="doExtend(15)">+15 Min</button>
<button class="btn" onclick="doExtend(30)">+30 Min</button>
<button class="btn" onclick="doExtend(60)">+1 Std</button>
<button class="btn" onclick="doExtend(120)">+2 Std</button>
<button class="btn btn-success" onclick="doUnlimited()">Unbegrenzt</button>
</div>
<div style="margin-top:16px" id="extend-custom-group">
<label style="font-size:0.8rem;color:var(--text-dim)">Eigene Dauer (Minuten)</label>
<div style="display:flex;gap:8px;margin-top:4px">
<input type="number" id="extend-custom" placeholder="z.B. 45" style="flex:1">
<button class="btn btn-primary" onclick="doExtendCustom()">Verlängern</button>
</div>
</div>
<div class="modal-actions">
<button class="btn" onclick="closeModal('extendModal')">Abbrechen</button>
</div>
</div>
</div>
<!-- Recording Detail Modal -->
<div class="modal-overlay" id="recDetailModal">
<div class="modal" style="max-width:560px">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px">
<div class="modal-title" id="recDetailTitle" style="margin:0"></div>
<div id="recDetailBadges"></div>
</div>
<div class="rec-detail-grid" id="recDetailBody"></div>
<div class="modal-actions">
<button class="btn" onclick="closeModal('recDetailModal')">Schließen</button>
</div>
</div>
</div>
<script>
const API = '';
let stationsCache = [];
let authLost = false;
function formatErrorDetail(detail) {
if (Array.isArray(detail)) {
return detail.map(item => {
if (item && typeof item === 'object') {
const loc = Array.isArray(item.loc) ? item.loc.slice(1).join('.') : '';
const msg = item.msg != null ? String(item.msg) : '';
return loc ? `${loc}: ${msg}` : msg;
}
return String(item);
}).join('\n');
}
if (typeof detail === 'string') return detail;
if (detail && typeof detail === 'object') return detail.msg ? String(detail.msg) : JSON.stringify(detail);
return '';
}
async function api(path, opts = {}) {
const headers = { ...(opts.headers || {}) };
if (opts.body !== undefined && opts.body !== null && !headers['Content-Type']) {
headers['Content-Type'] = 'application/json';
}
let res;
try {
res = await fetch(API + path, { ...opts, headers });
} catch (e) {
throw new Error('Server nicht erreichbar');
}
if (!res.ok) {
if (res.status === 401) {
authLost = true;
throw new Error('Nicht angemeldet – Seite neu laden');
}
if (res.status === 429) throw new Error('Zu viele Fehlversuche, bitte später erneut versuchen');
const err = await res.json().catch(() => ({}));
throw new Error(formatErrorDetail(err.detail) || res.statusText || ('HTTP ' + res.status));
}
const text = await res.text();
return text ? JSON.parse(text) : null;
}
// Burger menu
function toggleBurger() {
document.getElementById('navTabs').classList.toggle('open');
}
// Tabs
function switchTab(name) {
document.getElementById('navTabs').classList.remove('open');
const panel = document.getElementById('panel-' + name);
if (!panel) return;
document.querySelectorAll('.panel').forEach(p => p.classList.remove('active'));
panel.classList.add('active');
document.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', t.dataset.tab === name));
if (name === 'logs') loadLogs();
if (name === 'jobs') loadJobs();
if (name === 'stations') loadStations();
if (name === 'settings') loadSettings();
}
// Dashboard
let dashInFlight = false;
let dashPending = false;
async function loadDashboard() {
if (dashInFlight) { dashPending = true; return; }
dashInFlight = true;
try {
const [active, recent] = await Promise.all([
api('/api/recordings?active_only=true'),
api('/api/recordings?limit=20'),
]);
renderActive(active || []);
renderRecent((recent || []).filter(r => r.status !== 'running' && r.status !== 'starting'));
} catch (e) {
console.error(e);
} finally {
dashInFlight = false;
if (dashPending) {
dashPending = false;
loadDashboard();
}
}
}
function pollDashboard() {
if (document.hidden || authLost) return;
loadDashboard();
}
function renderActive(recs) {
const el = document.getElementById('activeRecordings');
if (!recs.length) {
el.innerHTML = '<div class="empty"><div class="empty-icon">&#9898;</div>Keine aktiven Aufnahmen</div>';
return;
}
el.innerHTML = recs.map(r => {
const id = intId(r.id);
const hasLimit = r.remaining !== null && r.remaining !== undefined;
const remaining = Number(r.remaining);
const remainClass = hasLimit
? (remaining < 300 ? 'critical' : remaining < 900 ? 'low' : '')
: '';
return `
<div class="card">
<div class="card-header">
<div class="card-title">
<span class="rec-dot"></span>
${esc(r.job_name)}
<span class="badge ${statusClass(r.status)}">${esc(r.status)}</span>
${r.is_scheduled ? '<span class="badge badge-scheduled">geplant</span>' : ''}
</div>
${id !== null ? `<div class="actions">
<button class="btn btn-sm btn-success" onclick="openExtend(${id}, ${hasLimit ? 'true' : 'false'})">Verlängern</button>
<button class="btn btn-sm btn-danger" onclick="stopRec(${id})">Stoppen</button>
</div>` : ''}
</div>
<div class="meta">
<span class="meta-item">&#128339; ${esc(r.elapsed_display)}</span>
<span class="meta-item">&#128190; ${esc(r.total_size_display)}</span>
${r.segment_count != null ? `<span class="meta-item">&#128196; ${esc(r.segment_count)} Segmente</span>` : ''}
${hasLimit ? `<span class="meta-item">Verbleibend: <span class="remaining ${remainClass}">${esc(r.remaining_display)}</span></span>` : '<span class="meta-item">&#8734; Unbegrenzt</span>'}
</div>
<div class="meta" style="margin-top:4px">
<span class="meta-item" style="opacity:0.6">${esc(r.stream_url)}</span>
</div>
</div>`;
}).join('');
}
function renderRecent(recs) {
const el = document.getElementById('recentRecordings');
if (!recs.length) {
el.innerHTML = '<div class="empty" style="padding:24px"><span style="color:var(--text-dim)">Noch keine abgeschlossenen Aufnahmen</span></div>';
return;
}
el.innerHTML = recs.slice(0, 10).map(r => {
const started = fmtDate(r.started_at, '');
const id = intId(r.id);
return `
<div class="card card-recent" style="padding:12px"${id !== null ? ` onclick="openRecDetail(${id})"` : ''}>
<div style="display:flex;align-items:center;justify-content:space-between">
<div>
<span style="font-weight:500">${esc(r.job_name)}</span>
<span class="badge ${statusClass(r.status)}" style="margin-left:8px">${esc(r.status)}</span>
${r.plik_url ? '<span class="badge badge-completed" style="margin-left:4px">&#128228; Plik</span>' : ''}
</div>
<div class="recent-meta">
<span class="rm-dur">&#128339; ${esc(r.elapsed_display)}</span>
<span class="rm-size">&#128190; ${esc(r.total_size_display)}</span>
<span class="rm-date">${esc(started)}</span>
</div>
</div>
</div>`;
}).join('');
}
async function openRecDetail(recId) {
try {
const id = intId(recId);
if (id === null) return;
const r = await api('/api/recordings/' + id);
const started = fmtDate(r.started_at, '—');
const stopped = fmtDate(r.stopped_at, '—');
const fileName = r.output_file ? String(r.output_file).split('/').pop() : null;
document.getElementById('recDetailTitle').textContent = r.job_name || '';
document.getElementById('recDetailBadges').innerHTML =
`<span class="badge ${statusClass(r.status)}">${esc(r.status)}</span>` +
(r.plik_url ? ' <span class="badge badge-completed">&#128228; Plik</span>' : '');
let html = '';
if (r.plik_url) {
const plikUrl = String(r.plik_url);
const plikValue = /^https?:\/\//i.test(plikUrl)
? `<a class="rec-plik-link" href="${esc(plikUrl)}" target="_blank" rel="noopener noreferrer">&#128279; Im Browser öffnen</a>`
: esc(plikUrl);
html += `<div class="rec-detail-item full">
<span class="rec-detail-label">Plik-Download</span>
<span class="rec-detail-value">${plikValue}</span>
</div>
<hr class="rec-detail-divider">`;
}
html += `
<div class="rec-detail-item">
<span class="rec-detail-label">Gestartet</span>
<span class="rec-detail-value">${esc(started)}</span>
</div>
<div class="rec-detail-item">
<span class="rec-detail-label">Beendet</span>
<span class="rec-detail-value">${esc(stopped)}</span>
</div>
<div class="rec-detail-item">
<span class="rec-detail-label">Dauer</span>
<span class="rec-detail-value">${esc(r.elapsed_display)}</span>
</div>
<div class="rec-detail-item">
<span class="rec-detail-label">Dateigröße</span>
<span class="rec-detail-value">${esc(r.total_size_display)}</span>
</div>`;
if (r.error_message) {
html += `<div class="rec-detail-item full">
<span class="rec-detail-label">Fehler</span>
<span class="rec-detail-value" style="color:var(--error)">${esc(r.error_message)}</span>
</div>`;
}
html += '<hr class="rec-detail-divider">';
if (fileName) {
html += `<div class="rec-detail-item full">
<span class="rec-detail-label">Datei</span>
<span class="rec-detail-value">${esc(fileName)}</span>
</div>`;
}
if (r.output_dir) {
html += `<div class="rec-detail-item full">
<span class="rec-detail-label">Verzeichnis</span>
<span class="rec-detail-value">${esc(r.output_dir)}</span>
</div>`;
}
html += `<div class="rec-detail-item full">
<span class="rec-detail-label">Stream-URL</span>
<span class="rec-detail-value">${esc(r.stream_url)}</span>
</div>`;
document.getElementById('recDetailBody').innerHTML = html;
openModal('recDetailModal');
} catch (e) {
alert('Fehler: ' + e.message);
}
}
// Stations
async function loadStationOptions() {
try {
stationsCache = await api('/api/stations');
populateStationSelects();
} catch (e) {
console.error(e);
}
}
function populateStationSelects() {
['qr-station', 'job-station'].forEach(id => {
const el = document.getElementById(id);
if (!el) return;
const selected = el.value;
el.innerHTML = '<option value="">URL manuell eingeben</option>' + stationsCache.map(s => {
const id = intId(s.id);
if (id === null) return '';
return `<option value="${id}">${esc(s.name)}${s.description ? ' - ' + esc(s.description) : ''}</option>`;
}).join('');
if (stationsCache.some(s => String(s.id) === selected)) el.value = selected;
});
}
async function loadStations() {
try {
await loadStationOptions();
const el = document.getElementById('stationsList');
if (!stationsCache.length) {
el.innerHTML = '<div class="empty"><div class="empty-icon">&#128251;</div>Keine Sender gespeichert<br><button class="btn btn-primary" style="margin-top:12px" onclick="openStationModal()">Ersten Sender anlegen</button></div>';
return;
}
el.innerHTML = stationsCache.map(s => {
const id = intId(s.id);
return `
<div class="card">
<div class="card-header">
<div class="card-title">
${esc(s.name)}
<span class="badge badge-scheduled">${esc(s.stream_type || 'auto')}</span>
</div>
${id !== null ? `<div class="actions">
<button class="btn btn-sm" onclick="openStationModal(${id})">Bearbeiten</button>
<button class="btn btn-sm btn-danger" onclick="deleteStation(${id})">Löschen</button>
</div>` : ''}
</div>
${s.description ? `<div class="meta" style="margin-bottom:4px"><span class="meta-item" style="color:var(--text)">${esc(s.description)}</span></div>` : ''}
<div class="meta">
<span class="meta-item">${esc(s.stream_url)}</span>
</div>
</div>
`;
}).join('');
} catch (e) {
console.error(e);
}
}
function openStationModal(id = null) {
const station = id ? stationsCache.find(s => intId(s.id) === intId(id)) : null;
document.getElementById('station-edit-id').value = station ? station.id : '';
document.getElementById('stationModalTitle').textContent = station ? 'Sender bearbeiten' : 'Neuer Sender';
document.getElementById('station-name').value = station ? station.name : '';
document.getElementById('station-url').value = station ? station.stream_url : '';
document.getElementById('station-type').value = station ? station.stream_type || 'auto' : 'auto';
document.getElementById('station-description').value = station ? station.description || '' : '';
openModal('stationModal');
}
async function saveStation() {
const editId = document.getElementById('station-edit-id').value;
const data = {
name: document.getElementById('station-name').value,
stream_url: document.getElementById('station-url').value,
stream_type: document.getElementById('station-type').value,
description: document.getElementById('station-description').value,
};
if (!data.name || !data.stream_url) {
alert('Name und URL sind Pflichtfelder');
return;
}
try {
if (editId) {
await api(`/api/stations/${editId}`, { method: 'PUT', body: JSON.stringify(data) });
} else {
await api('/api/stations', { method: 'POST', body: JSON.stringify(data) });
}
closeModal('stationModal');
loadStations();
} catch (e) {
alert('Fehler: ' + e.message);
}
}
async function deleteStation(id) {
if (!confirm('Sender wirklich löschen?')) return;
try {
await api(`/api/stations/${intId(id)}`, { method: 'DELETE' });
loadStations();
} catch (e) {
alert('Fehler: ' + e.message);
}
}
function applyStationToQuickRecord() {
const id = document.getElementById('qr-station').value;
const station = stationsCache.find(s => String(s.id) === id);
if (!station) return;
document.getElementById('qr-url').value = station.stream_url;
document.getElementById('qr-type').value = station.stream_type || 'auto';
if (!document.getElementById('qr-name').value) {
document.getElementById('qr-name').value = station.name;
}
}
function applyStationToJob() {
const id = document.getElementById('job-station').value;
const station = stationsCache.find(s => String(s.id) === id);
document.getElementById('jobUrlGroup').style.display = station ? 'none' : '';
if (!station) return;
document.getElementById('job-url').value = station.stream_url;
document.getElementById('job-type').value = station.stream_type || 'auto';
if (!document.getElementById('job-name').value) {
document.getElementById('job-name').value = station.name;
}
if (!document.getElementById('job-description').value && station.description) {
document.getElementById('job-description').value = station.description;
}
}
// Jobs
async function loadJobs() {
try {
const jobs = await api('/api/jobs');
const el = document.getElementById('jobsList');
if (!jobs.length) {
el.innerHTML = '<div class="empty"><div class="empty-icon">&#128203;</div>Keine Jobs konfiguriert<br><button class="btn btn-primary" style="margin-top:12px" onclick="openJobModal()">Ersten Job erstellen</button></div>';
return;
}
el.innerHTML = jobs.map(j => {
const id = intId(j.id);
return `
<div class="card">
<div class="card-header">
<div class="card-title">
${esc(j.name)}
${j.is_recording ? '<span class="badge badge-running">läuft</span>' : ''}
${j.schedule_enabled ? '<span class="badge badge-scheduled">geplant</span>' : ''}
</div>
${id !== null ? `<div class="actions">
${!j.is_recording ? `<button class="btn btn-sm btn-success" onclick="startJob(${id})">Aufnehmen</button>` : ''}
<button class="btn btn-sm" onclick="editJob(${id})">Bearbeiten</button>
<button class="btn btn-sm btn-danger" onclick="deleteJob(${id})">Löschen</button>
</div>` : ''}
</div>
${j.description ? `<div class="meta" style="margin-bottom:4px"><span class="meta-item" style="color:var(--text)">${esc(j.description)}</span></div>` : ''}
<div class="meta">
<span class="meta-item">${esc(j.stream_type || 'auto')}</span>
<span class="meta-item">${j.station_name ? '&#128251; ' + esc(j.station_name) : esc(j.stream_url)}</span>
${j.max_duration ? `<span class="meta-item">&#128339; ${esc(Math.round(j.max_duration/60))} Min</span>` : ''}
${j.segment_duration ? `<span class="meta-item">&#128196; alle ${esc(Math.round(j.segment_duration/60))} Min</span>` : ''}
${j.schedule_enabled ? `<span class="meta-item">&#128197; ${esc(j.schedule_once ? j.schedule_date : j.schedule_days)} ${esc(j.schedule_start)}${j.schedule_stop ? ' - ' + esc(j.schedule_stop) : ''}${j.pre_buffer ? ' (&#8722;' + esc(j.pre_buffer) + 'min)' : ''}${j.post_buffer ? ' (+' + esc(j.post_buffer) + 'min)' : ''}</span>` : ''}
${j.ntfy_enabled ? `<span class="meta-item">&#128276; NTFY</span>` : ''}
${j.plik_enabled ? `<span class="meta-item">&#128228; Plik${j.delete_after_upload ? ' (danach löschen)' : ''}</span>` : ''}
</div>
</div>
`;
}).join('');
} catch (e) {
console.error(e);
}
}
function openJobModal(job = null) {
loadStationOptions();
document.getElementById('job-edit-id').value = job ? job.id : '';
document.getElementById('jobModalTitle').textContent = job ? 'Job bearbeiten' : 'Neuer Job';
document.getElementById('job-station').value = job && job.station_id ? job.station_id : '';
document.getElementById('jobUrlGroup').style.display = (job && job.station_id) ? 'none' : '';
document.getElementById('job-url').value = job ? job.stream_url : '';
document.getElementById('job-name').value = job ? job.name : '';
document.getElementById('job-type').value = job ? job.stream_type : 'auto';
document.getElementById('job-description').value = job ? job.description || '' : '';
document.getElementById('job-format').value = job ? job.output_format || '' : '';
document.getElementById('job-duration').value = job && job.max_duration ? Math.round(job.max_duration / 60) : '';
document.getElementById('job-segment').value = job && job.segment_duration ? Math.round(job.segment_duration / 60) : '';
document.getElementById('job-ffmpeg').value = job ? job.extra_ffmpeg_args || '' : '';
document.getElementById('job-schedule-enabled').checked = job ? job.schedule_enabled : false;
document.getElementById('job-schedule-once').checked = job ? job.schedule_once : false;
document.getElementById('job-schedule-date').value = job ? job.schedule_date || '' : '';
document.getElementById('job-schedule-end-date').value = job ? job.schedule_end_date || '' : '';
document.getElementById('job-schedule-days').value = job ? job.schedule_days || '*' : '*';
document.getElementById('job-schedule-start').value = job ? job.schedule_start || '' : '';
document.getElementById('job-schedule-stop').value = job ? job.schedule_stop || '' : '';
document.getElementById('job-ntfy-enabled').checked = job ? job.ntfy_enabled : false;
document.getElementById('job-plik-enabled').checked = job ? job.plik_enabled : false;
document.getElementById('job-delete-after').checked = job ? job.delete_after_upload : false;
document.getElementById('job-pre-buffer-enabled').checked = job ? (job.pre_buffer || 0) > 0 : false;
document.getElementById('job-pre-buffer').value = job && job.pre_buffer ? job.pre_buffer : '';
document.getElementById('job-post-buffer-enabled').checked = job ? (job.post_buffer || 0) > 0 : false;
document.getElementById('job-post-buffer').value = job && job.post_buffer ? job.post_buffer : '';
toggleSchedule();
toggleScheduleOnce();
togglePreBuffer();
togglePostBuffer();
togglePlik();
openModal('jobModal');
}
async function editJob(id) {
try {
const job = await api(`/api/jobs/${intId(id)}`);
openJobModal(job);
} catch (e) {
alert('Fehler: ' + e.message);
}
}
function isUInt(v) { return /^\d+$/.test(String(v).trim()); }
async function saveJob() {
const editId = document.getElementById('job-edit-id').value;
const durMin = document.getElementById('job-duration').value;
const segMin = document.getElementById('job-segment').value;
const data = {
name: document.getElementById('job-name').value,
stream_url: document.getElementById('job-url').value,
stream_type: document.getElementById('job-type').value,
output_format: document.getElementById('job-format').value,
max_duration: durMin ? parseInt(durMin) * 60 : null,
extra_ffmpeg_args: document.getElementById('job-ffmpeg').value,
segment_duration: segMin ? parseInt(segMin) * 60 : null,
schedule_enabled: document.getElementById('job-schedule-enabled').checked,
schedule_once: document.getElementById('job-schedule-once').checked,
schedule_date: document.getElementById('job-schedule-date').value,
schedule_days: document.getElementById('job-schedule-days').value || '*',
schedule_start: document.getElementById('job-schedule-start').value,
schedule_stop: document.getElementById('job-schedule-stop').value,
schedule_end_date: document.getElementById('job-schedule-end-date').value,
pre_buffer: document.getElementById('job-pre-buffer-enabled').checked ? parseInt(document.getElementById('job-pre-buffer').value || '0') : 0,
post_buffer: document.getElementById('job-post-buffer-enabled').checked ? parseInt(document.getElementById('job-post-buffer').value || '0') : 0,
description: document.getElementById('job-description').value,
ntfy_enabled: document.getElementById('job-ntfy-enabled').checked,
plik_enabled: document.getElementById('job-plik-enabled').checked,
delete_after_upload: document.getElementById('job-delete-after').checked,
station_id: document.getElementById('job-station').value ? parseInt(document.getElementById('job-station').value) : null,
};
if (!data.name || !data.stream_url) {
alert('Name und URL sind Pflichtfelder');
return;
}
if (durMin && (!isUInt(durMin) || parseInt(durMin) < 1)) {
alert('Max. Dauer muss eine ganze Zahl ≥ 1 (Minuten) sein');
return;
}
if (segMin && (!isUInt(segMin) || parseInt(segMin) < 1)) {
alert('Segment-Dauer muss eine ganze Zahl ≥ 1 (Minuten) sein');
return;
}
if (data.schedule_enabled) {
if (!data.schedule_start) {
alert('Bei aktivem Zeitplan ist eine Startzeit erforderlich');
return;
}
if (data.schedule_once && !data.schedule_date) {
alert('Bei einmaligem Zeitplan ist ein Startdatum erforderlich');
return;
}
if (data.schedule_once && data.schedule_end_date && data.schedule_date && data.schedule_end_date < data.schedule_date) {
alert('Enddatum darf nicht vor dem Startdatum liegen');
return;
}
}
for (const [key, label] of [['pre', 'Vorlaufzeit'], ['post', 'Nachlaufzeit']]) {
if (!document.getElementById(`job-${key}-buffer-enabled`).checked) continue;
const v = document.getElementById(`job-${key}-buffer`).value;
if (v && (!isUInt(v) || parseInt(v) > 120)) {
alert(`${label} muss eine ganze Zahl zwischen 0 und 120 Minuten sein`);
return;
}
}
try {
if (editId) {
await api(`/api/jobs/${intId(editId)}`, { method: 'PUT', body: JSON.stringify(data) });
} else {
await api('/api/jobs', { method: 'POST', body: JSON.stringify(data) });
}
closeModal('jobModal');
loadJobs();
} catch (e) {
alert('Fehler: ' + e.message);
}
}
async function deleteJob(id) {
if (!confirm('Job wirklich löschen?')) return;
try {
await api(`/api/jobs/${intId(id)}`, { method: 'DELETE' });
loadJobs();
} catch (e) {
alert('Fehler: ' + e.message);
}
}
async function startJob(id) {
try {
await api(`/api/jobs/${intId(id)}/record`, { method: 'POST' });
loadDashboard();
loadJobs();
} catch (e) {
alert('Fehler: ' + e.message);
}
}
function toggleSchedule() {
document.getElementById('scheduleFields').style.display =
document.getElementById('job-schedule-enabled').checked ? 'block' : 'none';
updateDurationField();
}
function updateDurationField() {
const scheduleOn = document.getElementById('job-schedule-enabled').checked;
const start = document.getElementById('job-schedule-start').value;
const stop = document.getElementById('job-schedule-stop').value;
const once = document.getElementById('job-schedule-once').checked;
const startDate = document.getElementById('job-schedule-date').value;
const endDate = document.getElementById('job-schedule-end-date').value;
const hasScheduleStop = scheduleOn && start && stop;
document.getElementById('durationGroup').style.display = hasScheduleStop ? 'none' : '';
document.getElementById('durationAutoGroup').style.display = hasScheduleStop ? '' : 'none';
if (hasScheduleStop) {
const preBuf = document.getElementById('job-pre-buffer-enabled').checked ? parseInt(document.getElementById('job-pre-buffer').value || '0') : 0;
const postBuf = document.getElementById('job-post-buffer-enabled').checked ? parseInt(document.getElementById('job-post-buffer').value || '0') : 0;
const bufTotal = preBuf + postBuf;
let text;
if (once && startDate && endDate && endDate !== startDate) {
const startDt = new Date(`${startDate}T${start}`);
const endDt = new Date(`${endDate}T${stop}`);
let diffMin = Math.round((endDt - startDt) / 60000) + bufTotal;
if (diffMin <= 0) { text = 'Enddatum muss nach Startdatum liegen'; }
else {
const d = Math.floor(diffMin / 1440);
const h = Math.floor((diffMin % 1440) / 60);
const m = diffMin % 60;
const parts = [];
if (d > 0) parts.push(`${d} Tag${d > 1 ? 'e' : ''}`);
if (h > 0) parts.push(`${h} Std.`);
if (m > 0) parts.push(`${m} Min.`);
text = `${parts.join(' ')} (mehrtägig, aus Zeitplan)`;
if (bufTotal > 0) text += ` inkl. ${bufTotal} Min. Puffer`;
}
} else {
const [sh, sm] = start.split(':').map(Number);
const [eh, em] = stop.split(':').map(Number);
let diff = (eh * 60 + em) - (sh * 60 + sm);
if (diff <= 0) diff += 1440;
diff += bufTotal;
const h = Math.floor(diff / 60);
const m = diff % 60;
text = (h > 0 ? `${h} Std. ${m > 0 ? m + ' Min.' : ''}` : `${m} Min.`) + ' (aus Zeitplan)';
if (bufTotal > 0) text += ` inkl. ${bufTotal} Min. Puffer`;
}
document.getElementById('durationAutoText').textContent = text;
}
}
function toggleScheduleOnce() {
const once = document.getElementById('job-schedule-once').checked;
document.getElementById('scheduleDateGroup').style.display = once ? 'block' : 'none';
document.getElementById('scheduleEndDateGroup').style.display = once ? 'block' : 'none';
document.getElementById('scheduleDaysGroup').style.display = once ? 'none' : 'block';
updateDurationField();
}
function togglePreBuffer() {
const on = document.getElementById('job-pre-buffer-enabled').checked;
document.getElementById('job-pre-buffer').style.display = on ? '' : 'none';
document.getElementById('job-pre-buffer-unit').style.display = on ? '' : 'none';
if (on && !document.getElementById('job-pre-buffer').value) {
document.getElementById('job-pre-buffer').value = window._defaultPreBuffer || 5;
}
updateDurationField();
}
function togglePostBuffer() {
const on = document.getElementById('job-post-buffer-enabled').checked;
document.getElementById('job-post-buffer').style.display = on ? '' : 'none';
document.getElementById('job-post-buffer-unit').style.display = on ? '' : 'none';
if (on && !document.getElementById('job-post-buffer').value) {
document.getElementById('job-post-buffer').value = window._defaultPostBuffer || 5;
}
updateDurationField();
}
function togglePlik() {
document.getElementById('plikFields').style.display =
document.getElementById('job-plik-enabled').checked ? 'block' : 'none';
}
// Quick Record
function openQuickRecord() {
loadStationOptions();
document.getElementById('qr-station').value = '';
document.getElementById('qr-url').value = '';
document.getElementById('qr-name').value = '';
document.getElementById('qr-type').value = 'auto';
document.getElementById('qr-duration').value = '';
openModal('quickRecordModal');
}
async function startQuickRecord() {
const url = document.getElementById('qr-url').value;
if (!url) { alert('URL ist ein Pflichtfeld'); return; }
const durMin = document.getElementById('qr-duration').value;
if (durMin && (!isUInt(durMin) || parseInt(durMin) < 1)) {
alert('Maximale Dauer muss eine ganze Zahl ≥ 1 (Minuten) sein');
return;
}
try {
await api('/api/quick-record', {
method: 'POST',
body: JSON.stringify({
url,
name: document.getElementById('qr-name').value,
stream_type: document.getElementById('qr-type').value,
max_duration: durMin ? parseInt(durMin) * 60 : null,
}),
});
closeModal('quickRecordModal');
switchTab('dashboard');
loadDashboard();
} catch (e) {
alert('Fehler: ' + e.message);
}
}
// Recording actions
async function stopRec(id) {
if (!confirm('Aufnahme wirklich stoppen?')) return;
try {
await api(`/api/recordings/${intId(id)}/stop`, { method: 'POST' });
loadDashboard();
} catch (e) {
alert('Fehler: ' + e.message);
}
}
function openExtend(id, hasLimit = true) {
document.getElementById('extend-rec-id').value = intId(id) ?? '';
document.getElementById('extend-custom').value = '';
document.getElementById('extend-question').style.display = hasLimit ? '' : 'none';
document.getElementById('extend-options').style.display = hasLimit ? '' : 'none';
document.getElementById('extend-custom-group').style.display = hasLimit ? '' : 'none';
document.getElementById('extend-unlimited-info').style.display = hasLimit ? 'none' : '';
openModal('extendModal');
}
async function doExtend(minutes) {
const id = intId(document.getElementById('extend-rec-id').value);
const min = Number(minutes);
if (id === null) return;
if (!Number.isInteger(min) || min < 1 || min > 1440) {
alert('Bitte eine ganze Zahl zwischen 1 und 1440 Minuten eingeben');
return;
}
try {
await api(`/api/recordings/${id}/extend?minutes=${min}`, { method: 'POST' });
closeModal('extendModal');
loadDashboard();
} catch (e) {
alert('Fehler: ' + e.message);
}
}
async function doExtendCustom() {
const min = document.getElementById('extend-custom').value;
if (!isUInt(min) || parseInt(min) < 1 || parseInt(min) > 1440) {
alert('Bitte eine ganze Zahl zwischen 1 und 1440 Minuten eingeben');
return;
}
await doExtend(parseInt(min));
}
async function doUnlimited() {
const id = intId(document.getElementById('extend-rec-id').value);
if (id === null) return;
try {
await api(`/api/recordings/${id}/unlimited`, { method: 'POST' });
closeModal('extendModal');
loadDashboard();
} catch (e) {
alert('Fehler: ' + e.message);
}
}
// Logs
const LOG_LEVELS = new Set(['DEBUG', 'INFO', 'WARN', 'WARNING', 'ERROR']);
async function loadLogs() {
try {
const level = document.getElementById('logLevel').value;
const logs = await api(`/api/logs?limit=200${level ? '&level=' + encodeURIComponent(level) : ''}`) || [];
const el = document.getElementById('logContainer');
if (!logs.length) {
el.innerHTML = '<span style="color:var(--text-dim)">Keine Logeinträge</span>';
return;
}
el.innerHTML = logs.map(l => {
const t = fmtDate(l.timestamp, '');
const lvlClass = LOG_LEVELS.has(l.level) ? l.level : '';
return `<div class="log-entry"><span class="log-time">${esc(t)}</span><span class="log-level ${lvlClass}">${esc(l.level)}</span><span class="log-msg">${esc(l.message)}</span></div>`;
}).join('');
} catch (e) {
alert('Fehler: ' + e.message);
}
}
// Settings
let settingsCfg = null;
async function loadSettings() {
let cfg;
try {
cfg = await api('/api/config');
} catch (e) {
alert('Fehler: ' + e.message);
return;
}
settingsCfg = cfg;
const secretPh = isSet => isSet ? '•••••••• (gespeichert)' : 'nicht gesetzt';
const el = document.getElementById('settingsForm');
el.innerHTML = `
<div class="settings-section">
<h3>Aufnahme</h3>
<div class="form-grid">
<div class="form-group full">
<label>Download-Pfad</label>
<input type="text" id="cfg-download-path" value="${esc(cfg.recording?.download_path || '/recordings')}">
</div>
<div class="form-group">
<label>Max. Wiederholungen</label>
<input type="number" id="cfg-max-retries" value="${esc(cfg.recording?.max_retries ?? 5)}">
</div>
<div class="form-group">
<label>Retry-Delay (Sek.)</label>
<input type="number" id="cfg-retry-delay" value="${esc(cfg.recording?.retry_delay ?? 5)}">
</div>
</div>
</div>
<div class="settings-section">
<h3>Authentifizierung</h3>
<div class="form-grid">
<div class="form-group full">
<div class="checkbox-group">
<input type="checkbox" id="cfg-auth-enabled" ${cfg.auth?.enabled ? 'checked' : ''}>
<label for="cfg-auth-enabled" style="margin:0">Basic Auth aktivieren</label>
</div>
</div>
<div class="form-group">
<label>Benutzername</label>
<input type="text" id="cfg-auth-user" value="${esc(cfg.auth?.username || 'admin')}">
</div>
<div class="form-group">
<label>Passwort</label>
<input type="password" id="cfg-auth-pass" autocomplete="new-password" placeholder="${esc(secretPh(cfg.auth?.password_set))}">
</div>
</div>
</div>
<div class="settings-section">
<h3>Zeitplan-Puffer (Standardwerte)</h3>
<div class="form-grid">
<div class="form-group">
<label>Standard-Vorlaufzeit (Min.)</label>
<input type="number" id="cfg-pre-buffer" min="1" max="120" value="${esc(cfg.schedule?.default_pre_buffer ?? 5)}">
</div>
<div class="form-group">
<label>Standard-Nachlaufzeit (Min.)</label>
<input type="number" id="cfg-post-buffer" min="1" max="120" value="${esc(cfg.schedule?.default_post_buffer ?? 5)}">
</div>
</div>
</div>
<div class="settings-section">
<h3>NTFY-Benachrichtigungen</h3>
<div class="form-grid">
<div class="form-group full">
<label>NTFY URL</label>
<input type="text" id="cfg-ntfy-url" value="${esc(cfg.ntfy?.url || '')}" placeholder="https://ntfy.sh/mein-topic">
</div>
<div class="form-group">
<label>Token</label>
<input type="password" id="cfg-ntfy-token" autocomplete="new-password" placeholder="${esc(secretPh(cfg.ntfy?.token_set))}">
${cfg.ntfy?.token_set ? `<div class="checkbox-group" style="margin-top:6px">
<input type="checkbox" id="cfg-ntfy-token-clear">
<label for="cfg-ntfy-token-clear" style="margin:0">Token entfernen</label>
</div>` : ''}
</div>
<div class="form-group">
<label>Events (kommasepariert)</label>
<input type="text" id="cfg-ntfy-events" value="${esc(cfg.ntfy?.events || 'error')}">
</div>
</div>
</div>
<div class="settings-section">
<h3>Plik Upload</h3>
<div class="form-grid">
<div class="form-group full">
<label>Plik URL</label>
<input type="text" id="cfg-plik-url" value="${esc(cfg.plik?.url || '')}" placeholder="https://plik.example.com">
</div>
<div class="form-group">
<label>API Key</label>
<input type="password" id="cfg-plik-key" autocomplete="new-password" placeholder="${esc(secretPh(cfg.plik?.api_key_set))}">
${cfg.plik?.api_key_set ? `<div class="checkbox-group" style="margin-top:6px">
<input type="checkbox" id="cfg-plik-key-clear">
<label for="cfg-plik-key-clear" style="margin:0">API-Key entfernen</label>
</div>` : ''}
</div>
<div class="form-group">
<label>TTL</label>
<input type="text" id="cfg-plik-ttl" value="${esc(cfg.plik?.ttl || '30d')}">
</div>
</div>
</div>
<div style="margin-top:16px">
<button class="btn btn-primary" onclick="saveSettings()">Einstellungen speichern</button>
</div>
`;
}
async function saveSettings() {
const prev = settingsCfg || {};
const passVal = document.getElementById('cfg-auth-pass').value;
const authData = {
enabled: document.getElementById('cfg-auth-enabled').checked,
username: document.getElementById('cfg-auth-user').value,
};
if (passVal && passVal.length < 8) {
alert('Bitte ein Passwort (mind. 8 Zeichen) setzen');
return;
}
if (authData.enabled && !prev.auth?.password_set && !passVal) {
alert('Bitte ein Passwort (mind. 8 Zeichen) setzen');
return;
}
if (passVal) authData.password = passVal;
const authChanged = authData.enabled !== !!prev.auth?.enabled
|| authData.username !== (prev.auth?.username || '')
|| !!passVal;
const ntfyData = {
url: document.getElementById('cfg-ntfy-url').value,
events: document.getElementById('cfg-ntfy-events').value,
};
const ntfyToken = document.getElementById('cfg-ntfy-token').value;
const ntfyClear = document.getElementById('cfg-ntfy-token-clear');
if (ntfyClear && ntfyClear.checked) ntfyData.token = '';
else if (ntfyToken) ntfyData.token = ntfyToken;
const plikData = {
url: document.getElementById('cfg-plik-url').value,
ttl: document.getElementById('cfg-plik-ttl').value,
};
const plikKey = document.getElementById('cfg-plik-key').value;
const plikClear = document.getElementById('cfg-plik-key-clear');
if (plikClear && plikClear.checked) plikData.api_key = '';
else if (plikKey) plikData.api_key = plikKey;
const data = {
recording: {
download_path: document.getElementById('cfg-download-path').value,
max_retries: intOr(document.getElementById('cfg-max-retries').value, 5),
retry_delay: intOr(document.getElementById('cfg-retry-delay').value, 5),
},
schedule: {
default_pre_buffer: intOr(document.getElementById('cfg-pre-buffer').value, 5),
default_post_buffer: intOr(document.getElementById('cfg-post-buffer').value, 5),
},
auth: authData,
ntfy: ntfyData,
plik: plikData,
};
try {
await api('/api/config', { method: 'PUT', body: JSON.stringify({ data }) });
window._defaultPreBuffer = data.schedule.default_pre_buffer;
window._defaultPostBuffer = data.schedule.default_post_buffer;
let msg = 'Einstellungen gespeichert';
if (authData.enabled && authChanged) {
msg += '\n\nBei aktivierter Anmeldung fragt der Browser beim nächsten Laden nach Benutzername/Passwort';
}
alert(msg);
loadSettings();
} catch (e) {
alert('Fehler: ' + e.message);
}
}
// Helpers
function openModal(id) { document.getElementById(id).classList.add('active'); }
function closeModal(id) { document.getElementById(id).classList.remove('active'); }
function intOr(v, fallback) { const n = parseInt(v, 10); return Number.isFinite(n) ? n : fallback; }
function esc(s) {
if (s === null || s === undefined) return '';
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function intId(v) {
if (v === null || v === undefined || v === '') return null;
const n = Number(v);
return Number.isInteger(n) ? n : null;
}
const STATUS_CLASSES = new Set(['running', 'starting', 'stopped', 'completed', 'error']);
function statusClass(status) {
return STATUS_CLASSES.has(status) ? 'badge-' + status : 'badge-stopped';
}
function fmtDate(s, fallback) {
return s ? new Date(s).toLocaleString('de-DE') : fallback;
}
// Close modals on overlay click
document.querySelectorAll('.modal-overlay').forEach(overlay => {
overlay.addEventListener('click', e => {
if (e.target === overlay) overlay.classList.remove('active');
});
});
// Close open modal on Escape
document.addEventListener('keydown', e => {
if (e.key === 'Escape') {
document.querySelectorAll('.modal-overlay.active').forEach(o => o.classList.remove('active'));
}
});
// Close burger menu on outside click
document.addEventListener('click', e => {
const tabs = document.getElementById('navTabs');
const burger = document.getElementById('burgerBtn');
if (tabs.classList.contains('open') && !tabs.contains(e.target) && e.target !== burger) {
tabs.classList.remove('open');
}
});
// Load schedule defaults from config
async function loadDefaults() {
try {
const cfg = await api('/api/config');
window._defaultPreBuffer = cfg.schedule?.default_pre_buffer ?? 5;
window._defaultPostBuffer = cfg.schedule?.default_post_buffer ?? 5;
} catch (e) { /* defaults stay at 5 */ }
}
// Auto-refresh
loadStationOptions();
loadDefaults();
loadDashboard();
setInterval(pollDashboard, 5000);
document.addEventListener('visibilitychange', () => {
if (!document.hidden && !authLost) loadDashboard();
});
api('/api/health').then(h => {
document.getElementById('footerVersion').textContent = 'Stream-Recorder v' + h.version;
}).catch(() => {});
</script>
<footer class="site-footer">
<span id="footerVersion">Stream-Recorder</span> &copy; <a href="https://www.patrick-asmus.de" target="_blank" rel="noopener" style="color:var(--text-dim)">Patrick Asmus</a> &middot; <a href="https://git.techniverse.net/scriptos/stream-recorder" target="_blank" rel="noopener" style="color:var(--accent)">Source</a>
</footer>
</body>
</html>