Compare commits

...

No commits in common. "9cbce7415d2429d2132afb3b1d985c09f6ddd7f2" and "main" have entirely different histories.

28 changed files with 1946 additions and 54 deletions

165
.gitignore vendored Normal file
View file

@ -0,0 +1,165 @@
# ---> Python
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
.pdm.toml
.pdm-python
.pdm-build/
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
/app/cfg/media_path.yaml

109
README.md Normal file
View file

@ -0,0 +1,109 @@
# Video-Konvertierungsprogramm
Dies ist ein einfaches Video-Konvertierungsprogramm, das eine Weboberfläche zur Verfügung stellt, um Videos in verschiedene Formate zu konvertieren. Das Backend läuft auf Python und verwendet WebSockets, um mit der Benutzeroberfläche zu kommunizieren und den Status von Konvertierungen zu verfolgen.
## Features
- **Weboberfläche** zur Anzeige aktueller Konvertierungen, Warteschlangen und Status.
- **Echtzeit-Kommunikation** via WebSocket zwischen Frontend und Backend.
- **Automatische Konvertierung** bei Dateipfadänderungen (optional, basierend auf den Einstellungen).
- **Statistiken** zu konvertierten Videos.
- Unterstützung für verschiedene Videoformate und Codecs.
## Anforderungen
- Python 3.8+
- Abhängigkeiten:
- `aiohttp` Für den HTTP-Server
- `websockets` Für die WebSocket-Kommunikation
- Weitere Abhängigkeiten wie z. B. `ffmpeg` für die Video-Konvertierung
### Installationsanweisungen
1. **Klonen des Repositories:**
```bash
git clone https://github.com/dein-benutzername/video-konverter.git
cd video-konverter
```
2. **Installiere die Abhängigkeiten:**
Wenn du noch keine virtuelle Umgebung eingerichtet hast, erstelle eine:
```bash
python3 -m venv venv
source venv/bin/activate # Auf Windows: venv\Scriptsctivate
```
Dann installiere alle benötigten Abhängigkeiten:
```bash
pip install -r requirements.txt
```
3. **Installiere ffmpeg:**
Für die Video-Konvertierung benötigst du `ffmpeg`. Installiere es je nach deinem Betriebssystem:
- **Ubuntu/Debian:**
```bash
sudo apt-get install ffmpeg
```
- **macOS (via Homebrew):**
```bash
brew install ffmpeg
```
- **Windows:**
Lade `ffmpeg` von [ffmpeg.org](https://ffmpeg.org/download.html) herunter und stelle sicher, dass es im Systempfad verfügbar ist.
## Nutzung
1. **Starte den Server:**
Der WebSocket-Server und der HTTP-Server laufen auf demselben Event-Loop. Um beide zu starten, führe einfach das Python-Skript aus:
```bash
python server.py
```
Der HTTP-Server ist auf `http://localhost:8080` erreichbar, und die WebSocket-Verbindung läuft unter `ws://localhost:8000`.
2. **Weboberfläche aufrufen:**
Öffne deinen Webbrowser und gehe zu `http://localhost:8080`, um die Benutzeroberfläche des Video-Konverters zu sehen. Du kannst Videos hochladen und deren Konvertierungsstatus in Echtzeit verfolgen.
## Funktionen der Weboberfläche
- **Aktuelle Konvertierungen**: Zeigt alle laufenden und abgeschlossenen Konvertierungen an.
- **Warteschlange**: Zeigt an, welche Videos noch konvertiert werden müssen.
- **Start/Pause/Stop**: Ermöglicht es, die Konvertierung zu steuern (je nach Implementierung).
- **Statistiken**: Zeigt Informationen zu den konvertierten Videos (z. B. Dateigröße, Format, etc.).
## Backend
Das Backend ist für die Verwaltung der Video-Konvertierungen zuständig und kommuniziert über WebSockets mit der Frontend-Weboberfläche.
### WebSocket-Server
Der WebSocket-Server wartet auf Verbindungen und sendet Updates zu den Konvertierungen an die Clients. Die Kommunikation erfolgt mit JSON-Nachrichten. Der Server unterstützt folgende Nachrichtenarten:
- **Datenpfad** (`data_path`): Gibt den Pfad der zu konvertierenden Datei an.
- **Befehle** (`data_command`): Für zukünftige Erweiterungen von Steuerbefehlen.
## Konfiguration
Die Konfiguration des Programms erfolgt über eine YAML-Datei (z. B. `config.yaml`), die Parameter wie den Serverport, den Pfad zu den Konvertierungswerkzeugen (z. B. `ffmpeg`) und automatische Startoptionen für die Konvertierungen enthält.
## Troubleshooting
- **Problem:** Der WebSocket-Server startet nicht.
- **Lösung:** Überprüfe, ob der Port (Standard: `8000`) bereits von einer anderen Anwendung verwendet wird.
- **Problem:** Videos werden nicht konvertiert.
- **Lösung:** Stelle sicher, dass `ffmpeg` korrekt installiert und im Systempfad verfügbar ist.
## Lizenz
Dieses Projekt ist unter der MIT-Lizenz lizenziert siehe [LICENSE](LICENSE) für Details.

16
__main__.py Normal file
View file

@ -0,0 +1,16 @@
import asyncio
import logging
from platform import python_version
from app.main_server import Server
if __name__ == "__main__":
print(python_version())
obj_server = Server()
try:
asyncio.run(obj_server.start_server())
except KeyboardInterrupt:
logging.warning("Server wurde manuell beendet")
except Exception as e:
logging.critical(f"Global error: {e}")

24
app/cfg/settings.yaml Normal file
View file

@ -0,0 +1,24 @@
log_file: "server.log"
log_level: DEBUG
log_rotation: time
path_file: "media_path.yaml"
server_ip: "0.0.0.0"
extern_http_url: localhost:8080
webserver_port: 8080
webserver_https: 0
extern_websocket_url: localhost:8000
websocket_port: 8000
websocket_https: 0
task_max: 1
autostart: true
subtitle:
language:
- ger
- eng
blacklist:
- hdmv_pgs_subtitle
- dvd_subtitle
audio:
language:
- ger
- eng

132
app/class_file_convert.py Executable file
View file

@ -0,0 +1,132 @@
import logging
import asyncio
import time
import traceback
from typing import TYPE_CHECKING
from app.class_file_convert_read_out import Process
from app.class_media_file_stat import Stat
if TYPE_CHECKING:
from app.class_media_file import Media
class Convert:
def __init__(self, websocket, cfg, obj_path):
self.yaml = cfg
self.obj_path = obj_path
self.obj_websocket = websocket
self.active_tasks = set()
self.active_process = set()
async def snake_waiting(self):
while True:
if len(self.active_tasks) < self.yaml["task_max"] and self.obj_path.count_paths(None) > 0:
for obj_id, obj in list(self.obj_path.paths.items()):
if obj.status is None:
obj.task = asyncio.create_task(self.convert_video(obj))
self.active_tasks.add(obj)
logging.info(f"Warteschlange started Auftrag - {obj.task}")
obj.status = 3
if len(self.active_tasks) >= self.yaml["task_max"]:
break
if len(self.active_tasks) > 0:
logging.info(f"{len(self.active_tasks)} is active.")
await asyncio.sleep(500)
continue
if self.obj_path.count_paths(None) == 0 and len(self.active_tasks) == 0:
break
print(self.obj_path.paths)
async def convert_video(self, obj):
"""Startet die Videokonvertierung asynchron."""
obj_process = Process(self.obj_websocket)
obj_stat = Stat()
obj.convert_start = time.time()
command = self.convert_cmd(obj)
result = None
logging.info(f"Starte Konvertierung: {command}")
await self.obj_websocket.send_websocket(self.obj_path.active_path_to_dict())
await self.obj_websocket.send_websocket(self.obj_path.queue_path_to_dict())
try:
# Starte den Subprozess asynchron
obj.process = await asyncio.create_subprocess_exec(
*command,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
self.active_process.add(obj)
obj.process_start = time.time()
await obj_process.read_out(obj)
await obj.process.wait()
# Prself.obj_websocket.send_websocket(self.obj_path.active_path_to_dict())ozess beendet, Status auswerten
if obj.process.returncode == 0:
obj.status = 0
result = "Finished"
else:
obj.status = 1
result = "Failure"
except Exception as e:
obj.status = 2
logging.error(f"Fehler in video_convert(): {e}")
logging.error(traceback.format_exc())
finally:
logging.info(f"Prozess {result}({obj.process.returncode}): {obj.source_file_name}")
await self.obj_websocket.send_websocket(self.obj_path.active_path_to_dict())
await self.obj_websocket.send_websocket(self.obj_path.queue_path_to_dict())
self.active_process.discard(obj)
self.active_tasks.discard(obj)
self.obj_path.save_paths()
obj.convert_end = time.time()
obj_stat.save_stat(obj)
def convert_cmd(self, obj):
command_convert = [
"ffmpeg", "-y", "-i", obj.source_file,
# "-init_hw_device", "vaapi=va:/dev/dri/renderD128",
"-map", "0:0",
"-c:v", "libsvtav1",
#"-c:v", "av1_qsv",
"-preset", "5",
"-crf", "30",
"-g", "240",
"-pix_fmt", "yuv420p10le",
"-svtav1-params", "tune=0:film-grain=8",
]
if len(obj.streams_audio):
for audio_stream in obj.streams_audio:
if audio_stream.get("tags", {}).get("language", None) in self.yaml["audio"]["language"]:
command_convert.extend([
"-map", f"0:{audio_stream['index']}",
f"-c:a", "libopus",
f"-b:a", "320k",
f"-ac", str(audio_stream['channels'])
])
# Subtitle-Streams einbinden
if len(obj.streams_subtitle):
for subtitle_stream in obj.streams_subtitle:
if subtitle_stream.get("codec_name") not in self.yaml["subtitle"]["blacklist"]:
if subtitle_stream.get("tags", {}).get("language", None) in self.yaml["subtitle"]["language"]:
command_convert.extend([
"-map", f"0:{subtitle_stream['index']}",
])
command_convert.append(obj.target_file)
obj.cmd = command_convert
return command_convert
def snake_update(self):
print(self.obj_path.paths)

View file

@ -0,0 +1,125 @@
import logging
import re
import asyncio
class Process:
def __init__(self, obj_websocket):
self.obj_websocket = obj_websocket
self.line_empty = 0
# Data
self.id = None
self.fps: float = 0.0
self.speed: float = 0.0
self.quantizer: int = 0
self.bitrate: list = [0, "kbits/s"]
self.size: list = [0, "KiB"]
self.time: int = 0
self.time_remaining = 0
self.loading = 0
self.frames: int = 0
async def read_out(self, obj):
self.id = obj.id
self.line_empty = 0
i = 100
while True:
line = await obj.process.stderr.read(1024)
line_decoded = line.decode()
self.process_line_extract(obj, line_decoded)
#logging.info(line_decoded)
if obj.source_frames_total > 0:
self.loading = (self.frames / obj.source_frames_total) * 100
else:
self.loading = 0
await self.obj_websocket.send_websocket(self.to_dict(obj))
if self.line_empty > 30:
break
elif not line:
self.line_empty += 1
await asyncio.sleep(2)
continue
elif line:
self.line_empty = 0
if i == 100 or i == 200 or i == 300 or i == 400 or i == 500:
logging.info(self.to_dict(obj))
self.save_stat_value(obj)
elif i == 101 or i == 501:
i = 0
time = self.time_remaining
if time != " ":
logging.info(f"Time remaining: {time}")
i += 1
def process_line_extract(self, obj, line:str):
# FPS
fps = re.findall(r"fps=\s*(\d+.\d*)", line)
self.fps = float(fps[0]) if fps else 0.0
# Quantizer
q = re.findall(r"q=\s*(\d+).\d+", line)
self.quantizer = int(q[0]) if q else 0
# Bitrate
bitrate = re.findall(r"bitrate=\s*(\d+)", line)
self.bitrate[0] = int(bitrate[0]) if bitrate else 0
# Speed
speed = re.findall(r"speed=\s*(\d+\.\d+)", line)
self.speed = float(speed[0]) if speed else 0.0
# File Size
size = re.findall(r"size=\s*(\d+)", line)
if size and int(size[0]) > self.size[0]:
self.size = obj.size_convert("KiB", None, "storage", int(size[0]))
obj.process_size = self.size
# Time
media_time = re.findall(r"time=\s*(\d+:\d+:\d+)", line)
time_v = media_time[0] if media_time else "00:00:00"
if self.time < obj.time_in_sec(time_v):
self.time = obj.time_in_sec(time_v)
obj.process_time = self.time
# Frames
frame = re.findall(r"frame=\s*(\d+)", line)
if frame and int(frame[0]) > self.frames:
self.frames = int(frame[0])
obj.process_frames = self.frames
def save_stat_value(self, obj):
if self.fps:
obj.stat_fps = [obj.stat_fps[0] + self.fps, obj.stat_fps[1] + 1]
if self.quantizer:
obj.stat_quantizer = [obj.stat_quantizer[0] + self.quantizer, obj.stat_quantizer[1] + 1]
if self.bitrate[0]:
obj.stat_bitrate = [obj.stat_bitrate[0] + self.bitrate[0], obj.stat_bitrate[1] + 1]
if self.speed:
obj.stat_speed = [obj.stat_speed[0] + self.speed, obj.stat_speed[1] + 1]
def to_dict(self, obj):
return {"data_flow": {
"id": self.id,
"frames": self.frames,
"fps": self.fps,
"quantizer": self.quantizer,
"size": self.size,
"time": obj.format_time(self.time, "Tage", "Std", "Min", None),
"time_remaining": obj.format_time(obj.time_remaining(), None, "Std", "Min", None),
"loading": self.loading,
"bitrate": self.bitrate,
"speed": self.speed
}}

208
app/class_file_path.py Executable file
View file

@ -0,0 +1,208 @@
import logging
import re
import os
import json
import yaml
import subprocess
from app.class_media_file import Media
class Path:
def __init__(self, cfg):
"""
Receive Paths extract from String and save to a List Variable.
Methoden: save_paths / read_paths / delete_path / receive_path
:param cfg: Global Settings Object
"""
# Settings
self.yaml = cfg
# Autostart
# self.read_paths()
# Variablen
self.paths:dict = {}
def save_paths(self):
"""
Saves the extrated Paths in a File
:return: True or False
"""
paths:list = []
for obj in self.paths.values():
if obj.status is None:
paths.append(obj.source_file)
paths_extrat_dict = {"paths": paths}
try:
with open(f"app/cfg/{self.yaml['path_file']}", "w", encoding="utf8") as file:
yaml.dump(paths_extrat_dict, file, default_flow_style=False, indent=4)
logging.info(f"{len(self.paths)} paths were saved to file")
return 1
except FileNotFoundError:
logging.error(f"File {self.yaml['path_file']} not found")
return 0
except IOError:
logging.critical(f"Error file {self.yaml['path_file']} maybe damaged")
return 0
# Achtung Abrufen aus Datei und neu einlesen der ffprobe Daten fehlt noch
def read_paths(self):
"""
Read Media Paths from a File
:return: True or False
"""
try:
with open(f"app/cfg/{self.yaml['path_file']}", "r", encoding="utf8") as file:
list_paths = yaml.safe_load(file)
count = len(list_paths.get("paths", []))
if count > 0:
self.receive_paths(list_paths)
logging.info(f"{count} paths were read from file")
return 1
except FileNotFoundError:
logging.error(f"File {self.yaml['path_file']} not found")
return 0
except IOError:
logging.critical(f"Error file {self.yaml['path_file']} maybe damaged")
return 0
def delete_path(self, obj_id:int):
"""
Delete a Media Path from a List Variable and overwrite the path file
:param obj_id: Path from a Media File which to delete
:return: True or False
"""
try:
del self.paths[obj_id]
self.save_paths()
return 1
except ValueError:
return 0
def receive_paths(self, var_paths):
"""
Splitting the Path String to List Single Media Paths
:param var_paths: String or List of a single or more Media Paths
:return: True or False
"""
logging.info(f"Empfangen{var_paths}")
if isinstance(var_paths, str):
pattern = r"(?<=\.mkv\s|\.mp4\s|\.avi\s)|(?<=\.webm\s)"
paths = re.split(pattern, var_paths)
else:
paths = var_paths
for path in paths:
self.get_with_ffprobe(path)
print(paths)
if len(paths):
self.save_paths()
return 1
@staticmethod
def extract_media_info(path:str, select:str):
"""
Erstellt ffprobe command for selected Streams
:param path: path to media file
:param select: v (for video), a (for audio), s (for subtitle)
:return:
"""
command = [
"ffprobe", "-v",
"error",
"-select_streams",
f"{select}",
"-show_entries",
"stream=index,channels,codec_name,codec_type,pix_fmt,level,"
"film_grain,r_frame_rate,bit_rate,sample_rate,width,height,size,tags:stream_tags=language,duration",
"-show_entries",
"format=size,bit_rate,nb_streams,duration",
"-of", "json",
path
]
result = subprocess.run(command, stdout=subprocess.PIPE, text=True)
json_data = json.loads(result.stdout)
if select == "v":
if json_data.get("format") is not list:
list_format = [json_data.get("format", "")]
else:
list_format = json_data.get("format")
return json_data.get("streams", []), list_format
elif select == "a":
return json_data.get("streams", [])
elif select == "s":
return json_data.get("streams", [])
return ""
def get_with_ffprobe(self, path:str):
try:
path = path.strip()
if os.path.exists(path):
streams_video, streams_format = self.extract_media_info(path, "v")
streams_audio = self.extract_media_info(path, "a")
streams_subtitle = self.extract_media_info(path, "s")
obj = Media(path, streams_video, streams_audio, streams_subtitle, streams_format)
if not self.search_paths(path):
self.paths.update({obj.id: obj})
logging.info(obj)
else:
logging.info(f"File exists in Waiting Snake :D: {path}")
return 1
except Exception as e:
logging.error(f"Get Video Information: {e}")
return 0
def search_paths(self, path):
for obj in self.paths.values():
if obj.source_file == path:
return 1
return 0
def count_paths(self, status):
count = 0
for obj in self.paths.values():
if obj.status == status:
count += 1
return count
def active_path_to_dict(self):
list_active_paths = {}
for obj in self.paths.values():
if obj.status == 3:
list_active_paths.update({obj.id: obj.to_dict_active_paths()})
return {"data_convert": list_active_paths}
def queue_path_to_dict(self):
list_active_paths = {}
for obj in self.paths.values():
if obj.status is None or obj.status == 1 or obj.status == 2 or obj.status == 3:
list_active_paths.update({obj.id: obj.to_dict_active_paths()})
return {"data_queue": list_active_paths}

233
app/class_media_file.py Executable file
View file

@ -0,0 +1,233 @@
import os
import math
import time
class Media:
_id_counter = 0
def __init__(self, path, streams_video, streams_audio, streams_subtitle, streams_format):
# misc
self.id = int(f"{int(time.time() * 1000)}{str(Media._id_counter).zfill(3)}")
self.cmd : list = None
# source
self.source_file: str = path
self.source_path: str = os.path.dirname(path)
self.source_file_name: str = os.path.basename(self.source_file)
self.source_duration: int = self.time_in_sec(streams_format[0].get("duration", "00:00:00"))
self.source_size: list = self.size_convert("B", None, "storage", int(streams_format[0].get("size")))
self.source_frame_rate: int = self.frame_rate(streams_video)
self.source_frames_total: int = self.source_frame_rate * self.source_duration
self.source_time: int = 0
# target
self.target_file: str = f"{path.rsplit('.', 1)[0]}.webm"
self.target_file_name: str = os.path.basename(self.target_file)
self.target_size: int = 0
# process
self.status = None
self.process_start: int = 0
self.process_end: int = 0
self.process_time: int = 0
self.process_size: list = [0, "KiB"]
self.process_frames: int = 0
self.process_time_remaining: int = 0
# statistic
self.stat_fps: list = [0, 0]
self.stat_bitrate: list = [0, 0]
self.stat_quantizer: list = [0, 0]
self.stat_speed: list = [0, 0]
# raw
self.streams_video = streams_video
self.streams_audio = streams_audio
self.streams_subtitle = streams_subtitle
self.streams_format = streams_format
# --------------------------------------------------------------------------------------------------------------
Media._id_counter += 1
def __str__(self):
def stream_output(stream_list):
count = 1
string = ""
for video_stream in stream_list:
string += f"{video_stream.get('codec_type').capitalize()} {count}" if video_stream.get(
'codec_type') else "Format"
for key, value in video_stream.items():
string += f" -- {key}: {value}"
string += "\n"
count += 1
return string
# Ausgabe
output_string = f"\n{self.source_file}\n"
output_string += "------------------------------------\n"
output_string += stream_output(self.streams_format)
output_string += "------------------------------------\n"
output_string += stream_output(self.streams_video)
output_string += "------------------------------------\n"
output_string += stream_output(self.streams_audio)
output_string += "------------------------------------\n"
output_string += stream_output(self.streams_subtitle)
output_string += "------------------------------------\n"
output_string += f"{self.target_file}\n"
output_string += "------------------------------------\n"
output_string += f"{self.id} -- {self.status}"
output_string += "\n************************************\n"
return output_string
def to_dict_active_paths(self):
return {
"source_file_name": self.source_file_name,
"source_file": self.source_file,
"source_path": self.source_path,
"source_duration": self.source_duration,
"source_size": self.source_size,
"source_frame_rate": self.source_frame_rate,
"source_frames_total": self.source_frames_total,
"source_time": self.source_time,
# target
"target_file_name": self.target_file_name,
"target_file": self.target_file,
"target_size": self.target_size,
#process
"status": self.status
}
def to_dict_stat(self):
return {self.id: {
# source
"source_file_name": self.source_file_name,
"source_file": self.source_file,
"source_duration": self.source_duration,
"source_size": self.source_size,
"source_frame_rate": self.source_frame_rate,
"source_frames_total": self.source_frames_total,
"source_time": self.source_time,
# target
"target_file_name": self.target_file_name,
"target_file": self.target_file,
"target_size": self.target_size,
# process
"status": self.status,
"process_start": self.process_start,
"process_end": self.process_end,
"process_time": self.process_time,
"process_size": self.process_size,
"process_frames": self.process_frames,
"process_time_remaining": self.process_time_remaining,
# statistic
"stat_fps": self.stat_fps,
"stat_bitrate": self.stat_bitrate,
"stat_quantizer": self.stat_quantizer,
"stat_speed": self.stat_speed
}}
@staticmethod
def frame_rate(video_streams):
var_frame_rate = video_streams[0].get("r_frame_rate", "0/0").split("/")
if int(var_frame_rate[1]) > 0:
int_frame_rate = round(int(var_frame_rate[0]) / int(var_frame_rate[1]))
else:
int_frame_rate = 0
return int_frame_rate
def time_remaining(self):
if self.stat_fps[0] > 0:
var_time_remaining = (self.source_frames_total - self.process_frames) / (self.stat_fps[0] / self.stat_fps[1])
self.process_time_remaining = round(var_time_remaining)
elif self.stat_fps[0] == 0:
self.process_time_remaining = 0
return self.process_time_remaining
@staticmethod
def format_time(seconds:int, str_day, str_hour, str_min, str_s):
days = round(seconds // (24 * 3600))
seconds %= (24 * 3600)
if days and str_day is not None:
d = f"{days} {str_day}"
else:
d = ""
hours = round(seconds // 3600)
seconds %= 3600
if hours and str_hour is not None:
h = f"{hours} {str_hour}"
else:
h = ""
minutes = math.ceil(seconds // 60)
seconds %= 60
if minutes and str_min is not None:
m = f"{minutes} {str_min}"
else:
m = ""
if seconds and str_s is not None:
s = f"{seconds} {str_s}"
else:
s = ""
return f"{d} {h} {m} {s}"
# Data convert
@staticmethod
def time_in_sec(time_str: str) -> int:
parts = time_str.split(":")
if len(parts) == 1: # Falls nur Sekunden mit Nachkommastellen vorliegen
return int(float(parts[0])) # Erst in float, dann in int umwandeln
if len(parts) == 3: # Normales HH:MM:SS-Format
h, m, s = map(float, parts) # In float umwandeln, falls Nachkommastellen im Sekundenwert sind
return int(h * 3600 + m * 60 + s) # Alles in Sekunden umrechnen
raise ValueError(f"Ungültiges Zeitformat: {time_str}")
@staticmethod
def size_convert(source: str, target, unit: str, size=0):
list_unit: list = []
if unit == "storage":
list_unit = ["B", "KiB", "MiB", "GiB", "TiB", "PiB"]
elif unit == "data_rate":
list_unit = ["bps", "Kbps", "Mbps", "Gbps", "Tbps", "Pbps"]
elif unit == "binary_data_rate":
list_unit = ["b/s", "Kib/s", "Mib/s", "Gib/s", "Tib/s", "Pib/s"]
factor = 1024 # Binäre Umrechnung
if source not in list_unit:
raise ValueError("Ungültige Quell-Einheit!")
source_index = list_unit.index(source)
if target:
if target not in list_unit:
raise ValueError("Ungültige Ziel-Einheit!")
target_index = list_unit.index(target)
if source_index < target_index:
return size / (factor ** (target_index - source_index)), target
else:
return size * (factor ** (source_index - target_index)), target
# Automatische Umrechnung
while size >= 1000 and source_index < len(list_unit) - 1:
size /= factor
source_index += 1
return [round(size, 1), list_unit[source_index]]

53
app/class_media_file_stat.py Executable file
View file

@ -0,0 +1,53 @@
import logging
from typing import TYPE_CHECKING
import yaml
import os
if TYPE_CHECKING:
from app.class_media_file import Media
class Stat:
"""
Handles reading and writing video conversion statistics
"""
def __init__(self) -> None:
"""
Initialize Class with path for statistic file
"""
self.path = "app/cfg/statistic.yaml"
def save_stat(self, obj: "Media") -> None:
"""
Saves the statistic in YAML file
:param obj: Media object
:type obj: Media
"""
daten = self.read_stat()
daten.setdefault("videos", {})
try:
# Neuen Eintrag hinzufügen
daten["videos"].update(obj.to_dict_stat())
with open(self.path, "w", encoding="utf8") as file:
yaml.dump(daten, file, default_flow_style=False, indent=4, allow_unicode=True)
except Exception as e:
logging.error(f"Save Stat Failure: {e}")
def read_stat(self) -> dict[str, any]:
"""
Read statistic from YAML file
:return: Dictionary width statistics
:rtype: Dict[str, any]
"""
# Bestehende Daten laden
if os.path.exists(self.path):
with open(self.path, "r", encoding="utf8") as file:
daten = yaml.safe_load(file) or {}
else:
daten = {}
return daten

74
app/class_settings.py Executable file
View file

@ -0,0 +1,74 @@
import logging
import yaml
import traceback
from logging.handlers import TimedRotatingFileHandler, RotatingFileHandler
# === ANSI-Farbcodes für Konsole ===
RESET = "\x1b[0m" # Farbe zurücksetzen
RED = "\x1b[31m" # Fehler: Rot
YELLOW = "\x1b[33m" # Warnung: Gelb
GREEN = "\x1b[32m" # Info: Grün
BLUE = "\x1b[34m" # Debug: Blau
class CustomFormatter(logging.Formatter):
def format(self, record):
if record.levelno >= logging.ERROR and record.exc_info:
record.msg = f"{record.msg}\n{traceback.format_exc()}"
# Farbe nach Level
if record.levelno == logging.DEBUG:
record.msg = f"{BLUE}{record.msg}{RESET}"
elif record.levelno == logging.INFO:
record.msg = f"{GREEN}{record.msg}{RESET}"
elif record.levelno == logging.WARNING:
record.msg = f"{YELLOW}{record.msg}{RESET}"
elif record.levelno >= logging.ERROR:
record.msg = f"{RED}{record.msg}{RESET}"
return super().format(record)
class Settings:
def __init__(self):
self.yaml = None
self.read()
def read(self):
with open("app/cfg/settings.yaml", "r", encoding="utf-8") as file:
self.yaml = yaml.safe_load(file)
def write(self):
with open("app/cfg/settings.yaml", "w", encoding="utf8") as file:
yaml.dump(self.yaml, file, default_flow_style=False, indent=4)
def set_logging(self):
log_level = self.yaml.get("log_level", "INFO")
log_file = self.yaml.get("log_file", "app.log")
log_mode = self.yaml.get("log_rotation", "size") # "time" oder "size"
handlers = [logging.StreamHandler()]
# Handler je nach Modus erstellen
if log_mode == "time":
log_handler = TimedRotatingFileHandler(
f"app/logs/{log_file}", when="midnight", interval=1, backupCount=7, encoding="utf-8"
)
else:
log_handler = RotatingFileHandler(
f"app/logs/{log_file}", maxBytes=5 * 1024 * 1024, backupCount=5, encoding="utf-8"
)
handlers.append(log_handler)
# Logger konfigurieren
logging.basicConfig(
level=log_level,
format="%(asctime)s - %(levelname)s - %(message)s",
handlers=handlers
)
# Traceback-Formatter setzen
formatter = CustomFormatter("%(asctime)s - %(levelname)s - %(message)s")
for handler in logging.getLogger().handlers:
handler.setFormatter(formatter)

161
app/main_server.py Executable file
View file

@ -0,0 +1,161 @@
import asyncio
import websockets
import json
import logging
import shlex
from asyncio import CancelledError
from websockets import InvalidUpgrade, ConnectionClosed, ConnectionClosedError
from aiohttp import web
from app.class_settings import Settings
from app.class_file_path import Path
from app.class_file_convert import Convert
from app.class_media_file_stat import Stat
var_convert_active = False
class Server:
def __init__(self):
self.websocket = None
self.clients = set()
obj_settings = Settings()
obj_settings.set_logging()
self.yaml = obj_settings.yaml
self.obj_path = Path(self.yaml)
self.obj_convert = Convert(self, self.yaml, self.obj_path)
async def start_convert(self):
global var_convert_active
try:
if not var_convert_active and self.yaml['autostart']:
await self.obj_convert.snake_waiting()
var_convert_active = True
else:
self.obj_convert.snake_update()
finally:
self.set_var_convert_active(False)
async def send_websocket(self, message):
message_json = json.dumps(message)
for client in self.clients.copy():
try:
await client.send(message_json)
except websockets.exceptions.ConnectionClosed:
self.clients.remove(client)
async def handle_client(self, websocket):
self.websocket = websocket
global var_convert_active
if websocket not in self.clients:
self.clients.add(websocket)
await self.send_websocket(self.obj_path.active_path_to_dict())
await self.send_websocket(self.obj_path.queue_path_to_dict())
try:
async for message in websocket:
data = json.loads(message)
if data.get("data_path"):
self.obj_path.receive_paths(data.get("data_path"))
await self.send_websocket(self.obj_path.active_path_to_dict())
await self.send_websocket(self.obj_path.queue_path_to_dict())
await self.start_convert()
elif data.get("data_command"):
if data["data_command"]["cmd"] == "delete":
self.obj_path.delete_path(data["data_command"]["id"])
await self.send_websocket(self.obj_path.queue_path_to_dict())
elif data.get("data_message"):
logging.info(f"Server hat Empfangen: {data.get('data_message')}")
except (ConnectionClosedError, ConnectionClosed):
logging.warning("Client Verbindung geschlossen")
except InvalidUpgrade:
logging.warning("Ungültiger Websocket Upgrade versuch")
except Exception as e:
logging.error(f"Unerwarteter Fehler {e}")
finally:
self.clients.discard(websocket)
@staticmethod
def set_var_convert_active(value: bool):
global var_convert_active
var_convert_active = value
async def server_websocket(self):
self.obj_path.read_paths()
asyncio.create_task(self.start_convert())
server = await websockets.serve(self.handle_client, self.yaml['server_ip'], self.yaml['websocket_port'])
logging.info(f"Websocket Server läuft auf IP: {self.yaml['server_ip']} Port: {self.yaml['websocket_port']}")
await server.wait_closed()
# WebServer --------------------------------------------------------------------------------------------------------
# noinspection PyUnusedLocal
@staticmethod
async def handle_index(request):
return web.FileResponse("./client/index.html")
# noinspection PyUnusedLocal
async def handle_ip(self, request):
url = self.yaml["extern_websocket_url"]
port = self.yaml["websocket_port"]
websocket_https = self.yaml["websocket_https"]
return web.json_response({"extern_websocket_url": url, "websocket_port": port, "websocket_https": websocket_https})
# noinspection PyUnusedLocal
@staticmethod
async def handle_stat(request):
obj_stat = Stat()
return web.json_response(obj_stat.read_stat())
# noinspection PyUnusedLocal
async def handle_cmd(self, request):
command = self.obj_convert.active_tasks # deine oben gepostete Funktion
html = ""
for obj in command:
# ffmpeg-Befehl als String (mit Shell-Escapes, falls Leerzeichen etc.)
cmd_str = " ".join([shlex.quote(arg) for arg in obj.cmd])
html += f"{cmd_str}<br /><br />"
return web.Response(text=html, content_type="text/html")
async def server_http(self):
app = web.Application()
app.router.add_get("/", self.handle_index)
app.router.add_get("/api/ip", self.handle_ip)
app.router.add_get("/api/stats", self.handle_stat)
app.router.add_get("/api/cmd", self.handle_cmd)
app.router.add_static("/client/", path="./client", name="client")
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(runner, "0.0.0.0", self.yaml["webserver_port"])
await site.start()
logging.info(f"HTTP Server läuft auf Port {self.yaml['webserver_port']}")
# Start Server -----------------------------------------------------------------------------------------------------
async def start_server(self):
try:
await asyncio.gather(
self.server_websocket(),
self.server_http()
)
except CancelledError:
logging.warning("Server wurde durch Keyboard Interrupt gestoppt.")
except Exception as e:
logging.error(f"Unerwarteter Fehler beim Starten des Servers {e}")

View file

@ -1,34 +0,0 @@
/**
* @returns {Promise<{server_ip: string, server_port: number}>}
*/
async function getServerConfig() {
const response = await fetch('/api/ip');
return await response.json();
}
getServerConfig()
.then(data => {
const websocketIp = data.server_ip;
const websocketPort = data.server_port;
const ws = new WebSocket(`ws://${websocketIp}:${websocketPort}`);
ws.onopen = function() {
console.log("WebSocket ist geöffnet");
ws.send(JSON.stringify({"data_message": "Server Adresse: " + websocketIp + ":" + websocketPort}));
};
ws.onmessage = function(messageEvent) {
console.log(messageEvent.data);
};
ws.onclose = function() {
console.log("WebSocket wurde geschlossen");
};
ws.onerror = function(errorEvent) {
console.error("WebSocket-Fehler: ", errorEvent);
};
})
.catch(error => {
console.error('Error fetching settings:', error);
});

BIN
client/icons/close-144.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

BIN
client/icons/favicon.ico Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
client/icons/fehler-96.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
client/icons/muell-128.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

BIN
client/icons/stat-100.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

BIN
client/icons/wait-100.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

BIN
client/icons/wait.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

48
client/index.html Executable file
View file

@ -0,0 +1,48 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Webm VC</title>
<link rel="stylesheet" href="/client/index_style.css">
<link rel="icon" href="/client/icons/favicon.ico" type="image/x-icon">
</head>
<body>
<!-- === Menü === -->
<header>
<h1>Video Konverter AV1/Opus - Webm</h1>
<nav>
<img src="/client/icons/stat-100.png" onclick="openPopup()" alt="Statisiken" width="50" style="cursor: pointer;">
</nav>
</header>
<!-- === Aktive Konvertierungen === -->
<section id="active-conversions">
<h2>Aktive Konvertierungen</h2>
<!-- Wird dynamisch mit JS gefüllt -->
</section>
<!-- === Warteschleife === -->
<header>
<h1>Warteschlange</h1>
</header>
<section id="queue">
<!-- Wird dynamisch mit JS gefüllt -->
</section>
<!-- === Statistikbereich === -->
<div id="popup" class="popup">
<div class="popup-content">
<div class="flex-row">
<h2>Allgemeine Statistiken</h2>
<img src="/client/icons/close-144.png" onclick="closePopup()" alt="Statisiken" width="15" style="cursor: pointer;">
</div>
<p>Hier könnten Diagramme, Durchschnittswerte etc. angezeigt werden.</p>
<section id="stat"></section>
</div>
</div>
<script src="/client/media_conversion.js"></script>
<script src="/client/media_stat.js"></script>
</body>
</html>

306
client/index_style.css Executable file
View file

@ -0,0 +1,306 @@
/* === Allgemeines Design === */
body {
margin: 0;
font-family: Arial, sans-serif;
background-color: #121212;
color: #eee;
}
a {
color: #90caf9;
}
/* === Menü oben === */
header {
background-color: #1e1e1e;
padding: 1rem;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid #333;
}
header h1 {
margin: 0;
font-size: 1.5rem;
}
nav button {
background-color: #333;
color: #fff;
border: none;
padding: 0.5rem 1rem;
margin-left: 0.5rem;
cursor: pointer;
border-radius: 5px;
}
nav button:hover {
background-color: #444;
}
/* === Bereich: Aktive Konvertierungen === */
#active-conversions {
padding: 1rem;
border-bottom: 1px solid #333;
}
#active-conversions h2 {
margin-top: 0;
}
.video-card {
background-color: #1f1f1f;
padding: 1rem;
margin-bottom: 1rem;
border-radius: 8px;
position: relative;
}
.video-card h3 {
margin: 0 0 0.5rem;
}
.video-card-values {
display: grid;
grid-template-columns: 1fr 1fr 1fr auto;
grid-auto-rows: auto;
gap: 10px;
}
.video-card-values-items {
padding: 5px;
text-align: center;
font-size: 0.8rem;
}
.video-card .actions button {
background-color: #444;
color: white;
border: none;
padding: 0.4rem 0.8rem;
margin-right: 0.5rem;
cursor: pointer;
border-radius: 4px;
}
.video-card .actions button:hover {
background-color: #666;
}
.delete-button {
grid-column: 4;
grid-row: 1 / span 3; /* oder wie viele Zeilen du hast */
display: flex;
align-items: center;
justify-content: center;
}
.tooltip {
position: absolute;
background-color: #333;
color: #eee;
padding: 0.5rem;
border-radius: 6px;
top: 100%;
left: 0;
z-index: 1;
display: none;
width: max-content;
max-width: 300px;
font-size: 0.7rem;
}
.video-card:hover .tooltip {
display: block;
}
.progress-container {
width: 100%;
background: #333;
border-radius: 5px;
margin-top: 10px;
height: 10px;
overflow: hidden;
}
.progress-bar {
height: 100%;
width: 0%;
background: linear-gradient(90deg, #4caf50, #00c853);
transition: width 0.5s ease-in-out;
}
/* === Bereich: Warteschleife === */
#queue {
padding: 1rem;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 1rem;
}
.queue_wait-card {
display: flex;
flex-direction: column;
height: 80%;
background-color: #1f1f1f;
padding: 1rem;
border-radius: 10px;
color: #eee;
}
.card_wait-inner {
display: flex;
flex-direction: column;
height: 100%;
}
.card-inner {
display: flex;
flex-direction: column;
height: 100%;
}
#menu_wait {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: auto; /* ← Schiebt das Ding ganz nach unten */
}
#queue h2 {
margin-top: 0;
}
.conversion_wait_icons {
width: 25px;
height: auto;
cursor: pointer;
}
.status_icons {
width: 25px;
height: auto;
}
#menu_wait {
display: flex;
justify-content: space-between; /* Abstand dazwischen */
align-items: center; /* Vertikal zentriert, optional */
margin-top: auto; /* ← Schiebt das Ding ganz nach unten */
}
.links {
width: 25px;
}
.rechts {
width: 25px;
}
/* === Statistik-Bereich === */
#statistics {
padding: 1rem;
border-top: 1px solid #333;
}
#statistics h2 {
margin-top: 0;
}
.conversion_icons {
width: 50px;
height: auto;
}
.conversion_url {
font-size: 0.7rem;
text-align: center;
}
.popup {
display: none;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
overflow: auto;
background-color: rgba(0,0,0,0.7);
}
.popup-content {
background-color: #000000;
margin: 15% auto;
padding: 20px;
border: 1px solid #444;
width: 90%;
border-radius: 10px;
color: #eee;
}
.close {
color: #aaa;
float: right;
font-size: 24px;
font-weight: bold;
cursor: pointer;
}
.close:hover {
color: #fff;
}
.flex-row {
display: flex;
flex-direction: row;
align-items: flex-start;
justify-content: space-between;
}
.queue-card {
background-color: #1f1f1f;
padding: 1rem;
border-radius: 8px;
color: #eee;
}
@media (max-width: 768px) {
header h1 {
font-size: 1.4rem; /* vorher 1.75rem */
}
nav button {
padding: 0.4rem 0.8rem; /* etwas kleiner */
font-size: 0.85rem;
}
.video-card {
padding: 0.8rem;
}
.video-card h3 {
font-size: 1rem;
}
.video-card-values-items {
font-size: 0.65rem;
}
.conversion_url {
font-size: 0.6rem;
}
.popup-content {
width: 90%; /* vorher 90% */
font-size: 0.85rem;
}
.conversion_icons {
width: 40px;
}
}

