chore: archive current Kino runtime and deployment #5

Merged
DasPoschi merged 1 commits from sync/current-ct201-20260821 into main 2026-08-21 16:16:38 +00:00
64 changed files with 5081 additions and 0 deletions

View File

@@ -2,6 +2,8 @@
Docker-hosted web GUI for searching and downloading movies/series, with Jellyfin integration.
> **Current runtime snapshot (2026-08-21):** The latest VM207/CT201 FastAPI implementation, browser extension, tests, Docker stack, token monitor, and migration report are under [`runtime-current/`](runtime-current/README.md). The older root frontend/backend/worker layout below remains as project history and has not been overwritten.
## Quick Start
```bash

27
runtime-current/.gitignore vendored Normal file
View File

@@ -0,0 +1,27 @@
# Secrets and local runtime config
*.env
!.env.example
kino/kino-projekt.env
token-monitor/token-monitor.env
# Runtime data
**/data/
**/export/
*.sqlite
*.sqlite3
*.sqlite3-wal
*.sqlite3-shm
*.db
*.db-wal
*.db-shm
# Credentials and browser/runtime caches
**/.ssh/
**/known_hosts
**/.cache/
**/.venv/
**/venv/
**/__pycache__/
**/*.pyc
**/*.egg-info/
**/.pytest_cache/

View File

@@ -0,0 +1,63 @@
# Migration Kino-Projekt: VM207 → Docker-CT201
**Datum:** 21.08.2026
**Status:** erfolgreich migriert; VM207 gestoppt, nicht gelöscht
## Zielzustand
| Komponente | Neuer Ort | Port/Status |
|---|---|---|
| Kino-Projekt | Docker-CT201 `docker`, `192.168.178.224` | `8099`, Container `kino-projekt`, healthy |
| Token-Monitor | Docker-CT201 | `8098`, Container `token-monitor`, healthy |
| Öffentlicher Kino-Tunnel | CT201 systemd `kino-vps-tunnel.service` | aktiv, Remote-Port `8892` |
| Jellyfin-Ziel | CT100, `192.168.178.222` | rsync als `kino-transfer` weiterhin aktiv |
| Alte VM | VM207 `kino-projekt`, `192.168.178.213` | gestoppt, Disk für Rollback erhalten |
## Durchgeführt
- Live-Dienste, Daten, Umgebungsdateien, SSH-Transferkeys, Reverse-Tunnel und aktive Jobs auf VM207 inventarisiert.
- Keine laufenden Importe vor dem Cutover festgestellt; zwei vorhandene Jobs waren bereits `done`.
- Quellcode, SQLite-Datenbank, Export-Staging, Browser-Erweiterung, Transferkey, Token-Monitor-Daten und Tunnelkey gesichert übertragen.
- Rollbackfähigen Docker-Stack unter `/srv/kino-stack/` auf CT201 aufgebaut.
- Kino-Image auf Python 3.12 mit ffmpeg, rsync, OpenSSH, yt-dlp und Playwright/Chromium gebaut.
- Build-Test: **45 Tests bestanden**, eine unkritische Starlette/httpx-Deprecation-Warnung.
- Container laufen im Host-Netz, da Docker-Portweiterleitung auf CT201 von den bestehenden Meshguard-/Docker-Regeln für neue Ports nicht zuverlässig erreichbar war.
- Jellyfin-SSH-Regel um `kino-transfer@192.168.178.224` ergänzt; alter VM207-Zugang für Rollback beibehalten.
- Reverse-Tunnel von VM207 auf CT201 übertragen; NPM-Container erreicht `headscale-tunnel-sshd:8892/health` mit `{"status":"ok"}`.
- Alte Dienste auf VM207 gestoppt und deaktiviert; finalen Datenstand danach erneut synchronisiert.
- VM207 kontrolliert heruntergefahren.
## Verifikation
- `http://192.168.178.224:8099/health` → HTTP 200 `{"status":"ok"}`
- `http://192.168.178.224:8099/` → HTTP 200
- `http://192.168.178.224:8099/api/auth/status` → bisheriges Verhalten erhalten (`enabled=false`)
- `http://192.168.178.224:8098/` → HTTP 200
- Playwright/Chromium-Start im Container → `PLAYWRIGHT_OK`
- Analyse einer öffentlichen Internet-Archive-Seite → erlaubter direkter MP4-Kandidat erkannt
- SSH-/rsync-Schreibtest aus dem Kino-Container nach `/jellyfin` → erfolgreich
- Realer Testimport einer öffentlichen MP4-Datei → Job `done`, Datei per rsync auf Jellyfin angekommen; Testjob und Testmedium anschließend entfernt
- Container-Restart → beide Container erneut `healthy`
- Tunnel-Restart → aktiv; NPM-interner Upstream-Healthcheck weiterhin OK
- Öffentlicher Schutz unverändert: HTTP → HTTPS 301, HTTPS ohne Zugangsdaten → 401
- Nach Abschaltung von VM207: alte Ports `192.168.178.213:8099/8080` nicht mehr erreichbar; neue Ports weiterhin HTTP 200
## Daten und Backups
- CT201 Stack: `/srv/kino-stack/`
- CT201 Vorbereitungsbackup: `/root/kino-stack-backups/pre-migration-20260821-172103`
- VM207 Rollbackbackup: `/root/kino-migration-backup-20260821-154102`
- PVE-Konfigurationssnapshot: `/root/decommissioned-vm-configs/vm207-kino-projekt-20260821-175059.conf`
- VM207 wurde **nicht gelöscht**; ein Start ist als schneller Infrastruktur-Rollback weiterhin möglich.
## Rollback
1. Auf CT201 `kino-vps-tunnel.service` stoppen und `/srv/kino-stack` herunterfahren.
2. VM207 starten.
3. Auf VM207 `kino-projekt.service`, `token-monitor.service` und `kino-vps-tunnel.service` wieder aktivieren/starten.
4. Health, rsync und NPM-Tunnel erneut prüfen.
## Hinweise
- Die vorhandene Token-Monitor-Datenbank wurde unverändert übernommen. Ihr Hermes-History-Snapshot war bereits vor der Migration nicht aktuell; die Provider-Liveabfragen und Alarmzustände laufen weiter. Eine erneuerte automatische Synchronisation der Hermes-`state.db` ist ein separates Folgeprojekt.
- Für den Kino-Dienst ist die neue LAN-Adresse `http://192.168.178.224:8099`. Der öffentliche Hostname `https://kino.dasposchi.de` bleibt unverändert.

50
runtime-current/README.md Normal file
View File

@@ -0,0 +1,50 @@
# Current Kino/Token-Monitor Runtime Export
This directory contains the latest **sanitized source and deployment definition** copied from Docker CT201 on 2026-08-21.
The two runtime containers were removed afterward at the user's request. This is therefore a reproducible source/configuration export, not proof that the services are currently running.
## Contents
- `kino/app-src/` latest Kino-Projekt backend, web UI, browser extension, tests and Dockerfile
- `token-monitor/` token monitor source and Dockerfile
- `compose.yml` last validated CT201 Docker Compose topology
- `deployment/kino-vps-tunnel.service.example` reverse-tunnel unit without private keys
- `MIGRATION-2026-08-21.md` VM207 → CT201 migration and verification report
## Intentionally excluded
Runtime and secret-bearing data must not be committed:
- `kino/kino-projekt.env`
- `token-monitor/token-monitor.env`
- SQLite databases and WAL/SHM files
- imported/exported media
- SSH private keys and `known_hosts`
- browser caches and virtual environments
- `/var/lib/token-monitor/hermes-state.db`
Use the two `.env.example` templates and inject real values from Vaultwarden or host-managed environment files.
## Last verified build
```text
45 tests passed
Playwright Chromium launch: OK
Kino health: HTTP 200
Token monitor: HTTP 200
Jellyfin rsync: OK
Real public-media import: done, test media removed
```
## Rebuild
```bash
cp kino/kino-projekt.env.example kino/kino-projekt.env
cp token-monitor/token-monitor.env.example token-monitor/token-monitor.env
# Fill secrets outside Git.
docker compose build
docker compose up -d
```
The Compose file uses host networking with Kino on `8099` and Token Monitor on `8098`, matching the last validated CT201 deployment.

View File

@@ -0,0 +1,21 @@
# Container Removal 2026-08-21
At the user's request, the Docker containers `kino-projekt` and `token-monitor` were stopped and removed from CT201 after the verified VM207 migration.
Also disabled/stopped:
- `kino-vps-tunnel.service`
Preserved on CT201 (not committed to Git):
- `/srv/kino-stack/kino/data/`
- `/srv/kino-stack/kino/export/`
- `/srv/kino-stack/kino/ssh/`
- `/srv/kino-stack/kino/kino-projekt.env`
- `/srv/kino-stack/token-monitor/data/`
- `/srv/kino-stack/token-monitor/token-monitor.env`
- Docker images and `/srv/kino-stack/` deployment files
VM207 remains stopped and not deleted, so it is still an infrastructure-level rollback option.
This Git export contains source code, tests, sanitized deployment files and `.env.example` templates only. It deliberately excludes credentials, SSH keys, imported media, SQLite state and Hermes history data.

View File

@@ -0,0 +1,57 @@
services:
kino-projekt:
build:
context: ./kino/app-src
dockerfile: Dockerfile
container_name: kino-projekt
init: true
restart: unless-stopped
env_file:
- ./kino/kino-projekt.env
environment:
PLAYWRIGHT_BROWSERS_PATH: /ms-playwright
KINOPROJEKT_UBLOCK_PATH: /opt/kino-projekt/ublock-origin
network_mode: host
command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8099"]
volumes:
- ./kino/data:/opt/kino-projekt/data
- ./kino/export:/opt/kino-projekt/export
- ./kino/ssh:/opt/kino-projekt/.ssh:ro
- ./kino/ublock-origin:/opt/kino-projekt/ublock-origin:ro
shm_size: "1gb"
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8099/health', timeout=3).read()"]
interval: 30s
timeout: 5s
retries: 5
start_period: 30s
token-monitor:
build:
context: ./token-monitor
dockerfile: Dockerfile
container_name: token-monitor
init: true
restart: unless-stopped
env_file:
- ./token-monitor/token-monitor.env
environment:
DATA_DIR: /data
network_mode: host
command: ["gunicorn", "--bind", "0.0.0.0:8098", "--workers", "1", "--threads", "4", "--timeout", "30", "app:app"]
volumes:
- ./token-monitor/data:/data
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8098/', timeout=3).read()"]
interval: 30s
timeout: 5s
retries: 3
start_period: 15s

View File

@@ -0,0 +1,16 @@
[Unit]
Description=Reverse SSH tunnel for containerized Kino-Projekt via public VPS/NPM
After=network-online.target docker.service
Wants=network-online.target
Requires=docker.service
[Service]
Type=simple
User=root
ExecStartPre=/bin/sh -c 'for i in $(seq 1 30); do curl -fsS http://127.0.0.1:8099/health >/dev/null && exit 0; sleep 2; done; exit 1'
ExecStart=/usr/bin/ssh -N -T -p 2222 -i /root/.ssh/kino_reverse_tunnel_ed25519 -o ExitOnForwardFailure=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=3 -o StrictHostKeyChecking=yes -R 0.0.0.0:8892:127.0.0.1:8099 root@194.59.204.106
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,12 @@
.venv/
**/.venv/
__pycache__/
**/__pycache__/
.pytest_cache/
**/.pytest_cache/
*.pyc
*.pyo
backend/*.egg-info/
backend/**/*.egg-info/
plugin.video.xstream/
script.module.xstreamscraper/

View File

@@ -0,0 +1,13 @@
KINOPROJEKT_DB=/data/kino.sqlite3
KINOPROJEKT_MEDIA_ROOT=/jellyfin
KINOPROJEKT_TMP=/data/tmp
JELLYFIN_URL=http://jellyfin:8096
# JELLYFIN_API_KEY belongs in the protected target-host .env or Vaultwarden, never in Nextcloud notes.
JELLYFIN_API_KEY=
DEFAULT_MAX_HEIGHT=1080
MAX_DOWNLOAD_BYTES=15000000000
KINOPROJEKT_ADMIN_USER=admin
# Generate with: python -c "from app.services.auth import hash_password; import getpass; print(hash_password(getpass.getpass()))"
KINOPROJEKT_ADMIN_PASSWORD_HASH=
# Generate with: python -c "import secrets; print(secrets.token_urlsafe(48))"
KINOPROJEKT_SESSION_SECRET=

View File

