Files
stream-recorder/bin/frontend/static/index.html
T
Patrick AsmusandClaude Opus 4.6 b446ce9b3e
Build Release Docker Image / build-arm64 (release) Successful in 1m0s
Build Release Docker Image / build-amd64 (release) Successful in 2m50s
Build Release Docker Image / publish-release-manifest (release) Successful in 7s
v3.0.0: Docker-Migration mit CI-Pipeline und neuen Features
- Gitea CI-Workflows fuer Multi-Arch Docker Builds (amd64 + arm64)
- Beschreibungsfeld und mehrtaegige Zeitplanung fuer Jobs
- Benachrichtigungen auf Deutsch
- docker-compose nutzt Registry-Image

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

1132 lines
46 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;
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);
}
</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('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>
<!-- 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>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>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>
<!-- 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 = '';
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');
document.querySelectorAll('.tab').forEach(t => {
if (t.textContent.toLowerCase().includes(name === 'dashboard' ? 'dash' : name === 'settings' ? 'einst' : name))
t.classList.add('active');
});
if (name === 'logs') loadLogs();
if (name === 'jobs') loadJobs();
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('');
}
// 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) {
document.getElementById('job-edit-id').value = job ? job.id : '';
document.getElementById('jobModalTitle').textContent = job ? 'Job bearbeiten' : 'Neuer Job';
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() {
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
loadDashboard();
setInterval(loadDashboard, 5000);
</script>
<footer style="text-align:center;padding:32px 16px 16px;color:var(--text-dim);font-size:0.75rem">
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>