Import remaining Nextcloud Kino-Projekt material into git

Moves git-compatible content from Nextcloud
(Botomir/Projekte/Angefangen/Kino-Projekt) into this repo per cleanup
request, under nextcloud-import/ to avoid colliding with the actively
maintained backend/frontend/worker layout:

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

A third-party Firefox extension folder was intentionally left in Nextcloud
(unrelated vendor code, not project source).
This commit is contained in:
Hermes Agent
2026-08-17 10:47:13 +02:00
parent 0980e79297
commit 884c198783
175 changed files with 28028 additions and 0 deletions

View File

@@ -0,0 +1,716 @@
# Botomir-Status
Aktualisiert: 2026-06-25 06:20 CEST
## Update 2026-06-25 06:20 CEST
- Reverse-Proxy-Blocker für `kino.dasposchi.de` behoben: Nginx Proxy Manager auf dem Public-VPS hat jetzt einen eigenen Proxy-Host `kino.dasposchi.de` mit Lets-Encrypt-Zertifikat und Headscale-Mesh-only Access List.
- Für den Upstream wurde ein dedizierter Reverse-SSH-Tunnel vom Jellyfin-LXC zum VPS eingerichtet:
- `kino-vps-tunnel.service` auf CT `100` (`jellyfin`) ist `active`.
- VPS/NPM-Container `headscale-tunnel-sshd:8892` leitet auf `127.0.0.1:8099` im Jellyfin-LXC weiter.
- NPM-Datenbank/Configs wurden vor Änderung gesichert: `/opt/infra/npm/backups/botomir-kino-20260625-060320/`.
- Neues Zertifikat: `/etc/letsencrypt/live/npm-29/`, SAN `DNS:kino.dasposchi.de`, gültig bis `2026-09-23 03:05:22 UTC`.
- Secret-Hygiene: App-Passwort wurde nicht ausgegeben; lokale Passwortdatei ist jetzt `0600`.
### Verifikation in diesem Lauf
```text
# Public ohne Mesh: TLS/SNI repariert, aber bewusst blockiert
GET http://kino.dasposchi.de/
# Ergebnis: HTTP 301 -> https://kino.dasposchi.de/
GET https://kino.dasposchi.de/
# Ergebnis: HTTP 403 openresty (Headscale-Mesh-only Access List greift)
# Mesh-Zugriff über public Hostname und VPS-Mesh-IP
curl --interface tailscale0 --resolve kino.dasposchi.de:443:100.64.0.9 https://kino.dasposchi.de/
# Ergebnis: HTTP 200, 2217 bytes, WebUI erreichbar
curl --interface tailscale0 --resolve kino.dasposchi.de:443:100.64.0.9 https://kino.dasposchi.de/health
# Ergebnis: HTTP 200 {"status":"ok"}
curl --interface tailscale0 --resolve kino.dasposchi.de:443:100.64.0.9 https://kino.dasposchi.de/api/auth/status
# Ergebnis: HTTP 200 {"enabled":true,"authenticated":false,"username":null}
# App-Login ohne Secret-Ausgabe
POST https://kino.dasposchi.de/api/auth/login (über Mesh, Passwort aus root-only lokaler Datei)
# Ergebnis: HTTP 204
GET https://kino.dasposchi.de/api/auth/status (mit Session-Cookie)
# Ergebnis: HTTP 200 {"enabled":true,"authenticated":true,"username":"admin"}
POST https://kino.dasposchi.de/api/probe {"url":"https://archive.org/details/BigBuckBunny_328"} (mit Session-Cookie)
# Ergebnis: HTTP 200, provider=internet_archive, title="Big Buck Bunny", external_id="BigBuckBunny_328", allowed=True
# Zielhost lokal
systemctl is-active kino-projekt.service
# Ergebnis: active
systemctl is-active kino-vps-tunnel.service
# Ergebnis: active
curl http://127.0.0.1:8099/health
# Ergebnis: HTTP 200 {"status":"ok"}
# VPS/NPM
nginx -t im npm-server-app Container
# Ergebnis: syntax is ok; test is successful
proxy_host DB-Zeile:
# id=15, domain=["kino.dasposchi.de"], upstream=headscale-tunnel-sshd:8892, access_list_id=4, certificate_id=29, ssl_forced=1, enabled=1
```
### Aktueller Stand
- Akzeptanzkriterien `public blockiert`, `Mesh erreichbar`, `App-Login aktiv`, `Probe legaler Quelle`, `Jellyfin-Dienst gesund` sind erfüllt.
- Nicht durchgeführt: echter Download/Import in die Jellyfin-Bibliothek. Grund: Das würde reale Mediendateien in `/jellyfin` schreiben; ohne explizite Auswahl/Bestätigung wurde nur eine Probe gegen eine legale Quelle durchgeführt.
## Update 2026-06-25 02:00 CEST
- Zielhost-Deployment auf dem Jellyfin-LXC `jellyfin` (`192.168.178.222`) durchgeführt: bestehendes `/opt/kino-projekt/app-src` wurde gesichert und durch den aktuellen legal-source FastAPI-Stand ersetzt.
- Vor dem Austausch wurde ein Backup erstellt: `/opt/kino-projekt/backups/deploy-20260625-015600/` mit `app-src.tar.gz` und der geschützten `/etc/kino-projekt.env`.
- Der bestehende systemd-Dienst `kino-projekt.service` wurde neu gestartet und läuft weiter auf Port `8099`.
- `/jellyfin/YouTube-Mediathek` ist vorhanden und passend gesetzt: `2775 kino:media`.
- Kompatibilität ergänzt: `backend/app/config.py` akzeptiert jetzt das bereits auf dem Zielhost vorhandene Legacy-Setting `KINOPROJEKT_DB_URL=sqlite:////...`, ohne neue Secrets oder `.env`-Werte in Nextcloud abzulegen.
### Verifikation in diesem Lauf
```text
cd /root/botomir-work/kino-projekt-current/backend
. .venv/bin/activate
pytest -q
# Ergebnis: 27 passed, 1 warning in 0.45s
cd /root/botomir-work/kino-projekt-current
docker build -t kino-projekt-botomir:cron-20260625-deploy .
# Ergebnis: Image gebaut: sha256:e78ca77fb8434ea10ff4622489f4543a31f2d682c688ff72f71f4c429b832669
Container-Smoke-Test:
GET http://127.0.0.1:18085/health
# Ergebnis: HTTP 200 {"status":"ok"}
GET http://127.0.0.1:18085/api/auth/status
# Ergebnis: HTTP 200 {"enabled":false,"authenticated":true,"username":null}
POST http://127.0.0.1:18085/api/probe {"url":"http://localhost/video.mp4"}
# Ergebnis: HTTP 400, private/loopback Ziele werden abgelehnt
Zielhost:
systemctl is-active kino-projekt.service
# Ergebnis: active
GET http://192.168.178.222:8099/health
# Ergebnis: HTTP 200 {"status":"ok"}
GET http://192.168.178.222:8099/api/auth/status
# Ergebnis: HTTP 200 {"enabled":true,"authenticated":false,"username":null}
GET http://192.168.178.222:8099/
# Ergebnis: HTTP 200, 2217 bytes, neue WebUI erreichbar
POST http://192.168.178.222:8099/api/probe ohne Login
# Ergebnis: HTTP 401 {"detail":"Login required"}
stat /jellyfin/YouTube-Mediathek
# Ergebnis: 2775 kino:media /jellyfin/YouTube-Mediathek
Jellyfin Health: GET http://192.168.178.222:8096/health
# Ergebnis: HTTP 200 Healthy
```
### Noch offen / Blocker
- Öffentliches `https://kino.dasposchi.de/` ist noch nicht korrekt auf den Dienst geroutet: aktueller Probe-Fehler `TLS connect error ... tlsv1 unrecognized name`/HTTP `000`. Der lokale Dienst auf dem Jellyfin-LXC läuft; als nächstes muss der Nginx-Proxy-Manager-/Edge-VHost für `kino.dasposchi.de` auf `192.168.178.222:8099` mit Mesh-only Access List eingerichtet bzw. repariert werden.
- Ein echter Import mit App-Login/Jellyfin-Refresh wurde noch nicht ausgelöst, weil kein Klartext-App-Passwort in Nextcloud liegt und Secrets nicht ausgegeben wurden. Die Auth-Schicht ist aktiv (`enabled=true`) und blockiert unauthentifizierte API-Aufrufe korrekt.
### Dateien mit Änderungen in diesem Lauf
- `backend/app/config.py` — Legacy-Kompatibilität für `KINOPROJEKT_DB_URL` ergänzt.
- Zielhost `/opt/kino-projekt/app-src/` — aktueller App-Stand deployed.
- `Botomir-Status.md` — diese Statusnotiz aktualisiert.
## Update 2026-06-24 21:41 CEST
- Jellyfin-Zielprofile umgesetzt: `film`, `serie` und `clip` erzeugen jetzt serverseitig die zur verifizierten Bibliotheksstruktur passenden Zielordner unter `/jellyfin/Filme`, `/jellyfin/Serien/.../Staffel<N>` und `/jellyfin/YouTube-Mediathek`.
- WebUI erweitert: Zielprofil-Radios sowie optionale Felder für Titel, Jahr, Serientitel, Staffel, Episode und Episodentitel werden beim Import an `POST /api/imports` gesendet.
- Import-Request-Metadaten werden in SQLite mit der Source gespeichert, damit der Hintergrundjob die Zielpfad-Korrekturen ohne zusätzliche Secrets/Nextcloud-Daten verwenden kann.
- README und Deployment-Doku beschreiben die Zielprofile und den anzulegenden `/jellyfin/YouTube-Mediathek`-Ordner.
### Verifikation in diesem Lauf
```text
cd /root/botomir-work/kino-projekt-current/backend
. .venv/bin/activate
pytest -q
# Ergebnis: 27 passed, 1 warning in 0.49s
cd /root/botomir-work/kino-projekt-current
docker build -t kino-projekt-botomir:cron-20260624-profiles .
# Ergebnis: Image gebaut: sha256:574c9013385541f774a160eb99b14ab139cfd99c8a7e0aec4119daf41b4a18f8
# Build-Kontext: 26.66 kB
docker run -d --rm -p 127.0.0.1:18084:8080 -e KINOPROJEKT_DB=/tmp/kino.sqlite3 -e KINOPROJEKT_MEDIA_ROOT=/tmp/jellyfin kino-projekt-botomir:cron-20260624-profiles
curl http://127.0.0.1:18084/health
# Ergebnis: HTTP 200 {"status":"ok"}
GET /
# Ergebnis: HTTP 200, 2217 bytes, Zielprofil-Steuerelemente vorhanden
GET /static/app.js
# Ergebnis: HTTP 200, 4973 bytes, `currentImportPayload` sendet `target_profile`
```
### Dateien mit Änderungen in diesem Lauf
- `backend/app/services/paths.py` — Jellyfin-Zielprofil-Pfade (`film`, `serie`, `clip`) ergänzt.
- `backend/app/schemas.py` — ImportRequest um Zielprofil- und Korrekturfelder erweitert.
- `backend/app/main.py` — Import-Request-Metadaten werden in `Source.metadata_json` persistiert; Jobs verwenden das Zielprofil.
- `backend/app/services/downloader.py` — Hintergrundimport nutzt die Zielprofil-Pfade statt freier Bibliotheksnamen.
- `backend/app/web/index.html`, `backend/app/web/app.js`, `backend/app/web/style.css` — WebUI-Zielprofil-Auswahl und optionale Korrekturfelder.
- `backend/tests/test_path_safety.py` — Regressionstests für Film-/Serien-/Clip-Pfade.
- `README.md`, `docs/deployment.md`, `Botomir-Status.md` — Doku/Status aktualisiert.
### Offene nächste Schritte
1. Zielhost-Deployment vorbereiten: geschützte `.env`/Vaultwarden-Werte für App-Login und Jellyfin setzen; keine Secrets in Nextcloud ablegen.
2. Auf dem Zielhost `/jellyfin/YouTube-Mediathek` mit Owner/Gruppe analog zu `/jellyfin/Filme` und `/jellyfin/Serien` anlegen.
3. Mesh-only Veröffentlichung `kino.dasposchi.de` und danach echten legalen Testimport plus Jellyfin-Refresh verifizieren.
## Update 2026-06-24 17:34 CEST
- Internet-Archive-Provider ergänzt: `archive.org/details/<identifier>`, `archive.org/download/<identifier>/...` und `archive.org/metadata/<identifier>` werden jetzt vor dem generischen Direktlink-Provider erkannt.
- Metadaten werden über die öffentliche Archive-Metadata-API abgefragt; Downloads wählen nur plausible öffentliche Medien-Dateien und laufen danach durch den bestehenden SSRF-geschützten Downloadpfad.
- Provider-Policy und README wurden um Internet Archive als erlaubte Quelle erweitert.
### Verifikation in diesem Lauf
```text
cd /root/botomir-work/kino-projekt-current/backend
. .venv/bin/activate
pytest -q
# Ergebnis: 23 passed, 1 warning in 0.43s
python - <<'PY'
import asyncio
from app.providers.internet_archive import InternetArchiveProvider
async def main():
meta = await InternetArchiveProvider().probe('https://archive.org/details/BigBuckBunny_328')
print(meta.model_dump())
asyncio.run(main())
PY
# Ergebnis: provider=internet_archive, title='Big Buck Bunny', external_id='BigBuckBunny_328'
docker build -t kino-projekt-botomir:cron-20260624-ia .
# Ergebnis: Image gebaut: sha256:4c45d54599dcde1a5d212213c637190000cd2086e9b0671e6499b55ffe723135
# Build-Kontext: 19.41 kB
docker run -d --rm -p 127.0.0.1:18083:8080 -e KINOPROJEKT_DB=/tmp/kino.sqlite3 kino-projekt-botomir:cron-20260624-ia
curl http://127.0.0.1:18083/health
# Ergebnis: HTTP 200 {"status":"ok"}
curl -H 'content-type: application/json' -d '{"url":"https://archive.org/details/BigBuckBunny_328"}' http://127.0.0.1:18083/api/probe
# Ergebnis: provider=internet_archive, title='Big Buck Bunny', external_id='BigBuckBunny_328', allowed=True
```
### Dateien mit Änderungen in diesem Lauf
- `backend/app/providers/internet_archive.py` — neuer Provider für öffentliche Internet-Archive-Items.
- `backend/app/main.py` — Provider registriert, vor dem generischen Direktlink-Provider.
- `backend/app/providers/mediathek.py` — Archive-URLs vom generischen Direktlink-Provider ausgeschlossen.
- `backend/tests/test_internet_archive_provider.py` — Unit-Tests für Erkennung, Identifier-Parsing, Medienauswahl und Fehlfall ohne Medien.
- `backend/tests/test_provider_detection.py` — Detection-Regression für Internet Archive ergänzt.
- `README.md`, `docs/provider-policy.md`, `Botomir-Status.md` — Dokumentation aktualisiert.
## Aktueller Stand
- Projekt wurde aus `Projekte/ToDo/` nach `Projekte/Angefangen/` verschoben.
- Initialer, rechtlich sauberer MVP-Code wurde ergänzt. Der hochgeladene xstream-Code bleibt nur Analyse-Anhang; Piracy-Scraper/Hoster werden nicht implementiert.
- Umgesetzt: FastAPI-Healthcheck, statische Minimal-WebUI, Provider-Abstraktion, YouTube-Erkennung/Probe über `yt-dlp` ohne Cookies/Umgehungsoptionen, konservative Mediathek-/Direktlink-Prüfung mit SSRF-Schutz, SQLite-Modelle, Pfadsicherheitshelfer, Dockerfile, Compose-Datei, `.dockerignore` und Doku.
- Import-Job-MVP: `POST /api/imports`, `GET /api/imports`, `GET /api/imports/{id}`, `GET /api/sources`, Hintergrund-Runner, sichere Zielpfad-Kopie, YouTube-/Direktlink-Download-Wrapper, optionaler Jellyfin-Library-Refresh über `JELLYFIN_API_KEY`, UI-Button zum Starten eines Imports und Downloader-Test.
- App-Login: Argon2-Passwort-Hash, signiertes Session-Cookie (`HttpOnly`, `Secure`, `SameSite=Lax`), `POST /api/auth/login`, `POST /api/auth/logout`, `GET /api/auth/status`, UI-Login-Formular, `.env.example` und Tests. API-Routen sind geschützt, sobald `KINOPROJEKT_ADMIN_PASSWORD_HASH` und `KINOPROJEKT_SESSION_SECRET` gesetzt sind.
- Neu in diesem Lauf: Import-Fortschritt verbessert. `yt-dlp` läuft mit `--newline`; Download-Prozentzeilen werden geparst und in den Job-Fortschritt gemappt. Direkte Mediathek-/HTTP-Downloads melden Fortschritt über `content-length`, wenn verfügbar. Die WebUI pollt `GET /api/imports/{id}` alle 2 Sekunden und zeigt Status, Prozent, Zielpfad sowie Hinweise/Fehler live an.
- Secrets wurden nicht in Nextcloud abgelegt. `.env.example` enthält nur leere Platzhalter und Befehle zum lokalen Erzeugen von Hash/Secret.
## Verifikation lokal im Hermes-Container
```text
cd /root/botomir-work/kino-projekt-current/backend
. .venv/bin/activate
pytest -q
# Ergebnis: 17 passed, 1 warning in 0.41s
```
Docker-Build:
```text
cd /root/botomir-work/kino-projekt-current
docker build -t kino-projekt-botomir:cron-20260624-progress .
# Ergebnis: Image gebaut: sha256:f6b1fc960f939d2cda658aefc1df7e0c29f5d73bc2a4ca04a2e27fbbea897b0b
# Build-Kontext: 21.22 kB; lokale .venv/__pycache__/egg-info-Artefakte wurden durch .dockerignore ausgeschlossen.
```
Container-Smoke-Test ohne Zielhost-Secrets:
```text
docker run -d --rm -p 127.0.0.1:18082:8080 \
-e KINOPROJEKT_DB=/tmp/kino.sqlite3 \
kino-projekt-botomir:cron-20260624-progress
curl http://127.0.0.1:18082/health
# Ergebnis: HTTP 200 {"status":"ok"}
curl http://127.0.0.1:18082/api/auth/status
# Ergebnis: HTTP 200 {"enabled":false,"authenticated":true,"username":null}
curl -o /tmp/kino_index.html -w '%{http_code} bytes=%{size_download}\n' http://127.0.0.1:18082/
# Ergebnis: 200 bytes=1212
curl http://127.0.0.1:18082/static/app.js | grep -q 'pollJob'
# Ergebnis: yes
```
## Dateien mit Änderungen in diesem Lauf
- `backend/app/providers/base.py` — Provider-Download-Signatur um optionalen Fortschritts-Callback erweitert.
- `backend/app/providers/youtube.py``yt-dlp --newline`, Fortschrittsparser und laufendes Stream-Auslesen statt nur finalem `communicate()`.
- `backend/app/providers/mediathek.py` — direkter HTTP-Download meldet Fortschritt anhand `content-length`.
- `backend/app/services/downloader.py` — Provider-Fortschritt wird auf `progress=0.15..0.75` gemappt und in SQLite gespeichert.
- `backend/app/web/app.js` — Job-Status wird live gepollt und gerendert.
- `backend/tests/test_downloader.py` — Regressionstest für `yt-dlp`-Fortschrittsparser ergänzt.
- `Botomir-Status.md` — diese Statusnotiz aktualisiert.
## Offene nächste Schritte
1. Zielhost-Deployment vorbereiten: `KINOPROJEKT_ADMIN_PASSWORD_HASH`, `KINOPROJEKT_SESSION_SECRET`, `JELLYFIN_API_KEY` und Zielpfade aus Vaultwarden/geschützter `.env` setzen; keine Secrets in Nextcloud ablegen.
2. Mesh-only Deployment von `kino.dasposchi.de` vorbereiten/umsetzen, sobald Zielhost und Reverse-Proxy-Pfad eindeutig feststehen.
3. Danach echten Import gegen einen legalen Testlink sowie Jellyfin-Refresh auf dem Zielhost verifizieren.
## Update 2026-06-29 03:20 CEST
- Neuer Upload im Projekt erkannt: `{b9db16a4-6edc-47ec-a1f4-b86292ed211d}/` ist die Firefox-Erweiterung **Video DownloadHelper 10.2.71.2**. Es lag keine separate Aufgabenbeschreibung dazu vor; ich habe den Upload als Hinweis auf HLS/DASH-/DownloadHelper-ähnliche Workflows gewertet.
- Bugfix umgesetzt und auf den Zielhost deployed: Mediathek-/Direktlink-Downloads behandeln `.m3u8`/`.mpd` bzw. `application/vnd.apple.mpegurl`, `application/x-mpegurl`, `application/dash+xml` jetzt als HLS/DASH-Manifeste. Sie werden nicht mehr als kleine Playlist-Datei per raw HTTP in Jellyfin kopiert, sondern über `yt-dlp --newline --remux-video mp4` mit `ffmpeg` geroutet.
- Sicherheitsverhalten bleibt erhalten: Redirect-Ziele werden weiter gegen private/interne IP-Ranges geprüft; direkte `localhost`-/private Ziele werden abgelehnt; Secrets wurden nicht in Nextcloud geschrieben.
- `.dockerignore` erweitert, damit lokale virtuelle Umgebungen wie `.venv312/` nicht in Docker-Kontexte geraten.
- Deploy auf Zielhost `jellyfin` (`192.168.178.222`) in bestehende Architektur `/opt/kino-projekt/app-src`; bestehender Stand wurde vor Austausch unter `/opt/kino-projekt/backups/20260629-031329/` gesichert.
### Verifikation in diesem Lauf
```text
# Lokale Build-/Testverifikation im Botomir-Worktree
cd /root/botomir-work/kino-cron-20260629/backend
uv venv -p 3.12 .venv312 && uv pip install -e '.[test]'
pytest -q
# Ergebnis: 30 passed, 1 warning in 0.51s
cd /root/botomir-work/kino-cron-20260629
docker build -t kino-projekt-botomir:cron-20260629 .
# Ergebnis: Image sha256:582ec85a3d785f6a94fefb03a56d0153afc2b601c5476babcb8137024754c457
# Docker build context: 65.30kB
Container-Smoke-Test:
GET http://127.0.0.1:18086/health
# Ergebnis: HTTP 200 {"status":"ok"}
POST http://127.0.0.1:18086/api/probe {"url":"http://localhost/video.mp4"}
# Ergebnis: HTTP 400 {"detail":"Refusing private or non-public target address for localhost"}
GET http://127.0.0.1:18086/api/auth/status
# Ergebnis: HTTP 200 {"enabled":false,"authenticated":true,"username":null}
# Zielhost nach Deploy
cd /opt/kino-projekt/app-src/backend
sudo -u kino env KINOPROJEKT_DB=/opt/kino-projekt/data/test-kino.sqlite3 \
KINOPROJEKT_MEDIA_ROOT=/opt/kino-projekt/data/test-media \
KINOPROJEKT_TMP=/opt/kino-projekt/data/test-tmp \
/opt/kino-projekt/venv/bin/pytest -q
# Ergebnis: 30 passed, 1 warning in 1.15s
systemctl is-active kino-projekt.service
# Ergebnis: active
curl http://127.0.0.1:8099/health
# Ergebnis: HTTP 200 {"status":"ok"}
ss -ltnp | grep 8099
# Ergebnis: LISTEN 0 2048 0.0.0.0:8099
```
### Noch offen
- Der neue DownloadHelper-Upload enthält keine konkrete Änderungsanforderung. Wenn daraus mehr entstehen soll als die abgesicherte HLS/DASH-Korrektur, braucht es eine explizite gewünschte Funktion (z. B. Bookmarklet/Browser-Capture, Kandidatenliste aus einer Seite, oder Import eines konkret ausgewählten legalen Medienlinks).
- Kein echter Medienimport gestartet, weil das weiterhin reale Dateien in die Jellyfin-Bibliothek schreiben würde und keine explizite Quell-/Zielauswahl vorlag.
## Update 2026-06-30 03:48 CEST
- Beim planmäßigen Botomir-Check war `kino.dasposchi.de` öffentlich weiterhin korrekt blockiert (`403 openresty`), aber der Mesh-Zugriff lieferte `502 Bad Gateway`.
- Ursache: Der produktive Kino-Projekt-Dienst läuft auf der separaten VM `kino-projekt` (`192.168.178.213`) und war lokal gesund; der alte/fehlende Reverse-SSH-Upstream zum Public-VPS/NPM-Tunnelcontainer stellte jedoch keinen Listener auf `8892` bereit.
- Reparatur umgesetzt:
- Auf VM `kino-projekt` wurde ein dedizierter root-only Reverse-Tunnel-Key `/root/.ssh/kino_reverse_tunnel_ed25519` erzeugt.
- Im VPS-Tunnelcontainer wurde der vorhandene `kino-jellyfin-reverse-tunnel`-Eintrag restriktiv auf den neuen Public Key aktualisiert (`restrict,port-forwarding,permitlisten="0.0.0.0:8892"`).
- Neuer Dienst `/etc/systemd/system/kino-vps-tunnel.service` auf `kino-projekt` eingerichtet und aktiviert; er verbindet per SSH über `152.53.187.222:2222` und forwarded `0.0.0.0:8892` auf `127.0.0.1:8099` der Kino-App.
- Der neue Service-Key wurde als Vaultwarden-Item `VPN Mesh - Kino Reverse Tunnel SSH Key` in der Homelab-Collection abgelegt; keine Secrets wurden in Nextcloud notiert.
### Verifikation in diesem Lauf
```text
# Zielhost
systemctl is-active kino-projekt.service
# Ergebnis: active
systemctl is-active kino-vps-tunnel.service
# Ergebnis: active
curl http://127.0.0.1:8099/health
# Ergebnis: HTTP 200 {"status":"ok"}
# VPS-Tunnelcontainer
/proc/net/tcp enthält Listener 0.0.0.0:8892
# Ergebnis: Port 8892 lauscht
# Mesh-Zugriff über Public Hostname und VPS-Mesh-IP
curl --interface tailscale0 --resolve kino.dasposchi.de:443:100.64.0.9 https://kino.dasposchi.de/health
# Ergebnis: HTTP 200 {"status":"ok"}
curl --interface tailscale0 --resolve kino.dasposchi.de:443:100.64.0.9 https://kino.dasposchi.de/api/auth/status
# Ergebnis: HTTP 200 {"enabled":true,"authenticated":false,"username":null}
curl --interface tailscale0 --resolve kino.dasposchi.de:443:100.64.0.9 https://kino.dasposchi.de/
# Ergebnis: HTTP 200, WebUI `Kino-Projekt` erreichbar
# Öffentlicher Zugriff ohne Mesh bleibt blockiert
curl https://kino.dasposchi.de/health
# Ergebnis: HTTP 403 openresty
```
### Noch offen
- Kein echter Medienimport gestartet, da weiterhin keine explizite Quell-/Zielauswahl vorliegt und ein Import reale Dateien in die Jellyfin-Bibliothek schreiben würde.
## Update 2026-07-01 20:12 CEST
- Planmäßiger Botomir-Check: keine neuen Dateien in `Botomir/Aufgaben/` oder `Botomir/Projekte/ToDo/`; aktives Projekt bleibt `Projekte/Angefangen/Kino-Projekt/`.
- Bounded Health-Recheck ohne Secret-Ausgabe durchgeführt.
### Verifikation in diesem Lauf
```text
# Nextcloud/WebDAV
PROPFIND Botomir/
# Ergebnis: erreichbar; Aufgaben/ enthält nur erledigt/, Projekte/ToDo/ ist leer; aktives Projekt: Kino-Projekt.
# Öffentlicher Zugriff ohne Mesh bleibt blockiert
curl https://kino.dasposchi.de/health
# Ergebnis: HTTP 403 openresty, 150 bytes
# Zielhost/LAN-App direkt
curl http://192.168.178.213:8099/health
# Ergebnis: HTTP 200 {"status":"ok"}
# Alte Mesh-Prüfroute aus der letzten Statusnotiz
curl --interface tailscale0 --resolve kino.dasposchi.de:443:100.64.0.9 https://kino.dasposchi.de/health
# Ergebnis: Timeout; `tailscale ping 100.64.0.9` meldet `no matching peer`.
# `tailscale status` in dieser Hermes-Umgebung listet aktuell keinen Peer 100.64.0.9/Public-VPS-Edge.
```
### Offener Punkt
- Kein echter Medienimport gestartet, da weiterhin keine explizite Quell-/Zielauswahl vorliegt.
- Hinweis: App und öffentliche Sperre sind gesund, aber die Mesh-Verifikation über die alte Edge-IP `100.64.0.9` ist aus dieser Hermes-Umgebung derzeit nicht möglich. Falls `kino.dasposchi.de` über Mesh erreichbar sein soll, sollte die aktuelle Tailnet-/Edge-IP bzw. Edge-Enrolment geprüft werden.
## Update 2026-07-02 Botomir-Check
- Keine neuen Dateien in `Botomir/Aufgaben/`; `Botomir/Projekte/ToDo/` ist weiterhin leer.
- Bounded Health-Recheck für das aktive `Kino-Projekt` durchgeführt.
- Die alte Mesh-IP `100.64.0.9` aus der vorherigen Notiz ist weiterhin nicht nutzbar; `tailscale status` zeigt den Public-VPS jetzt als `100.80.185.34`.
- Mit der aktuellen Public-VPS-Mesh-IP funktioniert der Mesh-Zugriff wieder; öffentliche Zugriffe ohne Mesh bleiben erwartungsgemäß blockiert.
- Kein echter Medienimport gestartet, da weiterhin keine explizite Quell-/Zielauswahl vorliegt.
### Verifikation in diesem Lauf
```text
PROPFIND Botomir/Aufgaben/
# HTTP 207; enthält nur erledigt/
PROPFIND Botomir/Projekte/ToDo/
# HTTP 207; keine Projektordner enthalten
curl https://kino.dasposchi.de/health
# HTTP 403, openresty-Blockseite, remote_ip=152.53.187.222
curl http://192.168.178.213:8099/health
# HTTP 200 {"status":"ok"}
tailscale status
# public-vps ist aktuell 100.80.185.34
curl --interface tailscale0 --resolve kino.dasposchi.de:443:100.80.185.34 https://kino.dasposchi.de/health
# HTTP 200 {"status":"ok"}
curl --interface tailscale0 --resolve kino.dasposchi.de:443:100.80.185.34 https://kino.dasposchi.de/api/auth/status
# HTTP 200; Login aktiv, nicht authentifiziert
```
## Cron-Check 2026-07-03 12:42 CEST
- Keine neuen Dateien in `Botomir/Aufgaben/` oder `Botomir/Projekte/ToDo/` gefunden; `Kino-Projekt` bleibt das einzige laufende Projekt.
- Korrektur zum Zwischenbefund dieses Laufs: Die zuerst geprüften Ziele `192.168.178.222:8099` und `100.64.0.9` waren veraltet. Laut vorhandener Statusnotiz ist der aktuelle Kino-App-Zielhost `192.168.178.213:8099` und der aktuelle Public-VPS-Mesh-Endpunkt `100.80.185.34`.
- Nextcloud ist gesund: `GET https://cloud.dasposchi.de/status.php` → HTTP `200`, `maintenance=false`, `needsDbUpgrade=false`.
- Öffentlicher Zugriff ohne Mesh bleibt wie beabsichtigt gesperrt: `GET https://kino.dasposchi.de/` → HTTP `403 openresty`.
- Kino-App lokal/LAN gesund: `GET http://192.168.178.213:8099/health` → HTTP `200`, `{"status":"ok"}`.
- Mesh-Zugriff gesund: `tailscale ping 100.80.185.34``pong from public-vps ... in 23ms`; `GET https://kino.dasposchi.de/health` über `tailscale0`/`--resolve ...:100.80.185.34` → HTTP `200`, `{"status":"ok"}`; `GET /api/auth/status` → HTTP `200`, `{"enabled":true,"authenticated":false,"username":null}`.
- Jellyfin-Health bleibt über den Medien-Host erreichbar: `GET http://192.168.178.222:8096/health` → HTTP `200 Healthy`. `192.168.178.213:8096` ist nicht offen, was zur Trennung App-VM vs. Jellyfin-Host passt.
- Kein echter Medienimport gestartet, weil weiterhin keine explizite Quell-/Zielauswahl vorliegt.
## Cron-Check 2026-07-04 05:03 CEST
- Keine neuen Dateien in `Botomir/Aufgaben/` gefunden; `Botomir/Projekte/ToDo/` ist leer. `Kino-Projekt` bleibt das einzige laufende Projekt.
- Nextcloud/WebDAV wurde über LAN-Fallback geprüft, weil der öffentliche Edge-Pfad erneut hängen kann: `GET https://cloud.dasposchi.de/status.php` mit `--resolve ...:192.168.178.32` → HTTP `200`, `maintenance=false`, `needsDbUpgrade=false`.
- Regressionsbefund beim aktiven Kino-Projekt: Der bisher aktuelle App-Host `192.168.178.213` ist aus dem Hermes/LAN nicht erreichbar. `ping -c 2 192.168.178.213` → 100% Paketverlust; Portscan auf `22/80/443/8080/8099/8000/5000` → keine offenen Dienste; `curl http://192.168.178.213:8099/health` → Timeout/HTTP `000`.
- Mesh/Public-Edge-Prüfung: `tailscale status` listet `public-vps` weiter als `100.80.185.34`, `tailscale ping 100.80.185.34` → Pong über `152.53.187.222:41641`; aber `curl --interface tailscale0 --resolve kino.dasposchi.de:443:100.80.185.34 https://kino.dasposchi.de/health` → Timeout/HTTP `000`. Öffentlicher Zugriff ohne Mesh bleibt korrekt gesperrt: `https://kino.dasposchi.de/health` → HTTP `403 openresty`.
- Jellyfin selbst ist weiterhin erreichbar: LAN-Portscan findet `192.168.178.222:8096` offen. Damit wirkt der Ausfall auf die separate Kino-App-VM `VM207 / 192.168.178.213` bzw. deren Reverse-Tunnel begrenzt, nicht auf Nextcloud oder Jellyfin.
- Reparaturversuch/Blocker: Proxmox-Web-API ist erreichbar (`https://192.168.178.30:8006/api2/json/version` → HTTP `401`, also Dienst lebt), aber in dieser Cron-Session stehen keine funktionierenden Proxmox-Admin-Credentials bereit: SSH mit vorhandenen PVE/Homelab-Keys wurde von `192.168.178.30` bzw. `100.79.14.36` abgewiesen/geschlossen. Ohne PVE-Konsole/API-Login kann ich VM207 hier nicht sicher starten oder neu booten.
### Offener Punkt
Bitte VM207 `kino` / `192.168.178.213` auf Proxmox prüfen/starten oder einen funktionierenden PVE-Adminzugang für Botomir bereitstellen. Danach sollte Botomir `kino-projekt.service`, lokalen `/health` und den Reverse-Tunnel erneut prüfen.
## Cron-Check 2026-07-04 09:08 CEST
- Keine neuen Dateien in `Botomir/Aufgaben/` oder `Botomir/Projekte/ToDo/` gefunden; `Kino-Projekt` bleibt das einzige laufende Projekt.
- Nextcloud selbst ist erreichbar und nicht im Wartungs-/DB-Upgrade-Zustand: `GET https://cloud.dasposchi.de/status.php` → HTTP `200`, `maintenance=false`, `needsDbUpgrade=false`, Version `34.0.0.12`.
- Der bisherige Kino-App-Host `192.168.178.213` ist weiterhin nicht erreichbar: `ping -c 2 192.168.178.213` → 100% Paketverlust; `curl http://192.168.178.213:8099/health` → Verbindungsfehler/HTTP `000`.
- Jellyfin ist weiterhin separat gesund: `GET http://192.168.178.222:8096/health` → HTTP `200`, Body `Healthy`.
- Public Edge / Mesh: öffentlicher Zugriff ohne Mesh bleibt korrekt blockiert (`GET https://kino.dasposchi.de/health` → HTTP `403`). `tailscale status` listet `public-vps` als `100.80.185.34`; `tailscale ping 100.80.185.34` → Pong über `152.53.187.222:41641` in ca. 19 ms. Der Mesh-Host erreicht aber den Kino-Upstream nicht: `curl --interface tailscale0 --resolve kino.dasposchi.de:443:100.80.185.34 https://kino.dasposchi.de/health` → HTTP `502 Bad Gateway` von openresty.
- Vaultwarden-CLI wurde entsperrt, aber `bw list` scheitert aktuell am Vaultwarden-Serverpfad: `https://vault.dasposchi.de/api/config` liefert HTTP `502`; ein direkter LAN-HTTPS-Probe auf `192.168.178.224:443` ist nicht offen. Daher konnten keine zusätzlichen PVE-/Homelab-Credentials aus Vaultwarden nachgeladen werden.
- Proxmox-Web-API lebt weiterhin unauthentifiziert (`GET https://192.168.178.30:8006/api2/json/version` → HTTP `401`), aber SSH auf PVE als `root` wird über LAN und Tailnet mit den vorhandenen Keys abgewiesen (`Permission denied (publickey)`). Ohne funktionierenden PVE-Adminzugang kann ich VM207 nicht sicher starten/neustarten.
### Offener Punkt
Unverändert: Bitte VM207 `kino` / `192.168.178.213` auf Proxmox prüfen/starten oder Botomir einen funktionierenden PVE-Adminzugang bereitstellen. Danach kann ich `kino-projekt.service`, lokalen `/health` und den Reverse-Tunnel erneut prüfen.
## Cron-Check 2026-07-05 17:28
- Keine neuen Dateien in `Botomir/Aufgaben/` gefunden; `Botomir/Projekte/ToDo/` ist leer. `Kino-Projekt` bleibt das einzige laufende Projekt.
- Der vorherige Blocker zur VM207/Kino-App ist in diesem Lauf behoben bzw. nicht mehr reproduzierbar: `192.168.178.213` antwortet wieder und die Kino-App liefert Health-Checks.
- Nextcloud ist erreichbar und nicht im Wartungs-/DB-Upgrade-Zustand.
- Öffentlicher Zugriff ohne Mesh bleibt wie gewünscht blockiert; Mesh-Zugriff über den aktuellen Public-VPS-Tailnet-Endpunkt funktioniert wieder.
- Jellyfin ist weiterhin separat gesund.
- Kein echter Medienimport gestartet, weil weiterhin keine explizite Quell-/Zielauswahl vorliegt und ein Import reale Dateien in die Jellyfin-Bibliothek schreiben würde.
### Verifikation in diesem Lauf
```text
GET https://cloud.dasposchi.de/status.php
# HTTP 200; maintenance=false; needsDbUpgrade=false; version=34.0.0.12
PROPFIND Botomir/Aufgaben/ Depth:1
# HTTP 207; enthält nur erledigt/
PROPFIND Botomir/Projekte/ToDo/ Depth:1
# HTTP 207; keine Projektordner enthalten
ping -c 2 192.168.178.213
# 2/2 Pakete empfangen, 0% Paketverlust
GET http://192.168.178.213:8099/health
# HTTP 200 {"status":"ok"}
GET http://192.168.178.213:8099/api/auth/status
# HTTP 200 {"enabled":true,"authenticated":false,"username":null}
GET http://192.168.178.213:8099/
# HTTP 200, Titel: Kino-Projekt
GET http://192.168.178.222:8096/health
# HTTP 200 Healthy
GET https://kino.dasposchi.de/health
# HTTP 403 openresty (öffentlicher Zugriff ohne Mesh blockiert)
tailscale status
# public-vps: 100.80.185.34
tailscale ping 100.80.185.34
# pong von public-vps in 36 ms
GET https://kino.dasposchi.de/health über tailscale0 mit --resolve kino.dasposchi.de:443:100.80.185.34
# HTTP 200 {"status":"ok"}
GET https://kino.dasposchi.de/api/auth/status über tailscale0 mit --resolve kino.dasposchi.de:443:100.80.185.34
# HTTP 200 {"enabled":true,"authenticated":false,"username":null}
GET https://kino.dasposchi.de/ über tailscale0 mit --resolve kino.dasposchi.de:443:100.80.185.34
# HTTP 200, WebUI erreichbar
```
## Manueller Botomir-Check 2026-07-05 22:18 CEST
- Keine neuen Dateien in `Botomir/Aufgaben/` gefunden; `Botomir/Projekte/ToDo/` ist leer. `Kino-Projekt` bleibt das einzige laufende Projekt.
- Priorisierung: keine neue Einzelaufgabe / kein neues ToDo-Projekt vorhanden; daher nur bounded Health-Recheck des laufenden Kino-Projekts statt erneuter Feature-Arbeit oder Medienimport.
- Nextcloud ist erreichbar und nicht im Wartungs-/DB-Upgrade-Zustand.
- Kino-App auf `192.168.178.213:8099` ist per LAN gesund; App-Login ist aktiv.
- Jellyfin auf `192.168.178.222:8096` ist gesund.
- Öffentlicher Zugriff ohne Mesh bleibt wie gewünscht blockiert; Mesh-Zugriff über `public-vps` (`100.80.185.34`) funktioniert.
- Kein echter Medienimport gestartet, weil weiterhin keine explizite Quell-/Zielauswahl vorliegt und ein Import reale Dateien in die Jellyfin-Bibliothek schreiben würde.
### Verifikation in diesem Lauf
```text
GET https://cloud.dasposchi.de/status.php
# HTTP 200; maintenance=false; needsDbUpgrade=false; version=34.0.0.12
PROPFIND Botomir/Aufgaben/ Depth:1
# HTTP 207; enthält nur erledigt/
PROPFIND Botomir/Projekte/ToDo/ Depth:1
# HTTP 207; keine Projektordner enthalten
ping -c 2 192.168.178.213
# 2/2 Pakete empfangen, 0% Paketverlust
GET http://192.168.178.213:8099/health
# HTTP 200 {"status":"ok"}
GET http://192.168.178.213:8099/api/auth/status
# HTTP 200 {"enabled":true,"authenticated":false,"username":null}
GET http://192.168.178.213:8099/
# HTTP 200, Titel: Kino-Projekt
GET http://192.168.178.222:8096/health
# HTTP 200 Healthy
GET https://kino.dasposchi.de/health
# HTTP 403 openresty (öffentlicher Zugriff ohne Mesh blockiert)
tailscale status
# public-vps: 100.80.185.34 active
tailscale ping 100.80.185.34
# pong von public-vps in 19 ms
GET https://kino.dasposchi.de/health über tailscale0 mit --resolve kino.dasposchi.de:443:100.80.185.34
# HTTP 200 {"status":"ok"}
GET https://kino.dasposchi.de/api/auth/status über tailscale0 mit --resolve kino.dasposchi.de:443:100.80.185.34
# HTTP 200 {"enabled":true,"authenticated":false,"username":null}
GET https://kino.dasposchi.de/ über tailscale0 mit --resolve kino.dasposchi.de:443:100.80.185.34
# HTTP 200, WebUI erreichbar
```
## Automatischer Botomir-Check 2026-07-06 02:56 CEST
- Keine neuen Dateien in `Botomir/Aufgaben/` gefunden; `Botomir/Projekte/ToDo/` ist leer. `Kino-Projekt` bleibt das einzige laufende Projekt.
- Nextcloud ist gesund: `/status.php` meldet `maintenance=false`, `needsDbUpgrade=false`, Version `34.0.0.12`.
- Lokale/LAN-Dienste sind gesund:
- `GET http://192.168.178.213:8099/health` → HTTP 200 `{"status":"ok"}`
- `GET http://192.168.178.213:8099/api/auth/status` → HTTP 200 `{"enabled":true,"authenticated":false,"username":null}`
- `GET http://192.168.178.213:8099/` → HTTP 200, WebUI-Titel `Kino-Projekt`
- `GET http://192.168.178.222:8096/health` → HTTP 200 `Healthy`
- Öffentlicher Zugriff ohne Mesh bleibt wie gewünscht blockiert:
- `GET https://kino.dasposchi.de/` → HTTP 403 von `152.53.187.222`
- `GET https://kino.dasposchi.de/health` → HTTP 403 von `152.53.187.222`
- Mesh-/VPS-Pfad ist diesmal gestört, obwohl der VPS im Tailnet erreichbar ist:
- `tailscale status` zeigt `public-vps` (`100.80.185.34`) als aktiv, direkt über `152.53.187.222:41641`
- `tailscale ping 100.80.185.34` → pong in 25 ms
- TCP `100.80.185.34:443` und `100.80.185.34:22` → geschlossen/Timeout
- `GET https://kino.dasposchi.de/health` über `tailscale0` mit `--resolve kino.dasposchi.de:443:100.80.185.34` → Timeout/HTTP 000
- `GET https://kino.dasposchi.de/api/auth/status` über `tailscale0` mit gleichem Resolve → Timeout/HTTP 000
- Bounded Admin-Probe versucht: SSH mit vorhandenen lokalen Hermes/VPS/PVE/Homelab-Keys gegen `100.80.185.34` und `152.53.187.222` als `hermes`/`root` wurde abgewiesen bzw. teils als `Connection refused`; keine Änderung am VPS vorgenommen.
### Einschätzung / Blocker
Die Kino-App und Jellyfin selbst sind **kein** Ausfall: LAN-Healthchecks sind grün. Der Fehler liegt im `public-vps`/NPM/Tailnet-Pfad: Tailnet-Ping funktioniert, aber HTTPS/TCP auf die Tailnet-Adresse des VPS erreicht keinen Listener bzw. wird blockiert. Für Reparatur brauche ich funktionierenden Admin-Zugriff auf den public-vps/NPM bzw. eine freigeschaltete Tailscale-SSH/SSH-Key-Route.
## Automatischer Botomir-Check 2026-07-06 06:59 CEST
- Keine neuen Dateien in `Botomir/Aufgaben/` gefunden; `Botomir/Projekte/ToDo/` ist leer. `Kino-Projekt` bleibt das einzige laufende Projekt.
- Nextcloud ist gesund: `/status.php` meldet `maintenance=false`, `needsDbUpgrade=false`, Version `34.0.0.12`.
- LAN-Dienste sind gesund:
- `GET http://192.168.178.213:8099/health` → HTTP 200 `{"status":"ok"}`
- `GET http://192.168.178.213:8099/api/auth/status` → HTTP 200 `{"enabled":true,"authenticated":false,"username":null}`
- `GET http://192.168.178.222:8096/health` → HTTP 200 `Healthy`
- Öffentlicher Zugriff ohne Mesh bleibt wie gewünscht blockiert:
- `GET https://kino.dasposchi.de/` → HTTP 403 von `152.53.187.222`
- `GET https://kino.dasposchi.de/health` → HTTP 403 von `152.53.187.222`
- Der im vorherigen Lauf beobachtete VPS/Tailnet-Blocker ist aktuell **nicht mehr reproduzierbar**:
- `tailscale status` zeigt `public-vps` (`100.80.185.34`) aktiv, direkt über `152.53.187.222:41641`
- `tailscale ping 100.80.185.34` → pong in 18 ms
- TCP `100.80.185.34:443` und `100.80.185.34:22` sind erreichbar
- `GET https://kino.dasposchi.de/health` über `tailscale0` mit `--resolve kino.dasposchi.de:443:100.80.185.34` → HTTP 200 `{"status":"ok"}`
### Einschätzung
Kein neuer Arbeitsauftrag und kein neues ToDo-Projekt vorhanden. Die Kino-App, Jellyfin, Nextcloud und der Mesh-Zugriff sind im bounded Recheck gesund; es wurde kein Medienimport gestartet, weil weiterhin keine explizite Quell-/Zielauswahl vorliegt.
## Automatischer Botomir-Check 2026-07-06 15:05 CEST
- Keine neuen Dateien in `Botomir/Aufgaben/` gefunden; `Botomir/Projekte/ToDo/` ist leer. `Kino-Projekt` bleibt das einzige laufende Projekt.
- Nextcloud ist gesund: `/status.php` → HTTP 200, `maintenance=false`, `needsDbUpgrade=false`, Version `34.0.0.12`.
- LAN-Dienste sind gesund:
- `GET http://192.168.178.213:8099/health` → HTTP 200 `{"status":"ok"}`
- `GET http://192.168.178.213:8099/api/auth/status` → HTTP 200 `{"enabled":true,"authenticated":false,"username":null}`
- `GET http://192.168.178.222:8096/health` → HTTP 200 `Healthy`
- Öffentlicher Zugriff ohne Mesh bleibt wie gewünscht blockiert:
- `GET https://kino.dasposchi.de/` → HTTP 403 von `152.53.187.222`
- `GET https://kino.dasposchi.de/health` → HTTP 403 von `152.53.187.222`
- Mesh-/VPS-Pfad ist wieder gestört:
- `tailscale status` zeigt `public-vps` (`100.80.185.34`) aktiv.
- `tailscale ping 100.80.185.34` → pong über `152.53.187.222:41641` in ca. 22 ms.
- TCP-Proben: `100.80.185.34:22` und `100.80.185.34:443` → Timeout/geschlossen; `152.53.187.222:22` und `:443` sind offen.
- `GET https://kino.dasposchi.de/health` über `tailscale0` mit `--resolve kino.dasposchi.de:443:100.80.185.34` → Timeout / HTTP 000.
- Bounded Admin-Probe ohne Secret-Ausgabe:
- SSH `hermes/root@100.80.185.34` → Timeout.
- SSH `hermes/root@152.53.187.222``Permission denied`.
### Einschätzung / Blocker
Die Kino-App und Jellyfin selbst sind weiterhin **gesund**; der Fehler liegt nicht auf den App-Hosts. Der Ausfall betrifft den `public-vps`/NPM/Tailnet-Pfad: Tailnet-Ping funktioniert, aber auf der Tailnet-IP des VPS sind HTTPS/SSH nicht erreichbar. Zur Reparatur brauche ich funktionierenden Admin-Zugriff auf den public-vps/NPM bzw. eine freigeschaltete Tailscale-SSH/SSH-Key-Route. Es wurde keine Änderung an Diensten oder Secrets vorgenommen.
## Automatischer Botomir-Check 2026-07-07 19:28 CEST
- Keine neuen Dateien in `Botomir/Aufgaben/` gefunden; `Botomir/Projekte/ToDo/` ist leer. `Kino-Projekt` bleibt das einzige laufende Projekt.
- Nextcloud ist gesund: `/status.php` → HTTP 200, `maintenance=false`, `needsDbUpgrade=false`, Version `34.0.0.12`.
- LAN-Dienste sind gesund:
- `GET http://192.168.178.213:8099/health` → HTTP 200 `{"status":"ok"}`
- `GET http://192.168.178.213:8099/api/auth/status` → HTTP 200 `{"enabled":true,"authenticated":false,"username":null}`
- `GET http://192.168.178.222:8096/health` → HTTP 200 `Healthy`
- Öffentlicher Zugriff ist weiterhin blockiert, aber der Status hat sich geändert: `GET https://kino.dasposchi.de/health` → HTTP 401 von `152.53.187.222` (OpenResty Basic Auth), statt der zuvor erwarteten/gesehenen 403-Access-List-Antwort.
- Tailnet/VPS-Pfad ist diesmal TCP-seitig erreichbar, liefert aber ebenfalls nicht die App-Antwort:
- `tailscale status` zeigt `public-vps` (`100.80.185.34`) aktiv.
- `tailscale ping 100.80.185.34` → pong über `152.53.187.222:41641` in ca. 32 ms.
- TCP-Proben: `100.80.185.34:22` und `100.80.185.34:443` kurzzeitig offen/erreichbar.
- `GET https://kino.dasposchi.de/health` über `tailscale0` mit `--resolve kino.dasposchi.de:443:100.80.185.34` → HTTP 401 von OpenResty Basic Auth, also kein App-JSON `{"status":"ok"}`.
- Bounded Admin-Probe ohne Secrets:
- SSH mit vorhandenen lokalen Hermes/VPS/PVE/Homelab-Keys gegen `hermes/root@100.80.185.34``Permission denied (publickey)` bzw. kurz darauf `Connection refused`.
- `tailscale ssh root/hermes@public-vps``502 Bad Gateway` / `connect: connection refused`.
### Einschätzung / Blocker
Die Kino-App und Jellyfin selbst sind weiterhin gesund. Der verbleibende Blocker liegt am `public-vps`/NPM/OpenResty-Pfad: statt Mesh-Clients direkt zur App durchzulassen, greift derzeit ein vorgelagerter Basic-Auth/Proxy-Layer und liefert HTTP 401. Zur Reparatur brauche ich funktionierenden Admin-Zugriff auf den public-vps/NPM bzw. eine freigeschaltete Tailscale-SSH/SSH-Zugangsregel. Es wurden keine Dienste oder Secrets geändert.
## Automatischer Botomir-Check 2026-07-08 23:44 CEST
- Keine neuen Dateien in `Botomir/Aufgaben/` gefunden; `Botomir/Projekte/ToDo/` ist leer. `Kino-Projekt` bleibt das einzige laufende Projekt.
- Nextcloud ist gesund: `/status.php` → HTTP 200, `maintenance=false`, `needsDbUpgrade=false`, Version `34.0.0.12`.
- LAN-Dienste sind gesund:
- `GET http://192.168.178.213:8099/health` → HTTP 200 `{"status":"ok"}`
- `GET http://192.168.178.213:8099/api/auth/status` → HTTP 200 `{"enabled":false,"authenticated":true,"username":null}`
- `GET http://192.168.178.222:8096/health` → HTTP 200 `Healthy`
- Öffentlicher Zugriff und Mesh/VPS-Pfad liefern weiterhin nicht die erwartete App-Health-Antwort:
- `GET https://kino.dasposchi.de/health` öffentlich → HTTP 401 von OpenResty Basic Auth auf `152.53.187.222`.
- `tailscale status` zeigt `public-vps` (`100.80.185.34`) aktiv; `tailscale ping 100.80.185.34` → pong über `152.53.187.222:41641` in ca. 33 ms.
- TCP-Proben auf `100.80.185.34:443` und `100.80.185.34:22` sind offen.
- `GET https://kino.dasposchi.de/health` über `tailscale0` mit `--resolve kino.dasposchi.de:443:100.80.185.34` → HTTP 401 von OpenResty Basic Auth, also kein App-JSON `{"status":"ok"}`.
- Bounded Admin-Probe ohne Secret-Ausgabe:
- SSH `hermes/root@100.80.185.34` und `hermes/root@152.53.187.222` mit vorhandenen lokalen Keys → `Permission denied (publickey)`.
- `tailscale ssh root/hermes@public-vps` scheitert aktuell an Host-Key-Verifikation (`No ED25519 host key is known for 100.80.185.34`); keine Änderung am VPS vorgenommen.
### Einschätzung / Blocker
Die Kino-App und Jellyfin selbst sind weiterhin gesund; der verbleibende Fehler liegt am `public-vps`/NPM/OpenResty-Pfad. Auch Mesh-Clients werden derzeit vor der App von Basic Auth abgefangen und erhalten HTTP 401 statt `{"status":"ok"}`. Für Reparatur brauche ich funktionierenden Admin-Zugriff auf den public-vps/NPM bzw. eine freigeschaltete Tailscale-SSH/SSH-Zugangsregel. Es wurden keine Dienste oder Secrets geändert.
## Automatischer Botomir-Check 2026-07-25 16:49 CEST
- Keine neuen Dateien in `Botomir/Aufgaben/`; `Botomir/Projekte/ToDo/` ist leer.
- Monitoring-Regel 2026-07-24 angewendet: Öffentliche `401`/NPM-Basic-Auth-Antworten für geschützte Dienste werden nicht mehr als Blocker verfolgt, sofern die internen Healthchecks grün sind.
- Interne Healthchecks vom Cron-Host:
- `GET http://192.168.178.213:8099/health` → HTTP 200 `{"status":"ok"}`
- `GET http://192.168.178.222:8096/health` → HTTP 200 `Healthy`
- Paperclip via `ssh hermes@192.168.178.34` und lokal `GET http://127.0.0.1:3100/api/health` → HTTP 200 `{"status":"ok", ...}`
- `30-Geburtstag-30-ueber-Nacht`: Die vorhandene `06-klickliste-amazon.md` bleibt gemäß User-Entscheidung die finale Lösung; keine Amazon-Login-/Vaultwarden-Suche durchgeführt.
### Ergebnis
Kein akuter Service-Blocker und keine neue Botomir-Arbeit. Es wurden keine Dienste oder Secrets geändert.

