commit ce70a6dd7812bfc3a9c1f98bb848594ec4dca407 Author: DasPoschi Date: Sat Jan 3 22:19:29 2026 +0100 new file: .env.example new file: README.md new file: docker-compose.yml new file: media-webgui/Dockerfile new file: media-webgui/app.py new file: media-webgui/static/style.css diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a7cc20c --- /dev/null +++ b/.env.example @@ -0,0 +1,32 @@ +TZ=Europe/Berlin +WEBGUI_PORT=8080 + +# Optional Basic Auth (leave empty to disable) +BASIC_AUTH_USER=admin +BASIC_AUTH_PASS=change_me + +# Paths inside container +OUTPUT_DIR=/output +MD5_DIR=/md5 + +# Jellyfin VM upload (SFTP) +JELLYFIN_HOST=192.168.1.1 +JELLYFIN_PORT=22 +JELLYFIN_USER=jellyfinuser +JELLYFIN_SSH_KEY=/ssh/id_ed25519 + +# Targets on Jellyfin VM +JELLYFIN_MOVIES_DIR=/jellyfin/Filme +JELLYFIN_SERIES_DIR=/jellyfin/Serien + +# Engines +ENGINE_DEFAULT=auto # auto|ytdlp|direct +YTDLP_FORMAT=bestvideo+bestaudio/best + +# Proxy pool (ONLY used for downloads; not for SFTP / webgui internal calls) +PROXY_MODE=round_robin # off|round_robin|random +# One proxy per line. Supported schemes: socks5://, socks4://, http://, https:// +PROXY_LIST= +# Example: +# PROXY_LIST=socks5://1.2.3.4:1080 +# http://5.6.7.8:3128 diff --git a/README.md b/README.md new file mode 100644 index 0000000..4d4e83c --- /dev/null +++ b/README.md @@ -0,0 +1,24 @@ +# Media WebGUI (ohne JDownloader) + +Dieses Projekt lädt Links über: +- **yt-dlp** (YouTube & unterstützte Video-Plattformen) +- **aria2c** (direkte HTTP/HTTPS-Links, z.B. .mkv/.mp4) + +Danach: +- erzeugt es eine **MD5** und speichert sie als Sidecar +- kopiert Datei + .md5 per **SFTP** auf die Jellyfin-VM +- prüft die Remote-MD5 +- löscht lokale Dateien nach Erfolg + +## Wichtig +- Keine Umgehung von Paywalls/DRM/Captchas. Für "Hoster" funktioniert das nur, wenn du eine **direkte** Download-URL hast bzw. eine legitime Authentifizierung (Headers/Cookies) nutzt. + +## Start +1) `.env.example` -> `.env` kopieren und Werte setzen. +2) SSH Key ablegen: `data/ssh/id_ed25519` (chmod 600) +3) `docker compose up -d --build` +4) WebUI: `http://:8080` + +## Proxies +- Proxies werden **nur** an yt-dlp/aria2 übergeben (pro Job), beeinflussen also nicht SFTP/Jellyfin. +- `PROXY_LIST` enthält eine Zeile pro Proxy: `socks5://IP:PORT`, `http://IP:PORT`, ... diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..4c50356 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,15 @@ +version: "3.8" + +services: + media-webgui: + build: ./media-webgui + container_name: media-webgui + restart: unless-stopped + ports: + - "${WEBGUI_PORT:-8080}:8080" + env_file: + - .env + volumes: + - ./data/output:/output + - ./data/md5:/md5 + - ./data/ssh:/ssh:ro diff --git a/media-webgui/Dockerfile b/media-webgui/Dockerfile new file mode 100644 index 0000000..7d4be0c --- /dev/null +++ b/media-webgui/Dockerfile @@ -0,0 +1,16 @@ +FROM python:3.12-slim + +WORKDIR /app + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ffmpeg aria2 ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +RUN pip install --no-cache-dir fastapi uvicorn python-multipart paramiko requests +RUN pip install --no-cache-dir yt-dlp + +COPY app.py /app/app.py +COPY static /app/static + +EXPOSE 8080 +CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8080"] diff --git a/media-webgui/app.py b/media-webgui/app.py new file mode 100644 index 0000000..8c3b2c9 --- /dev/null +++ b/media-webgui/app.py @@ -0,0 +1,467 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import base64 +import hashlib +import os +import random +import re +import shlex +import subprocess +import threading +import time +from dataclasses import dataclass +from typing import Dict, List + +import paramiko +from fastapi import FastAPI, Form, Request +from fastapi.responses import HTMLResponse, RedirectResponse +from fastapi.staticfiles import StaticFiles + +OUTPUT_DIR = os.environ.get("OUTPUT_DIR", "/output").rstrip("/") +MD5_DIR = os.environ.get("MD5_DIR", "/md5").rstrip("/") + +JELLYFIN_HOST = os.environ.get("JELLYFIN_HOST", "192.168.1.1") +JELLYFIN_PORT = int(os.environ.get("JELLYFIN_PORT", "22")) +JELLYFIN_USER = os.environ.get("JELLYFIN_USER", "") +JELLYFIN_SSH_KEY = os.environ.get("JELLYFIN_SSH_KEY", "/ssh/id_ed25519") + +JELLYFIN_MOVIES_DIR = os.environ.get("JELLYFIN_MOVIES_DIR", "/jellyfin/Filme").rstrip("/") +JELLYFIN_SERIES_DIR = os.environ.get("JELLYFIN_SERIES_DIR", "/jellyfin/Serien").rstrip("/") + +ENGINE_DEFAULT = os.environ.get("ENGINE_DEFAULT", "auto").strip().lower() +YTDLP_FORMAT = os.environ.get("YTDLP_FORMAT", "bestvideo+bestaudio/best") + +BASIC_AUTH_USER = os.environ.get("BASIC_AUTH_USER", "").strip() +BASIC_AUTH_PASS = os.environ.get("BASIC_AUTH_PASS", "").strip() + +PROXY_MODE = os.environ.get("PROXY_MODE", "round_robin").strip().lower() +PROXY_LIST_RAW = os.environ.get("PROXY_LIST", "") + +URL_RE = re.compile(r"^https?://", re.I) +YOUTUBE_RE = re.compile(r"(youtube\.com|youtu\.be)", re.I) + +VIDEO_EXTS = (".mkv",".mp4",".m4v",".avi",".mov",".wmv",".flv",".webm",".ts",".m2ts",".mpg",".mpeg",".vob",".ogv",".3gp",".3g2") +SERIES_RE = re.compile(r"(?:^|[^a-z0-9])S(\d{1,2})E(\d{1,2})(?:[^a-z0-9]|$)", re.IGNORECASE) + +app = FastAPI() +app.mount("/static", StaticFiles(directory="static"), name="static") + +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="media-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) + +@dataclass +class Job: + id: str + url: str + engine: str + library: str + proxy: str + status: str + message: str + +jobs: Dict[str, Job] = {} +lock = threading.Lock() +_rr_idx = 0 + +def parse_proxy_list(raw: str) -> List[str]: + out = [] + for line in (raw or "").splitlines(): + s = line.strip() + if not s or s.startswith("#"): + continue + out.append(s) + seen = set() + dedup = [] + for x in out: + if x not in seen: + seen.add(x) + dedup.append(x) + return dedup + +PROXIES = parse_proxy_list(PROXY_LIST_RAW) + +def pick_proxy(forced_proxy: str = "") -> str: + global _rr_idx + if forced_proxy: + return forced_proxy.strip() + if PROXY_MODE == "off" or not PROXIES: + return "" + if PROXY_MODE == "random": + return random.choice(PROXIES) + p = PROXIES[_rr_idx % len(PROXIES)] + _rr_idx += 1 + return p + +def format_proxy_lines(raw: str, scheme: str) -> str: + scheme = scheme.strip().lower() + if scheme not in {"socks5", "socks4", "http", "https"}: + 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, port = host.strip(), port.strip() + if not host or not port.isdigit(): + continue + out.append(f"{scheme}://{host}:{port}") + seen=set(); ded=[] + for x in out: + if x not in seen: + seen.add(x); ded.append(x) + return "\n".join(ded) + +def pick_engine(url: str, forced: str) -> str: + forced = (forced or "").strip().lower() + if forced and forced != "auto": + return forced + u = url.lower() + if YOUTUBE_RE.search(u): + return "ytdlp" + if u.split("?")[0].endswith(VIDEO_EXTS): + return "direct" + return "direct" + +def run_ytdlp(url: str, out_dir: str, fmt: str, proxy: str): + cmd = ["yt-dlp", "-f", fmt, "-o", f"{out_dir}/%(title)s.%(ext)s", url] + if proxy: + cmd += ["--proxy", proxy] + subprocess.check_call(cmd) + +def run_aria2(url: str, out_dir: str, proxy: str): + cmd = ["aria2c", "--dir", out_dir, "--allow-overwrite=true", "--auto-file-renaming=false", url] + if proxy: + cmd += ["--all-proxy", proxy] + subprocess.check_call(cmd) + +def md5_file(path: str) -> str: + h = hashlib.md5() + with open(path, "rb") as f: + for chunk in iter(lambda: f.read(1024*1024), b""): + h.update(chunk) + return h.hexdigest() + +def write_md5_sidecar(local_file: str, md5_hex: str) -> str: + os.makedirs(MD5_DIR, exist_ok=True) + base = os.path.basename(local_file) + md5p = os.path.join(MD5_DIR, base + ".md5") + with open(md5p, "w", encoding="utf-8") as f: + f.write(f"{md5_hex} {base}\n") + return md5p + +def ssh_connect() -> paramiko.SSHClient: + if not JELLYFIN_USER: + raise RuntimeError("JELLYFIN_USER missing") + ssh = paramiko.SSHClient() + ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + ssh.connect( + hostname=JELLYFIN_HOST, + port=JELLYFIN_PORT, + username=JELLYFIN_USER, + key_filename=JELLYFIN_SSH_KEY, + timeout=30, + ) + 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): + sftp = ssh.open_sftp() + try: + sftp_mkdirs(sftp, os.path.dirname(remote_path)) + sftp.put(local_path, remote_path) + except Exception as e: + raise RuntimeError(f"SFTP upload failed: local={local_path} remote={remote_path} error={e}") + finally: + sftp.close() + +def remote_md5sum(ssh: paramiko.SSHClient, remote_path: str) -> str: + cmd = f"md5sum {shlex.quote(remote_path)}" + _, stdout, stderr = ssh.exec_command(cmd, timeout=120) + out = stdout.read().decode("utf-8", "replace").strip() + err = stderr.read().decode("utf-8", "replace").strip() + if err and not out: + raise RuntimeError(f"Remote md5sum failed: {err}") + if not out: + raise RuntimeError("Remote md5sum returned empty output") + return out.split()[0] + +def choose_target_dir(library: str, filename: str) -> str: + library = (library or "auto").lower() + if library == "series": + return JELLYFIN_SERIES_DIR + if library == "movies": + return JELLYFIN_MOVIES_DIR + if SERIES_RE.search(filename): + return JELLYFIN_SERIES_DIR + return JELLYFIN_MOVIES_DIR + +def list_output_files(before: set) -> List[str]: + now = set() + for root, _, files in os.walk(OUTPUT_DIR): + for fn in files: + now.add(os.path.join(root, fn)) + new = [p for p in sorted(now) if p not in before] + final = [] + for p in new: + low = p.lower() + if low.endswith((".part",".tmp",".crdownload")): + continue + final.append(p) + return final + +def worker(jobid: str): + try: + with lock: + job = jobs[jobid] + + os.makedirs(OUTPUT_DIR, exist_ok=True) + + before = set() + for root, _, files in os.walk(OUTPUT_DIR): + for fn in files: + before.add(os.path.join(root, fn)) + + engine = pick_engine(job.url, job.engine) + proxy = job.proxy + + with lock: + job.status = "downloading" + job.message = f"Engine={engine} Proxy={'none' if not proxy else proxy}" + + if engine == "ytdlp": + run_ytdlp(job.url, OUTPUT_DIR, YTDLP_FORMAT, proxy) + else: + run_aria2(job.url, OUTPUT_DIR, proxy) + + new_files = list_output_files(before) + if not new_files: + raise RuntimeError("No output file detected in /output") + + ssh = ssh_connect() + try: + for f in new_files: + if not os.path.isfile(f): + continue + + md5_hex = md5_file(f) + md5_path = write_md5_sidecar(f, md5_hex) + + target_dir = choose_target_dir(job.library, os.path.basename(f)) + remote_file = f"{target_dir}/{os.path.basename(f)}" + remote_md5f = remote_file + ".md5" + + with lock: + job.status = "upload" + job.message = f"Uploading: {os.path.basename(f)} -> {remote_file}" + + sftp_upload(ssh, f, remote_file) + sftp_upload(ssh, md5_path, remote_md5f) + + remote_md5 = remote_md5sum(ssh, remote_file) + if remote_md5.lower() != md5_hex.lower(): + raise RuntimeError(f"MD5 mismatch: local={md5_hex} remote={remote_md5}") + + try: os.remove(f) + except Exception: pass + try: os.remove(md5_path) + except Exception: pass + + finally: + ssh.close() + + with lock: + job.status = "finished" + job.message = f"OK ({len(new_files)} file(s))" + + except Exception as e: + with lock: + jobs[jobid].status = "failed" + jobs[jobid].message = str(e) + +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"{label}" + return "
" + link("Downloads","/","downloads") + link("Proxies","/proxies","proxies") + "
" + +def render_downloads(error: str = "") -> str: + rows = "" + with lock: + job_list = list(jobs.values())[::-1] + + for j in job_list: + rows += ( + f"" + f"{j.id}" + f"{j.url}" + f"{j.engine}" + f"{j.library}" + f"{'none' if not j.proxy else j.proxy}" + f"{j.status}
{j.message}" + f"" + ) + + err_html = f"