199
client/media_conversion.js Executable file
View file

@ -0,0 +1,199 @@
/**
* @returns {Promise<{server_ip: string, server_port: number}>}
*/
let videoActive = {};
let videoQueue = {};
let ws = null
async function getServerConfig() {
const response = await fetch('/api/ip');
return await response.json();
}
async function getMediaStat() {
const response = await fetch('/api/stats');
return await response.json();
}
getServerConfig()
.then(data => {
const externWebsocketUrl = data.extern_websocket_url;
const websocketHttps = data.websocket_https
let connect;
if (websocketHttps === 1){
connect = `wss://${externWebsocketUrl}`
} else {
connect = `ws://${externWebsocketUrl}`
}
ws = new WebSocket(connect);
ws.onopen = function() {
console.log("WebSocket ist geöffnet");
ws.send(JSON.stringify({"data_message": "Server Adresse: " + externWebsocketUrl}));
};
ws.onmessage = function(messageEvent) {
try{
console.log(messageEvent.data);
let packet = JSON.parse(messageEvent.data);
if (packet.data_flow !== undefined){
updateVideoElement(packet);
} else if(packet.data_convert !== undefined){
deleteVideoElement(packet)
createVideoElement(packet);
} else if(packet.data_queue !== undefined){
createWaitingSnake(packet);
}
} catch (e){
console.error("Error parsing data flow");
}
};
ws.onclose = function() {
console.log("WebSocket wurde geschlossen");
};
ws.onerror = function(errorEvent) {
console.error("WebSocket-Fehler: ", errorEvent);
};
})
.catch(error => {
console.error('Error fetching settings:', error);
});
function sendCommand(command, id){
if (ws && ws.readyState === WebSocket.OPEN) {
const payload = {"data_command": {"cmd": command, "id": id}};
ws.send(JSON.stringify(payload));
} else {
console.warn("WebSocket ist nicht verbunden")
}
}
function deleteVideoElement(packet) {
for (let key in videoActive){
if (!(key in packet.data_convert)){
const elem = document.getElementById(key);
if(elem){
elem.remove();
}
delete videoActive[key];
}
}
}
function createVideoElement(packet){
const active_Conversions = document.getElementById('active-conversions');
Object.keys(packet.data_convert).forEach(key => {
const video = packet.data_convert[key];
if(!videoActive[key]){
const card = document.createElement('div');
card.className = 'video-card';
card.id = key
card.innerHTML = `
<h3 title="${video.source_path}" align="center">${video.source_file_name} - ${video.target_file_name}</h3>
<div class="progress-container">
<div class="progress-bar"></div>
</div>
<br/>
<div class="video-card-values">
<div title="Anzahl der verarbeiteten Frames" class="video-card-values-items"><b>Frames:</b> <span class="frames">0</span> Anz</div>
<div title="Dateigröße" class="video-card-values-items"><b>Größe:</b> <span class="size">0</span> <span class="size_unit"></span></div>
<div title="Verarbeitungsgeschwindigkeit in Frames/Sekunde" class="video-card-values-items"><b>FPS:</b> <span class="fps">0</span></div>
<div title="Qualitätswert: je niedriger, desto besser, nur bei bestimmten Codecs sichtbar" class="video-card-values-items"><b>Quanitzer:</b> <span class="quantizer">0</span></div>
<div title="Menge an Daten pro Sekunde, die für das Video verwendet wird" class="video-card-values-items"><b>Bitrate:</b> <span class="bitrate">0</span> <span class="bitrate_unit"></span></div>
<div title="Verarbeitungsgeschwindigkeit im Vergleich zur Echtzeit (1.0x = Echtzeit)" class="video-card-values-items"><b>Speed:</b> <span class="speed">0</span></div>
<div title="Verbleibende Zeit" class="video-card-values-items"><b>Verbleibend:</b> <span class="time_remaining">0</span></div>
<div title="Position im Film" class="video-card-values-items"><b>Zeit:</b> <span class="time">0</span></div>
<div class="video-card-values-items delete-button"><!--<img src="/client/icons/muell-128.png" class="conversion_icons">--></div>
</div>`;
active_Conversions.appendChild(card)
videoActive[key] = video;
}
});
}
function createWaitingSnake(packet){
const queue = document.getElementById('queue');
for (let key in videoQueue) {
if (!(key in packet.data_queue) || (videoQueue[key] && videoQueue[key].status !== packet.data_queue[key]?.status)) {
const elem = document.getElementById(key);
if (elem) {
elem.remove();
delete videoQueue[key];
}
}
}
Object.keys(packet.data_queue).forEach(key => {
const video = packet.data_queue[key];
if(!videoQueue[key]){
const card = document.createElement('div');
card.className = 'queue_wait-card';
card.id = key
if(video.status === 1 || video.status === 2){
status_img = `<img src="/client/icons/fehler-96.png" class="status_icons">`
} else if(video.status === 3) {
status_img = `<img src="/client/icons/wait.gif" class="status_icons">`
} else {
status_img = `<img src="/client/icons/wait-100.png" class="status_icons">`
}
card.innerHTML = `
<div class="card_wait-inner">
<h3 title="${video.source_path}" align="center">${video.source_file_name}</h3>
<div id="menu_wait">
<div class="links">${status_img}</div>
<div class="rechts"><img src="/client/icons/muell-128.png" class="conversion_wait_icons" onclick="sendCommand('delete', ${key})"></div>
</div>
</div>
`;
queue.appendChild(card)
videoQueue[key] = video;
}
});
}
function updateVideoElement(packet){
let video = packet.data_flow;
let container = document.getElementById(video.id);
container.querySelector(".frames").textContent = video.frames ?? 0;
container.querySelector(".size").textContent = video.size[0] || 0;
container.querySelector(".size_unit").textContent = video.size[1] || "KB";
container.querySelector(".fps").textContent = video.fps ?? 0;
container.querySelector(".quantizer").textContent = video.quantizer ?? 0;
container.querySelector(".bitrate").textContent = video.bitrate[0] || 0;
container.querySelector(".bitrate_unit").textContent = video.bitrate[1] || 0;
container.querySelector(".speed").textContent = video.speed ?? 0;
container.querySelector(".time_remaining").textContent = video.time_remaining ?? "0 Min";
container.querySelector(".time").textContent = video.time ?? "0 Min";
let progressBar = container.querySelector(".progress-bar");
let progress = video.loading; // Annahme: `loading` kommt als Zahl von 0 bis 100
progressBar.style.width = progress + "%"
if (videoActive[video.id].status === 3) {
progressBar.style.background = "linear-gradient(90deg, #4caf50, #00c853)";
} else if(videoActive[video.id].status === 0) {
progressBar.style.background = "#4caf50";
} else if(videoActive[video.id].status === 1) {
progressBar.style.background = "#ff0000";
}
}

