Enhance app.py with directory options and auth

Added support for separate movie and series directories, implemented basic authentication, and improved error handling.
This commit is contained in:
2025-12-30 22:00:27 +01:00
committed by GitHub
parent a6c3af46af
commit 87e8d987dc

View File

@@ -1,21 +1,26 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
from __future__ import annotations from __future__ import annotations
import base64
import hashlib
import os import os
import re import re
import time import shlex
import subprocess
import threading import threading
import hashlib import time
from dataclasses import dataclass from dataclasses import dataclass
from typing import Dict, List, Any, Optional, Tuple from typing import Any, Dict, List, Optional, Tuple
import myjdapi import myjdapi
import paramiko import paramiko
from fastapi import FastAPI, Form from fastapi import FastAPI, Form, Request
from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
# ---- ENV ---- # ============================================================
# Environment
# ============================================================
MYJD_EMAIL = os.environ.get("MYJD_EMAIL", "") MYJD_EMAIL = os.environ.get("MYJD_EMAIL", "")
MYJD_PASSWORD = os.environ.get("MYJD_PASSWORD", "") MYJD_PASSWORD = os.environ.get("MYJD_PASSWORD", "")
MYJD_DEVICE = os.environ.get("MYJD_DEVICE", "") MYJD_DEVICE = os.environ.get("MYJD_DEVICE", "")
@@ -23,43 +28,93 @@ MYJD_DEVICE = os.environ.get("MYJD_DEVICE", "")
JELLYFIN_HOST = os.environ.get("JELLYFIN_HOST", "192.168.1.1") JELLYFIN_HOST = os.environ.get("JELLYFIN_HOST", "192.168.1.1")
JELLYFIN_PORT = int(os.environ.get("JELLYFIN_PORT", "22")) JELLYFIN_PORT = int(os.environ.get("JELLYFIN_PORT", "22"))
JELLYFIN_USER = os.environ.get("JELLYFIN_USER", "") JELLYFIN_USER = os.environ.get("JELLYFIN_USER", "")
JELLYFIN_DEST_DIR = os.environ.get("JELLYFIN_DEST_DIR", "/srv/media/movies/inbox").rstrip("/")
JELLYFIN_SSH_KEY = os.environ.get("JELLYFIN_SSH_KEY", "/ssh/id_ed25519") JELLYFIN_SSH_KEY = os.environ.get("JELLYFIN_SSH_KEY", "/ssh/id_ed25519")
# Optional: getrennte Ziele für Filme/Serien
JELLYFIN_MOVIES_DIR = os.environ.get("JELLYFIN_MOVIES_DIR", "").rstrip("/")
JELLYFIN_SERIES_DIR = os.environ.get("JELLYFIN_SERIES_DIR", "").rstrip("/")
# Fallback-Ziel (wenn movies/series nicht gesetzt)
JELLYFIN_DEST_DIR = os.environ.get("JELLYFIN_DEST_DIR", "/srv/media/movies/inbox").rstrip("/")
# Auth (optional)
BASIC_AUTH_USER = os.environ.get("BASIC_AUTH_USER", "")
BASIC_AUTH_PASS = os.environ.get("BASIC_AUTH_PASS", "")
POLL_SECONDS = float(os.environ.get("POLL_SECONDS", "5")) POLL_SECONDS = float(os.environ.get("POLL_SECONDS", "5"))
# JD speichert im Container nach /output (wie von dir angegeben) # JDownloader speichert im Container nach /output
JD_OUTPUT_PATH = "/output" JD_OUTPUT_PATH = "/output"
URL_RE = re.compile(r"^https?://", re.I) URL_RE = re.compile(r"^https?://", re.I)
# “Gängige Videoformate” (Whitelist; bei Bedarf erweitern) # gängige Videoformate
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",
".3gp", ".3g2" ".3gp", ".3g2"
} }
# Optional: auch Container/Archive ignorieren
IGNORE_EXTS = {".part", ".tmp", ".crdownload"} IGNORE_EXTS = {".part", ".tmp", ".crdownload"}
# Serien-Heuristik (S01E02 etc.)
SERIES_RE = re.compile(r"(?:^|[^a-z0-9])S(\d{1,2})E(\d{1,2})(?:[^a-z0-9]|$)", re.IGNORECASE)
app = FastAPI() app = FastAPI()
app.mount("/static", StaticFiles(directory="static"), name="static") app.mount("/static", StaticFiles(directory="static"), name="static")
# ============================================================
# Basic Auth (optional)
# ============================================================
def _auth_enabled() -> bool:
return bool(BASIC_AUTH_USER and BASIC_AUTH_PASS)
def _check_basic_auth(req: Request) -> bool:
if not _auth_enabled():
return True
hdr = req.headers.get("authorization", "")
if not hdr.lower().startswith("basic "):
return False
b64 = hdr.split(" ", 1)[1].strip()
try:
raw = base64.b64decode(b64).decode("utf-8", "replace")
except Exception:
return False
if ":" not in raw:
return False
user, pw = raw.split(":", 1)
return user == BASIC_AUTH_USER and pw == BASIC_AUTH_PASS
def _auth_challenge() -> HTMLResponse:
return HTMLResponse(
"Authentication required",
status_code=401,
headers={"WWW-Authenticate": 'Basic realm="jd-webgui"'},
)
@app.middleware("http")
async def basic_auth_middleware(request: Request, call_next):
if not _check_basic_auth(request):
return _auth_challenge()
return await call_next(request)
# ============================================================
# Models / State
# ============================================================
@dataclass @dataclass
class Job: class Job:
id: str id: str
url: str url: str
package_name: str package_name: str
status: str library: str # movies|series|auto
status: str # queued|collecting|downloading|upload|finished|failed
message: str message: str
jobs: Dict[str, Job] = {} jobs: Dict[str, Job] = {}
lock = threading.Lock() lock = threading.Lock()
# ============================================================
# Helpers
# ============================================================
def ensure_env(): def ensure_env():
missing = [] missing = []
for k, v in [ for k, v in [
@@ -67,15 +122,16 @@ def ensure_env():
("MYJD_PASSWORD", MYJD_PASSWORD), ("MYJD_PASSWORD", MYJD_PASSWORD),
("MYJD_DEVICE", MYJD_DEVICE), ("MYJD_DEVICE", MYJD_DEVICE),
("JELLYFIN_USER", JELLYFIN_USER), ("JELLYFIN_USER", JELLYFIN_USER),
("JELLYFIN_DEST_DIR", JELLYFIN_DEST_DIR),
("JELLYFIN_SSH_KEY", JELLYFIN_SSH_KEY), ("JELLYFIN_SSH_KEY", JELLYFIN_SSH_KEY),
]: ]:
if not v: if not v:
missing.append(k) missing.append(k)
# Zielverzeichnisse: entweder MOVIES/SERIES oder DEST
if not (JELLYFIN_DEST_DIR or (JELLYFIN_MOVIES_DIR and JELLYFIN_SERIES_DIR)):
missing.append("JELLYFIN_DEST_DIR or (JELLYFIN_MOVIES_DIR+JELLYFIN_SERIES_DIR)")
if missing: if missing:
raise RuntimeError("Missing env vars: " + ", ".join(missing)) raise RuntimeError("Missing env vars: " + ", ".join(missing))
def get_device(): def get_device():
jd = myjdapi.myjdapi() jd = myjdapi.myjdapi()
jd.connect(MYJD_EMAIL, MYJD_PASSWORD) jd.connect(MYJD_EMAIL, MYJD_PASSWORD)
@@ -85,35 +141,42 @@ def get_device():
raise RuntimeError(f"MyJDownloader device not found: {MYJD_DEVICE}") raise RuntimeError(f"MyJDownloader device not found: {MYJD_DEVICE}")
return dev return dev
def is_video_file(path: str) -> bool:
name = os.path.basename(path).lower()
_, ext = os.path.splitext(name)
if ext in IGNORE_EXTS:
return False
return ext in VIDEO_EXTS
def md5_file(path: str, chunk_size: int = 1024 * 1024) -> 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:
while True: for chunk in iter(lambda: f.read(1024 * 1024), b""):
b = f.read(chunk_size) h.update(chunk)
if not b:
break
h.update(b)
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:
md5_path = file_path + ".md5" md5_path = file_path + ".md5"
with open(md5_path, "w", encoding="utf-8") as f: with open(md5_path, "w", encoding="utf-8") as f:
f.write(md5_hex + " " + os.path.basename(file_path) + "\n") f.write(f"{md5_hex} {os.path.basename(file_path)}\n")
return md5_path return md5_path
def ffprobe_ok(path: str) -> bool:
def sftp_mkdirs(sftp: paramiko.SFTPClient, remote_dir: str): """
parts = [p for p in remote_dir.split("/") if p] Validiert, dass die Datei wirklich ein Video ist (Container/Streams lesbar).
cur = "" Erfordert ffprobe im Container (kommt über Dockerfile).
for p in parts: """
cur += "/" + p
try: try:
sftp.stat(cur) cp = subprocess.run(
except IOError: ["ffprobe", "-v", "error", "-show_streams", "-select_streams", "v:0", path],
sftp.mkdir(cur) stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
timeout=60,
)
return cp.returncode == 0 and "codec_type=video" in (cp.stdout or "")
except Exception:
return False
def ssh_connect() -> paramiko.SSHClient: def ssh_connect() -> paramiko.SSHClient:
ssh = paramiko.SSHClient() ssh = paramiko.SSHClient()
@@ -127,6 +190,15 @@ def ssh_connect() -> paramiko.SSHClient:
) )
return ssh return ssh
def sftp_mkdirs(sftp: paramiko.SFTPClient, remote_dir: str):
parts = [p for p in remote_dir.split("/") if p]
cur = ""
for p in parts:
cur += "/" + p
try:
sftp.stat(cur)
except IOError:
sftp.mkdir(cur)
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()
@@ -136,29 +208,41 @@ def sftp_upload(ssh: paramiko.SSHClient, local_path: str, remote_path: str):
finally: finally:
sftp.close() sftp.close()
def remote_md5sum(ssh: paramiko.SSHClient, remote_path: str) -> str: def remote_md5sum(ssh: paramiko.SSHClient, remote_path: str) -> str:
# md5sum "<file>" | awk '{print $1}' quoted = shlex.quote(remote_path)
cmd = f"md5sum '{remote_path.replace(\"'\", \"'\\\\''\")}' | awk '{{print $1}}'" cmd = f"md5sum {quoted}"
stdin, stdout, stderr = ssh.exec_command(cmd, timeout=60) 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()
if not out or "No such file" in err: if err and not out:
raise RuntimeError(f"Remote md5sum failed. out='{out}' err='{err}'") raise RuntimeError(f"Remote md5sum failed: {err}")
# md5sum may return: "<hash> <file>" if not out:
# but awk ensures hash only. Still, keep first token safe: raise RuntimeError("Remote md5sum returned empty output")
return out.split()[0] return out.split()[0]
def pick_library_target(library_choice: str, filename: str, package_name: str) -> str:
"""
- library_choice: movies|series|auto
- auto: heuristic SxxEyy in filename or package
"""
if library_choice not in {"movies", "series", "auto"}:
library_choice = "auto"
def is_video_file(path: str) -> bool: if library_choice == "auto":
name = os.path.basename(path).lower() if SERIES_RE.search(filename) or SERIES_RE.search(package_name or ""):
_, ext = os.path.splitext(name) library_choice = "series"
if ext in IGNORE_EXTS: else:
return False library_choice = "movies"
return ext in VIDEO_EXTS
if library_choice == "movies" and JELLYFIN_MOVIES_DIR:
return JELLYFIN_MOVIES_DIR
if library_choice == "series" and JELLYFIN_SERIES_DIR:
return JELLYFIN_SERIES_DIR
def query_links_and_packages_by_jobid(dev, jobid: str) -> Tuple[List[Dict[str, Any]], Dict[Any, Dict[str, Any]]]: # fallback
return JELLYFIN_DEST_DIR
def query_links_and_packages(dev, jobid: str) -> Tuple[List[Dict[str, Any]], Dict[Any, Dict[str, Any]]]:
links = dev.downloads.query_links([{ links = dev.downloads.query_links([{
"jobUUIDs": [int(jobid)] if jobid.isdigit() else [jobid], "jobUUIDs": [int(jobid)] if jobid.isdigit() else [jobid],
"maxResults": -1, "maxResults": -1,
@@ -184,7 +268,6 @@ def query_links_and_packages_by_jobid(dev, jobid: str) -> Tuple[List[Dict[str, A
pkg_map = {p.get("uuid"): p for p in pkgs} pkg_map = {p.get("uuid"): p for p in pkgs}
return links, pkg_map return links, pkg_map
def local_paths_from_links(links: List[Dict[str, Any]], pkg_map: Dict[Any, Dict[str, Any]]) -> List[str]: def local_paths_from_links(links: List[Dict[str, Any]], pkg_map: Dict[Any, Dict[str, Any]]) -> List[str]:
paths: List[str] = [] paths: List[str] = []
for l in links: for l in links:
@@ -204,29 +287,28 @@ 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 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]:
""" """
Best effort removal. Different JD API versions / wrappers expose different method names. Best effort removal. Wrapper/API version differences exist.
We try a few. If none exist, return a message.
""" """
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())
# Try several known method names & payload styles
candidates = [ candidates = [
("downloads", "remove_links"),
("downloads", "removeLinks"), ("downloads", "removeLinks"),
("downloads", "delete_links"), ("downloads", "remove_links"),
("downloads", "deleteLinks"), ("downloads", "deleteLinks"),
("downloadcontroller", "remove_links"), ("downloads", "delete_links"),
("downloadcontroller", "removeLinks"), ("downloadcontroller", "removeLinks"),
("downloadcontroller", "remove_links"),
] ]
payload_variants = [ payloads = [
{"linkIds": link_ids, "packageIds": pkg_ids},
{"linkIds": link_ids},
{"packageIds": pkg_ids},
{"linkUUIDs": link_ids, "packageUUIDs": pkg_ids}, {"linkUUIDs": link_ids, "packageUUIDs": pkg_ids},
{"linkIds": link_ids, "packageIds": pkg_ids},
{"linkUUIDs": link_ids},
{"packageUUIDs": pkg_ids},
] ]
for ns, fn in candidates: for ns, fn in candidates:
@@ -236,81 +318,90 @@ def try_remove_from_jd(dev, links: List[Dict[str, Any]], pkg_map: Dict[Any, Dict
meth = getattr(obj, fn, None) meth = getattr(obj, fn, None)
if meth is None: if meth is None:
continue continue
for payload in payload_variants: for payload in payloads:
try: try:
meth([payload] if not isinstance(payload, list) else payload) # some wrappers expect list meth([payload]) # most wrappers expect list
return None return None
except Exception: except Exception:
continue continue
return "Could not remove package/links via API (method not available in this wrapper). Local files were deleted." return "JDownloader-API: Paket/Links konnten nicht entfernt werden (Wrapper-Methoden nicht vorhanden)."
# ============================================================
# Worker
# ============================================================
def worker(jobid: str): def worker(jobid: str):
try: try:
ensure_env() ensure_env()
dev = get_device() dev = get_device()
while True: while True:
links, pkg_map = query_links_and_packages_by_jobid(dev, jobid) with lock:
job = jobs.get(jobid)
if not job:
return
links, pkg_map = query_links_and_packages(dev, jobid)
if not links: if not links:
with lock: with lock:
jobs[jobid].status = "collecting" job.status = "collecting"
jobs[jobid].message = "Warte auf Link-Crawler…" job.message = "Warte auf Link-Crawler…"
time.sleep(POLL_SECONDS) time.sleep(POLL_SECONDS)
continue continue
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:
with lock: with lock:
jobs[jobid].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"))
jobs[jobid].message = f"Download läuft… ({done}/{len(links)} fertig)" job.message = f"Download läuft… ({done}/{len(links)} fertig)"
time.sleep(POLL_SECONDS) time.sleep(POLL_SECONDS)
continue continue
# Download finished -> build local file list
local_paths = local_paths_from_links(links, pkg_map) local_paths = local_paths_from_links(links, pkg_map)
# Filter to video files only video_files = [p for p in local_paths if is_video_file(p) and os.path.isfile(p)]
video_files = [p for p in local_paths if is_video_file(p)]
if not video_files: if not video_files:
with lock: with lock:
jobs[jobid].status = "failed" job.status = "failed"
jobs[jobid].message = "Keine Video-Datei gefunden (Whitelist)." job.message = "Keine Video-Datei gefunden (Whitelist)."
return return
# Compute local MD5 and write sidecar # ffprobe validation (only keep valid videos)
md5_records: List[Tuple[str, str, str]] = [] # (file, md5, md5file) valid_videos = [p for p in video_files if ffprobe_ok(p)]
for f in video_files: if not valid_videos:
if not os.path.isfile(f): with lock:
continue job.status = "failed"
md5_hex = md5_file(f) job.message = "ffprobe: keine gültige Video-Datei (oder ffprobe fehlt)."
md5_path = write_md5_sidecar(f, md5_hex) return
md5_records.append((f, md5_hex, md5_path))
with lock: with lock:
jobs[jobid].status = "upload" job.status = "upload"
jobs[jobid].message = f"Download fertig. Upload {len(md5_records)} Datei(en) + MD5" job.message = f"Download fertig. MD5/Upload/Verify für {len(valid_videos)} Datei(en)…"
ssh = ssh_connect() ssh = ssh_connect()
try: try:
# Upload each file + its .md5, verify md5 remote, then cleanup local for f in valid_videos:
for f, md5_hex, md5_path in md5_records: fn = os.path.basename(f)
remote_file = f"{JELLYFIN_DEST_DIR}/{os.path.basename(f)}" target_dir = pick_library_target(job.library, fn, job.package_name)
remote_file = f"{target_dir}/{fn}"
remote_md5f = remote_file + ".md5" remote_md5f = remote_file + ".md5"
# MD5 local
md5_hex = md5_file(f)
md5_path = write_md5_sidecar(f, md5_hex)
# Upload file + md5
sftp_upload(ssh, f, remote_file) sftp_upload(ssh, f, remote_file)
sftp_upload(ssh, md5_path, remote_md5f) sftp_upload(ssh, md5_path, remote_md5f)
# Verify remote
remote_md5 = remote_md5sum(ssh, remote_file) remote_md5 = remote_md5sum(ssh, remote_file)
if remote_md5.lower() != md5_hex.lower(): if remote_md5.lower() != md5_hex.lower():
raise RuntimeError( raise RuntimeError(f"MD5 mismatch for {fn}: local={md5_hex} remote={remote_md5}")
f"MD5 mismatch for {os.path.basename(f)}: local={md5_hex} remote={remote_md5}"
)
# Remote ok -> delete local file and local md5 # Cleanup local after successful verify
try: try:
os.remove(f) os.remove(f)
except Exception: except Exception:
@@ -323,73 +414,109 @@ def worker(jobid: str):
finally: finally:
ssh.close() ssh.close()
# Remove package/container in JD (best effort) # Cleanup JD package/links (best effort)
msg = try_remove_from_jd(dev, links, pkg_map) jd_cleanup_msg = try_remove_from_jd(dev, links, pkg_map)
with lock: with lock:
jobs[jobid].status = "finished" job.status = "finished"
jobs[jobid].message = "Upload + MD5 OK. " + (msg or "JDownloader: Paket/Links entfernt.") job.message = "Upload + MD5 OK. " + (jd_cleanup_msg or "JDownloader: Paket/Links entfernt.")
return return
except Exception as e: except Exception as e:
with lock: with lock:
jobs[jobid].status = "failed" job = jobs.get(jobid)
jobs[jobid].message = str(e) if job:
job.status = "failed"
job.message = str(e)
# ============================================================
@app.get("/", response_class=HTMLResponse) # Web
def index(): # ============================================================
def render_page(error: str = "") -> str:
rows = "" rows = ""
with lock: with lock:
for j in sorted(jobs.values(), key=lambda x: x.id, reverse=True): job_list = list(jobs.values())[::-1]
for j in job_list:
rows += ( rows += (
f"<tr><td><code>{j.id}</code></td>" f"<tr>"
f"<td style='max-width:680px; word-break:break-all;'>{j.url}</td>" f"<td><code>{j.id}</code></td>"
f"<td style='max-width:560px; word-break:break-all;'>{j.url}</td>"
f"<td>{j.package_name}</td>" f"<td>{j.package_name}</td>"
f"<td><b>{j.status}</b><br/><small>{j.message}</small></td></tr>" f"<td>{j.library}</td>"
f"<td><b>{j.status}</b><br/><small>{j.message}</small></td>"
f"</tr>"
) )
err_html = f"<p class='error'>{error}</p>" if error else ""
auth_note = "aktiv" if _auth_enabled() else "aus"
return f""" return f"""
<html> <html>
<head> <head>
<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>
</head> </head>
<body> <body>
<h1>JD → Jellyfin</h1> <h1>JD → Jellyfin</h1>
{err_html}
<form method="post"> <form method="post" action="/submit">
<div> <div class="row">
<input name="url" placeholder="https://..." size="90" required /> <label>Link</label><br/>
<input name="url" placeholder="https://..." required />
</div> </div>
<div> <div class="row">
<input name="package_name" placeholder="Paketname (optional)" size="90" /> <label>Paketname (optional)</label><br/>
<input name="package_name" placeholder="z. B. Sister Act (1992)" />
</div>
<div class="row">
<label>Ziel</label><br/>
<select name="library">
<option value="auto">auto</option>
<option value="movies">movies</option>
<option value="series">series</option>
</select>
</div> </div>
<button type="submit">Download starten</button> <button type="submit">Download starten</button>
</form> </form>
<p>Hinweis: JDownloader muss nach <code>/output</code> speichern und der Container muss <code>/output</code> mounten.</p> <p class="hint">
<p>Video-Whitelist: {", ".join(sorted(VIDEO_EXTS))}</p> Auth: <b>{auth_note}</b> |
JD Output: <code>{JD_OUTPUT_PATH}</code> |
Video-Whitelist: {", ".join(sorted(VIDEO_EXTS))}
</p>
<table border="1" cellpadding="6" cellspacing="0"> <table>
<tr><th>JobID</th><th>URL</th><th>Paket</th><th>Status</th></tr> <thead>
{rows if rows else "<tr><td colspan='4'><em>No jobs yet</em></td></tr>"} <tr><th>JobID</th><th>URL</th><th>Paket</th><th>Ziel</th><th>Status</th></tr>
</thead>
<tbody>
{rows if rows else "<tr><td colspan='5'><em>No jobs yet.</em></td></tr>"}
</tbody>
</table> </table>
</body></html> </body>
</html>
""" """
@app.get("/", response_class=HTMLResponse)
def index():
try:
ensure_env()
return HTMLResponse(render_page())
except Exception as e:
return HTMLResponse(render_page(str(e)), status_code=400)
@app.post("/") @app.post("/submit")
def submit(url: str = Form(...), package_name: str = Form("")): def submit(url: str = Form(...), package_name: str = Form(""), library: str = Form("auto")):
ensure_env() ensure_env()
url = url.strip() url = url.strip()
package_name = (package_name or "").strip() or "WebGUI"
library = (library or "auto").strip().lower()
if not URL_RE.match(url): if not URL_RE.match(url):
return HTMLResponse("Nur http(s) URLs erlaubt", status_code=400) return HTMLResponse(render_page("Nur http(s) URLs erlaubt."), status_code=400)
dev = get_device() dev = get_device()
package_name = (package_name or "").strip() or "WebGUI"
resp = dev.linkgrabber.add_links([{ resp = dev.linkgrabber.add_links([{
"links": url, "links": url,
"autostart": True, "autostart": True,
@@ -399,10 +526,19 @@ def submit(url: str = Form(...), package_name: str = Form("")):
jobid = str(resp.get("id", "")) jobid = str(resp.get("id", ""))
if not jobid: if not jobid:
return HTMLResponse(f"Unerwartete Antwort von add_links: {resp}", status_code=500) return HTMLResponse(render_page(f"Unerwartete Antwort von add_links: {resp}"), status_code=500)
with lock: with lock:
jobs[jobid] = Job(jobid, url, package_name, "queued", "Download gestartet") jobs[jobid] = Job(
id=jobid,
url=url,
package_name=package_name,
library=library,
status="queued",
message="Download gestartet",
)
threading.Thread(target=worker, args=(jobid,), daemon=True).start() t = threading.Thread(target=worker, args=(jobid,), daemon=True)
return RedirectResponse("/", status_code=303) t.start()
return RedirectResponse(url="/", status_code=303)