Compare commits

..

45 Commits

Author SHA1 Message Date
7d0231c004 Handle submit API response safely and avoid 500 crash 2026-04-12 18:44:48 +02:00
0fe0e436aa Update docker-compose.yml 2026-04-12 16:43:36 +02:00
6d103d42c5 Merge pull request #19 from DasPoschi/claude/audit-security-performance-pWwx2
Add security hardening and XSS protection
2026-04-06 09:49:22 +02:00
Claude
a879543a1c Security audit: fix XSS, missing function, improve SSH & URL handling
- Fix XSS: HTML-escape all user input (URLs, package names, errors, proxy data)
- Fix NameError: add missing is_demo_link() function (called but undefined)
- Fix: remove unused http_in fetch in proxies_get()
- Security: mask API keys in log output (TMDB key no longer visible in logs)
- Security: use known_hosts for SSH host key verification when available
- Security: remove .env from git tracking, add .env.example template
- Usability: add URL reachability check before submitting to JDownloader
- Usability: add "Erledigte Jobs entfernen" button to clear finished/failed jobs
- Usability: color-code job status (red for failed, green for finished)
- Docs: add security section to README (known_hosts, HTTPS, .env)

https://claude.ai/code/session_01S774Pqazr2U8vkSyhUBgDs
2026-04-06 07:46:53 +00:00
44e4354d1f Merge pull request #18 from DasPoschi/codex/fix-jdownloader-api-package-removal-error-54zoo0
Detect demo link downloads and fail early
2026-01-21 21:25:03 +01:00
f87f0f5cdc Merge branch 'main' into codex/fix-jdownloader-api-package-removal-error-54zoo0 2026-01-21 21:23:26 +01:00
68353b33aa Detect demo link downloads and fail early 2026-01-21 21:22:59 +01:00
c3b1fcadfa Merge pull request #17 from DasPoschi/codex/fix-jdownloader-api-package-removal-error
Add raw MyJDownloader API fallback for removing/canceling links
2026-01-21 21:09:25 +01:00
25ad8c05d0 Add raw API cleanup fallback for JDownloader 2026-01-21 21:08:48 +01:00
b65cb53463 Merge pull request #16 from DasPoschi/codex/fetch-proxies-from-proxyscrape-api-4xe4oq
Remove proxy blacklist and HTTP proxy handling; use ProxyScrape SOCKS lists
2026-01-04 14:46:16 +01:00
6c13fbbb2f Merge branch 'main' into codex/fetch-proxies-from-proxyscrape-api-4xe4oq 2026-01-04 14:46:06 +01:00
33282ddbcb Remove proxy blacklist filters 2026-01-04 14:45:44 +01:00
7795e22744 Merge pull request #15 from DasPoschi/codex/fetch-proxies-from-proxyscrape-api-4vaqb3
Remove HTTP proxies from proxy UI
2026-01-04 14:27:14 +01:00
e83f1323cd Merge branch 'main' into codex/fetch-proxies-from-proxyscrape-api-4vaqb3 2026-01-04 14:27:07 +01:00
194b16e09c Remove HTTP proxies from UI 2026-01-04 14:26:36 +01:00
423e8e28ec Merge pull request #14 from DasPoschi/codex/fetch-proxies-from-proxyscrape-api
Use ProxyScrape API for SOCKS lists and normalize single-line responses
2026-01-04 14:20:54 +01:00
daeee039fa Update proxy sources for socks lists 2026-01-04 14:20:38 +01:00
97a5afbee9 Update app.py 2026-01-03 23:09:10 +01:00
a2de578087 Merge pull request #13 from DasPoschi/codex/configure-proxies-for-downloads-only-r44dl5
Add *.your-server.de to proxy blacklist
2026-01-03 23:04:38 +01:00
c3aac479fe Merge branch 'main' into codex/configure-proxies-for-downloads-only-r44dl5 2026-01-03 22:56:21 +01:00
1350b50199 Add your-server.de to proxy blacklist 2026-01-03 22:55:54 +01:00
6b06134edf Merge pull request #12 from DasPoschi/codex/configure-proxies-for-downloads-only
Bypass proxies for internal HTTP calls
2026-01-03 22:43:36 +01:00
be4785b04a Bypass proxies for non-download requests 2026-01-03 22:42:49 +01:00
db39f2b55e Merge pull request #11 from DasPoschi/codex/add-proxy-list-import-function-3g187r
Refresh jobs table via `/jobs` endpoint instead of full page reload
2026-01-01 22:23:09 +01:00
a0e7ed91c7 Update jobs progress without full reload 2026-01-01 22:22:41 +01:00
7443a0e0ca Merge pull request #10 from DasPoschi/codex/add-proxy-list-import-function-g3956j
Add JDProxies blacklist filters and save/export support
2026-01-01 22:12:53 +01:00
3cf7581797 Merge branch 'main' into codex/add-proxy-list-import-function-g3956j 2026-01-01 22:12:44 +01:00
e9ccb51f13 Add JDProxies blacklist filters 2026-01-01 22:12:10 +01:00
a549ba66ba Merge pull request #9 from DasPoschi/codex/add-proxy-list-import-function-q1akx1
Write JDProxies JSON export and add save endpoint/UI
2026-01-01 21:27:20 +01:00
f1267a46a1 Merge branch 'main' into codex/add-proxy-list-import-function-q1akx1 2026-01-01 21:27:13 +01:00
9baf87cc33 Write jdproxies JSON export format 2026-01-01 21:26:47 +01:00
d57948af82 Merge pull request #8 from DasPoschi/codex/add-proxy-list-import-function-p0zu8g
Export proxies as .jdproxies and add save endpoint/UI
2026-01-01 21:06:59 +01:00
93310e3d99 Merge branch 'main' into codex/add-proxy-list-import-function-p0zu8g 2026-01-01 21:06:52 +01:00
00c72a78d2 Export proxies as jdproxies file 2026-01-01 21:04:41 +01:00
2891466635 Merge pull request #7 from DasPoschi/codex/add-proxy-list-import-function
Add proxy export file support for JDownloader
2026-01-01 20:57:12 +01:00
de41769e5f Add proxy export to file for JDownloader 2026-01-01 20:56:54 +01:00
f9ba535c56 Merge pull request #6 from DasPoschi/codex/analysieren-und-beheben-von-fehlern-4xbqut
Fetch and prefill proxy lists from TheSpeedX on Proxies page
2026-01-01 20:35:46 +01:00
32f159c03a Merge branch 'main' into codex/analysieren-und-beheben-von-fehlern-4xbqut 2026-01-01 20:35:33 +01:00
30329ef72a Fetch proxy lists from upstream sources 2026-01-01 20:18:30 +01:00
1f5fa2e31d Merge pull request #5 from DasPoschi/codex/analysieren-und-beheben-von-fehlern-8ffk1k
Auto-refresh progress display
2026-01-01 20:08:58 +01:00
b42fda4852 Auto-refresh progress display 2026-01-01 20:08:34 +01:00
e8dfe28b0b Merge pull request #4 from DasPoschi/codex/analysieren-und-beheben-von-fehlern-g3are0
Add download cancel and progress display
2026-01-01 20:02:04 +01:00
0d4f030f2c Add download cancel and progress display 2026-01-01 20:01:46 +01:00
812cb08df7 Change jd-output volume from read-only to read-write 2026-01-01 19:19:31 +01:00
063c22ab86 Merge pull request #3 from DasPoschi/codex/analysieren-und-beheben-von-fehlern
Handle TMDB auth failures gracefully
2026-01-01 18:49:14 +01:00
5 changed files with 702 additions and 48 deletions

