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:
Eduard Wisch 2026-02-28 08:06:17 +01:00
parent 4424398391
commit 0ebe600215
5 changed files with 100 additions and 33 deletions

View file

@ -79,6 +79,26 @@ def setup_api_routes(app: web.Application, config: Config,
return web.json_response({"message": "Job wiederholt"})
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 ---
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_post("/api/jobs/{job_id}/cancel", post_cancel)
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_put("/api/settings", put_settings)
app.router.add_get("/api/presets", get_presets)

View file

@ -33,6 +33,7 @@ class QueueService:
self.jobs: OrderedDict[int, ConversionJob] = OrderedDict()
self._active_count: int = 0
self._running: bool = False
self._paused: bool = False
self._queue_task: Optional[asyncio.Task] = None
self._queue_file = str(config.data_path / "queue.json")
self._db_pool: Optional[aiomysql.Pool] = None
@ -172,6 +173,28 @@ class QueueService:
logging.info(f"Job abgebrochen: {job.media.source_filename}")
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:
"""Setzt fehlgeschlagenen Job zurueck auf QUEUED"""
job = self.jobs.get(job_id)
@ -203,7 +226,7 @@ class QueueService:
if job.status in (JobStatus.QUEUED, JobStatus.ACTIVE,
JobStatus.FAILED, JobStatus.CANCELLED):
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:
"""Aktive Jobs fuer WebSocket"""
@ -226,7 +249,8 @@ class QueueService:
"""Hauptschleife: Startet neue Jobs wenn Kapazitaet frei"""
while self._running:
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()
if next_job:
asyncio.create_task(self._execute_job(next_job))

View file

@ -302,7 +302,7 @@ function renderMovieGrid(movies) {
const size = m.total_size ? formatSize(m.total_size) : "";
const tvdbBtn = m.tvdb_id
? '<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
? `<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><span class="tag">${(v.container || "-").toUpperCase()}</span></td>
<td>
<button class="btn-small btn-play" onclick="playVideo(${v.id}, ${escapeAttr(vidTitle)})" title="Abspielen">&#9654;</button>
<button class="btn-small btn-play" onclick="playVideo(${v.id}, '${escapeAttr(vidTitle)}')" title="Abspielen">&#9654;</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">&#10005;</button>
<button class="btn-small btn-danger" onclick="deleteVideo(${v.id}, '${escapeAttr(vidTitle)}')" title="Loeschen">&#10005;</button>
</td>
</tr>`;
}
@ -464,7 +464,7 @@ function renderSeriesGrid(series) {
const genres = s.genres ? `<div class="series-genres">${escapeHtml(s.genres)}</div>` : "";
const tvdbBtn = s.tvdb_id
? `<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})">
${poster}
@ -492,7 +492,7 @@ function renderBreadcrumb(crumbs, pathId) {
html += `<a class="breadcrumb-link" href="#" onclick="loadSectionBrowser(${pathId}); return false;">Basis</a>`;
for (const c of crumbs) {
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>';
return html;
@ -662,9 +662,9 @@ function renderEpisodesTab(series) {
<td><span class="tag">${fileExt}</span></td>
<td class="td-audio">${audioInfo || "-"}</td>
<td>
<button class="btn-small btn-play" onclick="playVideo(${ep.id}, ${escapeAttr(epTitle)})" title="Abspielen">&#9654;</button>
<button class="btn-small btn-play" onclick="playVideo(${ep.id}, '${escapeAttr(epTitle)}')" title="Abspielen">&#9654;</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">&#10005;</button>
<button class="btn-small btn-danger" onclick="deleteVideo(${ep.id}, '${escapeAttr(epTitle)}', 'series')" title="Loeschen">&#10005;</button>
</td>
</tr>`;
}
@ -915,9 +915,9 @@ function openMovieDetail(movieId) {
<td>${formatSize(v.file_size || 0)}</td>
<td>${formatDuration(v.duration_sec || 0)}</td>
<td>
<button class="btn-small btn-play" onclick="playVideo(${v.id}, ${escapeAttr(movieTitle)})" title="Abspielen">&#9654;</button>
<button class="btn-small btn-play" onclick="playVideo(${v.id}, '${escapeAttr(movieTitle)}')" title="Abspielen">&#9654;</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">&#10005;</button>
<button class="btn-small btn-danger" onclick="deleteVideo(${v.id}, '${escapeAttr(movieTitle)}', 'movie')" title="Loeschen">&#10005;</button>
</td>
</tr>`;
}
@ -1516,7 +1516,7 @@ function renderTvdbReviewList() {
const poster = s.poster
? `<img src="${s.poster}" alt="" class="review-poster" loading="lazy">`
: '<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 += `<div class="review-suggestion-info">`;
html += `<strong>${escapeHtml(s.name)}</strong>`;
@ -1615,7 +1615,7 @@ function executeManualReviewSearch(index) {
return;
}
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>'}
<div class="review-suggestion-info">
<strong>${escapeHtml(r.name)}</strong>
@ -2255,7 +2255,7 @@ function importBrowse(path) {
for (const c of (data.breadcrumb || [])) {
if (c.path === "/mnt" || c.path.length < 5) continue;
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>';
@ -2263,7 +2263,7 @@ function importBrowse(path) {
const parts = data.current_path.split("/");
if (parts.length > 2) {
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">&#128281;</span>
<span class="fb-name">..</span>
</div>`;
@ -2272,7 +2272,7 @@ function importBrowse(path) {
// Unterordner: Einfachklick = auswaehlen, Doppelklick = navigieren
for (const f of (data.folders || [])) {
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">&#128193;</span>
<span class="fb-name">${escapeHtml(f.name)}</span>
<span class="fb-meta">${meta}</span>
@ -2831,25 +2831,12 @@ let _importWsActive = false; // WebSocket liefert Updates?
async function executeImport() {
if (!currentImportJobId) return;
document.getElementById("import-preview").style.display = "none";
document.getElementById("import-progress").style.display = "";
document.getElementById("import-status-text").textContent = "Importiere...";
document.getElementById("import-bar").style.width = "0%";
_importWsActive = false;
// Modal schliessen - Fortschritt laeuft ueber globalen Progress-Balken
closeImportModal();
resetImport();
// Starte Import (non-blocking - Server antwortet sofort)
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

View file

@ -7,6 +7,7 @@ let ws = null;
let videoActive = {};
let videoQueue = {};
let reconnectTimer = null;
let queuePaused = false;
// WebSocket verbinden
function connectWebSocket() {
@ -32,6 +33,9 @@ function connectWebSocket() {
updateActiveConversions(packet.data_convert);
} else if (packet.data_queue !== undefined) {
updateQueue(packet.data_queue);
if (packet.queue_paused !== undefined) {
updatePauseState(packet.queue_paused);
}
} else if (packet.data_log !== undefined) {
// Log-Nachrichten ans Benachrichtigungs-System weiterleiten
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 ? "&#9654; Weiter" : "&#9208; 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
connectWebSocket();

View file

@ -21,7 +21,13 @@
<!-- Warteschlange -->
<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()">
&#9208; Pause
</button>
<span id="queue-paused-hint" class="status-badge warn" style="display:none">Pausiert</span>
</div>
<div id="queue">
<!-- Wird dynamisch via WebSocket gefuellt -->
</div>