70
client/media_stat.js Executable file
View file

@ -0,0 +1,70 @@
/**
* @returns {Promise<{server_ip: string, server_port: number}>}
*/
async function getServerConfig() {
const response = await fetch('/api/ip');
return await response.json();
}
async function getMediaStat() {
const response = await fetch('/api/stats');
return await response.json();
}
async function toggleStatHidden() {
const section_stat = document.getElementById("stat")
section_stat.hidden = !section_stat.hidden
}
function openPopup() {
document.getElementById("popup").style.display = "block";
}
function closePopup() {
document.getElementById("popup").style.display = "none";
}
getMediaStat()
.then(data => {
console.log("Antwort von /api/stats:", data); // Debug-Ausgabe
if (!data || typeof data !== 'object' || !data.videos || typeof data.videos !== 'object') {
throw new Error("Ungültiges Antwortformat oder fehlende 'videos'-Eigenschaft");
}
const stat_Container = document.getElementById('stat');
Object.keys(data.videos).forEach(key => {
const video = data.videos[key];
const card = document.createElement('div');
card.className = 'video-card';
card.id = key
card.innerHTML = `
<h3 title="${video.source_file}">${video.source_file_name}</h3>
<div class="actions">
<button>Löschen</button>
</div>
<div class="tooltip">
Quelle: ${video.source_file}<br>
Dauer: ${video.source_duration}<br>
Größe: ${video.source_size}<br>
FPS: ${video.source_frame_rate}<br>
Frames: ${video.source_frames_total}<br>
Start: ${video.process_start}<br>
Ende: ${video.process_end}<br>
Status: ${video.status}<br>
Zeit: ${video.process_time}<br>
Größe (Ziel): ${video.target_size}<br>
FPS (aktuell): ${video.stat_fps}<br>
Bitrate: ${video.stat_bitrate}<br>
Speed: ${video.stat_speed}
</div>
`;
stat_Container.appendChild(card)
});
})
.catch(error => {
console.error('Fehler beim Abrufen der Medienstatistik:', error);
});

View file

@ -1,10 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Websocket Client</title>
</head>
<body>
<script src="/client/client.js"></script>
</body>
</html>

View file

@ -0,0 +1,9 @@
#!/bin/bash
SERVER="ws://192.168.155.110:8000/"
for FILE in "$@"; do
JSON=$(printf '{"data_path": "%s"}' "$FILE")
echo "$JSON" | websocat "$SERVER"
done

View file

@ -0,0 +1,10 @@
[Desktop Entry]
Type=Service
ServiceTypes=KonqPopupMenu/Plugin
MimeType=video/*
Actions=sendToWebSocket
[Desktop Action sendToWebSocket]
Name=Video to New Server
Exec=/home/data/.local/share/kio/servicemenus/send.path.sh %F
Icon=video-x-generic

View file

@ -1,10 +0,0 @@
{
"name": "client",
"version": "1.0.0",
"description": "",
"main": "client.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"private": true
}

4
requirements.txt Normal file
View file

@ -0,0 +1,4 @@
PyYAML~=6.0.2
websockets~=15.0
asyncio~=3.4.3
aiohttp~=3.11.16