View File

@@ -53,3 +53,9 @@ BASIC_AUTH_PASS=CHANGE_ME
# ===== Polling ===== # ===== Polling =====
POLL_SECONDS=5 POLL_SECONDS=5
# ===== SSH host key verification (optional) =====
# Path to known_hosts file inside container. If present, strict host key
# checking is used. If absent, all host keys are accepted (less secure).
# Generate with: ssh-keyscan -p 22 192.168.1.1 > known_hosts
# SSH_KNOWN_HOSTS=/ssh/known_hosts

View File

@@ -11,7 +11,7 @@ Web GUI to:
## Files ## Files
- `docker-compose.yml` stack - `docker-compose.yml` stack
- `.env.example` copy to `.env` and fill values - `.env.example` copy to `.env` and fill in your values (**never commit `.env`!**)
- `jd-webgui/app.py` FastAPI web app - `jd-webgui/app.py` FastAPI web app
- `jd-webgui/Dockerfile` includes ffprobe - `jd-webgui/Dockerfile` includes ffprobe
@@ -40,6 +40,16 @@ docker compose up -d --build
- If `MYJD_DEVICE` is empty, the WebGUI will automatically pick the first available device. - If `MYJD_DEVICE` is empty, the WebGUI will automatically pick the first available device.
- Ensure the SSH user can write to `/jellyfin/Filme` (and series dir if used). - Ensure the SSH user can write to `/jellyfin/Filme` (and series dir if used).
## Security
- **Never commit `.env`** it contains passwords and API keys. Only `.env.example` is tracked.
- **SSH host key verification**: For secure SFTP transfers, provide a `known_hosts` file:
```bash
ssh-keyscan -p 22 192.168.1.1 > known_hosts
```
Mount it in `docker-compose.yml` and set `SSH_KNOWN_HOSTS=/ssh/known_hosts`.
Without it, any host key is accepted (MITM risk on untrusted networks).
- **Basic Auth** protects the WebGUI but transmits credentials in cleartext over HTTP. Use a reverse proxy with HTTPS (e.g. Traefik, Caddy) in production.
## Troubleshooting ## Troubleshooting
- Device not found: list devices - Device not found: list devices
```bash ```bash

View File

@@ -21,11 +21,9 @@ services:
- jdownloader - jdownloader
ports: ports:
- "8080:8080" - "8080:8080"
env_file:
- .env
environment: environment:
TZ: Europe/Berlin TZ: Europe/Berlin
volumes: volumes:
- ./data/jd-output:/output:ro - ./data/jd-output:/output:rw
- ./data/md5:/md5:rw - ./data/md5:/md5:rw
- /root/.ssh/id_ed25519:/ssh/id_ed25519:ro - /root/.ssh/id_ed25519:/ssh/id_ed25519:ro

View File

