commit 381f8cafa7f931a7e9b7b277a7be64ce4fe0ff92 Author: data Date: Mon Feb 17 13:23:40 2025 +0100 New Project diff --git a/app/main.py b/app/main.py new file mode 100755 index 0000000..6c78312 --- /dev/null +++ b/app/main.py @@ -0,0 +1,206 @@ +import asyncio +import re +import subprocess +import json + +from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect +from fastapi.staticfiles import StaticFiles +from fastapi.responses import HTMLResponse +from fastapi.templating import Jinja2Templates +from contextlib import asynccontextmanager + +import app.video_class as vc + +@asynccontextmanager +async def lifespan(_: FastAPI): + await queue_video() + yield + +app = FastAPI(lifespan=lifespan) +app.mount("/webs", StaticFiles(directory="app/webs"), name="webs") +templates = Jinja2Templates(directory="app/templates") + +queue = asyncio.Queue() +read_output_task = None +convert_task = 0 + +# Settings +language = ["ger", "eng"] +process_count = 1 + +# Test +#obj_file_test = vc.Video("/mnt/Media/21 - Spielfilme M/Star Trek/Star Trek 9 - Der Aufstand (1998) 1080p/Star Trek 9 - Der Aufstand (1998) 1080p.mkv") +#video_files = {"/mnt/Media/21 - Spielfilme M/Star Trek/Star Trek 9 - Der Aufstand (1998) 1080p/Star Trek 9 - Der Aufstand (1998) 1080p.mkv": obj_file_test} + +video_files = {} + +#pattern = r"(/[^:]+?\.(?:mkv|webm|avi|mp4))" +pattern = r"(?<=\.mkv\s|\.mp4\s|\.avi\s)|(?<=\.webm\s)" + +progress = {} +async def queue_video(): + global convert_task + for key, obj in video_files.items(): + if process_count > convert_task: + if obj.finished == 0: + convert_task += 1 + asyncio.create_task(video_convert(obj)) + +async def video_convert(obj): + global convert_task + + # Audio ------------------------------------------------------------------------------------------------------------ + command = [ + "ffprobe", "-v", + "error", + "-select_streams", + "a", "-show_entries", + "stream=index,channels,tags:stream_tags=language,tags:format=duration", + "-of", "json", + obj.source_file + ] + + json_data = {} + + try: + result = subprocess.run(command, stdout=subprocess.PIPE, text=True) + json_data = json.loads(result.stdout) + print(json_data) + + obj.duration = json_data.get("format", {"duration":999}).get("duration") + + except Exception as e: + convert_task -= 1 + await queue_video() + obj.error.append(f"ffprobe ---- {e}") + print(obj.error) + + # Konvertierung ---------------------------------------------------------------------------------------------------- + command = [ + "ffmpeg", "-y", "-i", obj.source_file, + "-map", "0:0", + "-c:v", "libsvtav1", + "-preset", "5", + "-crf", "24", + "-g", "240", + "-pix_fmt", "yuv420p", + "-svtav1-params", "tune=0:film-grain=8", + ] + + if "streams" in json_data: + i = 0 + for audio_stream in json_data["streams"]: + if audio_stream.get("tags", {}).get("language", None) in language: + command.extend([ + "-map", f"0:{audio_stream['index']}", + f"-c:a", "libopus", + f"-b:a", "320k", + f"-ac", str(audio_stream['channels']) + ]) + i += 1 + + command.extend(["-map", "0:s?", "-c:s", "copy"]) + command.append(obj.output_file) + + # Prozess + try: + process_video = await asyncio.create_subprocess_exec( + *command, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE + ) + + obj.progress = process_video + if process_video.returncode == 0: + obj.finished = 1 + convert_task -= 1 + await queue_video() + + except Exception as e: + convert_task -= 1 + obj.finished = 2 + await queue_video() + obj.error.append(f"ffmpeg ---- {e}") + +async def read_output(): + while True: + active_processes = [obj for obj in video_files.values() if obj.progress] + + if not active_processes: + await asyncio.sleep(10) # Kein aktives Video -> kurz warten + continue + + for obj in active_processes: + if obj.progress: + line = await obj.progress.stderr.read(2048) + + line_decoded = line.decode().strip() + print(line_decoded) + + frame = re.findall(r"frame=\s*(\d+)", line_decoded) + obj.frame = frame[0] if frame else obj.frame + + fps = re.findall(r"fps=\s*(\d+.\d+)", line_decoded) + obj.fps = fps[0] if fps else obj.fps + + q = re.findall(r"q=\s*(\d+.\d+)", line_decoded) + obj.q = q[0] if q else obj.q + + size = re.findall(r"size=\s*(\d+)", line_decoded) + obj.size = round(int(size[0]) / 1024, 2) if size else obj.size + + time = re.findall(r"time=\s*(\d+:\d+:\d+)", line_decoded) + time_v = time[0] if time else time + obj.time = obj.time_in_sec(time_v) + + bitrate = re.findall(r"bitrate=\s*(\d+)", line_decoded) + obj.bitrate = round(int(bitrate[0]) / 1024, 2) if bitrate else obj.bitrate + + speed = re.findall(r"speed=\s*(\d+\.\d+)", line_decoded) + obj.speed = speed[0] if speed else obj.speed + + json_data = json.dumps(obj.to_dict()) + await queue.put(json_data) + +@app.post("/") +async def receive_video_file(data: dict): + file_paths = data.get("files", [])[0] + var_list_file = re.split(pattern, file_paths) + + for path in var_list_file: + obj_file = vc.Video(path.strip()) + video_files.update({path: obj_file}) + + await queue_video() + #Test + print(video_files) + +@app.get("/progress", response_class=HTMLResponse) +async def display_paths(request: Request): + return templates.TemplateResponse("progress.html", {"request": request, "videos": video_files}) + +@app.get("/webs-ui", response_class=HTMLResponse) +async def display_paths(request: Request): + return templates.TemplateResponse("webs-ui.html", {"request": request, "videos": video_files}) + +@app.websocket("/ws") +async def websocket_v(websocket: WebSocket): + global read_output_task + + await websocket.accept() + + if read_output_task is None or read_output_task.done(): + read_output_task = asyncio.create_task(read_output()) + + try: + while True: + message = await queue.get() + await websocket.send_text(message) + except WebSocketDisconnect: + print("WebSocket disconnected") # Optional: Logging + except Exception as e: + print(f"WebSocket error: {e}") # Fehlerbehandlung + finally: + await websocket.close() + + diff --git a/app/templates/progress.html b/app/templates/progress.html new file mode 100644 index 0000000..7d655a6 --- /dev/null +++ b/app/templates/progress.html @@ -0,0 +1,15 @@ + + + + + Video Liste + + +

