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>
132 lines
4.3 KiB
Python
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)
|