Files
project-kino/nextcloud-import/backend-nextcloud-variant/app/providers/mediathek.py
Hermes Agent 884c198783 Import remaining Nextcloud Kino-Projekt material into git
Moves git-compatible content from Nextcloud
(Botomir/Projekte/Angefangen/Kino-Projekt) into this repo per cleanup
request, under nextcloud-import/ to avoid colliding with the actively
maintained backend/frontend/worker layout:

- status/planning docs (Botomir-Status.md, Plan-Botomir.md, Projektbeschreibung.md)
- docs/ (deployment.md, provider-policy.md)
- backend-nextcloud-variant/ (older app/ layout, kept for reference only)
- plugin.video.xstream/, script.module.xstreamscraper/ (vendored Kodi addon source)

A third-party Firefox extension folder was intentionally left in Nextcloud
(unrelated vendor code, not project source).
2026-08-17 10:47:13 +02:00

167 lines
7.9 KiB
Python

import asyncio
import ipaddress
import shutil
import socket
from collections.abc import Callable
from pathlib import Path, PurePosixPath
from urllib.parse import urlparse
import httpx
from app.providers.base import DownloadOptions, DownloadResult, MediaMetadata, ProviderError
from app.providers.youtube import parse_yt_dlp_progress
MEDIA_CONTENT_TYPES = (
"video/", "audio/", "application/vnd.apple.mpegurl", "application/x-mpegurl", "application/dash+xml"
)
MEDIA_EXTENSIONS = {".mp4", ".mkv", ".webm", ".mov", ".m4v", ".mp3", ".m4a", ".aac", ".ogg", ".flac", ".m3u8", ".mpd"}
class MediathekProvider:
name = "mediathek"
def can_handle(self, url: str) -> bool:
parsed = urlparse(url)
host = parsed.hostname or ""
excluded_hosts = {"youtube.com", "www.youtube.com", "m.youtube.com", "youtu.be", "archive.org", "www.archive.org"}
return parsed.scheme in {"http", "https"} and host not in excluded_hosts
async def probe(self, url: str) -> MediaMetadata:
parsed = urlparse(url)
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
raise ProviderError("Only http/https URLs are allowed")
self._reject_private_targets(parsed.hostname)
async with httpx.AsyncClient(follow_redirects=True, timeout=httpx.Timeout(10, read=10), max_redirects=5) as client:
response = await client.head(url)
if response.status_code in {405, 403}:
response = await client.get(url, headers={"Range": "bytes=0-0"})
for hop in [*response.history, response]:
host = hop.url.host
if host:
self._reject_private_targets(host)
content_type = self._content_type(response)
ext = PurePosixPath(urlparse(str(response.url)).path).suffix.lower()
if not (content_type.startswith(MEDIA_CONTENT_TYPES) or ext in MEDIA_EXTENSIONS):
raise ProviderError(f"URL is reachable, but content type/extension is not accepted: {content_type or ext or 'unknown'}")
title = PurePosixPath(urlparse(str(response.url)).path).name or str(response.url.host)
return MediaMetadata(provider=self.name, title=title, external_id=str(response.url))
async def download(
self,
url: str,
target_dir: Path,
options: DownloadOptions,
progress_callback: Callable[[float], None] | None = None,
) -> DownloadResult:
metadata = await self.probe(url)
if self._is_manifest(metadata.external_id or url):
return await self._download_manifest_with_ytdlp(metadata.external_id or url, target_dir, options, progress_callback)
target_dir.mkdir(parents=True, exist_ok=True)
name = PurePosixPath(urlparse(metadata.external_id or url).path).name or "download.bin"
output_path = target_dir / name
written = 0
async with httpx.AsyncClient(follow_redirects=True, timeout=httpx.Timeout(20, read=60), max_redirects=5) as client:
async with client.stream("GET", url) as response:
response.raise_for_status()
for hop in [*response.history, response]:
host = hop.url.host
if host:
self._reject_private_targets(host)
if self._is_manifest(str(response.url), self._content_type(response)):
raise ProviderError("Manifest URL must be downloaded through yt-dlp/ffmpeg, not raw HTTP streaming")
total = int(response.headers.get("content-length") or 0)
with output_path.open("wb") as fh:
async for chunk in response.aiter_bytes():
written += len(chunk)
if written > options.max_bytes:
raise ProviderError("Download exceeds configured maximum size")
fh.write(chunk)
if total > 0 and progress_callback is not None:
progress_callback(min(written / total, 1.0))
if progress_callback is not None:
progress_callback(1.0)
return DownloadResult(output_files=[output_path])
async def _download_manifest_with_ytdlp(
self,
url: str,
target_dir: Path,
options: DownloadOptions,
progress_callback: Callable[[float], None] | None = None,
) -> DownloadResult:
if shutil.which("yt-dlp") is None:
raise ProviderError("yt-dlp is required for HLS/DASH manifest downloads")
if shutil.which("ffmpeg") is None:
raise ProviderError("ffmpeg is required to remux HLS/DASH manifest downloads")
target_dir.mkdir(parents=True, exist_ok=True)
output_template = str(target_dir / "%(title).120s [%(id)s].%(ext)s")
height_expr = f"bestvideo[height<={options.max_height}]+bestaudio/best[height<={options.max_height}]/best"
proc = await asyncio.create_subprocess_exec(
"yt-dlp",
"--newline",
"--no-playlist",
"--no-warnings",
"--restrict-filenames",
"--remux-video",
"mp4",
"--format",
height_expr,
"--output",
output_template,
url,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
captured: list[str] = []
async def consume(stream: asyncio.StreamReader | None) -> None:
if stream is None:
return
while True:
line = await stream.readline()
if not line:
break
text = line.decode(errors="replace").strip()
if not text:
continue
captured.append(text)
if len(captured) > 80:
del captured[:40]
progress = parse_yt_dlp_progress(text)
if progress is not None and progress_callback is not None:
progress_callback(progress)
await asyncio.wait_for(asyncio.gather(consume(proc.stdout), consume(proc.stderr), proc.wait()), timeout=60 * 60)
if proc.returncode != 0:
detail = "\n".join(captured).strip() or "yt-dlp manifest download failed"
raise ProviderError(detail[:1000])
files = [p for p in target_dir.iterdir() if p.is_file()]
media_files = [p for p in files if p.suffix.lower() not in {".json", ".jpg", ".jpeg", ".png", ".webp"}]
if not media_files:
raise ProviderError("yt-dlp finished without producing a media file")
for media_file in media_files:
if media_file.stat().st_size > options.max_bytes:
raise ProviderError("Downloaded media exceeds configured maximum size")
if progress_callback is not None:
progress_callback(1.0)
return DownloadResult(output_files=media_files)
def _content_type(self, response: httpx.Response) -> str:
return response.headers.get("content-type", "").split(";", 1)[0].lower()
def _is_manifest(self, url: str, content_type: str | None = None) -> bool:
ext = PurePosixPath(urlparse(url).path).suffix.lower()
ctype = (content_type or "").lower()
return ext in {".m3u8", ".mpd"} or ctype in {"application/vnd.apple.mpegurl", "application/x-mpegurl", "application/dash+xml"}
def _reject_private_targets(self, hostname: str) -> None:
try:
infos = socket.getaddrinfo(hostname, None, proto=socket.IPPROTO_TCP)
except socket.gaierror as exc:
raise ProviderError(f"DNS lookup failed for {hostname}") from exc
for info in infos:
ip = ipaddress.ip_address(info[4][0])
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_multicast or ip.is_reserved or ip.is_unspecified:
raise ProviderError(f"Refusing private or non-public target address for {hostname}")