Files
Patrick Asmus a9a9a89cba
Build Release Docker Image / build-amd64 (release) Successful in 6s
Build Release Docker Image / build-arm64 (release) Successful in 10s
Build Release Docker Image / publish-release-manifest (release) Successful in 6s
Release: v3.1.0
2026-09-16 20:22:34 +02:00

1573 lines
65 KiB
HTML

<!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 { 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" onclick="switchTab('dashboard')">Dashboard</button>
<button class="tab" onclick="switchTab('jobs')">Jobs</button>
<button class="tab" onclick="switchTab('stations')">Sender</button>
<button class="tab" onclick="switchTab('logs')">Logs</button>
<button class="tab" 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 style="color:var(--text-dim);font-size:0.85rem;margin-bottom:12px">Wie lange soll die Aufnahme verlängert werden?</p>
<div class="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">
<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 = [];
async function api(path, opts = {}) {
const res = await fetch(API + path, {
headers: { 'Content-Type': 'application/json', ...opts.headers },
...opts,
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.detail || res.statusText);
}
return res.json();
}
// Burger menu
function toggleBurger() {
document.getElementById('navTabs').classList.toggle('open');
}
// Tabs
function switchTab(name) {
document.getElementById('navTabs').classList.remove('open');
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.panel').forEach(p => p.classList.remove('active'));
document.querySelector(`#panel-${name}`).classList.add('active');
const tabLabel = {
dashboard: 'dash',
jobs: 'jobs',
stations: 'sender',
logs: 'logs',
settings: 'einst',
}[name] || name;
document.querySelectorAll('.tab').forEach(t => {
if (t.textContent.toLowerCase().includes(tabLabel))
t.classList.add('active');
});
if (name === 'logs') loadLogs();
if (name === 'jobs') loadJobs();
if (name === 'stations') loadStations();
if (name === 'settings') loadSettings();
}
// Dashboard
async function loadDashboard() {
try {
const active = await api('/api/recordings?active_only=true');
const recent = await api('/api/recordings?limit=20');
renderActive(active);
renderRecent(recent.filter(r => r.status !== 'running' && r.status !== 'starting'));
} catch (e) {
console.error(e);
}
}
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 remainClass = r.remaining !== null
? (r.remaining < 300 ? 'critical' : r.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 badge-${r.status}">${r.status}</span>
${r.is_scheduled ? '<span class="badge badge-scheduled">geplant</span>' : ''}
</div>
<div class="actions">
<button class="btn btn-sm btn-success" onclick="openExtend(${r.id})">Verlängern</button>
<button class="btn btn-sm btn-danger" onclick="stopRec(${r.id})">Stoppen</button>
</div>
</div>
<div class="meta">
<span class="meta-item">&#128339; ${r.elapsed_display}</span>
<span class="meta-item">&#128190; ${r.total_size_display}</span>
${r.segment_count !== null ? `<span class="meta-item">&#128196; ${r.segment_count} Segmente</span>` : ''}
${r.remaining !== null ? `<span class="meta-item">Verbleibend: <span class="remaining ${remainClass}">${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 = r.started_at ? new Date(r.started_at).toLocaleString('de-DE') : '';
return `
<div class="card card-recent" style="padding:12px" onclick="openRecDetail(${r.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 badge-${r.status}" style="margin-left:8px">${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; ${r.elapsed_display}</span>
<span class="rm-size">&#128190; ${r.total_size_display}</span>
<span class="rm-date">${started}</span>
</div>
</div>
</div>`;
}).join('');
}
async function openRecDetail(recId) {
try {
const r = await api('/api/recordings/' + recId);
const started = r.started_at ? new Date(r.started_at).toLocaleString('de-DE') : '—';
const stopped = r.stopped_at ? new Date(r.stopped_at).toLocaleString('de-DE') : '—';
const fileName = r.output_file ? r.output_file.split('/').pop() : null;
document.getElementById('recDetailTitle').textContent = r.job_name;
document.getElementById('recDetailBadges').innerHTML =
`<span class="badge badge-${r.status}">${r.status}</span>` +
(r.plik_url ? ' <span class="badge badge-completed">&#128228; Plik</span>' : '');
let html = '';
if (r.plik_url) {
html += `<div class="rec-detail-item full">
<span class="rec-detail-label">Plik-Download</span>
<span class="rec-detail-value"><a class="rec-plik-link" href="${esc(r.plik_url)}" target="_blank" rel="noopener">&#128279; Im Browser öffnen</a></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">${started}</span>
</div>
<div class="rec-detail-item">
<span class="rec-detail-label">Beendet</span>
<span class="rec-detail-value">${stopped}</span>
</div>
<div class="rec-detail-item">
<span class="rec-detail-label">Dauer</span>
<span class="rec-detail-value">${r.elapsed_display}</span>
</div>
<div class="rec-detail-item">
<span class="rec-detail-label">Dateigröße</span>
<span class="rec-detail-value">${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) {
console.error(e);
}
}
// 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 =>
`<option value="${s.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 => `
<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>
<div class="actions">
<button class="btn btn-sm" onclick="openStationModal(${s.id})">Bearbeiten</button>
<button class="btn btn-sm btn-danger" onclick="deleteStation(${s.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 => s.id === 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;
await api(`/api/stations/${id}`, { method: 'DELETE' });
loadStations();
}
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 => `
<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>
<div class="actions">
${!j.is_recording ? `<button class="btn btn-sm btn-success" onclick="startJob(${j.id})">Aufnehmen</button>` : ''}
<button class="btn btn-sm" onclick="editJob(${j.id})">Bearbeiten</button>
<button class="btn btn-sm btn-danger" onclick="deleteJob(${j.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; ${Math.round(j.max_duration/60)} Min</span>` : ''}
${j.segment_duration ? `<span class="meta-item">&#128196; alle ${Math.round(j.segment_duration/60)} Min</span>` : ''}
${j.schedule_enabled ? `<span class="meta-item">&#128197; ${j.schedule_once ? j.schedule_date : j.schedule_days} ${j.schedule_start}${j.schedule_stop ? ' - '+j.schedule_stop : ''}${j.pre_buffer ? ' (&#8722;'+j.pre_buffer+'min)' : ''}${j.post_buffer ? ' (+'+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) {
const job = await api(`/api/jobs/${id}`);
openJobModal(job);
}
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;
}
try {
if (editId) {
await api(`/api/jobs/${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;
await api(`/api/jobs/${id}`, { method: 'DELETE' });
loadJobs();
}
async function startJob(id) {
try {
await api(`/api/jobs/${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;
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;
await api(`/api/recordings/${id}/stop`, { method: 'POST' });
loadDashboard();
}
function openExtend(id) {
document.getElementById('extend-rec-id').value = id;
document.getElementById('extend-custom').value = '';
openModal('extendModal');
}
async function doExtend(minutes) {
const id = document.getElementById('extend-rec-id').value;
await api(`/api/recordings/${id}/extend?minutes=${minutes}`, { method: 'POST' });
closeModal('extendModal');
loadDashboard();
}
async function doExtendCustom() {
const min = document.getElementById('extend-custom').value;
if (!min || min <= 0) return;
await doExtend(parseInt(min));
}
async function doUnlimited() {
const id = document.getElementById('extend-rec-id').value;
await api(`/api/recordings/${id}/unlimited`, { method: 'POST' });
closeModal('extendModal');
loadDashboard();
}
// Logs
async function loadLogs() {
const level = document.getElementById('logLevel').value;
const logs = await api(`/api/logs?limit=200${level ? '&level='+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 = l.timestamp ? new Date(l.timestamp).toLocaleString('de-DE') : '';
return `<div class="log-entry"><span class="log-time">${t}</span><span class="log-level ${l.level}">${l.level}</span><span class="log-msg">${esc(l.message)}</span></div>`;
}).join('');
}
// Settings
async function loadSettings() {
const cfg = await api('/api/config');
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="${cfg.recording?.max_retries ?? 5}">
</div>
<div class="form-group">
<label>Retry-Delay (Sek.)</label>
<input type="number" id="cfg-retry-delay" value="${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" placeholder="***">
</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="${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="${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="text" id="cfg-ntfy-token" value="${esc(cfg.ntfy?.token || '')}">
</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="text" id="cfg-plik-key" value="${esc(cfg.plik?.api_key || '')}">
</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 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 !== '***') authData.password = passVal;
window._defaultPreBuffer = parseInt(document.getElementById('cfg-pre-buffer').value) || 5;
window._defaultPostBuffer = parseInt(document.getElementById('cfg-post-buffer').value) || 5;
const data = {
recording: {
download_path: document.getElementById('cfg-download-path').value,
max_retries: parseInt(document.getElementById('cfg-max-retries').value) || 5,
retry_delay: parseInt(document.getElementById('cfg-retry-delay').value) || 5,
},
schedule: {
default_pre_buffer: parseInt(document.getElementById('cfg-pre-buffer').value) || 5,
default_post_buffer: parseInt(document.getElementById('cfg-post-buffer').value) || 5,
},
auth: authData,
ntfy: {
url: document.getElementById('cfg-ntfy-url').value,
token: document.getElementById('cfg-ntfy-token').value,
events: document.getElementById('cfg-ntfy-events').value,
},
plik: {
url: document.getElementById('cfg-plik-url').value,
api_key: document.getElementById('cfg-plik-key').value,
ttl: document.getElementById('cfg-plik-ttl').value,
},
};
try {
await api('/api/config', { method: 'PUT', body: JSON.stringify({ data }) });
alert('Einstellungen gespeichert');
} 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 esc(s) { const d = document.createElement('div'); d.textContent = s || ''; return d.innerHTML; }
// Close modals on overlay click
document.querySelectorAll('.modal-overlay').forEach(overlay => {
overlay.addEventListener('click', e => {
if (e.target === overlay) overlay.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(loadDashboard, 5000);
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>