@@ -0,0 +1,26 @@
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PLAYWRIGHT_BROWSERS_PATH=/ms-playwright \
HOME=/opt/kino-projekt
WORKDIR /app/backend
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates curl ffmpeg openssh-client rsync \
&& rm -rf /var/lib/apt/lists/*
COPY backend/ ./
RUN pip install --no-cache-dir .[test] yt-dlp \
&& python -m playwright install --with-deps chromium \
&& pytest -q
RUN groupadd --gid 10001 kino \
&& useradd --uid 10001 --gid 10001 --create-home --home-dir /opt/kino-projekt --shell /usr/sbin/nologin kino \
&& mkdir -p /opt/kino-projekt/data /opt/kino-projekt/export /opt/kino-projekt/.ssh /opt/kino-projekt/.cache \
&& chown -R kino:kino /opt/kino-projekt /app /ms-playwright
USER kino
EXPOSE 8080
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"]

View File

@@ -0,0 +1,51 @@
# Kino-Projekt
Interne WebUI zum Prüfen und Importieren von Inhalten aus legalen Quellen in Jellyfin.
## Sicherheitsgrenzen
Dieses Projekt implementiert bewusst **keine** Scraper für Piracy-Seiten, keine DRM-/Paywall-/Login-Umgehung und keinen Cookie-Import. Unterstützt werden YouTube-Metadaten via `yt-dlp`, öffentliche Internet-Archive-Items sowie konservativ geprüfte öffentliche Medien-/Mediathek-URLs.
## Entwicklung
```bash
cd backend
python -m venv .venv
. .venv/bin/activate
pip install -e '.[test]'
pytest -q
uvicorn app.main:app --host 0.0.0.0 --port 8080
```
Healthcheck: `curl http://localhost:8080/health`
## API-MVP
- `POST /api/probe` prüft eine URL und liefert Metadaten, ohne einen Download zu starten.
- `POST /api/imports` legt Source + Import-Job in SQLite an und startet den Import im Hintergrund.
- `GET /api/imports` und `GET /api/imports/{id}` zeigen Status/Fortschritt/Fehler an.
- `GET /api/sources` listet bekannte Quellen.
## Zielprofile
Die WebUI bietet drei Jellyfin-Zielprofile an:
- `film``/jellyfin/Filme/<Titel> (<Jahr>)/`
- `serie``/jellyfin/Serien/<Serientitel>/Staffel<N>/<SxxExx - Episodentitel>/`
- `clip``/jellyfin/YouTube-Mediathek/<Kanal oder Quelle>/<Titel> [<id>]/`
Optionale Titel-/Jahr-/Serienfelder dienen nur zur Zielpfad-Korrektur; Pfade werden serverseitig normalisiert und bleiben unter `KINOPROJEKT_MEDIA_ROOT`.
## App-Login
Wenn `KINOPROJEKT_ADMIN_PASSWORD_HASH` und `KINOPROJEKT_SESSION_SECRET` gesetzt sind, schützt ein lokaler App-Login die API. Das Passwort wird nur als Argon2-Hash gespeichert; die Session liegt in einem `HttpOnly`, `Secure`, `SameSite=Lax` Cookie.
Hash/Secret für die geschützte Zielhost-`.env` erzeugen:
```bash
cd backend
python -c "from app.services.auth import hash_password; import getpass; print(hash_password(getpass.getpass()))"
python -c "import secrets; print(secrets.token_urlsafe(48))"
```
Jellyfin wird nur aktualisiert, wenn `JELLYFIN_API_KEY` in einer geschützten Zielhost-Umgebung gesetzt ist; Secrets gehören nicht in Nextcloud.

View File

@@ -0,0 +1,54 @@
from functools import lru_cache
from pathlib import Path
from urllib.parse import unquote, urlparse
from pydantic import Field, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
def sqlite_url_to_path(value: str | None) -> Path | None:
if not value:
return None
parsed = urlparse(value)
if parsed.scheme != "sqlite":
return None
if parsed.netloc and parsed.netloc != "localhost":
return None
return Path(unquote(parsed.path))
class Settings(BaseSettings):
db_path: Path = Field(default=Path("/data/kino.sqlite3"), alias="KINOPROJEKT_DB")
legacy_db_url: str | None = Field(default=None, alias="KINOPROJEKT_DB_URL")
media_root: Path = Field(default=Path("/jellyfin"), alias="KINOPROJEKT_MEDIA_ROOT")
tmp_dir: Path = Field(default=Path("/data/tmp"), alias="KINOPROJEKT_TMP")
jellyfin_url: str = Field(default="http://jellyfin:8096", alias="JELLYFIN_URL")
jellyfin_api_key: str | None = Field(default=None, alias="JELLYFIN_API_KEY")
default_max_height: int = Field(default=1080, alias="DEFAULT_MAX_HEIGHT")
max_download_bytes: int = Field(default=15_000_000_000, alias="MAX_DOWNLOAD_BYTES")
request_timeout: float = Field(default=15.0, alias="KINOPROJEKT_REQUEST_TIMEOUT")
admin_username: str = Field(default="admin", alias="KINOPROJEKT_ADMIN_USER")
admin_password_hash: str | None = Field(default=None, alias="KINOPROJEKT_ADMIN_PASSWORD_HASH")
session_secret: str | None = Field(default=None, alias="KINOPROJEKT_SESSION_SECRET")
rsync_target: str | None = Field(default=None, alias="KINOPROJEKT_RSYNC_TARGET")
rsync_ssh_key: Path | None = Field(default=None, alias="KINOPROJEKT_RSYNC_SSH_KEY")
rsync_remote_root: Path = Field(default=Path("/jellyfin"), alias="KINOPROJEKT_RSYNC_REMOTE_ROOT")
import_concurrency: int = Field(default=3, ge=1, le=8, alias="KINOPROJEKT_IMPORT_CONCURRENCY")
@model_validator(mode="after")
def apply_legacy_db_url(self) -> "Settings":
# The first target-host deployment used KINOPROJEKT_DB_URL=sqlite:////...
# Keep it working unless the newer KINOPROJEKT_DB path is explicitly set.
fields_set = getattr(self, "__pydantic_fields_set__", set())
if "db_path" not in fields_set:
legacy_path = sqlite_url_to_path(self.legacy_db_url)
if legacy_path is not None:
self.db_path = legacy_path
return self
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
@lru_cache
def get_settings() -> Settings:
return Settings()

View File

@@ -0,0 +1,22 @@
from pathlib import Path
from sqlmodel import SQLModel, Session, create_engine
from app.config import get_settings
def get_engine(db_path: Path | None = None):
settings = get_settings()
path = db_path or settings.db_path
path.parent.mkdir(parents=True, exist_ok=True)
return create_engine(f"sqlite:///{path}", connect_args={"check_same_thread": False, "timeout": 30})
def init_db(engine=None) -> None:
SQLModel.metadata.create_all(engine or get_engine())
def get_session():
engine = get_engine()
with Session(engine) as session:
yield session

View File

@@ -0,0 +1,431 @@
from contextlib import asynccontextmanager
import json
from fastapi import Depends, FastAPI, HTTPException, Request
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from sqlmodel import Session, select
from app.config import get_settings
from app.db import get_engine, get_session, init_db
from app.models import ImportJob, Source
from app.providers.base import ProviderError
from app.providers.internet_archive import InternetArchiveProvider
from app.providers.mediathek import MediathekProvider
from app.providers.youtube import YouTubeProvider
from app.schemas import (
AnalyzeRequest,
AnalyzeResponse,
BrowserCaptureRequest,
ImportJobResponse,
ImportRequest,
InteractiveAnalyzeClickRequest,
InteractiveAnalyzeKeyRequest,
InteractiveAnalyzeResponse,
InteractiveAnalyzeStartRequest,
MediaCandidateResponse,
ProbeRequest,
ProbeResponse,
SourceResponse,
)
from app.services.downloader import metadata_json, run_import_job
from app.services.import_queue import TERMINAL_IMPORT_STATUSES, import_queue_manager
from app.services.interactive_browser import interactive_browser_manager
from app.services.page_analyzer import analyze_browser_capture, analyze_page
from app.services.url_safety import UnsafeUrlError
from app.services.auth import LoginPayload, auth_enabled, clear_session_cookie, login_response, require_user
providers = [YouTubeProvider(), InternetArchiveProvider(), MediathekProvider()]
engine = get_engine()
@asynccontextmanager
async def lifespan(app: FastAPI):
app.state.settings = get_settings()
init_db(engine)
await import_queue_manager.start(
engine=engine,
providers=providers,
settings=app.state.settings,
runner=run_import_job,
concurrency=app.state.settings.import_concurrency,
)
try:
yield
finally:
await import_queue_manager.stop()
app = FastAPI(title="Kino-Projekt", version="0.2.0", lifespan=lifespan)
app.state.settings = get_settings()
def pick_provider(url: str):
return next((p for p in providers if p.can_handle(url)), None)
IMPORTABLE_CANDIDATE_KINDS = {"direct_video", "hls_manifest", "dash_manifest"}
def candidate_import_score(candidate) -> tuple[int, int]:
kind_priority = {"direct_video": 3, "hls_manifest": 2, "dash_manifest": 2}.get(candidate.kind, 0)
return kind_priority, int(candidate.content_length or 0)
async def resolve_importable_url(url: str):
"""Return a provider/metadata for an importable media URL.
Users often paste the player/page URL first. If that page is reachable as
text/html, the generic provider cannot import it directly; analyze it and
switch to the best discovered media candidate before creating the job.
"""
provider = pick_provider(url)
if provider is None:
raise HTTPException(status_code=400, detail="No legal provider accepted this URL")
try:
metadata = await provider.probe(url)
return url, provider, metadata, None
except ProviderError as exc:
original_error = exc
if "text/html" not in str(exc).lower():
raise
candidates = await analyze_page(url)
allowed = [
candidate
for candidate in candidates
if candidate.allowed and candidate.kind in IMPORTABLE_CANDIDATE_KINDS
]
if not allowed:
raise HTTPException(
status_code=400,
detail=(
"HTML page is reachable, but no importable media URL was found automatically. "
"Use 'Seite analysieren' or the Browser-Capture bookmarklet after playing the video, "
"then import one of the discovered media candidates."
),
) from original_error
candidate = max(allowed, key=candidate_import_score)
provider = pick_provider(candidate.url)
if provider is None:
raise HTTPException(status_code=400, detail="No legal provider accepted the discovered media URL")
metadata = await provider.probe(candidate.url)
return candidate.url, provider, metadata, candidate
@app.get("/health")
def health() -> dict[str, str]:
return {"status": "ok"}
@app.get("/api/auth/status")
def auth_status(request: Request) -> dict[str, bool | str | None]:
settings = request.app.state.settings
user = None
if auth_enabled(settings):
try:
current = require_user(request)
user = current.username if current else None
except HTTPException:
user = None
return {"enabled": auth_enabled(settings), "authenticated": user is not None or not auth_enabled(settings), "username": user}
@app.post("/api/auth/login", status_code=204)
def login(payload: LoginPayload, request: Request):
return login_response(payload, request, request.app.state.settings)
@app.post("/api/auth/logout", status_code=204)
def logout(request: Request):
return clear_session_cookie(request)
@app.post("/api/probe", response_model=ProbeResponse)
async def probe(payload: ProbeRequest, _: object = Depends(require_user)) -> ProbeResponse:
url = str(payload.url)
provider = pick_provider(url)
if provider is None:
raise HTTPException(status_code=400, detail="No legal provider accepted this URL")
try:
resolved_url, provider, metadata, candidate = await resolve_importable_url(url)
except ProviderError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
response = metadata.model_dump()
if candidate is not None:
response["title"] = candidate.title or metadata.title
response["external_id"] = resolved_url
response["description"] = "Automatisch aus der HTML-Seite erkannte Medienquelle"
return ProbeResponse(**response, allowed=True)
@app.post("/api/analyze", response_model=AnalyzeResponse)
async def analyze(payload: AnalyzeRequest, _: object = Depends(require_user)) -> AnalyzeResponse:
try:
candidates = await analyze_page(str(payload.url))
except UnsafeUrlError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return AnalyzeResponse(
page_url=str(payload.url),
candidates=[MediaCandidateResponse(**candidate.__dict__) for candidate in candidates],
)
@app.post("/api/browser-capture", response_model=AnalyzeResponse)
async def browser_capture(payload: BrowserCaptureRequest, _: object = Depends(require_user)) -> AnalyzeResponse:
try:
candidates = await analyze_browser_capture(
[item.model_dump() for item in payload.candidates],
str(payload.page_url) if payload.page_url else None,
)
except Exception as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return AnalyzeResponse(
page_url=str(payload.page_url) if payload.page_url else "browser-capture",
candidates=[MediaCandidateResponse(**candidate.__dict__) for candidate in candidates],
)
async def interactive_response(session) -> InteractiveAnalyzeResponse:
candidates = await interactive_browser_manager.candidates(session)
return InteractiveAnalyzeResponse(
session_id=session.id,
page_url=session.page.url or session.page_url,
screenshot=await interactive_browser_manager.screenshot_data_url(session),
viewport={"width": 1280, "height": 720},
candidates=[MediaCandidateResponse(**candidate.__dict__) for candidate in candidates],
ublock_origin=bool(session.ublock_path),
)
@app.post("/api/interactive-analyze", response_model=InteractiveAnalyzeResponse)
async def start_interactive_analyze(payload: InteractiveAnalyzeStartRequest, _: object = Depends(require_user)) -> InteractiveAnalyzeResponse:
try:
session = await interactive_browser_manager.start(str(payload.url))
return await interactive_response(session)
except UnsafeUrlError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@app.post("/api/interactive-analyze/{session_id}/click", response_model=InteractiveAnalyzeResponse)
async def click_interactive_analyze(session_id: str, payload: InteractiveAnalyzeClickRequest, _: object = Depends(require_user)) -> InteractiveAnalyzeResponse:
try:
session = await interactive_browser_manager.click(session_id, payload.x, payload.y)
return await interactive_response(session)
except KeyError as exc:
raise HTTPException(status_code=404, detail="Interactive analysis session not found") from exc
except Exception as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@app.post("/api/interactive-analyze/{session_id}/key", response_model=InteractiveAnalyzeResponse)
async def key_interactive_analyze(session_id: str, payload: InteractiveAnalyzeKeyRequest, _: object = Depends(require_user)) -> InteractiveAnalyzeResponse:
try:
session = await interactive_browser_manager.press(session_id, payload.key)
return await interactive_response(session)
except KeyError as exc:
raise HTTPException(status_code=404, detail="Interactive analysis session not found") from exc
except Exception as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@app.delete("/api/interactive-analyze/{session_id}", status_code=204)
async def stop_interactive_analyze(session_id: str, _: object = Depends(require_user)) -> None:
await interactive_browser_manager.stop(session_id)
@app.post("/api/imports", response_model=ImportJobResponse, status_code=202)
async def create_import(
request: ImportRequest,
_: object = Depends(require_user),
session: Session = Depends(get_session),
) -> ImportJobResponse:
job = await create_import_job(request, session)
return import_job_response(job, session)
def import_job_response(job: ImportJob, session: Session) -> ImportJobResponse:
source = session.get(Source, job.source_id)
return ImportJobResponse(
id=job.id or 0,
source_id=job.source_id,
title=source.title if source is not None else None,
status=job.status,
target_library=job.target_library,
target_path=job.target_path,
progress=job.progress,
error=job.error,
created_at=job.created_at,
updated_at=job.updated_at,
)
async def create_import_job(request: ImportRequest, session: Session) -> ImportJob:
url = str(request.url)
provider = pick_provider(url)
if provider is None:
raise HTTPException(status_code=400, detail="No legal provider accepted this URL")
try:
resolved_url, provider, metadata, candidate = await resolve_importable_url(url)
except ProviderError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
source = Source(
kind=provider.name,
provider=provider.name,
url=resolved_url,
title=request.title or (candidate.title if candidate is not None else None) or metadata.title,
external_id=metadata.external_id,
metadata_json=metadata_json(
metadata,
import_request={
"source_page_url": url if resolved_url != url else None,
"discovered_media_url": resolved_url if resolved_url != url else None,
"discovered_candidate_kind": candidate.kind if candidate is not None else None,
"target_profile": request.target_profile,
"title": request.title,
"year": request.year,
"series_title": request.series_title,
"season": request.season,
"episode": request.episode,
"episode_title": request.episode_title,
},
),
)
session.add(source)
session.commit()
session.refresh(source)
job = ImportJob(source_id=source.id, target_library=request.target_profile, status="queued")
session.add(job)
session.commit()
session.refresh(job)
if job.id is not None:
await import_queue_manager.enqueue(job.id)
return job
@app.get("/api/imports", response_model=list[ImportJobResponse])
def list_imports(_: object = Depends(require_user), session: Session = Depends(get_session)) -> list[ImportJobResponse]:
jobs = list(session.exec(select(ImportJob).order_by(ImportJob.created_at.desc())).all())
return [import_job_response(job, session) for job in jobs]
@app.get("/api/imports/{job_id}", response_model=ImportJobResponse)
def get_import(job_id: int, _: object = Depends(require_user), session: Session = Depends(get_session)) -> ImportJobResponse:
job = session.get(ImportJob, job_id)
if job is None:
raise HTTPException(status_code=404, detail="Import job not found")
return import_job_response(job, session)
@app.delete("/api/imports/{job_id}", status_code=204)
def delete_import(job_id: int, _: object = Depends(require_user), session: Session = Depends(get_session)) -> None:
job = session.get(ImportJob, job_id)
if job is None:
raise HTTPException(status_code=404, detail="Import job not found")
if job.status not in TERMINAL_IMPORT_STATUSES:
raise HTTPException(status_code=409, detail="Only completed, failed or cancelled imports can be deleted from the overview")
# This deletes only the import record from the overview. Jellyfin media is not removed here.
session.delete(job)
session.commit()
@app.post("/api/imports/{job_id}/retry", response_model=ImportJobResponse, status_code=202)
async def retry_import(job_id: int, _: object = Depends(require_user), session: Session = Depends(get_session)) -> ImportJobResponse:
old_job = session.get(ImportJob, job_id)
if old_job is None:
raise HTTPException(status_code=404, detail="Import job not found")
if old_job.status not in TERMINAL_IMPORT_STATUSES:
raise HTTPException(status_code=409, detail="Only completed, failed or cancelled imports can be retried")
new_job = ImportJob(source_id=old_job.source_id, target_library=old_job.target_library, status="queued")
session.add(new_job)
session.commit()
session.refresh(new_job)
if new_job.id is not None:
await import_queue_manager.enqueue(new_job.id)
return import_job_response(new_job, session)
@app.post("/api/imports/{job_id}/retry-alternative", response_model=ImportJobResponse, status_code=202)
async def retry_import_alternative(job_id: int, _: object = Depends(require_user), session: Session = Depends(get_session)) -> ImportJobResponse:
old_job = session.get(ImportJob, job_id)
if old_job is None:
raise HTTPException(status_code=404, detail="Import job not found")
if old_job.status not in TERMINAL_IMPORT_STATUSES:
raise HTTPException(status_code=409, detail="Only completed, failed or cancelled imports can use alternative retry")
source = session.get(Source, old_job.source_id)
if source is None:
raise HTTPException(status_code=404, detail="Import source missing")
try:
stored = json.loads(source.metadata_json or "{}")
except Exception:
stored = {}
import_request = stored.get("import_request", {}) if isinstance(stored, dict) else {}
retry_url = import_request.get("source_page_url") or source.url
try:
resolved_url, provider, metadata, candidate = await resolve_importable_url(retry_url)
except ProviderError as exc:
raise HTTPException(status_code=400, detail=f"Alternative Analyse fehlgeschlagen: {exc}") from exc
new_source = Source(
kind=provider.name,
provider=provider.name,
url=resolved_url,
title=source.title or (candidate.title if candidate is not None else None) or metadata.title,
external_id=metadata.external_id,
metadata_json=metadata_json(
metadata,
import_request={
**import_request,
"source_page_url": retry_url if resolved_url != retry_url else import_request.get("source_page_url"),
"discovered_media_url": resolved_url if resolved_url != retry_url else None,
"discovered_candidate_kind": candidate.kind if candidate is not None else None,
"alternative_retry_of": job_id,
},
),
)
session.add(new_source)
session.commit()
session.refresh(new_source)
new_job = ImportJob(source_id=new_source.id, target_library=old_job.target_library, status="queued")
session.add(new_job)
session.commit()
session.refresh(new_job)
if new_job.id is not None:
await import_queue_manager.enqueue(new_job.id)
return import_job_response(new_job, session)
@app.post("/api/imports/{job_id}/cancel", response_model=ImportJobResponse)
def cancel_import(job_id: int, _: object = Depends(require_user), session: Session = Depends(get_session)) -> ImportJobResponse:
job = session.get(ImportJob, job_id)
if job is None:
raise HTTPException(status_code=404, detail="Import job not found")
if job.status in TERMINAL_IMPORT_STATUSES:
raise HTTPException(status_code=409, detail="Import is already finished")
job.status = "cancelled" if job.status == "queued" else "cancelling"
job.error = "Abbruch angefordert"
job.updated_at = utcnow()
session.add(job)
session.commit()
session.refresh(job)
return import_job_response(job, session)
@app.get("/api/sources", response_model=list[SourceResponse])
def list_sources(_: object = Depends(require_user), session: Session = Depends(get_session)) -> list[Source]:
return list(session.exec(select(Source).order_by(Source.created_at.desc())).all())
app.mount("/static", StaticFiles(directory="app/web"), name="static")
@app.get("/", include_in_schema=False)
def index() -> FileResponse:
return FileResponse("app/web/index.html", headers={"Cache-Control": "no-store"})

View File

@@ -0,0 +1,30 @@
from datetime import datetime, timezone
from sqlmodel import Field, SQLModel
def utcnow() -> datetime:
return datetime.now(timezone.utc)
class Source(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True)
kind: str
url: str
title: str | None = None
provider: str | None = None
external_id: str | None = None
metadata_json: str = "{}"
created_at: datetime = Field(default_factory=utcnow)
class ImportJob(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True)
source_id: int = Field(foreign_key="source.id")
status: str = "queued"
target_library: str
target_path: str | None = None
progress: float = 0.0
error: str | None = None
created_at: datetime = Field(default_factory=utcnow)
updated_at: datetime = Field(default_factory=utcnow)

View File

@@ -0,0 +1,46 @@
from collections.abc import Callable
from pathlib import Path
from typing import Protocol
from pydantic import BaseModel
class ProviderError(RuntimeError):
pass
class MediaMetadata(BaseModel):
provider: str
title: str | None = None
description: str | None = None
thumbnail: str | None = None
duration_seconds: int | None = None
external_id: str | None = None
class DownloadOptions(BaseModel):
max_height: int = 1080
target_library: str = "movies"
audio_only: bool = False
max_bytes: int = 15_000_000_000
class DownloadResult(BaseModel):
output_files: list[Path]
metadata_files: list[Path] = []
class Provider(Protocol):
name: str
def can_handle(self, url: str) -> bool: ...
async def probe(self, url: str) -> MediaMetadata: ...
async def download(
self,
url: str,
target_dir: Path,
options: DownloadOptions,
progress_callback: Callable[[float], None] | None = None,
) -> DownloadResult: ...

View File

@@ -0,0 +1,123 @@
from collections.abc import Callable
from pathlib import Path
from urllib.parse import quote, urlparse
import httpx
from app.providers.base import DownloadOptions, DownloadResult, MediaMetadata, ProviderError
from app.providers.mediathek import MEDIA_EXTENSIONS, MediathekProvider
from app.services.url_safety import BROWSER_LIKE_HEADERS
ARCHIVE_HOSTS = {"archive.org", "www.archive.org"}
METADATA_BASE = "https://archive.org/metadata"
DOWNLOAD_BASE = "https://archive.org/download"
class InternetArchiveProvider:
name = "internet_archive"
def can_handle(self, url: str) -> bool:
parsed = urlparse(url)
return parsed.scheme in {"http", "https"} and (parsed.hostname or "").lower() in ARCHIVE_HOSTS and self._identifier_from_url(url) is not None
async def probe(self, url: str) -> MediaMetadata:
identifier = self._identifier_from_url(url)
if not identifier:
raise ProviderError("Internet Archive URL must contain an item identifier")
metadata = await self._metadata(identifier)
meta = metadata.get("metadata") or {}
files = metadata.get("files") or []
selected = self._select_media_file(files)
if selected is None:
raise ProviderError("Internet Archive item has no accepted public media file")
title = self._string(meta.get("title")) or identifier
description = self._string(meta.get("description"))
return MediaMetadata(
provider=self.name,
title=title,
description=description,
external_id=identifier,
)
async def download(
self,
url: str,
target_dir: Path,
options: DownloadOptions,
progress_callback: Callable[[float], None] | None = None,
) -> DownloadResult:
identifier = self._identifier_from_url(url)
if not identifier:
raise ProviderError("Internet Archive URL must contain an item identifier")
metadata = await self._metadata(identifier)
selected = self._select_media_file(metadata.get("files") or [])
if selected is None:
raise ProviderError("Internet Archive item has no accepted public media file")
file_name = selected["name"]
safe_url = f"{DOWNLOAD_BASE}/{quote(identifier, safe='')}/{quote(file_name, safe='/')}"
return await MediathekProvider().download(safe_url, target_dir, options, progress_callback=progress_callback)
async def _metadata(self, identifier: str) -> dict:
url = f"{METADATA_BASE}/{quote(identifier, safe='')}"
async with httpx.AsyncClient(timeout=httpx.Timeout(15, read=20), follow_redirects=True) as client:
response = await client.get(url, headers=BROWSER_LIKE_HEADERS)
if response.status_code == 404:
raise ProviderError(f"Internet Archive item not found: {identifier}")
response.raise_for_status()
data = response.json()
if not isinstance(data, dict):
raise ProviderError("Internet Archive metadata response was not an object")
return data
def _identifier_from_url(self, url: str) -> str | None:
parsed = urlparse(url)
parts = [part for part in parsed.path.split("/") if part]
if not parts:
return None
if parts[0] in {"details", "download", "metadata"} and len(parts) >= 2:
return parts[1]
return parts[0]
def _select_media_file(self, files: list[dict]) -> dict | None:
candidates: list[tuple[int, dict]] = []
for item in files:
if not isinstance(item, dict):
continue
name = item.get("name")
if not isinstance(name, str) or not name or name.endswith("/") or ".." in Path(name).parts:
continue
lower = name.lower()
ext = Path(lower).suffix
fmt = str(item.get("format") or "").lower()
source = str(item.get("source") or "").lower()
if ext not in MEDIA_EXTENSIONS and "mpeg4" not in fmt and "h.264" not in fmt:
continue
if source in {"metadata", "derivative"} and ext not in {".mp4", ".m4v", ".mp3", ".m4a", ".ogg", ".webm"}:
continue
size = self._int_or_zero(item.get("size"))
# Prefer original/public mp4-ish media, then larger files as likely higher quality.
score = 0
if source == "original":
score += 1000
if ext in {".mp4", ".m4v", ".webm"}:
score += 500
if size:
score += min(size // 1_000_000, 400)
candidates.append((score, item))
if not candidates:
return None
candidates.sort(key=lambda pair: pair[0], reverse=True)
return candidates[0][1]
def _int_or_zero(self, value: object) -> int:
try:
return int(value) # type: ignore[arg-type]
except (TypeError, ValueError):
return 0
def _string(self, value: object) -> str | None:
if isinstance(value, str):
return value.strip() or None
if isinstance(value, list) and value and isinstance(value[0], str):
return value[0].strip() or None
return None

View File

@@ -0,0 +1,204 @@
import asyncio
import ipaddress
import shutil
import socket
import sys
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
from app.services.url_safety import BROWSER_LIKE_MEDIA_HEADERS, CHROME_WINDOWS_11_USER_AGENT
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",
}
MANIFEST_CONTENT_TYPES = {"application/vnd.apple.mpegurl", "application/x-mpegurl", "application/dash+xml"}
MANIFEST_EXTENSIONS = {".m3u8", ".mpd"}
def is_stream_manifest(url: str, content_type: str | None = None) -> bool:
clean_content_type = (content_type or "").split(";", 1)[0].lower()
ext = PurePosixPath(urlparse(url).path).suffix.lower()
return clean_content_type in MANIFEST_CONTENT_TYPES or ext in MANIFEST_EXTENSIONS
def ytdlp_command() -> list[str]:
binary = shutil.which("yt-dlp")
if binary:
return [binary]
return [sys.executable, "-m", "yt_dlp"]
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, headers=BROWSER_LIKE_MEDIA_HEADERS)
if response.status_code in {405, 403}:
response = await client.get(url, headers={**BROWSER_LIKE_MEDIA_HEADERS, "Range": "bytes=0-0"})
for hop in [*response.history, response]:
host = hop.url.host
if host:
self._reject_private_targets(host)
content_type = response.headers.get("content-type", "").split(";", 1)[0].lower()
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)
target_dir.mkdir(parents=True, exist_ok=True)
source_url = metadata.external_id or url
if is_stream_manifest(source_url):
return await self._download_with_ytdlp(source_url, target_dir, options, progress_callback)
name = PurePosixPath(urlparse(source_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", source_url, headers=BROWSER_LIKE_MEDIA_HEADERS) as response:
response.raise_for_status()
for hop in [*response.history, response]:
host = hop.url.host
if host:
self._reject_private_targets(host)
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_with_ytdlp(
self,
url: str,
target_dir: Path,
options: DownloadOptions,
progress_callback: Callable[[float], None] | None = None,
) -> DownloadResult:
"""Download HLS/DASH manifests as real media, not the tiny manifest file.
A .m3u8/.mpd is only a playlist/manifest. Saving it directly produces a
few-KB text file in Jellyfin instead of the actual media stream. Let
yt-dlp drive ffmpeg so segments are downloaded and remuxed into MP4.
"""
command = ytdlp_command()
output_template = str(target_dir / "%(title,playlist_index,id).120s.%(ext)s")
height_expr = f"bestvideo[height<={options.max_height}]+bestaudio/best[height<={options.max_height}]/best"
if options.audio_only:
height_expr = "bestaudio/best"
proc = await asyncio.create_subprocess_exec(
*command,
"--newline",
"--no-playlist",
"--no-warnings",
"--restrict-filenames",
"--format",
height_expr,
"--remux-video",
"mp4",
"--max-filesize",
str(options.max_bytes),
"--user-agent",
CHROME_WINDOWS_11_USER_AGENT,
"--add-header",
f"Accept-Language: {BROWSER_LIKE_MEDIA_HEADERS['Accept-Language']}",
"--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)
try:
await asyncio.wait_for(asyncio.gather(consume(proc.stdout), consume(proc.stderr), proc.wait()), timeout=60 * 60)
except BaseException:
if proc.returncode is None:
proc.kill()
await proc.wait()
raise
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", ".m3u8", ".mpd"}]
if not media_files:
raise ProviderError("yt-dlp finished without producing a media file")
if progress_callback is not None:
progress_callback(1.0)
return DownloadResult(output_files=media_files)
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}")

View File

@@ -0,0 +1,151 @@
import asyncio
import json
import re
import shutil
import sys
from collections.abc import Callable
from pathlib import Path
from urllib.parse import parse_qs, urlparse
from app.providers.base import DownloadOptions, DownloadResult, MediaMetadata, ProviderError
from app.services.url_safety import CHROME_WINDOWS_11_USER_AGENT, BROWSER_LIKE_HEADERS
class YouTubeProvider:
name = "youtube"
allowed_hosts = {"youtube.com", "www.youtube.com", "m.youtube.com", "youtu.be"}
def can_handle(self, url: str) -> bool:
host = urlparse(url).hostname or ""
return host.lower() in self.allowed_hosts
async def probe(self, url: str) -> MediaMetadata:
if not self.can_handle(url):
raise ProviderError("Not a YouTube URL")
if shutil.which("yt-dlp") is None:
return MediaMetadata(provider=self.name, external_id=self._external_id(url), title="YouTube URL (yt-dlp not installed)")
proc = await asyncio.create_subprocess_exec(
"yt-dlp",
"--dump-single-json",
"--no-playlist",
"--no-warnings",
"--user-agent",
CHROME_WINDOWS_11_USER_AGENT,
"--add-header",
f"Accept-Language: {BROWSER_LIKE_HEADERS['Accept-Language']}",
url,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=45)
if proc.returncode != 0:
raise ProviderError((stderr.decode(errors="replace") or "yt-dlp probe failed").strip()[:500])
data = json.loads(stdout.decode())
return MediaMetadata(
provider=self.name,
title=data.get("title"),
description=data.get("description"),
thumbnail=data.get("thumbnail"),
duration_seconds=data.get("duration"),
external_id=data.get("id") or self._external_id(url),
)
async def download(
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 not installed")
target_dir.mkdir(parents=True, exist_ok=True)
height_expr = f"bestvideo[height<={options.max_height}]+bestaudio/best[height<={options.max_height}]/best"
if options.audio_only:
height_expr = "bestaudio/best"
output_template = str(target_dir / "%(title).120s [%(id)s].%(ext)s")
proc = await asyncio.create_subprocess_exec(
"yt-dlp",
"--newline",
"--no-playlist",
"--no-warnings",
"--restrict-filenames",
"--write-info-json",
"--write-thumbnail",
"--user-agent",
CHROME_WINDOWS_11_USER_AGENT,
"--add-header",
f"Accept-Language: {BROWSER_LIKE_HEADERS['Accept-Language']}",
"--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)
try:
await asyncio.wait_for(asyncio.gather(consume(proc.stdout), consume(proc.stderr), proc.wait()), timeout=60 * 60)
except BaseException:
if proc.returncode is None:
proc.kill()
await proc.wait()
raise
if proc.returncode != 0:
detail = "\n".join(captured).strip() or "yt-dlp 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"}]
metadata_files = [p for p in files if p not in media_files]
if not media_files:
raise ProviderError("yt-dlp finished without producing a media file")
if progress_callback is not None:
progress_callback(1.0)
return DownloadResult(output_files=media_files, metadata_files=metadata_files)
def _external_id(self, url: str) -> str | None:
parsed = urlparse(url)
if parsed.hostname == "youtu.be":
return parsed.path.strip("/") or None
return parse_qs(parsed.query).get("v", [None])[0]
def parse_yt_dlp_progress(line: str) -> float | None:
"""Return yt-dlp percent progress as 0.0..1.0, or None for non-progress lines."""
if "[download]" not in line:
return None
match = re.search(r"(\d+(?:\.\d+)?)%", line)
if not match:
return None
return max(0.0, min(float(match.group(1)) / 100.0, 1.0))
async def _main(url: str) -> None:
meta = await YouTubeProvider().probe(url)
print(meta.model_dump_json(indent=2))
if __name__ == "__main__":
if len(sys.argv) != 2:
raise SystemExit("usage: python -m app.providers.youtube <url>")
asyncio.run(_main(sys.argv[1]))

View File

@@ -0,0 +1,116 @@
from datetime import datetime
from typing import Any
from pydantic import AnyHttpUrl, BaseModel, Field
class ProbeRequest(BaseModel):
url: AnyHttpUrl
class AnalyzeRequest(BaseModel):
url: AnyHttpUrl
class BrowserCapturedCandidate(BaseModel):
url: str
title: str | None = None
name: str | None = None
content_type: str | None = None
type: str | None = None
quality: str | None = None
class BrowserCaptureRequest(BaseModel):
page_url: AnyHttpUrl | None = None
candidates: list[BrowserCapturedCandidate] = Field(default_factory=list, max_length=80)
class MediaCandidateResponse(BaseModel):
url: str
kind: str
title: str | None = None
source: str
content_type: str | None = None
content_length: int | None = None
file_size: str | None = None
quality: str | None = None
allowed: bool = True
reason: str | None = None
metadata: dict[str, Any] = Field(default_factory=dict)
class AnalyzeResponse(BaseModel):
page_url: str
candidates: list[MediaCandidateResponse]
class InteractiveAnalyzeStartRequest(BaseModel):
url: AnyHttpUrl
class InteractiveAnalyzeClickRequest(BaseModel):
x: float = Field(ge=0)
y: float = Field(ge=0)
class InteractiveAnalyzeKeyRequest(BaseModel):
key: str = Field(min_length=1, max_length=40)
class InteractiveAnalyzeResponse(BaseModel):
session_id: str
page_url: str
screenshot: str
viewport: dict[str, int]
candidates: list[MediaCandidateResponse]
ublock_origin: bool = False
class ProbeResponse(BaseModel):
provider: str
title: str | None = None
description: str | None = None
thumbnail: str | None = None
duration_seconds: int | None = None
external_id: str | None = None
allowed: bool = True
class ImportRequest(BaseModel):
url: AnyHttpUrl
target_profile: str = Field(default="clip", pattern="^(film|serie|clip)$")
title: str | None = Field(default=None, max_length=180)
year: int | None = Field(default=None, ge=1888, le=2200)
series_title: str | None = Field(default=None, max_length=180)
season: int | None = Field(default=None, ge=1, le=200)
episode: int | None = Field(default=None, ge=1, le=1000)
episode_title: str | None = Field(default=None, max_length=180)
target_library: str | None = Field(default=None, max_length=80) # legacy API alias
max_height: int = Field(default=1080, ge=144, le=2160)
audio_only: bool = False
class ImportJobResponse(BaseModel):
id: int
source_id: int
title: str | None = None
status: str
target_library: str
target_path: str | None = None
progress: float
error: str | None = None
created_at: datetime
updated_at: datetime
class SourceResponse(BaseModel):
id: int
kind: str
url: str
title: str | None = None
provider: str | None = None
external_id: str | None = None
created_at: datetime

View File

@@ -0,0 +1,122 @@
import base64
import hashlib
import hmac
import secrets
import time
from dataclasses import dataclass
from argon2 import PasswordHasher
from argon2.exceptions import InvalidHashError, VerifyMismatchError
from fastapi import HTTPException, Request, Response, status
from pydantic import BaseModel
from app.config import Settings
SESSION_COOKIE = "kino_session"
SESSION_TTL_SECONDS = 60 * 60 * 12
_ph = PasswordHasher()
class LoginPayload(BaseModel):
username: str
password: str
@dataclass(frozen=True)
class AuthenticatedUser:
username: str
def hash_password(password: str) -> str:
return _ph.hash(password)
def verify_password(password: str, password_hash: str) -> bool:
try:
return _ph.verify(password_hash, password)
except (VerifyMismatchError, InvalidHashError):
return False
def auth_enabled(settings: Settings) -> bool:
return bool(settings.admin_password_hash and settings.session_secret)
def _sign(value: str, secret: str) -> str:
return hmac.new(secret.encode("utf-8"), value.encode("utf-8"), hashlib.sha256).hexdigest()
def create_session_cookie(username: str, settings: Settings, now: int | None = None) -> str:
if not settings.session_secret:
raise RuntimeError("session secret is not configured")
issued = int(time.time() if now is None else now)
expires = issued + SESSION_TTL_SECONDS
payload = f"{username}:{expires}"
encoded = base64.urlsafe_b64encode(payload.encode("utf-8")).decode("ascii").rstrip("=")
return f"{encoded}.{_sign(encoded, settings.session_secret)}"
def parse_session_cookie(cookie_value: str | None, settings: Settings, now: int | None = None) -> AuthenticatedUser | None:
if not cookie_value or not settings.session_secret or "." not in cookie_value:
return None
encoded, signature = cookie_value.rsplit(".", 1)
if not hmac.compare_digest(signature, _sign(encoded, settings.session_secret)):
return None
try:
padding = "=" * (-len(encoded) % 4)
payload = base64.urlsafe_b64decode((encoded + padding).encode("ascii")).decode("utf-8")
username, expires_raw = payload.rsplit(":", 1)
expires = int(expires_raw)
except (ValueError, UnicodeDecodeError):
return None
current = int(time.time() if now is None else now)
if expires < current:
return None
if not secrets.compare_digest(username, settings.admin_username):
return None
return AuthenticatedUser(username=username)
def require_user(request: Request) -> AuthenticatedUser | None:
settings = request.app.state.settings
if not auth_enabled(settings):
return None
user = parse_session_cookie(request.cookies.get(SESSION_COOKIE), settings)
if user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Login required")
return user
def should_use_secure_cookie(request: Request) -> bool:
forwarded_proto = request.headers.get("x-forwarded-proto", "").split(",", 1)[0].strip().lower()
return request.url.scheme == "https" or forwarded_proto == "https"
def login_response(payload: LoginPayload, request: Request, settings: Settings) -> Response:
if not auth_enabled(settings):
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="App login is not configured")
if not secrets.compare_digest(payload.username, settings.admin_username):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid username or password")
if not verify_password(payload.password, settings.admin_password_hash or ""):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid username or password")
response = Response(status_code=status.HTTP_204_NO_CONTENT)
response.set_cookie(
SESSION_COOKIE,
create_session_cookie(payload.username, settings),
httponly=True,
secure=should_use_secure_cookie(request),
samesite="lax",
max_age=SESSION_TTL_SECONDS,
)
return response
def clear_session_cookie(request: Request | None = None) -> Response:
response = Response(status_code=status.HTTP_204_NO_CONTENT)
response.delete_cookie(
SESSION_COOKIE,
httponly=True,
secure=should_use_secure_cookie(request) if request is not None else True,
samesite="lax",
)
return response

View File

@@ -0,0 +1,293 @@
import asyncio
import hashlib
import json
import shlex
import shutil
from pathlib import Path
from sqlmodel import Session
from app.config import Settings
from app.models import ImportJob, Source, utcnow
from app.providers.base import DownloadOptions, Provider, ProviderError
from app.services.jellyfin import refresh_jellyfin
from app.services.paths import jellyfin_profile_target, tmp_target
class ImportCancelled(RuntimeError):
pass
def file_md5(path: Path, chunk_size: int = 1024 * 1024) -> str:
digest = hashlib.md5() # nosec B324 - integrity check, not security/auth
with path.open("rb") as fh:
while chunk := fh.read(chunk_size):
digest.update(chunk)
return digest.hexdigest()
def local_md5_manifest(base_dir: Path) -> dict[str, str]:
base_dir = base_dir.resolve()
manifest: dict[str, str] = {}
if not base_dir.exists():
return manifest
for path in sorted(p for p in base_dir.rglob("*") if p.is_file()):
if path.is_symlink():
raise ProviderError(f"Refusing to hash symlink: {path.relative_to(base_dir)}")
manifest[path.relative_to(base_dir).as_posix()] = file_md5(path)
return manifest
def job_title(source: Source | None) -> str:
if source is None:
return "media"
return source.title or source.external_id or "media"
def ensure_not_cancelled(session: Session, job: ImportJob) -> None:
session.refresh(job)
if job.status in {"cancelling", "cancelled"}:
raise ImportCancelled("Import abgebrochen")
async def remote_md5_manifest(remote_dir: str, settings: Settings) -> dict[str, str]:
if not settings.rsync_target or not settings.rsync_ssh_key:
return {}
ssh_cmd = [
"ssh",
"-i",
str(settings.rsync_ssh_key),
"-o",
"BatchMode=yes",
"-o",
"StrictHostKeyChecking=accept-new",
settings.rsync_target,
"cd " + shlex.quote(remote_dir) + " && find . -type f -print0 | sort -z | xargs -0 md5sum",
]
proc = await asyncio.create_subprocess_exec(
*ssh_cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await proc.communicate()
if proc.returncode != 0:
raise ProviderError(f"Jellyfin md5 verification failed: {stderr.decode(errors='replace')[:500]}")
manifest: dict[str, str] = {}
for line in stdout.decode(errors="replace").splitlines():
if not line.strip():
continue
checksum, _, rel = line.partition(" ")
if not rel:
checksum, _, rel = line.partition(" ")
manifest[rel.removeprefix("./")] = checksum.strip()
return manifest
def remote_dir_from_target(target_path: str | None, settings: Settings) -> str | None:
if not target_path or not settings.rsync_target:
return None
prefix = f"{settings.rsync_target}:"
if target_path.startswith(prefix):
return target_path[len(prefix):]
return None
async def verify_jellyfin_transfer(local_dir: Path, target_path: str | None, settings: Settings) -> bool:
local_manifest = local_md5_manifest(local_dir)
if not local_manifest:
raise ProviderError("No local files available for md5 verification")
remote_dir = remote_dir_from_target(target_path, settings)
if remote_dir is None:
# No remote rsync target: the local media root is the final target.
return local_md5_manifest(local_dir) == local_manifest
remote_manifest = await remote_md5_manifest(remote_dir, settings)
if remote_manifest != local_manifest:
missing = sorted(set(local_manifest) - set(remote_manifest))[:5]
changed = sorted(k for k in local_manifest.keys() & remote_manifest.keys() if local_manifest[k] != remote_manifest[k])[:5]
raise ProviderError(f"Jellyfin md5 verification mismatch; missing={missing}, changed={changed}")
return True
async def cleanup_verified_download_data(tmp_dir: Path, target_dir: Path, target_path: str | None, settings: Settings) -> None:
if tmp_dir.exists():
shutil.rmtree(tmp_dir)
# When rsync copied to a remote Jellyfin host, local media_root is staging and can be removed after md5 proof.
if remote_dir_from_target(target_path, settings) is not None and target_dir.exists():
media_root = settings.media_root.resolve()
resolved = target_dir.resolve()
if resolved != media_root and media_root in resolved.parents:
shutil.rmtree(resolved)
def copy_tree_contents(src: Path, dest: Path) -> list[Path]:
dest.mkdir(parents=True, exist_ok=True)
copied: list[Path] = []
for path in src.iterdir():
if path.is_symlink():
raise ProviderError(f"Refusing to copy symlink from import output: {path.name}")
target = dest / path.name
if path.is_dir():
for child in path.rglob("*"):
if child.is_symlink():
raise ProviderError(f"Refusing to copy symlink from import output: {child.relative_to(src)}")
if target.exists():
shutil.rmtree(target)
shutil.copytree(path, target)
copied.extend(p for p in target.rglob("*") if p.is_file())
else:
shutil.copy2(path, target)
copied.append(target)
return copied
async def sync_to_jellyfin_vm(local_dir: Path, settings: Settings) -> str | None:
"""Copy the completed local import directory to Jellyfin's media VM via rsync."""
if not settings.rsync_target or not settings.rsync_ssh_key:
return None
local_dir = local_dir.resolve()
media_root = settings.media_root.resolve()
if media_root != local_dir and media_root not in local_dir.parents:
raise ProviderError(f"Refusing to sync path outside media root: {local_dir}")
relative = local_dir.relative_to(media_root)
remote_root = Path(str(settings.rsync_remote_root)).as_posix().rstrip("/") or "/jellyfin"
remote_dir = f"{remote_root}/{relative.as_posix()}"
ssh_cmd = [
"ssh",
"-i",
str(settings.rsync_ssh_key),
"-o",
"BatchMode=yes",
"-o",
"StrictHostKeyChecking=accept-new",
]
mkdir_proc = await asyncio.create_subprocess_exec(
*ssh_cmd,
settings.rsync_target,
f"mkdir -p {shlex.quote(remote_dir)}",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
_, mkdir_stderr = await mkdir_proc.communicate()
if mkdir_proc.returncode != 0:
raise ProviderError(f"Jellyfin transfer mkdir failed: {mkdir_stderr.decode(errors='replace')[:500]}")
rsync_proc = await asyncio.create_subprocess_exec(
"rsync",
"-a",
"--delete",
"-s",
"-e",
" ".join(shlex.quote(part) for part in ssh_cmd),
f"{local_dir}/",
f"{settings.rsync_target}:{remote_dir}/",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
_, rsync_stderr = await rsync_proc.communicate()
if rsync_proc.returncode != 0:
raise ProviderError(f"Jellyfin transfer rsync failed: {rsync_stderr.decode(errors='replace')[:500]}")
return f"{settings.rsync_target}:{remote_dir}"
def update_job(session: Session, job: ImportJob, **fields) -> None:
for key, value in fields.items():
setattr(job, key, value)
job.updated_at = utcnow()
session.add(job)
session.commit()
session.refresh(job)
async def run_import_job(job_id: int, engine, providers: list[Provider], settings: Settings) -> None:
tmp_dir: Path | None = None
target_dir: Path | None = None
with Session(engine) as session:
job = session.get(ImportJob, job_id)
if job is None:
return
source = session.get(Source, job.source_id)
if source is None:
update_job(session, job, status="failed", error="Source missing")
return
provider = next((p for p in providers if p.name == source.provider or p.can_handle(source.url)), None)
if provider is None:
update_job(session, job, status="failed", error="No provider available for source")
return
try:
ensure_not_cancelled(session, job)
last_reported_progress = 0.0
def report_download_progress(provider_progress: float) -> None:
nonlocal last_reported_progress
ensure_not_cancelled(session, job)
mapped = 0.15 + (max(0.0, min(provider_progress, 1.0)) * 0.60)
if mapped - last_reported_progress >= 0.01 or mapped >= 0.75:
last_reported_progress = mapped
update_job(session, job, status="downloading", progress=round(mapped, 3))
update_job(session, job, status="downloading", progress=0.15, error=None)
title = job_title(source)
stored_metadata = json.loads(source.metadata_json or "{}")
import_request = stored_metadata.get("import_request", {}) if isinstance(stored_metadata, dict) else {}
provider_metadata = stored_metadata.get("metadata", stored_metadata) if isinstance(stored_metadata, dict) else {}
tmp_dir = tmp_target(settings.tmp_dir, job.id or 0, title)
result = await provider.download(
source.url,
tmp_dir,
DownloadOptions(
max_height=settings.default_max_height,
target_library=job.target_library,
max_bytes=settings.max_download_bytes,
),
progress_callback=report_download_progress,
)
ensure_not_cancelled(session, job)
update_job(session, job, status="postprocessing", progress=0.75)
target_dir = jellyfin_profile_target(
settings.media_root,
job.target_library,
import_request.get("title") or title,
year=import_request.get("year"),
series_title=import_request.get("series_title"),
season=import_request.get("season"),
episode=import_request.get("episode"),
episode_title=import_request.get("episode_title"),
channel=provider_metadata.get("uploader") or provider_metadata.get("channel") or source.provider,
external_id=source.external_id,
)
update_job(session, job, status="copying", progress=0.82)
copied = copy_tree_contents(tmp_dir, target_dir)
if not copied and not result.output_files:
raise ProviderError("No files were produced by the import")
ensure_not_cancelled(session, job)
remote_target = await sync_to_jellyfin_vm(target_dir, settings)
final_target = remote_target or str(target_dir)
update_job(session, job, target_path=final_target, status="refreshing", progress=0.9)
verify_ok = await verify_jellyfin_transfer(target_dir, final_target, settings)
if verify_ok:
await cleanup_verified_download_data(tmp_dir, target_dir, final_target, settings)
refreshed = await refresh_jellyfin(settings)
note = None if refreshed else "Import completed; Jellyfin refresh skipped because no API key is configured"
update_job(session, job, status="done", progress=1.0, error=note)
except ImportCancelled:
if tmp_dir and tmp_dir.exists():
shutil.rmtree(tmp_dir)
update_job(session, job, status="cancelled", error="Import abgebrochen; temporäre Download-Dateien gelöscht")
except Exception as exc: # keep background task failures visible in DB/API
update_job(session, job, status="failed", error=str(exc)[:1000])
def schedule_import(job_id: int, engine, providers: list[Provider], settings: Settings) -> None:
asyncio.create_task(run_import_job(job_id, engine, providers, settings))
def metadata_json(metadata, *, import_request: dict | None = None) -> str:
payload = metadata.model_dump()
if import_request is not None:
payload = {"metadata": payload, "import_request": import_request}
return json.dumps(payload, ensure_ascii=False)

View File

@@ -0,0 +1,94 @@
from __future__ import annotations
import asyncio
from collections.abc import Awaitable, Callable
from sqlmodel import Session, select
from app.models import ImportJob, utcnow
ACTIVE_IMPORT_STATUSES = {"queued", "downloading", "postprocessing", "copying", "refreshing"}
TERMINAL_IMPORT_STATUSES = {"done", "failed"}
class ImportQueueManager:
"""In-process background worker pool for import jobs.
Jobs are persisted in SQLite, so the UI can poll progress and a service restart
requeues any jobs that were active when the process stopped. The heavy work is
still executed inside the app process, but decoupled from the request that
created the job and bounded by a configurable concurrency limit.
"""
def __init__(self) -> None:
self._queue: asyncio.Queue[int] = asyncio.Queue()
self._workers: list[asyncio.Task] = []
self._known: set[int] = set()
self._stopping = False
async def start(
self,
*,
engine,
providers,
settings,
runner: Callable[[int, object, list, object], Awaitable[None]],
concurrency: int,
) -> None:
self._stopping = False
self._engine = engine
self._providers = providers
self._settings = settings
self._runner = runner
self._concurrency = max(1, int(concurrency or 1))
await self.requeue_active_jobs()
self._workers = [asyncio.create_task(self._worker(idx)) for idx in range(self._concurrency)]
async def stop(self) -> None:
self._stopping = True
for worker in self._workers:
worker.cancel()
if self._workers:
await asyncio.gather(*self._workers, return_exceptions=True)
self._workers = []
self._known.clear()
async def enqueue(self, job_id: int) -> None:
if self._stopping or job_id in self._known:
return
self._known.add(job_id)
await self._queue.put(job_id)
async def requeue_active_jobs(self) -> int:
count = 0
with Session(self._engine) as session:
jobs = list(
session.exec(
select(ImportJob).where(ImportJob.status.in_(ACTIVE_IMPORT_STATUSES)).order_by(ImportJob.created_at)
).all()
)
for job in jobs:
# Jobs interrupted during a restart become queued again; completed
# progress is intentionally reset because the temp dir may be gone.
job.status = "queued"
job.progress = 0.0
job.updated_at = utcnow()
session.add(job)
session.commit()
for job in jobs:
if job.id is not None:
await self.enqueue(job.id)
count += 1
return count
async def _worker(self, idx: int) -> None:
while True:
job_id = await self._queue.get()
try:
await self._runner(job_id, self._engine, self._providers, self._settings)
finally:
self._known.discard(job_id)
self._queue.task_done()
import_queue_manager = ImportQueueManager()

View File

@@ -0,0 +1,202 @@
from __future__ import annotations
import asyncio
import base64
import time
import uuid
from dataclasses import dataclass, field
from typing import Any
from urllib.parse import unquote, urlparse
from app.config import get_settings
from app.services.page_analyzer import (
MediaCandidate,
_browser_context,
_is_ad_or_tracker_url,
_looks_like_media_response,
_probe_candidates_with_meta,
extract_media_urls,
)
from app.services.url_safety import BROWSER_LIKE_HEADERS, UnsafeUrlError, assert_public_host, check_url_for_page
_INTERACTIVE_CAPTURE_LIMIT = 80
_DEFAULT_VIEWPORT = {"width": 1280, "height": 720}
_SESSION_TTL_SECONDS = 15 * 60
@dataclass
class InteractiveBrowserSession:
id: str
page_url: str
context: Any
page: Any
browser: Any | None = None
user_data_dir: Any | None = None
ublock_path: str | None = None
seen: dict[str, tuple[str | None, str | None]] = field(default_factory=dict)
pending_response_tasks: set[asyncio.Task] = field(default_factory=set)
created_at: float = field(default_factory=time.monotonic)
updated_at: float = field(default_factory=time.monotonic)
class InteractiveBrowserManager:
"""Small in-process Playwright session manager for interactive media analysis.
The browser never receives private/internal network requests: every request is
routed through the same public-host guard used by the non-interactive browser
analyzer. The UI gets screenshots and sends clicks/keystrokes back, avoiding
iframe/CORS limitations while still allowing the user to press Play.
"""
def __init__(self) -> None:
self._sessions: dict[str, InteractiveBrowserSession] = {}
self._playwright: Any | None = None
self._lock = asyncio.Lock()
async def _ensure_playwright(self):
if self._playwright is None:
from playwright.async_api import async_playwright
self._playwright = await async_playwright().start()
return self._playwright
async def _close_session(self, session: InteractiveBrowserSession) -> None:
for task in list(session.pending_response_tasks):
if not task.done():
task.cancel()
try:
await session.context.close()
finally:
if session.browser is not None:
await session.browser.close()
if session.user_data_dir is not None:
session.user_data_dir.cleanup()
async def cleanup_expired(self) -> None:
now = time.monotonic()
expired = [sid for sid, sess in self._sessions.items() if now - sess.updated_at > _SESSION_TTL_SECONDS]
for sid in expired:
await self.stop(sid)
async def start(self, url: str) -> InteractiveBrowserSession:
await self.cleanup_expired()
settings = get_settings()
checked_page = await check_url_for_page(url, timeout=settings.request_timeout)
playwright = await self._ensure_playwright()
context, browser, user_data_dir, ublock_path = await _browser_context(playwright, ignore_https_errors=False)
await context.set_extra_http_headers({**BROWSER_LIKE_HEADERS, "DNT": "1"})
session = InteractiveBrowserSession(
id=uuid.uuid4().hex,
page_url=checked_page.final_url,
context=context,
page=await context.new_page(),
browser=browser,
user_data_dir=user_data_dir,
ublock_path=ublock_path,
)
await session.page.set_viewport_size(_DEFAULT_VIEWPORT)
async def guard_route(route):
request_url = route.request.url
parsed = urlparse(request_url)
if parsed.scheme not in {"http", "https"}:
await route.abort()
return
if _is_ad_or_tracker_url(request_url):
await route.abort()
return
try:
await assert_public_host(request_url)
except UnsafeUrlError:
await route.abort()
return
await route.continue_()
async def on_response(response):
response_url = response.url
try:
headers = await response.all_headers()
except Exception:
headers = {}
content_type = headers.get("content-type")
if _looks_like_media_response(response_url, content_type):
title = unquote(urlparse(response_url).path.rsplit("/", 1)[-1]) or response_url
session.seen.setdefault(response_url, (title, content_type))
def schedule_response_probe(response):
task = asyncio.create_task(on_response(response))
session.pending_response_tasks.add(task)
task.add_done_callback(session.pending_response_tasks.discard)
await context.route("**/*", guard_route)
context.on("response", schedule_response_probe)
try:
await session.page.goto(checked_page.final_url, wait_until="domcontentloaded", timeout=int(settings.request_timeout * 1000))
try:
await session.page.wait_for_load_state("networkidle", timeout=3000)
except Exception:
pass
await self._collect_dom_media(session)
except Exception:
await self._close_session(session)
raise
self._sessions[session.id] = session
return session
async def _collect_dom_media(self, session: InteractiveBrowserSession) -> None:
try:
html = await session.page.content()
page_url = session.page.url
except Exception:
return
for dom_url in extract_media_urls(html, page_url):
session.seen.setdefault(dom_url, (None, None))
async def _settle(self, session: InteractiveBrowserSession) -> None:
session.updated_at = time.monotonic()
await asyncio.sleep(0.5)
if session.pending_response_tasks:
await asyncio.gather(*list(session.pending_response_tasks), return_exceptions=True)
await self._collect_dom_media(session)
def get(self, session_id: str) -> InteractiveBrowserSession:
session = self._sessions.get(session_id)
if not session:
raise KeyError(session_id)
session.updated_at = time.monotonic()
return session
async def click(self, session_id: str, x: float, y: float) -> InteractiveBrowserSession:
session = self.get(session_id)
await session.page.mouse.click(x, y)
await self._settle(session)
return session
async def press(self, session_id: str, key: str) -> InteractiveBrowserSession:
session = self.get(session_id)
await session.page.keyboard.press(key)
await self._settle(session)
return session
async def stop(self, session_id: str) -> None:
session = self._sessions.pop(session_id, None)
if session is not None:
await self._close_session(session)
async def screenshot_data_url(self, session: InteractiveBrowserSession) -> str:
png = await session.page.screenshot(type="png", full_page=False)
return "data:image/png;base64," + base64.b64encode(png).decode("ascii")
async def candidates(self, session: InteractiveBrowserSession) -> list[MediaCandidate]:
if session.pending_response_tasks:
await asyncio.gather(*list(session.pending_response_tasks), return_exceptions=True)
await self._collect_dom_media(session)
return await _probe_candidates_with_meta(
dict(list(session.seen.items())[:_INTERACTIVE_CAPTURE_LIMIT]),
source="interactive-browser",
)
interactive_browser_manager = InteractiveBrowserManager()