View File

@@ -0,0 +1,3 @@
# Statusentscheidung
Das Kino-Projekt ist pausiert und hat aktuell niedrige Priorität. Keine Dienste ändern.

View File

@@ -0,0 +1,632 @@
# Kino-Projekt Implementation Plan
> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task.
**Goal:** Eine interne WebUI namens „Kino-Projekt“, mit der YouTube-Videos sowie Links aus legalen Mediatheken/öffentlich erlaubten Quellen geprüft, heruntergeladen/importiert, sauber für Jellyfin abgelegt und anschließend in Jellyfin aktualisiert werden.
**Architecture:** Das System besteht aus einer Mesh-only WebUI, einem FastAPI-Backend, einer SQLite-Datenbank, einer Job-Queue für Imports und einem Provider-System. Provider sind strikt getrennt: YouTube, Mediathek-/Direktlink-Import, Internet Archive und später weitere legale Quellen. Downloads laufen nur über geprüfte Provider und definierte Zielpfade; Jellyfin wird danach per API aktualisiert.
**Tech Stack:** Python 3.12+, FastAPI, SQLite, SQLModel oder SQLAlchemy, yt-dlp, httpx, Docker Compose, Jellyfin API, Nginx Proxy Manager/Headscale-Mesh.
---
## Scope
Der Projektname bleibt **Kino-Projekt**.
Der initiale, rechtlich saubere Scope ist:
1. YouTube-Videos, Playlists und Kanäle importieren, soweit öffentlich erreichbar und nicht DRM-/Login-/Paywall-geschützt.
2. Links aus legalen Mediatheken oder erlaubten Quellen hinzufügen, z. B. öffentlich verfügbare ARD/ZDF/Arte/3sat-/Mediathek-URLs, Internet Archive, eigene Download-URLs oder andere Quellen mit klar erlaubter Nutzung.
3. Downloads in Jellyfin-kompatibler Struktur ablegen.
4. Jellyfin-Library-Refresh auslösen.
5. UI zeigt Status, Quelle, Zielpfad und Fehler transparent an.
## Non-Goals
Nicht implementieren:
- Scraping/Download von Piracy-Seiten wie Kinox/Kinoger/xstream-Hostern.
- Umgehung von DRM, Paywalls, Logins, Geoblocking, Altersprüfungen oder Zugriffsbeschränkungen.
- Cookie-Import oder Browser-Session-Nutzung als Standardfunktion.
- Beliebige Shell-Parameter aus der UI.
- Freie Schreibpfade außerhalb der konfigurierten Medienordner.
---
## Ziel-UX
Die WebUI soll sich wie ein internes „Kino-Dashboard“ anfühlen:
- URL oder Suchbegriff eingeben.
- Quelle erkennen: YouTube, Mediathek, Direktlink, Internet Archive.
- Metadaten anzeigen: Titel, Kanal/Sender, Dauer, Beschreibung, Thumbnail, Quelle.
- Qualität/Zielbibliothek auswählen.
- Import starten.
- Fortschritt beobachten.
- Nach Abschluss Link zu Jellyfin bzw. Zielpfad anzeigen.
Beispiel-Ziel-URL:
```text
https://kino.dasposchi.de
```
Die WebUI wird nur über das Headscale-Mesh erreichbar gemacht.
---
## Datenmodell
### Tabelle: `sources`
```python
class Source(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True)
kind: str # youtube, mediathek, direct_url, internet_archive
url: str
title: str | None = None
provider: str | None = None
external_id: str | None = None
metadata_json: str = "{}"
created_at: datetime
```
### Tabelle: `import_jobs`
```python
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, probing, downloading, postprocessing, refreshing, done, failed
target_library: str
target_path: str | None = None
progress: float = 0.0
error: str | None = None
created_at: datetime
updated_at: datetime
```
---
## Provider-Konzept
Alle Quellen implementieren ein gemeinsames Interface.
```python
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) -> DownloadResult: ...
```
### Provider 1: YouTube
- Erkennung über `youtube.com`, `youtu.be`, Playlist-/Channel-URLs.
- Metadaten via `yt-dlp --dump-json`.
- Download via `yt-dlp`, aber ohne Cookies und ohne Umgehungsoptionen.
- Qualitätsprofil: maximal 1080p als Default.
### Provider 2: Mediathek/Direktlink
- Für legale Mediathek-Links und direkte Mediendateien.
- Keine feste Domain-Allowlist, weil diese zu aufwändig zu pflegen wäre.
- Stattdessen konservative Sicherheitsprüfung pro URL:
- nur `http`/`https`
- DNS-Auflösung und Redirect-Kette prüfen
- keine privaten, loopback-, link-local- oder sonstigen internen Ziel-IP-Ranges
- Content-Type muss Video/Audio oder ein bekannter öffentlicher Stream-Typ sein
- Dateiendung/Container plausibilisieren
- Maximalgröße, Timeout und Rate-Limits erzwingen
- keine Cookies, keine Login-Flows, keine Umgehungsflags
- Wenn `yt-dlp` eine Mediathek-URL direkt unterstützt, darf derselbe sichere yt-dlp Wrapper genutzt werden.
- Keine DRM-/Paywall-/Login-/Geoblocking-Umgehung.
### Provider 3: Internet Archive
- API-/URL-basierter Import öffentlich verfügbarer Inhalte.
- Später als eigener Provider, wenn MVP stabil ist.
---
## Konfiguration
Datei: `.env`
```env
KINOPROJEKT_DB=/data/kino.sqlite3
KINOPROJEKT_MEDIA_ROOT=/jellyfin
KINOPROJEKT_TMP=/data/tmp
JELLYFIN_URL=http://jellyfin:8096
JELLYFIN_API_KEY=change-me
DEFAULT_MAX_HEIGHT=1080
MAX_DOWNLOAD_BYTES=15000000000
```
Secrets wie `JELLYFIN_API_KEY` kommen nicht nach Nextcloud, sondern in Vaultwarden oder eine geschützte `.env` auf dem Zielhost.
---
## Ziel-Dateistruktur
```text
kino-projekt/
backend/
app/
main.py
config.py
db.py
models.py
schemas.py
providers/
base.py
youtube.py
mediathek.py
internet_archive.py
services/
downloader.py
jellyfin.py
paths.py
metadata.py
web/
index.html
app.js
style.css
tests/
test_provider_detection.py
test_path_safety.py
test_youtube_probe.py
test_mediathek_probe.py
pyproject.toml
docker-compose.yml
README.md
docs/
provider-policy.md
deployment.md
```
---
# Tasks
### Task 1: Projektgerüst anlegen
**Objective:** Repository-Struktur, Python-Projekt und minimale FastAPI-App erstellen.
**Files:**
- Create: `backend/pyproject.toml`
- Create: `backend/app/main.py`
- Create: `backend/app/config.py`
- Create: `backend/tests/test_health.py`
**Steps:**
1. FastAPI-Abhängigkeiten eintragen.
2. `/health` Endpoint erstellen.
3. Test für `/health` schreiben.
4. `pytest` ausführen.
**Verification:**
```bash
cd backend
pytest -q
uvicorn app.main:app --host 0.0.0.0 --port 8080
curl http://localhost:8080/health
```
Expected:
```json
{"status":"ok"}
```
---
### Task 2: Konfiguration und Pfadsicherheit
**Objective:** Medien-/Temp-Pfade sicher konfigurieren und Path Traversal verhindern.
**Files:**
- Create: `backend/app/services/paths.py`
- Modify: `backend/app/config.py`
- Create: `backend/tests/test_path_safety.py`
**Rules:**
- Alle Downloads nur unter `KINOPROJEKT_TMP`.
- Alle finalen Dateien nur unter `KINOPROJEKT_MEDIA_ROOT`.
- Keine `../`-Escape-Möglichkeiten.
- Titel/Kanalnamen werden für Dateisysteme normalisiert.
**Verification:**
```bash
pytest backend/tests/test_path_safety.py -q
```
---
### Task 3: Datenbankmodelle erstellen
**Objective:** SQLite-Modelle für Quellen und Import-Jobs anlegen.
**Files:**
- Create: `backend/app/db.py`
- Create: `backend/app/models.py`
- Create: `backend/tests/test_models.py`
**Verification:**
```bash
pytest backend/tests/test_models.py -q
```
---
### Task 4: Provider-Interface definieren
**Objective:** Gemeinsame Provider-Abstraktion für YouTube, Mediathek und zukünftige Quellen schaffen.
**Files:**
- Create: `backend/app/providers/base.py`
- Create: `backend/tests/test_provider_detection.py`
**Implementation Notes:**
- `MediaMetadata` enthält Titel, Beschreibung, Thumbnail, Dauer, Provider, external_id.
- `DownloadOptions` enthält Qualität, Zielbibliothek und Audio-only-Flag.
- `DownloadResult` enthält Ausgabedateien und Metadatenpfade.
---
### Task 5: YouTube Provider Probe
**Objective:** YouTube-URLs erkennen und Metadaten über `yt-dlp --dump-json` abrufen.
**Files:**
- Create: `backend/app/providers/youtube.py`
- Create: `backend/tests/test_youtube_provider.py`
**Security Constraints:**
- Kein Cookie-Import.
- Keine Login-/DRM-/Paywall-Umgehung.
- Subprocess-Aufruf mit fester Argumentliste, keine Shell.
**Verification:**
```bash
python -m app.providers.youtube 'https://www.youtube.com/watch?v=PUBLIC_TEST_ID'
```
Expected: JSON-Metadaten oder sauberer Fehler.
---
### Task 6: Mediathek-/Direktlink Provider Probe
**Objective:** Legale Mediathek- und Direktlinks ohne feste Domain-Allowlist prüfen und Metadaten grob erfassen.
**Files:**
- Create: `backend/app/providers/mediathek.py`
- Create: `backend/tests/test_mediathek_provider.py`
**Rules:**
- Keine feste Domain-Allowlist, weil diese zu aufwändig zu pflegen wäre.
- Stattdessen Sicherheitsprüfung pro URL:
- nur `http`/`https`
- keine privaten/loopback/link-local IP-Ziele nach DNS-Auflösung
- Redirect-Kette prüfen
- Content-Type/Dateiendung plausibilisieren
- Maximalgröße/Timeout/Rate-Limits erzwingen
- keine Cookies, keine Login-Flows, keine Umgehungsflags
- Direkte Medien-URLs müssen erlaubte Content-Types liefern.
- yt-dlp darf als Extractor genutzt werden, wenn Quelle öffentlich und ohne Umgehung erreichbar ist.
---
### Task 7: Probe API erstellen
**Objective:** UI kann eine URL einreichen und Metadaten anzeigen.
**Files:**
- Modify: `backend/app/main.py`
- Create: `backend/app/schemas.py`
- Create: `backend/tests/test_probe_api.py`
**Endpoint:**
```http
POST /api/probe
Content-Type: application/json
{"url":"https://www.youtube.com/watch?v=..."}
```
**Response:**
```json
{
"provider": "youtube",
"title": "...",
"thumbnail": "...",
"duration_seconds": 123,
"allowed": true
}
```
---
### Task 8: Minimal-WebUI bauen
**Objective:** Eine einfache interne WebUI zum Einfügen und Prüfen von Links erstellen.
**Files:**
- Create: `backend/app/web/index.html`
- Create: `backend/app/web/style.css`
- Create: `backend/app/web/app.js`
- Modify: `backend/app/main.py`
**UI:**
- Titel: „Kino-Projekt“
- URL-Eingabe
- Button: „Quelle prüfen“
- Metadatenkarte
- Button: „Import starten“ zunächst disabled, bis Task 10.
---
### Task 9: Download Wrapper bauen
**Objective:** Sicheren Download-Service mit yt-dlp und festen Optionen implementieren.
**Files:**
- Create: `backend/app/services/downloader.py`
- Create: `backend/tests/test_downloader_args.py`
**YouTube Default Command:**
```bash
yt-dlp \
--no-playlist \
--write-thumbnail \
--write-info-json \
--merge-output-format mkv \
-f "bv*[height<=1080]+ba/b[height<=1080]/b" \
-o "<safe-target-template>" \
"<url>"
```
**Important:** In Python `subprocess.run([...], shell=False)` nutzen.
---
### Task 10: Import-Job API
**Objective:** Import-Jobs anlegen und ausführen.
**Files:**
- Modify: `backend/app/main.py`
- Create: `backend/app/services/jobs.py`
- Create: `backend/tests/test_jobs.py`
**Endpoints:**
```http
POST /api/imports
GET /api/imports/{job_id}
GET /api/imports
```
MVP darf Jobs synchron oder mit einfachem BackgroundTask ausführen. Später kann Redis/RQ ergänzt werden.
---
### Task 11: Jellyfin Refresh Service
**Objective:** Nach erfolgreichem Import Jellyfin-Library aktualisieren.
**Files:**
- Create: `backend/app/services/jellyfin.py`
- Create: `backend/tests/test_jellyfin.py`
**Endpoint intern:**
```http
POST {JELLYFIN_URL}/Library/Refresh
X-Emby-Token: {JELLYFIN_API_KEY}
```
**Verification:**
- Mit Test-API-Key gegen Jellyfin prüfen.
- Danach in Jellyfin sichtbar machen.
---
### Task 12: Docker Compose
**Objective:** Dienst als Container betreiben.
**Files:**
- Create: `Dockerfile`
- Create: `docker-compose.yml`
- Create: `.env.example`
**Services:**
```yaml
services:
kino-projekt:
build: .
ports:
- "127.0.0.1:8099:8080"
volumes:
- ./data:/data
- /jellyfin:/jellyfin
env_file:
- .env
```
### Task 13: App-Login einbauen
**Objective:** Zusätzlich zu Mesh-only einen einfachen App-Login mit sicherem Passwort-Hash und Session-Cookie einbauen.
**Files:**
- Create: `backend/app/services/auth.py`
- Modify: `backend/app/models.py`
- Modify: `backend/app/main.py`
- Modify: `backend/app/web/app.js`
- Create: `backend/tests/test_auth.py`
**Rules:**
- Passwort nie im Klartext speichern.
- Hashing mit `argon2-cffi` oder `passlib[bcrypt]`.
- Session-Cookie `HttpOnly`, `Secure`, `SameSite=Lax`.
- Initialer Admin-User wird über `.env`/Setup-Befehl erstellt, nicht im Repo.
**Verification:**
```bash
pytest backend/tests/test_auth.py -q
curl -kI https://kino.dasposchi.de/ # ohne Session: Redirect/Login oder 401
```
---
### Task 14: Mesh-only Reverse Proxy
**Objective:** WebUI nur im Headscale-Netz verfügbar machen.
**NPM Config:**
- Domain: `kino.dasposchi.de`
- Forward: Zielhost Port `8099`
- Access List: `headscale-mesh-only`
- SSL: Lets Encrypt
**Verification:**
```bash
curl -4 -kI https://kino.dasposchi.de/ # public: 403
curl --interface tailscale0 -kI https://kino.dasposchi.de/ # mesh: 200
```
---
### Task 15: Dokumentation
**Objective:** Nutzung und Grenzen dokumentieren.
**Files:**
- Create: `README.md`
- Create: `docs/provider-policy.md`
- Create: `docs/deployment.md`
**README enthält:**
- Projektname bleibt Kino-Projekt.
- Unterstützte Quellen: YouTube, legale Mediatheken, Direktlinks, Internet Archive später.
- Keine feste Mediathek-Domain-Allowlist; stattdessen URL-/Redirect-/IP-/Content-Type-/Größen-/Timeout-Sicherheitschecks.
- Nicht unterstützte Quellen: Piracy-/Hoster-Scraper, DRM, Paywalls, Login-geschützte Inhalte.
- Jellyfin-Konfiguration mit Medienwurzel `/jellyfin`.
- App-Login plus Mesh-only Deployment.
---
## Getroffene Entscheidungen
1. **Host:** Neue kleine Debian-VM oder Debian-LXC/CT auf Proxmox. Empfehlung: LXC/CT, wenn Jellyfin-Medienpfade sauber per Mount/Berechtigung eingebunden werden können; sonst VM.
2. **Zielbibliothek/Zielordner:** Inhalte werden unter `/jellyfin` in passende Jellyfin-Ordner geladen. Die WebUI bietet dafür ein Zielprofil an, z. B. Film, Serie, YouTube/Mediathek. „Passender Ordner“ wird technisch so konkretisiert:
- Filme: `/jellyfin/Filme/<Titel> (<Jahr>)/...`
- Serien: `/jellyfin/Serien/<Serientitel>/Staffel<Nummer>/...` (bestehende Struktur nutzt z. B. `Staffel1`, `Staffel2`, nicht `Season 01`)
- YouTube/Mediathek-Clips ohne Serienstruktur: `/jellyfin/YouTube-Mediathek/<Kanal oder Sender>/<Titel> [<id>].mkv`
- `/jellyfin/YouTube-Mediathek` existiert aktuell noch nicht und soll beim Deployment mit passenden Rechten angelegt werden.
3. **Medienwurzel:** `/jellyfin`.
4. **Login:** Zusätzlich zu Mesh-only wird ein Login eingebaut. MVP: lokaler App-Login mit Passwort-Hash und Session-Cookie; optional später OIDC/Authelia.
5. **Mediathek-Allowlist:** Keine feste Allowlist. Stattdessen URL-Sicherheitsprüfungen, Content-Type-Prüfung, Redirect-Prüfung, Size-/Timeout-Limits und Verbot privater Ziel-IP-Ranges.
Empfohlener MVP:
- Host: neue kleine Debian-VM oder Debian-LXC/CT auf Proxmox.
- Domain: `kino.dasposchi.de`.
- Medienwurzel: `/jellyfin`.
- Bibliothek/Zielprofil: automatisch auswählbare Zielprofile für Film, Serie und YouTube/Mediathek.
- Provider zuerst: YouTube + generischer Mediathek-/Direktlink-Provider ohne Domain-Allowlist, aber mit strikten Sicherheitschecks.
- Zugriff: Headscale-Mesh-only plus App-Login.
---
## Konkretisierung: „passender Jellyfin-Ordner“
Die WebUI soll nicht nach einer abstrakten Jellyfin-Bibliothek fragen, sondern nach einem **Zielprofil**. Das Zielprofil bestimmt den Ordner unter `/jellyfin` und die Dateibenennung.
MVP-Zielprofile:
1. **Film**
- Ziel: `/jellyfin/Filme/<Titel> (<Jahr>)/<Titel> (<Jahr>).mkv`
- Nutzer kann Jahr optional manuell korrigieren.
2. **Serie/Episode**
- Ziel: `/jellyfin/Serien/<Serientitel>/Staffel<SS>/<Serientitel> - S<SS>E<EE> - <Episodentitel>.mkv`
- Bestehende Ordner nutzen `Staffel1`, `Staffel2`, `Staffel3` ohne Leerzeichen/führende Null.
- Staffel/Episode können manuell gesetzt werden, falls Metadaten fehlen.
3. **YouTube/Mediathek-Clip**
- Ziel: `/jellyfin/YouTube-Mediathek/<Kanal oder Sender>/<Titel> [<id>].mkv`
- Default für einzelne YouTube-/Mediathek-Videos.
Die vorhandene Jellyfin-Ordnerstruktur muss vor der Umsetzung einmal geprüft werden; falls die realen Ordnernamen anders sind, werden die Zielprofile entsprechend angepasst.
---
## Verifizierte Jellyfin-Struktur
Geprüft auf Proxmox CT `100` (`jellyfin`, IP `192.168.178.222`). Jellyfin ist aktiv und lauscht auf `8096`.
Jellyfin-Bibliotheken laut Konfiguration:
- Filme: `/jellyfin/Filme`
- Serien: `/jellyfin/Serien`
Rechte/Owner:
- `/jellyfin`: `jellyfinuser:media`, Modus `775`
- `/jellyfin/Filme`: `jellyfinuser:media`, setgid-Gruppe `media`
- `/jellyfin/Serien`: `jellyfinuser:media`, setgid-Gruppe `media`
- Jellyfin-Prozessuser `jellyfin` ist Mitglied der Gruppe `media`
Beispiele aus der vorhandenen Struktur:
```text
/jellyfin/Filme/Der Super Mario Bros. Film (2023)/Der Super Mario Bros. Film (2023).mp4
/jellyfin/Serien/The White Lotus/Staffel1/The-White-Lotus-S01E02-German-720p-WEB-h264-WvF.mp4
```
Für das Kino-Projekt bedeutet das:
- Die Annahme `/jellyfin` als Medienwurzel ist korrekt.
- Filme und Serien sollen direkt in die vorhandenen Ordner `/jellyfin/Filme` und `/jellyfin/Serien` geschrieben werden.
- Serien-Staffelordner sollen zur vorhandenen Struktur passend `Staffel1`, `Staffel2`, ... heißen.
- Für YouTube-/Mediathek-Einzelclips soll neu `/jellyfin/YouTube-Mediathek` angelegt werden, idealerweise ebenfalls `jellyfinuser:media` und setgid.
---
## Akzeptanzkriterien MVP
- `https://kino.dasposchi.de` ist öffentlich blockiert und im Mesh erreichbar.
- Zusätzlich ist ein App-Login aktiv.
- YouTube-URL kann geprüft werden.
- Mediathek-/Direktlink kann ohne feste Domain-Allowlist geprüft werden, sofern URL-, Redirect-, IP-, Content-Type-, Größen- und Timeout-Sicherheitschecks bestehen.
- Import-Job lädt Datei unter `/jellyfin` in das gewählte Zielprofil bzw. den passenden Jellyfin-Ordner.
- Jellyfin Refresh wird ausgelöst.
- Status und Fehler sind in der UI sichtbar.
- Passwort-Hashes/Secrets liegen nicht in Nextcloud oder im Repository.
- Keine nicht erlaubten Quellen/Umgehungsmechanismen sind implementiert.

