1573 lines
65 KiB
HTML
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ü">☰</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">⚪</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">🕓 ${r.elapsed_display}</span>
|
|
<span class="meta-item">💾 ${r.total_size_display}</span>
|
|
${r.segment_count !== null ? `<span class="meta-item">📄 ${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">∞ 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">📤 Plik</span>' : ''}
|
|
</div>
|
|
<div class="recent-meta">
|
|
<span class="rm-dur">🕓 ${r.elapsed_display}</span>
|
|
<span class="rm-size">💾 ${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">📤 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">🔗 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">📻</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">📋</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 ? '📻 ' + esc(j.station_name) : esc(j.stream_url)}</span>
|
|
${j.max_duration ? `<span class="meta-item">🕓 ${Math.round(j.max_duration/60)} Min</span>` : ''}
|
|
${j.segment_duration ? `<span class="meta-item">📄 alle ${Math.round(j.segment_duration/60)} Min</span>` : ''}
|
|
${j.schedule_enabled ? `<span class="meta-item">📅 ${j.schedule_once ? j.schedule_date : j.schedule_days} ${j.schedule_start}${j.schedule_stop ? ' - '+j.schedule_stop : ''}${j.pre_buffer ? ' (−'+j.pre_buffer+'min)' : ''}${j.post_buffer ? ' (+'+j.post_buffer+'min)' : ''}</span>` : ''}
|
|
${j.ntfy_enabled ? `<span class="meta-item">🔔 NTFY</span>` : ''}
|
|
${j.plik_enabled ? `<span class="meta-item">📤 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> © <a href="https://www.patrick-asmus.de" target="_blank" rel="noopener" style="color:var(--text-dim)">Patrick Asmus</a> · <a href="https://git.techniverse.net/scriptos/stream-recorder" target="_blank" rel="noopener" style="color:var(--accent)">Source</a>
|
|
</footer>
|
|
</body>
|
|
</html>
|