View File

@@ -0,0 +1,23 @@
import httpx
from app.config import Settings
class JellyfinRefreshSkipped(RuntimeError):
pass
async def refresh_jellyfin(settings: Settings) -> bool:
"""Trigger Jellyfin's library refresh when credentials are configured.
Returns False when no API key is present so local/dev imports can still finish
without writing secrets into the project tree.
"""
if not settings.jellyfin_api_key:
return False
url = settings.jellyfin_url.rstrip("/") + "/Library/Refresh"
headers = {"X-Emby-Token": settings.jellyfin_api_key}
async with httpx.AsyncClient(timeout=20) as client:
response = await client.post(url, headers=headers)
response.raise_for_status()
return True

View File

@@ -0,0 +1,503 @@
from __future__ import annotations
import asyncio
import html as html_lib
import os
import re
import tempfile
from dataclasses import dataclass, field
from html.parser import HTMLParser
from pathlib import Path
from typing import Any
from urllib.parse import unquote, urljoin, urlparse
import httpx
try:
from yt_dlp import YoutubeDL
except ImportError: # optional analyzer backend
YoutubeDL = None # type: ignore[assignment]
from app.config import get_settings
from app.services.url_safety import BROWSER_LIKE_HEADERS, BROWSER_LIKE_MEDIA_HEADERS, CHROME_WINDOWS_11_USER_AGENT, UnsafeUrlError, assert_public_host, check_url_for_download, check_url_for_page
_MEDIA_EXTENSIONS = (".mp4", ".m4v", ".webm", ".mkv", ".mov", ".m3u8", ".mpd")
_MEDIA_CONTENT_TYPES = {
"video/mp4": "direct_video",
"video/webm": "direct_video",
"video/x-matroska": "direct_video",
"video/mp2t": "hls_segment",
"application/vnd.apple.mpegurl": "hls_manifest",
"application/x-mpegurl": "hls_manifest",
"application/dash+xml": "dash_manifest",
}
_QUOTED_URL_RE = re.compile(r"[\"']([^\"']+\.(?:mp4|m4v|webm|mkv|mov|m3u8|mpd)(?:\?[^\"']*)?)[\"']", re.I)
_ABSOLUTE_MEDIA_URL_RE = re.compile(
r"(?:https?://|https?:\\/\\/|//|\\/\\/)[^\s\"'<>]+?\.(?:mp4|m4v|webm|mkv|mov|m3u8|mpd)(?=$|[?&#\s\"'<>])(?:[?&][^\s\"'<>]*)?",
re.I,
)
_BROWSER_CAPTURE_LIMIT = 80
_YTDLP_ALLOWED_HOST_SUFFIXES = (
"youtube.com",
"youtu.be",
"archive.org",
"ardmediathek.de",
"zdf.de",
"arte.tv",
)
_AD_HOST_FRAGMENTS = (
"doubleclick.net",
"googlesyndication.com",
"googleadservices.com",
"adservice.google.",
"adsystem.com",
"adnxs.com",
"adsafeprotected.com",
"amazon-adsystem.com",
"taboola.com",
"outbrain.com",
"scorecardresearch.com",
)
@dataclass
class MediaCandidate:
url: str
kind: str
title: str | None = None
source: str = "html"
content_type: str | None = None
content_length: int | None = None
file_size: str | None = None
quality: str | None = None
allowed: bool = True
reason: str | None = None
metadata: dict[str, Any] = field(default_factory=dict)
class _MediaHTMLParser(HTMLParser):
def __init__(self, base_url: str) -> None:
super().__init__()
self.base_url = base_url
self.urls: list[str] = []
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
attr_map = {key.lower(): value for key, value in attrs if value}
for attr in ("src", "href", "data-src", "data-url"):
value = attr_map.get(attr)
if value and _looks_like_media_url(value):
self.urls.append(urljoin(self.base_url, value))
def _looks_like_media_url(value: str) -> bool:
parsed_path = urlparse(value).path.lower()
return parsed_path.endswith(_MEDIA_EXTENSIONS)
def _looks_like_media_response(url: str, content_type: str | None = None) -> bool:
clean = (content_type or "").split(";", 1)[0].strip().lower()
return clean in _MEDIA_CONTENT_TYPES or clean.startswith("video/") or _looks_like_media_url(url)
def human_size(size: int | None) -> str | None:
if size is None:
return None
value = float(size)
for unit in ("B", "KB", "MB", "GB"):
if value < 1024 or unit == "GB":
if unit == "B":
return f"{int(value)} {unit}"
return f"{value:.1f} {unit}"
value /= 1024
def _is_ad_or_tracker_url(url: str) -> bool:
host = (urlparse(url).hostname or "").lower()
if any(fragment in host for fragment in _AD_HOST_FRAGMENTS):
return True
path = urlparse(url).path.lower()
return any(marker in path for marker in ("/ads/", "/adserver/", "/prebid", "/vast", "/vpaid"))
def _host_allowed_for_ytdlp(url: str) -> bool:
host = (urlparse(url).hostname or "").lower().rstrip(".")
return any(host == suffix or host.endswith(f".{suffix}") for suffix in _YTDLP_ALLOWED_HOST_SUFFIXES)
async def _fetch_public_page_body(url: str, *, timeout: float, max_redirects: int = 8) -> tuple[str, str]:
"""Fetch a page body while validating every GET redirect hop.
``check_url_for_page`` validates HEAD redirects, but some origins redirect
differently for GET. Keep GET redirects manual so the analyzer cannot be
turned into an internal-network fetch proxy by a method-dependent redirect.
"""
current = url
async with httpx.AsyncClient(follow_redirects=False, timeout=timeout) as client:
for _ in range(max_redirects + 1):
checked = await check_url_for_page(current, timeout=timeout, max_redirects=max_redirects)
response = await client.get(checked.final_url, headers=BROWSER_LIKE_HEADERS)
if response.is_redirect:
loc = response.headers.get("location")
if not loc:
raise UnsafeUrlError("Redirect ohne Location-Header")
current = str(response.url.join(loc))
continue
response.raise_for_status()
await assert_public_host(str(response.url))
return response.text, str(response.url)
raise UnsafeUrlError("Zu viele Weiterleitungen")
def _ublock_origin_path() -> Path | None:
configured = os.environ.get("KINOPROJEKT_UBLOCK_PATH")
candidates = [Path(configured)] if configured else []
candidates.extend([Path("/opt/kino-projekt/ublock-origin"), Path("/opt/kino-projekt/ublock")])
for candidate in candidates:
if not candidate:
continue
if candidate.exists() and candidate.is_dir() and (candidate / "manifest.json").exists():
return candidate
nested = candidate / "uBlock0.chromium"
if nested.exists() and nested.is_dir() and (nested / "manifest.json").exists():
return nested
return None
def _has_importable_media(candidates: dict[str, tuple[str | None, str | None]]) -> bool:
return any(
classify_candidate(url, content_type).kind in {"direct_video", "hls_manifest", "dash_manifest"}
for url, (_, content_type) in candidates.items()
)
def _normalize_media_url(raw: str, base_url: str) -> str | None:
value = html_lib.unescape(raw.strip())
if not value:
return None
value = value.replace("\\/", "/").replace("\\u002F", "/").replace("\\u002f", "/")
value = value.rstrip(",;)}]")
if value.startswith("//"):
value = f"{urlparse(base_url).scheme or 'https'}:{value}"
return urljoin(base_url, unquote(value))
def extract_media_urls(html: str, base_url: str) -> list[str]:
parser = _MediaHTMLParser(base_url)
parser.feed(html)
urls = list(parser.urls)
for match in _QUOTED_URL_RE.finditer(html):
normalized = _normalize_media_url(match.group(1), base_url)
if normalized:
urls.append(normalized)
for match in _ABSOLUTE_MEDIA_URL_RE.finditer(html):
normalized = _normalize_media_url(match.group(0), base_url)
if normalized:
urls.append(normalized)
# Stable de-duplication while preserving discovery order.
return list(dict.fromkeys(urls))
def classify_candidate(url: str, content_type: str | None = None) -> MediaCandidate:
normalized_content_type = (content_type or "").split(";", 1)[0].strip().lower()
if normalized_content_type in _MEDIA_CONTENT_TYPES:
kind = _MEDIA_CONTENT_TYPES[normalized_content_type]
elif normalized_content_type.startswith("video/"):
kind = "direct_video"
else:
path = urlparse(url).path.lower()
if path.endswith(".m3u8"):
kind = "hls_manifest"
elif path.endswith(".mpd"):
kind = "dash_manifest"
elif path.endswith((".mp4", ".m4v", ".webm", ".mkv", ".mov")):
kind = "direct_video"
else:
kind = "unknown_media"
title = unquote(urlparse(url).path.rsplit("/", 1)[-1]) or url
return MediaCandidate(url=url, kind=kind, title=title, content_type=content_type)
async def _probe_candidate(url: str, source: str, title: str | None = None, quality: str | None = None) -> MediaCandidate:
settings = get_settings()
try:
checked = await check_url_for_download(url, max_bytes=settings.max_download_bytes, timeout=settings.request_timeout)
candidate = classify_candidate(checked.final_url, checked.content_type)
candidate.source = source
candidate.title = title or candidate.title
candidate.quality = quality
candidate.content_length = checked.content_length
candidate.file_size = human_size(checked.content_length)
candidate.metadata = {"redirect_chain": checked.chain}
return candidate
except UnsafeUrlError as exc:
c = classify_candidate(url)
c.source = source
c.title = title or c.title
c.quality = quality
c.allowed = False
c.reason = str(exc)
return c
async def analyze_html_page(url: str) -> list[MediaCandidate]:
settings = get_settings()
# First validate the page URL itself so the analyzer cannot be used as an internal-network proxy.
checked_page = await check_url_for_page(url, timeout=settings.request_timeout)
body, final_url = await _fetch_public_page_body(checked_page.final_url, timeout=settings.request_timeout)
found = extract_media_urls(body, final_url)
return await _probe_candidates(found, source="html")
async def analyze_with_ytdlp(url: str) -> list[MediaCandidate]:
checked_page = await check_url_for_page(url, timeout=get_settings().request_timeout)
if not _host_allowed_for_ytdlp(checked_page.final_url):
return []
if YoutubeDL is None:
return []
def _extract() -> dict[str, Any] | None:
opts = {
"quiet": True,
"skip_download": True,
"noplaylist": True,
"extract_flat": False,
"socket_timeout": get_settings().request_timeout,
}
with YoutubeDL(opts) as ydl:
return ydl.extract_info(checked_page.final_url, download=False)
try:
info = await asyncio.to_thread(_extract)
except Exception:
return []
if not info:
return []
candidates: list[tuple[str, str | None, str | None]] = []
title = info.get("title")
if info.get("url") and _looks_like_media_url(info["url"]):
candidates.append((info["url"], title, None))
for fmt in info.get("formats") or []:
fmt_url = fmt.get("url")
if not fmt_url:
continue
ext = fmt.get("ext") or ""
protocol = fmt.get("protocol") or ""
if _looks_like_media_url(fmt_url) or ext in {"mp4", "webm", "m3u8", "mpd"} or "m3u8" in protocol or "dash" in protocol:
quality = fmt.get("format_note") or fmt.get("format") or fmt.get("height")
candidates.append((fmt_url, title, str(quality) if quality else None))
deduped: dict[str, tuple[str | None, str | None]] = {}
for candidate_url, candidate_title, quality in candidates:
deduped.setdefault(candidate_url, (candidate_title, quality))
return await _probe_candidates_with_meta(deduped, source="yt-dlp")
async def _trigger_video_playback(page) -> None:
"""Try to start embedded players so lazy video requests become visible."""
await page.evaluate(
"""
async () => {
for (const text of ['Akzeptieren', 'Accept', 'I agree', 'OK']) {
const buttons = [...document.querySelectorAll('button, [role="button"], input[type="button"], input[type="submit"]')];
const button = buttons.find(el => (el.innerText || el.value || '').toLowerCase().includes(text.toLowerCase()));
if (button) { try { button.click(); } catch (_) {} }
}
for (const video of document.querySelectorAll('video')) {
try {
video.muted = true;
video.playsInline = true;
video.autoplay = true;
video.play().catch(()=>{});
} catch (_) {}
}
const playSelectors = [
'[aria-label*="play" i]', '[title*="play" i]', '.play', '.play-button', '.vjs-big-play-button',
'button[class*="play" i]', 'button[aria-label*="abspielen" i]'
];
for (const selector of playSelectors) {
for (const el of document.querySelectorAll(selector)) {
try { el.click(); } catch (_) {}
}
}
}
"""
)
async def _browser_context(playwright, *, ignore_https_errors: bool = False):
base_args = [
"--disable-dev-shm-usage",
"--disable-gpu",
"--no-sandbox",
"--disable-setuid-sandbox",
"--disable-background-networking",
"--window-size=1365,768",
]
context_options = {
"ignore_https_errors": ignore_https_errors,
"java_script_enabled": True,
"user_agent": CHROME_WINDOWS_11_USER_AGENT,
"locale": "de-DE",
"timezone_id": "Europe/Berlin",
"viewport": {"width": 1365, "height": 768},
"device_scale_factor": 1,
"is_mobile": False,
"has_touch": False,
"extra_http_headers": {
key: value for key, value in BROWSER_LIKE_HEADERS.items() if key.lower() != "user-agent"
},
}
ublock_path = _ublock_origin_path()
if ublock_path:
user_data_dir = tempfile.TemporaryDirectory(prefix="kino-browser-")
context = await playwright.chromium.launch_persistent_context(
user_data_dir.name,
headless=True,
args=[
*base_args,
f"--disable-extensions-except={ublock_path}",
f"--load-extension={ublock_path}",
],
**context_options,
)
return context, None, user_data_dir, str(ublock_path)
browser = await playwright.chromium.launch(headless=True, args=base_args)
context = await browser.new_context(**context_options)
return context, browser, None, None
async def analyze_with_browser(url: str) -> list[MediaCandidate]:
"""Headless-Browser-Analyzer (Option B): observe JS-loaded media requests.
Every browser request is still constrained to public http(s) targets. This keeps the
server-side browser from becoming a LAN/internal-network fetch proxy.
"""
settings = get_settings()
checked_page = await check_url_for_page(url, timeout=settings.request_timeout)
try:
from playwright.async_api import TimeoutError as PlaywrightTimeoutError
from playwright.async_api import async_playwright
except ImportError:
return []
seen: dict[str, tuple[str | None, str | None]] = {}
playback_triggered = False
async with async_playwright() as p:
context, browser, user_data_dir, ublock_path = await _browser_context(p, ignore_https_errors=False)
async def guard_route(route):
request_url = route.request.url
parsed = urlparse(request_url)
if parsed.scheme not in {"http", "https"}:
await route.abort()
return
if _is_ad_or_tracker_url(request_url):
await route.abort()
return
try:
await assert_public_host(request_url)
except UnsafeUrlError:
await route.abort()
return
await route.continue_()
async def on_response(response):
response_url = response.url
headers = await response.all_headers()
content_type = headers.get("content-type")
if _looks_like_media_response(response_url, content_type):
title = unquote(urlparse(response_url).path.rsplit("/", 1)[-1]) or response_url
seen.setdefault(response_url, (title, content_type))
await context.route("**/*", guard_route)
pending_response_tasks: set[asyncio.Task] = set()
def schedule_response_probe(response):
task = asyncio.create_task(on_response(response))
pending_response_tasks.add(task)
task.add_done_callback(pending_response_tasks.discard)
context.on("response", schedule_response_probe)
page = await context.new_page()
try:
await page.goto(checked_page.final_url, wait_until="domcontentloaded", timeout=int(settings.request_timeout * 1000))
try:
await page.wait_for_load_state("networkidle", timeout=5000)
except PlaywrightTimeoutError:
pass
if pending_response_tasks:
await asyncio.gather(*pending_response_tasks, return_exceptions=True)
if not _has_importable_media(seen):
playback_triggered = True
await _trigger_video_playback(page)
try:
await page.wait_for_load_state("networkidle", timeout=5000)
except PlaywrightTimeoutError:
pass
if pending_response_tasks:
await asyncio.gather(*pending_response_tasks, return_exceptions=True)
html = await page.content()
for dom_url in extract_media_urls(html, page.url):
seen.setdefault(dom_url, (None, None))
finally:
await context.close()
if browser is not None:
await browser.close()
if user_data_dir is not None:
user_data_dir.cleanup()
candidates = await _probe_candidates_with_meta(dict(list(seen.items())[:_BROWSER_CAPTURE_LIMIT]), source="browser")
for candidate in candidates:
candidate.metadata["playback_triggered"] = playback_triggered
candidate.metadata["ublock_origin"] = bool(ublock_path)
return candidates
async def analyze_browser_capture(captured: list[dict[str, Any]], page_url: str | None = None) -> list[MediaCandidate]:
"""Validate and normalize media URLs captured by a user's real browser (Option C)."""
base_url = page_url or ""
deduped: dict[str, tuple[str | None, str | None]] = {}
for item in captured[:_BROWSER_CAPTURE_LIMIT]:
raw_url = str(item.get("url") or "").strip()
if not raw_url:
continue
candidate_url = urljoin(base_url, raw_url) if base_url else raw_url
if not _looks_like_media_response(candidate_url, item.get("content_type") or item.get("type")):
continue
title = item.get("title") or item.get("name")
quality = item.get("quality")
deduped.setdefault(candidate_url, (str(title) if title else None, str(quality) if quality else None))
return await _probe_candidates_with_meta(deduped, source="browser-capture")
async def _probe_candidates(urls: list[str], source: str) -> list[MediaCandidate]:
return await _probe_candidates_with_meta({url: (None, None) for url in urls}, source=source)
async def _probe_candidates_with_meta(urls: dict[str, tuple[str | None, str | None]], source: str) -> list[MediaCandidate]:
candidates = [await _probe_candidate(url, source, title, quality) for url, (title, quality) in list(urls.items())[:50]]
if any(candidate.kind in {"hls_manifest", "dash_manifest"} for candidate in candidates):
candidates = [candidate for candidate in candidates if candidate.kind != "hls_segment"]
# Show usable candidates first, then blocked candidates as diagnostics.
candidates.sort(key=lambda c: (not c.allowed, c.kind, c.quality or ""))
return candidates
async def analyze_page(url: str) -> list[MediaCandidate]:
results = await asyncio.gather(
analyze_html_page(url),
analyze_with_ytdlp(url),
analyze_with_browser(url),
return_exceptions=True,
)
html_candidates, ytdlp_candidates, browser_candidates = [item if isinstance(item, list) else [] for item in results]
merged: dict[str, MediaCandidate] = {}
# Prefer browser/ytdlp metadata over static HTML when the same URL appears twice.
for candidate in [*browser_candidates, *ytdlp_candidates, *html_candidates]:
existing = merged.get(candidate.url)
if not existing or (not existing.allowed and candidate.allowed):
merged[candidate.url] = candidate
return list(merged.values())