{error}

" if error else "" + proxy_note = f"{len(PROXIES)} configured, mode={PROXY_MODE}" if PROXIES else "none configured" + return f""" + + + + Media WebGUI + + +

Media WebGUI

+ {render_nav("downloads")} + {err_html} + +
+

+ +
+ +

+ +
+ +

+ +
+ +

+ +
+ + +
+ +

+ Output: {OUTPUT_DIR} | MD5: {MD5_DIR} | Proxies: {proxy_note} +

+ + + + + {rows if rows else ""} + +
JobIDURLEngineLibraryProxyStatus
No jobs yet.
+ + """ + +@app.get("/", response_class=HTMLResponse) +def index(): + return HTMLResponse(render_downloads()) + +@app.post("/submit") +def submit(url: str = Form(...), engine: str = Form("auto"), library: str = Form("auto"), proxy: str = Form("")): + url = url.strip() + if not URL_RE.match(url): + return HTMLResponse(render_downloads("Only http(s) URLs supported"), status_code=400) + + engine = (engine or ENGINE_DEFAULT).strip().lower() + library = (library or "auto").strip().lower() + + chosen_proxy = pick_proxy(proxy.strip()) + jobid = str(int(time.time() * 1000)) + + with lock: + jobs[jobid] = Job(id=jobid, url=url, engine=engine, library=library, proxy=chosen_proxy, status="queued", message="queued") + + t = threading.Thread(target=worker, args=(jobid,), daemon=True) + t.start() + return RedirectResponse(url="/", status_code=303) + +def render_proxies_page(error: str = "", s5: str = "", s4: str = "", hp: str = "", out_text: str = "") -> str: + err_html = f"

{error}

" if error else "" + return f""" + + + + Proxies + + +

