docker.videokonverter/app/models/job.py
data d65ca027e0 v2.3.0: Import-Jobs, Ordner-Loeschen, Serien-Konvertierung, Server-Log
Features:
- Import-Jobs: Persistierung in DB, Jobs beim Laden wiederherstellen
- Ordner loeschen: Button in Browser-Ansicht mit Modal-Dialog
- Serien konvertieren: Alle Episoden einer Serie in Queue senden
- Serien aufraumen: Alte Codec-Versionen nach Konvertierung loeschen
- Server-Log: Live-Ansicht in Admin mit Auto-Scroll
- Toast-Benachrichtigungen statt Browser-Alerts
- Bessere Fehlerbehandlung und Feedback

API:
- POST /api/library/delete-folder
- POST /api/library/series/{id}/convert
- GET /api/library/series/{id}/convert-status
- POST /api/library/series/{id}/cleanup
- GET /api/logs

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-24 14:48:30 +01:00

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,
}