View File

@@ -0,0 +1,67 @@
import re
from pathlib import Path
_SAFE_CHARS = re.compile(r"[^A-Za-z0-9._ -]+")
def slugify_name(value: str, fallback: str = "media") -> str:
cleaned = _SAFE_CHARS.sub("_", value).strip(" ._- ")
return cleaned[:120] or fallback
def ensure_under(base: Path, candidate: Path) -> Path:
base_resolved = base.expanduser().resolve()
path = (base_resolved / candidate).resolve() if not candidate.is_absolute() else candidate.resolve()
if base_resolved != path and base_resolved not in path.parents:
raise ValueError(f"Path escapes base directory: {candidate}")
return path
def tmp_target(tmp_dir: Path, job_id: int, title: str) -> Path:
return ensure_under(tmp_dir, Path(str(job_id)) / slugify_name(title))
def media_target(media_root: Path, library: str, title: str) -> Path:
return ensure_under(media_root, Path(slugify_name(library, "library")) / slugify_name(title))
def jellyfin_profile_target(
media_root: Path,
target_profile: str,
title: str,
*,
year: int | None = None,
series_title: str | None = None,
season: int | None = None,
episode: int | None = None,
episode_title: str | None = None,
channel: str | None = None,
external_id: str | None = None,
) -> Path:
"""Return the Jellyfin-compatible destination directory for a target profile.
The returned path is always below ``media_root``. The downloader copies the
provider output into this directory; provider-selected filenames are kept for
now, while the directory structure follows the real Jellyfin layout from the
project plan.
"""
profile = target_profile.strip().lower()
safe_title = slugify_name(title)
if profile == "film":
movie_dir = f"{safe_title} ({year})" if year else safe_title
return ensure_under(media_root, Path("Filme") / movie_dir)
if profile == "serie":
show = slugify_name(series_title or title, "Serie")
season_number = season if season and season > 0 else 1
episode_prefix = f"S{season_number:02d}E{episode:02d} - " if episode and episode > 0 else ""
episode_dir = slugify_name(f"{episode_prefix}{episode_title or title}", "Episode")
return ensure_under(media_root, Path("Serien") / show / f"Staffel{season_number}" / episode_dir)
if profile == "clip":
owner = slugify_name(channel or "Unbekannte Quelle", "Quelle")
suffix = f" [{slugify_name(external_id)}]" if external_id else ""
return ensure_under(media_root, Path("YouTube-Mediathek") / owner / f"{safe_title}{suffix}")
raise ValueError(f"Unknown target profile: {target_profile}")

