docker.videokonverter/video-konverter/app/services/progress.py
data 37dff4de69 feat: VideoKonverter v2.9 - Projekt-Reset aus Docker-Image
Projekt aus Docker-Image videoconverter:2.9 extrahiert.
Enthält zweiphasigen Import-Workflow mit Serien-Zuordnung.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-27 11:41:48 +01:00

132 lines
4.3 KiB
Python

"""Echtzeit-Parsing der ffmpeg stderr-Ausgabe"""
import re
import asyncio
import logging
from typing import Callable, Awaitable
from app.models.job import ConversionJob
from app.models.media import MediaFile
class ProgressParser:
"""
Liest ffmpeg stderr und extrahiert Fortschrittsinformationen.
Ruft Callback fuer WebSocket-Updates auf.
Speichert letzte stderr-Zeilen fuer Fehlerdiagnose.
"""
def __init__(self, on_progress: Callable[[ConversionJob], Awaitable[None]]):
self.on_progress = on_progress
self.last_lines: list[str] = []
self._max_lines = 50
async def monitor(self, job: ConversionJob) -> None:
"""
Hauptschleife: Liest stderr des ffmpeg-Prozesses,
parst Fortschritt und ruft Callback auf.
"""
if not job.process or not job.process.stderr:
return
empty_reads = 0
max_empty_reads = 30
update_counter = 0
while True:
try:
data = await job.process.stderr.read(1024)
except Exception:
break
if not data:
empty_reads += 1
if empty_reads > max_empty_reads:
break
await asyncio.sleep(0.5)
continue
empty_reads = 0
line = data.decode(errors="replace")
# Letzte Zeilen fuer Fehlerdiagnose speichern
for part in line.splitlines():
part = part.strip()
if part:
self.last_lines.append(part)
if len(self.last_lines) > self._max_lines:
self.last_lines.pop(0)
self._extract_values(job, line)
self._calculate_progress(job)
job.update_stats(job.progress_fps, job.progress_speed, job.progress_bitrate)
# WebSocket-Update senden
await self.on_progress(job)
# Ausfuehrliches Logging alle 100 Reads
update_counter += 1
if update_counter % 100 == 0:
logging.info(
f"[{job.media.source_filename}] "
f"{job.progress_percent:.1f}% | "
f"FPS: {job.progress_fps} | "
f"Speed: {job.progress_speed}x | "
f"ETA: {MediaFile.format_time(job.progress_eta_sec)}"
)
def get_error_output(self) -> str:
"""Gibt die letzten stderr-Zeilen als String zurueck"""
return "\n".join(self.last_lines[-10:])
@staticmethod
def _extract_values(job: ConversionJob, line: str) -> None:
"""Regex-Extraktion aus ffmpeg stderr"""
# Frame
match = re.findall(r"frame=\s*(\d+)", line)
if match:
frames = int(match[-1])
if frames > job.progress_frames:
job.progress_frames = frames
# FPS
match = re.findall(r"fps=\s*(\d+\.?\d*)", line)
if match:
job.progress_fps = float(match[-1])
# Speed
match = re.findall(r"speed=\s*(\d+\.?\d*)", line)
if match:
job.progress_speed = float(match[-1])
# Bitrate
match = re.findall(r"bitrate=\s*(\d+)", line)
if match:
job.progress_bitrate = int(match[-1])
# Size (KiB von ffmpeg)
match = re.findall(r"size=\s*(\d+)", line)
if match:
size_kib = int(match[-1])
size_bytes = size_kib * 1024
if size_bytes > job.progress_size_bytes:
job.progress_size_bytes = size_bytes
# Time (HH:MM:SS)
match = re.findall(r"time=\s*(\d+:\d+:\d+\.?\d*)", line)
if match:
seconds = MediaFile.time_to_seconds(match[-1])
if seconds > job.progress_time_sec:
job.progress_time_sec = seconds
@staticmethod
def _calculate_progress(job: ConversionJob) -> None:
"""Berechnet Fortschritt in % und ETA"""
total_frames = job.media.total_frames
if total_frames > 0:
job.progress_percent = min(
(job.progress_frames / total_frames) * 100, 100.0
)
# ETA basierend auf durchschnittlicher FPS
if job.avg_fps > 0 and total_frames > 0:
remaining_frames = total_frames - job.progress_frames
job.progress_eta_sec = max(0, remaining_frames / job.avg_fps)