New Project
206
app/main.py
Executable file
|
|
@ -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()
|
||||
|
||||
|
||||
15
app/templates/progress.html
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Video Liste</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Vorhandene Videos</h1>
|
||||
<ul>
|
||||
{% for key, video in videos.items() %}
|
||||
<li>{{ video.source_file }} <b>{{video.finished}}</b></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</body>
|
||||
</html>
|
||||
15
app/templates/webs-ui.html
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Live Fortschritt</title>
|
||||
<link rel="stylesheet" href="/webs/webs.css">
|
||||
</head>
|
||||
<body>
|
||||
<h1>Video-Konvertierung Fortschritt</h1>
|
||||
<div id="videos"></div>
|
||||
<script src="/webs/webs.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
53
app/video_class.py
Normal file
|
|
@ -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)
|
||||
BIN
app/webs/animation-32.png
Normal file
|
After Width: | Height: | Size: 297 B |
BIN
app/webs/bitrate-30.png
Normal file
|
After Width: | Height: | Size: 392 B |
BIN
app/webs/fps-32.png
Normal file
|
After Width: | Height: | Size: 543 B |
BIN
app/webs/q-24.png
Normal file
|
After Width: | Height: | Size: 345 B |
BIN
app/webs/speed-32.png
Normal file
|
After Width: | Height: | Size: 543 B |
BIN
app/webs/ssd-30.png
Normal file
|
After Width: | Height: | Size: 201 B |
95
app/webs/webs.css
Normal file
|
|
@ -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;
|
||||
}
|
||||
78
app/webs/webs.js
Normal file
|
|
@ -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 = `
|
||||
<div class="video-header">${source} ➝ ${target}</div>
|
||||
<div class="progress-container">
|
||||
<div class="progress-bar"></div>
|
||||
</div>
|
||||
<div class="video-info">
|
||||
<div><span class="path">${path}</span></div>
|
||||
<table class="table_video_info">
|
||||
<tr>
|
||||
<th><img src="/webs/animation-32.png" class="icons"></th><th class="label"><div title="Anzahl Frames"><span class="frame">0</span> Anz</div></th>
|
||||
<th><img src="/webs/fps-32.png" class="icons"></th><th class="label"><div title="Frames / Sekunde"><span class="fps">0</span> fps</div></th>
|
||||
<th><img src="/webs/q-24.png" class="icons"></th><th class="label"><div title="Quantizer"><span class="q">0 </span> Q</div></th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th><img src="/webs/ssd-30.png" class="icons"></th><th class="label"><div title="Dateigröße"><span class="size">0 </span> MB</div></th>
|
||||
<th><img src="/webs/bitrate-30.png" class="icons"></th><th class="label"><div title="Bitrate"><span class="bitrate">0 </span> Mb/s</div></th>
|
||||
<th><img src="/webs/speed-32.png" class="icons"></th><th class="label"><div title="Speed"><span class="speed">0</span> x</div></th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th></th><th></th>
|
||||
<th></th><th></th>
|
||||
<th></th><th><div class="loader"><span class="finished"></span></div></th>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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";
|
||||
}
|
||||
}
|
||||
BIN
app/webs/zeit-30.png
Normal file
|
After Width: | Height: | Size: 472 B |
10
kio/servicemenus/convert_videos_to_local.desktop
Executable file
|
|
@ -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
|
||||
10
kio/servicemenus/convert_videos_to_server.desktop
Executable file
|
|
@ -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
|
||||
10
kio/servicemenus/ffprobe.desktop
Executable file
|
|
@ -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
|
||||