View File

@@ -0,0 +1,209 @@
from __future__ import annotations
import asyncio
import ipaddress
import socket
from dataclasses import dataclass
from typing import Iterable
from urllib.parse import urlparse
import httpx
BLOCKED_NETWORKS = [
ipaddress.ip_network("0.0.0.0/8"),
ipaddress.ip_network("10.0.0.0/8"),
ipaddress.ip_network("100.64.0.0/10"),
ipaddress.ip_network("127.0.0.0/8"),
ipaddress.ip_network("169.254.0.0/16"),
ipaddress.ip_network("172.16.0.0/12"),
ipaddress.ip_network("192.168.0.0/16"),
ipaddress.ip_network("224.0.0.0/4"),
ipaddress.ip_network("240.0.0.0/4"),
ipaddress.ip_network("::1/128"),
ipaddress.ip_network("::/128"),
ipaddress.ip_network("fc00::/7"),
ipaddress.ip_network("fe80::/10"),
ipaddress.ip_network("ff00::/8"),
]
ALLOWED_CONTENT_PREFIXES = ("video/", "audio/")
ALLOWED_CONTENT_TYPES = {
"application/vnd.apple.mpegurl",
"application/x-mpegurl",
"application/dash+xml",
"application/octet-stream", # accepted only with known extension
}
ALLOWED_EXTENSIONS = (
".mp4",
".m4v",
".mkv",
".webm",
".mov",
".mp3",
".m4a",
".aac",
".ogg",
".flac",
".m3u8",
".mpd",
)
CHROME_WINDOWS_11_USER_AGENT = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/126.0.0.0 Safari/537.36"
)
# Chrome on Windows 11 intentionally still reports Windows NT 10.0 in the
# classic User-Agent for compatibility. The client-hint headers below are the
# browser-like Windows/Chrome signals many upstreams check before serving pages
# or media manifests. Keep this central so safety checks, analyzer fetches and
# provider downloads use the same fingerprint.
BROWSER_LIKE_HEADERS = {
"User-Agent": CHROME_WINDOWS_11_USER_AGENT,
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,video/*;q=0.8,*/*;q=0.7",
"Accept-Language": "de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7",
"Sec-CH-UA": '"Google Chrome";v="126", "Chromium";v="126", "Not-A.Brand";v="99"',
"Sec-CH-UA-Mobile": "?0",
"Sec-CH-UA-Platform": '"Windows"',
"Upgrade-Insecure-Requests": "1",
"Sec-Fetch-Dest": "document",
"Sec-Fetch-Mode": "navigate",
"Sec-Fetch-Site": "none",
"Sec-Fetch-User": "?1",
}
BROWSER_LIKE_MEDIA_HEADERS = {
**BROWSER_LIKE_HEADERS,
"Accept": "video/webm,video/mp4,video/*;q=0.9,application/vnd.apple.mpegurl;q=0.9,application/dash+xml;q=0.9,*/*;q=0.8",
"Sec-Fetch-Dest": "video",
"Sec-Fetch-Mode": "no-cors",
"Sec-Fetch-Site": "cross-site",
}
class UnsafeUrlError(ValueError):
pass
@dataclass
class CheckedUrl:
final_url: str
chain: list[str]
content_type: str | None
content_length: int | None
def _is_blocked_ip(ip: str) -> bool:
addr = ipaddress.ip_address(ip)
return any(addr in net for net in BLOCKED_NETWORKS) or addr.is_private or addr.is_loopback or addr.is_link_local
def validate_scheme(url: str) -> None:
parsed = urlparse(url)
if parsed.scheme not in {"http", "https"}:
raise UnsafeUrlError("Nur http/https URLs sind erlaubt")
if not parsed.hostname:
raise UnsafeUrlError("URL enthält keinen Hostnamen")
if parsed.username or parsed.password:
raise UnsafeUrlError("Zugangsdaten in URLs sind nicht erlaubt")
async def resolve_host(hostname: str, port: int | None = None) -> list[str]:
def _resolve() -> list[str]:
infos = socket.getaddrinfo(hostname, port or 443, type=socket.SOCK_STREAM)
return sorted({str(info[4][0]) for info in infos})
return await asyncio.to_thread(_resolve)
async def assert_public_host(url: str) -> None:
validate_scheme(url)
parsed = urlparse(url)
assert parsed.hostname is not None
try:
ips = await resolve_host(parsed.hostname, parsed.port)
except socket.gaierror as exc:
raise UnsafeUrlError(f"Hostname kann nicht aufgelöst werden: {parsed.hostname}") from exc
if not ips:
raise UnsafeUrlError("Hostname hat keine IP-Adressen")
blocked = [ip for ip in ips if _is_blocked_ip(ip)]
if blocked:
raise UnsafeUrlError("URL zeigt auf interne/private IP-Adresse")
def _content_type_allowed(content_type: str | None, url: str) -> bool:
if not content_type:
return any(urlparse(url).path.lower().endswith(ext) for ext in ALLOWED_EXTENSIONS)
clean = content_type.split(";", 1)[0].strip().lower()
if clean.startswith(ALLOWED_CONTENT_PREFIXES):
return True
if clean in ALLOWED_CONTENT_TYPES and any(urlparse(url).path.lower().endswith(ext) for ext in ALLOWED_EXTENSIONS):
return True
return False
async def check_url_for_page(url: str, *, timeout: float = 15.0, max_redirects: int = 8) -> CheckedUrl:
"""Validate URL and redirects for page analysis without requiring media Content-Type."""
current = str(url)
chain: list[str] = []
async with httpx.AsyncClient(follow_redirects=False, timeout=timeout) as client:
for _ in range(max_redirects + 1):
await assert_public_host(current)
chain.append(current)
try:
response = await client.head(current, headers=BROWSER_LIKE_HEADERS)
except httpx.TimeoutException:
response = await client.get(current, headers={**BROWSER_LIKE_MEDIA_HEADERS, "Range": "bytes=0-0"})
if response.status_code in {405, 403}:
response = await client.get(current, headers={**BROWSER_LIKE_MEDIA_HEADERS, "Range": "bytes=0-0"})
if response.is_redirect:
loc = response.headers.get("location")
if not loc:
raise UnsafeUrlError("Redirect ohne Location-Header")
current = str(response.url.join(loc))
continue
response.raise_for_status()
await assert_public_host(str(response.url))
content_type = response.headers.get("content-type")
length_header = response.headers.get("content-length")
content_length = int(length_header) if length_header and length_header.isdigit() else None
return CheckedUrl(str(response.url), chain, content_type, content_length)
raise UnsafeUrlError("Zu viele Weiterleitungen")
async def check_url_for_download(url: str, *, max_bytes: int, timeout: float = 15.0, max_redirects: int = 8) -> CheckedUrl:
"""Validate URL, every redirect hop, final target, content type, and size.
This is the no-domain-allowlist SSRF guard for Mediathek/direct links.
"""
current = str(url)
chain: list[str] = []
async with httpx.AsyncClient(follow_redirects=False, timeout=timeout) as client:
for _ in range(max_redirects + 1):
await assert_public_host(current)
chain.append(current)
try:
response = await client.head(current, headers=BROWSER_LIKE_HEADERS)
except httpx.TimeoutException:
response = await client.get(current, headers={**BROWSER_LIKE_MEDIA_HEADERS, "Range": "bytes=0-0"})
if response.status_code in {405, 403}:
response = await client.get(current, headers={**BROWSER_LIKE_MEDIA_HEADERS, "Range": "bytes=0-0"})
if response.is_redirect:
loc = response.headers.get("location")
if not loc:
raise UnsafeUrlError("Redirect ohne Location-Header")
current = str(response.url.join(loc))
continue
response.raise_for_status()
await assert_public_host(str(response.url))
content_type = response.headers.get("content-type")
length_header = response.headers.get("content-length")
content_length = int(length_header) if length_header and length_header.isdigit() else None
if content_length is not None and content_length > max_bytes:
raise UnsafeUrlError("Datei ist größer als das konfigurierte Limit")
if not _content_type_allowed(content_type, str(response.url)):
raise UnsafeUrlError("Ziel liefert keinen plausiblen Medien-Content-Type")
return CheckedUrl(str(response.url), chain, content_type, content_length)
raise UnsafeUrlError("Zu viele Weiterleitungen")

View File

@@ -0,0 +1,477 @@
const loginSection = document.querySelector('#login-section');
const loginForm = document.querySelector('#login-form');
const loginMessage = document.querySelector('#login-message');
const logoutButton = document.querySelector('#logout');
const form = document.querySelector('#probe-form');
const result = document.querySelector('#result');
const candidatesSection = document.querySelector('#candidates');
const importsSection = document.querySelector('#imports');
const analyzeButton = document.querySelector('#analyze-page');
const interactiveButton = document.querySelector('#interactive-analyze');
function escapeHtml(value) {
return String(value ?? '').replace(/[&<>'"]/g, (char) => ({'&': '&amp;', '<': '&lt;', '>': '&gt;', "'": '&#39;', '"': '&quot;'}[char]));
}
function setLoginVisible(visible) {
loginSection.classList.toggle('hidden', !visible);
form.classList.toggle('hidden', visible);
logoutButton.classList.toggle('hidden', visible);
}
function renderJob(job) {
const percent = Math.round((Number(job.progress) || 0) * 100);
if (job.status === 'done') {
return `Job #${job.id}: fertig (${percent}%)\nDatei übertragen.`;
}
const labels = {
queued: 'wartet',
downloading: 'Download',
postprocessing: 'Verarbeitung',
copying: 'Kopiervorgang',
refreshing: 'Kopiervorgang / Jellyfin aktualisieren',
failed: 'Fehlgeschlagen',
cancelling: 'Abbruch läuft',
cancelled: 'Abgebrochen',
};
const lines = [`Job #${job.id}: ${labels[job.status] || job.status} (${percent}%)`];
if (job.status === 'failed' && job.error) lines.push(`Fehler: ${job.error}`);
return lines.join('\n');
}
function decodeCapturePayload(value) {
return JSON.parse(decodeURIComponent(escape(atob(value))));
}
const terminalStatuses = new Set(['done', 'failed', 'cancelled']);
const activeStatuses = new Set(['queued', 'downloading', 'postprocessing', 'copying', 'refreshing', 'cancelling']);
const hideDoneAfterMs = 5 * 60 * 1000;
function statusLabel(status) {
return {
queued: 'Wartet',
downloading: 'Download',
postprocessing: 'Verarbeitung',
copying: 'Kopiervorgang',
refreshing: 'Kopiervorgang',
done: 'Fertig',
failed: 'Fehlgeschlagen',
cancelling: 'Abbruch läuft',
cancelled: 'Abgebrochen',
}[status] || status;
}
function renderImports(jobs) {
importsSection.className = 'card';
if (!jobs.length) {
importsSection.innerHTML = '<h2>Importe</h2><p class="muted">Noch keine Importe.</p>';
return;
}
importsSection.innerHTML = '<h2>Importe</h2>';
const visibleJobs = jobs.filter((job) => {
if (job.status !== 'done') return true;
const updated = Date.parse(job.updated_at);
return Number.isNaN(updated) || Date.now() - updated < hideDoneAfterMs;
});
if (!visibleJobs.length) {
importsSection.innerHTML += '<p class="muted">Keine sichtbaren Importe. Fertige Importe werden nach fünf Minuten automatisch ausgeblendet.</p>';
return;
}
visibleJobs.forEach((job) => {
const percent = Math.round((Number(job.progress) || 0) * 100);
const card = document.createElement('div');
card.className = `import-job status-${escapeHtml(job.status)}`;
card.innerHTML = `
<div class="import-header">
<strong>#${escapeHtml(job.id)} · ${escapeHtml(job.title || 'Unbenannter Import')} · ${escapeHtml(statusLabel(job.status))}</strong>
<span class="muted">${percent}% · ${escapeHtml(job.target_library || '')}</span>
</div>
<div class="progress" aria-label="Fortschritt ${percent}%"><span style="width:${Math.max(0, Math.min(percent, 100))}%"></span></div>
${job.error ? `<p class="error">${escapeHtml(job.error)}</p>` : ''}
${job.target_path && job.status === 'done' ? '<p class="muted">Datei per MD5 auf Jellyfin verifiziert. Lokale Download-Daten wurden bereinigt.</p>' : ''}
<div class="import-actions"></div>
`;
const actions = card.querySelector('.import-actions');
if (activeStatuses.has(job.status) && job.status !== 'cancelling') {
const cancel = document.createElement('button');
cancel.type = 'button';
cancel.textContent = 'Abbrechen';
cancel.addEventListener('click', () => importAction(job.id, 'cancel'));
actions.appendChild(cancel);
}
if (terminalStatuses.has(job.status)) {
const retry = document.createElement('button');
retry.type = 'button';
retry.textContent = 'Wiederholen';
retry.addEventListener('click', () => importAction(job.id, 'retry'));
actions.appendChild(retry);
if (job.status === 'failed') {
const alternative = document.createElement('button');
alternative.type = 'button';
alternative.textContent = 'Alternative Fehlerbehebung';
alternative.addEventListener('click', () => importAction(job.id, 'retry-alternative'));
actions.appendChild(alternative);
}
const remove = document.createElement('button');
remove.type = 'button';
remove.textContent = 'Aus Liste löschen';
remove.addEventListener('click', () => importAction(job.id, 'delete'));
actions.appendChild(remove);
}
importsSection.appendChild(card);
});
}
async function refreshImports() {
const response = await fetch('/api/imports');
if (response.status === 401) {
await refreshAuthStatus();
return;
}
const jobs = await response.json();
if (!response.ok) throw new Error(jobs.detail || 'Importstatus konnte nicht gelesen werden');
renderImports(jobs);
}
async function importAction(jobId, action) {
try {
let endpoint = `/api/imports/${jobId}`;
const options = {method: 'DELETE'};
if (action !== 'delete') {
endpoint += `/${action}`;
options.method = 'POST';
}
const response = await fetch(endpoint, options);
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data.detail || 'Import-Aktion fehlgeschlagen');
}
await refreshImports();
} catch (err) {
result.className = 'card';
result.innerHTML = `<p class="error">${escapeHtml(err.message)}</p>`;
}
}
function startImportPolling() {
refreshImports().catch(() => {});
setInterval(() => refreshImports().catch(() => {}), 2000);
}
async function pollJob(jobId) {
const output = document.querySelector('#job-output');
for (;;) {
const response = await fetch(`/api/imports/${jobId}`);
const job = await response.json();
if (response.status === 401) {
await refreshAuthStatus();
throw new Error('Bitte zuerst einloggen.');
}
if (!response.ok) throw new Error(job.detail || 'Job-Status konnte nicht gelesen werden');
output.textContent = renderJob(job);
if (['done', 'failed'].includes(job.status)) return job;
await new Promise((resolve) => setTimeout(resolve, 2000));
}
}
async function refreshAuthStatus() {
const response = await fetch('/api/auth/status');
const status = await response.json();
setLoginVisible(status.enabled && !status.authenticated);
}
loginForm.addEventListener('submit', async (event) => {
event.preventDefault();
loginMessage.textContent = 'Melde an…';
const username = document.querySelector('#username').value;
const password = document.querySelector('#password').value;
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: {'content-type': 'application/json'},
body: JSON.stringify({username, password}),
});
if (!response.ok) {
loginMessage.textContent = 'Login fehlgeschlagen.';
return;
}
loginMessage.textContent = '';
await refreshAuthStatus();
});
logoutButton.addEventListener('click', async () => {
await fetch('/api/auth/logout', {method: 'POST'});
await refreshAuthStatus();
});
function currentImportPayload(url) {
const payload = {
url,
target_profile: document.querySelector('input[name="target-profile"]:checked').value,
};
const textFields = [
['title', 'title'],
['series-title', 'series_title'],
['episode-title', 'episode_title'],
];
for (const [elementId, key] of textFields) {
const value = document.querySelector(`#${elementId}`).value.trim();
if (value) payload[key] = value;
}
const numberFields = [
['year', 'year'],
['season', 'season'],
['episode', 'episode'],
];
for (const [elementId, key] of numberFields) {
const value = document.querySelector(`#${elementId}`).value;
if (value) payload[key] = Number(value);
}
return payload;
}
function isImportableCandidate(candidate) {
return candidate && candidate.allowed && ['direct_video', 'hls_manifest', 'dash_manifest'].includes(candidate.kind);
}
function candidateScore(candidate) {
const kindPriority = {direct_video: 3, hls_manifest: 2, dash_manifest: 2}[candidate.kind] || 0;
return (kindPriority * 1_000_000_000_000) + (Number(candidate.content_length) || 0);
}
function bestImportableCandidate(candidates) {
return (candidates || []).filter(isImportableCandidate).sort((a, b) => candidateScore(b) - candidateScore(a))[0] || null;
}
async function startImportForUrl(url, title) {
result.className = 'card';
result.innerHTML = `<h2>Download gestartet</h2><p>${escapeHtml(title || url)}</p><pre id="job-output">Starte Import…</pre>`;
const payload = currentImportPayload(url);
if (title && !payload.title) payload.title = title;
const jobResponse = await fetch('/api/imports', {method: 'POST', headers: {'content-type': 'application/json'}, body: JSON.stringify(payload)});
const job = await jobResponse.json();
if (jobResponse.status === 401) {
await refreshAuthStatus();
throw new Error('Bitte zuerst einloggen.');
}
if (!jobResponse.ok) throw new Error(job.detail || 'Import konnte nicht gestartet werden');
document.querySelector('#job-output').textContent = renderJob(job);
await refreshImports();
}
form.addEventListener('submit', async (event) => {
event.preventDefault();
const url = document.querySelector('#url').value;
result.className = 'card';
result.textContent = 'Prüfe Quelle…';
try {
const response = await fetch('/api/probe', {method: 'POST', headers: {'content-type': 'application/json'}, body: JSON.stringify({url})});
const data = await response.json();
if (response.status === 401) {
await refreshAuthStatus();
throw new Error('Bitte zuerst einloggen.');
}
if (!response.ok) throw new Error(data.detail || 'Probe fehlgeschlagen');
result.innerHTML = `
<h2>${escapeHtml(data.title || 'Unbenannte Quelle')}</h2>
<p>Provider: ${escapeHtml(data.provider)}</p>
<p>Dauer: ${escapeHtml(data.duration_seconds ?? 'unbekannt')}</p>
<button id="start-import" type="button">Import starten</button>
<pre id="job-output"></pre>`;
document.querySelector('#start-import').addEventListener('click', async () => {
await startImportForUrl(url, data.title);
});
} catch (err) {
result.innerHTML = `<p class="error">${escapeHtml(err.message)}</p>`;
}
});
analyzeButton.addEventListener('click', async () => {
const url = document.querySelector('#url').value;
result.className = 'card';
candidatesSection.className = 'card hidden';
candidatesSection.innerHTML = '';
result.textContent = 'Starte serverseitige Analyse mit Vorschau…';
try {
const response = await fetch('/api/interactive-analyze', {method: 'POST', headers: {'content-type': 'application/json'}, body: JSON.stringify({url})});
const data = await response.json();
if (response.status === 401) {
await refreshAuthStatus();
throw new Error('Bitte zuerst einloggen.');
}
if (!response.ok) throw new Error(data.detail || 'Analyse konnte nicht gestartet werden');
renderInteractive(data);
} catch (err) {
result.innerHTML = `<p class="error">${escapeHtml(err.message)}</p>`;
}
});
let interactiveSessionId = null;
function renderInteractive(data) {
interactiveSessionId = data.session_id;
const count = (data.candidates || []).length;
result.className = 'card interactive-card';
result.innerHTML = `
<h2>Interaktive Serveranalyse</h2>
<p>Die Seite läuft in einem kurzlebigen Server-Browser. Klicke in die Vorschau oder nutze Play/Pause; Medien-Requests werden während der Wiedergabe erneut ausgewertet.</p>
<p>${count} Kandidat(en) gefunden.${data.ublock_origin ? ' uBlock Origin ist aktiv.' : ''}</p>
<div class="interactive-toolbar">
<button id="interactive-space" type="button">Play/Pause (Leertaste)</button>
<button id="interactive-refresh" type="button">Kandidaten aktualisieren</button>
<button id="interactive-stop" type="button">Session beenden</button>
</div>
<img id="interactive-screenshot" class="interactive-screenshot" src="${escapeHtml(data.screenshot)}" alt="Interaktive Analyse-Vorschau" />
`;
const screenshot = document.querySelector('#interactive-screenshot');
screenshot.addEventListener('click', async (event) => {
const rect = screenshot.getBoundingClientRect();
const x = (event.clientX - rect.left) * (data.viewport.width / rect.width);
const y = (event.clientY - rect.top) * (data.viewport.height / rect.height);
await interactiveAction('click', {x, y});
});
document.querySelector('#interactive-space').addEventListener('click', () => interactiveAction('key', {key: 'Space'}));
document.querySelector('#interactive-refresh').addEventListener('click', () => interactiveAction('key', {key: 'Shift'}));
document.querySelector('#interactive-stop').addEventListener('click', stopInteractive);
renderCandidates(data.candidates || [], {showBestDownload: true});
}
async function interactiveAction(action, payload) {
if (!interactiveSessionId) return;
result.classList.add('loading');
try {
const endpoint = action === 'click'
? `/api/interactive-analyze/${interactiveSessionId}/click`
: `/api/interactive-analyze/${interactiveSessionId}/key`;
const response = await fetch(endpoint, {method: 'POST', headers: {'content-type': 'application/json'}, body: JSON.stringify(payload)});
const data = await response.json();
if (!response.ok) throw new Error(data.detail || 'Interaktive Aktion fehlgeschlagen');
renderInteractive(data);
} catch (err) {
result.innerHTML += `<p class="error">${escapeHtml(err.message)}</p>`;
} finally {
result.classList.remove('loading');
}
}
async function stopInteractive() {
if (!interactiveSessionId) return;
await fetch(`/api/interactive-analyze/${interactiveSessionId}`, {method: 'DELETE'}).catch(() => {});
interactiveSessionId = null;
result.className = 'card';
result.textContent = 'Interaktive Analyse beendet.';
}
interactiveButton.addEventListener('click', async () => {
const url = document.querySelector('#url').value;
result.className = 'card';
candidatesSection.className = 'card hidden';
candidatesSection.innerHTML = '';
result.textContent = 'Starte serverseitigen Analyse-Browser…';
try {
const response = await fetch('/api/interactive-analyze', {method: 'POST', headers: {'content-type': 'application/json'}, body: JSON.stringify({url})});
const data = await response.json();
if (response.status === 401) {
await refreshAuthStatus();
throw new Error('Bitte zuerst einloggen.');
}
if (!response.ok) throw new Error(data.detail || 'Interaktive Analyse konnte nicht gestartet werden');
renderInteractive(data);
} catch (err) {
result.innerHTML = `<p class="error">${escapeHtml(err.message)}</p>`;
}
});
function renderCandidates(candidates, options = {}) {
candidatesSection.className = 'card';
const importable = (candidates || []).filter(isImportableCandidate);
const best = bestImportableCandidate(candidates);
if (!candidates.length) {
candidatesSection.innerHTML = '<h2>Gefundene Medienquellen</h2><p>Noch keine direkt importierbare Medienquelle gefunden. Starte das Video in der Vorschau und drücke dann „Kandidaten aktualisieren“ oder Play/Pause.</p>';
return;
}
candidatesSection.innerHTML = '<h2>Gefundene Medienquellen</h2>';
if (options.showBestDownload && best) {
const bestBox = document.createElement('div');
bestBox.className = 'candidate best-candidate';
bestBox.innerHTML = `
<div>
<strong>Bester Kandidat: ${escapeHtml(best.title || best.url)}</strong>
<p>${escapeHtml([best.kind, best.source, best.quality, best.file_size || (best.content_length ? formatBytes(best.content_length) : '')].filter(Boolean).join(' · '))}</p>
</div>
<button id="download-best" type="button">Besten Kandidaten herunterladen</button>
`;
bestBox.querySelector('#download-best').addEventListener('click', async () => {
try {
await startImportForUrl(best.url, best.title);
} catch (err) {
result.innerHTML = `<p class="error">${escapeHtml(err.message)}</p>`;
}
});
candidatesSection.appendChild(bestBox);
} else if (options.showBestDownload && !importable.length) {
const hint = document.createElement('p');
hint.textContent = 'Es wurden nur Diagnose-/Blockier-Kandidaten gefunden. Bitte Video in der Vorschau starten und erneut aktualisieren.';
candidatesSection.appendChild(hint);
}
candidates.forEach((candidate, idx) => {
const card = document.createElement('div');
card.className = `candidate ${candidate.allowed ? '' : 'blocked'}`;
const size = candidate.file_size || (candidate.content_length ? formatBytes(candidate.content_length) : 'Dateigröße unbekannt');
const details = [candidate.kind, candidate.source, candidate.quality, `Dateigröße: ${size}`].filter(Boolean).join(' · ');
card.innerHTML = `
<div>
<strong>${escapeHtml(candidate.title || `Kandidat ${idx + 1}`)}</strong>
<p>${escapeHtml(details)}</p>
<code>${escapeHtml(candidate.url)}</code>
${candidate.reason ? `<p class="error">${escapeHtml(candidate.reason)}</p>` : ''}
</div>
<button ${candidate.allowed ? '' : 'disabled'} data-url="${escapeHtml(candidate.url)}" data-title="${escapeHtml(candidate.title || '')}">Übernehmen</button>
`;
card.querySelector('button').addEventListener('click', (event) => {
document.querySelector('#url').value = event.currentTarget.dataset.url;
if (event.currentTarget.dataset.title && !document.querySelector('#title').value) {
document.querySelector('#title').value = event.currentTarget.dataset.title;
}
result.className = 'card';
result.textContent = 'Kandidat übernommen. Du kannst jetzt Quelle prüfen oder Import starten.';
});
candidatesSection.appendChild(card);
});
}
function formatBytes(bytes) {
const units = ['B', 'KB', 'MB', 'GB'];
let value = Number(bytes || 0);
for (const unit of units) {
if (value < 1024 || unit === 'GB') return unit === 'B' ? `${value} ${unit}` : `${value.toFixed(1)} ${unit}`;
value /= 1024;
}
}
async function importCapturedFromUrl() {
const params = new URLSearchParams(location.search);
const encoded = params.get('capture') || (location.hash.startsWith('#capture=') ? location.hash.slice(9) : '');
if (!encoded) return;
try {
const payload = decodeCapturePayload(encoded);
result.className = 'card';
result.textContent = 'Browser-Capture wird geprüft…';
const response = await fetch('/api/browser-capture', {method: 'POST', headers: {'content-type': 'application/json'}, body: JSON.stringify(payload)});
const data = await response.json();
if (response.status === 401) {
await refreshAuthStatus();
throw new Error('Bitte zuerst einloggen.');
}
if (!response.ok) throw new Error(data.detail || 'Browser-Capture konnte nicht geprüft werden');
document.querySelector('#url').value = payload.page_url || '';
result.textContent = `${data.candidates.length} Kandidat(en) aus Browser-Capture gefunden.`;
renderCandidates(data.candidates || []);
history.replaceState(null, '', '/');
} catch (err) {
result.className = 'card';
result.innerHTML = `<p class="error">${escapeHtml(err.message)}</p>`;
}
}
refreshAuthStatus().catch(() => setLoginVisible(false));
startImportPolling();
importCapturedFromUrl();