View File

@@ -0,0 +1,7 @@
Folgendes soll umgesetzt werden;:
Eine WebUI mit der man in der Lage ist, Serien und Filme zu suchen.
Durchsucht werden dabei gängige Hosting-Seiten, wie kinox.to, kinoger.com und co - orientieren an das kodi-plugin xstream.
Zusätzlich soll ein Download stattfinden und die Datei auf den jellyfin-server übertragen und die bibliothek dort refresht werden.
Die Repository zu xstream habe ich zur analyse ebenfalls hochgeladen.

View File

@@ -0,0 +1,29 @@
# Nextcloud Import (2026-08-17)
This directory preserves material migrated from the Nextcloud project folder
`Botomir/Projekte/Angefangen/Kino-Projekt/` into git, per user request to move
git-compatible Nextcloud projects into Gitea.
Nextcloud remains the authoritative source for narrative status docs; this
copy is a point-in-time snapshot for version history and searchability.
## Contents
- `Botomir-Status.md`, `Botomir-Statusentscheidung-*.md`, `Plan-Botomir.md`,
`Projektbeschreibung.md` — Botomir/Nextcloud project narrative and status
docs (not authoritative — Nextcloud is canonical for these).
- `docs/` — deployment and provider-policy docs from the Nextcloud copy.
- `backend-nextcloud-variant/` — an older backend implementation
(`app/` layout with providers for internet_archive/mediathek/youtube) that
predates the current `backend/src/` layout in the repo root. Kept for
reference; **not wired into `docker-compose.yml`** — the root `backend/`
is the actively maintained/deployed version.
- `plugin.video.xstream/`, `script.module.xstreamscraper/` — third-party
Kodi addon source (reference/vendored material used during early research,
not part of DasPoschi's own web/API implementation).
## Not migrated
A Firefox extension folder (`{b9db16a4-...}` — "Video DownloadHelper" by a
third party) was intentionally left in Nextcloud; it is unrelated vendor
browser-extension code, not project source.

View File

@@ -0,0 +1,49 @@
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")
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")
@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})
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,149 @@
from contextlib import asynccontextmanager
from fastapi import BackgroundTasks, 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 ImportJobResponse, ImportRequest, ProbeRequest, ProbeResponse, SourceResponse
from app.services.downloader import metadata_json, run_import_job
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)
yield
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)
@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.app.state.settings)
@app.post("/api/auth/logout", status_code=204)
def logout():
return clear_session_cookie()
@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:
metadata = await provider.probe(url)
except ProviderError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return ProbeResponse(**metadata.model_dump(), allowed=True)
@app.post("/api/imports", response_model=ImportJobResponse, status_code=202)
async def create_import(
request: ImportRequest,
background_tasks: BackgroundTasks,
_: object = Depends(require_user),
session: Session = Depends(get_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:
metadata = await provider.probe(url)
except ProviderError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
source = Source(
kind=provider.name,
provider=provider.name,
url=url,
title=request.title or metadata.title,
external_id=metadata.external_id,
metadata_json=metadata_json(
metadata,
import_request={
"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)
background_tasks.add_task(run_import_job, job.id, engine, providers, get_settings())
return job
@app.get("/api/imports", response_model=list[ImportJobResponse])
def list_imports(_: object = Depends(require_user), session: Session = Depends(get_session)) -> list[ImportJob]:
return list(session.exec(select(ImportJob).order_by(ImportJob.created_at.desc())).all())
@app.get("/api/imports/{job_id}", response_model=ImportJobResponse)
def get_import(job_id: int, _: object = Depends(require_user), session: Session = Depends(get_session)) -> ImportJob:
job = session.get(ImportJob, job_id)
if job is None:
raise HTTPException(status_code=404, detail="Import job not found")
return job
@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")

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

View File

@@ -0,0 +1,136 @@
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
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",
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",
"--format",
height_expr,
"--output",
output_template,
url,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
captured: list[str] = []
async def consume(stream: asyncio.StreamReader | None) -> None:
if stream is None:
return
while True:
line = await stream.readline()
if not line:
break
text = line.decode(errors="replace").strip()
if not text:
continue
captured.append(text)
if len(captured) > 80:
del captured[:40]
progress = parse_yt_dlp_progress(text)
if progress is not None and progress_callback is not None:
progress_callback(progress)
await asyncio.wait_for(asyncio.gather(consume(proc.stdout), consume(proc.stderr), proc.wait()), timeout=60 * 60)
if proc.returncode != 0:
detail = "\n".join(captured).strip() or "yt-dlp 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,53 @@
from datetime import datetime
from pydantic import AnyHttpUrl, BaseModel, Field
class ProbeRequest(BaseModel):
url: AnyHttpUrl
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
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,112 @@
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 login_response(payload: LoginPayload, 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=True,
samesite="lax",
max_age=SESSION_TTL_SECONDS,
)
return response
def clear_session_cookie() -> Response:
response = Response(status_code=status.HTTP_204_NO_CONTENT)
response.delete_cookie(SESSION_COOKIE, httponly=True, secure=True, samesite="lax")
return response

View File

@@ -0,0 +1,111 @@
import asyncio
import json
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
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():
target = dest / path.name
if path.is_dir():
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
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:
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:
last_reported_progress = 0.0
def report_download_progress(provider_progress: float) -> None:
nonlocal last_reported_progress
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)
title = source.title or source.external_id or "media"
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,
)
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,
)
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")
update_job(session, job, target_path=str(target_dir), status="refreshing", progress=0.9)
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 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,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,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,132 @@
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');
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);
const lines = [`Job #${job.id}: ${job.status} (${percent}%)`];
if (job.target_path) lines.push(`Ziel: ${job.target_path}`);
if (job.error) lines.push(`Hinweis/Fehler: ${job.error}`);
return lines.join('\n');
}
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;
}
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 () => {
const jobResponse = await fetch('/api/imports', {method: 'POST', headers: {'content-type': 'application/json'}, body: JSON.stringify(currentImportPayload(url))});
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 pollJob(job.id);
});
} catch (err) {
result.innerHTML = `<p class="error">${escapeHtml(err.message)}</p>`;
}
});
refreshAuthStatus().catch(() => setLoginVisible(false));

