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>
205 lines
7 KiB
Python
205 lines
7 KiB
Python
"""Konvertierungs-Job-Modell mit Status-Management"""
|
|
import time
|
|
import asyncio
|
|
from dataclasses import dataclass, field
|
|
from enum import IntEnum
|
|
from typing import Optional
|
|
from app.models.media import MediaFile
|
|
|
|
# Globaler Zaehler fuer eindeutige IDs
|
|
_id_counter = 0
|
|
|
|
|
|
class JobStatus(IntEnum):
|
|
QUEUED = 0
|
|
ACTIVE = 1
|
|
FINISHED = 2
|
|
FAILED = 3
|
|
CANCELLED = 4
|
|
|
|
|
|
@dataclass
|
|
class ConversionJob:
|
|
"""Einzelner Konvertierungs-Auftrag"""
|
|
media: MediaFile
|
|
preset_name: str = ""
|
|
|
|
# Wird in __post_init__ gesetzt
|
|
id: int = field(init=False)
|
|
status: JobStatus = field(default=JobStatus.QUEUED)
|
|
|
|
# Ziel-Informationen
|
|
target_path: str = ""
|
|
target_filename: str = ""
|
|
target_container: str = "webm"
|
|
|
|
# Optionen
|
|
delete_source: bool = False # Quelldatei nach Konvertierung loeschen
|
|
|
|
# ffmpeg Prozess
|
|
ffmpeg_cmd: list[str] = field(default_factory=list)
|
|
process: Optional[asyncio.subprocess.Process] = field(default=None, repr=False)
|
|
task: Optional[asyncio.Task] = field(default=None, repr=False)
|
|
|
|
# Fortschritt
|
|
progress_percent: float = 0.0
|
|
progress_fps: float = 0.0
|
|
progress_speed: float = 0.0
|
|
progress_bitrate: int = 0
|
|
progress_size_bytes: int = 0
|
|
progress_time_sec: float = 0.0
|
|
progress_frames: int = 0
|
|
progress_eta_sec: float = 0.0
|
|
|
|
# Zeitstempel
|
|
created_at: float = field(default_factory=time.time)
|
|
started_at: Optional[float] = None
|
|
finished_at: Optional[float] = None
|
|
|
|
# Statistik-Akkumulation (Summe, Anzahl)
|
|
_stat_fps: list = field(default_factory=lambda: [0.0, 0])
|
|
_stat_speed: list = field(default_factory=lambda: [0.0, 0])
|
|
_stat_bitrate: list = field(default_factory=lambda: [0, 0])
|
|
|
|
def __post_init__(self) -> None:
|
|
global _id_counter
|
|
self.id = int(time.time() * 1000) * 1000 + _id_counter
|
|
_id_counter += 1
|
|
|
|
def build_target_path(self, config) -> None:
|
|
"""Baut Ziel-Pfad basierend auf Config"""
|
|
files_cfg = config.files_config
|
|
container = files_cfg.get("target_container", "webm")
|
|
self.target_container = container
|
|
|
|
# Dateiname ohne Extension + neue Extension
|
|
base_name = self.media.source_filename.rsplit(".", 1)[0]
|
|
self.target_filename = f"{base_name}.{container}"
|
|
|
|
# Ziel-Ordner
|
|
target_folder = files_cfg.get("target_folder", "same")
|
|
if target_folder == "same":
|
|
target_dir = self.media.source_dir
|
|
else:
|
|
target_dir = target_folder
|
|
|
|
self.target_path = f"{target_dir}/{self.target_filename}"
|
|
|
|
# Konfliktvermeidung: Wenn Ziel = Quelle (gleiche Extension)
|
|
if self.target_path == self.media.source_path:
|
|
base = self.target_path.rsplit(".", 1)[0]
|
|
self.target_path = f"{base}_converted.{container}"
|
|
self.target_filename = f"{base_name}_converted.{container}"
|
|
|
|
def update_stats(self, fps: float, speed: float, bitrate: int) -> None:
|
|
"""Akkumuliert Statistik-Werte fuer Durchschnittsberechnung"""
|
|
if fps > 0:
|
|
self._stat_fps[0] += fps
|
|
self._stat_fps[1] += 1
|
|
if speed > 0:
|
|
self._stat_speed[0] += speed
|
|
self._stat_speed[1] += 1
|
|
if bitrate > 0:
|
|
self._stat_bitrate[0] += bitrate
|
|
self._stat_bitrate[1] += 1
|
|
|
|
@property
|
|
def avg_fps(self) -> float:
|
|
if self._stat_fps[1] > 0:
|
|
return self._stat_fps[0] / self._stat_fps[1]
|
|
return 0.0
|
|
|
|
@property
|
|
def avg_speed(self) -> float:
|
|
if self._stat_speed[1] > 0:
|
|
return self._stat_speed[0] / self._stat_speed[1]
|
|
return 0.0
|
|
|
|
@property
|
|
def avg_bitrate(self) -> int:
|
|
if self._stat_bitrate[1] > 0:
|
|
return int(self._stat_bitrate[0] / self._stat_bitrate[1])
|
|
return 0
|
|
|
|
@property
|
|
def duration_sec(self) -> float:
|
|
"""Konvertierungsdauer in Sekunden"""
|
|
if self.started_at and self.finished_at:
|
|
return self.finished_at - self.started_at
|
|
return 0.0
|
|
|
|
def to_dict_active(self) -> dict:
|
|
"""Fuer WebSocket data_convert Nachricht"""
|
|
size = MediaFile.format_size(self.media.source_size_bytes)
|
|
return {
|
|
"source_file_name": self.media.source_filename,
|
|
"source_file": self.media.source_path,
|
|
"source_path": self.media.source_dir,
|
|
"source_duration": self.media.source_duration_sec,
|
|
"source_size": [size[0], size[1]],
|
|
"source_frame_rate": self.media.frame_rate,
|
|
"source_frames_total": self.media.total_frames,
|
|
"target_file_name": self.target_filename,
|
|
"target_file": self.target_path,
|
|
"status": self.status.value,
|
|
"preset": self.preset_name,
|
|
}
|
|
|
|
def to_dict_queue(self) -> dict:
|
|
"""Fuer WebSocket data_queue Nachricht"""
|
|
return {
|
|
"source_file_name": self.media.source_filename,
|
|
"source_file": self.media.source_path,
|
|
"source_path": self.media.source_dir,
|
|
"status": self.status.value,
|
|
"preset": self.preset_name,
|
|
}
|
|
|
|
def to_dict_progress(self) -> dict:
|
|
"""Fuer WebSocket data_flow Nachricht"""
|
|
target_size = MediaFile.format_size(self.progress_size_bytes)
|
|
return {
|
|
"id": self.id,
|
|
"frames": self.progress_frames,
|
|
"fps": self.progress_fps,
|
|
"speed": self.progress_speed,
|
|
"quantizer": 0,
|
|
"size": [target_size[0], target_size[1]],
|
|
"time": MediaFile.format_time(self.progress_time_sec),
|
|
"time_remaining": MediaFile.format_time(self.progress_eta_sec),
|
|
"loading": round(self.progress_percent, 1),
|
|
"bitrate": [self.progress_bitrate, "kbits/s"],
|
|
}
|
|
|
|
def to_dict_stats(self) -> dict:
|
|
"""Fuer Statistik-Datenbank"""
|
|
return {
|
|
"source_path": self.media.source_path,
|
|
"source_filename": self.media.source_filename,
|
|
"source_size_bytes": self.media.source_size_bytes,
|
|
"source_duration_sec": self.media.source_duration_sec,
|
|
"source_frame_rate": self.media.frame_rate,
|
|
"source_frames_total": self.media.total_frames,
|
|
"target_path": self.target_path,
|
|
"target_filename": self.target_filename,
|
|
"target_size_bytes": self.progress_size_bytes,
|
|
"target_container": self.target_container,
|
|
"preset_name": self.preset_name,
|
|
"status": self.status.value,
|
|
"started_at": self.started_at,
|
|
"finished_at": self.finished_at,
|
|
"duration_sec": self.duration_sec,
|
|
"avg_fps": self.avg_fps,
|
|
"avg_speed": self.avg_speed,
|
|
"avg_bitrate": self.avg_bitrate,
|
|
}
|
|
|
|
def to_json(self) -> dict:
|
|
"""Fuer Queue-Persistierung"""
|
|
return {
|
|
"source_path": self.media.source_path,
|
|
"preset_name": self.preset_name,
|
|
"status": self.status.value,
|
|
"created_at": self.created_at,
|
|
"delete_source": self.delete_source,
|
|
}
|