View File

@@ -0,0 +1,60 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Kino-Projekt</title>
<link rel="stylesheet" href="/static/style.css?v=import-cancel-md5-20260714" />
</head>
<body>
<main>
<h1>Kino-Projekt</h1>
<p>Interner Import für YouTube, legale Mediatheken und direkte erlaubte Medienlinks.</p>
<section id="login-section" class="card hidden">
<h2>Login</h2>
<form id="login-form">
<input id="username" autocomplete="username" placeholder="Benutzername" required />
<input id="password" type="password" autocomplete="current-password" placeholder="Passwort" required />
<button type="submit">Einloggen</button>
</form>
<p id="login-message" class="error"></p>
</section>
<button id="logout" type="button" class="hidden">Abmelden</button>
<form id="probe-form">
<label>
Quelle
<input id="url" type="url" placeholder="https://…" required />
</label>
<fieldset>
<legend>Zielprofil</legend>
<label><input type="radio" name="target-profile" value="clip" checked /> YouTube/Mediathek-Clip</label>
<label><input type="radio" name="target-profile" value="film" /> Film</label>
<label><input type="radio" name="target-profile" value="serie" /> Serie/Episode</label>
</fieldset>
<div class="grid">
<label>Titel-Korrektur <input id="title" placeholder="optional" /></label>
<label>Jahr <input id="year" type="number" min="1888" max="2200" placeholder="optional" /></label>
<label>Serientitel <input id="series-title" placeholder="nur Serie" /></label>
<label>Staffel <input id="season" type="number" min="1" placeholder="1" /></label>
<label>Episode <input id="episode" type="number" min="1" placeholder="optional" /></label>
<label>Episodentitel <input id="episode-title" placeholder="optional" /></label>
</div>
<button type="submit">Quelle prüfen</button>
<button id="analyze-page" type="button">Analysieren</button>
<button id="interactive-analyze" type="button" class="hidden">Interaktive Analyse starten</button>
</form>
<section id="analyzer-tools" class="card">
<h2>Ablauf</h2>
<p>Link einfügen und „Analysieren“ klicken: Die Seite wird serverseitig als Vorschau geöffnet. Findet die Analyse einen importierbaren Kandidaten, erscheint direkt der Button „Besten Kandidaten herunterladen“. Falls noch nichts gefunden wurde, starte das Video in der Vorschau und aktualisiere die Kandidaten während der Wiedergabe.</p>
</section>
<section id="result" class="card hidden"></section>
<section id="candidates" class="card hidden"></section>
<section id="imports" class="card">
<h2>Importe</h2>
<p>Lade Importstatus…</p>
</section>
<button id="import" disabled>Import starten</button>
</main>
<script src="/static/app.js?v=import-cancel-md5-20260714"></script>
</body>
</html>

View File

