Files
stream-recorder/bin/frontend/static/index.html
T
Patrick AsmusandClaude Opus 4.6 851ff086ac
Build Dev Docker Image / build-amd64 (push) Successful in 10s
Build Dev Docker Image / build-arm64 (push) Successful in 16s
Build Dev Docker Image / publish-dev-manifest (push) Successful in 9s
Dateigröße pro Aufnahme entkoppelt - feste Größe nach Abschluss
Die Dateigröße wird jetzt pro Aufnahme korrekt berechnet statt das
gesamte Verzeichnis zu summieren. Bei abgeschlossenen Aufnahmen wird
die Größe einmalig gespeichert und nicht mehr durch laufende Aufnahmen
verändert.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-16 11:46:18 +02:00

1350 lines
54 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>
<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 .rec-dot {
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); }
/* Responsive */
@media (max-width: 640px) {
.app { padding: 10px; }
.form-grid { grid-template-columns: 1fr; }
.tabs { flex-wrap: wrap; }
.tab { padding: 6px 12px; font-size: 0.8rem; }
header { flex-direction: column; gap: 12px; align-items: flex-start; }
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);
}
.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><span class="rec-dot" id="recDot"></span> Stream-Recorder</h1>
<div class="tabs">
<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">
<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>
</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">Nach Upload löschen</label>
</div>
</div>
</div>
<div class="job-section" style="margin-top:12px; border-top:1px solid var(--border); padding-top:12px">
<div class="checkbox-group">
<input type="checkbox" id="job-metadata-enabled" onchange="toggleMetadata()">
<label for="job-metadata-enabled" style="margin:0">Euer-Radio Metadata-Monitor</label>
</div>
<div id="metadataFields" style="display:none; margin-top:8px" class="form-grid">
<div class="form-group full">
<label>Show-Pattern (z.B. Sendungsname)</label>
<input type="text" id="job-metadata-pattern" placeholder="Donnerwolke">
</div>
<div class="form-group">
<label>Karenzzeit (Min.)</label>
<input type="number" id="job-metadata-grace" value="5" min="1">
</div>
<div class="form-group">
<label>Poll-Intervall (Sek.)</label>
<input type="number" id="job-metadata-poll" value="30" min="10">
</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>
<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();
}
// Tabs
function switchTab(name) {
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');
const dot = document.getElementById('recDot');
dot.style.background = active.length > 0 ? 'var(--recording)' : 'var(--text-dim)';
dot.style.animation = active.length > 0 ? 'pulse 2s ease-in-out infinite' : 'none';
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 style="color:var(--recording)">&#9679;</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 => `
<div class="card" style="padding:12px">
<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>
</div>
<div class="meta">
<span class="meta-item">&#128339; ${r.elapsed_display}</span>
<span class="meta-item">&#128190; ${r.total_size_display}</span>
<span class="meta-item">${r.started_at ? new Date(r.started_at).toLocaleString('de-DE') : ''}</span>
</div>
</div>
</div>
`).join('');
}
// 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);
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">${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 : ''}</span>` : ''}
${j.ntfy_enabled ? `<span class="meta-item">&#128276; NTFY</span>` : ''}
${j.plik_enabled ? `<span class="meta-item">&#128228; Plik${j.delete_after_upload ? ' (löschen)' : ''}</span>` : ''}
${j.metadata_monitor_enabled ? `<span class="meta-item">&#128225; ${esc(j.metadata_pattern)}</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 = '';
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-metadata-enabled').checked = job ? job.metadata_monitor_enabled : false;
document.getElementById('job-metadata-pattern').value = job ? job.metadata_pattern || '' : '';
document.getElementById('job-metadata-grace').value = job ? Math.round((job.metadata_grace_period || 300) / 60) : 5;
document.getElementById('job-metadata-poll').value = job ? job.metadata_poll_interval || 30 : 30;
toggleSchedule();
toggleScheduleOnce();
togglePlik();
toggleMetadata();
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,
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,
metadata_monitor_enabled: document.getElementById('job-metadata-enabled').checked,
metadata_pattern: document.getElementById('job-metadata-pattern').value,
metadata_grace_period: parseInt(document.getElementById('job-metadata-grace').value || '5') * 60,
metadata_poll_interval: parseInt(document.getElementById('job-metadata-poll').value || '30'),
};
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) {
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);
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)`;
}
} 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;
const h = Math.floor(diff / 60);
const m = diff % 60;
text = (h > 0 ? `${h} Std. ${m > 0 ? m + ' Min.' : ''}` : `${m} Min.`) + ' (automatisch aus Zeitplan)';
}
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 togglePlik() {
document.getElementById('plikFields').style.display =
document.getElementById('job-plik-enabled').checked ? 'block' : 'none';
}
function toggleMetadata() {
document.getElementById('metadataFields').style.display =
document.getElementById('job-metadata-enabled').checked ? 'grid' : '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>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;
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,
},
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');
});
});
// Auto-refresh
loadStationOptions();
loadDashboard();
setInterval(loadDashboard, 5000);
</script>
<footer class="site-footer">
stream-recorder &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>