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).
716
nextcloud-import/Botomir-Status.md
Normal 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 Let’s-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.
|
||||
@@ -0,0 +1,3 @@
|
||||
# Statusentscheidung
|
||||
|
||||
Das Kino-Projekt ist pausiert und hat aktuell niedrige Priorität. Keine Dienste ändern.
|
||||
632
nextcloud-import/Plan-Botomir.md
Normal 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: Let’s 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.
|
||||
7
nextcloud-import/Projektbeschreibung.md
Normal 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.
|
||||
29
nextcloud-import/README.md
Normal 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.
|
||||
49
nextcloud-import/backend-nextcloud-variant/app/config.py
Normal 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()
|
||||
22
nextcloud-import/backend-nextcloud-variant/app/db.py
Normal 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
|
||||
149
nextcloud-import/backend-nextcloud-variant/app/main.py
Normal 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")
|
||||
30
nextcloud-import/backend-nextcloud-variant/app/models.py
Normal 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)
|
||||
@@ -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: ...
|
||||
@@ -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
|
||||
@@ -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}")
|
||||
@@ -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]))
|
||||
53
nextcloud-import/backend-nextcloud-variant/app/schemas.py
Normal 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
|
||||
112
nextcloud-import/backend-nextcloud-variant/app/services/auth.py
Normal 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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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}")
|
||||
132
nextcloud-import/backend-nextcloud-variant/app/web/app.js
Normal 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) => ({'&': '&', '<': '<', '>': '>', "'": ''', '"': '"'}[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));
|
||||
@@ -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>
|
||||
14
nextcloud-import/backend-nextcloud-variant/app/web/style.css
Normal 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; }
|
||||
21
nextcloud-import/backend-nextcloud-variant/pyproject.toml
Normal 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"]
|
||||
11
nextcloud-import/backend-nextcloud-variant/tests/conftest.py
Normal 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:")
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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'}
|
||||
@@ -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")
|
||||
@@ -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())
|
||||
@@ -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')
|
||||
@@ -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'
|
||||
@@ -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')
|
||||
@@ -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
|
||||
@@ -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')
|
||||
@@ -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'
|
||||
19
nextcloud-import/docs/deployment.md
Normal 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.
|
||||
11
nextcloud-import/docs/provider-policy.md
Normal 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.
|
||||
76
nextcloud-import/plugin.video.xstream/ScraperInfo.txt
Normal 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)
|
||||
35
nextcloud-import/plugin.video.xstream/addon.xml
Normal 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>
|
||||
40
nextcloud-import/plugin.video.xstream/default.py
Normal 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()
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
|
After Width: | Height: | Size: 51 KiB |
BIN
nextcloud-import/plugin.video.xstream/resources/art/no_cover.png
Normal file
|
After Width: | Height: | Size: 119 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 57 KiB |
BIN
nextcloud-import/plugin.video.xstream/resources/art/search.png
Normal file
|
After Width: | Height: | Size: 62 KiB |
BIN
nextcloud-import/plugin.video.xstream/resources/art/settings.png
Normal file
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 252 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 176 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 145 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 135 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 76 KiB |
BIN
nextcloud-import/plugin.video.xstream/resources/art/vod.png
Normal file
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 61 KiB |
BIN
nextcloud-import/plugin.video.xstream/resources/banner.png
Normal file
|
After Width: | Height: | Size: 72 KiB |
BIN
nextcloud-import/plugin.video.xstream/resources/clearlogo.png
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
nextcloud-import/plugin.video.xstream/resources/fanart.jpg
Normal file
|
After Width: | Height: | Size: 86 KiB |
BIN
nextcloud-import/plugin.video.xstream/resources/icon.png
Normal file
|
After Width: | Height: | Size: 47 KiB |
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
127
nextcloud-import/plugin.video.xstream/resources/lib/download.py
Normal 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')
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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
|
||||
418
nextcloud-import/plugin.video.xstream/resources/lib/gui/gui.py
Normal 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')))
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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'
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||