@@ -0,0 +1,37 @@
body { font-family: system-ui, sans-serif; margin: 0; background: #111827; color: #f9fafb; }
main { max-width: 860px; margin: 4rem auto; padding: 2rem; }
form { display: flex; gap: .75rem; flex-wrap: wrap; }
label { display: flex; flex: 1 1 220px; flex-direction: column; gap: .35rem; }
fieldset { flex: 1 1 100%; border: 1px solid #374151; border-radius: .75rem; display: flex; gap: 1rem; flex-wrap: wrap; }
fieldset label { flex: 0 1 auto; flex-direction: row; align-items: center; }
input { flex: 1; padding: .8rem; border-radius: .5rem; border: 1px solid #374151; background: #1f2937; color: #fff; }
input[type="radio"] { flex: 0 0 auto; }
button { padding: .8rem 1rem; border: 0; border-radius: .5rem; background: #38bdf8; color: #082f49; font-weight: 700; }
button:disabled { opacity: .45; cursor: not-allowed; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: .75rem; width: 100%; }
.card { margin-top: 1rem; padding: 1rem; border: 1px solid #374151; border-radius: .75rem; background: #1f2937; }
.hidden { display: none; }
.error { color: #fca5a5; }
.candidate { margin-top: .75rem; padding-top: .75rem; border-top: 1px solid #374151; display: grid; gap: .75rem; }
.candidate code { display: block; overflow-wrap: anywhere; color: #bae6fd; }
.candidate.blocked { opacity: .7; }
.best-candidate { border: 1px solid #38bdf8; border-radius: .75rem; padding: 1rem; background: #0f2f3f; }
.import-job { border-top: 1px solid #374151; padding: .9rem 0; display: grid; gap: .5rem; }
.import-job:first-of-type { border-top: 0; }
.import-header { display: flex; justify-content: space-between; gap: .75rem; flex-wrap: wrap; }
.import-actions { display: flex; gap: .5rem; flex-wrap: wrap; }
.import-actions button { padding: .55rem .75rem; }
.progress { height: .7rem; border-radius: 999px; background: #111827; overflow: hidden; border: 1px solid #374151; }
.progress > span { display: block; height: 100%; background: #38bdf8; }
.status-done .progress > span { background: #22c55e; }
.status-failed .progress > span { background: #ef4444; }
.muted { color: #9ca3af; }
.tool-actions { display: flex; flex-wrap: wrap; align-items: center; gap: .75rem; }
details { margin-top: .75rem; }
details code { color: #bae6fd; }
.interactive-toolbar { display: flex; flex-wrap: wrap; gap: .75rem; margin: .75rem 0; }
.interactive-screenshot { width: 100%; max-height: 70vh; object-fit: contain; border: 1px solid #374151; border-radius: .5rem; background: #000; cursor: crosshair; }
.loading { opacity: .8; }
.status-cancelling, .status-cancelled { border-color: #f59e0b; }
.import-actions button { margin-right: .5rem; margin-top: .35rem; }

View File

@@ -0,0 +1,23 @@
[project]
name = "kino-projekt-backend"
version = "0.1.0"
description = "Legal-source media import dashboard for Jellyfin"
requires-python = ">=3.11"
dependencies = [
"fastapi>=0.115",
"uvicorn[standard]>=0.30",
"pydantic-settings>=2.4",
"sqlmodel>=0.0.22",
"httpx>=0.27",
"argon2-cffi>=23.1",
"yt-dlp>=2025.1.1",
"playwright>=1.49",
]
[project.optional-dependencies]
test = ["pytest>=8.0", "pytest-asyncio>=0.23"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
pythonpath = ["."]
testpaths = ["tests"]

View File

@@ -0,0 +1,11 @@
import pytest
from app.config import Settings
from app.main import app
@pytest.fixture(autouse=True)
def reset_app_settings():
app.state.settings = Settings(KINOPROJEKT_DB=":memory:")
yield
app.state.settings = Settings(KINOPROJEKT_DB=":memory:")

View File

@@ -0,0 +1,82 @@
from fastapi.testclient import TestClient
from app.main import app
from app.services.page_analyzer import MediaCandidate
def test_analyze_api_returns_candidates(monkeypatch):
async def fake_analyze_page(url):
return [
MediaCandidate(
url="https://cdn.example.org/movie.mp4",
kind="direct_video",
source="html",
content_length=1536,
file_size="1.5 KB",
)
]
monkeypatch.setattr("app.main.analyze_page", fake_analyze_page)
client = TestClient(app)
response = client.post("/api/analyze", json={"url": "https://example.org/watch"})
assert response.status_code == 200
body = response.json()
assert body["page_url"] == "https://example.org/watch"
assert body["candidates"][0]["file_size"] == "1.5 KB"
assert body["candidates"][0]["source"] == "html"
def test_browser_capture_api_returns_candidates(monkeypatch):
async def fake_analyze_browser_capture(captured, page_url=None):
assert page_url == "https://example.org/watch"
assert captured[0]["url"] == "/media/movie.mp4"
return [MediaCandidate(url="https://example.org/media/movie.mp4", kind="direct_video", source="browser-capture")]
monkeypatch.setattr("app.main.analyze_browser_capture", fake_analyze_browser_capture)
client = TestClient(app)
response = client.post(
"/api/browser-capture",
json={"page_url": "https://example.org/watch", "candidates": [{"url": "/media/movie.mp4"}]},
)
assert response.status_code == 200
body = response.json()
assert body["candidates"][0]["source"] == "browser-capture"
def test_interactive_analyze_api_returns_screenshot_and_candidates(monkeypatch):
class FakePage:
url = "https://example.org/watch"
class FakeSession:
id = "session-1"
page_url = "https://example.org/watch"
page = FakePage()
ublock_path = None
async def fake_start(url):
assert url == "https://example.org/watch"
return FakeSession()
async def fake_candidates(session):
assert session.id == "session-1"
return [MediaCandidate(url="https://cdn.example.org/live.m3u8", kind="hls_manifest", source="interactive-browser")]
async def fake_screenshot(session):
return "data:image/png;base64,ZmFrZQ=="
monkeypatch.setattr("app.main.interactive_browser_manager.start", fake_start)
monkeypatch.setattr("app.main.interactive_browser_manager.candidates", fake_candidates)
monkeypatch.setattr("app.main.interactive_browser_manager.screenshot_data_url", fake_screenshot)
client = TestClient(app)
response = client.post("/api/interactive-analyze", json={"url": "https://example.org/watch"})
assert response.status_code == 200
body = response.json()
assert body["session_id"] == "session-1"
assert body["screenshot"].startswith("data:image/png;base64,")
assert body["candidates"][0]["source"] == "interactive-browser"

View File

@@ -0,0 +1,103 @@
from fastapi.testclient import TestClient
from app.config import Settings
from app.main import app
from app.services.auth import hash_password
def configure_auth(password: str = "correct horse battery staple"):
app.state.settings = Settings(
KINOPROJEKT_ADMIN_USER="admin",
KINOPROJEKT_ADMIN_PASSWORD_HASH=hash_password(password),
KINOPROJEKT_SESSION_SECRET="test-secret-that-is-long-enough",
KINOPROJEKT_DB=":memory:",
)
return password
def disable_auth():
app.state.settings = Settings(KINOPROJEKT_DB=":memory:")
def test_auth_disabled_keeps_mvp_api_accessible():
disable_auth()
client = TestClient(app)
response = client.get("/api/auth/status")
assert response.json() == {"enabled": False, "authenticated": True, "username": None}
def test_configured_auth_blocks_api_without_session():
configure_auth()
client = TestClient(app)
response = client.post("/api/probe", json={"url": "http://localhost/video.mp4"})
assert response.status_code == 401
def test_login_sets_secure_httponly_session_and_allows_api():
password = configure_auth()
client = TestClient(app, base_url="https://testserver")
login = client.post("/api/auth/login", json={"username": "admin", "password": password})
assert login.status_code == 204
set_cookie = login.headers["set-cookie"]
assert "HttpOnly" in set_cookie
assert "Secure" in set_cookie
assert "SameSite=lax" in set_cookie
response = client.post("/api/probe", json={"url": "http://localhost/video.mp4"})
assert response.status_code == 400
assert "private" in response.json()["detail"].lower()
def test_login_omits_secure_cookie_on_plain_http_lan_access():
password = configure_auth()
client = TestClient(app, base_url="http://testserver")
login = client.post("/api/auth/login", json={"username": "admin", "password": password})
assert login.status_code == 204
set_cookie = login.headers["set-cookie"]
assert "HttpOnly" in set_cookie
assert "Secure" not in set_cookie
def test_login_sets_secure_cookie_with_forwarded_https_proto():
password = configure_auth()
client = TestClient(app, base_url="http://testserver")
login = client.post(
"/api/auth/login",
json={"username": "admin", "password": password},
headers={"x-forwarded-proto": "https"},
)
assert login.status_code == 204
assert "Secure" in login.headers["set-cookie"]
def test_logout_omits_secure_cookie_on_plain_http_lan_access():
configure_auth()
client = TestClient(app, base_url="http://testserver")
logout = client.post("/api/auth/logout")
assert logout.status_code == 204
assert "Secure" not in logout.headers["set-cookie"]
def test_logout_sets_secure_cookie_with_forwarded_https_proto():
configure_auth()
client = TestClient(app, base_url="http://testserver")
logout = client.post("/api/auth/logout", headers={"x-forwarded-proto": "https"})
assert logout.status_code == 204
assert "Secure" in logout.headers["set-cookie"]
def test_login_rejects_wrong_password():
configure_auth()
client = TestClient(app, base_url="https://testserver")
response = client.post("/api/auth/login", json={"username": "admin", "password": "wrong"})
assert response.status_code == 401

View File

@@ -0,0 +1,101 @@
from pathlib import Path
import pytest
from app.providers.base import DownloadOptions, MediaMetadata
from app.providers.mediathek import MediathekProvider, is_stream_manifest
from app.providers.youtube import parse_yt_dlp_progress
from app.services.downloader import copy_tree_contents
class FakeStream:
def __init__(self, lines: list[bytes]):
self.lines = lines
async def readline(self) -> bytes:
if self.lines:
return self.lines.pop(0)
return b""
class FakeProcess:
def __init__(self, output_path: Path):
self.returncode = 0
self.stdout = FakeStream([b"[download] 100% of 12.00MiB\n"])
self.stderr = FakeStream([])
self.output_path = output_path
async def wait(self) -> int:
self.output_path.write_bytes(b"real media bytes")
return self.returncode
def test_copy_tree_contents_copies_files(tmp_path: Path):
src = tmp_path / 'src'
src.mkdir()
(src / 'movie.mp4').write_bytes(b'data')
(src / 'meta.info.json').write_text('{}')
dest = tmp_path / 'dest'
copied = copy_tree_contents(src, dest)
assert sorted(p.name for p in copied) == ['meta.info.json', 'movie.mp4']
assert (dest / 'movie.mp4').read_bytes() == b'data'
def test_copy_tree_contents_rejects_top_level_symlink(tmp_path: Path):
src = tmp_path / "src"
src.mkdir()
(tmp_path / "secret.txt").write_text("secret")
(src / "leak.txt").symlink_to(tmp_path / "secret.txt")
with pytest.raises(Exception, match="symlink"):
copy_tree_contents(src, tmp_path / "dest")
def test_copy_tree_contents_rejects_nested_symlink(tmp_path: Path):
src = tmp_path / "src"
nested = src / "nested"
nested.mkdir(parents=True)
(tmp_path / "secret.txt").write_text("secret")
(nested / "leak.txt").symlink_to(tmp_path / "secret.txt")
with pytest.raises(Exception, match="symlink"):
copy_tree_contents(src, tmp_path / "dest")
def test_parse_yt_dlp_progress_lines():
assert parse_yt_dlp_progress('[download] 42.7% of 10.00MiB at 1.00MiB/s ETA 00:05') == pytest.approx(0.427)
assert parse_yt_dlp_progress('[download] 100% of 10.00MiB') == 1.0
assert parse_yt_dlp_progress('[info] unrelated') is None
def test_stream_manifest_detection():
assert is_stream_manifest('https://example.org/master.m3u8')
assert is_stream_manifest('https://example.org/manifest.mpd')
assert is_stream_manifest('https://example.org/video', 'application/vnd.apple.mpegurl')
assert not is_stream_manifest('https://example.org/video.mp4', 'video/mp4')
async def test_m3u8_download_uses_ytdlp_not_manifest_file(monkeypatch, tmp_path: Path):
provider = MediathekProvider()
manifest_url = 'https://cdn.example.org/master.m3u8'
async def fake_probe(url: str) -> MediaMetadata:
return MediaMetadata(provider='mediathek', title='master.m3u8', external_id=manifest_url)
async def fake_create_subprocess_exec(*args, **kwargs):
assert args[0].endswith('yt-dlp')
assert '--proxy' not in args
assert '--remux-video' in args
output_template = Path(args[args.index('--output') + 1])
return FakeProcess(output_template.parent / 'downloaded.mp4')
monkeypatch.setattr(provider, 'probe', fake_probe)
monkeypatch.setattr('app.providers.mediathek.shutil.which', lambda name: '/usr/bin/yt-dlp' if name == 'yt-dlp' else None)
monkeypatch.setattr('app.providers.mediathek.asyncio.create_subprocess_exec', fake_create_subprocess_exec)
result = await provider.download(manifest_url, tmp_path, DownloadOptions())
assert [p.name for p in result.output_files] == ['downloaded.mp4']
assert not (tmp_path / 'master.m3u8').exists()

View File

@@ -0,0 +1,7 @@
from fastapi.testclient import TestClient
from app.main import app
def test_health():
client = TestClient(app)
assert client.get('/health').json() == {'status': 'ok'}

View File

@@ -0,0 +1,45 @@
import pytest
from app.main import resolve_importable_url
from app.providers.base import MediaMetadata, ProviderError
from app.services.page_analyzer import MediaCandidate
class FakeProvider:
name = "mediathek"
def can_handle(self, url: str) -> bool:
return url.startswith("https://")
async def probe(self, url: str) -> MediaMetadata:
if url == "https://example.org/watch":
raise ProviderError("URL is reachable, but content type/extension is not accepted: text/html")
if url == "https://cdn.example.org/master.m3u8":
return MediaMetadata(provider=self.name, title="master.m3u8", external_id=url)
raise ProviderError("unexpected url")
async def test_html_page_import_resolves_to_best_media_candidate(monkeypatch):
async def fake_analyze_page(url: str):
assert url == "https://example.org/watch"
return [
MediaCandidate(
url="https://cdn.example.org/master.m3u8",
kind="hls_manifest",
title="HLS stream",
source="browser",
content_length=4096,
allowed=True,
)
]
fake_provider = FakeProvider()
monkeypatch.setattr("app.main.providers", [fake_provider])
monkeypatch.setattr("app.main.analyze_page", fake_analyze_page)
resolved_url, provider, metadata, candidate = await resolve_importable_url("https://example.org/watch")
assert resolved_url == "https://cdn.example.org/master.m3u8"
assert provider is fake_provider
assert metadata.external_id == resolved_url
assert candidate.kind == "hls_manifest"

View File

@@ -0,0 +1,46 @@
import pytest
from app.providers.base import ProviderError
from app.providers.internet_archive import InternetArchiveProvider
def test_internet_archive_detection():
provider = InternetArchiveProvider()
assert provider.can_handle("https://archive.org/details/BigBuckBunny_328")
assert provider.can_handle("https://archive.org/download/BigBuckBunny_328/video.mp4")
assert not provider.can_handle("https://example.com/details/BigBuckBunny_328")
def test_internet_archive_identifier_parsing():
provider = InternetArchiveProvider()
assert provider._identifier_from_url("https://archive.org/details/BigBuckBunny_328") == "BigBuckBunny_328"
assert provider._identifier_from_url("https://archive.org/download/BigBuckBunny_328/video.mp4") == "BigBuckBunny_328"
assert provider._identifier_from_url("https://archive.org/metadata/BigBuckBunny_328") == "BigBuckBunny_328"
def test_select_media_file_prefers_original_mp4():
provider = InternetArchiveProvider()
selected = provider._select_media_file([
{"name": "thumb.jpg", "format": "JPEG", "source": "metadata", "size": "10"},
{"name": "movie.ogv", "format": "Ogg Video", "source": "derivative", "size": "200000000"},
{"name": "movie.mp4", "format": "MPEG4", "source": "original", "size": "100000000"},
])
assert selected is not None
assert selected["name"] == "movie.mp4"
def test_select_media_file_rejects_path_traversal():
provider = InternetArchiveProvider()
assert provider._select_media_file([{"name": "../evil.mp4", "format": "MPEG4", "source": "original"}]) is None
@pytest.mark.asyncio
async def test_probe_requires_media_file(monkeypatch):
provider = InternetArchiveProvider()
async def fake_metadata(identifier: str) -> dict:
return {"metadata": {"title": "No media"}, "files": [{"name": "notes.txt", "format": "Text"}]}
monkeypatch.setattr(provider, "_metadata", fake_metadata)
with pytest.raises(ProviderError):
await provider.probe("https://archive.org/details/no-media")

View File

@@ -0,0 +1,9 @@
import pytest
from app.providers.base import ProviderError
from app.providers.mediathek import MediathekProvider
def test_private_target_rejected():
with pytest.raises(ProviderError):
MediathekProvider()._reject_private_targets('localhost')

View File

@@ -0,0 +1,19 @@
from sqlmodel import Session, select
from app.db import get_engine, init_db
from app.models import ImportJob, Source
def test_models_roundtrip(tmp_path):
engine = get_engine(tmp_path / 'kino.sqlite3')
init_db(engine)
with Session(engine) as session:
src = Source(kind='youtube', url='https://youtu.be/example', title='Example', provider='youtube')
session.add(src)
session.commit()
session.refresh(src)
job = ImportJob(source_id=src.id, target_library='movies')
session.add(job)
session.commit()
assert session.exec(select(Source)).one().title == 'Example'
assert session.exec(select(ImportJob)).one().status == 'queued'

View File

@@ -0,0 +1,175 @@
import pytest
from app.services import page_analyzer
from app.services.page_analyzer import (
MediaCandidate,
analyze_browser_capture,
analyze_page,
analyze_with_ytdlp,
extract_media_urls,
human_size,
_fetch_public_page_body,
)
def test_human_size_labels_file_sizes():
assert human_size(None) is None
assert human_size(512) == "512 B"
assert human_size(1536) == "1.5 KB"
assert human_size(5 * 1024 * 1024) == "5.0 MB"
def test_extract_media_urls_finds_encoded_and_unquoted_js_urls():
html = """
<video data-src="/media/trailer.mp4?token=abc&amp;v=1"></video>
<script>
const hls = "https:\\/\\/cdn.example.org\\/movie\\/master.m3u8?sig=123";
window.player = {dash: https://cdn.example.org/movie/manifest.mpd?x=1};
</script>
"""
assert extract_media_urls(html, "https://example.org/watch") == [
"https://example.org/media/trailer.mp4?token=abc&v=1",
"https://cdn.example.org/movie/master.m3u8?sig=123",
"https://cdn.example.org/movie/manifest.mpd?x=1",
]
@pytest.mark.asyncio
async def test_browser_capture_filters_and_normalizes_candidates(monkeypatch):
captured_probe_input = {}
async def fake_probe(urls, source):
captured_probe_input["urls"] = urls
captured_probe_input["source"] = source
return [MediaCandidate(url=url, kind="direct_video", source=source) for url in urls]
monkeypatch.setattr(page_analyzer, "_probe_candidates_with_meta", fake_probe)
result = await analyze_browser_capture(
[
{"url": "/media/movie.mp4", "title": "Movie"},
{"url": "https://cdn.example.org/live/master.m3u8", "content_type": "application/vnd.apple.mpegurl"},
{"url": "https://cdn.example.org/poster.jpg", "content_type": "image/jpeg"},
],
page_url="https://example.org/watch/1",
)
assert captured_probe_input["source"] == "browser-capture"
assert list(captured_probe_input["urls"]) == [
"https://example.org/media/movie.mp4",
"https://cdn.example.org/live/master.m3u8",
]
assert [item.source for item in result] == ["browser-capture", "browser-capture"]
@pytest.mark.asyncio
async def test_analyze_page_merges_browser_ytdlp_and_html_candidates(monkeypatch):
async def fake_html(url):
return [MediaCandidate(url="https://cdn.example.org/static.mp4", kind="direct_video", source="html")]
async def fake_ytdlp(url):
return [MediaCandidate(url="https://cdn.example.org/ytdlp.m3u8", kind="hls_manifest", source="yt-dlp")]
async def fake_browser(url):
return [MediaCandidate(url="https://cdn.example.org/browser.mpd", kind="dash_manifest", source="browser")]
monkeypatch.setattr(page_analyzer, "analyze_html_page", fake_html)
monkeypatch.setattr(page_analyzer, "analyze_with_ytdlp", fake_ytdlp)
monkeypatch.setattr(page_analyzer, "analyze_with_browser", fake_browser)
result = await analyze_page("https://example.org/watch")
assert {candidate.url: candidate.source for candidate in result} == {
"https://cdn.example.org/static.mp4": "html",
"https://cdn.example.org/ytdlp.m3u8": "yt-dlp",
"https://cdn.example.org/browser.mpd": "browser",
}
@pytest.mark.asyncio
async def test_ytdlp_is_skipped_for_generic_untrusted_hosts(monkeypatch):
calls = []
class Checked:
final_url = "https://example.org/watch"
async def fake_check(url, **kwargs):
return Checked()
class FakeYDL:
def __init__(self, opts):
calls.append(opts)
def __enter__(self):
return self
def __exit__(self, *args):
return None
def extract_info(self, url, download=False):
raise AssertionError("yt-dlp must not run for generic hosts")
monkeypatch.setattr(page_analyzer, "check_url_for_page", fake_check)
monkeypatch.setattr(page_analyzer, "YoutubeDL", FakeYDL)
assert await analyze_with_ytdlp("https://example.org/watch") == []
assert calls == []
@pytest.mark.asyncio
async def test_fetch_public_page_body_revalidates_get_redirect_hops(monkeypatch):
checked_urls = []
class Checked:
def __init__(self, final_url):
self.final_url = final_url
async def fake_check(url, **kwargs):
checked_urls.append(url)
return Checked(url)
async def fake_assert_public_host(url):
checked_urls.append(f"assert:{url}")
class FakeURL:
def __init__(self, url):
self._url = url
def join(self, loc):
return loc
def __str__(self):
return self._url
class FakeResponse:
def __init__(self, url, redirect_to=None, text=""):
self.url = FakeURL(url)
self._redirect_to = redirect_to
self.headers = {"location": redirect_to} if redirect_to else {}
self.text = text
@property
def is_redirect(self):
return self._redirect_to is not None
def raise_for_status(self):
return None
class FakeClient:
def __init__(self, *args, **kwargs):
self.calls = 0
async def __aenter__(self):
return self
async def __aexit__(self, *args):
return None
async def get(self, url, **kwargs):
self.calls += 1
if self.calls == 1:
return FakeResponse(url, redirect_to="https://cdn.example.org/page")
return FakeResponse(url, text="<video src='/movie.mp4'></video>")
monkeypatch.setattr(page_analyzer, "check_url_for_page", fake_check)
monkeypatch.setattr(page_analyzer, "assert_public_host", fake_assert_public_host)
monkeypatch.setattr(page_analyzer.httpx, "AsyncClient", FakeClient)
body, final_url = await _fetch_public_page_body("https://example.org/watch", timeout=1)
assert body.startswith("<video")
assert final_url == "https://cdn.example.org/page"
assert "https://example.org/watch" in checked_urls
assert "https://cdn.example.org/page" in checked_urls
assert "assert:https://cdn.example.org/page" in checked_urls

View File

@@ -0,0 +1,56 @@
from pathlib import Path
import pytest
from app.services.paths import ensure_under, jellyfin_profile_target, media_target, slugify_name, tmp_target
def test_slugify_name_removes_dangerous_chars():
assert slugify_name('../Bad:Title?') == 'Bad_Title'
def test_ensure_under_accepts_child(tmp_path: Path):
assert ensure_under(tmp_path, Path('a/b')).is_relative_to(tmp_path.resolve())
def test_ensure_under_rejects_escape(tmp_path: Path):
with pytest.raises(ValueError):
ensure_under(tmp_path, Path('../escape'))
def test_targets_stay_in_configured_roots(tmp_path: Path):
assert tmp_target(tmp_path / 'tmp', 7, '../../Film').is_relative_to((tmp_path / 'tmp').resolve())
assert media_target(tmp_path / 'media', 'movies', '../../Film').is_relative_to((tmp_path / 'media').resolve())
def test_jellyfin_film_profile_matches_real_library_layout(tmp_path: Path):
target = jellyfin_profile_target(tmp_path / 'media', 'film', 'Big Buck Bunny', year=2008)
assert target == (tmp_path / 'media' / 'Filme' / 'Big Buck Bunny (2008)').resolve()
def test_jellyfin_series_profile_uses_staffel_folder(tmp_path: Path):
target = jellyfin_profile_target(
tmp_path / 'media',
'serie',
'Pilot/Unsafe',
series_title='The Show',
season=2,
episode=3,
episode_title='Pilot/Unsafe',
)
assert target == (tmp_path / 'media' / 'Serien' / 'The Show' / 'Staffel2' / 'S02E03 - Pilot_Unsafe').resolve()
def test_jellyfin_clip_profile_uses_youtube_mediathek(tmp_path: Path):
target = jellyfin_profile_target(
tmp_path / 'media',
'clip',
'../../Clip',
channel='Archive Org',
external_id='abc/123',
)
assert target == (tmp_path / 'media' / 'YouTube-Mediathek' / 'Archive Org' / 'Clip [abc_123]').resolve()
def test_jellyfin_profile_rejects_unknown_profile(tmp_path: Path):
with pytest.raises(ValueError):
jellyfin_profile_target(tmp_path / 'media', 'other', 'Title')

View File

@@ -0,0 +1,9 @@
from fastapi.testclient import TestClient
from app.main import app
def test_probe_rejects_private_direct_url():
client = TestClient(app)
response = client.post('/api/probe', json={'url': 'http://localhost/video.mp4'})
assert response.status_code == 400

View File

@@ -0,0 +1,24 @@
from app.providers.internet_archive import InternetArchiveProvider
from app.providers.mediathek import MediathekProvider
from app.providers.youtube import YouTubeProvider
def test_youtube_detection():
provider = YouTubeProvider()
assert provider.can_handle('https://www.youtube.com/watch?v=abc')
assert provider.can_handle('https://youtu.be/abc')
assert not provider.can_handle('https://example.com/video.mp4')
def test_mediathek_detection_excludes_youtube_and_internet_archive():
provider = MediathekProvider()
assert provider.can_handle('https://example.com/video.mp4')
assert not provider.can_handle('https://youtube.com/watch?v=abc')
assert not provider.can_handle('https://archive.org/details/BigBuckBunny_328')
def test_internet_archive_detection():
provider = InternetArchiveProvider()
assert provider.can_handle('https://archive.org/details/BigBuckBunny_328')
assert provider.can_handle('https://archive.org/download/BigBuckBunny_328/video.mp4')
assert not provider.can_handle('https://example.com/video.mp4')

View File

@@ -0,0 +1,7 @@
from app.providers.youtube import YouTubeProvider
def test_extract_youtube_id():
provider = YouTubeProvider()
assert provider._external_id('https://youtu.be/abc123') == 'abc123'
assert provider._external_id('https://www.youtube.com/watch?v=xyz') == 'xyz'

View File

@@ -0,0 +1,32 @@
# Kino Capture Browser-Extension
Kleine generische Browser-Extension für das Kino-Projekt. Sie sammelt im aktiven Browser-Tab sichtbare Medien-Requests und DOM-/Performance-Kandidaten und übergibt diese an die Kino-App.
## Installation als entpackte Extension
### Chromium / Chrome / Edge
1. Ordner `kino-capture` entpacken.
2. `chrome://extensions` öffnen.
3. Entwicklermodus aktivieren.
4. „Entpackte Erweiterung laden“ klicken.
5. Den Ordner `kino-capture` auswählen.
### Firefox / LibreWolf temporär
1. `about:debugging#/runtime/this-firefox` öffnen.
2. „Temporäres Add-on laden“ klicken.
3. `manifest.json` im Ordner `kino-capture` auswählen.
## Nutzung
1. Video im Browser starten und einige Sekunden laufen lassen.
2. Extension-Icon „Kino Capture“ öffnen.
3. Prüfen, dass Kandidaten angezeigt werden.
4. Kino-App-URL setzen, z. B. `http://192.168.178.213:8099/`.
5. „An Kino-App senden“ klicken.
6. In der Kino-App Kandidat übernehmen und Import starten.
## Grenzen
Die Extension sammelt nur Medien-URLs, die der Browser normal sichtbar anfragt oder im DOM/Performance-API offenlegt. Sie enthält keine hoster-spezifischen Bypässe, keine CAPTCHA-/DRM-/Token-Umgehung und keine Entschlüsselungslogik.

View File

@@ -0,0 +1,68 @@
const MEDIA_EXT_RE = /\.(mp4|m4v|webm|mkv|mov|m3u8|mpd)([?#]|$)/i;
const ABSOLUTE_MEDIA_RE = /(?:https?:\/\/|https?:\\\/\\\/|\/\/|\\\/\\\/)[^\s"'<>]+?\.(?:mp4|m4v|webm|mkv|mov|m3u8|mpd)(?=$|[?&#\s"'<>])(?:[?&][^\s"'<>]*)?/gi;
function ignoreChromeResult(result) {
if (result && typeof result.catch === 'function') result.catch(() => {});
}
function normalizeMediaUrl(rawUrl, baseUrl = location.href) {
if (!rawUrl || typeof rawUrl !== 'string') return null;
try {
const cleaned = rawUrl
.trim()
.replaceAll('\\/', '/')
.replaceAll('\\u002F', '/')
.replaceAll('\\u002f', '/')
.replace(/[;,)}\]]+$/, '');
return new URL(cleaned, baseUrl).href;
} catch (_) {
return null;
}
}
function pushCandidate(candidates, rawUrl, title = document.title, contentType = '', source = 'dom') {
const url = normalizeMediaUrl(rawUrl);
if (!url) return;
const cleanType = String(contentType || '').split(';', 1)[0].trim();
if (MEDIA_EXT_RE.test(url) || cleanType.startsWith('video/') || /mpegurl|dash\+xml/i.test(cleanType)) {
candidates.push({url, title: title || document.title, content_type: contentType || '', source});
}
}
function collectCandidates() {
const candidates = [];
document.querySelectorAll('video,source,a').forEach((el) => {
pushCandidate(candidates, el.currentSrc || el.src || el.href, el.title || el.textContent || document.title, el.type || '', 'dom');
});
performance.getEntriesByType('resource').forEach((entry) => {
pushCandidate(candidates, entry.name, document.title, entry.initiatorType === 'video' ? 'video/unknown' : '', `performance:${entry.initiatorType || 'resource'}`);
});
const html = document.documentElement?.outerHTML || '';
for (const match of html.matchAll(ABSOLUTE_MEDIA_RE)) {
pushCandidate(candidates, match[0], document.title, '', 'html-js');
}
const seen = new Set();
return candidates.filter((candidate) => {
if (seen.has(candidate.url)) return false;
seen.add(candidate.url);
return true;
}).slice(0, 120);
}
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
if (message?.type === 'collect-media') {
sendResponse({candidates: collectCandidates()});
return true;
}
return false;
});
// Opportunistisch nach dem Laden melden, damit das Popup sofort Daten hat.
setTimeout(() => {
for (const candidate of collectCandidates()) {
ignoreChromeResult(chrome.runtime.sendMessage({type: 'candidate', candidate}));
}
}, 1500);

View File

@@ -0,0 +1,30 @@
{
"manifest_version": 2,
"name": "Kino Capture",
"version": "0.1.1",
"description": "Sammelt sichtbare Medien-Requests im aktiven Tab und übergibt sie an die Kino-Projekt-App.",
"permissions": [
"activeTab",
"storage",
"tabs",
"webNavigation",
"webRequest",
"<all_urls>"
],
"background": {
"scripts": ["service_worker.js"],
"persistent": false
},
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["content.js"],
"run_at": "document_idle",
"all_frames": true
}
],
"browser_action": {
"default_title": "Kino Capture",
"default_popup": "popup.html"
}
}

View File

@@ -0,0 +1,35 @@
body {
min-width: 380px;
max-width: 520px;
margin: 0;
background: #111827;
color: #f9fafb;
font: 14px system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
main { padding: 14px; }
h1 { margin: 0 0 8px; font-size: 18px; }
label { display: grid; gap: 4px; margin: 10px 0; }
input {
padding: 8px;
border-radius: 8px;
border: 1px solid #374151;
background: #1f2937;
color: #fff;
}
button {
padding: 8px 10px;
border: 0;
border-radius: 8px;
background: #38bdf8;
color: #082f49;
font-weight: 700;
cursor: pointer;
}
button.secondary { background: #374151; color: #f9fafb; }
button:disabled { opacity: .5; cursor: not-allowed; }
.actions { display: flex; flex-wrap: wrap; gap: 8px; }
ol { padding-left: 20px; max-height: 320px; overflow: auto; }
li { margin: 8px 0; border-top: 1px solid #374151; padding-top: 8px; }
code { display: block; color: #bae6fd; overflow-wrap: anywhere; font-size: 12px; }
.meta, .hint { color: #d1d5db; font-size: 12px; }
.error { color: #fca5a5; }

View File

@@ -0,0 +1,27 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Kino Capture</title>
<link rel="stylesheet" href="popup.css" />
</head>
<body>
<main>
<h1>Kino Capture</h1>
<p id="status">Lade Kandidaten…</p>
<label>
Kino-App URL
<input id="app-url" type="url" placeholder="http://192.168.178.213:8099/" />
</label>
<div class="actions">
<button id="refresh" type="button">Neu scannen</button>
<button id="send" type="button">An Kino-App senden</button>
<button id="clear" type="button" class="secondary">Leeren</button>
</div>
<ol id="candidates"></ol>
<p class="hint">Tipp: Video starten und einige Sekunden laufen lassen, dann dieses Popup öffnen.</p>
</main>
<script src="popup.js"></script>
</body>
</html>

View File

@@ -0,0 +1,103 @@
const DEFAULT_APP_URL = 'http://192.168.178.213:8099/';
const statusEl = document.querySelector('#status');
const listEl = document.querySelector('#candidates');
const appUrlEl = document.querySelector('#app-url');
const sendButton = document.querySelector('#send');
const refreshButton = document.querySelector('#refresh');
const clearButton = document.querySelector('#clear');
let activeTab = null;
let currentPayload = {page_url: '', candidates: []};
function encodeCapturePayload(payload) {
return btoa(unescape(encodeURIComponent(JSON.stringify(payload))));
}
function normalizeAppUrl(value) {
try {
const url = new URL(value || DEFAULT_APP_URL);
if (!url.pathname.endsWith('/')) url.pathname = `${url.pathname}/`;
url.search = '';
url.hash = '';
return url.href;
} catch (_) {
return DEFAULT_APP_URL;
}
}
function render(payload) {
currentPayload = payload || {page_url: activeTab?.url || '', candidates: []};
const candidates = currentPayload.candidates || [];
statusEl.textContent = `${candidates.length} Kandidat(en) im aktiven Tab gefunden.`;
statusEl.className = candidates.length ? '' : 'error';
sendButton.disabled = candidates.length === 0;
listEl.innerHTML = '';
for (const candidate of candidates.slice(0, 30)) {
const li = document.createElement('li');
const type = candidate.content_type || candidate.source || 'media';
li.innerHTML = `<div class="meta"></div><code></code>`;
li.querySelector('.meta').textContent = type;
li.querySelector('code').textContent = candidate.url;
listEl.appendChild(li);
}
}
async function getActiveTab() {
const [tab] = await chrome.tabs.query({active: true, currentWindow: true});
return tab;
}
async function refresh() {
activeTab = await getActiveTab();
if (!activeTab?.id) {
statusEl.textContent = 'Kein aktiver Tab gefunden.';
statusEl.className = 'error';
return;
}
const response = await chrome.runtime.sendMessage({type: 'get-candidates', tabId: activeTab.id});
if (!response?.ok) {
statusEl.textContent = response?.error || 'Kandidaten konnten nicht gelesen werden.';
statusEl.className = 'error';
return;
}
render(response);
}
async function saveAppUrl() {
const appUrl = normalizeAppUrl(appUrlEl.value);
appUrlEl.value = appUrl;
await chrome.storage.local.set({kinoAppUrl: appUrl});
return appUrl;
}
async function sendToKinoApp() {
const appUrl = await saveAppUrl();
const encoded = encodeCapturePayload({
page_url: currentPayload.page_url || activeTab?.url || '',
candidates: currentPayload.candidates || [],
});
const destination = `${appUrl}?capture=${encodeURIComponent(encoded)}`;
await chrome.tabs.create({url: destination});
}
async function clearCandidates() {
if (!activeTab?.id) activeTab = await getActiveTab();
if (activeTab?.id) await chrome.runtime.sendMessage({type: 'clear-candidates', tabId: activeTab.id});
render({page_url: activeTab?.url || '', candidates: []});
}
async function init() {
const stored = await chrome.storage.local.get({kinoAppUrl: DEFAULT_APP_URL});
appUrlEl.value = normalizeAppUrl(stored.kinoAppUrl);
await refresh();
}
appUrlEl.addEventListener('change', saveAppUrl);
refreshButton.addEventListener('click', refresh);
sendButton.addEventListener('click', sendToKinoApp);
clearButton.addEventListener('click', clearCandidates);
init().catch((error) => {
statusEl.textContent = error?.message || String(error);
statusEl.className = 'error';
});

View File

@@ -0,0 +1,138 @@
const MEDIA_EXT_RE = /\.(mp4|m4v|webm|mkv|mov|m3u8|mpd)([?#]|$)/i;
const MEDIA_CONTENT_RE = /^(video\/|application\/(vnd\.apple\.mpegurl|x-mpegurl|dash\+xml)|audio\/mpegurl)/i;
const MAX_CANDIDATES_PER_TAB = 120;
const tabCandidates = new Map();
const tabPageUrls = new Map();
const actionApi = chrome.action || chrome.browserAction;
function ignoreChromeResult(result) {
if (result && typeof result.catch === 'function') result.catch(() => {});
}
function setBadgeTextSafe(tabId, text) {
if (actionApi?.setBadgeText) ignoreChromeResult(actionApi.setBadgeText({tabId, text}));
}
function setBadgeBackgroundColorSafe(tabId, color) {
if (actionApi?.setBadgeBackgroundColor) ignoreChromeResult(actionApi.setBadgeBackgroundColor({tabId, color}));
}
function candidateKey(candidate) {
return candidate.url;
}
function normalizeUrl(rawUrl) {
if (!rawUrl || typeof rawUrl !== 'string') return null;
try {
return new URL(rawUrl).href;
} catch (_) {
return null;
}
}
function looksLikeMedia(url, contentType = '') {
return MEDIA_EXT_RE.test(url) || MEDIA_CONTENT_RE.test(String(contentType).split(';', 1)[0].trim());
}
function addCandidate(tabId, candidate) {
if (tabId < 0 || !candidate?.url) return;
const url = normalizeUrl(candidate.url);
if (!url) return;
const contentType = candidate.content_type || candidate.type || '';
if (!looksLikeMedia(url, contentType)) return;
const existing = tabCandidates.get(tabId) || [];
if (existing.some((item) => candidateKey(item) === url)) return;
existing.unshift({
url,
title: candidate.title || candidate.name || '',
content_type: contentType,
quality: candidate.quality || '',
source: candidate.source || 'webRequest',
ts: Date.now(),
});
tabCandidates.set(tabId, existing.slice(0, MAX_CANDIDATES_PER_TAB));
setBadgeTextSafe(tabId, String(Math.min(existing.length, 99)));
setBadgeBackgroundColorSafe(tabId, '#38bdf8');
}
chrome.webRequest.onBeforeRequest.addListener(
(details) => {
if (details.tabId < 0 || details.type === 'image') return;
if (looksLikeMedia(details.url)) {
addCandidate(details.tabId, {url: details.url, source: `request:${details.type}`});
}
},
{urls: ['<all_urls>']}
);
chrome.webRequest.onHeadersReceived.addListener(
(details) => {
if (details.tabId < 0) return;
const header = (details.responseHeaders || []).find((item) => item.name.toLowerCase() === 'content-type');
const contentType = header?.value || '';
if (looksLikeMedia(details.url, contentType)) {
addCandidate(details.tabId, {url: details.url, content_type: contentType, source: 'response'});
}
},
{urls: ['<all_urls>']},
['responseHeaders']
);
chrome.webNavigation?.onCommitted?.addListener?.((details) => {
if (details.frameId === 0) {
tabPageUrls.set(details.tabId, details.url);
tabCandidates.delete(details.tabId);
setBadgeTextSafe(details.tabId, '');
}
});
chrome.tabs.onRemoved.addListener((tabId) => {
tabCandidates.delete(tabId);
tabPageUrls.delete(tabId);
});
async function collectDomCandidates(tabId) {
try {
const responses = await chrome.tabs.sendMessage(tabId, {type: 'collect-media'});
return Array.isArray(responses) ? responses : responses?.candidates || [];
} catch (_) {
return [];
}
}
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
(async () => {
if (message?.type === 'candidate' && sender.tab?.id != null) {
addCandidate(sender.tab.id, message.candidate);
sendResponse({ok: true});
return;
}
if (message?.type === 'get-candidates') {
const tabId = message.tabId;
const domCandidates = await collectDomCandidates(tabId);
for (const candidate of domCandidates) addCandidate(tabId, {...candidate, source: candidate.source || 'dom'});
const tab = await chrome.tabs.get(tabId).catch(() => null);
sendResponse({
ok: true,
page_url: tab?.url || tabPageUrls.get(tabId) || '',
candidates: tabCandidates.get(tabId) || [],
});
return;
}
if (message?.type === 'clear-candidates') {
const tabId = message.tabId;
tabCandidates.delete(tabId);
setBadgeTextSafe(tabId, '');
sendResponse({ok: true});
return;
}
sendResponse({ok: false, error: 'unknown message'});
})();
return true;
});

View File

@@ -0,0 +1,11 @@
services:
kino-projekt:
build: .
ports:
- "127.0.0.1:8099:8080"
volumes:
- ./data:/data
- /jellyfin:/jellyfin
env_file:
- .env
restart: unless-stopped

View File

@@ -0,0 +1,19 @@
# Deployment
1. `.env` auf dem Zielhost aus `.env.example` erstellen; `JELLYFIN_API_KEY`, `KINOPROJEKT_ADMIN_PASSWORD_HASH` und `KINOPROJEKT_SESSION_SECRET` nur dort oder in Vaultwarden speichern.
2. Medien- und Datenverzeichnisse als Volumes mounten.
3. Dienst nur Mesh-only veröffentlichen und den App-Login aktivieren.
4. Nach Start prüfen:
```bash
curl http://127.0.0.1:8080/health
curl -X POST http://127.0.0.1:8080/api/probe \
-H 'content-type: application/json' \
--data '{"url":"http://localhost/video.mp4"}'
# Erwartet: HTTP 400, weil private/loopback Ziele abgelehnt werden.
```
5. Login prüfen: Ohne Session antworten geschützte API-Routen mit `401`; nach `POST /api/auth/login` setzt die App ein `HttpOnly; Secure; SameSite=Lax` Session-Cookie.
6. Zielprofile prüfen: Ein Import mit `target_profile=film|serie|clip` muss unter `/jellyfin/Filme`, `/jellyfin/Serien/.../Staffel<N>` oder `/jellyfin/YouTube-Mediathek` landen. Der Ordner `/jellyfin/YouTube-Mediathek` sollte auf dem Zielhost `jellyfinuser:media` gehören und setgid wie die vorhandenen Bibliotheken nutzen.
7. Für echte Importe muss `yt-dlp` im Container/Host verfügbar sein; das Dockerfile installiert es.
8. Jellyfin-Refresh wird über `POST <JELLYFIN_URL>/Library/Refresh` mit `X-Emby-Token` ausgelöst, wenn `JELLYFIN_API_KEY` gesetzt ist.

View File

@@ -0,0 +1,11 @@
# Provider Policy
Erlaubt sind nur öffentlich und legal erreichbare Quellen ohne Umgehung technischer oder rechtlicher Zugriffsbeschränkungen.
## Implementierte Provider
- **YouTube:** Metadaten/Downloads über `yt-dlp`, ohne Cookies, Login-Flows oder Umgehungsflags; Default-Qualität maximal 1080p.
- **Internet Archive:** akzeptiert `archive.org/details/<identifier>`, `archive.org/download/<identifier>/...` und `archive.org/metadata/<identifier>`; Metadaten kommen aus der öffentlichen Archive-Metadata-API, heruntergeladen wird nur ein plausibler öffentlicher Medien-Track.
- **Mediathek/Direktlink:** direkte URLs werden vor Verwendung gegen SSRF-Risiken geprüft: Schema, DNS, Redirects, private IP-Ranges und Content-Type/Dateiendung.
Nicht erlaubt sind Piracy-Scraper, DRM-/Paywall-/Login-/Geoblocking-Umgehung, Cookie-Importe und freie Server-Schreibpfade.

View File

@@ -0,0 +1,13 @@
DEFAULT_MAX_HEIGHT=1080
MAX_DOWNLOAD_BYTES=5368709120
JELLYFIN_URL=http://192.168.178.222:8096
JELLYFIN_API_KEY=CHANGE_ME
KINOPROJEKT_DB_URL=sqlite:////opt/kino-projekt/data/kino.sqlite3
KINOPROJEKT_MEDIA_ROOT=/opt/kino-projekt/export
KINOPROJEKT_TMP=/opt/kino-projekt/data/tmp
KINOPROJEKT_RSYNC_TARGET=kino-transfer@192.168.178.222
KINOPROJEKT_RSYNC_SSH_KEY=/opt/kino-projekt/.ssh/jellyfin_transfer_ed25519
KINOPROJEKT_RSYNC_REMOTE_ROOT=/jellyfin
KINOPROJEKT_ADMIN_USER=admin
KINOPROJEKT_ADMIN_PASSWORD_HASH=
KINOPROJEKT_SESSION_SECRET=CHANGE_ME

View File

@@ -0,0 +1,11 @@
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 DATA_DIR=/data
WORKDIR /app
RUN pip install --no-cache-dir flask requests gunicorn
COPY app.py /app/app.py
RUN useradd --uid 10002 --create-home --shell /usr/sbin/nologin monitor \
&& mkdir -p /data \
&& chown -R monitor:monitor /app /data
USER monitor
EXPOSE 8080
CMD ["gunicorn", "--bind", "0.0.0.0:8080", "--workers", "1", "--threads", "4", "--timeout", "30", "app:app"]

View File

@@ -0,0 +1,253 @@
#!/usr/bin/env python3
import os, sqlite3, time, json, datetime, pathlib, threading
from collections import defaultdict
from flask import Flask, jsonify, render_template_string
import requests
DATA_DIR=pathlib.Path(os.getenv('DATA_DIR','/var/lib/token-monitor'))
DB=DATA_DIR/'hermes-state.db'
ALERT_STATE=DATA_DIR/'alert-state.json'
# Legacy Hermes-internal limits are kept for backwards-compatible API fields only.
# Provider stock/remaining budget is calculated in provider_inventory() below.
DAILY_LIMIT=int(os.getenv('DAILY_TOKEN_LIMIT','0') or 0)
MONTHLY_LIMIT=int(os.getenv('MONTHLY_TOKEN_LIMIT','0') or 0)
DISCORD_BOT_TOKEN=os.getenv('DISCORD_BOT_TOKEN','')
DISCORD_CHANNEL_ID=os.getenv('DISCORD_CHANNEL_ID','')
REFRESH_SECONDS=int(os.getenv('REFRESH_SECONDS','300'))
app=Flask(__name__)
PROVIDER_LABELS={
'anthropic':'Anthropic', 'nvidia':'NVIDIA', 'openrouter':'OpenRouter',
'ollama':'Ollama Cloud', 'openai':'OpenAI', 'openai-codex':'OpenAI/Codex'
}
def _connect():
if not DB.exists(): return None
return sqlite3.connect(f'file:{DB}?mode=ro', uri=True)
def _num_env(name):
raw=os.getenv(name,'').strip()
if not raw: return None
try: return float(raw.replace('_',''))
except ValueError: return None
def _fmt_amount(value, unit):
if value is None: return None
if unit == 'usd':
return f'${value:,.2f}'
if unit == 'credits':
return f'{value:,.2f} Credits'
return f'{int(value):,} Tokens'.replace(',', '.')
def provider_for_model(model):
m=(model or '').lower()
if m.startswith('anthropic/') or m.startswith('claude-') or '/claude-' in m:
return 'anthropic'
if m.startswith('nvidia/') or 'nemotron' in m:
return 'nvidia'
if m.startswith('openai/') or m.startswith('gpt-') or '/gpt-' in m:
return 'openai-codex'
if m.startswith('openrouter/'):
return 'openrouter'
if m.startswith('ollama/'):
return 'ollama'
return 'unknown'
def usage_by_provider(rows):
daily=defaultdict(int); monthly=defaultdict(int); total=defaultdict(int)
now=datetime.datetime.now()
month_start=now.replace(day=1,hour=0,minute=0,second=0,microsecond=0).timestamp()
day_start=now.replace(hour=0,minute=0,second=0,microsecond=0).timestamp()
for r in rows:
prov=provider_for_model(r['model'])
tok=int(r['tok'] or 0); ts=float(r['timestamp'] or 0)
total[prov]+=tok
if ts>=day_start: daily[prov]+=tok
if ts>=month_start: monthly[prov]+=tok
return {'daily':dict(daily), 'monthly':dict(monthly), 'total':dict(total)}
def provider_inventory(usage=None):
"""Return provider-reported or explicitly configured remaining stock.
We intentionally do not treat Hermes message history as the source of truth for
available quota. It is only used as a subtraction term when a provider-specific
stock limit was explicitly configured via environment variables.
"""
usage=usage or {'daily':{}, 'monthly':{}, 'total':{}}
providers=[]
candidates=[]
if os.getenv('ANTHROPIC_API_KEY') or os.getenv('ANTHROPIC_TOKEN'):
candidates.append('anthropic')
if os.getenv('NVIDIA_API_KEY'):
candidates.append('nvidia')
if os.getenv('OPENROUTER_API_KEY'):
candidates.append('openrouter')
if os.getenv('OLLAMA_API_KEY'):
candidates.append('ollama')
for extra in os.getenv('TOKEN_MONITOR_PROVIDERS','').split(','):
extra=extra.strip().lower()
if extra and extra not in candidates: candidates.append(extra)
for name in candidates:
unit=os.getenv(f'{name.upper().replace("-","_")}_STOCK_UNIT','tokens').lower()
entry={
'provider': name,
'label': PROVIDER_LABELS.get(name,name),
'ok': None,
'status': None,
'source': 'unavailable',
'unit': unit,
'daily_limit': _num_env(f'{name.upper().replace("-","_")}_DAILY_TOKEN_LIMIT'),
'total_limit': _num_env(f'{name.upper().replace("-","_")}_TOTAL_TOKEN_LIMIT'),
'daily_used': usage.get('daily',{}).get(name,0),
'total_used': usage.get('total',{}).get(name,0),
'daily_remaining': None,
'total_remaining': None,
'note': None,
}
try:
if name=='openrouter':
r=requests.get('https://openrouter.ai/api/v1/credits',headers={'Authorization':'Bearer '+os.getenv('OPENROUTER_API_KEY','')},timeout=8)
entry.update({'ok':r.status_code<400,'status':r.status_code})
if r.ok:
data=r.json().get('data',{})
credits=float(data.get('total_credits') or 0)
used=float(data.get('total_usage') or 0)
entry.update({
'source':'provider_api', 'unit':'usd',
'total_limit': credits,
'total_used': used,
'total_remaining': max(0.0, credits-used),
'note':'OpenRouter meldet Gesamt-Credits und Gesamtverbrauch; kein Tagesbestand verfügbar.'
})
elif name=='anthropic':
r=requests.get('https://api.anthropic.com/v1/models',headers={'x-api-key':os.getenv('ANTHROPIC_API_KEY') or os.getenv('ANTHROPIC_TOKEN',''),'anthropic-version':'2023-06-01'},timeout=8)
entry.update({'ok':r.status_code<400,'status':r.status_code,'note':'Anthropic stellt für normale API-Keys keinen Restbestand/Quota-Endpunkt bereit.'})
elif name=='nvidia':
r=requests.get('https://integrate.api.nvidia.com/v1/models',headers={'Authorization':'Bearer '+os.getenv('NVIDIA_API_KEY','')},timeout=8)
entry.update({'ok':r.status_code<400,'status':r.status_code,'note':'NVIDIA meldet per Models-API nur Erreichbarkeit, keinen Token-Restbestand.'})
elif name=='ollama':
r=requests.get('https://ollama.com/v1/models',headers={'Authorization':'Bearer '+os.getenv('OLLAMA_API_KEY','')},timeout=8)
entry.update({'ok':r.status_code<400,'status':r.status_code,'note':'Ollama Cloud meldet hier nur API-Erreichbarkeit, keinen Restbestand.'})
except Exception as e:
entry.update({'ok':False,'error':str(e)[:160]})
if entry['daily_limit'] is not None and entry['daily_remaining'] is None:
entry['daily_remaining']=max(0, entry['daily_limit']-entry['daily_used'])
entry['source']='configured_limit_minus_hermes_usage'
if entry['total_limit'] is not None and entry['total_remaining'] is None:
entry['total_remaining']=max(0, entry['total_limit']-entry['total_used'])
entry['source']='configured_limit_minus_hermes_usage'
entry['daily_remaining_display']=_fmt_amount(entry['daily_remaining'], entry['unit'])
entry['total_remaining_display']=_fmt_amount(entry['total_remaining'], entry['unit'])
entry['daily_limit_display']=_fmt_amount(entry['daily_limit'], entry['unit'])
entry['total_limit_display']=_fmt_amount(entry['total_limit'], entry['unit'])
providers.append(entry)
return providers
def metrics():
con=_connect()
now=datetime.datetime.now()
month_start=now.replace(day=1,hour=0,minute=0,second=0,microsecond=0).timestamp()
day_start=now.replace(hour=0,minute=0,second=0,microsecond=0).timestamp()
out={'updated_at': time.time(), 'daily_limit':DAILY_LIMIT,'monthly_limit':MONTHLY_LIMIT,
'total_tokens':0,'daily_tokens':0,'monthly_tokens':0,'by_model':[], 'by_day':[], 'by_source':[],
'recent_sessions':[], 'provider_inventory': [], 'provider_health': []}
rows=[]
if not con:
out['error']='Hermes state DB noch nicht synchronisiert.'
out['provider_inventory']=provider_inventory()
out['provider_health']=out['provider_inventory']
return out
con.row_factory=sqlite3.Row
cur=con.cursor()
cur.execute('''select coalesce(s.model,"unknown") model, coalesce(s.source,"unknown") source, m.timestamp,
case when m.token_count is not null then m.token_count
else max(1, cast(length(coalesce(m.content,''))/4 as integer)) end tok
from messages m join sessions s on s.id=m.session_id''')
rows=cur.fetchall()
by_model=defaultdict(int); by_source=defaultdict(int); by_day=defaultdict(int)
for r in rows:
tok=int(r['tok'] or 0); ts=float(r['timestamp'] or 0)
out['total_tokens']+=tok
by_model[r['model']]+=tok; by_source[r['source']]+=tok
by_day[datetime.datetime.fromtimestamp(ts).strftime('%Y-%m-%d')]+=tok
if ts>=day_start: out['daily_tokens']+=tok
if ts>=month_start: out['monthly_tokens']+=tok
usage=usage_by_provider(rows)
out['provider_inventory']=provider_inventory(usage)
out['provider_health']=out['provider_inventory'] # backwards-compatible name for old UI/API users
out['by_provider_usage']=[{'provider':k,'tokens':v} for k,v in sorted(usage['total'].items(), key=lambda x:-x[1])]
out['by_model']=[{'model':k,'tokens':v} for k,v in sorted(by_model.items(), key=lambda x:-x[1])]
out['by_source']=[{'source':k,'tokens':v} for k,v in sorted(by_source.items(), key=lambda x:-x[1])]
out['by_day']=[{'day':k,'tokens':v} for k,v in sorted(by_day.items())[-60:]]
cur.execute('select id, source, model, started_at, ended_at, message_count from sessions order by started_at desc limit 20')
out['recent_sessions']=[dict(r) for r in cur.fetchall()]
return out
def send_discord(msg):
if not (DISCORD_BOT_TOKEN and DISCORD_CHANNEL_ID): return False
r=requests.post(f'https://discord.com/api/v10/channels/{DISCORD_CHANNEL_ID}/messages',headers={'Authorization':'Bot '+DISCORD_BOT_TOKEN,'Content-Type':'application/json'},json={'content':msg},timeout=10)
return r.status_code<300
def alert_loop():
DATA_DIR.mkdir(parents=True, exist_ok=True)
while True:
try:
m=metrics(); state={}
if ALERT_STATE.exists(): state=json.loads(ALERT_STATE.read_text())
today=datetime.date.today().isoformat(); month=datetime.date.today().strftime('%Y-%m')
for p in m.get('provider_inventory',[]):
for scope,period in [('daily',today),('total',month)]:
rem=p.get(f'{scope}_remaining'); limit=p.get(f'{scope}_limit')
if rem is None or not limit or limit <= 0: continue
ratio=rem/limit
for threshold,label in [(0.2,'unter 20%'),(0.0,'aufgebraucht')]:
if ratio<=threshold:
sid=f'provider-stock:{p["provider"]}:{scope}:{period}:{threshold}'
if not state.get(sid):
send_discord(f'⚠️ Token-Monitor: {p.get("label",p["provider"])} {scope}-Bestand {label}: {p.get(f"{scope}_remaining_display") or rem} verfügbar')
state[sid]=time.time()
ALERT_STATE.write_text(json.dumps(state,indent=2))
except Exception as e:
print('alert_loop error',e,flush=True)
time.sleep(REFRESH_SECONDS)
@app.route('/api/metrics')
def api_metrics(): return jsonify(metrics())
@app.route('/')
def index():
return render_template_string('''<!doctype html><html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Model Token Monitor</title><script src="https://cdn.jsdelivr.net/npm/chart.js"></script><style>body{font-family:system-ui;background:#0b1020;color:#e5eefc;margin:0}main{max-width:1200px;margin:auto;padding:24px}.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:16px}.card{background:#111a33;border:1px solid #26375f;border-radius:16px;padding:16px}.muted{color:#9fb0d0}.big{font-size:1.8rem;font-weight:700;margin:.2rem 0}.pill{display:inline-block;border-radius:999px;padding:2px 8px;background:#25375f;margin-left:6px}canvas{background:#111a33;border-radius:16px;padding:12px;margin-top:16px}.ok{color:#56d364}.bad{color:#ff7b72}.unknown{color:#d29922}table{width:100%;border-collapse:collapse}td,th{padding:8px;border-bottom:1px solid #26375f;text-align:left}code{color:#7ee787}</style></head><body><main><h1>Model Token Monitor</h1><p>Bestand der Modell-Anbieter: wie viel heute bzw. insgesamt noch verfügbar ist. Hermes-Verbrauch bleibt darunter nur als Historie sichtbar.</p><div class="cards" id="cards"></div><h2>Anbieter-Bestand</h2><div class="cards" id="providers"></div><h2>Hermes-Nutzungshistorie</h2><canvas id="day"></canvas><canvas id="model"></canvas><h2>Letzte Sessions</h2><div id="sessions"></div></main><script>
let charts=[];
function fmt(n){return (n||0).toLocaleString('de-DE')}
function val(v,fallback='nicht verfügbar'){return (v===null||v===undefined||v==='')?fallback:v}
function statusClass(p){return p.ok===true?'ok':(p.ok===false?'bad':'unknown')}
async function load(){let m=await (await fetch('/api/metrics')).json();
let providers=m.provider_inventory||[];
let availableDaily=providers.filter(p=>p.daily_remaining_display).length;
let availableTotal=providers.filter(p=>p.total_remaining_display).length;
cards.innerHTML=`<div class=card><b>Anbieter geprüft</b><div class=big>${providers.length}</div></div><div class=card><b>Tagesbestand bekannt</b><div class=big>${availableDaily}/${providers.length}</div></div><div class=card><b>Gesamtbestand bekannt</b><div class=big>${availableTotal}/${providers.length}</div></div><div class=card><b>Update</b><div class=big>${new Date(m.updated_at*1000).toLocaleString('de-DE')}</div></div>`;
providers.innerHTML=providers.map(p=>`<div class=card><b>${p.label||p.provider}</b><span class="pill ${statusClass(p)}">${p.ok===true?'API OK':p.ok===false?'API FEHLER':'Bestand unbekannt'}</span><p class=muted>Quelle: ${p.source||'unbekannt'} ${p.status?`· HTTP ${p.status}`:''}</p><div>Täglich verfügbar</div><div class=big>${val(p.daily_remaining_display)}</div>${p.daily_limit_display?`<p class=muted>Limit ${p.daily_limit_display}, Hermes heute genutzt ${fmt(p.daily_used)}</p>`:''}<div>Insgesamt verfügbar</div><div class=big>${val(p.total_remaining_display)}</div>${p.total_limit_display?`<p class=muted>Limit/Credits ${p.total_limit_display}, genutzt ${p.total_used}</p>`:''}${p.note?`<p class=muted>${p.note}</p>`:''}${p.error?`<code>${p.error}</code>`:''}</div>`).join('')||'<p>Keine Provider-Credentials oder TOKEN_MONITOR_PROVIDERS konfiguriert.</p>';
charts.forEach(c=>c.destroy()); charts=[];
charts.push(new Chart(day,{type:'line',data:{labels:m.by_day.map(x=>x.day),datasets:[{label:'Hermes Tokens/Tag (Historie)',data:m.by_day.map(x=>x.tokens),borderColor:'#00d4ff'}]}}));
charts.push(new Chart(model,{type:'bar',data:{labels:m.by_model.slice(0,12).map(x=>x.model),datasets:[{label:'Hermes Tokens nach Modell',data:m.by_model.slice(0,12).map(x=>x.tokens),backgroundColor:'#00d4ff'}]},options:{indexAxis:'y'}}));
sessions.innerHTML='<table><tr><th>Zeit</th><th>Quelle</th><th>Modell</th><th>Msgs</th></tr>'+m.recent_sessions.map(s=>`<tr><td>${new Date(s.started_at*1000).toLocaleString('de-DE')}</td><td>${s.source}</td><td>${s.model}</td><td>${s.message_count}</td></tr>`).join('')+'</table>';
} load(); setInterval(load,300000);
</script></body></html>''')
if __name__=='__main__':
threading.Thread(target=alert_loop,daemon=True).start()
app.run(host='0.0.0.0',port=8080)

View File

@@ -0,0 +1,11 @@
DATA_DIR=/data
DAILY_TOKEN_LIMIT=0
MONTHLY_TOKEN_LIMIT=0
REFRESH_SECONDS=300
TOKEN_MONITOR_PROVIDERS=nvidia,ollama
NVIDIA_API_KEY=CHANGE_ME
OLLAMA_API_KEY=CHANGE_ME
DISCORD_BOT_TOKEN=CHANGE_ME
DISCORD_CHANNEL_ID=CHANGE_ME
PVE_HOST=192.168.178.30
HERMES_CTID=109