feat: VideoKonverter v3.0 - Bugfixes, Queue-Pause, Button-Audit
- fix: escapeAttr() ueberall mit Anfuehrungszeichen versehen (14 Stellen) Ohne Quotes brachen onclick-Handler bei Titeln mit Leerzeichen -> TVDB-Zuordnung, Play, Delete, Import-Browser betroffen - fix: escapeHtml() in onclick durch escapeAttr() ersetzt (4 Stellen) escapeHtml erzeugt & statt \' -> JS-Syntaxfehler in Handlern - fix: Import-Modal schliesst nach Start statt Ladebalken anzuzeigen Globaler Progress-Balken in base.html uebernimmt den Fortschritt - feat: Queue Pause/Resume - laufende Konvertierung laeuft fertig, keine neuen Jobs gestartet bis Weiter geklickt wird API: POST /api/queue/pause, /api/queue/resume, GET /api/queue/status Button im Dashboard neben Warteschlange-Header Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
4424398391
commit
0ebe600215
5 changed files with 100 additions and 33 deletions
|
|
@ -79,6 +79,26 @@ def setup_api_routes(app: web.Application, config: Config,
|
||||||
return web.json_response({"message": "Job wiederholt"})
|
return web.json_response({"message": "Job wiederholt"})
|
||||||
return web.json_response({"error": "Job nicht fehlgeschlagen"}, status=400)
|
return web.json_response({"error": "Job nicht fehlgeschlagen"}, status=400)
|
||||||
|
|
||||||
|
# --- Queue Pause/Resume ---
|
||||||
|
|
||||||
|
async def post_queue_pause(request: web.Request) -> web.Response:
|
||||||
|
"""POST /api/queue/pause - Queue pausieren"""
|
||||||
|
success = await queue_service.pause_queue()
|
||||||
|
if success:
|
||||||
|
return web.json_response({"message": "Queue pausiert", "paused": True})
|
||||||
|
return web.json_response({"message": "Queue bereits pausiert", "paused": True})
|
||||||
|
|
||||||
|
async def post_queue_resume(request: web.Request) -> web.Response:
|
||||||
|
"""POST /api/queue/resume - Queue fortsetzen"""
|
||||||
|
success = await queue_service.resume_queue()
|
||||||
|
if success:
|
||||||
|
return web.json_response({"message": "Queue fortgesetzt", "paused": False})
|
||||||
|
return web.json_response({"message": "Queue laeuft bereits", "paused": False})
|
||||||
|
|
||||||
|
async def get_queue_status(request: web.Request) -> web.Response:
|
||||||
|
"""GET /api/queue/status - Pause-Status abfragen"""
|
||||||
|
return web.json_response({"paused": queue_service.is_paused})
|
||||||
|
|
||||||
# --- Settings ---
|
# --- Settings ---
|
||||||
|
|
||||||
async def get_settings(request: web.Request) -> web.Response:
|
async def get_settings(request: web.Request) -> web.Response:
|
||||||
|
|
@ -372,6 +392,9 @@ def setup_api_routes(app: web.Application, config: Config,
|
||||||
app.router.add_delete("/api/jobs/{job_id}", delete_job)
|
app.router.add_delete("/api/jobs/{job_id}", delete_job)
|
||||||
app.router.add_post("/api/jobs/{job_id}/cancel", post_cancel)
|
app.router.add_post("/api/jobs/{job_id}/cancel", post_cancel)
|
||||||
app.router.add_post("/api/jobs/{job_id}/retry", post_retry)
|
app.router.add_post("/api/jobs/{job_id}/retry", post_retry)
|
||||||
|
app.router.add_post("/api/queue/pause", post_queue_pause)
|
||||||
|
app.router.add_post("/api/queue/resume", post_queue_resume)
|
||||||
|
app.router.add_get("/api/queue/status", get_queue_status)
|
||||||
app.router.add_get("/api/settings", get_settings)
|
app.router.add_get("/api/settings", get_settings)
|
||||||
app.router.add_put("/api/settings", put_settings)
|
app.router.add_put("/api/settings", put_settings)
|
||||||
app.router.add_get("/api/presets", get_presets)
|
app.router.add_get("/api/presets", get_presets)
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ class QueueService:
|
||||||
self.jobs: OrderedDict[int, ConversionJob] = OrderedDict()
|
self.jobs: OrderedDict[int, ConversionJob] = OrderedDict()
|
||||||
self._active_count: int = 0
|
self._active_count: int = 0
|
||||||
self._running: bool = False
|
self._running: bool = False
|
||||||
|
self._paused: bool = False
|
||||||
self._queue_task: Optional[asyncio.Task] = None
|
self._queue_task: Optional[asyncio.Task] = None
|
||||||
self._queue_file = str(config.data_path / "queue.json")
|
self._queue_file = str(config.data_path / "queue.json")
|
||||||
self._db_pool: Optional[aiomysql.Pool] = None
|
self._db_pool: Optional[aiomysql.Pool] = None
|
||||||
|
|
@ -172,6 +173,28 @@ class QueueService:
|
||||||
logging.info(f"Job abgebrochen: {job.media.source_filename}")
|
logging.info(f"Job abgebrochen: {job.media.source_filename}")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
async def pause_queue(self) -> bool:
|
||||||
|
"""Pausiert die Queue - laufende Jobs werden fertig, keine neuen gestartet"""
|
||||||
|
if self._paused:
|
||||||
|
return False
|
||||||
|
self._paused = True
|
||||||
|
logging.info("Queue pausiert - keine neuen Jobs werden gestartet")
|
||||||
|
await self.ws_manager.broadcast_queue_update()
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def resume_queue(self) -> bool:
|
||||||
|
"""Setzt die Queue fort"""
|
||||||
|
if not self._paused:
|
||||||
|
return False
|
||||||
|
self._paused = False
|
||||||
|
logging.info("Queue fortgesetzt")
|
||||||
|
await self.ws_manager.broadcast_queue_update()
|
||||||
|
return True
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_paused(self) -> bool:
|
||||||
|
return self._paused
|
||||||
|
|
||||||
async def retry_job(self, job_id: int) -> bool:
|
async def retry_job(self, job_id: int) -> bool:
|
||||||
"""Setzt fehlgeschlagenen Job zurueck auf QUEUED"""
|
"""Setzt fehlgeschlagenen Job zurueck auf QUEUED"""
|
||||||
job = self.jobs.get(job_id)
|
job = self.jobs.get(job_id)
|
||||||
|
|
@ -203,7 +226,7 @@ class QueueService:
|
||||||
if job.status in (JobStatus.QUEUED, JobStatus.ACTIVE,
|
if job.status in (JobStatus.QUEUED, JobStatus.ACTIVE,
|
||||||
JobStatus.FAILED, JobStatus.CANCELLED):
|
JobStatus.FAILED, JobStatus.CANCELLED):
|
||||||
queue[job_id] = job.to_dict_queue()
|
queue[job_id] = job.to_dict_queue()
|
||||||
return {"data_queue": queue}
|
return {"data_queue": queue, "queue_paused": self._paused}
|
||||||
|
|
||||||
def get_active_jobs(self) -> dict:
|
def get_active_jobs(self) -> dict:
|
||||||
"""Aktive Jobs fuer WebSocket"""
|
"""Aktive Jobs fuer WebSocket"""
|
||||||
|
|
@ -226,7 +249,8 @@ class QueueService:
|
||||||
"""Hauptschleife: Startet neue Jobs wenn Kapazitaet frei"""
|
"""Hauptschleife: Startet neue Jobs wenn Kapazitaet frei"""
|
||||||
while self._running:
|
while self._running:
|
||||||
try:
|
try:
|
||||||
if self._active_count < self.config.max_parallel_jobs:
|
if (not self._paused and
|
||||||
|
self._active_count < self.config.max_parallel_jobs):
|
||||||
next_job = self._get_next_queued()
|
next_job = self._get_next_queued()
|
||||||
if next_job:
|
if next_job:
|
||||||
asyncio.create_task(self._execute_job(next_job))
|
asyncio.create_task(self._execute_job(next_job))
|
||||||
|
|
|
||||||
|
|
@ -302,7 +302,7 @@ function renderMovieGrid(movies) {
|
||||||
const size = m.total_size ? formatSize(m.total_size) : "";
|
const size = m.total_size ? formatSize(m.total_size) : "";
|
||||||
const tvdbBtn = m.tvdb_id
|
const tvdbBtn = m.tvdb_id
|
||||||
? '<span class="tag ok">TVDB</span>'
|
? '<span class="tag ok">TVDB</span>'
|
||||||
: `<button class="btn-small btn-secondary" onclick="event.stopPropagation(); openMovieTvdbModal(${m.id}, ${escapeAttr(m.title || m.folder_name)})">TVDB zuordnen</button>`;
|
: `<button class="btn-small btn-secondary" onclick="event.stopPropagation(); openMovieTvdbModal(${m.id}, '${escapeAttr(m.title || m.folder_name)}')">TVDB zuordnen</button>`;
|
||||||
const overview = m.overview
|
const overview = m.overview
|
||||||
? `<p class="movie-overview">${escapeHtml(m.overview.substring(0, 120))}${m.overview.length > 120 ? '...' : ''}</p>`
|
? `<p class="movie-overview">${escapeHtml(m.overview.substring(0, 120))}${m.overview.length > 120 ? '...' : ''}</p>`
|
||||||
: "";
|
: "";
|
||||||
|
|
@ -403,9 +403,9 @@ function renderVideoTable(items) {
|
||||||
<td>${formatDuration(v.duration_sec || 0)}</td>
|
<td>${formatDuration(v.duration_sec || 0)}</td>
|
||||||
<td><span class="tag">${(v.container || "-").toUpperCase()}</span></td>
|
<td><span class="tag">${(v.container || "-").toUpperCase()}</span></td>
|
||||||
<td>
|
<td>
|
||||||
<button class="btn-small btn-play" onclick="playVideo(${v.id}, ${escapeAttr(vidTitle)})" title="Abspielen">▶</button>
|
<button class="btn-small btn-play" onclick="playVideo(${v.id}, '${escapeAttr(vidTitle)}')" title="Abspielen">▶</button>
|
||||||
<button class="btn-small btn-primary" onclick="convertVideo(${v.id})">Conv</button>
|
<button class="btn-small btn-primary" onclick="convertVideo(${v.id})">Conv</button>
|
||||||
<button class="btn-small btn-danger" onclick="deleteVideo(${v.id}, ${escapeAttr(vidTitle)})" title="Loeschen">✕</button>
|
<button class="btn-small btn-danger" onclick="deleteVideo(${v.id}, '${escapeAttr(vidTitle)}')" title="Loeschen">✕</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
}
|
}
|
||||||
|
|
@ -464,7 +464,7 @@ function renderSeriesGrid(series) {
|
||||||
const genres = s.genres ? `<div class="series-genres">${escapeHtml(s.genres)}</div>` : "";
|
const genres = s.genres ? `<div class="series-genres">${escapeHtml(s.genres)}</div>` : "";
|
||||||
const tvdbBtn = s.tvdb_id
|
const tvdbBtn = s.tvdb_id
|
||||||
? `<span class="tag ok">TVDB</span>`
|
? `<span class="tag ok">TVDB</span>`
|
||||||
: `<button class="btn-small btn-secondary" onclick="event.stopPropagation(); openTvdbModal(${s.id}, ${escapeAttr(s.folder_name)})">TVDB zuordnen</button>`;
|
: `<button class="btn-small btn-secondary" onclick="event.stopPropagation(); openTvdbModal(${s.id}, '${escapeAttr(s.folder_name)}')">TVDB zuordnen</button>`;
|
||||||
|
|
||||||
html += `<div class="series-card" onclick="openSeriesDetail(${s.id})">
|
html += `<div class="series-card" onclick="openSeriesDetail(${s.id})">
|
||||||
${poster}
|
${poster}
|
||||||
|
|
@ -492,7 +492,7 @@ function renderBreadcrumb(crumbs, pathId) {
|
||||||
html += `<a class="breadcrumb-link" href="#" onclick="loadSectionBrowser(${pathId}); return false;">Basis</a>`;
|
html += `<a class="breadcrumb-link" href="#" onclick="loadSectionBrowser(${pathId}); return false;">Basis</a>`;
|
||||||
for (const c of crumbs) {
|
for (const c of crumbs) {
|
||||||
html += ' <span class="breadcrumb-sep">/</span> ';
|
html += ' <span class="breadcrumb-sep">/</span> ';
|
||||||
html += `<a class="breadcrumb-link" href="#" onclick="loadSectionBrowser(${pathId}, '${escapeHtml(c.path)}'); return false;">${escapeHtml(c.name)}</a>`;
|
html += `<a class="breadcrumb-link" href="#" onclick="loadSectionBrowser(${pathId}, '${escapeAttr(c.path)}'); return false;">${escapeHtml(c.name)}</a>`;
|
||||||
}
|
}
|
||||||
html += '</div>';
|
html += '</div>';
|
||||||
return html;
|
return html;
|
||||||
|
|
@ -662,9 +662,9 @@ function renderEpisodesTab(series) {
|
||||||
<td><span class="tag">${fileExt}</span></td>
|
<td><span class="tag">${fileExt}</span></td>
|
||||||
<td class="td-audio">${audioInfo || "-"}</td>
|
<td class="td-audio">${audioInfo || "-"}</td>
|
||||||
<td>
|
<td>
|
||||||
<button class="btn-small btn-play" onclick="playVideo(${ep.id}, ${escapeAttr(epTitle)})" title="Abspielen">▶</button>
|
<button class="btn-small btn-play" onclick="playVideo(${ep.id}, '${escapeAttr(epTitle)}')" title="Abspielen">▶</button>
|
||||||
<button class="btn-small btn-primary" onclick="convertVideo(${ep.id})">Conv</button>
|
<button class="btn-small btn-primary" onclick="convertVideo(${ep.id})">Conv</button>
|
||||||
<button class="btn-small btn-danger" onclick="deleteVideo(${ep.id}, ${escapeAttr(epTitle)}, 'series')" title="Loeschen">✕</button>
|
<button class="btn-small btn-danger" onclick="deleteVideo(${ep.id}, '${escapeAttr(epTitle)}', 'series')" title="Loeschen">✕</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
}
|
}
|
||||||
|
|
@ -915,9 +915,9 @@ function openMovieDetail(movieId) {
|
||||||
<td>${formatSize(v.file_size || 0)}</td>
|
<td>${formatSize(v.file_size || 0)}</td>
|
||||||
<td>${formatDuration(v.duration_sec || 0)}</td>
|
<td>${formatDuration(v.duration_sec || 0)}</td>
|
||||||
<td>
|
<td>
|
||||||
<button class="btn-small btn-play" onclick="playVideo(${v.id}, ${escapeAttr(movieTitle)})" title="Abspielen">▶</button>
|
<button class="btn-small btn-play" onclick="playVideo(${v.id}, '${escapeAttr(movieTitle)}')" title="Abspielen">▶</button>
|
||||||
<button class="btn-small btn-primary" onclick="convertVideo(${v.id})">Conv</button>
|
<button class="btn-small btn-primary" onclick="convertVideo(${v.id})">Conv</button>
|
||||||
<button class="btn-small btn-danger" onclick="deleteVideo(${v.id}, ${escapeAttr(movieTitle)}, 'movie')" title="Loeschen">✕</button>
|
<button class="btn-small btn-danger" onclick="deleteVideo(${v.id}, '${escapeAttr(movieTitle)}', 'movie')" title="Loeschen">✕</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
}
|
}
|
||||||
|
|
@ -1516,7 +1516,7 @@ function renderTvdbReviewList() {
|
||||||
const poster = s.poster
|
const poster = s.poster
|
||||||
? `<img src="${s.poster}" alt="" class="review-poster" loading="lazy">`
|
? `<img src="${s.poster}" alt="" class="review-poster" loading="lazy">`
|
||||||
: '<div class="review-poster-placeholder">?</div>';
|
: '<div class="review-poster-placeholder">?</div>';
|
||||||
html += `<div class="review-suggestion" onclick="confirmReviewItem(${i}, ${s.tvdb_id}, ${escapeAttr(s.name)})">`;
|
html += `<div class="review-suggestion" onclick="confirmReviewItem(${i}, ${s.tvdb_id}, '${escapeAttr(s.name)}')">`;
|
||||||
html += poster;
|
html += poster;
|
||||||
html += `<div class="review-suggestion-info">`;
|
html += `<div class="review-suggestion-info">`;
|
||||||
html += `<strong>${escapeHtml(s.name)}</strong>`;
|
html += `<strong>${escapeHtml(s.name)}</strong>`;
|
||||||
|
|
@ -1615,7 +1615,7 @@ function executeManualReviewSearch(index) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
results.innerHTML = list.map(r => `
|
results.innerHTML = list.map(r => `
|
||||||
<div class="review-suggestion" onclick="confirmReviewItem(${index}, ${r.tvdb_id}, ${escapeAttr(r.name)})">
|
<div class="review-suggestion" onclick="confirmReviewItem(${index}, ${r.tvdb_id}, '${escapeAttr(r.name)}')">
|
||||||
${r.poster ? `<img src="${r.poster}" alt="" class="review-poster" loading="lazy">` : '<div class="review-poster-placeholder">?</div>'}
|
${r.poster ? `<img src="${r.poster}" alt="" class="review-poster" loading="lazy">` : '<div class="review-poster-placeholder">?</div>'}
|
||||||
<div class="review-suggestion-info">
|
<div class="review-suggestion-info">
|
||||||
<strong>${escapeHtml(r.name)}</strong>
|
<strong>${escapeHtml(r.name)}</strong>
|
||||||
|
|
@ -2255,7 +2255,7 @@ function importBrowse(path) {
|
||||||
for (const c of (data.breadcrumb || [])) {
|
for (const c of (data.breadcrumb || [])) {
|
||||||
if (c.path === "/mnt" || c.path.length < 5) continue;
|
if (c.path === "/mnt" || c.path.length < 5) continue;
|
||||||
html += ` <span style="color:#555">/</span> `;
|
html += ` <span style="color:#555">/</span> `;
|
||||||
html += `<a href="#" onclick="importBrowse('${escapeHtml(c.path)}'); return false;">${escapeHtml(c.name)}</a>`;
|
html += `<a href="#" onclick="importBrowse('${escapeAttr(c.path)}'); return false;">${escapeHtml(c.name)}</a>`;
|
||||||
}
|
}
|
||||||
html += '</div>';
|
html += '</div>';
|
||||||
|
|
||||||
|
|
@ -2263,7 +2263,7 @@ function importBrowse(path) {
|
||||||
const parts = data.current_path.split("/");
|
const parts = data.current_path.split("/");
|
||||||
if (parts.length > 2) {
|
if (parts.length > 2) {
|
||||||
const parentPath = parts.slice(0, -1).join("/") || "/mnt";
|
const parentPath = parts.slice(0, -1).join("/") || "/mnt";
|
||||||
html += `<div class="import-browser-folder" onclick="importBrowse('${escapeHtml(parentPath)}')">
|
html += `<div class="import-browser-folder" onclick="importBrowse('${escapeAttr(parentPath)}')">
|
||||||
<span class="fb-icon">🔙</span>
|
<span class="fb-icon">🔙</span>
|
||||||
<span class="fb-name">..</span>
|
<span class="fb-name">..</span>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
@ -2272,7 +2272,7 @@ function importBrowse(path) {
|
||||||
// Unterordner: Einfachklick = auswaehlen, Doppelklick = navigieren
|
// Unterordner: Einfachklick = auswaehlen, Doppelklick = navigieren
|
||||||
for (const f of (data.folders || [])) {
|
for (const f of (data.folders || [])) {
|
||||||
const meta = f.video_count > 0 ? `${f.video_count} Videos` : "";
|
const meta = f.video_count > 0 ? `${f.video_count} Videos` : "";
|
||||||
html += `<div class="import-browser-folder" onclick="importFolderClick('${escapeHtml(f.path)}', this)">
|
html += `<div class="import-browser-folder" onclick="importFolderClick('${escapeAttr(f.path)}', this)">
|
||||||
<span class="fb-icon">📁</span>
|
<span class="fb-icon">📁</span>
|
||||||
<span class="fb-name">${escapeHtml(f.name)}</span>
|
<span class="fb-name">${escapeHtml(f.name)}</span>
|
||||||
<span class="fb-meta">${meta}</span>
|
<span class="fb-meta">${meta}</span>
|
||||||
|
|
@ -2831,25 +2831,12 @@ let _importWsActive = false; // WebSocket liefert Updates?
|
||||||
async function executeImport() {
|
async function executeImport() {
|
||||||
if (!currentImportJobId) return;
|
if (!currentImportJobId) return;
|
||||||
|
|
||||||
document.getElementById("import-preview").style.display = "none";
|
// Modal schliessen - Fortschritt laeuft ueber globalen Progress-Balken
|
||||||
document.getElementById("import-progress").style.display = "";
|
closeImportModal();
|
||||||
document.getElementById("import-status-text").textContent = "Importiere...";
|
resetImport();
|
||||||
document.getElementById("import-bar").style.width = "0%";
|
|
||||||
_importWsActive = false;
|
|
||||||
|
|
||||||
// Starte Import (non-blocking - Server antwortet sofort)
|
// Starte Import (non-blocking - Server antwortet sofort)
|
||||||
fetch(`/api/library/import/${currentImportJobId}/execute`, {method: "POST"});
|
fetch(`/api/library/import/${currentImportJobId}/execute`, {method: "POST"});
|
||||||
|
|
||||||
// Fallback-Polling nur starten falls WS nicht verbunden
|
|
||||||
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
|
||||||
startImportPolling();
|
|
||||||
} else {
|
|
||||||
// WS verbunden - trotzdem langsames Fallback-Polling
|
|
||||||
// falls WS-Updates ausbleiben
|
|
||||||
setTimeout(() => {
|
|
||||||
if (!_importWsActive) startImportPolling();
|
|
||||||
}, 3000);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// WebSocket-Handler fuer Import-Fortschritt
|
// WebSocket-Handler fuer Import-Fortschritt
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ let ws = null;
|
||||||
let videoActive = {};
|
let videoActive = {};
|
||||||
let videoQueue = {};
|
let videoQueue = {};
|
||||||
let reconnectTimer = null;
|
let reconnectTimer = null;
|
||||||
|
let queuePaused = false;
|
||||||
|
|
||||||
// WebSocket verbinden
|
// WebSocket verbinden
|
||||||
function connectWebSocket() {
|
function connectWebSocket() {
|
||||||
|
|
@ -32,6 +33,9 @@ function connectWebSocket() {
|
||||||
updateActiveConversions(packet.data_convert);
|
updateActiveConversions(packet.data_convert);
|
||||||
} else if (packet.data_queue !== undefined) {
|
} else if (packet.data_queue !== undefined) {
|
||||||
updateQueue(packet.data_queue);
|
updateQueue(packet.data_queue);
|
||||||
|
if (packet.queue_paused !== undefined) {
|
||||||
|
updatePauseState(packet.queue_paused);
|
||||||
|
}
|
||||||
} else if (packet.data_log !== undefined) {
|
} else if (packet.data_log !== undefined) {
|
||||||
// Log-Nachrichten ans Benachrichtigungs-System weiterleiten
|
// Log-Nachrichten ans Benachrichtigungs-System weiterleiten
|
||||||
if (typeof addNotification === "function") {
|
if (typeof addNotification === "function") {
|
||||||
|
|
@ -196,5 +200,28 @@ function sendCommand(command, id) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// === Queue Pause/Resume ===
|
||||||
|
|
||||||
|
function updatePauseState(paused) {
|
||||||
|
queuePaused = paused;
|
||||||
|
const btn = document.getElementById("btn-queue-pause");
|
||||||
|
const hint = document.getElementById("queue-paused-hint");
|
||||||
|
if (btn) {
|
||||||
|
btn.innerHTML = paused ? "▶ Weiter" : "⏸ Pause";
|
||||||
|
btn.className = paused ? "btn-primary btn-small" : "btn-secondary btn-small";
|
||||||
|
}
|
||||||
|
if (hint) {
|
||||||
|
hint.style.display = paused ? "" : "none";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleQueuePause() {
|
||||||
|
const url = queuePaused ? "/api/queue/resume" : "/api/queue/pause";
|
||||||
|
fetch(url, {method: "POST"})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => updatePauseState(data.paused))
|
||||||
|
.catch(e => console.error("Pause/Resume fehlgeschlagen:", e));
|
||||||
|
}
|
||||||
|
|
||||||
// Verbindung herstellen
|
// Verbindung herstellen
|
||||||
connectWebSocket();
|
connectWebSocket();
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,13 @@
|
||||||
|
|
||||||
<!-- Warteschlange -->
|
<!-- Warteschlange -->
|
||||||
<section id="queue-section">
|
<section id="queue-section">
|
||||||
<h2>Warteschlange</h2>
|
<div style="display:flex; align-items:center; gap:1rem;">
|
||||||
|
<h2 style="margin:0">Warteschlange</h2>
|
||||||
|
<button id="btn-queue-pause" class="btn-secondary btn-small" onclick="toggleQueuePause()">
|
||||||
|
⏸ Pause
|
||||||
|
</button>
|
||||||
|
<span id="queue-paused-hint" class="status-badge warn" style="display:none">Pausiert</span>
|
||||||
|
</div>
|
||||||
<div id="queue">
|
<div id="queue">
|
||||||
<!-- Wird dynamisch via WebSocket gefuellt -->
|
<!-- Wird dynamisch via WebSocket gefuellt -->
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue