Dateigröße pro Aufnahme entkoppelt - feste Größe nach Abschluss
Build Dev Docker Image / build-amd64 (push) Successful in 10s
Build Dev Docker Image / build-arm64 (push) Successful in 16s
Build Dev Docker Image / publish-dev-manifest (push) Successful in 9s

Die Dateigröße wird jetzt pro Aufnahme korrekt berechnet statt das
gesamte Verzeichnis zu summieren. Bei abgeschlossenen Aufnahmen wird
die Größe einmalig gespeichert und nicht mehr durch laufende Aufnahmen
verändert.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Patrick Asmus
2026-09-16 11:46:18 +02:00
co-authored by Claude Opus 4.6
parent 1ad87c7186
commit 851ff086ac
6 changed files with 600 additions and 124 deletions
+222 -4
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>stream-recorder</title>
<title>Stream-Recorder</title>
<style>
:root {
--bg: #0f1117;
@@ -30,6 +30,7 @@
color: var(--text);
line-height: 1.5;
min-height: 100vh;
padding-bottom: 52px;
display: flex;
flex-direction: column;
}
@@ -338,15 +339,38 @@
padding-bottom: 8px;
border-bottom: 1px solid var(--border);
}
.site-footer {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 50;
text-align: center;
padding: 12px 16px;
background: rgba(15, 17, 23, 0.96);
border-top: 1px solid var(--border);
color: var(--text-dim);
font-size: 0.75rem;
}
.site-footer a {
color: var(--text-dim);
}
.site-footer a:last-child {
color: var(--accent);
}
</style>
</head>
<body>
<div class="app">
<header>
<h1><span class="rec-dot" id="recDot"></span> stream-recorder</h1>
<h1><span class="rec-dot" id="recDot"></span> Stream-Recorder</h1>
<div class="tabs">
<button class="tab active" onclick="switchTab('dashboard')">Dashboard</button>
<button class="tab" onclick="switchTab('jobs')">Jobs</button>
<button class="tab" onclick="switchTab('stations')">Sender</button>
<button class="tab" onclick="switchTab('logs')">Logs</button>
<button class="tab" onclick="switchTab('settings')">Einstellungen</button>
</div>
@@ -373,6 +397,15 @@
<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">
@@ -402,6 +435,12 @@
<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">
@@ -442,6 +481,12 @@
<input type="hidden" id="job-edit-id">
<div class="form-grid">
<div class="form-group full">
<label>Senderbibliothek</label>
<select id="job-station" onchange="applyStationToJob()">
<option value="">URL manuell eingeben</option>
</select>
</div>
<div class="form-group full">
<label>Stream-URL *</label>
<input type="text" id="job-url" placeholder="rtmp://... oder https://...m3u8">
@@ -574,6 +619,44 @@
</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">
@@ -602,6 +685,7 @@
<script>
const API = '';
let stationsCache = [];
async function api(path, opts = {}) {
const res = await fetch(API + path, {
@@ -620,12 +704,20 @@ function switchTab(name) {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.panel').forEach(p => p.classList.remove('active'));
document.querySelector(`#panel-${name}`).classList.add('active');
const tabLabel = {
dashboard: 'dash',
jobs: 'jobs',
stations: 'sender',
logs: 'logs',
settings: 'einst',
}[name] || name;
document.querySelectorAll('.tab').forEach(t => {
if (t.textContent.toLowerCase().includes(name === 'dashboard' ? 'dash' : name === 'settings' ? 'einst' : name))
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();
}
@@ -706,6 +798,127 @@ function renderRecent(recs) {
`).join('');
}
// Stations
async function loadStationOptions() {
try {
stationsCache = await api('/api/stations');
populateStationSelects();
} catch (e) {
console.error(e);
}
}
function populateStationSelects() {
['qr-station', 'job-station'].forEach(id => {
const el = document.getElementById(id);
if (!el) return;
const selected = el.value;
el.innerHTML = '<option value="">URL manuell eingeben</option>' + stationsCache.map(s =>
`<option value="${s.id}">${esc(s.name)}${s.description ? ' - ' + esc(s.description) : ''}</option>`
).join('');
if (stationsCache.some(s => String(s.id) === selected)) el.value = selected;
});
}
async function loadStations() {
try {
await loadStationOptions();
const el = document.getElementById('stationsList');
if (!stationsCache.length) {
el.innerHTML = '<div class="empty"><div class="empty-icon">&#128251;</div>Keine Sender gespeichert<br><button class="btn btn-primary" style="margin-top:12px" onclick="openStationModal()">Ersten Sender anlegen</button></div>';
return;
}
el.innerHTML = stationsCache.map(s => `
<div class="card">
<div class="card-header">
<div class="card-title">
${esc(s.name)}
<span class="badge badge-scheduled">${esc(s.stream_type || 'auto')}</span>
</div>
<div class="actions">
<button class="btn btn-sm" onclick="openStationModal(${s.id})">Bearbeiten</button>
<button class="btn btn-sm btn-danger" onclick="deleteStation(${s.id})">Löschen</button>
</div>
</div>
${s.description ? `<div class="meta" style="margin-bottom:4px"><span class="meta-item" style="color:var(--text)">${esc(s.description)}</span></div>` : ''}
<div class="meta">
<span class="meta-item">${esc(s.stream_url)}</span>
</div>
</div>
`).join('');
} catch (e) {
console.error(e);
}
}
function openStationModal(id = null) {
const station = id ? stationsCache.find(s => s.id === id) : null;
document.getElementById('station-edit-id').value = station ? station.id : '';
document.getElementById('stationModalTitle').textContent = station ? 'Sender bearbeiten' : 'Neuer Sender';
document.getElementById('station-name').value = station ? station.name : '';
document.getElementById('station-url').value = station ? station.stream_url : '';
document.getElementById('station-type').value = station ? station.stream_type || 'auto' : 'auto';
document.getElementById('station-description').value = station ? station.description || '' : '';
openModal('stationModal');
}
async function saveStation() {
const editId = document.getElementById('station-edit-id').value;
const data = {
name: document.getElementById('station-name').value,
stream_url: document.getElementById('station-url').value,
stream_type: document.getElementById('station-type').value,
description: document.getElementById('station-description').value,
};
if (!data.name || !data.stream_url) {
alert('Name und URL sind Pflichtfelder');
return;
}
try {
if (editId) {
await api(`/api/stations/${editId}`, { method: 'PUT', body: JSON.stringify(data) });
} else {
await api('/api/stations', { method: 'POST', body: JSON.stringify(data) });
}
closeModal('stationModal');
loadStations();
} catch (e) {
alert('Fehler: ' + e.message);
}
}
async function deleteStation(id) {
if (!confirm('Sender wirklich löschen?')) return;
await api(`/api/stations/${id}`, { method: 'DELETE' });
loadStations();
}
function applyStationToQuickRecord() {
const id = document.getElementById('qr-station').value;
const station = stationsCache.find(s => String(s.id) === id);
if (!station) return;
document.getElementById('qr-url').value = station.stream_url;
document.getElementById('qr-type').value = station.stream_type || 'auto';
if (!document.getElementById('qr-name').value) {
document.getElementById('qr-name').value = station.name;
}
}
function applyStationToJob() {
const id = document.getElementById('job-station').value;
const station = stationsCache.find(s => String(s.id) === id);
if (!station) return;
document.getElementById('job-url').value = station.stream_url;
document.getElementById('job-type').value = station.stream_type || 'auto';
if (!document.getElementById('job-name').value) {
document.getElementById('job-name').value = station.name;
}
if (!document.getElementById('job-description').value && station.description) {
document.getElementById('job-description').value = station.description;
}
}
// Jobs
async function loadJobs() {
try {
@@ -748,8 +961,10 @@ async function loadJobs() {
}
function openJobModal(job = null) {
loadStationOptions();
document.getElementById('job-edit-id').value = job ? job.id : '';
document.getElementById('jobModalTitle').textContent = job ? 'Job bearbeiten' : 'Neuer Job';
document.getElementById('job-station').value = '';
document.getElementById('job-url').value = job ? job.stream_url : '';
document.getElementById('job-name').value = job ? job.name : '';
document.getElementById('job-type').value = job ? job.stream_type : 'auto';
@@ -915,6 +1130,8 @@ function toggleMetadata() {
// 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';
@@ -1121,10 +1338,11 @@ document.querySelectorAll('.modal-overlay').forEach(overlay => {
});
// Auto-refresh
loadStationOptions();
loadDashboard();
setInterval(loadDashboard, 5000);
</script>
<footer style="text-align:center;padding:32px 16px 16px;color:var(--text-dim);font-size:0.75rem">
<footer class="site-footer">
stream-recorder &copy; <a href="https://www.patrick-asmus.de" target="_blank" rel="noopener" style="color:var(--text-dim)">Patrick Asmus</a> &middot; <a href="https://git.techniverse.net/scriptos/stream-recorder" target="_blank" rel="noopener" style="color:var(--accent)">Source</a>
</footer>
</body>