Media WebGUI

+ {render_nav("proxies")} + {err_html} + +
+

+ +
+

+ +
+

+ +
+ +
+ +

Import-Liste (zum Kopieren)

+

Keine Prüfung/Validierung. In .env in PROXY_LIST einfügen (eine Zeile pro Proxy).

+
+ + + """ + +@app.get("/proxies", response_class=HTMLResponse) +def proxies_get(): + return HTMLResponse(render_proxies_page()) + +@app.post("/proxies", response_class=HTMLResponse) +def proxies_post(s5: str = Form(""), s4: str = Form(""), hp: str = Form("")): + try: + o1 = format_proxy_lines(s5, "socks5") + o2 = format_proxy_lines(s4, "socks4") + o3 = format_proxy_lines(hp, "http") + combined = "\n".join([x for x in [o1, o2, o3] if x.strip()]) + return HTMLResponse(render_proxies_page(s5=s5, s4=s4, hp=hp, out_text=combined)) + except Exception as e: + return HTMLResponse(render_proxies_page(error=str(e), s5=s5, s4=s4, hp=hp, out_text=""), status_code=400) diff --git a/media-webgui/static/style.css b/media-webgui/static/style.css new file mode 100644 index 0000000..b14ec70 --- /dev/null +++ b/media-webgui/static/style.css @@ -0,0 +1,13 @@ +body { font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif; background:#fafafa; margin:0; padding:24px; } +h1 { margin: 0 0 16px 0; } +form { background:#fff; border:1px solid #e5e5e5; border-radius:10px; padding:14px; max-width: 920px; } +.row { margin-bottom: 10px; } +input, select { padding:10px; border:1px solid #ccc; border-radius:8px; font-size:14px; width: 100%; max-width: 860px; } +textarea { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; } +button { padding:10px 14px; border:0; border-radius:8px; font-weight:600; cursor:pointer; } +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 { 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; } +.hint { color:#555; font-size: 12px; margin-top: 10px; } +.error { color:#b00020; font-weight: 700; }