Initial commit to Gitea
This commit is contained in:
@@ -13,3 +13,9 @@ venv/
|
|||||||
*.swp
|
*.swp
|
||||||
*.swo
|
*.swo
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
|
# Cache & Session-Dateien
|
||||||
|
cache/
|
||||||
|
cookies/
|
||||||
|
htmlcache/
|
||||||
|
downloads/
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ Web-basierter Downloader für Streaming-Seiten. Aus xStream (Kodi Addon) extrahi
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd xstreamDownloader
|
cd xstreamDownloader
|
||||||
python3 mini_app.py
|
python3 app.py
|
||||||
# → http://localhost:8765
|
# → http://localhost:8765
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -21,13 +21,14 @@ python3 mini_app.py
|
|||||||
|
|
||||||
```
|
```
|
||||||
xstreamDownloader/
|
xstreamDownloader/
|
||||||
├── mini_app.py # Flask-Server (API + Web-Interface)
|
├── app.py # Flask-Server (API + Web-Interface, XBMC-konformer Wrapper)
|
||||||
├── web/index.html # Single-Page Frontend (Vanilla JS)
|
├── web/index.html # Single-Page Frontend (Vanilla JS)
|
||||||
├── xstream/
|
├── xstream/
|
||||||
│ ├── sites/ # Site-Plugins (eines pro Streaming-Seite)
|
│ ├── sites/ # Site-Plugins (eines pro Streaming-Seite)
|
||||||
│ └── resources/lib/ # xStream Core (GUI, Player, Handler)
|
│ └── resources/lib/ # xStream Core (GUI, Player, Handler)
|
||||||
├── cookies/ # Session-Cookies für authentifizierte Sites
|
├── deps/ # Abhängigkeiten (resolveurl, requests, six, kodi_six, pyaes)
|
||||||
└── resources/ # Kodi settings.xml
|
├── cookies/ # Session-Cookies für authentifizierte Sites
|
||||||
|
└── downloads/ # Heruntergeladene Dateien
|
||||||
```
|
```
|
||||||
|
|
||||||
## API Endpoints
|
## API Endpoints
|
||||||
@@ -36,12 +37,16 @@ xstreamDownloader/
|
|||||||
|----------|--------------|
|
|----------|--------------|
|
||||||
| `GET /api/sites` | Liste aller Sites |
|
| `GET /api/sites` | Liste aller Sites |
|
||||||
| `GET /api/sites/<id>/entries` | Einträge einer Site |
|
| `GET /api/sites/<id>/entries` | Einträge einer Site |
|
||||||
| `GET /api/sites/<id>/hoster/<entry_id>` | Hoster für einen Eintrag |
|
| `GET /api/sites/<id>/search?q=...` | Suche auf einer Site |
|
||||||
| `POST /api/download` | Download starten |
|
| `GET /api/sites/<id>/hosters?url=...` | Hoster für einen Eintrag |
|
||||||
|
| `GET /api/resolve?url=...` | URL auflösen |
|
||||||
|
| `POST /api/download?url=...&title=...` | Download starten |
|
||||||
| `GET /api/downloads` | Download-Status |
|
| `GET /api/downloads` | Download-Status |
|
||||||
|
| `GET /api/downloads/<id>/file` | Download-Datei herunterladen |
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
- **Backend**: Python 3, Flask
|
- **Backend**: Python 3, Flask, Multiprocessing
|
||||||
- **Frontend**: Vanilla JS, CSS Grid
|
- **Frontend**: Vanilla JS, CSS Grid
|
||||||
- **Scraping**: xbmcjson/xStream Core (aus Kodi Addon extrahiert)
|
- **Scraping**: xstream Core (aus Kodi Addon extrahiert)
|
||||||
|
- **XBMC-Wrapper**: Mock-Module für xbmc/xbmcgui/xbmcplugin/xbmcvfs/xbmcaddon
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
# xStream Downloader – Projektstatus
|
||||||
|
|
||||||
|
Stand: 6.7.2026, 22:25
|
||||||
|
|
||||||
|
## Ziel
|
||||||
|
Web-Wrapper für das xstream Kodi-Addon, mit dem man Filme suchen, finden
|
||||||
|
und herunterladen kann – direkt im Browser, ohne Kodi.
|
||||||
|
|
||||||
|
## Architektur-Entscheidung
|
||||||
|
- **Option A**: Web-Wrapper mit XBMC-konformen Mock-Modulen
|
||||||
|
- Vorteil: läuft ohne Kodi-Installation, Downloads landen direkt als Datei
|
||||||
|
- Die XBMC-Architektur wurde aus `/home/matthiasberner/Dokumente/git/xbmc` analysiert
|
||||||
|
|
||||||
|
## Dateistruktur
|
||||||
|
|
||||||
|
```
|
||||||
|
xstreamDownloader/
|
||||||
|
├── app.py ← AKTIVER Wrapper-App (XBMC-konform, ~800 Zeilen)
|
||||||
|
├── web/index.html ← Frontend (Sidebar, Search, Grid, Downloads)
|
||||||
|
├── xstream/sites/ ← 18 Site-Plugins
|
||||||
|
├── xstream/resources/lib/ ← xstream Core
|
||||||
|
├── deps/
|
||||||
|
│ ├── resolveurl/
|
||||||
|
│ ├── requests/
|
||||||
|
│ ├── six/
|
||||||
|
│ ├── kodi_six/
|
||||||
|
│ └── pyaes/
|
||||||
|
├── cookies/
|
||||||
|
└── downloads/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Was funktioniert
|
||||||
|
|
||||||
|
1. **Flask-Server startet** auf http://localhost:8765
|
||||||
|
2. **18 Sites** werden erkannt und geladen
|
||||||
|
3. **XBMC-Mock-Module** für xbmc, xbmcgui, xbmcplugin, xbmcvfs, xbmcaddon
|
||||||
|
4. **Subprozess-Worker** mit eigenem sys.argv[1] = '1' (handle)
|
||||||
|
5. **xbmcplugin-Collector** thread-lokal (addDirectoryItem/endOfDirectory)
|
||||||
|
6. **xbmc.addon Version 20.3** – verhindert float('3.8.')-Crash in resolveurl
|
||||||
|
7. **Deutsche xStream-Language-Map** – Menu-Titel in Deutsch statt `[30813]`
|
||||||
|
8. **Plugin-URL-Generierung** – sys.argv[0] als reiner Pfad (keine doppelten `?`)
|
||||||
|
9. **URL-Parsing** – api_subentries parst plugin:// URLs und ruft die richtige Funktion auf
|
||||||
|
10. **API-Routen** alle vorhanden:
|
||||||
|
- /api/sites
|
||||||
|
- /api/sites/ID/entries
|
||||||
|
- /api/sites/ID/entries/PLUGIN_URL (parst function/site/params aus URL)
|
||||||
|
- /api/sites/ID/search?q=...
|
||||||
|
- /api/sites/ID/hosters?url=...
|
||||||
|
- /api/resolve?url=...
|
||||||
|
- /api/download?url=...&title=...
|
||||||
|
- /api/downloads[/ID][/file]
|
||||||
|
11. **Download-Manager** mit Fortschritt 0-100%
|
||||||
|
|
||||||
|
## Navigation (Menü → Inhalte)
|
||||||
|
|
||||||
|
Die Site-Plugins zeigen Menüpunkte (Neues, Filme, Serien, etc.) auf Deutsch.
|
||||||
|
Beim Klick auf einen Menüpunkt:
|
||||||
|
1. URL wird geparst: `plugin://...?site=aniworld&function=showEntries&sUrl=...`
|
||||||
|
2. Richtige Funktion (`showEntries`, `showNeues`, `showValue`, etc.) wird aufgerufen
|
||||||
|
3. Echte Filme/Serien werden geladen (getestet: AniWorld 45 Titel, HD Filme 22 Titel)
|
||||||
|
|
||||||
|
## Gelöste Bugs
|
||||||
|
|
||||||
|
| # | Bug | Fix | Datum |
|
||||||
|
|---|-----|-----|-------|
|
||||||
|
| 1 | SyntaxError `def setProperty` | sed-Korrektur | - |
|
||||||
|
| 2 | KeyError 'resources.lib.config' | Stubs nur im Worker | - |
|
||||||
|
| 3 | IndentationError in _CollectingGui.addFolder | Komplett neuer Collector | - |
|
||||||
|
| 4 | float('3.8.') Crash | xbmc.addon Version '20.3' | - |
|
||||||
|
| 5 | pyaes NOT in sys.path | deps/pyaes und deps/pyaes/lib | - |
|
||||||
|
| 6 | AttributeError 'xbmc' has no 'LOGERROR' | LOGERROR + LOGSEVERE in _make_xbmc() | 6.7.2026 |
|
||||||
|
| 7 | ModuleNotFoundError 'resources.lib' in Subprozess | BASE zum sys.path im Worker | 6.7.2026 |
|
||||||
|
| 8 | Menu-Titel als `[30813]` statt Deutsch | Deutsche Language-Map in getLocalizedString | 6.7.2026 |
|
||||||
|
| 9 | Doppelte `?` in plugin:// URLs | sys.argv[0] = reiner Plugin-Pfad | 6.7.2026 |
|
||||||
|
| 10 | Navigation: immer `showEntries` statt spezifischer Funktion | _parse_plugin_url() in api_subentries | 6.7.2026 |
|
||||||
|
| 11 | Such-API-Mismatch | Frontend: `/search?q=` statt `/entries?search=` | 6.7.2026 |
|
||||||
|
| 12 | Hoster-API-Mismatch | `?url=` statt `?entryUrl=` | 6.7.2026 |
|
||||||
|
| 13 | Download-Flow falsch | Resolve → Download → Poll statt `/resolve?download=true` | 6.7.2026 |
|
||||||
|
|
||||||
|
## Verbleibende Aufgaben
|
||||||
|
|
||||||
|
- [ ] Browser-MCP Debugging (im Browser testen)
|
||||||
|
- [ ] Hoster-Flow E2E testen (Eintrag → Hosters → Download)
|
||||||
|
- [ ] Download E2E testen (Resolve → Download → File)
|
||||||
|
- [ ] Such-Qualität verbessern (manche Sites liefern Haupteinträge statt Suchergebnisse)
|
||||||
|
- [ ] fix_and_run.sh auf app.py umstellen (bereits erledigt)
|
||||||
|
- [ ] mini_app.py wurde am 6.7.2026 gelöscht
|
||||||
|
|
||||||
|
## Starten
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/matthiasberner/Schreibtisch/kodi/xstreamDownloader
|
||||||
|
python3 app.py
|
||||||
|
# → http://localhost:8765
|
||||||
|
```
|
||||||
|
|
||||||
|
## Bekannte Probleme
|
||||||
|
|
||||||
|
- Port 8765 kann von Zombie-Prozessen belegt sein → mit `pkill -9 -f "python3 app.py"` beheben
|
||||||
|
- Manche Site-Suchfunktionen geben Haupteinträge statt Suchergebnisse zurück
|
||||||
|
- Manche Sites (z.B. serienstream A-Z) können 500er Timeout-Fehler werfen
|
||||||
+2
-22
@@ -19,33 +19,13 @@ else:
|
|||||||
print('requestHandler already fixed')
|
print('requestHandler already fixed')
|
||||||
"
|
"
|
||||||
|
|
||||||
# 3. Fix mini_app.py - collecting_gui passthrough + openSettings
|
# 3. mini_app.py wurde am 6.7.2026 gelöscht — app.py ist der aktive Server
|
||||||
python3 -c "
|
|
||||||
with open('$BASE/mini_app.py') as f:
|
|
||||||
c = f.read()
|
|
||||||
|
|
||||||
# Fix collecting_gui=False -> collecting_gui
|
|
||||||
if 'site_mod.load(collecting_gui=False)' in c:
|
|
||||||
c = c.replace('site_mod.load(collecting_gui=False)', 'site_mod.load(collecting_gui=collecting_gui)')
|
|
||||||
print('Fixed collecting_gui passthrough')
|
|
||||||
else:
|
|
||||||
print('collecting_gui already fixed')
|
|
||||||
|
|
||||||
# Add openSettings to _MockAddon
|
|
||||||
if 'def openSettings' not in c:
|
|
||||||
c = c.replace(' def getAddonInfo(self, key):',
|
|
||||||
' def openSettings(self, *args, **kwargs):\n pass\n\n def getAddonInfo(self, key):')
|
|
||||||
print('Added openSettings')
|
|
||||||
|
|
||||||
with open('$BASE/mini_app.py', 'w') as f:
|
|
||||||
f.write(c)
|
|
||||||
"
|
|
||||||
|
|
||||||
# 4. Kill old server, start new
|
# 4. Kill old server, start new
|
||||||
fuser -k 8765/tcp 2>/dev/null
|
fuser -k 8765/tcp 2>/dev/null
|
||||||
sleep 1
|
sleep 1
|
||||||
cd "$BASE"
|
cd "$BASE"
|
||||||
python3 mini_app.py > /tmp/server.log 2>&1 &
|
python3 app.py > /tmp/server.log 2>&1 &
|
||||||
SERVER_PID=$!
|
SERVER_PID=$!
|
||||||
sleep 3
|
sleep 3
|
||||||
|
|
||||||
|
|||||||
+2360
File diff suppressed because one or more lines are too long
-711
@@ -1,711 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
xStream Downloader — Flask App
|
|
||||||
Nutzt den echten xStream cRequestHandler für HTTP-Requests.
|
|
||||||
"""
|
|
||||||
import sys, os, re, io, gzip, hashlib, types, traceback, json, time
|
|
||||||
from pathlib import Path
|
|
||||||
from urllib.request import Request, urlopen, HTTPCookieProcessor, build_opener
|
|
||||||
from urllib.parse import quote, urlencode, urlparse
|
|
||||||
from urllib.error import HTTPError, URLError
|
|
||||||
import ssl
|
|
||||||
|
|
||||||
BASE = Path(__file__).parent.resolve()
|
|
||||||
sys.path.insert(0, str(BASE / 'deps'))
|
|
||||||
sys.path.insert(0, str(BASE))
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════
|
|
||||||
# MOCK KODI MODULES (in sys.modules vorallen imports)
|
|
||||||
# ═══════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
def _mkmod(name, **attrs):
|
|
||||||
class _M(types.ModuleType):
|
|
||||||
def __getattr__(self, n):
|
|
||||||
return lambda *a, **k: None
|
|
||||||
m = _M(name)
|
|
||||||
for k,v in attrs.items(): setattr(m, k, v)
|
|
||||||
return m
|
|
||||||
|
|
||||||
class _FakeAddon:
|
|
||||||
def __init__(self, *a, **kw):
|
|
||||||
self._id = a[0] if a else 'service.module.resolveurl'
|
|
||||||
def getAddonInfo(self, k):
|
|
||||||
return {'id': self._id, 'name': 'xStream', 'version': '1.0.0',
|
|
||||||
'icon': '', 'profile': '/tmp', 'author': '', 'description': ''}.get(k, '')
|
|
||||||
def openSettings(self, *a, **kw): pass
|
|
||||||
def getSetting(self, k): return ''
|
|
||||||
def setSetting(self, k, v): pass
|
|
||||||
def getLocalizedString(self, k): return str(k)
|
|
||||||
|
|
||||||
class _FakeDialog:
|
|
||||||
def ok(self, *a, **k): pass
|
|
||||||
def yesno(self, *a, **k): return True
|
|
||||||
def select(self, *a, **k): return 0
|
|
||||||
def notification(self, *a, **k): pass
|
|
||||||
def browse(self, *a, **k): return ''
|
|
||||||
|
|
||||||
class _FakeDialogBusy:
|
|
||||||
def create(self, *a, **k): pass
|
|
||||||
def update(self, *a, **k): pass
|
|
||||||
def close(self): pass
|
|
||||||
def isFinished(self): return True
|
|
||||||
|
|
||||||
class _FakeListItem:
|
|
||||||
def __init__(self, *a, **k):
|
|
||||||
self._title = str(a[0]) if a else ''
|
|
||||||
def __getattr__(self, name):
|
|
||||||
return lambda *a, **k: None
|
|
||||||
def setInfo(self, *a, **k): pass
|
|
||||||
def setThumbnail(self, *a, **k): pass
|
|
||||||
def setIsFolder(self, *a, **k): pass
|
|
||||||
def getLabel(self): return self._title
|
|
||||||
def addContextMenuItems(self, *a, **k): pass
|
|
||||||
|
|
||||||
class _FakeWin:
|
|
||||||
def __init__(self, *a, **k): pass
|
|
||||||
def doModal(self, *a, **k): pass
|
|
||||||
def close(self, *a, **k): pass
|
|
||||||
def clearProperty(self, *a, **k): pass
|
|
||||||
def setProperty(self, *a, **k): pass
|
|
||||||
def getProperty(self, *a, **k): return ''
|
|
||||||
|
|
||||||
class _FakePlayer:
|
|
||||||
def __init__(self): pass
|
|
||||||
def play(self, *a, **k): pass
|
|
||||||
def isPlaying(self): return False
|
|
||||||
def isPlayingVideo(self): return False
|
|
||||||
def getPlayingFile(self): return ''
|
|
||||||
def stop(self): pass
|
|
||||||
|
|
||||||
class _FakeMonitor:
|
|
||||||
def __init__(self, *a, **k): pass
|
|
||||||
def abortRequested(self): return False
|
|
||||||
def waitForAbort(self, t=None): return False
|
|
||||||
|
|
||||||
sys.modules['xbmcaddon'] = _mkmod('xbmcaddon', Addon=_FakeAddon)
|
|
||||||
sys.modules['xbmcgui'] = _mkmod('xbmcgui',
|
|
||||||
Dialog=_FakeDialog, DialogBusy=_FakeDialogBusy,
|
|
||||||
Window=_FakeWin, WindowDialog=_FakeWin, WindowXMLDialog=_FakeWin,
|
|
||||||
ControlButton=_FakeDialog, ControlImage=_FakeDialog, ControlFadeLabel=_FakeDialog,
|
|
||||||
ListItem=_FakeListItem,
|
|
||||||
ACTION_MOUSE_LEFT=100, ACTION_PREVIOUS_MENU=10, ACTION_NAV_BACK=92,
|
|
||||||
)
|
|
||||||
sys.modules['xbmcvfs'] = _mkmod('xbmcvfs',
|
|
||||||
translatePath=lambda s: '/tmp',
|
|
||||||
exists=lambda s: False, mkdir=lambda s: True, mkdirs=lambda s: True,
|
|
||||||
delete=lambda s: True, rename=lambda s,d: True, listdir=lambda s: ([],[]),
|
|
||||||
File=type('F',(),{'__enter__':lambda s:s,'__exit__':lambda s,*a:None,
|
|
||||||
'read':lambda s,n=-1:b'','write':lambda s,b:len(b),'close':lambda s:None})(),
|
|
||||||
)
|
|
||||||
sys.modules['xbmc'] = _mkmod('xbmc',
|
|
||||||
translatePath=lambda s: '/tmp', log=lambda s,*a: None,
|
|
||||||
executeJSONRPC=lambda s: '{}',
|
|
||||||
Player=_FakePlayer, Monitor=_FakeMonitor,
|
|
||||||
getCondVisibility=lambda s: 0, sleep=lambda s: None,
|
|
||||||
getInfoLabel=lambda s: '', executebuiltin=lambda s: None,
|
|
||||||
LOGDEBUG=0, LOGINFO=1, LOGWARNING=2, LOGERROR=3, LOGNONE=4, LOGFATAL=5,
|
|
||||||
)
|
|
||||||
sys.modules['xbmcplugin'] = _mkmod('xbmcplugin')
|
|
||||||
sys.modules['xbmcweb'] = _mkmod('xbmcweb')
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════
|
|
||||||
# ECHTER cRequestHandler (nur Standard-Lib + minimale Anpassungen)
|
|
||||||
# ═══════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
class _FakeConfig:
|
|
||||||
"""cConfig-Ersatz: gibt sichere Defaults zurück."""
|
|
||||||
_settings = {
|
|
||||||
'cacheTime': '1', 'requestTimeout': '10',
|
|
||||||
'bypassDNSlock': 'false', 'volatileHtmlCache': 'false',
|
|
||||||
}
|
|
||||||
def getAddonInfo(self, k):
|
|
||||||
return {'profile': '/tmp', 'id': 'xstream', 'name': 'xStream',
|
|
||||||
'version': '1.0.0', 'icon': '', 'author': '', 'description': ''}.get(k, '')
|
|
||||||
def getSetting(self, k): return self._settings.get(k, '')
|
|
||||||
def setSetting(self, k, v): self._settings[k] = str(v)
|
|
||||||
def getLocalizedString(self, k): return f'[{k}]'
|
|
||||||
|
|
||||||
class _FakeCache:
|
|
||||||
"""cCache-Ersatz: kein Memory-Cache, gibt immer None zurück."""
|
|
||||||
def get(self, key, cache_time):
|
|
||||||
return None
|
|
||||||
def set(self, key, data):
|
|
||||||
pass
|
|
||||||
def clear(self):
|
|
||||||
pass
|
|
||||||
|
|
||||||
class _FakeLogger:
|
|
||||||
def info(self, *a, **k): pass
|
|
||||||
def debug(self, *a, **k): pass
|
|
||||||
def error(self, *a, **k): pass
|
|
||||||
|
|
||||||
def _infoDialog(*a, **k): pass
|
|
||||||
|
|
||||||
# ── Standalone cRequestHandler ──────────────────────────────────
|
|
||||||
class cRequestHandler:
|
|
||||||
"""Echter HTTP-Client, basiert auf dem xStream requestHandler."""
|
|
||||||
persistent_openers = {}
|
|
||||||
|
|
||||||
UA_LIST = [
|
|
||||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:152.0) Gecko/20100101 Firefox/152.0',
|
|
||||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36',
|
|
||||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.5 Safari/605.1.15',
|
|
||||||
]
|
|
||||||
|
|
||||||
def __init__(self, sUrl, caching=True, ignoreErrors=False, method='GET',
|
|
||||||
data=None, compression=True, jspost=False, ssl_verify=False):
|
|
||||||
self._sUrl = str(sUrl) if sUrl else ''
|
|
||||||
self._sRealUrl = ''
|
|
||||||
self._USER_AGENT = self.UA_LIST[0]
|
|
||||||
self._aParameters = {}
|
|
||||||
self._headerEntries = {}
|
|
||||||
self._Status = ''
|
|
||||||
self._sResponseHeader = ''
|
|
||||||
self._ssl_verify = ssl_verify
|
|
||||||
self.ignoreDiscard(False)
|
|
||||||
self.ignoreExpired(False)
|
|
||||||
self.caching = caching
|
|
||||||
self.method = method
|
|
||||||
self.data = data
|
|
||||||
self.ignoreErrors = ignoreErrors
|
|
||||||
self.compression = compression
|
|
||||||
self.jspost = jspost
|
|
||||||
self.requestTimeout = 15
|
|
||||||
self.removeBreakLines(True)
|
|
||||||
self.removeNewLines(True)
|
|
||||||
self.__setDefaultHeader()
|
|
||||||
self.isMemoryCacheActive = False
|
|
||||||
self._memCache = None
|
|
||||||
self.__bRemoveNewLines = True
|
|
||||||
self.__bRemoveBreakLines = True
|
|
||||||
|
|
||||||
def __setDefaultHeader(self):
|
|
||||||
self.addHeaderEntry('User-Agent', self._USER_AGENT)
|
|
||||||
self.addHeaderEntry('Accept', '*/*')
|
|
||||||
self.addHeaderEntry('Accept-Language', 'en-US,en;q=0.5')
|
|
||||||
self.addHeaderEntry('Accept-Encoding', 'gzip, deflate' if self.compression else 'identity')
|
|
||||||
|
|
||||||
def removeNewLines(self, b): self.__bRemoveNewLines = b
|
|
||||||
def removeBreakLines(self, b): self.__bRemoveBreakLines = b
|
|
||||||
|
|
||||||
def addHeaderEntry(self, k, v):
|
|
||||||
self._headerEntries[k] = v
|
|
||||||
|
|
||||||
def addParameters(self, k, v, Quote=False):
|
|
||||||
self._aParameters[k] = v if not Quote else quote(str(v))
|
|
||||||
|
|
||||||
def getHeaderEntry(self, k): return self._headerEntries.get(k)
|
|
||||||
|
|
||||||
def getResponseHeader(self): return self._sResponseHeader
|
|
||||||
def getRealUrl(self): return self._sRealUrl or self._sUrl
|
|
||||||
def getStatus(self): return self._Status
|
|
||||||
def getUrl(self): return self._sUrl
|
|
||||||
|
|
||||||
def setUrl(self, url):
|
|
||||||
self._sUrl = str(url)
|
|
||||||
return self
|
|
||||||
|
|
||||||
def ignoreDiscard(self, v): pass
|
|
||||||
def ignoreExpired(self, v): pass
|
|
||||||
|
|
||||||
def getHtml(self, encoding='utf-8'):
|
|
||||||
"""Führt den Request aus und gibt den HTML-Body zurück."""
|
|
||||||
if not self._sUrl or self._sUrl == 'False':
|
|
||||||
return ''
|
|
||||||
try:
|
|
||||||
url = self._sUrl
|
|
||||||
if self._aParameters and self.method == 'GET':
|
|
||||||
url = url + ('&' if '?' in url else '?') + urlencode(self._aParameters)
|
|
||||||
|
|
||||||
req = Request(url, data=self.data, method=self.method)
|
|
||||||
for k, v in self._headerEntries.items():
|
|
||||||
req.add_header(k, v)
|
|
||||||
|
|
||||||
ctx = ssl.create_default_context() if not self._ssl_verify else None
|
|
||||||
if self._ssl_verify is False:
|
|
||||||
ctx = ssl._create_unverified_context()
|
|
||||||
|
|
||||||
opener = build_opener(HTTPCookieProcessor())
|
|
||||||
if ctx:
|
|
||||||
opener.handlers = [h for h in opener.handlers
|
|
||||||
if not isinstance(h, ssl.SSLHandler)] + [HTTPSHandler(context=ctx)]
|
|
||||||
|
|
||||||
with opener.open(req, timeout=self.requestTimeout) as resp:
|
|
||||||
self._sRealUrl = resp.geturl()
|
|
||||||
self._sResponseHeader = str(resp.headers)
|
|
||||||
data = resp.read()
|
|
||||||
|
|
||||||
if self.compression and 'gzip' in resp.headers.get('Content-Encoding', ''):
|
|
||||||
try: data = gzip.decompress(data)
|
|
||||||
except: pass
|
|
||||||
|
|
||||||
text = data.decode(encoding, errors='replace')
|
|
||||||
|
|
||||||
if self.__bRemoveBreakLines:
|
|
||||||
text = re.sub(r'[\r\n]+', '\n', text)
|
|
||||||
if self.__bRemoveNewLines:
|
|
||||||
text = re.sub(r'\n+', ' ', text)
|
|
||||||
|
|
||||||
return text
|
|
||||||
|
|
||||||
except HTTPError as e:
|
|
||||||
self._Status = str(e.code)
|
|
||||||
if self.ignoreErrors: return ''
|
|
||||||
return f'<!-- HTTPError {e.code} -->'
|
|
||||||
except URLError as e:
|
|
||||||
self._Status = str(e.reason)
|
|
||||||
if self.ignoreErrors: return ''
|
|
||||||
return f'<!-- URLError {e.reason} -->'
|
|
||||||
except Exception as e:
|
|
||||||
self._Status = str(e)
|
|
||||||
if self.ignoreErrors: return ''
|
|
||||||
return f'<!-- Error {e} -->'
|
|
||||||
|
|
||||||
def getJson(self):
|
|
||||||
import json
|
|
||||||
text = self.getHtml()
|
|
||||||
if text.strip().startswith('<!--'): return {}
|
|
||||||
try: return json.loads(text)
|
|
||||||
except: return {}
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════
|
|
||||||
# ECHTES cParser (nur Standard-Lib)
|
|
||||||
# ═══════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
class cParser:
|
|
||||||
@staticmethod
|
|
||||||
def urlEncode(params):
|
|
||||||
if isinstance(params, dict):
|
|
||||||
return urlencode(params)
|
|
||||||
return quote(str(params) if params else '')
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def parseDouble(source, pattern1, pattern2):
|
|
||||||
match1 = re.search(pattern1, source, re.S)
|
|
||||||
if not match1: return []
|
|
||||||
match2 = re.search(pattern2, source[match1.end():], re.S)
|
|
||||||
if not match2: return []
|
|
||||||
return [(match1.group(0), match2.group(0))]
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def parse(source, pattern, flags=0):
|
|
||||||
return re.findall(pattern, source, flags)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def replace(pattern, replacement, source, count=0):
|
|
||||||
return re.sub(pattern, replacement, source, count)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def searchSingle(result, searchPattern, outputParam=None, source=None):
|
|
||||||
if source is not None:
|
|
||||||
match = re.search(searchPattern, source, re.S)
|
|
||||||
elif result:
|
|
||||||
match = re.search(searchPattern, str(result), re.S)
|
|
||||||
else:
|
|
||||||
return None
|
|
||||||
if not match: return None
|
|
||||||
return match.group(1) if outputParam and outputParam <= match.lastindex else match.group(0)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def htmlParse(html):
|
|
||||||
"""Extrahiert Text aus HTML-Tags."""
|
|
||||||
if not html: return []
|
|
||||||
text = re.sub(r'<script[^>]*>.*?</script>', '', html, flags=re.S|re.I)
|
|
||||||
text = re.sub(r'<style[^>]*>.*?</style>', '', text, flags=re.S|re.I)
|
|
||||||
text = re.sub(r'<[^>]+>', ' ', text)
|
|
||||||
text = re.sub(r'\s+', ' ', text).strip()
|
|
||||||
return text.split(' ')
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════
|
|
||||||
# ECHTES ParameterHandler
|
|
||||||
# ═══════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
class ParameterHandler:
|
|
||||||
def __init__(self, url=''):
|
|
||||||
self._params = {}
|
|
||||||
if url:
|
|
||||||
if '?' in url:
|
|
||||||
_, qs = url.split('?', 1)
|
|
||||||
for part in qs.split('&'):
|
|
||||||
if '=' in part:
|
|
||||||
k, v = part.split('=', 1)
|
|
||||||
self._params[quote(k, safe='')] = quote(v, safe='')
|
|
||||||
def getValue(self, key='', default=''):
|
|
||||||
return self._params.get(key, default)
|
|
||||||
def setParam(self, key, value):
|
|
||||||
self._params[str(key)] = str(value)
|
|
||||||
def getAll(self): return self._params
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════
|
|
||||||
# GUI COLLECTOR (sammelt addFolder-Aufrufe)
|
|
||||||
# ═══════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
class _CollectingGui:
|
|
||||||
"""Ersetzt cGui und sammelt alle addFolder()-Einträge."""
|
|
||||||
def __init__(self):
|
|
||||||
self.entries = []
|
|
||||||
self.params_stack = [{}]
|
|
||||||
|
|
||||||
def addFolder(self, oGuiElement, params=None, bIsFolder=True, iTotal=0, isHoster=False, **kw):
|
|
||||||
# params kann ParameterHandler, dict, oder string sein
|
|
||||||
url = ''
|
|
||||||
name = ''
|
|
||||||
img = ''
|
|
||||||
fan = ''
|
|
||||||
desc = ''
|
|
||||||
|
|
||||||
if hasattr(oGuiElement, 'getTitle'):
|
|
||||||
name = oGuiElement.getTitle() or ''
|
|
||||||
if hasattr(oGuiElement, 'getMediaUrl'):
|
|
||||||
url = oGuiElement.getMediaUrl() or ''
|
|
||||||
elif hasattr(oGuiElement, '_sUrl'):
|
|
||||||
url = oGuiElement._sUrl or ''
|
|
||||||
if hasattr(oGuiElement, 'getThumbnailImage'):
|
|
||||||
img = oGuiElement.getThumbnailImage() or ''
|
|
||||||
if hasattr(oGuiElement, 'getFanart'):
|
|
||||||
fan = oGuiElement.getFanart() or ''
|
|
||||||
elif isinstance(oGuiElement, str):
|
|
||||||
name = oGuiElement
|
|
||||||
|
|
||||||
# URL aus params extrahieren
|
|
||||||
if not url and params:
|
|
||||||
if hasattr(params, 'getValue'):
|
|
||||||
url = params.getValue('entryUrl') or params.getValue('sUrl') or ''
|
|
||||||
elif isinstance(params, dict):
|
|
||||||
url = params.get('entryUrl') or params.get('sUrl') or ''
|
|
||||||
|
|
||||||
if url and not url.startswith(('http', '/')):
|
|
||||||
url = ''
|
|
||||||
|
|
||||||
if name and url:
|
|
||||||
self.entries.append({
|
|
||||||
'name': name.strip(),
|
|
||||||
'url': url,
|
|
||||||
'img': img,
|
|
||||||
'fan': fan,
|
|
||||||
'desc': desc,
|
|
||||||
})
|
|
||||||
|
|
||||||
def showInfo(self, *a, **k): pass
|
|
||||||
def showError(self, *a, **k): pass
|
|
||||||
def showKeyBoard(self, *a, **k): return ''
|
|
||||||
def showNofication(self, *a, **k): pass
|
|
||||||
def showLanguage(self, *a, **k): pass
|
|
||||||
def updateDirectory(self, *a, **k): pass
|
|
||||||
def setEndOfDirectory(self, *a, **k): pass
|
|
||||||
def setView(self, *a, **k): pass
|
|
||||||
def createListItem(self, *a, **k): return None
|
|
||||||
def getControl(self, *a, **k): return None
|
|
||||||
def doModal(self, *a, **k): pass
|
|
||||||
def close(self): pass
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════
|
|
||||||
# RESOLVEURL LAZY LOADER
|
|
||||||
# ═══════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
_resolver = None
|
|
||||||
def get_resolver():
|
|
||||||
global _resolver
|
|
||||||
if _resolver is None:
|
|
||||||
import resolveurl
|
|
||||||
_resolver = resolveurl
|
|
||||||
return _resolver
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════
|
|
||||||
# SITE SCANNER
|
|
||||||
# ═══════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
def scan_sites():
|
|
||||||
sites_dir = BASE / 'xstream' / 'sites'
|
|
||||||
result = []
|
|
||||||
for path in sorted(sites_dir.glob('*.py')):
|
|
||||||
if path.name.startswith('_'): continue
|
|
||||||
try:
|
|
||||||
txt = path.read_text(errors='ignore')
|
|
||||||
except: continue
|
|
||||||
ident_m = re.search(r"SITE_IDENTIFIER\s*=\s*['\"]([^'\"]+)['\"]", txt)
|
|
||||||
name_m = re.search(r"SITE_NAME\s*=\s*['\"]([^'\"]+)['\"]", txt)
|
|
||||||
url_m = re.search(r"URL_MAIN\s*=\s*['\"]([^'\"]+)['\"]", txt)
|
|
||||||
ident = ident_m.group(1) if ident_m else path.stem
|
|
||||||
name = name_m.group(1) if name_m else ident
|
|
||||||
url = url_m.group(1) if url_m else ''
|
|
||||||
has_search = bool(re.search(r'def\s+search\s*\(', txt))
|
|
||||||
result.append({'identifier': ident, 'name': name, 'url': url, 'hasSearch': has_search})
|
|
||||||
return result
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════
|
|
||||||
# SITE EXEC IN SUB-PROCESS
|
|
||||||
# ═══════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
import multiprocessing
|
|
||||||
|
|
||||||
def _run_site_in_subprocess(func_name, ident, url_arg='', timeout=20):
|
|
||||||
def worker(pipe, func_name, ident, url_arg):
|
|
||||||
# Mocks in diesem subprocess
|
|
||||||
sys.modules['xbmcaddon'] = _mkmod('xbmcaddon', Addon=_FakeAddon)
|
|
||||||
sys.modules['xbmcgui'] = _mkmod('xbmcgui',
|
|
||||||
Dialog=_FakeDialog, DialogBusy=_FakeDialogBusy,
|
|
||||||
Window=_FakeWin, WindowDialog=_FakeWin, WindowXMLDialog=_FakeWin,
|
|
||||||
ListItem=_FakeListItem,
|
|
||||||
ACTION_MOUSE_LEFT=100, ACTION_PREVIOUS_MENU=10, ACTION_NAV_BACK=92,
|
|
||||||
)
|
|
||||||
sys.modules['xbmcvfs'] = _mkmod('xbmcvfs', translatePath=lambda s: '/tmp',
|
|
||||||
exists=lambda s: False, mkdir=lambda s: True, mkdirs=lambda s: True,
|
|
||||||
delete=lambda s: True, rename=lambda s,d: True,
|
|
||||||
File=type('F',(),{'__enter__':lambda s:s,'__exit__':lambda s,*a:None,
|
|
||||||
'read':lambda s,n=-1:b'','write':lambda s,b:len(b),'close':lambda s:None})())
|
|
||||||
sys.modules['xbmc'] = _mkmod('xbmc',
|
|
||||||
translatePath=lambda s: '/tmp', log=lambda s,*a: None,
|
|
||||||
executeJSONRPC=lambda s: '{}',
|
|
||||||
Player=_FakePlayer, Monitor=_FakeMonitor,
|
|
||||||
getCondVisibility=lambda s: 0, sleep=lambda s: None,
|
|
||||||
getInfoLabel=lambda s: '', executebuiltin=lambda s: None,
|
|
||||||
LOGDEBUG=0, LOGINFO=1, LOGWARNING=2, LOGERROR=3, LOGNONE=4,
|
|
||||||
)
|
|
||||||
sys.modules['xbmcplugin'] = _mkmod('xbmcplugin')
|
|
||||||
sys.modules['xbmcweb'] = _mkmod('xbmcweb')
|
|
||||||
|
|
||||||
# Die echten Handler einsetzen
|
|
||||||
sys.modules['cRequestHandler'] = sys.modules.get('cRequestHandler', None)
|
|
||||||
sys.modules['cParser'] = sys.modules.get('cParser', None)
|
|
||||||
|
|
||||||
BASE_PATH = Path('/home/matthiasberner/Schreibtisch/kodi/xstreamDownloader')
|
|
||||||
site_path = BASE_PATH / 'xstream' / 'sites' / f'{ident}.py'
|
|
||||||
xstream_base = BASE_PATH / 'xstream'
|
|
||||||
handler_base = xstream_base / 'resources' / 'lib' / 'handler'
|
|
||||||
|
|
||||||
if not site_path.exists():
|
|
||||||
pipe.send({'error': 'site not found'}); pipe.close(); return
|
|
||||||
|
|
||||||
gui = _CollectingGui()
|
|
||||||
params = ParameterHandler(url_arg) if url_arg else ParameterHandler()
|
|
||||||
|
|
||||||
mod = {
|
|
||||||
'__name__': ident, '__file__': str(site_path),
|
|
||||||
'sys': sys, 'os': os, 'time': time, 're': re,
|
|
||||||
'xbmc': sys.modules['xbmc'], 'xbmcgui': sys.modules['xbmcgui'],
|
|
||||||
'xbmcaddon': sys.modules['xbmcaddon'], 'xbmcvfs': sys.modules['xbmcvfs'],
|
|
||||||
'xbmcplugin': sys.modules['xbmcplugin'],
|
|
||||||
'cGui': gui, 'ParameterHandler': ParameterHandler,
|
|
||||||
'cConfig': _FakeConfig, 'cCache': _FakeCache,
|
|
||||||
'logger': _FakeLogger(), 'infoDialog': _infoDialog,
|
|
||||||
'showEntries': None, 'showHosters': None, 'search': None,
|
|
||||||
}
|
|
||||||
|
|
||||||
old_path = list(sys.path)
|
|
||||||
# WICHTIG: handler_base VOR xstream_base, damit unser standalone cRequestHandler
|
|
||||||
# VOR dem original requestHandler.py gefunden wird
|
|
||||||
sys.path[:0] = [str(handler_base), str(xstream_base), str(xstream_base / 'resources' / 'lib')]
|
|
||||||
|
|
||||||
try:
|
|
||||||
code = site_path.read_text(errors='ignore')
|
|
||||||
exec(compile(code, str(site_path), 'exec'), mod)
|
|
||||||
|
|
||||||
func = mod.get(func_name)
|
|
||||||
if not func:
|
|
||||||
pipe.send({'error': f'{func_name} not found'}); pipe.close(); return
|
|
||||||
|
|
||||||
# Prüfe Signatur
|
|
||||||
import inspect
|
|
||||||
try:
|
|
||||||
sig = inspect.signature(func)
|
|
||||||
wants_url = any(p.name in ('entryUrl', 'url', 'sUrl', 'pageUrl') for p in sig.parameters.values())
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
wants_url = bool(url_arg)
|
|
||||||
|
|
||||||
if wants_url and url_arg:
|
|
||||||
result = func(url_arg)
|
|
||||||
else:
|
|
||||||
result = func()
|
|
||||||
|
|
||||||
if isinstance(result, list):
|
|
||||||
pipe.send({'entries': result})
|
|
||||||
elif hasattr(result, 'entries'):
|
|
||||||
pipe.send({'entries': result.entries})
|
|
||||||
else:
|
|
||||||
pipe.send({'entries': gui.entries})
|
|
||||||
except Exception as e:
|
|
||||||
pipe.send({'error': str(e), 'trace': traceback.format_exc()})
|
|
||||||
finally:
|
|
||||||
sys.path[:] = old_path
|
|
||||||
pipe.close()
|
|
||||||
|
|
||||||
ctx = multiprocessing.get_context('fork')
|
|
||||||
parent_conn, child_conn = ctx.Pipe()
|
|
||||||
p = ctx.Process(target=worker, args=(child_conn, func_name, ident, url_arg))
|
|
||||||
p.daemon = True
|
|
||||||
p.start()
|
|
||||||
p.join(timeout=timeout)
|
|
||||||
if p.is_alive():
|
|
||||||
p.terminate(); p.join(timeout=2)
|
|
||||||
return {'error': 'timeout'}
|
|
||||||
if parent_conn.poll(timeout=2):
|
|
||||||
return parent_conn.recv()
|
|
||||||
return {'error': 'no response'}
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════
|
|
||||||
# FLASK APP
|
|
||||||
# ═══════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
from flask import Flask, send_from_directory, jsonify, request, send_file
|
|
||||||
import threading
|
|
||||||
|
|
||||||
app = Flask(__name__, static_folder='web')
|
|
||||||
DOWNLOADS = {}
|
|
||||||
dl_lock = threading.Lock()
|
|
||||||
|
|
||||||
def _resolve_real(url):
|
|
||||||
try:
|
|
||||||
r = get_resolver()
|
|
||||||
h = r.resolve(url)
|
|
||||||
return (h, None) if h else (None, 'resolve failed')
|
|
||||||
except Exception as e:
|
|
||||||
return (None, str(e))
|
|
||||||
|
|
||||||
def _start_download(url, title, dl_id):
|
|
||||||
import requests
|
|
||||||
def run():
|
|
||||||
try:
|
|
||||||
final_url, err = _resolve_real(url)
|
|
||||||
if err:
|
|
||||||
DOWNLOADS[dl_id]['error'] = err; DOWNLOADS[dl_id]['status'] = 'error'
|
|
||||||
return
|
|
||||||
r = requests.get(final_url, stream=True, timeout=30,
|
|
||||||
headers={'User-Agent': 'Mozilla/5.0'})
|
|
||||||
r.raise_for_status()
|
|
||||||
total = int(r.headers.get('Content-Length', 0))
|
|
||||||
dl_path = BASE / 'downloads' / f'{dl_id}.mp4'
|
|
||||||
dl_path.parent.mkdir(exist_ok=True)
|
|
||||||
downloaded = 0
|
|
||||||
with open(dl_path, 'wb') as f:
|
|
||||||
for chunk in r.iter_content(65536):
|
|
||||||
if chunk:
|
|
||||||
f.write(chunk)
|
|
||||||
downloaded += len(chunk)
|
|
||||||
if total:
|
|
||||||
DOWNLOADS[dl_id]['progress'] = downloaded / total
|
|
||||||
DOWNLOADS[dl_id]['status'] = 'done'
|
|
||||||
DOWNLOADS[dl_id]['path'] = str(dl_path)
|
|
||||||
DOWNLOADS[dl_id]['progress'] = 1.0
|
|
||||||
except Exception as e:
|
|
||||||
DOWNLOADS[dl_id]['error'] = str(e)
|
|
||||||
DOWNLOADS[dl_id]['status'] = 'error'
|
|
||||||
threading.Thread(target=run, daemon=True).start()
|
|
||||||
|
|
||||||
@app.route('/')
|
|
||||||
def index():
|
|
||||||
return send_from_directory('web', 'index.html')
|
|
||||||
|
|
||||||
@app.route('/<path:fn>')
|
|
||||||
def static_files(fn):
|
|
||||||
return send_from_directory('web', fn)
|
|
||||||
|
|
||||||
@app.route('/api/sites')
|
|
||||||
def api_sites():
|
|
||||||
return jsonify(scan_sites())
|
|
||||||
|
|
||||||
@app.route('/api/sites/<ident>')
|
|
||||||
def api_site_info(ident):
|
|
||||||
for s in scan_sites():
|
|
||||||
if s['identifier'] == ident:
|
|
||||||
return jsonify(s)
|
|
||||||
return jsonify({'error': 'site not found'}), 404
|
|
||||||
|
|
||||||
@app.route('/api/sites/<ident>/entries')
|
|
||||||
def api_entries(ident):
|
|
||||||
url = request.args.get('url', '')
|
|
||||||
DEFAULTS = {
|
|
||||||
'hdfilme': 'https://hdfilme1.co/kinofilme-online/',
|
|
||||||
'fhdfilme': 'https://fhdfilme.stream',
|
|
||||||
'aniworld': 'https://aniworld.to',
|
|
||||||
'serienstream': 'https://serienstream.to',
|
|
||||||
'burningseries': 'https://burningseries.cx',
|
|
||||||
'filmpalast': 'https://filmpalast.to',
|
|
||||||
'kinoger': 'https://kinoger.com',
|
|
||||||
'kkiste': 'https://kkiste.movie',
|
|
||||||
'netzkino': 'https://netzkino.com',
|
|
||||||
'moflix-stream': 'https://moflix-stream.com',
|
|
||||||
'topstreamfilm': 'https://topstreamfilm.com',
|
|
||||||
'streamcloud': 'https://streamcloud.store',
|
|
||||||
'megakino': 'https://megakino.com',
|
|
||||||
'einschalten': 'https://einschalten.io',
|
|
||||||
'internetarchive': 'https://archive.org',
|
|
||||||
'animetoast': 'https://animetoast.org',
|
|
||||||
}
|
|
||||||
if not url and ident in DEFAULTS:
|
|
||||||
url = DEFAULTS[ident]
|
|
||||||
|
|
||||||
for func in ('showEntries', 'showNeues', 'showLatest', 'showNewest'):
|
|
||||||
result = _run_site_in_subprocess(func, ident, url)
|
|
||||||
err = result.get('error', '')
|
|
||||||
skip = ('not found', 'timeout', 'site not found', 'no response')
|
|
||||||
if err and any(s in err for s in skip):
|
|
||||||
continue
|
|
||||||
break
|
|
||||||
|
|
||||||
if 'error' in result and len(result) == 1:
|
|
||||||
return jsonify(result), 500
|
|
||||||
return jsonify(result)
|
|
||||||
|
|
||||||
@app.route('/api/sites/<ident>/hosters')
|
|
||||||
def api_hosters(ident):
|
|
||||||
url = request.args.get('url', '')
|
|
||||||
if not url:
|
|
||||||
return jsonify({'error': 'url required'}), 400
|
|
||||||
result = _run_site_in_subprocess('showHosters', ident, url)
|
|
||||||
if 'error' in result and len(result) == 1:
|
|
||||||
return jsonify(result), 500
|
|
||||||
return jsonify(result)
|
|
||||||
|
|
||||||
@app.route('/api/resolve')
|
|
||||||
def api_resolve():
|
|
||||||
url = request.args.get('url', '')
|
|
||||||
if not url:
|
|
||||||
return jsonify({'error': 'url required'}), 400
|
|
||||||
final_url, err = _resolve_real(url)
|
|
||||||
if err:
|
|
||||||
return jsonify({'error': err}), 400
|
|
||||||
return jsonify({'url': final_url})
|
|
||||||
|
|
||||||
@app.route('/api/download')
|
|
||||||
def api_download():
|
|
||||||
url = request.args.get('url', '')
|
|
||||||
title = request.args.get('title', 'download')
|
|
||||||
if not url:
|
|
||||||
return jsonify({'error': 'url required'}), 400
|
|
||||||
dl_id = hashlib.md5(f'{url}{title}'.encode()).hexdigest()[:12]
|
|
||||||
with dl_lock:
|
|
||||||
if dl_id not in DOWNLOADS:
|
|
||||||
DOWNLOADS[dl_id] = {'id': dl_id, 'title': title, 'url': url,
|
|
||||||
'status': 'starting', 'progress': 0.0,
|
|
||||||
'path': None, 'error': None}
|
|
||||||
_start_download(url, title, dl_id)
|
|
||||||
dl = dict(DOWNLOADS[dl_id])
|
|
||||||
return jsonify(dl)
|
|
||||||
|
|
||||||
@app.route('/api/downloads')
|
|
||||||
def api_downloads():
|
|
||||||
with dl_lock:
|
|
||||||
return jsonify(list(DOWNLOADS.values()))
|
|
||||||
|
|
||||||
@app.route('/api/downloads/<dl_id>')
|
|
||||||
def api_download_info(dl_id):
|
|
||||||
with dl_lock:
|
|
||||||
if dl_id not in DOWNLOADS:
|
|
||||||
return jsonify({'error': 'not found'}), 404
|
|
||||||
return jsonify(dict(DOWNLOADS[dl_id]))
|
|
||||||
|
|
||||||
@app.route('/api/downloads/<dl_id>/file')
|
|
||||||
def api_download_file(dl_id):
|
|
||||||
with dl_lock:
|
|
||||||
dl = DOWNLOADS.get(dl_id, {})
|
|
||||||
path = dl.get('path')
|
|
||||||
if not path or not os.path.exists(path):
|
|
||||||
return jsonify({'error': 'not ready'}), 404
|
|
||||||
return send_file(path, as_attachment=True, download_name=os.path.basename(path))
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
(BASE / 'downloads').mkdir(exist_ok=True)
|
|
||||||
print(f'xStream Downloader — http://localhost:8765')
|
|
||||||
print(f'Sites: {len(scan_sites())}')
|
|
||||||
app.run(host='0.0.0.0', port=8765, debug=False, threaded=True, use_reloader=False)
|
|
||||||
+850
-850
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 70 B |
Binary file not shown.
|
After Width: | Height: | Size: 66 B |
+160
-32
@@ -28,6 +28,19 @@ header button { padding: 8px 16px; background: #e94560; border: none; border-rad
|
|||||||
.card-body { padding: 10px; }
|
.card-body { padding: 10px; }
|
||||||
.card-title { font-size: 0.85rem; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
.card-title { font-size: 0.85rem; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
.card-meta { font-size: 0.75rem; color: #888; margin-top: 4px; }
|
.card-meta { font-size: 0.75rem; color: #888; margin-top: 4px; }
|
||||||
|
/* Suchergebnisse Liste */
|
||||||
|
.search-results { max-width: 700px; margin: 0 auto; }
|
||||||
|
.entry-list { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.entry-item { display: flex; align-items: center; gap: 12px; padding: 10px 12px; background: #16213e; border-radius: 8px; cursor: pointer; transition: background 0.15s; }
|
||||||
|
.entry-item:hover { background: #1a2744; }
|
||||||
|
.entry-item .entry-thumb { width: 50px; height: 70px; object-fit: cover; border-radius: 4px; background: #0f3460; flex-shrink: 0; }
|
||||||
|
.entry-item .entry-thumb.placeholder { display: flex; align-items: center; justify-content: center; font-size: 1.5rem; }
|
||||||
|
.entry-item .entry-info { flex: 1; min-width: 0; }
|
||||||
|
.entry-item .entry-title { font-size: 0.95rem; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.entry-item .entry-meta { font-size: 0.75rem; color: #888; margin-top: 4px; display: flex; gap: 10px; }
|
||||||
|
.entry-item .entry-year { }
|
||||||
|
.entry-item .entry-site { color: #e94560; }
|
||||||
|
.entry-item .entry-arrow { color: #888; font-size: 0.8rem; }
|
||||||
.hoster-list { max-width: 500px; margin: 20px auto; }
|
.hoster-list { max-width: 500px; margin: 20px auto; }
|
||||||
.hoster { display: flex; align-items: center; gap: 12px; padding: 12px; background: #16213e; border-radius: 8px; margin: 6px 0; }
|
.hoster { display: flex; align-items: center; gap: 12px; padding: 12px; background: #16213e; border-radius: 8px; margin: 6px 0; }
|
||||||
.hoster-name { flex: 1; font-weight: 600; }
|
.hoster-name { flex: 1; font-weight: 600; }
|
||||||
@@ -72,6 +85,8 @@ header button { padding: 8px 16px; background: #e94560; border: none; border-rad
|
|||||||
const API = '/api';
|
const API = '/api';
|
||||||
let state = { view: 'home', sites: [], site: null, entries: [], hosters: [], currentEntry: null, downloadId: null };
|
let state = { view: 'home', sites: [], site: null, entries: [], hosters: [], currentEntry: null, downloadId: null };
|
||||||
let downloads = {};
|
let downloads = {};
|
||||||
|
// Navigation stack: [{site, entries, breadcrumb}]
|
||||||
|
let navStack = [];
|
||||||
|
|
||||||
async function api(url) {
|
async function api(url) {
|
||||||
const r = await fetch(API + url);
|
const r = await fetch(API + url);
|
||||||
@@ -82,7 +97,8 @@ async function api(url) {
|
|||||||
async function loadSites() {
|
async function loadSites() {
|
||||||
try {
|
try {
|
||||||
state.sites = await api('/sites');
|
state.sites = await api('/sites');
|
||||||
state.sites = state.sites.map(s => ({ ...s, id: s.identifier }));
|
// backends response has id already so we just use it directly
|
||||||
|
state.sites = state.sites;
|
||||||
renderSidebar();
|
renderSidebar();
|
||||||
} catch(e) { console.error(e); }
|
} catch(e) { console.error(e); }
|
||||||
}
|
}
|
||||||
@@ -118,13 +134,36 @@ function renderEntries() {
|
|||||||
el.innerHTML = '<div class="loading">Keine Einträge gefunden</div>';
|
el.innerHTML = '<div class="loading">Keine Einträge gefunden</div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Suchergebnisse als Liste anzeigen
|
||||||
|
if (state.view === 'search') {
|
||||||
|
el.innerHTML = `<div class="search-results">
|
||||||
|
<h2 class="section-title">Suchergebnisse (${state.entries.length})</h2>
|
||||||
|
<div class="entry-list">
|
||||||
|
${state.entries.map((e, i) => `
|
||||||
|
<div class="entry-item" onclick="openSearchResult(${i})">
|
||||||
|
${e.thumb ? `<img src="${e.thumb}" class="entry-thumb" onerror="this.style.display='none'">` : '<div class="entry-thumb placeholder">🎬</div>'}
|
||||||
|
<div class="entry-info">
|
||||||
|
<div class="entry-title">${e.title || 'Unnamed'}</div>
|
||||||
|
<div class="entry-meta">
|
||||||
|
${e.year ? `<span class="entry-year">${e.year}</span>` : ''}
|
||||||
|
${e._site ? `<span class="entry-site">${e._site.name}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="entry-arrow">▶</div>
|
||||||
|
</div>`).join('')}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normale Kartenansicht
|
||||||
el.innerHTML = `<div class="entries">${state.entries.map(e => `
|
el.innerHTML = `<div class="entries">${state.entries.map(e => `
|
||||||
<div class="card" onclick="selectEntry(${state.entries.indexOf(e)})">
|
<div class="card" onclick="selectEntry(${state.entries.indexOf(e)})">
|
||||||
${e.thumb ? `<img src="${e.thumb}" onerror="this.style.display='none'">` : ''}
|
${e.thumb ? `<img src="${e.thumb}" onerror="this.style.display='none'">` : ''}
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="card-title">${e.title || 'Unnamed'}</div>
|
<div class="card-title">${e.title || 'Unnamed'}</div>
|
||||||
${e.year ? `<div class="card-meta">${e.year}</div>` : ''}
|
${e.year ? `<div class="card-meta">${e.year}</div>` : ''}
|
||||||
${e.quality ? `<div class="card-meta">${e.quality}</div>` : ''}
|
|
||||||
</div>
|
</div>
|
||||||
</div>`).join('')}</div>`;
|
</div>`).join('')}</div>`;
|
||||||
}
|
}
|
||||||
@@ -133,16 +172,38 @@ async function selectEntry(idx) {
|
|||||||
const entry = state.entries[idx];
|
const entry = state.entries[idx];
|
||||||
state.currentEntry = entry;
|
state.currentEntry = entry;
|
||||||
state.hosters = [];
|
state.hosters = [];
|
||||||
setBC([state.site.name, entry.title]);
|
|
||||||
const el = document.getElementById('content');
|
if (entry.isFolder) {
|
||||||
el.innerHTML = `<div class="loading">Lade Hosters für "${entry.title}"...</div>`;
|
// Folder: aktuellen Stand merken und tiefer navigieren
|
||||||
try {
|
navStack.push({ site: state.site, entries: [...state.entries] });
|
||||||
const params = new URLSearchParams({ entryUrl: entry.url || '', episode: entry.episode || '' });
|
const el = document.getElementById('content');
|
||||||
const data = await api(`/sites/${state.site.id}/hosters?${params}`);
|
el.innerHTML = `<div class="loading">Lade "${entry.title}"...</div>`;
|
||||||
state.hosters = (data.hosters || []).filter(h => typeof h === 'object');
|
try {
|
||||||
renderHosters(entry);
|
const encoded = encodeURIComponent(entry.url);
|
||||||
} catch(e) {
|
const data = await api(`/sites/${state.site.id}/entries/${encoded}`);
|
||||||
el.innerHTML = `<div class="error">${e.message}</div>`;
|
state.entries = data.entries || [];
|
||||||
|
setBC([state.site.name, entry.title]);
|
||||||
|
if (state.entries.length === 0) {
|
||||||
|
el.innerHTML = `<div class="error">Keine Eintraege gefunden</div><button class="tab" onclick="goBack()">← Zurueck</button>`;
|
||||||
|
} else {
|
||||||
|
renderEntries();
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
|
el.innerHTML = `<div class="error">${e.message}</div><button class="tab" onclick="goBack()">← Zurueck</button>`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Nicht-Folder: Hosters laden
|
||||||
|
setBC([state.site.name, entry.title]);
|
||||||
|
const el = document.getElementById('content');
|
||||||
|
el.innerHTML = `<div class="loading">Lade Hosters für "${entry.title}"...</div>`;
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({ url: entry.url || '', episode: entry.episode || '' });
|
||||||
|
const data = await api(`/sites/${state.site.id}/hosters?${params}`);
|
||||||
|
state.hosters = (data.hosters || []).filter(h => typeof h === 'object');
|
||||||
|
renderHosters(entry);
|
||||||
|
} catch(e) {
|
||||||
|
el.innerHTML = `<div class="error">${e.message}</div><button class="tab" onclick="goBack()">← Zurueck</button>`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,23 +231,43 @@ function jsEscape(s) {
|
|||||||
return (s || '').replace(/'/g, "\\'").replace(/"/g, '\\"');
|
return (s || '').replace(/'/g, "\\'").replace(/"/g, '\\"');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolveAndDownload(idx, url, title) {
|
|
||||||
const btn = document.getElementById(`dlbtn${idx}`);
|
|
||||||
btn.textContent = '⏳ Warte auf URL...';
|
function resolveAndDownload(idx, url, title) {
|
||||||
btn.classList.add('downloading');
|
// Öffnet den Link direkt in einem neuen Tab
|
||||||
|
const link = url || '';
|
||||||
|
if (link) {
|
||||||
|
window.open(link, '_blank');
|
||||||
|
} else {
|
||||||
|
alert('Kein Link verfügbar');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openSearchResult(idx) {
|
||||||
|
const entry = state.entries[idx];
|
||||||
|
if (!entry) return;
|
||||||
|
|
||||||
|
const site = entry._site;
|
||||||
|
if (!site) {
|
||||||
|
alert('Site nicht gefunden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Öffne Link direkt im neuen Tab - lade erst Hosters im Hintergrund
|
||||||
|
const params = new URLSearchParams({ url: entry.url || '' });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await api(`/resolve?url=${encodeURIComponent(url)}&title=${encodeURIComponent(title)}&download=true`);
|
const data = await api(`/sites/${site.id}/hosters?${params}`);
|
||||||
if (data.download_id) {
|
const hosters = (data.hosters || []).filter(h => typeof h === 'object');
|
||||||
downloads[data.download_id] = { title, progress: 0, status: 'queued' };
|
|
||||||
btn.textContent = '📥 queued';
|
if (hosters.length > 0) {
|
||||||
pollDownload(data.download_id, idx, title);
|
const link = hosters[0].link || hosters[0].url;
|
||||||
} else if (data.resolved) {
|
window.open(link, '_blank');
|
||||||
window.open(data.resolved, '_blank');
|
} else {
|
||||||
btn.textContent = '✅ Stream';
|
alert('Keine Streams gefunden für "' + entry.title + '"');
|
||||||
}
|
}
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
btn.textContent = '❌ Fehler';
|
alert('Fehler beim Laden der Streams: ' + e.message);
|
||||||
btn.title = e.message;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,19 +304,49 @@ async function doSearch() {
|
|||||||
state.entries = [];
|
state.entries = [];
|
||||||
setBC([`Suche: "${q}"`]);
|
setBC([`Suche: "${q}"`]);
|
||||||
document.getElementById('content').innerHTML = '<div class="loading">Suche läuft...</div>';
|
document.getElementById('content').innerHTML = '<div class="loading">Suche läuft...</div>';
|
||||||
// Try global search across all sites by searching each
|
|
||||||
|
// Global search across all sites
|
||||||
const results = [];
|
const results = [];
|
||||||
|
const errors = [];
|
||||||
for (const site of state.sites) {
|
for (const site of state.sites) {
|
||||||
try {
|
try {
|
||||||
const data = await api(`/sites/${site.id}/entries?search=${encodeURIComponent(q)}`);
|
const data = await api(`/sites/${site.id}/search?q=${encodeURIComponent(q)}`);
|
||||||
if (data.entries && data.entries.length) {
|
if (data.entries && data.entries.length) {
|
||||||
data.entries.forEach(e => e._site = site);
|
data.entries.forEach(e => {
|
||||||
|
e._site = site;
|
||||||
|
// Kein Prefix mehr - Titel kommen sauber zurück
|
||||||
|
});
|
||||||
results.push(...data.entries);
|
results.push(...data.entries);
|
||||||
|
} else if (data.error) {
|
||||||
|
errors.push(`${site.name}: ${data.error}`);
|
||||||
}
|
}
|
||||||
} catch(e) {}
|
} catch(e) {
|
||||||
|
errors.push(`${site.name}: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Duplikate zusammenfassen (gleiche Titel nur einmal zeigen)
|
||||||
|
const seen = new Set();
|
||||||
|
const uniqueResults = [];
|
||||||
|
for (const r of results) {
|
||||||
|
const key = r.title.toLowerCase().trim();
|
||||||
|
if (!seen.has(key)) {
|
||||||
|
seen.add(key);
|
||||||
|
uniqueResults.push(r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (results.length === 0) {
|
||||||
|
document.getElementById('content').innerHTML = `
|
||||||
|
<div class="error">
|
||||||
|
<h3>Keine Ergebnisse gefunden</h3>
|
||||||
|
<p>Versuche einen anderen Suchbegriff</p>
|
||||||
|
${errors.length ? `<details><summary>Fehler</summary><pre>${errors.join('\n')}</pre></details>` : ''}
|
||||||
|
</div>`;
|
||||||
|
} else {
|
||||||
|
state.entries = uniqueResults;
|
||||||
|
renderEntries();
|
||||||
}
|
}
|
||||||
state.entries = results;
|
|
||||||
renderEntries();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function showDownloads() {
|
async function showDownloads() {
|
||||||
@@ -273,10 +384,27 @@ function setBC(parts) {
|
|||||||
el.innerHTML = html;
|
el.innerHTML = html;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function goBack() {
|
||||||
|
if (navStack.length === 0) {
|
||||||
|
goHome();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const prev = navStack.pop();
|
||||||
|
state.site = prev.site;
|
||||||
|
state.entries = prev.entries;
|
||||||
|
state.hosters = [];
|
||||||
|
state.currentEntry = null;
|
||||||
|
setBC([state.site.name]);
|
||||||
|
renderEntries();
|
||||||
|
}
|
||||||
|
|
||||||
function goHome() {
|
function goHome() {
|
||||||
state.view = 'home';
|
state.view = 'home';
|
||||||
state.site = null;
|
state.site = null;
|
||||||
state.entries = [];
|
state.entries = [];
|
||||||
|
state.hosters = [];
|
||||||
|
state.currentEntry = null;
|
||||||
|
navStack = [];
|
||||||
setBC([]);
|
setBC([]);
|
||||||
document.getElementById('content').innerHTML = '<div class="loading">Wähle eine Site aus dem Menü oder suche nach einem Film.</div>';
|
document.getElementById('content').innerHTML = '<div class="loading">Wähle eine Site aus dem Menü oder suche nach einem Film.</div>';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -134,6 +134,10 @@ class cRequestHandler:
|
|||||||
self._Status = str(e)
|
self._Status = str(e)
|
||||||
return f'<!-- Error {e} -->' if not self.ignoreErrors else ''
|
return f'<!-- Error {e} -->' if not self.ignoreErrors else ''
|
||||||
|
|
||||||
|
# Alias for getHtml() - used by original xStream site plugins
|
||||||
|
def request(self, encoding='utf-8'):
|
||||||
|
return self.getHtml(encoding)
|
||||||
|
|
||||||
def getJson(self):
|
def getJson(self):
|
||||||
import json
|
import json
|
||||||
text = self.getHtml()
|
text = self.getHtml()
|
||||||
|
|||||||
Reference in New Issue
Block a user