@@ -3,6 +3,7 @@ from __future__ import annotations
import base64 import base64
import hashlib import hashlib
import html as html_mod
import json import json
import os import os
import re import re
@@ -18,7 +19,7 @@ from typing import Any, Dict, List, Optional, Tuple
from myjdapi import Myjdapi from myjdapi import Myjdapi
import paramiko import paramiko
from fastapi import FastAPI, Form, Request from fastapi import FastAPI, Form, Request
from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
# ============================================================ # ============================================================
@@ -56,9 +57,23 @@ POLL_SECONDS = float(os.environ.get("POLL_SECONDS", "5"))
# JDownloader writes here inside container # JDownloader writes here inside container
JD_OUTPUT_PATH = "/output" JD_OUTPUT_PATH = "/output"
PROXY_EXPORT_PATH = os.environ.get("PROXY_EXPORT_PATH", "/output/jd-proxies.jdproxies")
LOG_BUFFER_LIMIT = int(os.environ.get("LOG_BUFFER_LIMIT", "500"))
URL_RE = re.compile(r"^https?://", re.I) URL_RE = re.compile(r"^https?://", re.I)
NO_PROXY_OPENER = urllib.request.build_opener(urllib.request.ProxyHandler({}))
def esc(s: str) -> str:
"""HTML-escape a string to prevent XSS."""
return html_mod.escape(str(s), quote=True)
def mask_secret(value: str, visible: int = 4) -> str:
"""Mask a secret string, showing only the last `visible` characters."""
if len(value) <= visible:
return "***"
return "***" + value[-visible:]
VIDEO_EXTS = { VIDEO_EXTS = {
".mkv", ".mp4", ".m4v", ".avi", ".mov", ".wmv", ".flv", ".webm", ".mkv", ".mp4", ".m4v", ".avi", ".mov", ".wmv", ".flv", ".webm",
".ts", ".m2ts", ".mts", ".mpg", ".mpeg", ".vob", ".ogv", ".ts", ".m2ts", ".mts", ".mpg", ".mpeg", ".vob", ".ogv",
@@ -117,9 +132,26 @@ class Job:
library: str # movies|series|auto library: str # movies|series|auto
status: str # queued|collecting|downloading|upload|finished|failed status: str # queued|collecting|downloading|upload|finished|failed
message: str message: str
progress: float = 0.0
cancel_requested: bool = False
jobs: Dict[str, Job] = {} jobs: Dict[str, Job] = {}
lock = threading.Lock() lock = threading.Lock()
log_lock = threading.Lock()
connection_logs: List[str] = []
def log_connection(message: str) -> None:
timestamp = time.strftime("%Y-%m-%d %H:%M:%S")
line = f"[{timestamp}] {message}"
with log_lock:
connection_logs.append(line)
if len(connection_logs) > LOG_BUFFER_LIMIT:
excess = len(connection_logs) - LOG_BUFFER_LIMIT
del connection_logs[:excess]
def get_connection_logs() -> str:
with log_lock:
return "\n".join(connection_logs)
# ============================================================ # ============================================================
# Core helpers # Core helpers
@@ -146,6 +178,7 @@ def ensure_env():
def get_device(): def get_device():
jd = Myjdapi() jd = Myjdapi()
log_connection(f"MyJDownloader connect as {MYJD_EMAIL or 'unknown'}")
jd.connect(MYJD_EMAIL, MYJD_PASSWORD) jd.connect(MYJD_EMAIL, MYJD_PASSWORD)
wanted = (MYJD_DEVICE or "").strip() wanted = (MYJD_DEVICE or "").strip()
@@ -197,6 +230,29 @@ def is_video_file(path: str) -> bool:
return False return False
return ext in VIDEO_EXTS return ext in VIDEO_EXTS
DEMO_PATTERNS = {"big_buck_bunny", "bigbuckbunny", "big buck bunny", "bbb_sunflower"}
def is_demo_link(name: str) -> bool:
"""Detect JDownloader demo/fallback videos (e.g. Big Buck Bunny)."""
lower = name.lower().replace("-", "_").replace(".", " ")
return any(pat in lower for pat in DEMO_PATTERNS)
def check_url_reachable(url: str) -> Optional[str]:
"""Try a HEAD request to verify the URL is reachable. Returns error string or None."""
try:
req = urllib.request.Request(url, method="HEAD")
req.add_header("User-Agent", "Mozilla/5.0")
with urllib.request.urlopen(req, timeout=10) as resp:
if resp.status >= 400:
return f"URL antwortet mit HTTP {resp.status}"
except urllib.error.HTTPError as e:
return f"URL nicht erreichbar: HTTP {e.code}"
except urllib.error.URLError as e:
return f"URL nicht erreichbar: {e.reason}"
except Exception as e:
return f"URL-Check fehlgeschlagen: {e}"
return None
def md5_file(path: str) -> str: def md5_file(path: str) -> str:
h = hashlib.md5() h = hashlib.md5()
with open(path, "rb") as f: with open(path, "rb") as f:
@@ -205,12 +261,24 @@ def md5_file(path: str) -> str:
return h.hexdigest() return h.hexdigest()
def write_md5_sidecar(file_path: str, md5_hex: str) -> str: def write_md5_sidecar(file_path: str, md5_hex: str) -> str:
os.makedirs(MD5_DIR, exist_ok=True)
base = os.path.basename(file_path) base = os.path.basename(file_path)
md5_path = os.path.join(MD5_DIR, base + ".md5") candidates = [MD5_DIR, "/tmp/md5"]
last_err: Optional[Exception] = None
for target in candidates:
try:
os.makedirs(target, exist_ok=True)
md5_path = os.path.join(target, base + ".md5")
with open(md5_path, "w", encoding="utf-8") as f: with open(md5_path, "w", encoding="utf-8") as f:
f.write(f"{md5_hex} {base}\n") f.write(f"{md5_hex} {base}\n")
return md5_path return md5_path
except PermissionError as exc:
last_err = exc
continue
if last_err:
raise last_err
raise RuntimeError("Failed to write MD5 sidecar file.")
def ffprobe_ok(path: str) -> bool: def ffprobe_ok(path: str) -> bool:
try: try:
@@ -228,9 +296,17 @@ def ffprobe_ok(path: str) -> bool:
# ============================================================ # ============================================================
# SSH/SFTP # SSH/SFTP
# ============================================================ # ============================================================
SSH_KNOWN_HOSTS = os.environ.get("SSH_KNOWN_HOSTS", "/ssh/known_hosts")
def ssh_connect() -> paramiko.SSHClient: def ssh_connect() -> paramiko.SSHClient:
ssh = paramiko.SSHClient() ssh = paramiko.SSHClient()
if os.path.isfile(SSH_KNOWN_HOSTS):
ssh.load_host_keys(SSH_KNOWN_HOSTS)
ssh.set_missing_host_key_policy(paramiko.RejectPolicy())
log_connection(f"SSH connect {JELLYFIN_USER}@{JELLYFIN_HOST}:{JELLYFIN_PORT} (known_hosts verified)")
else:
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
log_connection(f"SSH connect {JELLYFIN_USER}@{JELLYFIN_HOST}:{JELLYFIN_PORT} (WARNING: no known_hosts, accepting any host key)")
ssh.connect( ssh.connect(
hostname=JELLYFIN_HOST, hostname=JELLYFIN_HOST,
port=JELLYFIN_PORT, port=JELLYFIN_PORT,
@@ -253,6 +329,7 @@ def sftp_mkdirs(sftp: paramiko.SFTPClient, remote_dir: str):
def sftp_upload(ssh: paramiko.SSHClient, local_path: str, remote_path: str): def sftp_upload(ssh: paramiko.SSHClient, local_path: str, remote_path: str):
sftp = ssh.open_sftp() sftp = ssh.open_sftp()
try: try:
log_connection(f"SFTP upload {local_path} -> {remote_path}")
sftp_mkdirs(sftp, os.path.dirname(remote_path)) sftp_mkdirs(sftp, os.path.dirname(remote_path))
sftp.put(local_path, remote_path) sftp.put(local_path, remote_path)
finally: finally:
@@ -261,6 +338,7 @@ def sftp_upload(ssh: paramiko.SSHClient, local_path: str, remote_path: str):
def remote_md5sum(ssh: paramiko.SSHClient, remote_path: str) -> str: def remote_md5sum(ssh: paramiko.SSHClient, remote_path: str) -> str:
quoted = shlex.quote(remote_path) quoted = shlex.quote(remote_path)
cmd = f"md5sum {quoted}" cmd = f"md5sum {quoted}"
log_connection(f"SSH exec {cmd}")
stdin, stdout, stderr = ssh.exec_command(cmd, timeout=120) stdin, stdout, stderr = ssh.exec_command(cmd, timeout=120)
out = stdout.read().decode("utf-8", "replace").strip() out = stdout.read().decode("utf-8", "replace").strip()
err = stderr.read().decode("utf-8", "replace").strip() err = stderr.read().decode("utf-8", "replace").strip()
@@ -273,9 +351,20 @@ def remote_md5sum(ssh: paramiko.SSHClient, remote_path: str) -> str:
# ============================================================ # ============================================================
# TMDB & naming # TMDB & naming
# ============================================================ # ============================================================
def _sanitize_url_for_log(url: str) -> str:
"""Remove sensitive query params (api_key) from URLs before logging."""
parsed = urllib.parse.urlparse(url)
params = urllib.parse.parse_qs(parsed.query, keep_blank_values=True)
for key in ("api_key", "apikey", "token"):
if key in params:
params[key] = ["***"]
safe_query = urllib.parse.urlencode(params, doseq=True)
return urllib.parse.urlunparse(parsed._replace(query=safe_query))
def _http_get_json(url: str, headers: Optional[Dict[str, str]] = None) -> Any: def _http_get_json(url: str, headers: Optional[Dict[str, str]] = None) -> Any:
req = urllib.request.Request(url, headers=headers or {}) req = urllib.request.Request(url, headers=headers or {})
with urllib.request.urlopen(req, timeout=20) as r: log_connection(f"HTTP GET {_sanitize_url_for_log(url)} (no-proxy)")
with NO_PROXY_OPENER.open(req, timeout=20) as r:
return json.loads(r.read().decode("utf-8", "replace")) return json.loads(r.read().decode("utf-8", "replace"))
def tmdb_search_movie(query: str) -> Optional[Dict[str, Any]]: def tmdb_search_movie(query: str) -> Optional[Dict[str, Any]]:
@@ -307,6 +396,124 @@ def sanitize_name(name: str) -> str:
out = "".join("_" if c in bad else c for c in name).strip() out = "".join("_" if c in bad else c for c in name).strip()
return re.sub(r"\s+", " ", out) return re.sub(r"\s+", " ", out)
def format_proxy_lines(raw: str, scheme: str) -> str:
"""
Takes raw lines (ip:port or scheme://ip:port) and outputs normalized lines:
scheme://ip:port (one per line). Ignores empty lines and comments.
"""
scheme = scheme.strip().lower()
if scheme not in {"socks5", "socks4", "http"}:
raise ValueError("Unsupported proxy scheme")
out = []
for line in (raw or "").splitlines():
s = line.strip()
if not s or s.startswith("#"):
continue
if "://" in s:
s = s.split("://", 1)[1].strip()
if ":" not in s:
continue
host, port = s.rsplit(":", 1)
host = host.strip()
port = port.strip()
if not host or not port.isdigit():
continue
out.append(f"{scheme}://{host}:{port}")
seen = set()
dedup = []
for x in out:
if x not in seen:
seen.add(x)
dedup.append(x)
return "\n".join(dedup)
def fetch_proxy_list(url: str) -> str:
req = urllib.request.Request(url)
log_connection(f"HTTP GET {url} (no-proxy)")
with NO_PROXY_OPENER.open(req, timeout=20) as resp:
text = resp.read().decode("utf-8", "replace")
if "\n" not in text and re.search(r"\s", text):
return re.sub(r"\s+", "\n", text.strip())
return text
def build_jdproxies_payload(text: str) -> Dict[str, Any]:
if not text.strip():
raise ValueError("Keine Proxy-Einträge zum Speichern.")
entries: List[Dict[str, Any]] = []
type_map = {
"socks5": "SOCKS5",
"socks4": "SOCKS4",
"http": "HTTP",
}
entries.append({
"filter": None,
"proxy": {
"address": None,
"password": None,
"port": 80,
"type": "NONE",
"username": None,
"connectMethodPrefered": False,
"preferNativeImplementation": False,
"resolveHostName": False,
},
"enabled": True,
"pac": False,
"rangeRequestsSupported": True,
"reconnectSupported": True,
})
for line in text.splitlines():
s = line.strip()
if not s:
continue
parsed = urllib.parse.urlparse(s)
if not parsed.scheme or not parsed.hostname or parsed.port is None:
continue
proxy_type = type_map.get(parsed.scheme.lower())
if not proxy_type:
continue
entries.append({
"filter": None,
"proxy": {
"address": parsed.hostname,
"password": None,
"port": int(parsed.port),
"type": proxy_type,
"username": None,
"connectMethodPrefered": False,
"preferNativeImplementation": False,
"resolveHostName": False,
},
"enabled": True,
"pac": False,
"rangeRequestsSupported": True,
"reconnectSupported": False,
})
if not entries:
raise ValueError("Keine gültigen Proxy-Einträge gefunden.")
return {"customProxyList": entries}
def save_proxy_export(text: str) -> str:
payload = build_jdproxies_payload(text)
export_path = PROXY_EXPORT_PATH
export_dir = os.path.dirname(export_path)
if export_dir:
os.makedirs(export_dir, exist_ok=True)
if os.path.exists(export_path):
os.remove(export_path)
with open(export_path, "w", encoding="utf-8") as handle:
handle.write(json.dumps(payload, indent=2))
handle.write("\n")
return export_path
def pick_library_target(library_choice: str, filename: str, package_name: str) -> str: def pick_library_target(library_choice: str, filename: str, package_name: str) -> str:
if library_choice not in {"movies", "series", "auto"}: if library_choice not in {"movies", "series", "auto"}:
library_choice = "auto" library_choice = "auto"
@@ -378,7 +585,8 @@ def jellyfin_refresh_library():
try: try:
url = JELLYFIN_API_BASE + path url = JELLYFIN_API_BASE + path
req = urllib.request.Request(url, headers=headers, method="POST") req = urllib.request.Request(url, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=20) as r: log_connection(f"HTTP POST {url} (no-proxy)")
with NO_PROXY_OPENER.open(req, timeout=20) as r:
_ = r.read() _ = r.read()
return return
except Exception: except Exception:
@@ -395,6 +603,10 @@ def query_links_and_packages(dev, jobid: str) -> Tuple[List[Dict[str, Any]], Dic
"name": True, "name": True,
"finished": True, "finished": True,
"running": True, "running": True,
"bytesLoaded": True,
"bytesTotal": True,
"bytes": True,
"totalBytes": True,
"status": True, "status": True,
"packageUUID": True, "packageUUID": True,
"uuid": True, "uuid": True,
@@ -431,6 +643,31 @@ def local_paths_from_links(links: List[Dict[str, Any]], pkg_map: Dict[Any, Dict[
out.append(p) out.append(p)
return out return out
def call_raw_jd_api(dev, endpoints: List[str], payloads: List[Dict[str, Any]]) -> bool:
method_candidates = ["action", "call", "api", "request"]
for method_name in method_candidates:
method = getattr(dev, method_name, None)
if method is None:
continue
for endpoint in endpoints:
for payload in payloads:
try:
method(endpoint, payload)
return True
except TypeError:
try:
method(endpoint, params=payload)
return True
except TypeError:
try:
method(endpoint, data=payload)
return True
except Exception:
continue
except Exception:
continue
return False
def try_remove_from_jd(dev, links: List[Dict[str, Any]], pkg_map: Dict[Any, Dict[str, Any]]) -> Optional[str]: def try_remove_from_jd(dev, links: List[Dict[str, Any]], pkg_map: Dict[Any, Dict[str, Any]]) -> Optional[str]:
link_ids = [l.get("uuid") for l in links if l.get("uuid") is not None] link_ids = [l.get("uuid") for l in links if l.get("uuid") is not None]
pkg_ids = list(pkg_map.keys()) pkg_ids = list(pkg_map.keys())
@@ -465,8 +702,104 @@ def try_remove_from_jd(dev, links: List[Dict[str, Any]], pkg_map: Dict[Any, Dict
except Exception: except Exception:
continue continue
endpoint_candidates = [
"downloads/removeLinks",
"downloadsV2/removeLinks",
"downloadcontroller/removeLinks",
]
if call_raw_jd_api(dev, endpoint_candidates, payloads):
return None
return "JDownloader-API: Paket/Links konnten nicht entfernt werden (Wrapper-Methoden nicht vorhanden)." return "JDownloader-API: Paket/Links konnten nicht entfernt werden (Wrapper-Methoden nicht vorhanden)."
def try_cancel_from_jd(dev, links: List[Dict[str, Any]], pkg_map: Dict[Any, Dict[str, Any]]) -> Optional[str]:
link_ids = [l.get("uuid") for l in links if l.get("uuid") is not None]
pkg_ids = list(pkg_map.keys())
candidates = [
("downloads", "removeLinks"),
("downloads", "remove_links"),
("downloads", "deleteLinks"),
("downloads", "delete_links"),
("downloadcontroller", "removeLinks"),
("downloadcontroller", "remove_links"),
]
payloads = [
{"linkUUIDs": link_ids, "packageUUIDs": pkg_ids, "deleteFiles": True},
{"linkIds": link_ids, "packageIds": pkg_ids, "deleteFiles": True},
{"linkUUIDs": link_ids, "deleteFiles": True},
{"packageUUIDs": pkg_ids, "deleteFiles": True},
{"linkUUIDs": link_ids, "packageUUIDs": pkg_ids, "removeFiles": True},
{"linkIds": link_ids, "packageIds": pkg_ids, "removeFiles": True},
]
for ns, fn in candidates:
obj = getattr(dev, ns, None)
if obj is None:
continue
meth = getattr(obj, fn, None)
if meth is None:
continue
for payload in payloads:
try:
meth([payload])
return None
except Exception:
continue
endpoint_candidates = [
"downloads/removeLinks",
"downloadsV2/removeLinks",
"downloadcontroller/removeLinks",
]
if call_raw_jd_api(dev, endpoint_candidates, payloads):
return None
return "JDownloader-API: Abbrechen fehlgeschlagen (Wrapper-Methoden nicht vorhanden)."
def cancel_job(dev, jobid: str) -> Optional[str]:
links, pkg_map = query_links_and_packages(dev, jobid)
local_paths = local_paths_from_links(links, pkg_map)
for path in local_paths:
try:
if os.path.isfile(path):
os.remove(path)
except Exception:
pass
try:
sidecar = os.path.join(MD5_DIR, os.path.basename(path) + ".md5")
if os.path.isfile(sidecar):
os.remove(sidecar)
except Exception:
pass
return try_cancel_from_jd(dev, links, pkg_map)
def calculate_progress(links: List[Dict[str, Any]]) -> float:
total = 0
loaded = 0
for link in links:
bytes_total = link.get("bytesTotal")
if bytes_total is None:
bytes_total = link.get("totalBytes")
if bytes_total is None:
bytes_total = link.get("bytes")
bytes_loaded = link.get("bytesLoaded")
if bytes_total is None or bytes_loaded is None:
continue
try:
bytes_total = int(bytes_total)
bytes_loaded = int(bytes_loaded)
except (TypeError, ValueError):
continue
if bytes_total <= 0:
continue
total += bytes_total
loaded += min(bytes_loaded, bytes_total)
if total <= 0:
return 0.0
return max(0.0, min(100.0, (loaded / total) * 100.0))
# ============================================================ # ============================================================
# Worker # Worker
# ============================================================ # ============================================================
@@ -480,6 +813,13 @@ def worker(jobid: str):
job = jobs.get(jobid) job = jobs.get(jobid)
if not job: if not job:
return return
if job.cancel_requested:
cancel_msg = cancel_job(dev, jobid)
with lock:
job.status = "canceled"
job.message = cancel_msg or "Download abgebrochen und Dateien entfernt."
job.progress = 0.0
return
links, pkg_map = query_links_and_packages(dev, jobid) links, pkg_map = query_links_and_packages(dev, jobid)
@@ -487,15 +827,28 @@ def worker(jobid: str):
with lock: with lock:
job.status = "collecting" job.status = "collecting"
job.message = "Warte auf Link-Crawler…" job.message = "Warte auf Link-Crawler…"
job.progress = 0.0
time.sleep(POLL_SECONDS) time.sleep(POLL_SECONDS)
continue continue
all_demo = all(is_demo_link(l.get("name", "")) for l in links)
if all_demo and not is_demo_link(job.url):
cancel_msg = cancel_job(dev, jobid)
with lock:
job.status = "failed"
base_msg = "JDownloader lieferte das Demo-Video Big Buck Bunny statt des gewünschten Links."
job.message = f"{base_msg} {cancel_msg}" if cancel_msg else base_msg
job.progress = 0.0
return
all_finished = all(bool(l.get("finished")) for l in links) all_finished = all(bool(l.get("finished")) for l in links)
if not all_finished: if not all_finished:
progress = calculate_progress(links)
with lock: with lock:
job.status = "downloading" job.status = "downloading"
done = sum(1 for l in links if l.get("finished")) done = sum(1 for l in links if l.get("finished"))
job.message = f"Download läuft… ({done}/{len(links)} fertig)" job.message = f"Download läuft… ({done}/{len(links)} fertig)"
job.progress = progress
time.sleep(POLL_SECONDS) time.sleep(POLL_SECONDS)
continue continue
@@ -506,6 +859,7 @@ def worker(jobid: str):
with lock: with lock:
job.status = "failed" job.status = "failed"
job.message = "Keine Video-Datei gefunden (Whitelist)." job.message = "Keine Video-Datei gefunden (Whitelist)."
job.progress = 0.0
return return
valid_videos = [p for p in video_files if ffprobe_ok(p)] valid_videos = [p for p in video_files if ffprobe_ok(p)]
@@ -513,11 +867,13 @@ def worker(jobid: str):
with lock: with lock:
job.status = "failed" job.status = "failed"
job.message = "ffprobe: keine gültige Video-Datei." job.message = "ffprobe: keine gültige Video-Datei."
job.progress = 0.0
return return
with lock: with lock:
job.status = "upload" job.status = "upload"
job.message = f"Download fertig. MD5/Upload/Verify für {len(valid_videos)} Datei(en)…" job.message = f"Download fertig. MD5/Upload/Verify für {len(valid_videos)} Datei(en)…"
job.progress = 100.0
ssh = ssh_connect() ssh = ssh_connect()
try: try:
@@ -557,6 +913,7 @@ def worker(jobid: str):
with lock: with lock:
job.status = "finished" job.status = "finished"
job.message = "Upload + MD5 OK. " + (jd_cleanup_msg or "JDownloader: Paket/Links entfernt.") job.message = "Upload + MD5 OK. " + (jd_cleanup_msg or "JDownloader: Paket/Links entfernt.")
job.progress = 100.0
return return
except Exception as e: except Exception as e:
@@ -565,6 +922,7 @@ def worker(jobid: str):
if job: if job:
job.status = "failed" job.status = "failed"
job.message = str(e) job.message = str(e)
job.progress = 0.0
# ============================================================ # ============================================================
# Web # Web
@@ -573,23 +931,57 @@ def worker(jobid: str):
def favicon(): def favicon():
return HTMLResponse(status_code=204) return HTMLResponse(status_code=204)
def render_page(error: str = "") -> str: @app.get("/jobs", response_class=HTMLResponse)
def jobs_get():
return HTMLResponse(render_job_rows())
@app.get("/logs", response_class=HTMLResponse)
def logs_get():
return HTMLResponse(render_logs_page())
@app.get("/logs/data", response_class=PlainTextResponse)
def logs_data():
return PlainTextResponse(get_connection_logs())
def render_job_rows() -> str:
rows = "" rows = ""
with lock: with lock:
job_list = list(jobs.values())[::-1] job_list = list(jobs.values())[::-1]
for j in job_list: for j in job_list:
progress_pct = f"{j.progress:.1f}%"
progress_html = (
f"<div class='progress-row'>"
f"<progress value='{j.progress:.1f}' max='100'></progress>"
f"<span class='progress-text'>{progress_pct}</span>"
f"</div>"
)
cancel_html = ""
if j.status not in {"finished", "failed", "canceled"}:
cancel_html = (
f"<form method='post' action='/cancel/{esc(j.id)}' class='inline-form'>"
f"<button type='submit' class='danger'>Abbrechen</button>"
f"</form>"
)
status_class = "error" if j.status == "failed" else ("success" if j.status == "finished" else "")
rows += ( rows += (
f"<tr>" f"<tr>"
f"<td><code>{j.id}</code></td>" f"<td><code>{esc(j.id)}</code></td>"
f"<td style='max-width:560px; word-break:break-all;'>{j.url}</td>" f"<td style='max-width:560px; word-break:break-all;'>{esc(j.url)}</td>"
f"<td>{j.package_name}</td>" f"<td>{esc(j.package_name)}</td>"
f"<td>{j.library}</td>" f"<td>{esc(j.library)}</td>"
f"<td><b>{j.status}</b><br/><small>{j.message}</small></td>" f"<td><b class='{status_class}'>{esc(j.status)}</b><br/><small>{esc(j.message)}</small>{progress_html}{cancel_html}</td>"
f"</tr>" f"</tr>"
) )
err_html = f"<p class='error'>{error}</p>" if error else "" if not rows:
rows = "<tr><td colspan='5'><em>No jobs yet.</em></td></tr>"
return rows
def render_page(error: str = "") -> str:
rows = render_job_rows()
err_html = f"<p class='error'>{esc(error)}</p>" if error else ""
auth_note = "aktiv" if _auth_enabled() else "aus" auth_note = "aktiv" if _auth_enabled() else "aus"
return f""" return f"""
<html> <html>
@@ -597,9 +989,24 @@ def render_page(error: str = "") -> str:
<link rel="stylesheet" href="/static/style.css"> <link rel="stylesheet" href="/static/style.css">
<meta charset="utf-8"> <meta charset="utf-8">
<title>JD → Jellyfin</title> <title>JD → Jellyfin</title>
<script>
async function refreshJobs() {{
if (document.hidden) return;
try {{
const resp = await fetch('/jobs');
if (!resp.ok) return;
const html = await resp.text();
const tbody = document.getElementById('jobs-body');
if (tbody) tbody.innerHTML = html;
}} catch (e) {{
}}
}}
setInterval(refreshJobs, 5000);
</script>
</head> </head>
<body> <body>
<h1>JD → Jellyfin</h1> <h1>JD → Jellyfin</h1>
{render_nav("downloads")}
{err_html} {err_html}
<form method="post" action="/submit"> <form method="post" action="/submit">
@@ -632,10 +1039,121 @@ def render_page(error: str = "") -> str:
<thead> <thead>
<tr><th>JobID</th><th>URL</th><th>Paket</th><th>Ziel</th><th>Status</th></tr> <tr><th>JobID</th><th>URL</th><th>Paket</th><th>Ziel</th><th>Status</th></tr>
</thead> </thead>
<tbody> <tbody id="jobs-body">
{rows if rows else "<tr><td colspan='5'><em>No jobs yet.</em></td></tr>"} {rows}
</tbody> </tbody>
</table> </table>
<form method="post" action="/clear-finished" style="margin-top:10px;">
<button type="submit" style="background:#666; color:#fff;">Erledigte Jobs entfernen</button>
</form>
</body>
</html>
"""
def render_nav(active: str) -> str:
def link(label: str, href: str, key: str) -> str:
style = "font-weight:700;" if active == key else ""
return f"<a href='{href}' style='margin-right:14px; {style}'>{label}</a>"
return (
"<div style='margin: 8px 0 14px 0;'>"
+ link("Downloads", "/", "downloads")
+ link("Proxies", "/proxies", "proxies")
+ link("Logs", "/logs", "logs")
+ "</div>"
)
def render_logs_page() -> str:
return f"""
<html>
<head>
<link rel="stylesheet" href="/static/style.css">
<meta charset="utf-8">
<title>JD → Jellyfin (Logs)</title>
<script>
async function refreshLogs() {{
if (document.hidden) return;
try {{
const resp = await fetch('/logs/data');
if (!resp.ok) return;
const text = await resp.text();
const area = document.getElementById('log-body');
if (area) {{
area.value = text;
area.scrollTop = area.scrollHeight;
}}
}} catch (e) {{
}}
}}
setInterval(refreshLogs, 2000);
window.addEventListener('load', refreshLogs);
</script>
</head>
<body>
<h1>JD → Jellyfin</h1>
{render_nav("logs")}
<p class="hint">Verbindungs-Debugger (Echtzeit). Letzte {LOG_BUFFER_LIMIT} Einträge.</p>
<textarea id="log-body" class="log-area" rows="20" readonly></textarea>
</body>
</html>
"""
def render_proxies_page(
error: str = "",
message: str = "",
socks5_in: str = "",
socks4_in: str = "",
out_text: str = "",
export_path: str = "",
) -> str:
err_html = f"<p class='error'>{esc(error)}</p>" if error else ""
msg_html = f"<p class='success'>{esc(message)}</p>" if message else ""
return f"""
<html>
<head>
<link rel="stylesheet" href="/static/style.css">
<meta charset="utf-8">
<title>JD → Jellyfin (Proxies)</title>
</head>
<body>
<h1>JD → Jellyfin</h1>
{render_nav("proxies")}
{err_html}
{msg_html}
<form method="post" action="/proxies">
<div class="row">
<label>SOCKS5 (ein Proxy pro Zeile, z. B. IP:PORT)</label><br/>
<textarea name="socks5_in" rows="6" style="width:100%; max-width:860px; padding:10px; border:1px solid #ccc; border-radius:8px;">{esc(socks5_in)}</textarea>
</div>
<div class="row">
<label>SOCKS4 (ein Proxy pro Zeile, z. B. IP:PORT)</label><br/>
<textarea name="socks4_in" rows="6" style="width:100%; max-width:860px; padding:10px; border:1px solid #ccc; border-radius:8px;">{esc(socks4_in)}</textarea>
</div>
<button type="submit">In JDownloader-Format umwandeln</button>
</form>
<h2 style="margin-top:18px;">JDownloader Import-Liste</h2>
<p class="hint">Format: <code>socks5://IP:PORT</code>, <code>socks4://IP:PORT</code>. Keine Prüfung/Validierung.</p>
<div class="row">
<textarea id="out" rows="12" readonly style="width:100%; max-width:860px; padding:10px; border:1px solid #ccc; border-radius:8px;">{esc(out_text)}</textarea>
</div>
<button type="button" onclick="navigator.clipboard.writeText(document.getElementById('out').value)">Kopieren</button>
<h2 style="margin-top:18px;">Datei für Connection Manager</h2>
<p class="hint">Speichert die Liste als <code>.jdproxies</code> im Container, z. B. zum Import in JDownloader → Verbindungsmanager → Importieren.</p>
<form method="post" action="/proxies/save">
<textarea name="socks5_in" style="display:none;">{esc(socks5_in)}</textarea>
<textarea name="socks4_in" style="display:none;">{esc(socks4_in)}</textarea>
<button type="submit">Liste als JDProxies speichern</button>
</form>
<p class="hint">Aktueller Pfad: <code>{esc(export_path or PROXY_EXPORT_PATH)}</code></p>
</body> </body>
</html> </html>
""" """
@@ -650,6 +1168,7 @@ def index():
@app.post("/submit") @app.post("/submit")
def submit(url: str = Form(...), package_name: str = Form(""), library: str = Form("auto")): def submit(url: str = Form(...), package_name: str = Form(""), library: str = Form("auto")):
try:
ensure_env() ensure_env()
url = url.strip() url = url.strip()
package_name = (package_name or "").strip() or "WebGUI" package_name = (package_name or "").strip() or "WebGUI"
@@ -658,6 +1177,11 @@ def submit(url: str = Form(...), package_name: str = Form(""), library: str = Fo
if not URL_RE.match(url): if not URL_RE.match(url):
return HTMLResponse(render_page("Nur http(s) URLs erlaubt."), status_code=400) return HTMLResponse(render_page("Nur http(s) URLs erlaubt."), status_code=400)
url_err = check_url_reachable(url)
if url_err:
log_connection(f"URL-Check fehlgeschlagen: {url} -> {url_err}")
return HTMLResponse(render_page(f"Link nicht erreichbar: {url_err}"), status_code=400)
dev = get_device() dev = get_device()
resp = dev.linkgrabber.add_links([{ resp = dev.linkgrabber.add_links([{
"links": url, "links": url,
@@ -666,9 +1190,18 @@ def submit(url: str = Form(...), package_name: str = Form(""), library: str = Fo
"packageName": package_name, "packageName": package_name,
}]) }])
jobid = str(resp.get("id", "")) jobid = ""
if isinstance(resp, dict):
jobid = str(resp.get("id", "")).strip()
elif isinstance(resp, (str, int)):
jobid = str(resp).strip()
elif isinstance(resp, list) and resp and isinstance(resp[0], dict):
jobid = str(resp[0].get("id", "")).strip()
if not jobid: if not jobid:
return HTMLResponse(render_page(f"Unerwartete Antwort von add_links: {resp}"), status_code=500) msg = f"Unerwartete Antwort von add_links (kein Job-ID): {resp!r}"
log_connection(msg)
return HTMLResponse(render_page(msg), status_code=502)
with lock: with lock:
jobs[jobid] = Job( jobs[jobid] = Job(
@@ -678,9 +1211,106 @@ def submit(url: str = Form(...), package_name: str = Form(""), library: str = Fo
library=library, library=library,
status="queued", status="queued",
message="Download gestartet", message="Download gestartet",
progress=0.0,
) )
t = threading.Thread(target=worker, args=(jobid,), daemon=True) t = threading.Thread(target=worker, args=(jobid,), daemon=True)
t.start() t.start()
return RedirectResponse(url="/", status_code=303) return RedirectResponse(url="/", status_code=303)
except Exception as e:
log_connection(f"Submit-Fehler: {e}")
return HTMLResponse(render_page(f"Interner Fehler beim Absenden: {e}"), status_code=500)
@app.post("/cancel/{jobid}")
def cancel(jobid: str):
with lock:
job = jobs.get(jobid)
if not job:
return RedirectResponse(url="/", status_code=303)
if job.status in {"finished", "failed", "canceled"}:
return RedirectResponse(url="/", status_code=303)
job.cancel_requested = True
job.message = "Abbruch angefordert…"
return RedirectResponse(url="/", status_code=303)
@app.post("/clear-finished")
def clear_finished():
with lock:
to_remove = [jid for jid, j in jobs.items() if j.status in {"finished", "failed", "canceled"}]
for jid in to_remove:
del jobs[jid]
return RedirectResponse(url="/", status_code=303)
@app.get("/proxies", response_class=HTMLResponse)
def proxies_get():
try:
socks5_in = fetch_proxy_list(
"https://api.proxyscrape.com/v4/free-proxy-list/get?request=displayproxies&protocol=socks5&timeout=10000&country=all&ssl=yes&anonymity=elite&skip=0&limit=2000"
)
socks4_in = fetch_proxy_list(
"https://api.proxyscrape.com/v4/free-proxy-list/get?request=displayproxies&protocol=socks4&timeout=10000&country=all&ssl=yes&anonymity=elite&skip=0&limit=2000"
)
s5 = format_proxy_lines(socks5_in, "socks5")
s4 = format_proxy_lines(socks4_in, "socks4")
combined = "\n".join([x for x in [s5, s4] if x.strip()])
return HTMLResponse(render_proxies_page(
socks5_in=socks5_in,
socks4_in=socks4_in,
out_text=combined,
export_path=PROXY_EXPORT_PATH,
))
except Exception as e:
return HTMLResponse(render_proxies_page(error=str(e)), status_code=502)
@app.post("/proxies", response_class=HTMLResponse)
def proxies_post(
socks5_in: str = Form(""),
socks4_in: str = Form(""),
):
try:
s5 = format_proxy_lines(socks5_in, "socks5")
s4 = format_proxy_lines(socks4_in, "socks4")
combined = "\n".join([x for x in [s5, s4] if x.strip()])
return HTMLResponse(render_proxies_page(
socks5_in=socks5_in,
socks4_in=socks4_in,
out_text=combined,
export_path=PROXY_EXPORT_PATH,
))
except Exception as e:
return HTMLResponse(render_proxies_page(
error=str(e),
socks5_in=socks5_in,
socks4_in=socks4_in,
out_text="",
export_path=PROXY_EXPORT_PATH,
), status_code=400)
@app.post("/proxies/save", response_class=HTMLResponse)
def proxies_save(
socks5_in: str = Form(""),
socks4_in: str = Form(""),
):
try:
s5 = format_proxy_lines(socks5_in, "socks5")
s4 = format_proxy_lines(socks4_in, "socks4")
combined = "\n".join([x for x in [s5, s4] if x.strip()])
export_path = save_proxy_export(combined)
return HTMLResponse(render_proxies_page(
message=f"Proxy-Liste gespeichert: {export_path}",
socks5_in=socks5_in,
socks4_in=socks4_in,
out_text=combined,
export_path=export_path,
))
except Exception as e:
return HTMLResponse(render_proxies_page(
error=str(e),
socks5_in=socks5_in,
socks4_in=socks4_in,
out_text="",
export_path=PROXY_EXPORT_PATH,
), status_code=400)

View File

@@ -4,9 +4,19 @@ form { background:#fff; border:1px solid #e5e5e5; border-radius:10px; padding:14
.row { margin-bottom: 10px; } .row { margin-bottom: 10px; }
input, select { padding:10px; border:1px solid #ccc; border-radius:8px; font-size:14px; width: 100%; max-width: 860px; } input, select { padding:10px; border:1px solid #ccc; border-radius:8px; font-size:14px; width: 100%; max-width: 860px; }
button { padding:10px 14px; border:0; border-radius:8px; font-weight:600; cursor:pointer; } button { padding:10px 14px; border:0; border-radius:8px; font-weight:600; cursor:pointer; }
button.danger { background:#b00020; color:#fff; }
progress { width: 100%; height: 12px; }
progress::-webkit-progress-bar { background:#f0f0f0; border-radius:8px; }
progress::-webkit-progress-value { background:#1b7f3a; border-radius:8px; }
progress::-moz-progress-bar { background:#1b7f3a; border-radius:8px; }
table { margin-top:16px; width:100%; border-collapse: collapse; background:#fff; border:1px solid #e5e5e5; border-radius:10px; overflow:hidden; } table { margin-top:16px; width:100%; border-collapse: collapse; background:#fff; border:1px solid #e5e5e5; border-radius:10px; overflow:hidden; }
th, td { border-top:1px solid #eee; padding:10px; vertical-align: top; font-size:14px; } th, td { border-top:1px solid #eee; padding:10px; vertical-align: top; font-size:14px; }
th { background:#fbfbfb; text-align:left; } th { background:#fbfbfb; text-align:left; }
code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; background:#f2f2f2; padding:2px 4px; border-radius:4px; } code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; background:#f2f2f2; padding:2px 4px; border-radius:4px; }
.hint { color:#555; font-size: 12px; margin-top: 10px; } .hint { color:#555; font-size: 12px; margin-top: 10px; }
.error { color:#b00020; font-weight: 700; } .error { color:#b00020; font-weight: 700; }
.success { color:#1b7f3a; font-weight: 700; }
.progress-row { display:flex; align-items:center; gap:8px; margin-top:6px; }
.progress-text { font-size:12px; color:#333; min-width:48px; }
.inline-form { margin-top:6px; }
.log-area { width:100%; max-width: 920px; padding:10px; border:1px solid #ccc; border-radius:8px; background:#fff; }