Compare commits

...

6 Commits

Author SHA1 Message Date
9279036c51 enable http communication with printer to fetch user and password 2026-05-07 12:49:10 +02:00
ce63cc5e7a fix: YouTube Thumbnail auf hqdefault 2026-05-04 14:39:00 +02:00
5c83cc6df0 docs: Video Tutorial in README 2026-05-04 14:37:10 +02:00
be11217896 release: v0.9.6.1 2026-05-02 21:31:54 +02:00
0292785fd8 release: v0.9.6.1 2026-05-02 21:27:19 +02:00
50419fb487 release: v0.9.6 2026-05-02 20:58:40 +02:00
9 changed files with 459 additions and 23 deletions

View File

@@ -1,5 +1,24 @@
# Changelog
## [0.9.6.1] 2026-05-02
### Fixes
- **Upload-Banner:** Banner wird nach Stopp/Abbruch nicht mehr erneut angezeigt — `file_ready` und Thumbnail werden jetzt gecleared wenn der Drucker `stoped` oder `canceled` meldet
---
## [0.9.6] 2026-05-02
### Neu
- **Fortschritts-Karte:** Verstrichen / Slicer-Schätzung / Restzeit als Mini-Cards (gleicher Stil wie Temperaturkarten)
- **Layer-Mini-Card:** Layerzahl als Mini-Card neben der Fortschrittsleiste
### Fixes
- **Slicer-Schätzzeit:** OrcaSlicer schreibt die geschätzte Zeit ans Ende der GCode-Datei — Bridge liest jetzt auch die letzten 64 KB (vorher nur die ersten 16 KB)
- **start.sh:** `config/`-Verzeichnis wird jetzt automatisch erstellt und `config.ini.example` wird beim ersten Start hineinkopiert (Issue #15)
---
## [0.9.5] 2026-05-01
### Neu

View File

@@ -1,5 +1,24 @@
# Changelog
## [0.9.6.1] 2026-05-02
### Fixes
- **Upload banner:** Banner is no longer shown again after print stop/cancel — `file_ready` and thumbnail are now cleared when the printer reports `stoped` or `canceled`
---
## [0.9.6] 2026-05-02
### New
- **Progress card:** Elapsed / Slicer estimate / Remaining time shown as mini-cards (same style as temperature cards)
- **Layer mini-card:** Layer count displayed as mini-card next to the progress bar
### Fixes
- **Slicer estimate time:** OrcaSlicer writes the estimated time at the end of the GCode file — bridge now also scans the last 64 KB (previously only the first 16 KB were checked)
- **start.sh:** `config/` directory is now created automatically and `config.ini.example` is copied into it on first run (Issue #15)
---
## [0.9.5] 2026-05-01
### New

View File

@@ -2,7 +2,7 @@
# KX-Bridge Anycubic Kobra X
**Version:** 0.9.5
**Version:** 0.9.6.1
Steuere deinen **Anycubic Kobra X** mit OrcaSlicer — ohne Klipper, ohne Raspberry Pi.
KX-Bridge ist eine Moonraker-kompatible Bridge die direkt mit dem Drucker kommuniziert.
@@ -46,6 +46,12 @@ Drucker → Verbindungstyp **Moonraker** → Host: `http://BRIDGE-IP:7125`
---
## 📺 Video Tutorial
[![KX-Bridge Setup & Bedienung](https://img.youtube.com/vi/1Ql4wfH27fM/hqdefault.jpg)](https://www.youtube.com/watch?v=1Ql4wfH27fM)
---
## ⚠️ Update von 0.9.1 oder älter
Ab **0.9.2** speichert KX-Bridge Einstellungen in `config/config.ini` statt in `.env`.

View File

@@ -2,7 +2,7 @@
# KX-Bridge Anycubic Kobra X
**Version:** 0.9.5
**Version:** 0.9.6.1
Control your **Anycubic Kobra X** with OrcaSlicer — no Klipper, no Raspberry Pi.
KX-Bridge is a Moonraker-compatible bridge that communicates directly with the printer.
@@ -46,6 +46,12 @@ Printer → Connection type **Moonraker** → Host: `http://BRIDGE-IP:7125`
---
## 📺 Video Tutorial
[![KX-Bridge Setup & Usage](https://img.youtube.com/vi/1Ql4wfH27fM/hqdefault.jpg)](https://www.youtube.com/watch?v=1Ql4wfH27fM)
---
## ⚠️ Upgrading from 0.9.1 or earlier
Starting with **0.9.2**, KX-Bridge stores settings in `config/config.ini` instead of `.env`.

View File

@@ -1 +1 @@
0.9.5
0.9.6.1

345
fetch_credentials.py Normal file
View File

@@ -0,0 +1,345 @@
#!/usr/bin/env python3
"""
Decrypt printer configuration data using AES-256-CBC
This script decrypts encrypted printer configuration data from a 3D printer using AES-256-CBC.
The method was reverse engineered from the vue project embedded in libWorkbench.so,
which is part of Anycubic Slicer Next.
The decryption key is derived from the device token,
and the IV is obtained from the response token in the encrypted data.
- Algorithm: AES-256-CBC
- Key: Characters 16-32 of the token from info.json
- IV: The response token from ctrl.json
- Mode: CBC with PKCS7 padding
Usage:
python3 fetch_credentials.py --ip 192.168.2.32
python3 fetch_credentials.py --ip 192.168.2.32 --port 18910
python3 fetch_credentials.py --ctrl ctrl.json --info info.json --output config.json
"""
import json
import sys
import base64
import hashlib
import argparse
import time
import random
import string
import requests
from pathlib import Path
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad
def evp_bytes_to_key(password, salt, key_len, iv_len):
"""
Derive key and IV from password and salt using OpenSSL EVP_BytesToKey
This mimics the CryptoJS default key derivation
"""
m = []
i = 0
while len(b''.join(m)) < (key_len + iv_len):
md5 = hashlib.md5()
data = password + salt
if i > 0:
data = m[i - 1] + password + salt
md5.update(data)
m.append(md5.digest())
i += 1
ms = b''.join(m)
return ms[:key_len], ms[key_len:key_len + iv_len]
def generate_signature(token, ts, nonce):
"""
Generate MD5 signature for /ctrl endpoint
Signature = md5(md5(token[0:16]) + ts + nonce)
"""
# First MD5: token.slice(0, 16)
first_md5 = hashlib.md5(token[:16].encode('utf-8')).hexdigest()
# Second MD5: first_md5 + ts + nonce
signature_data = first_md5 + str(ts) + nonce
signature = hashlib.md5(signature_data.encode('utf-8')).hexdigest()
return signature
def generate_nonce(length=6):
"""Generate a random alphanumeric nonce"""
chars = string.ascii_letters + string.digits
return ''.join(random.choice(chars) for _ in range(length))
def fetch_from_http(ip, port, endpoint, token=None, did="random", verbose=False):
"""
Fetch data from HTTP endpoint on the printer
Args:
ip (str): IP address of the printer
port (int): Port number (default 18910)
endpoint (str): Either 'info' or 'ctrl'
token (str): Device token (required for /ctrl endpoint)
did (str): Device ID (required for /ctrl endpoint)
verbose (bool): Print debug information
Returns:
dict: JSON response data
"""
try:
if endpoint == 'info':
url = f"http://{ip}:{port}/info"
if verbose:
print(f"Fetching: {url}")
response = requests.get(url, timeout=10)
response.raise_for_status()
return response.json()
elif endpoint == 'ctrl':
if not token:
raise ValueError("Token is required for /ctrl endpoint")
# Generate signature parameters
ts = int(time.time() * 1000) # Current timestamp in ms
nonce = generate_nonce(6)
signature = generate_signature(token, ts, nonce)
url = f"http://{ip}:{port}/ctrl"
params = {
'ts': ts,
'nonce': nonce,
'sign': signature,
'did': did
}
if verbose:
print(f"Fetching: {url}")
print(f" Parameters:")
print(f" ts: {ts}")
print(f" nonce: {nonce}")
print(f" sign: {signature}")
print(f" did: {did}")
response = requests.post(url, params=params, timeout=10)
response.raise_for_status()
return response.json()
else:
raise ValueError(f"Unknown endpoint: {endpoint}")
except requests.exceptions.RequestException as e:
raise Exception(f"HTTP request failed for {endpoint}: {e}")
except json.JSONDecodeError as e:
raise Exception(f"Invalid JSON response from {endpoint}: {e}")
def decrypt_text(encrypted_data, key, iv):
"""
Decrypt data using AES-256-CBC
Handles CryptoJS-style encrypted data (OpenSSL format with salt)
Args:
encrypted_data (str): Encrypted data string (CryptoJS format)
key (str): Decryption key string
iv (str): Initialization vector string
Returns:
dict: Decrypted JSON data
"""
try:
# Convert key and IV to bytes
key_bytes = key.encode('utf-8')
iv_bytes = iv.encode('utf-8')
# Decrypt using direct key and IV (as per the original code)
cipher = AES.new(key_bytes, AES.MODE_CBC, iv_bytes)
# The encrypted_data might be base64 or hex encoded
# Try base64 first
try:
encrypted_bytes = base64.b64decode(encrypted_data)
except:
try:
# Try as hex
encrypted_bytes = bytes.fromhex(encrypted_data)
except:
# If all else fails, encode as UTF-8
encrypted_bytes = encrypted_data.encode('utf-8')
# Decrypt
decrypted = cipher.decrypt(encrypted_bytes)
# Try to unpad
try:
unpadded = unpad(decrypted, AES.block_size)
except ValueError:
# If unpadding fails, use as-is
unpadded = decrypted
plaintext = unpadded.decode('utf-8')
# Parse JSON
return json.loads(plaintext)
except Exception as e:
return {"error": str(e), "error_type": type(e).__name__}
def main():
"""Main function to decrypt printer data"""
# Parse command-line arguments
parser = argparse.ArgumentParser(
description='Decrypt Anycubic printer configuration data',
)
# HTTP mode
parser.add_argument('--ip', help='IP address of the printer (fetch via HTTP)')
parser.add_argument('--port', type=int, default=18910, help='Printer port (default: 18910)')
# File mode
parser.add_argument('--ctrl', default='ctrl.json', help='Path to ctrl.json (default: ctrl.json)')
parser.add_argument('--info', default='info.json', help='Path to info.json (default: info.json)')
parser.add_argument('--output', default='decrypted_printer_config.json', help='Output file (default: decrypted_printer_config.json)')
parser.add_argument('--verbose', '-v', action='store_true', help='Verbose output')
args = parser.parse_args()
# Determine mode: HTTP or file
if args.ip:
# HTTP mode: fetch from printer
if args.verbose:
print("=" * 70)
print("Fetching configuration from printer via HTTP")
print("=" * 70)
print(f"Printer IP: {args.ip}:{args.port}")
print()
try:
# Fetch info.json
if args.verbose:
print("Step 1: Fetching device info...")
info = fetch_from_http(args.ip, args.port, 'info', verbose=args.verbose)
# Get token from info
token = info.get('token')
if not token:
print("Error: No token found in /info response", file=sys.stderr)
return 1
# Fetch data.json (encrypted config) from /ctrl endpoint
if args.verbose:
print("\nStep 2: Fetching encrypted configuration from /ctrl...")
data = fetch_from_http(args.ip, args.port, 'ctrl', token=token, verbose=args.verbose)
if args.verbose:
print("\nData fetched successfully!")
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
return 1
else:
# File mode: load from disk
if args.verbose:
print("=" * 70)
print("Loading configuration from files")
print("=" * 70)
# Check if input files exist
if not Path(args.ctrl).exists():
print(f"Error: {args.ctrl} not found", file=sys.stderr)
return 1
if not Path(args.info).exists():
print(f"Error: {args.info} not found", file=sys.stderr)
return 1
# Read data.json
try:
with open(args.data, 'r') as f:
data = json.load(f)
except json.JSONDecodeError as e:
print(f"Error reading {args.data}: {e}", file=sys.stderr)
return 1
except Exception as e:
print(f"Error reading {args.data}: {e}", file=sys.stderr)
return 1
# Read info.json
try:
with open(args.info, 'r') as f:
info = json.load(f)
except json.JSONDecodeError as e:
print(f"Error reading {args.info}: {e}", file=sys.stderr)
return 1
except Exception as e:
print(f"Error reading {args.info}: {e}", file=sys.stderr)
return 1
# Extract values
try:
encrypted_info = data['data']['info']
response_token = data['data']['token']
full_token = info['token']
except KeyError as e:
print(f"Error: Missing required key {e}", file=sys.stderr)
return 1
# Generate decryption key and IV
key_part = full_token[16:32]
if args.verbose:
print("=" * 70)
print("Printer Configuration Decryption")
print("=" * 70)
print(f"Input data file: {args.ctrl}")
print(f"Input info file: {args.info}")
print(f"Output file: {args.output}")
print()
print("Decryption Parameters:")
print(f" Encrypted data length: {len(encrypted_info)} bytes")
print(f" Full token: {full_token}")
print(f" Full token length: {len(full_token)} characters")
print(f" Response token (IV): {response_token}")
print(f" Decryption key: {key_part}")
print(f" Key length: {len(key_part)} characters")
print(f" IV length: {len(response_token)} characters")
print()
# Decrypt
if args.verbose:
print("Decrypting...")
result = decrypt_text(encrypted_info, key_part, response_token)
if 'error' in result:
print(f"Error during decryption: {result.get('error')}", file=sys.stderr)
return 1
# Pretty print result
if args.verbose:
print("=" * 70)
print("Decrypted Configuration:")
print("=" * 70)
print(json.dumps(result, indent=2))
print()
# Save to file
try:
with open(args.output, 'w') as f:
json.dump(result, f, indent=2)
if args.verbose:
print("=" * 70)
print(f"Successfully saved decrypted config to: {args.output}")
print("=" * 70)
else:
print(f"Decrypted config saved to: {args.output}")
except Exception as e:
print(f"Error writing to {args.output}: {e}", file=sys.stderr)
return 1
return 0
if __name__ == '__main__':
sys.exit(main())

View File

@@ -108,11 +108,17 @@ KLIPPER_VERSION = "v0.12.0-1"
def _parse_gcode_estimated_time(data: bytes) -> int:
"""Liest '; estimated printing time (normal mode) = Xh Ym Zs' aus GCode-Header.
Gibt Sekunden zurück, 0 wenn nicht gefunden. Sucht nur in den ersten 8KB."""
"""Liest geschätzte Druckzeit aus GCode (OrcaSlicer + PrusaSlicer).
Gibt Sekunden zurück, 0 wenn nicht gefunden.
PrusaSlicer schreibt die Zeit ins Header (erste 16KB),
OrcaSlicer schreibt sie ans Ende der Datei (letzte 16KB)."""
import re
header = data[:8192].decode("utf-8", errors="ignore")
m = re.search(r";\s*estimated printing time \(normal mode\)\s*=\s*(.*)", header)
# Anfang + Ende der Datei durchsuchen (OrcaSlicer schreibt Zeit am Ende)
search_text = (data[:16384] + data[-65536:]).decode("utf-8", errors="ignore")
# OrcaSlicer: ; total estimated time: 9m 20s
# PrusaSlicer: ; estimated printing time (normal mode) = 1h 9m 20s
m = (re.search(r";\s*total estimated time:\s*(.*)", search_text) or
re.search(r";\s*estimated printing time \(normal mode\)\s*=\s*(.*)", search_text))
if not m:
return 0
parts = re.findall(r"(\d+)\s*([hms])", m.group(1))
@@ -121,6 +127,8 @@ def _parse_gcode_estimated_time(data: bytes) -> int:
if unit == "h": secs += int(val) * 3600
elif unit == "m": secs += int(val) * 60
elif unit == "s": secs += int(val)
if secs:
log.info(f"Slicer-Schätzzeit: {secs}s ({m.group(1).strip()})")
return secs
@@ -192,6 +200,11 @@ class KobraXBridge:
if kobra_state in ("stoped", "canceled"):
self._state["progress"] = 0.0
self._state["filename"] = ""
self._state["file_ready"] = ""
self._state["print_duration"] = 0
self._state["remain_time"] = 0
self._state["slicer_time"] = 0
self._thumbnail_b64 = ""
self._state["filename"] = d.get("filename", self._state["filename"])
if "progress" in d:
self._state["progress"] = float(d["progress"]) / 100.0
@@ -869,6 +882,11 @@ main{flex:1;overflow-y:auto;padding:20px}
.spd-bar{height:4px;border-radius:2px;background:var(--border);margin-top:10px;overflow:hidden}
.spd-bar-fill{height:100%;border-radius:2px;background:linear-gradient(90deg,var(--accent2),var(--accent));transition:width .3s}
/* ── TIME CARDS ── */
.time-grid{display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px;margin-top:8px}
.time-block{background:var(--raised);border-radius:10px;padding:10px 12px}
.time-label{font-size:10px;text-transform:uppercase;letter-spacing:.08em;color:var(--txt2);margin-bottom:4px}
.time-val{font-size:20px;font-weight:700;font-family:var(--mono);color:var(--txt)}
/* ── TEMPS ── */
.temp-pair{display:grid;grid-template-columns:1fr 1fr;gap:12px}
.temp-card-inner{display:grid;grid-template-columns:1fr 1fr;gap:12px}
@@ -1202,14 +1220,26 @@ nav.bottom-nav{display:none;position:fixed;bottom:0;left:0;right:0;
<div class="card-title"><span>◉</span> <span id="d-card-progress">Fortschritt</span></div>
<img id="d-thumbnail" src="" alt="" style="display:none;width:100%;max-height:160px;object-fit:contain;border-radius:8px;background:#111;margin-bottom:10px">
<div class="pct-big"><span id="d-pct">0</span><small>%</small></div>
<div class="progress-bar" style="margin:8px 0"><div class="progress-fill" id="d-pbar" style="width:0%"></div></div>
<div class="meta-row" style="margin-top:6px">
<span id="d-elapsed"></span>
<span id="d-remain" style="color:var(--acc)"></span>
<span id="d-layers" class="layer-badge"></span>
<div style="display:flex;align-items:center;gap:10px;margin:8px 0">
<div class="progress-bar" style="flex:1;margin:0"><div class="progress-fill" id="d-pbar" style="width:0%"></div></div>
<div class="time-block" style="padding:6px 10px;min-width:72px;text-align:center;flex-shrink:0">
<div class="time-label" id="d-lbl-layers"></div>
<div class="time-val" style="font-size:16px" id="d-layers"></div>
</div>
</div>
<div class="time-grid">
<div class="time-block">
<div class="time-label" id="d-lbl-elapsed"></div>
<div class="time-val" id="d-elapsed"></div>
</div>
<div class="time-block" id="d-slicer-row" style="display:none">
<div class="time-label" id="d-slicer-label"></div>
<div class="time-val" id="d-slicer-time"></div>
</div>
<div class="time-block" style="color:var(--acc)">
<div class="time-label" id="d-lbl-remain"></div>
<div class="time-val" id="d-remain" style="color:var(--acc)"></div>
</div>
<div class="meta-row" style="margin-top:4px;font-size:0.82em;opacity:0.7" id="d-slicer-row">
<span id="d-slicer-label"></span><span id="d-slicer-time" style="margin-left:4px"></span>
</div>
<div class="fname" id="d-fname" title="" style="margin-top:6px"></div>
<div class="ctrl-btns" id="d-ctrl-btns" style="margin-top:12px">
@@ -1422,7 +1452,7 @@ var LANG_DE={
header_status_standby:'Bereit',header_status_printing:'Druckt',header_status_complete:'Fertig',header_status_error:'Fehler',
kobra_free:'Bereit',kobra_busy:'Beschäftigt',kobra_printing:'Druckt',kobra_preheating:'Aufheizen',kobra_auto_leveling:'Nivellierung',kobra_checking:'Prüfung',kobra_updated:'Aktualisierung',kobra_init:'Initialisierung',kobra_pausing:'Pausiert...',kobra_paused:'Pausiert',kobra_resuming:'Fortsetzen...',kobra_resumed:'Fortgesetzt',kobra_stopping:'Stoppt...',kobra_stoped:'Gestoppt',kobra_finished:'Abgeschlossen',kobra_failed:'Fehler',kobra_canceled:'Abgebrochen',kobra_offline:'Offline',
nav_dashboard:'Dashboard',nav_print:'Druck',nav_temps:'Temperaturen',nav_motion:'Achsen',nav_ams:'AMS',nav_extras:'Licht / Lüfter',nav_console:'Konsole',
card_progress:'Fortschritt',card_temps:'Temperaturen',card_light_fan:'Lüfter',card_speed:'Druckgeschwindigkeit',card_cam:'Kamera',lbl_elapsed:'Verstrichen',lbl_remaining:'verbleibend',lbl_slicer_time:'Slicer-Schätzung:',
card_progress:'Fortschritt',card_temps:'Temperaturen',card_light_fan:'Lüfter',card_speed:'Druckgeschwindigkeit',card_cam:'Kamera',lbl_elapsed:'Verstrichen:',lbl_remaining:'Restzeit:',lbl_slicer_time:'Slicer-Schätzung:',lbl_layers:'Layer',
speed_silent:'🐢 Leise',speed_normal:'⚡ Normal',speed_sport:'🚀 Sport',
lbl_light:'💡 Licht',lbl_feed:'Einziehen',lbl_unload:'Ausziehen',
cam_placeholder:'📷 Kamera nicht gestartet',btn_cam_start:'▶ Kamera',btn_cam_stop:'◼ Kamera',
@@ -1456,7 +1486,7 @@ var LANG_EN={
header_status_standby:'Ready',header_status_printing:'Printing',header_status_complete:'Complete',header_status_error:'Error',
kobra_free:'Ready',kobra_busy:'Busy',kobra_printing:'Printing',kobra_preheating:'Preheating',kobra_auto_leveling:'Auto Leveling',kobra_checking:'Checking',kobra_updated:'Updating',kobra_init:'Initializing',kobra_pausing:'Pausing...',kobra_paused:'Paused',kobra_resuming:'Resuming...',kobra_resumed:'Resumed',kobra_stopping:'Stopping...',kobra_stoped:'Stopped',kobra_finished:'Finished',kobra_failed:'Error',kobra_canceled:'Cancelled',kobra_offline:'Offline',
nav_dashboard:'Dashboard',nav_print:'Print',nav_temps:'Temperatures',nav_motion:'Motion',nav_ams:'AMS',nav_extras:'Light / Fan',nav_console:'Console',
card_progress:'Progress',card_temps:'Temperatures',card_light_fan:'Fan',card_speed:'Print Speed',card_cam:'Camera',lbl_elapsed:'Elapsed',lbl_remaining:'remaining',lbl_slicer_time:'Slicer estimate:',
card_progress:'Progress',card_temps:'Temperatures',card_light_fan:'Fan',card_speed:'Print Speed',card_cam:'Camera',lbl_elapsed:'Elapsed:',lbl_remaining:'Remaining:',lbl_slicer_time:'Slicer estimate:',lbl_layers:'Layer',
speed_silent:'🐢 Silent',speed_normal:'⚡ Normal',speed_sport:'🚀 Sport',
lbl_light:'💡 Light',lbl_feed:'Load',lbl_unload:'Unload',
cam_placeholder:'📷 Camera not started',btn_cam_start:'▶ Camera',btn_cam_stop:'◼ Camera',
@@ -1510,6 +1540,10 @@ function applyLang(){
setText('d-card-speed',T.card_speed);
setText('d-card-cam',T.card_cam);
setText('d-card-ams',T.panel_ams_title);
setText('d-lbl-elapsed',T.lbl_elapsed);
setText('d-lbl-remain',T.lbl_remaining);
setText('d-slicer-label',T.lbl_slicer_time);
setText('d-lbl-layers',T.lbl_layers);
setText('d-lbl-light',T.lbl_light);
setText('d-lbl-bed',T.label_bed);
// Dashboard buttons
@@ -1737,16 +1771,12 @@ function applyState(){
var layers=s.curr_layer&&s.total_layers?'L '+s.curr_layer+' / '+s.total_layers:'';
var dlayers=document.getElementById('d-layers');if(dlayers)dlayers.textContent=layers;
var elapsed=fmtTime(s.print_duration);
var delapsed=document.getElementById('d-elapsed');if(delapsed)delapsed.textContent=elapsed;
var remain=s.remain_time>0?''+fmtTime(s.remain_time)+' '+T.lbl_remaining:'';
var dremain=document.getElementById('d-remain');if(dremain)dremain.textContent=remain;
var delapsed=document.getElementById('d-elapsed');if(delapsed)delapsed.textContent=fmtTime(s.print_duration);
var dremain=document.getElementById('d-remain');if(dremain)dremain.textContent=s.remain_time>0?fmtTime(s.remain_time):'';
var dslrow=document.getElementById('d-slicer-row');
var dsltime=document.getElementById('d-slicer-time');
var dsllbl=document.getElementById('d-slicer-label');
if(dslrow&&dsltime){
if(s.slicer_time>0){dslrow.style.display='';if(dsllbl)dsllbl.textContent=T.lbl_slicer_time;dsltime.textContent=fmtTime(s.slicer_time);}
if(s.slicer_time>0){dslrow.style.display='';dsltime.textContent=fmtTime(s.slicer_time);}
else{dslrow.style.display='none';}
}

View File

@@ -1,2 +1,4 @@
aiohttp>=3.9
imageio-ffmpeg>=0.4.9
requests>=2.30.0
pycryptodome>=3.20.0

View File

@@ -15,6 +15,15 @@ if [[ ! -f .env ]]; then
fi
fi
# config/ Verzeichnis und config.ini.example anlegen falls nicht vorhanden
mkdir -p config
if [[ ! -f config/config.ini ]] && [[ ! -f config/config.ini.example ]]; then
if [[ -f config.ini.example ]]; then
cp config.ini.example config/config.ini.example
echo "[start] config/config.ini.example aus config.ini.example erstellt"
fi
fi
# Docker verfügbar?
if ! docker info > /dev/null 2>&1; then
echo "[start] Docker nicht gefunden bitte Docker installieren."