View File

@@ -0,0 +1,49 @@
<!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" />
</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>
</form>
<section id="result" class="card hidden"></section>
<button id="import" disabled>Import starten</button>
</main>
<script src="/static/app.js"></script>
</body>
</html>

View File

@@ -0,0 +1,14 @@
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; }

View File

@@ -0,0 +1,21 @@
[project]
name = "kino-projekt-backend"
version = "0.1.0"
description = "Legal-source media import dashboard for Jellyfin"
requires-python = ">=3.12"
dependencies = [
"fastapi>=0.115",
"uvicorn[standard]>=0.30",
"pydantic-settings>=2.4",
"sqlmodel>=0.0.22",
"httpx>=0.27",
"argon2-cffi>=23.1",
]
[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,57 @@
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_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,25 @@
from pathlib import Path
import pytest
from app.providers.youtube import parse_yt_dlp_progress
from app.services.downloader import copy_tree_contents
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_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

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,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,53 @@
import pytest
from app.providers.base import DownloadOptions, ProviderError
from app.providers.mediathek import MediathekProvider
def test_hls_and_dash_manifests_are_detected_by_extension_and_content_type():
provider = MediathekProvider()
assert provider._is_manifest("https://example.test/master.m3u8")
assert provider._is_manifest("https://example.test/manifest.mpd")
assert provider._is_manifest("https://example.test/stream", "application/vnd.apple.mpegurl")
assert provider._is_manifest("https://example.test/stream", "application/dash+xml")
assert not provider._is_manifest("https://example.test/movie.mp4", "video/mp4")
@pytest.mark.asyncio
async def test_manifest_download_path_requires_ytdlp_and_never_raw_copies(monkeypatch, tmp_path):
provider = MediathekProvider()
async def fake_probe(url: str):
from app.providers.base import MediaMetadata
return MediaMetadata(provider="mediathek", title="manifest", external_id="https://example.test/master.m3u8")
async def fail_if_called(*args, **kwargs):
raise AssertionError("manifest downloads must not use raw HTTP streaming")
monkeypatch.setattr(provider, "probe", fake_probe)
monkeypatch.setattr(provider, "_download_manifest_with_ytdlp", fail_if_called)
with pytest.raises(AssertionError, match="manifest downloads must not use raw HTTP streaming"):
await provider.download("https://example.test/master.m3u8", tmp_path, DownloadOptions())
@pytest.mark.asyncio
async def test_manifest_ytdlp_result_rejects_missing_media(monkeypatch, tmp_path):
provider = MediathekProvider()
monkeypatch.setattr("app.providers.mediathek.shutil.which", lambda binary: f"/usr/bin/{binary}")
class FakeProcess:
returncode = 0
stdout = None
stderr = None
async def wait(self):
return 0
async def fake_exec(*args, **kwargs):
return FakeProcess()
monkeypatch.setattr("app.providers.mediathek.asyncio.create_subprocess_exec", fake_exec)
with pytest.raises(ProviderError, match="without producing a media file"):
await provider._download_manifest_with_ytdlp("https://example.test/master.m3u8", tmp_path, DownloadOptions())

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,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,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,76 @@
Scaper Aufbau
-------------
# Imports bitte alle oben für den Überblick !
from resources.lib.handler.ParameterHandler import ParameterHandler
from resources.lib.handler.requestHandler import cRequestHandler
from resources.lib.tools import logger, cParser, cUtil
from resources.lib.gui.guiElement import cGuiElement
from resources.lib.config import cConfig
from resources.lib.gui.gui import cGui
----------------------------------------------------------------------------
# Namen für Quellen
SITE_IDENTIFIER = ''
SITE_NAME = ''
SITE_ICON = ''
URL_MAIN = ''
URL_MOVIES = ''
URL_POPULAR_MOVIES = URL_MAIN + ''
URL_KINO = URL_MAIN + ''
URL_SERIES = URL_MAIN + ''
URL_POPULAR_SERIES = URL_MAIN + ''
URL_POPULAR = URL_MAIN + ''
URL_GENRE = URL_MAIN + ''
URL_SEARCH = URL_MAIN + ''
URL_LOGIN = URL_MAIN + ''
URL_ENGLISH = URL_MAIN + ''
URL_NEW = URL_MAIN + ''
URL_ANIMATION = URL_MAIN + ''
URL_DOKU = URL_MAIN + ''
----------------------------------------------------------------------------
# Einbau der Sprachfunktion in Indexseiten
- sLanguage = cConfig().getSetting('prefLanguage') # Sprachauswahl aus settings.xml daraus eine Standard def schreiben
params.setParam('sLanguage', sLanguage)
0 = Alle Sprachen
1 = Deutsch
2 = English
3 = Japanisch
# z.B. beim Menü
def load():
logger.info('Load %s' % SITE_NAME)
params = ParameterHandler()
# Spracheinstellung aus der settings.xml
sLanguage = cConfig().getSetting('prefLanguage')
if sLanguage == '1': # 1 = Deutsch
sLanguage = '2'
elif sLanguage == '2': # 2 = Englisch
sLanguage = '3'
else:
sLanguage = 'all' # 0 = Alle Sprachen
params.setParam('sLanguage', sLanguage)
params.setParam('sUrl', URL_KINO)
cGui().addFolder(cGuiElement('Aktuelle Kinofilme', SITE_IDENTIFIER, 'showEntries'), params) # cConfig().getLocalizedString(30500) setzen für den jeweiligen Eintrag
cGui().addFolder(cGuiElement(cConfig().getLocalizedString(30500), SITE_IDENTIFIER, 'showEntries'), params)
----------------------------------------------------------------------------
# Einbau der Qualitätfunktion in Indexseiten
- sQuality = cConfig().getSetting('preferedQuality') # Qualitätsauswahl aus settings.xml daraus eine Standard def schreiben
params.setParam('sQuality', sQuality)
----------------------------------------------------------------------------
# Einbau der P18 funktion in Indexseiten
- sAdult = cConfig().getSetting('showAdult') # Adult An / Aus aus settings.xml daraus eine Standard def schreiben wie bei SITE_GLOBAL_SEARCH z.B.
params.setParam('sAdult', sAdult)

View File

@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<addon id="plugin.video.xstream" name="xStream" version="2026.02.11" provider-name="Michaz">
<requires>
<import addon="xbmc.python" version="3.0.0"/>
<import addon="script.module.requests"/>
<import addon="script.module.resolveurl" version="5.1.173"/>
<import addon="script.module.six" version="1.11.0"/>
<import addon="script.module.pyaes"/>
<import addon="repository.resolveurl" optional="true"/>
<import addon="script.module.inputstreamhelper" version="0.3.3" optional="true"/>
<import addon="script.module.pydevd" optional="true" />
<import addon="script.module.web-pdb" optional="true" />
</requires>
<extension point="xbmc.python.pluginsource" library="default.py">
<provides>video</provides>
</extension>
<extension point="xbmc.service" library="service.py" start="startup" />
<extension point="xbmc.addon.metadata">
<summary lang="de">xStream</summary>
<summary lang="en_GB">xStream</summary>
<description lang="de">xStream ist ein Video-Addon für Kodi. xStream dient als Suchmaschine für Filme und Serien auf verschiedenen Webseiten.</description>
<description lang="en_GB">xStream is a video add-on for Kodi. xStream serves as a search engine for films and series on various websites.</description>
<disclaimer lang="de">Sie können dieses Video Addon gerne verwenden. Der bereitgestellte Inhalt der Webseiten steht in keinem Bezug zu xStream oder den Entwicklern! Weitere Informationen finden Sie in unserer Wiki und FaQ.</disclaimer>
<disclaimer lang="en_GB">You are welcome to use this video addon. The content provided on the websites has no connection to xStream or the developers! For more information, see our Wiki and FaQ.</disclaimer>
<language>de en</language>
<platform>all</platform>
<license>GNU GENERAL PUBLIC LICENSE Version 3, 29 June 2007</license>
<assets>
<icon>resources/icon.png</icon>
<fanart>resources/fanart.jpg</fanart>
<banner>resources/banner.png</banner>
<clearlogo>resources/clearlogo.png</clearlogo>
</assets>
</extension>
</addon>

View File

@@ -0,0 +1,40 @@
# -*- coding: utf-8 -*-
# Python 3
def main():
from xstream import parseUrl
from os.path import join
from sys import path
import platform
from resources.lib.config import cConfig
from xbmc import LOGINFO as LOGNOTICE, log
from xbmcvfs import translatePath
_addonPath_ = translatePath(cConfig().getAddonInfo('path'))
path.append(join(_addonPath_, 'resources', 'lib'))
path.append(join(_addonPath_, 'resources', 'lib', 'gui'))
path.append(join(_addonPath_, 'resources', 'lib', 'handler'))
path.append(join(_addonPath_, 'resources', 'art', 'sites'))
path.append(join(_addonPath_, 'resources', 'art'))
path.append(join(_addonPath_, 'sites'))
LOGMESSAGE = cConfig().getLocalizedString(30166)
log('-----------------------------------------------------------------------', LOGNOTICE)
log(LOGMESSAGE + ' -> [default]: Start xStream Log, Version %s ' % cConfig().getAddonInfo('version'), LOGNOTICE)
log(LOGMESSAGE + ' -> [default]: Python-Version: %s' % platform.python_version(), LOGNOTICE)
try:
parseUrl()
except Exception as e:
if str(e) == 'UserAborted':
log(LOGMESSAGE + ' -> [default]: User aborted list creation', LOGNOTICE)
else:
import traceback
import xbmcgui
log(traceback.format_exc(), LOGNOTICE)
value = (str(e.__class__.__name__) + ' : ' + str(e), str(traceback.format_exc().splitlines()[-3].split('addons')[-1]))
dialog = xbmcgui.Dialog().ok(cConfig().getLocalizedString(257), str(value)) # Error
if __name__ == "__main__":
main()

View File

@@ -0,0 +1 @@

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

View File

@@ -0,0 +1,81 @@
# -*- coding: utf-8 -*-
# Python 3
#
#05.07.2025 Heptamer
#
# Captcha Helper
from resources.lib.tools import logger
from resources.lib.config import cConfig
from resources.lib.captcha.captcha_solver import CaptchaSolver
def solve_recaptcha(site_key, page_url, provider=None):
"""
Hilfsfunktion zum Lösen von reCAPTCHAs
Args:
site_key (str): Der Google reCAPTCHA Site-Key
page_url (str): Die URL der Seite mit dem Captcha
provider (str): Optional, überschreibt den konfigurierten Provider
Returns:
str: Der Captcha-Lösungstoken
"""
# Bestimme den Provider aus den Einstellungen oder dem Parameter
if provider is None:
provider = cConfig().getSetting('captcha.provider', '2captcha')
# API-Key abhängig vom Provider holen
if provider == '2captcha':
api_key = cConfig().getSetting('2captcha.pass')
if not api_key:
logger.error("2Captcha API-Schlüssel ist nicht gesetzt")
return None
elif provider == '9kw':
api_key = cConfig().getSetting('9kw.pass')
if not api_key:
logger.error("9kw.eu API-Schlüssel ist nicht gesetzt")
return None
###############################################################
else:
logger.error(f"Unbekannter Captcha-Provider '{provider}'")
return None
# Timeout aus Einstellungen holen
timeout = int(cConfig().getSetting('captcha.timeout', '120'))
# Solver erstellen und Captcha lösen
solver = CaptchaSolver(api_key=api_key, provider=provider, timeout=timeout)
try:
return solver.solve_recaptcha_v2(site_key, page_url)
except Exception as e:
logger.error(f"Fehler beim Lösen des reCAPTCHAs: {str(e)}")
return None
def extract_recaptcha_sitekey(html_content):
"""
Extrahiert den reCAPTCHA Site-Key aus dem HTML-Inhalt.
Args:
html_content (str): Der HTML-Inhalt der Webseite
Returns:
str: Der extrahierte Site-Key oder None
"""
from resources.lib.tools import cParser
patterns = [
r"series\.init\s*\(\s*\d+\s*,\s*\d+\s*,\s*'([^']+)'\s*\)\s*;", # BurningSeries-Muster
r'data-sitekey="([^"]+)"', # Standard reCAPTCHA-Muster
r"grecaptcha.execute\s*\(\s*'([^']+)'" # Unsichtbares reCAPTCHA
]
for pattern in patterns:
isMatch, site_key = cParser.parseSingleResult(html_content, pattern)
if isMatch:
return site_key
return None

View File

@@ -0,0 +1,194 @@
# -*- coding: utf-8 -*-
# Python 3
#
#05.07.2025 Heptamer
#
# Captcha Bibliothek
import json
import time
import xbmc
import xbmcgui
from resources.lib.handler.requestHandler import cRequestHandler
from resources.lib.tools import logger
from resources.lib.config import cConfig
class CaptchaSolver:
"""
Eine wiederverwendbare Klasse zum Lösen verschiedener Captcha-Typen.
Unterstützt aktuell 2Captcha für reCAPTCHA v2.
"""
def __init__(self, api_key=None, provider=None, timeout=120):
"""
Initialisiert den Captcha-Solver.
Args:
api_key (str): API-Schlüssel für den Captcha-Dienst
provider (str): Captcha-Dienst ('2captcha', '9kw', etc.)
timeout (int): Maximale Wartezeit in Sekunden
"""
self.api_key = api_key
self.provider = provider
self.timeout = timeout
self.is_alive = True # Für 9kw-Abbruch
def set_api_key(self, api_key):
"""Setzt den API-Schlüssel"""
self.api_key = api_key
def set_provider(self, provider):
"""Setzt den Provider"""
self.provider = provider
def set_kill(self):
"""Abbruch-Signal für 9kw"""
self.is_alive = False
def solve_recaptcha_v2(self, site_key, page_url):
"""
Löst ein reCAPTCHA v2.
Args:
site_key (str): Der Google reCAPTCHA Site-Key
page_url (str): Die URL der Seite mit dem Captcha
Returns:
str: Der Captcha-Lösungstoken
"""
if self.provider == '2captcha':
return self._solve_with_2captcha(site_key, page_url)
# Hier können später weitere Provider hinzugefügt werden
elif self.provider == '9kw':
return self._solve_with_9kw(site_key, page_url)
else:
raise ValueError(f"Captcha-Provider '{self.provider}' wird nicht unterstützt")
def _solve_with_2captcha(self, site_key, page_url):
"""2Captcha-Implementation für reCAPTCHA v2"""
if not self.api_key:
xbmcgui.Dialog().ok(cConfig().getLocalizedString(30241), cConfig().getLocalizedString(30291))
return None
params = {
'key': self.api_key,
'method': 'userrecaptcha',
'googlekey': site_key,
'pageurl': page_url,
'json': 1,
}
# Captcha-Anfrage senden
captcha_request = cRequestHandler('https://2captcha.com/in.php', caching=False, method='POST',
data=json.dumps(params))
captcha_request.addHeaderEntry('Content-Type', 'application/json')
response_text = captcha_request.request()
try:
json_response = json.loads(response_text)
except json.JSONDecodeError:
raise Exception(f"Ungültige Antwort vom Captcha-Dienst: {response_text}")
if 'request' not in json_response:
raise Exception(f"Ungültige Antwort vom Captcha-Dienst: {json_response}")
captcha_id = json_response['request']
logger.info(f'Captcha-Anfrage gesendet mit ID: {captcha_id}')
# Auf die Lösung warten
return self._get_2captcha_result(captcha_id)
def _get_2captcha_result(self, captcha_id):
"""Wartet auf und holt das Ergebnis von 2Captcha"""
start_time = time.time()
while True:
request = cRequestHandler(
f"https://2captcha.com/res.php?key={self.api_key}&json=1&action=get&id={captcha_id}",
caching=False
)
request.addHeaderEntry('Content-Type', 'application/json')
response_text = request.request()
try:
json_res = json.loads(response_text)
except json.JSONDecodeError:
logger.error(f"Fehler beim Parsen der 2Captcha-Antwort: {response_text}")
raise Exception(f"Ungültige JSON-Antwort: {response_text}")
if json_res.get('status') == 1:
return json_res.get('request')
elif json_res.get('request') != 'CAPCHA_NOT_READY':
error_msg = f"Fehler beim Lösen des Captchas: {json.dumps(json_res, indent=2)} \nID: {captcha_id}"
logger.error(error_msg)
raise Exception(error_msg)
# Timeout-Überprüfung
if time.time() - start_time >= self.timeout:
error_msg = f"Timeout beim Warten auf Captcha-Lösung (ID: {captcha_id})"
logger.error(error_msg)
raise Exception(error_msg)
# Kurze Pause zwischen den Anfragen
time.sleep(2)
def _solve_with_9kw(self, site_key, page_url):
"""9kw.eu-Implementation für reCAPTCHA v2"""
if not self.api_key:
xbmcgui.Dialog().ok(cConfig().getLocalizedString(30241), cConfig().getLocalizedString(30291))
return None
# Prüfen, ob Selfsolve aktiviert ist (über Addon-Einstellungen)
selfsolve = '1' if cConfig().getSetting('9kw.SelfSolve', 'false') == 'true' else '0'
post = {
'apikey': self.api_key,
'action': 'usercaptchaupload',
'interactive': '1',
'json': '1',
'file-upload-01': site_key,
'oldsource': 'recaptchav2',
'pageurl': page_url,
'maxtimeout': str(self.timeout)
}
if selfsolve == '1':
post['selfsolve'] = '1'
token = ''
try:
URL = 'https://www.9kw.eu/index.cgi'
request = cRequestHandler(URL, caching=False, method='POST', data=post)
response_text = request.request()
if response_text:
data = json.loads(response_text)
if 'captchaid' in data:
captcha_id = data['captchaid']
tries = 0
# Warte auf Captcha-Lösung
while tries < self.timeout and self.is_alive:
tries += 1
xbmc.sleep(1000) # 1 Sekunde warten
check_url = f"https://www.9kw.eu/index.cgi?action=usercaptchacorrectdata&id={captcha_id}&apikey={self.api_key}&json=1"
result_request = cRequestHandler(check_url, caching=False)
result_text = result_request.request()
if result_text:
try:
result_data = json.loads(result_text)
token = result_data.get('answer', '')
if token:
break
except Exception as e:
logger.error(f"Fehler beim Parsen der 9kw-Antwort: {str(e)}")
except Exception as e:
logger.error(f"9kw Error: {str(e)}")
return token

View File

@@ -0,0 +1,73 @@
# -*- coding: utf-8 -*-
# Python 3
import xbmcaddon
import resolveurl as resolver
import threading
from urllib.parse import urlparse
from xbmc import LOGWARNING, log
class cConfig:
_instances = {} # Cache for addon_id -> cConfig instance
_addon_cache = {} # Cache for addon_id -> xbmcaddon.Addon instance
_settings_lock = threading.Lock()
# singleton implementation
def __new__(cls, *args, **kwargs):
addon_id = kwargs.get('addon_id') or (args[0] if args else xbmcaddon.Addon().getAddonInfo('id'))
if addon_id not in cls._instances:
instance = super(cConfig, cls).__new__(cls)
instance._addon_id = addon_id
if addon_id not in cls._addon_cache:
cls._addon_cache[addon_id] = xbmcaddon.Addon(addon_id)
instance.__addon = cls._addon_cache[addon_id]
instance.__aLanguage = instance.__addon.getLocalizedString
cls._instances[addon_id] = instance
return cls._instances[addon_id]
def showSettingsWindow(self):
self.__addon.openSettings()
def getSetting(self, sName, default=''):
result = self.__addon.getSetting(sName)
if result:
return result
else:
return default
def getSettingString(self, sName, default=''):
result = self.__addon.getSetting(sName)
if result:
return str(result)
else:
return default
def setSetting(self, id, value):
if id and value:
with cConfig._settings_lock:
self.__addon.setSetting(id, value)
def getAddonInfo(self, sName):
result = self.__addon.getAddonInfo(sName)
if result:
return result
else:
return ''
def getLocalizedString(self, sCode):
return self.__aLanguage(sCode)
def isBlockedHoster(self, domain, checkResolver=True ):
domain = urlparse(domain).path if urlparse(domain).hostname == None else urlparse(domain).hostname
hostblockDict = ['flashx','streamlare','evoload', 'hd-stream', 'vivo'] # permanenter Block
blockedHoster = cConfig().getSetting('blockedHoster').split(',') # aus setting.xml blockieren
if len(blockedHoster) <= 1: blockedHoster = cConfig().getSetting('blockedHoster').split()
for i in blockedHoster: hostblockDict.append(i.lower())
for i in hostblockDict:
if i in domain.lower() or i.split('.')[0] in domain.lower(): return True, domain
if checkResolver: # Überprüfung in resolveUrl
if resolver.relevant_resolvers(domain=domain) == []:
log('[xStream] -> [isblockedHoster]: In resolveUrl no domain for url: %s' % domain, LOGWARNING)
return True, domain # Domain nicht in resolveUrl gefunden
return False, domain

View File

@@ -0,0 +1,127 @@
# -*- coding: utf-8 -*-
# Python 3
import os
import time
import xbmcgui
from resources.lib import utils
from resources.lib.config import cConfig
from resources.lib.gui.gui import cGui
from xbmc import LOGINFO as LOGNOTICE, log
from xbmcvfs import translatePath
from urllib.request import Request, urlopen
class cDownload:
def __createProcessDialog(self, downloadDialogTitle):
if cConfig().getSetting('backgrounddownload') == 'true':
oDialog = xbmcgui.DialogProgressBG()
else:
oDialog = xbmcgui.DialogProgress()
oDialog.create(downloadDialogTitle)
self.__oDialog = oDialog
def __createDownloadFilename(self, filename):
filename = filename.replace(' ', '_')
return filename
def download(self, url, sTitle, showDialog=True, downloadDialogTitle=cConfig().getLocalizedString(30245)):
sTitle = '%s' % sTitle
self.__processIsCanceled = False
try:
header = dict([item.split('=') for item in (url.split('|')[1]).split('&')])
except Exception:
header = {}
log(cConfig().getLocalizedString(30166) + ' -> [download]: Header for download: %s' % header, LOGNOTICE)
url = url.split('|')[0]
sTitle = self.__createTitle(url, sTitle)
self.__sTitle = self.__createDownloadFilename(sTitle)
if showDialog:
self.__sTitle = cGui().showKeyBoard(self.__sTitle, sHeading=cConfig().getLocalizedString(30290))
if self.__sTitle != False and len(self.__sTitle) > 0:
sPath = cConfig().getSetting('download-folder')
if sPath == '':
dialog = xbmcgui.Dialog()
sPath = dialog.browse(3, 'Downloadfolder', 'files', '')
if sPath != '':
sDownloadPath = translatePath(sPath + '%s' % (self.__sTitle,))
self.__prepareDownload(url, header, sDownloadPath, downloadDialogTitle)
elif self.__sTitle != False:
temp_dir = os.path.join(translatePath(cConfig().getAddonInfo('profile')))
if not os.path.isdir(temp_dir):
os.makedirs(os.path.join(temp_dir))
self.__prepareDownload(url, header, os.path.join(temp_dir, sTitle), downloadDialogTitle)
log(cConfig().getLocalizedString(30166) + ' -> [download]: download completed', LOGNOTICE)
def __prepareDownload(self, url, header, sDownloadPath, downloadDialogTitle):
try:
log(cConfig().getLocalizedString(30166) + ' -> [download]: download file: ' + str(url) + ' to ' + str(sDownloadPath), LOGNOTICE)
self.__createProcessDialog(downloadDialogTitle)
request = Request(url, headers=header)
self.__download(urlopen(request, timeout=240), sDownloadPath)
except Exception as e:
log(e)
self.__oDialog.close()
def __download(self, oUrlHandler, fpath):
headers = oUrlHandler.info()
iTotalSize = -1
if 'content-length' in headers:
iTotalSize = (headers['Content-Length'])
chunk = 4096
#f = open(r'%s' % fpath, 'wb')
import xbmcvfs
f = xbmcvfs.File(fpath, 'w')
log(cConfig().getLocalizedString(30166) + ' -> [download]: start download', LOGNOTICE)
try:
iCount = 0
self._startTime = time.time()
while 1:
iCount = iCount + 1
data = oUrlHandler.read(chunk)
if not data or self.__processIsCanceled == True:
break
f.write(data)
self.__stateCallBackFunction(iCount, chunk, iTotalSize)
f.close()
except:
log(cConfig().getLocalizedString(30166) + '-> [download]: download failed', LOGNOTICE)
f.close()
def __createTitle(self, sUrl, sTitle):
aTitle = sTitle.rsplit('.')
if len(aTitle) > 1:
return sTitle
aUrl = sUrl.rsplit('.')
if len(aUrl) > 1:
sSuffix = aUrl[-1]
sTitle = sTitle + '.' + sSuffix
return sTitle
def __stateCallBackFunction(self, iCount, iBlocksize, iTotalSize):
timedif = time.time() - self._startTime
currentLoaded = int(iCount) * iBlocksize
iPercent = (currentLoaded * 100 // int(iTotalSize))
if timedif > 0.0:
avgSpd = (currentLoaded // timedif // 1024.0)
else:
avgSpd = 5
value = self.__sTitle, str('%s/%s@%dKB/s' % (self.__formatFileSize(currentLoaded), self.__formatFileSize(iTotalSize), avgSpd))
self.__oDialog.update(iPercent, str(value))
if cConfig().getSetting('backgrounddownload') == 'false' and self.__oDialog.iscanceled():
self.__processIsCanceled = True
self.__oDialog.close()
def __formatFileSize(self, iBytes):
iBytes = int(iBytes)
if iBytes == 0:
return '%.*f %s' % (2, 0, 'MB')
return '%.*f %s' % (2, int(iBytes) // (1024 * 1024.0), 'MB')

View File

@@ -0,0 +1,40 @@
# -*- coding: utf-8 -*-
# Python 3
from resources.lib.handler.ParameterHandler import ParameterHandler
class cContextElement:
def __init__(self):
self.__sTitle = ''
self.__oOutputParameterHandler = ParameterHandler()
def setFunction(self, sFunctionName):
self.__sFunctionName = sFunctionName
def getFunction(self):
return self.__sFunctionName
def setFile(self, sFile):
self.__sFile = sFile
def getFile(self):
return self.__sFile
def setTitle(self, sTitle):
self.__sTitle = sTitle
def getTitle(self):
return self.__sTitle
def setSiteName(self, sSiteName):
self.__sSiteName = sSiteName
def getSiteName(self):
return self.__sSiteName
def setOutputParameterHandler(self, oOutputParameterHandler):
self.__oOutputParameterHandler = oOutputParameterHandler
def getOutputParameterHandler(self):
return self.__oOutputParameterHandler

View File

@@ -0,0 +1,418 @@
# -*- coding: utf-8 -*-
# Python 3
import sys
import xbmc
import xbmcgui
import xbmcplugin
from resources.lib import utils
from resources.lib.config import cConfig
from resources.lib.gui.contextElement import cContextElement
from resources.lib.gui.guiElement import cGuiElement
from resources.lib.handler.ParameterHandler import ParameterHandler
from urllib.parse import quote_plus, urlencode
class cGui:
# This class "abstracts" a list of xbmc listitems.
def __init__(self):
try:
self.pluginHandle = int(sys.argv[1])
except:
self.pluginHandle = 0
try:
self.pluginPath = sys.argv[0]
except:
self.pluginPath = ''
self.isMetaOn = cConfig().getSetting('TMDBMETA') == 'true'
if cConfig().getSetting('metaOverwrite') == 'true':
self.metaMode = 'replace'
else:
self.metaMode = 'add'
# for globalSearch or alterSearch
self.globalSearch = False
self._collectMode = False
self._isViewSet = False
self.searchResults = []
def addFolder(self, oGuiElement, params='', bIsFolder=True, iTotal=0, isHoster=False):
# add GuiElement to Gui, adds listitem to a list
# abort xbmc list creation if user requests abort
if xbmc.Monitor().abortRequested():
self.setEndOfDirectory(False)
raise RuntimeError('UserAborted')
# store result in list if we searched global for other sources
if self._collectMode:
import copy
self.searchResults.append({'guiElement': oGuiElement, 'params': copy.deepcopy(params), 'isFolder': bIsFolder})
return
if not oGuiElement._isMetaSet and self.isMetaOn and oGuiElement._mediaType and iTotal < 100:
tmdbID = params.getValue('tmdbID')
if tmdbID:
oGuiElement.getMeta(oGuiElement._mediaType, tmdbID, mode=self.metaMode)
else:
oGuiElement.getMeta(oGuiElement._mediaType, mode=self.metaMode)
sUrl = self.__createItemUrl(oGuiElement, bIsFolder, params)
#kasi
try:
if params.exist('trumb'): oGuiElement.setIcon(params.getValue('trumb'))
except:
pass
listitem = self.createListItem(oGuiElement)
if not bIsFolder and cConfig().getSetting('hosterSelect') == 'List':
bIsFolder = True
if isHoster:
bIsFolder = False
listitem = self.__createContextMenu(oGuiElement, listitem, bIsFolder, sUrl)
if not bIsFolder:
listitem.setProperty('IsPlayable', 'true')
xbmcplugin.addDirectoryItem(self.pluginHandle, sUrl, listitem, bIsFolder, iTotal)
def addNextPage(self, site, function, params=''):
guiElement = cGuiElement(cConfig().getLocalizedString(30279), site, function)
self.addFolder(guiElement, params)
def searchNextPage(self, sTitle, site, function, params=''):
guiElement = cGuiElement(sTitle, site, function)
self.addFolder(guiElement, params)
def createListItem(self, oGuiElement):
itemValues = oGuiElement.getItemValues()
itemTitle = oGuiElement.getTitle()
infoString = ''
if self.globalSearch: # Reihenfolge der zu anzeigenden GUI Elemente
infoString += ' %s' % oGuiElement.getSiteName()
if oGuiElement._sLanguage != '':
infoString += ' (%s)' % oGuiElement._sLanguage
if oGuiElement._sSubLanguage != '':
infoString += ' *Sub: %s*' % oGuiElement._sSubLanguage
if oGuiElement._sQuality != '':
infoString += ' [%s]' % oGuiElement._sQuality
if oGuiElement._sInfo != '':
infoString += ' [%s]' % oGuiElement._sInfo
# if self.globalSearch:
# infoString += ' %s' % oGuiElement.getSiteName()
if infoString:
infoString = '[I]%s[/I]' % infoString
itemValues['title'] = itemTitle + infoString
try:
if not 'plot' in str(itemValues) or itemValues['plot'] == '':
itemValues['plot'] = ' ' #kasi Alt 255
except:
pass
#listitem = xbmcgui.ListItem(itemTitle + infoString, oGuiElement.getIcon(), oGuiElement.getThumbnail())
listitem = xbmcgui.ListItem(itemTitle + infoString)
# Function: setInfo(type, infoLabels)
# listitem.setInfo('video', { 'genre': 'Comedy' })
listitem.setInfo(oGuiElement.getType(), itemValues)
#Wenn Kodi 19, dann ignoriere setinfotagvideo
kodi_version = xbmc.getInfoLabel('System.BuildVersion')
if kodi_version[:2] > '19':
self.setInfoTagVideo(oGuiElement, listitem)
listitem.setProperty('fanart_image', oGuiElement.getFanart())
listitem.setArt({'icon': oGuiElement.getIcon(), 'thumb': oGuiElement.getThumbnail(), 'poster': oGuiElement.getThumbnail(), 'fanart': oGuiElement.getFanart()})
aProperties = oGuiElement.getItemProperties()
if len(aProperties) > 0:
for sPropertyKey in aProperties.keys():
listitem.setProperty(sPropertyKey, aProperties[sPropertyKey])
return listitem
### ÄNDERUNG ANFANG ###
def setInfoTagVideo(self, oGuiElement, listitem):
itemValues = oGuiElement.getItemValues()
vtag = listitem.getVideoInfoTag()
vtag.setMediaType(oGuiElement.getType())
# Titel ist bereits gesetzt und der Infostring geht hier verloren, wenn man den Titel erneut setzt
#if 'title' in itemValues:
# try:
# vtag.setTitle(itemValues['title'])
# except: pass
if 'plot' in itemValues:
try:
vtag.setPlot(itemValues['plot'])
except: pass
if 'year' in itemValues:
try:
vtag.setYear(int(itemValues['year']))
except: pass
if 'season' in itemValues:
try:
vtag.setSeason(int(itemValues['season']))
except: pass
if 'episode' in itemValues:
try:
vtag.setEpisode(int(itemValues['episode']))
except: pass
if 'TVShowTitle' in itemValues:
try:
vtag.setTvShowTitle(itemValues['TVShowTitle'])
except: pass
if 'cast' in itemValues:
try:
vtag.setCast([xbmc.Actor(cast[0], cast[1], thumbnail=cast[2]) for cast in itemValues['cast']])
except: pass
if 'countries' in itemValues:
try:
vtag.setCountries(itemValues['countries'])
except: pass
if 'country' in itemValues:
try:
vtag.setCountries([itemValues['country']])
except: pass
if 'dateadded' in itemValues:
try:
vtag.setDateAdded(itemValues['dateadded'])
except: pass
if 'directors' in itemValues:
try:
vtag.setDirectors(itemValues['directors'])
except: pass
if 'duration' in itemValues:
# minuten in sekunden umrechnen
try:
vtag.setDuration(int(itemValues['duration']) * 60)
except: pass
if 'rating' in itemValues:
try:
vtag.setRating(float(itemValues['rating']))
except: pass
if 'genre' in itemValues:
try:
vtag.setGenres(itemValues['genres'].split(' / '))
except: pass
if 'imdb_id' in itemValues:
try:
vtag.setUniqueID(str(itemValues['imdb_id']), 'imdb')
except: pass
if 'tmdb_id' in itemValues:
try:
vtag.setUniqueID(str(itemValues['tmdb_id']), 'tmdb')
except: pass
if 'originaltitle' in itemValues:
try:
vtag.setOriginalTitle(itemValues['originaltitle'])
except: pass
if 'trailer' in itemValues:
try:
vtag.setTrailer(itemValues['trailer'])
except: pass
if 'tagline' in itemValues:
try:
vtag.setTagLine(itemValues['tagline'])
except: pass
# Kodi versucht Bilder er Studios zu finden, was zu Fehlern im Logfile führt
#if 'studio' in itemValues:
# try:
# vtag.setStudios(itemValues['studio'].split(' / '))
# except: pass
if 'premiered' in itemValues:
try:
vtag.setPremiered(itemValues['premiered'])
except: pass
### ÄNDERUNG ENDE ###
def __createContextMenu(self, oGuiElement, listitem, bIsFolder, sUrl):
contextmenus = []
if len(oGuiElement.getContextItems()) > 0:
for contextitem in oGuiElement.getContextItems():
params = contextitem.getOutputParameterHandler()
sParams = params.getParameterAsUri()
sTest = "%s?site=%s&function=%s&%s" % (self.pluginPath, contextitem.getFile(), contextitem.getFunction(), sParams)
contextmenus += [(contextitem.getTitle(), "RunPlugin(%s)" % (sTest,),)]
itemValues = oGuiElement.getItemValues()
contextitem = cContextElement()
if oGuiElement._mediaType == 'movie' or oGuiElement._mediaType == 'tvshow':
if cConfig().getSetting('xstream.trailer') == 'true':
if not xbmc.getCondVisibility('System.HasAddon(%s)' % 'script.module.xstream.trailer'): # Schauen ob Addon installiert
xbmc.executebuiltin('InstallAddon(%s)' % 'script.module.xstream.trailer') # Addon installieren
contextitem.setTitle(cConfig().getLocalizedString(30027)) # Trailer Funktion
contextmenus += [(contextitem.getTitle(), "RunPlugin(plugin://script.module.xstream.trailer/?action=play&name=%s&url=&language=)" % (itemValues['title'],),)]
if oGuiElement._mediaType == 'movie' or oGuiElement._mediaType == 'tvshow':
contextitem.setTitle(cConfig().getLocalizedString(30239)) # Erweiterte Info
searchParams = {'searchTitle': oGuiElement.getTitle(), 'sMeta': oGuiElement._mediaType, 'sYear': oGuiElement._sYear}
contextmenus += [(contextitem.getTitle(), "RunPlugin(%s?function=viewInfo&%s)" % (self.pluginPath, urlencode(searchParams),),)]
if oGuiElement._mediaType == 'season' or oGuiElement._mediaType == 'episode':
contextitem.setTitle(cConfig().getLocalizedString(30241)) # Info
contextmenus += [(contextitem.getTitle(), cConfig().getLocalizedString(30242),)] # Action(Info)
# search for alternative source
contextitem.setTitle(cConfig().getLocalizedString(30243)) # Weitere Quellen
searchParams = {'searchTitle': oGuiElement.getTitle()}
if 'imdb_id' in itemValues:
searchParams['searchImdbID'] = itemValues['imdb_id']
contextmenus += [(contextitem.getTitle(), "Container.Update(%s?function=searchAlter&%s)" % (self.pluginPath, urlencode(searchParams),),)]
if 'imdb_id' in itemValues and 'title' in itemValues:
metaParams = {}
if itemValues['title']:
metaParams['title'] = oGuiElement.getTitle()
if 'mediaType' in itemValues and itemValues['mediaType']:
metaParams['mediaType'] = itemValues['mediaType']
elif 'TVShowTitle' in itemValues and itemValues['TVShowTitle']:
metaParams['mediaType'] = 'tvshow'
else:
metaParams['mediaType'] = 'movie'
if 'season' in itemValues and itemValues['season'] and int(itemValues['season']) > 0:
metaParams['season'] = itemValues['season']
metaParams['mediaType'] = 'season'
if 'episode' in itemValues and itemValues['episode'] and int(itemValues['episode']) > 0 and 'season' in itemValues and itemValues['season'] and int(itemValues['season']):
metaParams['episode'] = itemValues['episode']
metaParams['mediaType'] = 'episode'
# context options for movies or episodes
if not bIsFolder:
contextitem.setTitle(cConfig().getLocalizedString(30244)) # Playlist hinzufügen
contextmenus += [(contextitem.getTitle(), "RunPlugin(%s&playMode=enqueue)" % (sUrl,),)]
contextitem.setTitle(cConfig().getLocalizedString(30245)) # Download
contextmenus += [(contextitem.getTitle(), "RunPlugin(%s&playMode=download)" % (sUrl,),)]
if cConfig().getSetting('jd_enabled') == 'true':
contextitem.setTitle(cConfig().getLocalizedString(30246)) # send JD
contextmenus += [(contextitem.getTitle(), "RunPlugin(%s&playMode=jd)" % (sUrl,),)]
if cConfig().getSetting('jd2_enabled') == 'true':
contextitem.setTitle(cConfig().getLocalizedString(30247)) # Send JD2
contextmenus += [(contextitem.getTitle(), "RunPlugin(%s&playMode=jd2)" % (sUrl,),)]
if cConfig().getSetting('myjd_enabled') == 'true':
contextitem.setTitle(cConfig().getLocalizedString(30248)) # Send myjd
contextmenus += [(contextitem.getTitle(), "RunPlugin(%s&playMode=myjd)" % (sUrl,),)]
if cConfig().getSetting('pyload_enabled') == 'true':
contextitem.setTitle(cConfig().getLocalizedString(30249)) # Send Pyload
contextmenus += [(contextitem.getTitle(), "RunPlugin(%s&playMode=pyload)" % (sUrl,),)]
if cConfig().getSetting('hosterSelect') == 'Auto':
contextitem.setTitle(cConfig().getLocalizedString(30149)) # select Hoster
contextmenus += [(contextitem.getTitle(), "RunPlugin(%s&playMode=play&manual=1)" % (sUrl,),)]
listitem.addContextMenuItems(contextmenus)
# listitem.addContextMenuItems(contextmenus, True)
return listitem
def setEndOfDirectory(self, success=True):
# mark the listing as completed, this is mandatory
if not self._isViewSet:
self.setView('files')
xbmcplugin.setPluginCategory(self.pluginHandle, "")
# add some sort methods, these will be available in all views
xbmcplugin.addSortMethod(self.pluginHandle, xbmcplugin.SORT_METHOD_UNSORTED)
xbmcplugin.addSortMethod(self.pluginHandle, xbmcplugin.SORT_METHOD_VIDEO_RATING)
xbmcplugin.addSortMethod(self.pluginHandle, xbmcplugin.SORT_METHOD_LABEL)
xbmcplugin.addSortMethod(self.pluginHandle, xbmcplugin.SORT_METHOD_DATE)
xbmcplugin.addSortMethod(self.pluginHandle, xbmcplugin.SORT_METHOD_PROGRAM_COUNT)
xbmcplugin.addSortMethod(self.pluginHandle, xbmcplugin.SORT_METHOD_VIDEO_RUNTIME)
xbmcplugin.addSortMethod(self.pluginHandle, xbmcplugin.SORT_METHOD_GENRE)
xbmcplugin.endOfDirectory(self.pluginHandle, success)
def setView(self, content='movies'):
# set the listing to a certain content, makes special views available
# sets view to the viewID which is selected in xStream settings
# see http://mirrors.xbmc.org/docs/python-docs/stable/xbmcplugin.html#-setContent
# (seasons is also supported but not listed)
content = content.lower()
supportedViews = ['files', 'songs', 'artists', 'albums', 'movies', 'tvshows', 'seasons', 'episodes', 'musicvideos']
if content in supportedViews:
self._isViewSet = True
xbmcplugin.setContent(self.pluginHandle, content)
if cConfig().getSetting('auto-view') == 'true' and content:
viewId = cConfig().getSetting(content + '-view')
if viewId:
xbmc.executebuiltin("Container.SetViewMode(%s)" % viewId)
def updateDirectory(self):
# update the current listing
xbmc.executebuiltin("Container.Refresh")
def __createItemUrl(self, oGuiElement, bIsFolder, params=''):
if params == '':
params = ParameterHandler()
itemValues = oGuiElement.getItemValues()
if 'tmdb_id' in itemValues and itemValues['tmdb_id']:
params.setParam('tmdbID', itemValues['tmdb_id'])
if 'TVShowTitle' in itemValues and itemValues['TVShowTitle']:
params.setParam('TVShowTitle', itemValues['TVShowTitle'])
if 'season' in itemValues and itemValues['season'] and int(itemValues['season']) > 0:
params.setParam('season', itemValues['season'])
if 'episode' in itemValues and itemValues['episode'] and float(itemValues['episode']) > 0:
params.setParam('episode', itemValues['episode'])
# TODO change this, it can cause bugs it influencec the params for the following listitems
if not bIsFolder:
params.setParam('MovieTitle', oGuiElement.getTitle())
thumbnail = oGuiElement.getThumbnail()
if thumbnail:
params.setParam('thumb', thumbnail)
if oGuiElement._mediaType:
params.setParam('mediaType', oGuiElement._mediaType)
elif 'TVShowTitle' in itemValues and itemValues['TVShowTitle']:
params.setParam('mediaType', 'tvshow')
if 'season' in itemValues and itemValues['season'] and int(itemValues['season']) > 0:
params.setParam('mediaType', 'season')
if 'episode' in itemValues and itemValues['episode'] and float(itemValues['episode']) > 0:
params.setParam('mediaType', 'episode')
sParams = params.getParameterAsUri()
try:
if params.getValue('sUrl').startswith("plugin://"):
return params.getValue('sUrl')
except: pass
if len(oGuiElement.getFunction()) == 0:
sUrl = "%s?site=%s&title=%s&%s" % (self.pluginPath, oGuiElement.getSiteName(), quote_plus(oGuiElement.getTitle()), sParams)
else:
#kasi
sUrl = "%s?site=%s&function=%s&title=%s&trumb=%s&%s" % (self.pluginPath, oGuiElement.getSiteName(), oGuiElement.getFunction(), quote_plus(oGuiElement.getTitle()), oGuiElement.getThumbnail(), sParams)
if not bIsFolder:
sUrl += '&playMode=play'
return sUrl
@staticmethod
def showKeyBoard(sDefaultText="", sHeading=""):
# Create the keyboard object and display it modal
oKeyboard = xbmc.Keyboard(sDefaultText, sHeading)
oKeyboard.doModal()
# If key board is confirmed and there was text entered return the text
if oKeyboard.isConfirmed():
sSearchText = oKeyboard.getText()
if len(sSearchText) > 0:
return sSearchText
return False
@staticmethod
def showNumpad(defaultNum="", numPadTitle=cConfig().getLocalizedString(30251)):
defaultNum = str(defaultNum)
dialog = xbmcgui.Dialog()
num = dialog.numeric(0, numPadTitle, defaultNum)
return num
@staticmethod
def openSettings():
cConfig().showSettingsWindow()
@staticmethod
def showNofication(sTitle, iSeconds=0):
if iSeconds == 0:
iSeconds = 1000
else:
iSeconds = iSeconds * 1000
xbmc.executebuiltin("Notification(%s,%s,%s,%s)" % (cConfig().getLocalizedString(30308), (cConfig().getLocalizedString(30309) % str(sTitle)), iSeconds, cConfig().getAddonInfo('icon')))
@staticmethod
def showError(sTitle, sDescription, iSeconds=0):
if iSeconds == 0:
iSeconds = 1000
else:
iSeconds = iSeconds * 1000
xbmc.executebuiltin("Notification(%s,%s,%s,%s)" % (str(sTitle), (str(sDescription)), iSeconds, cConfig().getAddonInfo('icon')))
@staticmethod
def showInfo(sTitle='xStream', sDescription=cConfig().getLocalizedString(30253), iSeconds=0):
if iSeconds == 0:
iSeconds = 1000
else:
iSeconds = iSeconds * 1000
xbmc.executebuiltin("Notification(%s,%s,%s,%s)" % (str(sTitle), (str(sDescription)), iSeconds, cConfig().getAddonInfo('icon')))
@staticmethod
def showLanguage(sTitle='xStream', sDescription=cConfig().getLocalizedString(30403), iSeconds=0):
if iSeconds == 0:
iSeconds = 1000
else:
iSeconds = iSeconds * 1000
xbmc.executebuiltin("Notification(%s,%s,%s,%s)" % (str(sTitle), (str(sDescription)), iSeconds, cConfig().getAddonInfo('icon')))

View File

@@ -0,0 +1,344 @@
# -*- coding: utf-8 -*-
# Python 3
from resources.lib.tools import cParser, cUtil
from resources.lib.config import cConfig
from xbmc import LOGERROR, log
from os import path
class cGuiElement:
'''
This class "abstracts" a xbmc listitem.
Kwargs:
sTitle (str): title/label oft the GuiElement/listitem
sSite (str): siteidentifier of the siteplugin, which is called if the GuiElement is selected
sFunction (str): name of the function, which is called if the GuiElement is selected
These arguments are mandatory. If not given on init, they have to be set by their setter-methods, before the GuiElement is added to the Gui.
'''
DEFAULT_FOLDER_ICON = 'DefaultFolder.png'
DEFAULT_FANART = path.join(cConfig().getAddonInfo('path'), 'fanart.jpg')
MEDIA_TYPES = ['movie', 'tvshow', 'season', 'episode']
def __init__(self, sTitle: object = '', sSite: object = None, sFunction: object = None) -> None:
self.__sType = 'video'
self.__sMediaUrl = ''
self.__sTitle = cUtil.cleanse_text(sTitle)
self.__sTitleSecond = ''
self.__sDescription = ''
self.__sThumbnail = ''
self.__sIcon = self.DEFAULT_FOLDER_ICON
self.__aItemValues = {}
self.__aProperties = {}
self.__aContextElements = []
self.__sFanart = self.DEFAULT_FANART
self.__sSiteName = sSite
self.__sFunctionName = sFunction
self._sLanguage = ''
self._sSubLanguage = ''
self._sYear = ''
self._sQuality = ''
self._sInfo = ''
self._mediaType = ''
self._season = ''
self._episode = ''
self._tmdbID = ''
self._rating = ''
self._isMetaSet = False
def setType(self, sType):
self.__sType = sType
def getType(self):
return self.__sType
def setMediaUrl(self, sMediaUrl):
self.__sMediaUrl = sMediaUrl
def getMediaUrl(self):
return self.__sMediaUrl
def setSiteName(self, sSiteName):
self.__sSiteName = sSiteName
def getSiteName(self):
return self.__sSiteName
def setFunction(self, sFunctionName):
self.__sFunctionName = sFunctionName
def getFunction(self):
return self.__sFunctionName
def setTitle(self, sTitle):
self.__sTitle = cUtil.cleanse_text(sTitle)
# Sprachen im sName ins GUI Element übernehmen
def getTitle(self):
if ' (19' in self.__sTitle or ' (20' in self.__sTitle:
isMatch, aYear = cParser.parse(self.__sTitle, r'(.*?)\((\d{4})\)')
if isMatch:
self.__sTitle = aYear[0][0]
self.setYear(aYear[0][1])
if '*19' in self.__sTitle or '*20' in self.__sTitle:
isMatch, aYear = cParser.parse(self.__sTitle, r'(.*?)\*(\d{4})\*')
if isMatch:
self.__sTitle = aYear[0][0]
self.setYear(aYear[0][1])
if '*english*' in self.__sTitle.lower():
isMatch, aLang = cParser.parse(self.__sTitle, r'(.*?)\*(.*?)\*')
if isMatch:
self.__sTitle = aLang[0][0]
self.setLanguage('EN')
if '*deutsch*' in self.__sTitle.lower():
isMatch, aLang = cParser.parse(self.__sTitle, r'(.*?)\*(.*?)\*')
if isMatch:
self.__sTitle = aLang[0][0]
self.setLanguage('DE')
if 'English:' in self.__sTitle:
self.__sTitle = self.__sTitle.replace('English:', '')
self.setLanguage('EN')
if 'Deutsch:' in self.__sTitle:
self.__sTitle = self.__sTitle.replace('Deutsch:', '')
self.setLanguage('DE')
if '(omu)' in self.__sTitle.lower() or '*OmU*' in self.__sTitle:
self.__sTitle = self.__sTitle.replace('(OmU) ', '')
self.__sTitle = self.__sTitle.replace('(Omu) ', '')
self.setLanguage('OmU')
if self._sYear: self.__sTitle = self.__sTitle.strip() + ' (' + self._sYear + ')'
return self.__sTitle.strip()
def setMediaType(self, mediaType):
'''
Set mediatype for GuiElement
Args:
mediaType(str): 'movie'/'tvshow'/'season'/'episode'
'''
mediaType = mediaType.lower()
if mediaType in self.MEDIA_TYPES:
self._mediaType = mediaType
else:
log(cConfig().getLocalizedString(30166) + ' -> [guiElement]: Unknown MediaType given for %s' % self.getTitle(), LOGERROR)
def setSeason(self, season):
self._season = season
self.__aItemValues['season'] = str(season)
def setEpisode(self, episode):
self._episode = episode
self.__aItemValues['episode'] = str(episode)
def setTVShowTitle(self, tvShowTitle):
self.__aItemValues['TVShowTitle'] = str(tvShowTitle)
def setYear(self, year):
try:
year = int(year)
except:
log(cConfig().getLocalizedString(30166) + ' -> [guiElement]: Year given for %s seems not to be a valid number' % self.getTitle(), LOGERROR)
return False
if len(str(year)) != 4:
log(cConfig().getLocalizedString(30166) + ' -> [guiElement]: Year given for %s has %s digits, required 4 digits' % (self.getTitle(), len(str(year))), LOGERROR)
return False
if year > 0:
self._sYear = str(year)
self.__aItemValues['year'] = year
return True
else:
log(cConfig().getLocalizedString(30166) + ' -> [guiElement]: Year given for %s must be greater than 0' % self.getTitle(), LOGERROR)
return False
def setQuality(self, quality):
try:
if '2160' in quality:
self._sQuality = '4K 2160p'
elif '1440' in quality:
self._sQuality = '2K 1440p'
elif '1080' in quality:
self._sQuality = 'HD 1080p'
elif '720' in quality:
self._sQuality = 'HD 720p'
elif '480' in quality:
self._sQuality = 'SD 480p'
elif '360' in quality:
self._sQuality = 'SD 360p'
elif 'HD' in quality:
self._sQuality = 'HD'
elif 'BDRip' in quality:
self._sQuality = 'BD Rip'
elif 'WEBRip' in quality:
self._sQuality = 'WEB Rip'
elif 'TS.MD' in quality:
self._sQuality = 'TS Mic'
elif 'MD.TS' in quality:
self._sQuality = 'TS Mic'
elif 'TS.LD' in quality:
self._sQuality = 'TS Line'
elif 'LD.TS' in quality:
self._sQuality = 'TS Line'
elif 'TS' in quality:
self._sQuality = 'TS'
#self._sQuality = quality
except:
pass
def getQuality(self):
return self._sQuality
def setInfo(self, info):
self._sInfo = info
def getInfo(self):
return self._sInfo
def setTitleSecond(self, sTitleSecond):
self.__sTitleSecond = cUtil.cleanse_text(str(sTitleSecond))
def getTitleSecond(self):
return self.__sTitleSecond
def setDescription(self, sDescription):
sDescription = cUtil.cleanse_text(sDescription)
self.__sDescription = sDescription
self.__aItemValues['plot'] = sDescription
def getDescription(self):
if 'plot' not in self.__aItemValues:
return self.__sDescription
else:
return self.__aItemValues['plot']
def setThumbnail(self, sThumbnail):
self.__sThumbnail = sThumbnail
try:
if cConfig().getSetting('replacefanart') == 'true' and sThumbnail.startswith('http'):
self.__sFanart = sThumbnail
except:
pass
def getThumbnail(self):
return self.__sThumbnail
def setIcon(self, sIcon):
self.__sIcon = sIcon
def getIcon(self):
return self.__sIcon
def setFanart(self, sFanart):
self.__sFanart = sFanart
def getFanart(self):
return self.__sFanart
def addItemValue(self, sItemKey, sItemValue):
self.__aItemValues[sItemKey] = sItemValue
def setItemValues(self, aValueList):
self.__aItemValues = aValueList
def getItemValues(self):
self.__aItemValues['title'] = self.getTitle()
if self.getDescription():
self.__aItemValues['plot'] = self.getDescription()
for sPropertyKey in self.__aProperties.keys():
self.__aItemValues[sPropertyKey] = self.__aProperties[sPropertyKey]
return self.__aItemValues
# siehe gui.setInfoTagVideo()
def addItemProperties(self, sPropertyKey, sPropertyValue):
self.__aProperties[sPropertyKey] = sPropertyValue
def getItemProperties(self):
for sItemValueKey in self.__aItemValues.keys():
if not self.__aItemValues[sItemValueKey] == '':
try:
self.__aProperties[sItemValueKey] = str(self.__aItemValues[sItemValueKey])
except:
pass
return self.__aProperties
def addContextItem(self, oContextElement):
self.__aContextElements.append(oContextElement)
def getContextItems(self):
return self.__aContextElements
def setLanguage(self, sLang):
self._sLanguage = str(sLang)
def setSubLanguage(self, sLang):
self._sSubLanguage = str(sLang)
def getMeta(self, mediaType, tmdbID='', TVShowTitle='', season='', episode='', mode='add'):
'''
Fetch metainformations for GuiElement.
Args:
mediaType(str): 'movie'/'tvshow'/'season'/'episode'
Kwargs:
tmdbID (str) :
TVShowTitle (str) :
mode (str) : 'add'/'replace' defines if fetched metainformtions should be added to existing informations, or if they should replace them.
'''
if cConfig().getSetting('TMDBMETA') == 'false':
return False
if not self._mediaType:
self.setMediaType(mediaType)
if mode not in ['add', 'replace']:
log(cConfig().getLocalizedString(30166) + ' -> [guiElement]: Wrong meta set mode', LOGERROR)
if not season and self._season:
season = self._season
if not episode and self._episode:
episode = self._episode
if not self._mediaType:
log(cConfig().getLocalizedString(30166) + ' -> [guiElement]: Could not get MetaInformations for %s, mediaType not defined' % self.getTitle(), LOGERROR)
return False
from resources.lib.tmdb import cTMDB
oMetaget = cTMDB()
if not oMetaget:
return False
if self._mediaType == 'movie':
if self._sYear:
meta = oMetaget.get_meta(self._mediaType, self.getTitle(), year=self._sYear, advanced=cConfig().getSetting('advanced'))
else:
meta = oMetaget.get_meta(self._mediaType, self.getTitle(), advanced=cConfig().getSetting('advanced'))
elif self._mediaType == 'tvshow':
if self._sYear:
meta = oMetaget.get_meta(self._mediaType, self.getTitle(), year=self._sYear, advanced=cConfig().getSetting('advanced'))
else:
meta = oMetaget.get_meta(self._mediaType, self.getTitle(), advanced=cConfig().getSetting('advanced'))
elif self._mediaType == 'season':
meta = {}
elif self._mediaType == 'episode':
meta = oMetaget.get_meta_episodes(self._mediaType, TVShowTitle, tmdbID, str(season), str(episode))
else:
return False
if not meta:
return False
if self._mediaType == 'season':
meta = meta[0]
if mode == 'replace':
self.setItemValues(meta)
if 'cover_url' in meta:
self.setThumbnail(meta['cover_url'])
if 'backdrop_url' in meta:
self.setFanart(meta['backdrop_url'])
if 'title' in meta and episode:
self.setTitle(str(episode) + '. ' + meta['title'])
else:
meta.update(self.__aItemValues)
meta.update(self.__aProperties)
if 'cover_url' in meta != '' and self.__sThumbnail == '':
self.setThumbnail(meta['cover_url'])
if 'backdrop_url' in meta and self.__sFanart == self.DEFAULT_FANART:
self.setFanart(meta['backdrop_url'])
self.setItemValues(meta)
if 'tmdb_id' in meta:
self._tmdbID = meta['tmdb_id']
self._isMetaSet = True
return meta

View File

@@ -0,0 +1,508 @@
# -*- coding: utf-8 -*-
# Python 3
#
# 24.01.23 - Heptamer: Korrektur getpriorities (nun werden alle Hoster gelesen und sortiert)
# 22.12.24 - Heptamer: m3u8 und mpd Files über inputstream adaptive abspielen lassen
import xbmc
import xbmcgui
import xbmcplugin
from resources.lib.handler.ParameterHandler import ParameterHandler
from resources.lib.gui.guiElement import cGuiElement
from resources.lib.gui.gui import cGui
from resources.lib.config import cConfig
from resources.lib.player import cPlayer
from resources.lib.tools import logger
class cHosterGui:
SITE_NAME = 'cHosterGui'
def __init__(self):
self.maxHoster = int(cConfig().getSetting('maxHoster', 100))
self.dialog = False
# TODO: unify parts of play, download etc.
def _getInfoAndResolve(self, siteResult):
oGui = cGui()
params = ParameterHandler()
# get data
mediaUrl = params.getValue('sMediaUrl')
fileName = params.getValue('MovieTitle')
try:
try:
import resolveurl as resolver
except:
import urlresolver as resolver
# resolve
if siteResult:
mediaUrl = siteResult.get('streamUrl', False)
mediaId = siteResult.get('streamID', False)
if mediaUrl:
logger.info('-> [hoster]: resolve: ' + mediaUrl)
link = mediaUrl if siteResult['resolved'] else resolver.resolve(mediaUrl)
elif mediaId:
logger.info('-> [hoster]: resolve: hoster: %s - mediaID: %s' % (siteResult['host'], mediaId))
link = resolver.HostedMediaFile(host=siteResult['host'].lower(), media_id=mediaId).resolve()
else:
oGui.showError('xStream', cConfig().getLocalizedString(30134), 5)
return False
elif mediaUrl:
logger.info('-> [hoster]: resolve: ' + mediaUrl)
link = resolver.resolve(mediaUrl)
else:
oGui.showError('xStream', cConfig().getLocalizedString(30134), 5)
return False
except resolver.resolver.ResolverError as e:
logger.error('-> [hoster]: ResolverError: %s' % e)
oGui.showError('xStream', cConfig().getLocalizedString(30135), 7)
return False
# resolver response
if link is not False:
data = {'title': fileName, 'season': params.getValue('season'), 'episode': params.getValue('episode'), 'showTitle': params.getValue('TVShowTitle'), 'thumb': params.getValue('thumb'), 'link': link}
return data
return False
def play(self, siteResult=False):
logger.info('-> [hoster]: attempt to play file')
data = self._getInfoAndResolve(siteResult)
if not data:
return False
if self.dialog:
try:
self.dialog.close()
except:
pass
vers = int(xbmc.getInfoLabel("System.BuildVersion").split(".")[0])
logger.info('-> [hoster]: play file link: ' + str(data['link']))
list_item = xbmcgui.ListItem(path=data['link'])
#m3u8 und mpd via inputstream, exklusive Filemoon, da IA mit dem Hoster nicht unter Android läuft
if not 'filemoon' in siteResult['streamUrl']:
if '.m3u8' in data['link'] or '.mpd' in data['link']:
list_item.setProperty("inputstream", "inputstream.adaptive")
if '.mpd' in data['link']:
if vers < 21: list_item.setProperty('inputstream.adaptive.manifest_type', 'mpd')
list_item.setMimeType('application/dash+xml')
else:
if vers < 21: list_item.setProperty('inputstream.adaptive.manifest_type', 'hls')
list_item.setMimeType("application/vnd.apple.mpegurl")
if '|' in data['link']:
data['link'], header = data['link'].split('|')
list_item.setProperty('inputstream.adaptive.stream_headers', header)
if vers > 19: list_item.setProperty('inputstream.adaptive.manifest_headers', header)
if 'youtube' in data['link']:
import time
time.sleep(1)
info = {'Title': data['title']}
if data['thumb']:
list_item.setArt(data['thumb'])
if data['showTitle']:
info['Episode'] = data['episode']
info['Season'] = data['season']
info['TVShowTitle'] = data['showTitle']
#Neuer Video-Tag, mit Kodi 19 nicht kompatibel, daher folgende Abfrage
kodi_version = xbmc.getInfoLabel('System.BuildVersion')
if kodi_version[:2] < '20':
list_item.setInfo(type="Video", infoLabels=info)
else:
vtag = list_item.getVideoInfoTag()
vtag.setMediaType('video')
if 'Title' in info:
try:
vtag.setTitle(str(info['Title']))
except: pass
if 'Season' in info:
try:
vtag.setSeason(int(info['Season']))
except: pass
if 'Episode' in info:
try:
vtag.setEpisode(int(info['Episode']))
except: pass
if 'TVShowTitle' in info:
try:
vtag.setTvShowTitle(info['TVShowTitle'])
except: pass
list_item.setProperty('IsPlayable', 'true')
if cGui().pluginHandle > 0:
xbmcplugin.setResolvedUrl(cGui().pluginHandle, True, list_item)
else:
xbmc.Player().play(data['link'], list_item)
return cPlayer().startPlayer()
def addToPlaylist(self, siteResult=False):
oGui = cGui()
logger.info('-> [hoster]: attempt addToPlaylist')
data = self._getInfoAndResolve(siteResult)
if not data: return False
logger.info('-> [hoster]: addToPlaylist file link: ' + str(data['link']))
oGuiElement = cGuiElement()
oGuiElement.setSiteName(self.SITE_NAME)
oGuiElement.setMediaUrl(data['link'])
oGuiElement.setTitle(data['title'])
if data['thumb']:
oGuiElement.setThumbnail(data['thumb'])
if data['showTitle']:
oGuiElement.setEpisode(data['episode'])
oGuiElement.setSeason(data['season'])
oGuiElement.setTVShowTitle(data['showTitle'])
if self.dialog:
self.dialog.close()
oPlayer = cPlayer()
oPlayer.addItemToPlaylist(oGuiElement)
oGui.showInfo(cConfig().getLocalizedString(30136), cConfig().getLocalizedString(30137), 5)
return True
def download(self, siteResult=False):
from resources.lib.download import cDownload
logger.info('-> [hoster]: attempt download')
data = self._getInfoAndResolve(siteResult)
if not data: return False
logger.info('-> [hoster]: download file link: ' + data['link'])
if self.dialog:
self.dialog.close()
oDownload = cDownload()
oDownload.download(data['link'], data['title'])
return True
def sendToPyLoad(self, siteResult=False):
from resources.lib.handler.pyLoadHandler import cPyLoadHandler
logger.info('-> [hoster]: attempt download with pyLoad')
data = self._getInfoAndResolve(siteResult)
if not data: return False
cPyLoadHandler().sendToPyLoad(data['title'], data['link'])
return True
def sendToJDownloader(self, sMediaUrl=False):
from resources.lib.handler.jdownloaderHandler import cJDownloaderHandler
params = ParameterHandler()
if not sMediaUrl:
sMediaUrl = params.getValue('sMediaUrl')
if self.dialog:
self.dialog.close()
logger.info('-> [hoster]: call send to JDownloader: ' + sMediaUrl)
cJDownloaderHandler().sendToJDownloader(sMediaUrl)
def sendToJDownloader2(self, sMediaUrl=False):
from resources.lib.handler.jdownloader2Handler import cJDownloader2Handler
params = ParameterHandler()
if not sMediaUrl:
sMediaUrl = params.getValue('sMediaUrl')
if self.dialog:
self.dialog.close()
logger.info('-> [hoster]: call send to JDownloader2: ' + sMediaUrl)
cJDownloader2Handler().sendToJDownloader2(sMediaUrl)
def sendToMyJDownloader(self, sMediaUrl=False, sMovieTitle='xStream'):
from resources.lib.handler.myjdownloaderHandler import cMyJDownloaderHandler
params = ParameterHandler()
if not sMediaUrl:
sMediaUrl = params.getValue('sMediaUrl')
sMovieTitle = params.getValue('MovieTitle')
if not sMovieTitle:
sMovieTitle = params.getValue('Title')
if not sMovieTitle: # only temporary
sMovieTitle = params.getValue('sMovieTitle')
if not sMovieTitle:
sMovieTitle = params.getValue('title')
if self.dialog:
self.dialog.close()
logger.info('-> [hoster]: call send to My.JDownloader: ' + sMediaUrl)
cMyJDownloaderHandler().sendToMyJDownloader(sMediaUrl, sMovieTitle)
def __getPriorities(self, hosterList, filter=True):
# Sort hosters based on their resolvers priority.
ranking = []
# handles multihosters but is about 10 times slower
for hoster in hosterList:
# we try to load resolveurl within the loop, making sure that the resolver loads new with every cycle
try:
import resolveurl as resolver
except:
import urlresolver as resolver
# accept hoster which is marked as resolveable by sitePlugin
if hoster.get('resolveable', False):
ranking.append([0, hoster])
continue
try:
# serienstream VOE hoster = {'link': [sUrl, sName], aus array "[0]" True bzw. False
link = hoster['link'][0] if isinstance(hoster['link'], list) else hoster['link']
hmf = resolver.HostedMediaFile(url=link)
#hmf = resolver.HostedMediaFile(url=hoster['link'])
except:
continue
if not hmf.valid_url():
hmf = resolver.HostedMediaFile(host=hoster['name'].lower(), media_id='dummy')
if len(hmf.get_resolvers()):
priority = False
for resolver in hmf.get_resolvers():
# prefer individual priority
if not resolver.isUniversal():
priority = resolver._get_priority()
break
if not priority:
priority = resolver._get_priority()
if priority:
ranking.append([priority, hoster])
elif not filter:
ranking.append([999, hoster])
# Reset resolver so we have a fresh instance when loop starts again
del(resolver)
if any('quality' in hoster[1] for hoster in ranking):
try:
# Sortiere Hoster nach Qualität (cConfig().getSetting('preferedQuality') == '5')
pref_quali = cConfig().getSetting('preferedQuality')
if pref_quali != '5' and any('quality' in hoster[1] and int(hoster[1]['quality']) == int(pref_quali) for hoster in ranking):
ranking = sorted(ranking, key=lambda hoster: int('quality' in hoster[1] and hoster[1]['quality']) == int(pref_quali), reverse=True)
else:
# Wenn Hosterliste prüfen an ist, sortiere Hoster nach Prio Qualität
ranking = sorted(ranking, key=lambda hoster: 'quality' in hoster[1] and int(hoster[1]['quality']), reverse=True)
except:
pass
# After sorting Quality, we sort for Hoster-Priority :) -Hep 24.01.23
# ranking = sorted(ranking, key=lambda ranking: ranking[0])
# Hoster Sprache über sLang im Siteplugin Prio nach sLang Code Reihenfolge (Deutsch, Englisch, Englisch mit untertitel
if ranking:
if "languageCode" in ranking[0][1]:
ranking = sorted(ranking, key=lambda ranking: (ranking[1]["languageCode"],ranking[0]))
else:
ranking = sorted(ranking, key=lambda ranking: ranking[0])
hosterQueue = []
for i, hoster in ranking:
hosterQueue.append(hoster)
return hosterQueue
def stream(self, playMode, siteName, function, url):
self.dialog = xbmcgui.DialogProgress()
self.dialog.create('xStream', cConfig().getLocalizedString(30138))
# load site as plugin and run the function
self.dialog.update(5, cConfig().getLocalizedString(30139))
plugin = __import__(siteName, globals(), locals())
function = getattr(plugin, function)
self.dialog.update(10, cConfig().getLocalizedString(30140))
if url:
siteResult = function(url)
else:
siteResult = function()
self.dialog.update(40)
if not siteResult:
self.dialog.close()
cGui().showInfo('xStream', cConfig().getLocalizedString(30141))
return
# if result is not a list, make in one
if not type(siteResult) is list:
temp = [siteResult]
siteResult = temp
# field "name" marks hosters
if 'name' in siteResult[0]:
functionName = siteResult[-1]
del siteResult[-1]
if not siteResult:
self.dialog.close()
cGui().showInfo('xStream', cConfig().getLocalizedString(30142))
return
self.dialog.update(60, cConfig().getLocalizedString(30143))
# Sitplugins VOD mit in automatische Abspielliste aufnehmen (Da Links bei der Überprüfung der Verfügbarkeit gekickt werden)
if (playMode != 'jd') and (playMode != 'jd2') and (playMode != 'pyload') and (cConfig().getSetting('presortHoster') == 'true') and (playMode != 'myjd'):
#if (not siteName.startswith('vod_')) and (playMode != 'jd') and (playMode != 'jd2') and (playMode != 'pyload') and (cConfig().getSetting('presortHoster') == 'true') and (playMode != 'myjd'):
siteResult = self.__getPriorities(siteResult)
if not siteResult:
self.dialog.close()
cGui().showInfo('xStream', cConfig().getLocalizedString(30144))
return False
self.dialog.update(90)
# self.dialog.close()
if len(siteResult) > self.maxHoster:
siteResult = siteResult[:self.maxHoster - 1]
if cConfig().getSetting('hosterSelect') == 'List':
self.showHosterFolder(siteResult, siteName, functionName)
return
if len(siteResult) > 1:
# choose hoster
siteResult = self._chooseHoster(siteResult)
if not siteResult:
return
else:
siteResult = siteResult[0]
# get stream links
logger.info(siteResult['link'])
function = getattr(plugin, functionName)
siteResult = function(siteResult['link'])
# if result is not a list, make in one
if not type(siteResult) is list:
temp = [siteResult]
siteResult = temp
# choose part
if len(siteResult) > 1:
siteResult = self._choosePart(siteResult)
if not siteResult:
logger.info('-> [hoster]: no part selected')
return
else:
siteResult = siteResult[0]
self.dialog = xbmcgui.DialogProgress()
self.dialog.create('xStream', cConfig().getLocalizedString(30145))
self.dialog.update(95, cConfig().getLocalizedString(30146))
if playMode == 'play':
self.play(siteResult)
elif playMode == 'download':
self.download(siteResult)
elif playMode == 'enqueue':
self.addToPlaylist(siteResult)
elif playMode == 'jd':
self.sendToJDownloader(siteResult['streamUrl'])
elif playMode == 'jd2':
self.sendToJDownloader2(siteResult['streamUrl'])
elif playMode == 'myjd':
self.sendToMyJDownloader(siteResult['streamUrl'])
elif playMode == 'pyload':
self.sendToPyLoad(siteResult)
def streamAuto(self, playMode, siteName, function):
logger.info('-> [hoster]: auto stream initiated')
self.dialog = xbmcgui.DialogProgress()
self.dialog.create('xStream', cConfig().getLocalizedString(30138))
# load site as plugin and run the function
self.dialog.update(5, cConfig().getLocalizedString(30139))
plugin = __import__(siteName, globals(), locals())
function = getattr(plugin, function)
self.dialog.update(10, cConfig().getLocalizedString(30140))
siteResult = function()
if not siteResult:
self.dialog.close()
cGui().showInfo('xStream', cConfig().getLocalizedString(30141))
return False
# if result is not a list, make in one
if not type(siteResult) is list:
temp = [siteResult]
siteResult = temp
# field "name" marks hosters
if 'name' in siteResult[0]:
self.dialog.update(90, cConfig().getLocalizedString(30143))
functionName = siteResult[-1]
del siteResult[-1]
# Sitplugins aus dem VOD Bereich bei self.__getPriorities(siteResult) ausschliessen da sonst die Hoster gekickt werden.
if siteName.startswith('dummy'): #Falls Servernamen im VOD sich ändern, hier vod_ eintragen
hosters = siteResult
else:
hosters = self.__getPriorities(siteResult)
if not hosters:
self.dialog.close()
cGui().showInfo('xStream', cConfig().getLocalizedString(30144))
return False
if len(siteResult) > self.maxHoster:
siteResult = siteResult[:self.maxHoster - 1]
check = False
self.dialog.create('xStream', cConfig().getLocalizedString(30147))
total = len(hosters)
for count, hoster in enumerate(hosters):
if self.dialog.iscanceled() or xbmc.Monitor().abortRequested() or check: return
percent = (count + 1) * 100 // total
try:
logger.info('-> [hoster]: try hoster %s' % hoster['name'])
self.dialog.create('xStream', cConfig().getLocalizedString(30147))
self.dialog.update(percent, cConfig().getLocalizedString(30147) + ' %s' % hoster['name'])
# get stream links
function = getattr(plugin, functionName)
siteResult = function(hoster['link'])
check = self.__autoEnqueue(siteResult, playMode)
if check:
return True
except:
self.dialog.update(percent, cConfig().getLocalizedString(30148) % hoster['name'])
logger.error('-> [hoster]: playback with hoster %s failed' % hoster['name'])
# field "resolved" marks streamlinks
elif 'resolved' in siteResult[0]:
for stream in siteResult:
try:
if self.__autoEnqueue(siteResult, playMode):
self.dialog.close()
return True
except:
pass
def _chooseHoster(self, siteResult):
dialog = xbmcgui.Dialog()
titles = []
for result in siteResult:
if 'displayedName' in result:
titles.append(str(result['displayedName']))
else:
titles.append(str(result['name']))
index = dialog.select(cConfig().getLocalizedString(30149), titles)
if index > -1:
siteResult = siteResult[index]
return siteResult
else:
logger.info('-> [hoster]: no hoster selected')
return False
def _choosePart(self, siteResult):
self.dialog = xbmcgui.Dialog()
titles = []
for result in siteResult:
titles.append(str(result['title']))
index = self.dialog.select(cConfig().getLocalizedString(30150), titles)
if index > -1:
siteResult = siteResult[index]
return siteResult
else:
return False
def showHosterFolder(self, siteResult, siteName, functionName):
oGui = cGui()
total = len(siteResult)
params = ParameterHandler()
for hoster in siteResult:
if 'displayedName' in hoster:
name = hoster['displayedName']
else:
name = hoster['name']
oGuiElement = cGuiElement(name, siteName, functionName)
oGuiElement.setThumbnail(str(params.getValue('thumb')))
params.setParam('url', hoster['link'])
params.setParam('isHoster', 'true')
oGui.addFolder(oGuiElement, params, iTotal=total, isHoster=True)
oGui.setEndOfDirectory()
def __autoEnqueue(self, partList, playMode):
# choose part
if not partList:
return False
for i in range(len(partList) - 1, -1, -1):
try:
if playMode == 'play' and i == 0:
if not self.play(partList[i]):
return False
elif playMode == 'download':
self.download(partList[i])
elif playMode == 'enqueue' or (playMode == 'play' and i > 0):
self.addToPlaylist(partList[i])
except:
return False
logger.info('-> [hoster]: autoEnqueue successful')
return True
class Hoster:
def __init__(self, name, link):
self.name = name
self.link = link

View File

@@ -0,0 +1,62 @@
# -*- coding: utf-8 -*-
# Python 3
import sys
from urllib.parse import parse_qsl, urlsplit, unquote_plus, urlencode
class ParameterHandler:
def __init__(self):
params = dict()
if len(sys.argv) >= 3 and len(sys.argv[2]) > 0:
params = dict(parse_qsl(urlsplit(sys.argv[2]).query))
self.__params = params
def getAllParameters(self):
# returns all parameters as dictionary
return self.__params
def getValue(self, paramName):
# returns value of one parameter as string, if parameter does not exists "False" is returned
if self.exist(paramName):
return self.__params[paramName]
# paramValue = self.__params[paramName]
# return unquote_plus(paramValue)
return False
def exist(self, paramName):
# checks if paramter with the name "paramName" exists
return paramName in self.__params
def setParam(self, paramName, paramValue):
# set the value of the parameter with the name "paramName" to "paramValue"
# if there is no such parameter, the parameter is created
# if no value is given "paramValue" is set "None"
paramValue = str(paramValue)
self.__params.update({paramName: paramValue})
def addParams(self, paramDict):
# adds a whole dictionary {key1:value1,...,keyN:valueN} of parameters to the ParameterHandler
# existing parameters are updated
for key, value in paramDict.items():
self.__params.update({key: str(value)})
def getParameterAsUri(self):
outParams = dict()
if 'params' in self.__params:
del self.__params['params']
if 'function' in self.__params:
del self.__params['function']
if 'title' in self.__params:
del self.__params['title']
if 'site' in self.__params:
del self.__params['site']
if len(self.__params) > 0:
for param in self.__params:
if len(self.__params[param]) < 1:
continue
outParams[param] = unquote_plus(self.__params[param])
return urlencode(outParams)
return 'params=0'

View File

@@ -0,0 +1,69 @@
# -*- coding: utf-8 -*-
# Python 3
import re
from resources.lib.config import cConfig
from resources.lib.gui.gui import cGui
from xbmc import LOGINFO as LOGNOTICE, log
from urllib.request import Request, urlopen
from urllib.parse import urlencode
class cJDownloader2Handler:
def sendToJDownloader2(self, sUrl):
if self.__checkConfig() is False:
cGui().showError(cConfig().getLocalizedString(30076), cConfig().getLocalizedString(30254), 5)
return False
if self.__checkConnection() is False:
cGui().showError(cConfig().getLocalizedString(30076), cConfig().getLocalizedString(30255), 5)
return False
if self.__download(sUrl) is True:
cGui().showInfo(cConfig().getLocalizedString(30076), cConfig().getLocalizedString(30256), 5)
return True
return False
def __client(self, path, params):
sHost = self.__getHost()
sPort = self.__getPort()
ENCODING = 'utf-8'
url = 'http://{}:{}/{}'.format(sHost, sPort, path)
if params is not None:
headers = {'Content-Type': 'application/x-www-form-urlencoded;charset={}'.format(ENCODING), }
request = Request(url, urlencode(params).encode(ENCODING), headers)
else:
request = Request(url)
return urlopen(request).read().decode(ENCODING).strip()
def __download(self, sFileUrl):
log(cConfig().getLocalizedString(30166) + ' -> [jdownloader2Handler]: JD2 Link: ' + str(sFileUrl), LOGNOTICE)
params = {'passwords': 'myPassword', 'source': 'http://jdownloader.org/spielwiese', 'urls': sFileUrl, 'submit': 'Add Link to JDownloader', }
if self.__client('flash/add', params).lower() == 'success':
return True
else:
return False
def __checkConfig(self):
log(cConfig().getLocalizedString(30166) + ' -> [jdownloader2Handler]: check JD2 Addon settings', LOGNOTICE)
bEnabled = cConfig().getSetting('jd2_enabled')
if bEnabled == 'true':
return True
return False
def __getHost(self):
return cConfig().getSetting('jd2_host')
def __getPort(self):
return cConfig().getSetting('jd2_port')
def __checkConnection(self):
log(cConfig().getLocalizedString(30166) + ' -> [jdownloader2Handler]: check JD2 Connection', LOGNOTICE)
try:
output = self.__client('jdcheck.js', None)
pattern = re.compile(r'jdownloader\s*=\s*true', re.IGNORECASE)
if pattern.search(output) != None:
return True
except Exception:
return False
return False

View File

@@ -0,0 +1,82 @@
# -*- coding: utf-8 -*-
# Python 3
from xbmc import LOGINFO as LOGNOTICE, log
from resources.lib.config import cConfig
from resources.lib.gui.gui import cGui
from resources.lib.handler.requestHandler import cRequestHandler
class cJDownloaderHandler:
def sendToJDownloader(self, sUrl):
if self.__checkConfig() == False:
cGui().showError(cConfig().getLocalizedString(30070), cConfig().getLocalizedString(30254), 5)
return False
if self.__checkConnection() == False:
cGui().showError(cConfig().getLocalizedString(30070), cConfig().getLocalizedString(30255), 5)
return False
bDownload = self.__download(sUrl)
if bDownload == True:
cGui().showInfo(cConfig().getLocalizedString(30070), cConfig().getLocalizedString(30256), 5)
def __checkConfig(self):
log(cConfig().getLocalizedString(30166) + ' -> [jdownloaderHandler]: check JD Addon settings', LOGNOTICE)
bEnabled = cConfig().getSetting('jd_enabled')
if bEnabled == 'true':
return True
return False
def __getHost(self):
return cConfig().getSetting('jd_host')
def __getPort(self):
return cConfig().getSetting('jd_port')
def __getAutomaticStart(self):
bAutomaticStart = cConfig().getSetting('jd_automatic_start')
if bAutomaticStart == 'true':
return True
return False
def __getLinkGrabber(self):
bAutomaticStart = cConfig().getSetting('jd_grabber')
if bAutomaticStart == 'true':
return True
return False
def __download(self, sFileUrl):
sHost = self.__getHost()
sPort = self.__getPort()
bAutomaticDownload = self.__getAutomaticStart()
bLinkGrabber = self.__getLinkGrabber()
sLinkForJd = self.__createJDUrl(sFileUrl, sHost, sPort, bAutomaticDownload, bLinkGrabber)
log(cConfig().getLocalizedString(30166) + ' -> [jdownloaderHandler]: JD Link: ' + str(sLinkForJd), LOGNOTICE)
oRequestHandler = cRequestHandler(sLinkForJd)
oRequestHandler.request()
return True
def __createJDUrl(self, sFileUrl, sHost, sPort, bAutomaticDownload, bLinkGrabber):
sGrabber = '0'
if bLinkGrabber == True:
sGrabber = '1'
sAutomaticStart = '0'
if bAutomaticDownload == True:
sAutomaticStart = '1'
sUrl = 'http://' + str(sHost) + ':' + str(sPort) + '/action/add/links/grabber' + str(sGrabber) + '/start' + str(
sAutomaticStart) + '/' + sFileUrl
return sUrl
def __checkConnection(self):
log(cConfig().getLocalizedString(30166) + ' -> [jdownloaderHandler]: check JD Connection', LOGNOTICE)
sHost = self.__getHost()
sPort = self.__getPort()
sLinkForJd = 'http://' + str(sHost) + ':' + str(sPort)
try:
oRequestHandler = cRequestHandler(sLinkForJd)
oRequestHandler.request()
return True
except Exception as e:
return False
return False

View File

@@ -0,0 +1,452 @@
# -*- coding: utf-8 -*-
# Python 3
import hashlib
import hmac
import json
import time
import base64
import requests
try:
from resources.lib import pyaes
except ImportError:
try:
import pyaes
except ImportError:
from resolveurl.lib import pyaes
from urllib.parse import quote
class MYJDException(BaseException):
pass
def PAD(s):
BS = 16
try:
return s + ((BS - len(s) % BS) * chr(BS - len(s) % BS)).encode()
except Exception:
return s + (BS - len(s) % BS) * chr(BS - len(s) % BS)
def UNPAD(s):
try:
return s[0:-s[-1]]
except Exception:
return s[0:-ord(s[-1])]
class System:
def __init__(self, device):
self.device = device
self.url = '/system'
def exit_jd(self):
return self.device.action(self.url + "/exitJD")
def restart_jd(self):
return self.device.action(self.url + "/restartJD")
def hibernate_os(self):
return self.device.action(self.url + "/hibernateOS")
def shutdown_os(self, force):
return self.device.action(self.url + "/shutdownOS", force)
def standby_os(self):
return self.device.action(self.url + "/standbyOS")
class Update:
def __init__(self, device):
self.device = device
self.url = '/update'
def restart_and_update(self):
return self.device.action(self.url + "/restartAndUpdate")
def run_update_check(self):
return self.device.action(self.url + "/runUpdateCheck")
def is_update_available(self):
return self.device.action(self.url + "/isUpdateAvailable")
class DownloadController:
def __init__(self, device):
self.device = device
self.url = '/downloadcontroller'
def start_downloads(self):
return self.device.action(self.url + "/start")
def stop_downloads(self):
return self.device.action(self.url + "/stop")
def pause_downloads(self, value):
params = [value]
return self.device.action(self.url + "/pause", params)
def get_speed_in_bytes(self):
return self.device.action(self.url + "/getSpeedInBps")
def force_download(self, link_ids, package_ids):
params = [link_ids, package_ids]
return self.device.action(self.url + "/forceDownload", params)
def get_current_state(self):
return self.device.action(self.url + "/getCurrentState")
class Linkgrabber:
def __init__(self, device):
self.device = device
self.url = '/linkgrabberv2'
def clear_list(self):
return self.device.action(self.url + "/clearList", http_action="POST")
def move_to_downloadlist(self, links_ids, packages_ids):
params = [links_ids, packages_ids]
return self.device.action(self.url + "/moveToDownloadlist", params)
def query_links(self, params=[
{"bytesTotal": True,
"comment": True,
"status": True,
"enabled": True,
"maxResults": -1,
"startAt": 0,
"hosts": True,
"url": True,
"availability": True,
"variantIcon": True,
"variantName": True,
"variantID": True,
"variants": True,
"priority": True}]):
return self.device.action(self.url + "/queryLinks", params)
def cleanup(self, action, mode, selection_type, links_ids=[], packages_ids=[]):
params = [links_ids, packages_ids]
params += [action, mode, selection_type]
return self.device.action(self.url + "/cleanup", params)
def add_container(self, type_, content):
params = [type_, content]
return self.device.action(self.url + "/addContainer", params)
def get_download_urls(self, links_ids, packages_ids, url_display_type):
params = [packages_ids, links_ids, url_display_type]
return self.device.action(self.url + "/getDownloadUrls", params)
def set_priority(self, priority, links_ids, packages_ids):
params = [priority, links_ids, packages_ids]
return self.device.action(self.url + "/setPriority", params)
def set_enabled(self, params):
return self.device.action(self.url + "/setEnabled", params)
def get_variants(self, params):
return self.device.action(self.url + "/getVariants", params)
def add_links(self, params=[
{"autostart": False,
"links": None,
"packageName": None,
"extractPassword": None,
"priority": "DEFAULT",
"downloadPassword": None,
"destinationFolder": None,
"overwritePackagizerRules": False}]):
return self.device.action("/linkgrabberv2/addLinks", params)
def get_childrenchanged(self):
pass
def remove_links(self):
pass
def get_downfolderhistoryselectbase(self):
pass
def help(self):
return self.device.action("/linkgrabberv2/help", http_action="GET")
def rename_link(self):
pass
def move_links(self):
pass
def set_variant(self):
pass
def get_package_count(self):
pass
def rename_package(self):
pass
def query_packages(self):
pass
def move_packages(self):
pass
def add_variant_copy(self):
pass
class Downloads:
def __init__(self, device):
self.device = device
self.url = "/downloadsV2"
def query_links(self, params=[
{"bytesTotal": True,
"comment": True,
"status": True,
"enabled": True,
"maxResults": -1,
"startAt": 0,
"packageUUIDs": [],
"host": True,
"url": True,
"bytesloaded": True,
"speed": True,
"eta": True,
"finished": True,
"priority": True,
"running": True,
"skipped": True,
"extractionStatus": True}]):
return self.device.action(self.url + "/queryLinks", params)
def query_packages(self, params=[
{"bytesLoaded": True,
"bytesTotal": True,
"comment": True,
"enabled": True,
"eta": True,
"priority": True,
"finished": True,
"running": True,
"speed": True,
"status": True,
"childCount": True,
"hosts": True,
"saveTo": True,
"maxResults": -1,
"startAt": 0}]):
return self.device.action(self.url + "/queryPackages", params)
def cleanup(self, action, mode, selection_type, links_ids=[], packages_ids=[]):
params = [links_ids, packages_ids]
params += [action, mode, selection_type]
return self.device.action(self.url + "/cleanup", params)
class Jddevice:
def __init__(self, jd, device_dict):
self.name = device_dict["name"]
self.device_id = device_dict["id"]
self.device_type = device_dict["type"]
self.myjd = jd
self.linkgrabber = Linkgrabber(self)
self.downloads = Downloads(self)
self.downloadcontroller = DownloadController(self)
self.update = Update(self)
self.system = System(self)
def action(self, path, params=(), http_action="POST"):
action_url = self.__action_url()
response = self.myjd.request_api(path, http_action, params, action_url)
if response is None:
return False
return response['data']
def __action_url(self):
return "/t_" + self.myjd.get_session_token() + "_" + self.device_id
class Myjdapi:
def __init__(self):
self.__request_id = int(time.time() * 1000)
self.__api_url = "http://api.jdownloader.org"
self.__app_key = "http://git.io/vmcsk"
self.__api_version = 1
self.__devices = None
self.__login_secret = None
self.__device_secret = None
self.__session_token = None
self.__regain_token = None
self.__server_encryption_token = None
self.__device_encryption_token = None
self.__connected = False
def get_session_token(self):
return self.__session_token
def is_connected(self):
return self.__connected
def set_app_key(self, app_key):
self.__app_key = app_key
def __secret_create(self, email, password, domain):
secret_hash = hashlib.sha256()
secret_hash.update(email.lower().encode('utf-8') + password.encode('utf-8') + domain.lower().encode('utf-8'))
return secret_hash.digest()
def __update_encryption_tokens(self):
if self.__server_encryption_token is None:
old_token = self.__login_secret
else:
old_token = self.__server_encryption_token
new_token = hashlib.sha256()
new_token.update(old_token + bytearray.fromhex(self.__session_token))
self.__server_encryption_token = new_token.digest()
new_token = hashlib.sha256()
new_token.update(self.__device_secret + bytearray.fromhex(self.__session_token))
self.__device_encryption_token = new_token.digest()
def __signature_create(self, key, data):
signature = hmac.new(key, data.encode('utf-8'), hashlib.sha256)
return signature.hexdigest()
def __decrypt(self, secret_token, data):
init_vector = secret_token[:len(secret_token) // 2]
key = secret_token[len(secret_token) // 2:]
decryptor = pyaes.Decrypter(pyaes.AESModeOfOperationCBC(key, init_vector))
decrypted_data = decryptor.feed(base64.b64decode(data))
decrypted_data += decryptor.feed()
return decrypted_data
def __encrypt(self, secret_token, data):
init_vector = secret_token[:len(secret_token) // 2]
key = secret_token[len(secret_token) // 2:]
encryptor = pyaes.Encrypter(pyaes.AESModeOfOperationCBC(key, init_vector))
encrypted_data = encryptor.feed(data)
encrypted_data += encryptor.feed()
encrypted_data = base64.b64encode(encrypted_data)
return encrypted_data.decode('utf-8')
def update_request_id(self):
self.__request_id = int(time.time())
def connect(self, email, password):
self.__login_secret = self.__secret_create(email, password, "server")
self.__device_secret = self.__secret_create(email, password, "device")
response = self.request_api("/my/connect", "GET", [("email", email), ("appkey", self.__app_key)])
self.__connected = True
self.update_request_id()
self.__session_token = response["sessiontoken"]
self.__regain_token = response["regaintoken"]
self.__update_encryption_tokens()
self.update_devices()
def reconnect(self):
response = self.request_api("/my/reconnect", "GET", [("sessiontoken", self.__session_token), ("regaintoken", self.__regain_token)])
self.update_request_id()
self.__session_token = response["sessiontoken"]
self.__regain_token = response["regaintoken"]
self.__update_encryption_tokens()
def disconnect(self):
self.request_api("/my/disconnect", "GET", [("sessiontoken", self.__session_token)])
self.update_request_id()
self.__login_secret = None
self.__device_secret = None
self.__session_token = None
self.__regain_token = None
self.__server_encryption_token = None
self.__device_encryption_token = None
self.__devices = None
self.__connected = False
def update_devices(self):
response = self.request_api("/my/listdevices", "GET", [("sessiontoken", self.__session_token)])
self.update_request_id()
self.__devices = response["list"]
def list_devices(self):
return self.__devices
def get_device(self, device_name=None, device_id=None):
if not self.is_connected():
raise (MYJDException("No connection established\n"))
if device_id is not None:
for device in self.__devices:
if device["id"] == device_id:
return Jddevice(self, device)
elif device_name is not None:
for device in self.__devices:
if device["name"] == device_name:
return Jddevice(self, device)
raise MYJDException("Device not found\n")
def request_api(self, path, http_method="GET", params=None, action=None):
data = None
if not self.is_connected() and path != "/my/connect":
raise (MYJDException("No connection established\n"))
if http_method == "GET":
query = [path + "?"]
for param in params:
if param[0] != "encryptedLoginSecret":
query += ["%s=%s" % (param[0], quote(param[1]))]
else:
query += ["&%s=%s" % (param[0], param[1])]
query += ["rid=" + str(self.__request_id)]
if self.__server_encryption_token is None:
query += ["signature=" + str(self.__signature_create(self.__login_secret, query[0] + "&".join(query[1:])))]
else:
query += ["signature=" + str(self.__signature_create(self.__server_encryption_token, query[0] + "&".join(query[1:])))]
query = query[0] + "&".join(query[1:])
encrypted_response = requests.get(self.__api_url + query)
else:
params_request = []
for param in params:
if not isinstance(param, list):
params_request += [json.dumps(param)]
else:
params_request += [param]
params_request = {"apiVer": self.__api_version, "url": path, "params": params_request, "rid": self.__request_id}
data = json.dumps(params_request).replace('"null"', "null").replace("'null'", "null")
encrypted_data = self.__encrypt(self.__device_encryption_token, data)
if action is not None:
request_url = self.__api_url + action + path
else:
request_url = self.__api_url + path
encrypted_response = requests.post(request_url, headers={"Content-Type": "application/aesjson-jd; charset=utf-8"}, data=encrypted_data)
if encrypted_response.status_code != 200:
error_msg = json.loads(encrypted_response.text)
msg = "\n\tSOURCE: " + error_msg["src"] + "\n\tTYPE: " + \
error_msg["type"] + "\n------\nREQUEST_URL: " + \
self.__api_url + path
if http_method == "GET":
msg += query
msg += "\n"
if data is not None:
msg += "DATA:\n" + data
raise (MYJDException(msg))
if action is None:
if not self.__server_encryption_token:
response = self.__decrypt(self.__login_secret, encrypted_response.text)
else:
response = self.__decrypt(self.__server_encryption_token, encrypted_response.text)
else:
if params is not None:
response = self.__decrypt(self.__device_encryption_token, encrypted_response.text)
else:
return {"data": response}
jsondata = json.loads(response.decode('utf-8'))
if jsondata['rid'] != self.__request_id:
self.update_request_id()
return None
self.update_request_id()
return jsondata

Some files were not shown because too many files have changed in this diff Show More