Vorhandene Videos

+ + + \ No newline at end of file diff --git a/app/templates/webs-ui.html b/app/templates/webs-ui.html new file mode 100644 index 0000000..0a51bc8 --- /dev/null +++ b/app/templates/webs-ui.html @@ -0,0 +1,15 @@ + + + + + + Live Fortschritt + + + +

Video-Konvertierung Fortschritt

+
+ + + + diff --git a/app/video_class.py b/app/video_class.py new file mode 100644 index 0000000..6193aa8 --- /dev/null +++ b/app/video_class.py @@ -0,0 +1,53 @@ +import os + + +class Video: + def __init__(self, path): + self.id = id(self) + self.source_file = path + self.output_file = f"{path.rsplit(".", 1)[0]}.webm" + self.frame = None + self.fps = None + self.q = None + self.size = None + self.time = 0 + self.bitrate = None + self.speed = None + self.finished = 0 + self.progress = None + self.duration = 1 + self.loading = 0 + self.error = [] + + def to_dict(self): + self.calc_loading() + + return { + "source": os.path.basename(self.source_file), + "target": os.path.basename(self.output_file), + "path": os.path.dirname(self.source_file), + "id": self.id, + "frame": self.frame, + "fps": self.fps, + "q": self.q, + "size": self.size, + "time": self.time, + "bitrate": self.bitrate, + "speed": self.speed, + "finished": self.finished, + "duration": self.duration, + "loading": self.loading + } + + @staticmethod + def time_in_sec(time): + if time != 0: + h_m_s = str(time).split(":") + time_in_s = int(h_m_s[0]) * 3600 + int(h_m_s[1]) * 60 + int(h_m_s[2]) + else: + time_in_s = 0 + + return time_in_s + + def calc_loading(self): + self.loading = round(self.time / float(self.duration) * 100, None) diff --git a/app/webs/animation-32.png b/app/webs/animation-32.png new file mode 100644 index 0000000..4392cc4 Binary files /dev/null and b/app/webs/animation-32.png differ diff --git a/app/webs/bitrate-30.png b/app/webs/bitrate-30.png new file mode 100644 index 0000000..08473bf Binary files /dev/null and b/app/webs/bitrate-30.png differ diff --git a/app/webs/fps-32.png b/app/webs/fps-32.png new file mode 100644 index 0000000..9ec0851 Binary files /dev/null and b/app/webs/fps-32.png differ diff --git a/app/webs/q-24.png b/app/webs/q-24.png new file mode 100644 index 0000000..a83610c Binary files /dev/null and b/app/webs/q-24.png differ diff --git a/app/webs/speed-32.png b/app/webs/speed-32.png new file mode 100644 index 0000000..1fa8b2a Binary files /dev/null and b/app/webs/speed-32.png differ diff --git a/app/webs/ssd-30.png b/app/webs/ssd-30.png new file mode 100644 index 0000000..e039be8 Binary files /dev/null and b/app/webs/ssd-30.png differ diff --git a/app/webs/webs.css b/app/webs/webs.css new file mode 100644 index 0000000..cdcd712 --- /dev/null +++ b/app/webs/webs.css @@ -0,0 +1,95 @@ +body { + background-color: #121212; + color: #ffffff; + font-family: Arial, sans-serif; + text-align: center; + margin: 0; + padding: 20px; +} + +h1 { + font-size: 24px; +} + +#videos { + display: flex; + flex-direction: column; + gap: 15px; + max-width: 1500px; + margin: auto; +} + +.video { + background: #1e1e1e; + padding: 15px; + border-radius: 8px; + box-shadow: 0 0 10px rgba(255, 255, 255, 0.1); + position: relative; + overflow: hidden; +} + +.video-header { + font-weight: bold; + font-size: 16px; + margin-bottom: 5px; +} + +.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; +} + +.finished { + color: #4caf50; + font-weight: bold; +} + +.table_video_info{ + border: None; + width: 100%; +} + +th, td { + width: 16%; /* Jede Zelle nimmt 33% der Breite der Tabelle ein */ + padding: 16px; + text-align: left; /* Optional: Text ausrichten */ +} + +.icons { + width: 25px; + float: right; +} + +.label { + text-align: left; + vertical-align: middle; +} + +.loader { + border: 16px solid #f3f3f3; /* Light grey */ + border-top: 16px solid #3498db; /* Blue */ + border-radius: 50%; + width: 20px; + height: 20px; + animation: spin 2s linear infinite; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +.path { + font-size: 12px; +} \ No newline at end of file diff --git a/app/webs/webs.js b/app/webs/webs.js new file mode 100644 index 0000000..73113e1 --- /dev/null +++ b/app/webs/webs.js @@ -0,0 +1,78 @@ +let ws = new WebSocket("ws://127.0.0.1:8000/ws"); +let videoQueue = {}; // Hier speichern wir alle laufenden Videos + +ws.onmessage = function(event) { + try { + let message = JSON.parse(event.data); + + // Falls das Video noch nicht existiert, erstelle ein neues Element + if (!videoQueue[message.id]) { + videoQueue[message.id] = createVideoElement(message.id, message.source, message.target, message.path); + } + + // Update der UI + updateVideoElement(message.id, message); + } catch (e) { + console.error("Fehler beim Parsen der WebSocket-Nachricht:", e, event.data); + } +}; + +function createVideoElement(id, source, target, path) { + let container = document.createElement("div"); + container.className = "video"; + container.id = id; + + container.innerHTML = ` +
${source}    ➝    ${target}
+
+
+
+
+
${path}
+ + + + + + + + + + + + + + + + +
0 Anz
0 fps
0 Q
0 MB
0 Mb/s
0 x
+
+ `; + + document.getElementById("videos").appendChild(container); + return container; +} + +function updateVideoElement(id, data) { + let container = document.getElementById(id); + if (!container) return; + + container.querySelector(".frame").textContent = data.frame || '---'; + container.querySelector(".fps").textContent = data.fps || '---'; + container.querySelector(".q").textContent = data.q || '---'; + container.querySelector(".size").textContent = data.size || '---'; + container.querySelector(".bitrate").textContent = data.bitrate || '---'; + container.querySelector(".speed").textContent = data.speed || '---'; + + let progressBar = container.querySelector(".progress-bar"); + let progress = data.loading; // Annahme: `loading` kommt als Zahl von 0 bis 100 + progressBar.style.width = progress + "%" + + if (data.finished) { + progressBar.style.background = "#4caf50"; + container.querySelector(".finished").textContent = "Fertig"; + } else { + progressBar.style.background = "linear-gradient(90deg, #4caf50, #00c853)"; + container.querySelector(".finished").textContent = "Läuft"; + } +} diff --git a/app/webs/zeit-30.png b/app/webs/zeit-30.png new file mode 100644 index 0000000..bb13515 Binary files /dev/null and b/app/webs/zeit-30.png differ diff --git a/kio/servicemenus/convert_videos_to_local.desktop b/kio/servicemenus/convert_videos_to_local.desktop new file mode 100755 index 0000000..9342ba6 --- /dev/null +++ b/kio/servicemenus/convert_videos_to_local.desktop @@ -0,0 +1,10 @@ +[Desktop Entry] +Type=Service +ServiceTypes=KonqPopupMenu/Plugin +MimeType=video/* +Actions=convert_videos + +[Desktop Action convert_videos] +Name=Convert Local Videos Server +Exec=curl -X POST -H "Content-Type: application/json" -d '{"files": ["%F"]}' http://localhost:8000/ +Icon=video-x-generic diff --git a/kio/servicemenus/convert_videos_to_server.desktop b/kio/servicemenus/convert_videos_to_server.desktop new file mode 100755 index 0000000..25eaa50 --- /dev/null +++ b/kio/servicemenus/convert_videos_to_server.desktop @@ -0,0 +1,10 @@ +[Desktop Entry] +Type=Service +ServiceTypes=KonqPopupMenu/Plugin +MimeType=video/* +Actions=convert_videos + +[Desktop Action convert_videos] +Name=Convert Videos Server +Exec=curl -X POST -H "Content-Type: application/json" -d '{"files":["%U"]}' http://192.168.155.110:8000/ +Icon=video-x-generic diff --git a/kio/servicemenus/ffprobe.desktop b/kio/servicemenus/ffprobe.desktop new file mode 100755 index 0000000..8767bfd --- /dev/null +++ b/kio/servicemenus/ffprobe.desktop @@ -0,0 +1,10 @@ +[Desktop Entry] +Type=Service +ServiceTypes=KonqPopupMenu/Plugin +MimeType=video/*;audio/*; +Actions=FFProbeInfo; + +[Desktop Action FFProbeInfo] +Name=FFprobe Info anzeigen +Icon=dialog-information +Exec=ffprobe -v error -show_format -show_streams "%f" | kdialog --textbox - --title "FFprobe Ausgabe" --geometry 800x600