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:
386
jd-webgui/app.py
386
jd-webgui/app.py
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user