Created
February 14, 2026 14:24
-
-
Save CarstenG2/33be0a4a8be3d04d7b8b7eb70b3a48fb to your computer and use it in GitHub Desktop.
xShip Medien-Info: audio language, multi-track, countdown, optimized MP4 probing (Issue #48)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # 2023-05-10 | |
| # edit 2025-06-12 | |
| import sys, json | |
| from urllib.parse import parse_qs, urlsplit | |
| from resources.lib import control | |
| params = dict(control.parse_qsl(control.urlsplit(sys.argv[2]).query)) | |
| action = params.get('action') | |
| name = params.get('name') | |
| table = params.get('table') | |
| title = params.get('title') | |
| source = params.get('source') | |
| # ------ navigator -------------- | |
| if action == None or action == 'root': | |
| from resources.lib.indexers import navigator | |
| navigator.navigator().root() | |
| elif action == 'pluginInfo': | |
| from resources.lib import supportinfo | |
| supportinfo.pluginInfo() | |
| elif action == 'movieNavigator': | |
| from resources.lib.indexers import navigator | |
| navigator.navigator().movies() | |
| elif action == 'tvNavigator': | |
| from resources.lib.indexers import navigator | |
| navigator.navigator().tvshows() | |
| elif action == 'toolNavigator': | |
| from resources.lib.indexers import navigator | |
| navigator.navigator().tools() | |
| elif action == 'downloadNavigator': | |
| from resources.lib.indexers import navigator | |
| navigator.navigator().downloads() | |
| # ------------------------------------------- | |
| elif action == 'download': | |
| image = params.get('image') | |
| from resources.lib import downloader | |
| from resources.lib import sources | |
| try: downloader.download(name, image, sources.sources().sourcesResolve(json.loads(source)[0], True)) | |
| except: pass | |
| elif action in ('sendToJD', 'sendToJD2', 'sendToMyJD', 'sendToPyLoad'): | |
| raw_url = json.loads(source)[0]['url'] | |
| if raw_url: | |
| # Extract best URL for download managers: | |
| # If URL has Kodi-style |headers with a Referer that has a path, | |
| # use the Referer (it's the hoster page URL that JD can resolve). | |
| # Otherwise use the bare URL without |headers. | |
| url = raw_url | |
| if '|' in raw_url: | |
| base_url, header_str = raw_url.split('|', 1) | |
| headers = dict(parse_qs(header_str, keep_blank_values=True)) | |
| referer = headers.get('Referer', [''])[0] | |
| if referer and urlsplit(referer).path not in ('', '/'): | |
| url = referer | |
| else: | |
| url = base_url | |
| if action == 'sendToJD': | |
| from resources.lib.handler.jdownloaderHandler import cJDownloaderHandler | |
| cJDownloaderHandler().sendToJDownloader(url) | |
| elif action == 'sendToJD2': | |
| from resources.lib.handler.jdownloader2Handler import cJDownloader2Handler | |
| cJDownloader2Handler().sendToJDownloader2(url) | |
| elif action == 'sendToMyJD': | |
| from resources.lib.handler.myjdownloaderHandler import cMyJDownloaderHandler | |
| cMyJDownloaderHandler().sendToMyJDownloader(url, name) | |
| elif action == 'sendToPyLoad': | |
| from resources.lib.handler.pyLoadHandler import cPyLoadHandler | |
| cPyLoadHandler().sendToPyLoad(name, url) | |
| elif action == 'mediaInfo': | |
| import xbmcgui | |
| dialog = xbmcgui.DialogProgress() | |
| dialog.create('Medien-Info', 'Löse Stream-URL auf... (20 Sek.)') | |
| dialog.update(0) | |
| from resources.lib import sources | |
| sources.sources().mediaInfo(source, dialog) | |
| elif action == 'playExtern': | |
| import json | |
| if not control.visible(): control.busy() | |
| try: | |
| sysmeta = {} | |
| for key, value in params.items(): | |
| if key == 'action': continue | |
| elif key == 'year' or key == 'season' or key == 'episode': value = int(value) | |
| if value == 0: continue | |
| sysmeta.update({key : value}) | |
| if int(params.get('season')) == 0: | |
| mediatype = 'movie' | |
| else: | |
| mediatype = 'tvshow' | |
| sysmeta.update({'mediatype': mediatype}) | |
| # if control.getSetting('hosts.mode') == '2': | |
| # sysmeta.update({'select': '2'}) | |
| # else: | |
| # sysmeta.update({'select': '1'}) | |
| sysmeta.update({'select': control.getSetting('hosts.mode')}) | |
| sysmeta = json.dumps(sysmeta) | |
| params.update({'sysmeta': sysmeta}) | |
| from resources.lib import sources | |
| sources.sources().play(params) | |
| except: | |
| pass | |
| elif action == 'playURL': | |
| try: | |
| import resolveurl | |
| import xbmcgui, xbmc | |
| #url = 'https://streamvid.net/embed-uhgo683xes41' | |
| #url = 'https://moflix-stream.click/v/gcd0aueegeia' | |
| url = xbmcgui.Dialog().input("URL Input") | |
| hmf = resolveurl.HostedMediaFile(url=url, include_disabled=True, include_universal=False) | |
| try: | |
| if hmf.valid_url(): url = hmf.resolve() | |
| except: | |
| pass | |
| item = xbmcgui.ListItem('URL-direkt') | |
| kodiver = int(xbmc.getInfoLabel("System.BuildVersion").split(".")[0]) | |
| if ".m3u8" in url or '.mpd' in url: | |
| item.setProperty("inputstream", "inputstream.adaptive") | |
| if '.mpd' in url: | |
| if kodiver < 21: item.setProperty('inputstream.adaptive.manifest_type', 'mpd') | |
| item.setMimeType('application/dash+xml') | |
| else: | |
| if kodiver < 21: item.setProperty('inputstream.adaptive.manifest_type', 'hls') | |
| item.setMimeType("application/vnd.apple.mpegurl") | |
| item.setContentLookup(False) | |
| if '|' in url: | |
| stream_url, strhdr = url.split('|') | |
| item.setProperty('inputstream.adaptive.stream_headers', strhdr) | |
| if kodiver > 19: item.setProperty('inputstream.adaptive.manifest_headers', strhdr) | |
| # item.setPath(stream_url) | |
| url = stream_url | |
| item.setPath(url) | |
| xbmc.Player().play(url, item) | |
| except: | |
| #print('Kein Video Link gefunden') | |
| control.infoDialog("Keinen Video Link gefunden", sound=True, icon='WARNING', time=1000) | |
| elif action == 'UpdatePlayCount': | |
| from resources.lib import playcountDB | |
| playcountDB.UpdatePlaycount(params) | |
| control.execute('Container.Refresh') | |
| # listings ------------------------------- | |
| elif action == 'listings': | |
| from resources.lib.indexers import listings | |
| listings.listings().get(params) | |
| elif action == 'movieYears': | |
| from resources.lib.indexers import listings | |
| listings.listings().movieYears() | |
| elif action == 'movieGenres': | |
| from resources.lib.indexers import listings | |
| listings.listings().movieGenres() | |
| elif action == 'tvGenres': | |
| from resources.lib.indexers import listings | |
| listings.listings().tvGenres() | |
| # search ---------------------- | |
| elif action == 'searchNew': | |
| from resources.lib import searchDB | |
| searchDB.search_new(table) | |
| elif action == 'searchClear': | |
| from resources.lib import searchDB | |
| searchDB.remove_all_query(table) | |
| # if len(searchDB.getSearchTerms()) == 0: | |
| # control.execute('Action(ParentDir)') | |
| elif action == 'searchDelTerm': | |
| from resources.lib import searchDB | |
| searchDB.remove_query(name, table) | |
| # if len(searchDB.getSearchTerms()) == 0: | |
| # control.execute('Action(ParentDir)') | |
| # person ---------------------- | |
| elif action == 'person': | |
| from resources.lib.indexers import person | |
| person.person().get(params) | |
| elif action == 'personSearch': | |
| from resources.lib.indexers import person | |
| person.person().search() | |
| elif action == 'personCredits': | |
| from resources.lib.indexers import person | |
| person.person().getCredits(params) | |
| elif action == 'playfromPerson': | |
| if not control.visible(): control.busy() | |
| sysmeta = json.loads(params['sysmeta']) | |
| if sysmeta['mediatype'] == 'movie': | |
| from resources.lib.indexers import movies | |
| sysmeta = movies.movies().super_meta(sysmeta['tmdb_id']) | |
| sysmeta = json.dumps(sysmeta) | |
| else: | |
| from resources.lib.indexers import tvshows | |
| sysmeta = tvshows.tvshows().super_meta(sysmeta['tmdb_id']) | |
| sysmeta = control.quote_plus(json.dumps(sysmeta)) | |
| params.update({'sysmeta': sysmeta}) | |
| from resources.lib import sources | |
| sources.sources().play(params) | |
| # movies ---------------------- | |
| elif action == 'movies': | |
| from resources.lib.indexers import movies | |
| movies.movies().get(params) | |
| elif action == 'moviesSearch': | |
| from resources.lib.indexers import movies | |
| movies.movies().search() | |
| # tvshows --------------------------------- | |
| elif action == 'tvshows': # 'tvshowPage' | |
| from resources.lib.indexers import tvshows | |
| tvshows.tvshows().get(params) | |
| elif action == 'tvshowsSearch': | |
| from resources.lib.indexers import tvshows | |
| tvshows.tvshows().search() | |
| # seasons --------------------------------- | |
| elif action == 'seasons': | |
| from resources.lib.indexers import seasons | |
| seasons.seasons().get(params) # params | |
| # episodes --------------------------------- | |
| elif action == 'episodes': | |
| from resources.lib.indexers import episodes | |
| episodes.episodes().get(params) | |
| # sources --------------------------------- | |
| elif action == 'play': | |
| if not control.visible(): control.busy() | |
| from resources.lib import sources | |
| sources.sources().play(params) | |
| elif action == 'addItem': | |
| from resources.lib import sources | |
| sources.sources().addItem(title) | |
| elif action == 'playItem': | |
| if not control.visible(): control.busy() | |
| from resources.lib import sources | |
| sources.sources().playItem(title, source) | |
| # Settings ------------------------------ | |
| elif action == "settings": # alle Quellen aktivieren / deaktivieren | |
| from resources import settings | |
| settings.run(params) | |
| elif action == 'addonSettings': | |
| # query = None | |
| query = params.get('query') | |
| control.openSettings(query) | |
| elif action == 'resetSettings': | |
| status = control.resetSettings() | |
| if status: | |
| control.reload_profile() | |
| control.sleep(500) | |
| control.execute('RunAddon("%s")' % control.addonId) | |
| elif action == 'resolverSettings': | |
| import resolveurl as resolver | |
| resolver.display_settings() | |
| # try: | |
| # import pydevd | |
| # if pydevd.connected: pydevd.kill_all_pydev_threads() | |
| # except: | |
| # pass | |
| # finally: | |
| # exit() |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # Media info probing for xShip streams | |
| # Detects stream type (HLS/DASH/MP4) and extracts resolution, codec, FPS, audio, bitrate, duration | |
| import re | |
| import time | |
| import threading | |
| from resources.lib import log_utils, control | |
| TOTAL_TIMEOUT = 20 # seconds budget for entire probe | |
| def _fetchWithDeadline(dialog, pct, msg, func, deadline): | |
| """Run func() in a background thread, showing countdown to deadline on dialog.""" | |
| result = [None] | |
| error = [None] | |
| def run(): | |
| try: result[0] = func() | |
| except Exception as e: error[0] = e | |
| t = threading.Thread(target=run) | |
| t.start() | |
| while t.is_alive(): | |
| remaining = int(deadline - time.time()) | |
| if remaining <= 0: | |
| break | |
| dialog.update(pct, '%s (%d Sek.)' % (msg, remaining)) | |
| if dialog.iscanceled(): | |
| return None | |
| t.join(timeout=1) | |
| t.join(timeout=0.5) | |
| if error[0]: | |
| raise error[0] | |
| return result[0] | |
| def _remaining(deadline): | |
| """Seconds left until deadline, minimum 2.""" | |
| return max(2, deadline - time.time()) | |
| def getMediaInfo(url, dialog, deadline=None): | |
| """Detect stream type and probe media info. Returns formatted info string or None.""" | |
| import requests | |
| if not deadline: | |
| deadline = time.time() + TOTAL_TIMEOUT | |
| stream_url = url.split('|')[0] | |
| url_lower = stream_url.lower() | |
| # 1. Check URL extension first (fast path) | |
| if '.m3u8' in url_lower: | |
| return _probeHLS(url, dialog, deadline) | |
| if '.mpd' in url_lower: | |
| return _probeDASH(url, dialog, deadline) | |
| # 2. Fetch a small chunk to detect by Content-Type + content sniffing | |
| headers = _parseHeaders(url) | |
| try: | |
| r = _fetchWithDeadline(dialog, 55, 'Erkenne Stream-Typ...', | |
| lambda: requests.get(stream_url, headers=headers, timeout=_remaining(deadline), verify=False, stream=True), | |
| deadline) | |
| if r is None: | |
| return None | |
| if r.status_code >= 400: | |
| return 'Stream nicht erreichbar (HTTP %d)' % r.status_code | |
| ct = r.headers.get('Content-Type', '').lower() | |
| content_length = r.headers.get('Content-Length', '') | |
| # Read first 8KB for sniffing | |
| peek = next(r.iter_content(chunk_size=8192), b'') | |
| r.close() | |
| # Detect HLS by Content-Type or content | |
| if 'mpegurl' in ct or 'apple' in ct or peek.lstrip().startswith(b'#EXTM3U'): | |
| return _probeHLS(url, dialog, deadline) | |
| # Detect DASH by Content-Type or content | |
| if 'dash' in ct or (peek.lstrip().startswith(b'<?xml') and b'<MPD' in peek): | |
| return _probeDASH(url, dialog, deadline) | |
| # Otherwise: direct file (MP4, MKV, etc.) | |
| return _probeDirect(url, dialog, deadline, content_length) | |
| except Exception as e: | |
| log_utils.log('getMediaInfo Error: %s' % str(e), log_utils.LOGERROR) | |
| return 'Stream-Typ konnte nicht erkannt werden' | |
| def _probeHLS(url, dialog=None, deadline=None): | |
| import requests | |
| if not deadline: | |
| deadline = time.time() + TOTAL_TIMEOUT | |
| try: | |
| stream_url = url.split('|')[0] | |
| headers = _parseHeaders(url) | |
| r = requests.get(stream_url, headers=headers, timeout=_remaining(deadline), verify=False) | |
| content = r.text | |
| if '#EXT-X-STREAM-INF' not in content: | |
| return 'HLS Stream (Single-Bitrate)\n\nKeine Auflösungsinfo im Manifest verfügbar.' | |
| lines = content.strip().split('\n') | |
| variants = [] | |
| for line in lines: | |
| if line.startswith('#EXT-X-STREAM-INF'): | |
| res_match = re.search(r'RESOLUTION=(\d+)x(\d+)', line) | |
| bw_match = re.search(r'BANDWIDTH=(\d+)', line) | |
| codec_match = re.search(r'CODECS="([^"]+)"', line) | |
| width = int(res_match.group(1)) if res_match else 0 | |
| height = int(res_match.group(2)) if res_match else 0 | |
| bandwidth = int(bw_match.group(1)) if bw_match else 0 | |
| codecs = codec_match.group(1) if codec_match else '' | |
| variants.append((width, height, bandwidth, codecs)) | |
| if not variants: | |
| return 'HLS Stream\n\nKeine Auflösungsinfo gefunden.' | |
| variants.sort(key=lambda x: x[1], reverse=True) | |
| best = variants[0] | |
| result = 'Typ: HLS Stream\n' | |
| result += 'Auflösung: %dx%d (%s)\n' % (best[0], best[1], _resLabel(best[1])) | |
| if best[3]: | |
| vc = [c.strip() for c in best[3].split(',') if c.strip()[:3].lower() in ('avc', 'hev', 'hvc', 'av0', 'vp0', 'vp8', 'vp9', 'mp4')] | |
| ac = [c.strip() for c in best[3].split(',') if c.strip() not in vc] | |
| # mp4a is audio, not video | |
| vc_final = [c for c in vc if not c.strip().lower().startswith('mp4a')] | |
| ac_final = ac + [c for c in vc if c.strip().lower().startswith('mp4a')] | |
| if vc_final: result += 'Video-Codec: %s\n' % _codecName(','.join(vc_final)) | |
| # Parse #EXT-X-MEDIA:TYPE=AUDIO lines for language info | |
| audio_tracks = [] | |
| for line in lines: | |
| if line.startswith('#EXT-X-MEDIA') and 'TYPE=AUDIO' in line: | |
| lang_m = re.search(r'LANGUAGE="([^"]*)"', line) | |
| lang = lang_m.group(1) if lang_m else '' | |
| audio_tracks.append(lang) | |
| if audio_tracks: | |
| codec_str = _codecName(','.join(ac_final)) if ac_final else '' | |
| seen_langs = set() | |
| for lang in audio_tracks: | |
| lang_key = lang.lower() | |
| if lang_key in seen_langs: | |
| continue | |
| seen_langs.add(lang_key) | |
| lang_str = _langName(lang) | |
| if lang_str and codec_str: | |
| result += 'Audio: %s — %s\n' % (codec_str, lang_str) | |
| elif codec_str: | |
| result += 'Audio: %s\n' % codec_str | |
| elif lang_str: | |
| result += 'Audio: %s\n' % lang_str | |
| elif ac_final: | |
| result += 'Audio: %s\n' % _codecName(','.join(ac_final)) | |
| if best[2]: result += 'Bitrate: %s\n' % _fmtBitrate(best[2]) | |
| return result.rstrip() | |
| except Exception as e: | |
| log_utils.log('_probeHLS Error: %s' % str(e), log_utils.LOGERROR) | |
| return None | |
| def _probeDASH(url, dialog=None, deadline=None): | |
| import requests | |
| import xml.etree.ElementTree as ET | |
| if not deadline: | |
| deadline = time.time() + TOTAL_TIMEOUT | |
| try: | |
| stream_url = url.split('|')[0] | |
| headers = _parseHeaders(url) | |
| r = requests.get(stream_url, headers=headers, timeout=_remaining(deadline), verify=False) | |
| root = ET.fromstring(r.content) | |
| # Handle XML namespace | |
| ns = '' | |
| ns_match = re.match(r'\{(.+?)\}', root.tag) | |
| if ns_match: | |
| ns = '{%s}' % ns_match.group(1) | |
| variants = [] | |
| for rep in root.iter('%sRepresentation' % ns): | |
| width = rep.get('width') | |
| height = rep.get('height') | |
| bandwidth = rep.get('bandwidth') | |
| codecs = rep.get('codecs', '') | |
| mime = rep.get('mimeType', '') | |
| # Also check parent AdaptationSet | |
| parent = None | |
| for adapt in root.iter('%sAdaptationSet' % ns): | |
| if rep in list(adapt): | |
| parent = adapt | |
| break | |
| if not mime and parent is not None: | |
| mime = parent.get('mimeType', '') | |
| if not codecs and parent is not None: | |
| codecs = parent.get('codecs', '') | |
| if width and height: | |
| variants.append((int(width), int(height), int(bandwidth) if bandwidth else 0, codecs, mime)) | |
| if not variants: | |
| return 'DASH Stream\n\nKeine Auflösungsinfo im Manifest verfügbar.' | |
| # Deduplicate and sort by height descending | |
| seen = set() | |
| unique = [] | |
| for v in variants: | |
| key = (v[0], v[1], v[2]) | |
| if key not in seen: | |
| seen.add(key) | |
| unique.append(v) | |
| unique.sort(key=lambda x: (x[1], x[2]), reverse=True) | |
| best = unique[0] | |
| result = 'Typ: DASH Stream\n' | |
| result += 'Auflösung: %dx%d (%s)\n' % (best[0], best[1], _resLabel(best[1])) | |
| if best[3]: result += 'Video-Codec: %s\n' % _codecName(best[3]) | |
| if best[2]: result += 'Bitrate: %s\n' % _fmtBitrate(best[2]) | |
| # Audio info from audio AdaptationSets | |
| for adapt in root.iter('%sAdaptationSet' % ns): | |
| mime = adapt.get('mimeType', '') | |
| if 'audio' not in mime: | |
| continue | |
| lang = adapt.get('lang', '') | |
| lang_str = _langName(lang) | |
| for rep in adapt.iter('%sRepresentation' % ns): | |
| ac = rep.get('codecs', '') or adapt.get('codecs', '') | |
| if ac: | |
| if lang_str: | |
| result += 'Audio: %s — %s\n' % (_codecName(ac), lang_str) | |
| else: | |
| result += 'Audio: %s\n' % _codecName(ac) | |
| break | |
| return result.rstrip() | |
| except Exception as e: | |
| log_utils.log('_probeDASH Error: %s' % str(e), log_utils.LOGERROR) | |
| return None | |
| def _fetchRange(url, headers, start, end, dialog, deadline, pct, msg): | |
| """Fetch a byte range, showing progress. Returns bytes or None.""" | |
| import requests | |
| h = dict(headers) | |
| h['Range'] = 'bytes=%d-%d' % (start, end) | |
| try: | |
| r = _fetchWithDeadline(dialog, pct, msg, | |
| lambda: requests.get(url, headers=h, timeout=_remaining(deadline), verify=False, stream=True), | |
| deadline) | |
| if r is None or r.status_code >= 400: | |
| return None | |
| except: | |
| return None | |
| data = b'' | |
| want = end - start + 1 | |
| for chunk in r.iter_content(chunk_size=65536): | |
| data += chunk | |
| remaining = int(deadline - time.time()) | |
| dialog.update(pct, '%s %.0f KB (%d Sek.)' % (msg, len(data) / 1024.0, max(remaining, 0))) | |
| if len(data) >= want: | |
| break | |
| if dialog.iscanceled() or remaining <= 0: | |
| break | |
| r.close() | |
| return data | |
| def _findMoov(data): | |
| """Scan top-level MP4 boxes to find moov offset and size. | |
| Returns (offset, size) or (None, None).""" | |
| import struct | |
| p = 0 | |
| while p < len(data) - 8: | |
| try: | |
| box_size = struct.unpack('>I', data[p:p+4])[0] | |
| box_type = data[p+4:p+8] | |
| except: | |
| break | |
| if box_size < 8: | |
| break | |
| if box_type == b'moov': | |
| return p, box_size | |
| if box_type == b'mdat': | |
| return None, None # moov is after mdat (at end of file) | |
| p += box_size | |
| return None, None | |
| def _probeDirect(url, dialog, deadline, file_size_str=''): | |
| import requests, struct | |
| try: | |
| stream_url = url.split('|')[0] | |
| headers = _parseHeaders(url) | |
| # Step 1: fetch first 64 KB to scan box headers | |
| INITIAL = 65536 | |
| data = _fetchRange(stream_url, headers, 0, INITIAL - 1, dialog, deadline, 65, 'Lade Datei-Header...') | |
| if data is None: | |
| return 'Typ: Direkter Stream\n\nServer nicht erreichbar' | |
| # Extract file size from response (need a quick HEAD-like check) | |
| content_type = '' | |
| total_size = 0 | |
| try: | |
| h_check = dict(headers) | |
| h_check['Range'] = 'bytes=0-0' | |
| r_check = requests.head(stream_url, headers=headers, timeout=_remaining(deadline), verify=False) | |
| content_type = r_check.headers.get('Content-Type', '') | |
| cr = r_check.headers.get('Content-Range', '') | |
| if '/' in cr: | |
| try: total_size = int(cr.split('/')[-1]) | |
| except: pass | |
| if not total_size: | |
| try: total_size = int(r_check.headers.get('Content-Length', '0')) | |
| except: pass | |
| except: | |
| pass | |
| if not total_size and file_size_str: | |
| try: total_size = int(file_size_str) | |
| except: pass | |
| # Step 2: scan top-level boxes to locate moov | |
| moov_off, moov_size = _findMoov(data) | |
| if moov_off is not None: | |
| # moov found at start — fetch more if we don't have it all | |
| moov_end = moov_off + moov_size | |
| if moov_end > len(data): | |
| dialog.update(70, 'Lade moov-Box... (%d KB)' % (moov_size // 1024)) | |
| extra = _fetchRange(stream_url, headers, len(data), moov_end - 1, | |
| dialog, deadline, 70, 'Lade moov-Box...') | |
| if extra: | |
| data = data + extra | |
| elif total_size > INITIAL: | |
| # moov not at start (mdat first) — try from end | |
| tail_size = min(total_size, 262144) # start with 256 KB from end | |
| tail_start = total_size - tail_size | |
| tail = _fetchRange(stream_url, headers, tail_start, total_size - 1, | |
| dialog, deadline, 75, 'Lade Datei-Ende...') | |
| if tail: | |
| moov_off, moov_size = _findMoov(tail) | |
| if moov_off is not None: | |
| moov_end = moov_off + moov_size | |
| if moov_end > len(tail): | |
| # moov larger than our tail chunk — fetch the full moov | |
| abs_moov_start = tail_start + moov_off | |
| extra = _fetchRange(stream_url, headers, abs_moov_start, abs_moov_start + moov_size - 1, | |
| dialog, deadline, 80, 'Lade moov-Box...') | |
| if extra: | |
| data = extra | |
| else: | |
| data = tail | |
| else: | |
| data = tail | |
| else: | |
| data = tail | |
| dialog.update(85, 'Analysiere Video-Header...') | |
| width, height, codec, duration_sec, fps, audio_traks = _parseMp4(data) | |
| result = 'Typ: Direkter Stream\n' | |
| if width and height: | |
| label = _resLabel(height) | |
| result += 'Auflösung: %dx%d (%s)\n' % (width, height, label) | |
| else: | |
| result += 'Auflösung: nicht aus Datei-Header ermittelbar\n' | |
| if codec: | |
| result += 'Video-Codec: %s\n' % _codecName(codec) | |
| if fps: | |
| if fps == int(fps): | |
| result += 'FPS: %d\n' % int(fps) | |
| else: | |
| fps_str = '%.3f' % fps | |
| result += 'FPS: %s\n' % fps_str.rstrip('0').rstrip('.') | |
| for at in audio_traks: | |
| parts = [_codecName(at.get('audio_codec', ''))] | |
| if at.get('audio_channels'): | |
| parts.append(_channelLabel(at['audio_channels'])) | |
| if at.get('audio_samplerate'): | |
| parts.append('%.1f kHz' % (at['audio_samplerate'] / 1000.0)) | |
| lang_str = _langName(at.get('lang', '')) | |
| if lang_str: | |
| result += 'Audio: %s — %s\n' % (', '.join(parts), lang_str) | |
| else: | |
| result += 'Audio: %s\n' % ', '.join(parts) | |
| if total_size > 0 and duration_sec and duration_sec > 0: | |
| bitrate = int(total_size * 8 / duration_sec) | |
| result += 'Bitrate: %s (Durchschnitt)\n' % _fmtBitrate(bitrate) | |
| if duration_sec and duration_sec > 0: | |
| hours = int(duration_sec) // 3600 | |
| mins = (int(duration_sec) % 3600) // 60 | |
| secs = int(duration_sec) % 60 | |
| if hours > 0: | |
| result += 'Dauer: %d:%02d:%02d\n' % (hours, mins, secs) | |
| else: | |
| result += 'Dauer: %d:%02d\n' % (mins, secs) | |
| if content_type and 'octet' not in content_type: | |
| result += 'Dateityp: %s\n' % content_type | |
| if total_size > 0: | |
| size_mb = total_size / (1024.0 * 1024.0) | |
| if size_mb >= 1024: | |
| result += 'Dateigröße: %.1f GB\n' % (size_mb / 1024.0) | |
| else: | |
| result += 'Dateigröße: %.0f MB\n' % size_mb | |
| return result.rstrip() | |
| except Exception as e: | |
| log_utils.log('_probeDirect Error: %s' % str(e), log_utils.LOGERROR) | |
| return None | |
| def _parseMp4(data): | |
| """Parse MP4 box structure to extract video/audio info. | |
| Returns (width, height, codec, duration_sec, fps, audio_traks).""" | |
| import struct | |
| if len(data) < 8: | |
| return None, None, None, None, None, [] | |
| width = height = None | |
| codec = None | |
| duration_sec = None | |
| fps = None | |
| current_trak = {} | |
| video_trak = None | |
| audio_traks = [] | |
| def read_boxes(data, start, end, depth=0): | |
| nonlocal duration_sec, current_trak, video_trak, audio_traks | |
| if depth > 10: | |
| return | |
| p = start | |
| while p < end - 8: | |
| try: | |
| box_size = struct.unpack('>I', data[p:p+4])[0] | |
| box_type = data[p+4:p+8] | |
| except: | |
| break | |
| if box_size < 8: | |
| break | |
| if p + box_size > end: | |
| box_size = end - p | |
| if box_type == b'moov': | |
| read_boxes(data, p + 8, p + box_size, depth + 1) | |
| elif box_type == b'trak': | |
| current_trak = {} | |
| read_boxes(data, p + 8, p + box_size, depth + 1) | |
| if 'codec' in current_trak and not video_trak: | |
| video_trak = current_trak | |
| elif 'audio_codec' in current_trak: | |
| audio_traks.append(current_trak) | |
| elif box_type in (b'mdia', b'minf', b'stbl'): | |
| read_boxes(data, p + 8, p + box_size, depth + 1) | |
| # mvhd — movie header with duration | |
| elif box_type == b'mvhd': | |
| version = data[p + 8] if p + 9 <= end else 0 | |
| if version == 0 and p + 28 <= end: | |
| ts = struct.unpack('>I', data[p+20:p+24])[0] | |
| dur = struct.unpack('>I', data[p+24:p+28])[0] | |
| if ts > 0 and dur > 0: | |
| duration_sec = float(dur) / ts | |
| elif version == 1 and p + 40 <= end: | |
| ts = struct.unpack('>I', data[p+28:p+32])[0] | |
| dur = struct.unpack('>Q', data[p+32:p+40])[0] | |
| if ts > 0 and dur > 0: | |
| duration_sec = float(dur) / ts | |
| # tkhd — track header with display dimensions (fallback) | |
| elif box_type == b'tkhd' and box_size >= 84: | |
| version = data[p + 8] if p + 9 <= end else 0 | |
| if version == 0 and p + 92 <= end: | |
| w_raw = struct.unpack('>I', data[p+84:p+88])[0] | |
| h_raw = struct.unpack('>I', data[p+88:p+92])[0] | |
| w, h = w_raw >> 16, h_raw >> 16 | |
| if 120 <= w <= 7680 and 90 <= h <= 4320: | |
| current_trak['tkhd_w'] = w | |
| current_trak['tkhd_h'] = h | |
| elif version == 1 and p + 104 <= end: | |
| w_raw = struct.unpack('>I', data[p+96:p+100])[0] | |
| h_raw = struct.unpack('>I', data[p+100:p+104])[0] | |
| w, h = w_raw >> 16, h_raw >> 16 | |
| if 120 <= w <= 7680 and 90 <= h <= 4320: | |
| current_trak['tkhd_w'] = w | |
| current_trak['tkhd_h'] = h | |
| # mdhd — media header with track timescale (for FPS) + language | |
| elif box_type == b'mdhd': | |
| version = data[p + 8] if p + 9 <= end else 0 | |
| if version == 0 and p + 24 <= end: | |
| ts = struct.unpack('>I', data[p+20:p+24])[0] | |
| if ts > 0: | |
| current_trak['mdhd_ts'] = ts | |
| if p + 30 <= end: | |
| lang = struct.unpack('>H', data[p+28:p+30])[0] | |
| lang_str = chr(((lang >> 10) & 0x1F) + 0x60) + chr(((lang >> 5) & 0x1F) + 0x60) + chr((lang & 0x1F) + 0x60) | |
| current_trak['lang'] = lang_str | |
| elif version == 1 and p + 32 <= end: | |
| ts = struct.unpack('>I', data[p+28:p+32])[0] | |
| if ts > 0: | |
| current_trak['mdhd_ts'] = ts | |
| if p + 42 <= end: | |
| lang = struct.unpack('>H', data[p+40:p+42])[0] | |
| lang_str = chr(((lang >> 10) & 0x1F) + 0x60) + chr(((lang >> 5) & 0x1F) + 0x60) + chr((lang & 0x1F) + 0x60) | |
| current_trak['lang'] = lang_str | |
| # stsd — sample description: codec + resolution/audio info | |
| elif box_type == b'stsd' and box_size > 24: | |
| if p + 24 <= end: | |
| codec_tag = data[p+20:p+24] | |
| is_video = False | |
| if codec_tag in (b'avc1', b'avc3'): | |
| current_trak['codec'] = 'avc1' | |
| is_video = True | |
| elif codec_tag in (b'hev1', b'hvc1'): | |
| current_trak['codec'] = 'hev1' | |
| is_video = True | |
| elif codec_tag in (b'av01',): | |
| current_trak['codec'] = 'av01' | |
| is_video = True | |
| elif codec_tag in (b'vp09',): | |
| current_trak['codec'] = 'vp09' | |
| is_video = True | |
| elif codec_tag == b'mp4v': | |
| current_trak['codec'] = 'mp4v' | |
| is_video = True | |
| # Video sample entry: width(uint16) at p+48, height at p+50 | |
| if is_video and p + 52 <= end: | |
| coded_w = struct.unpack('>H', data[p+48:p+50])[0] | |
| coded_h = struct.unpack('>H', data[p+50:p+52])[0] | |
| if 16 <= coded_w <= 7680 and 16 <= coded_h <= 4320: | |
| current_trak['stsd_w'] = coded_w | |
| current_trak['stsd_h'] = coded_h | |
| # Audio sample entry: channels at p+40, samplerate at p+48 | |
| if not is_video: | |
| audio_tags = {b'mp4a': 'mp4a', b'ac-3': 'ac-3', b'ec-3': 'ec-3', | |
| b'dtsh': 'dtsh', b'dtsl': 'dtsl', b'Opus': 'opus', | |
| b'opus': 'opus', b'fLaC': 'flac'} | |
| if codec_tag in audio_tags: | |
| current_trak['audio_codec'] = audio_tags[codec_tag] | |
| if p + 52 <= end: | |
| ch = struct.unpack('>H', data[p+40:p+42])[0] | |
| sr = struct.unpack('>I', data[p+48:p+52])[0] >> 16 | |
| if 1 <= ch <= 16: | |
| current_trak['audio_channels'] = ch | |
| if 8000 <= sr <= 192000: | |
| current_trak['audio_samplerate'] = sr | |
| # stts — sample-to-time: first delta gives frame duration | |
| elif box_type == b'stts' and p + 24 <= end: | |
| entry_count = struct.unpack('>I', data[p+12:p+16])[0] | |
| if entry_count >= 1: | |
| delta = struct.unpack('>I', data[p+20:p+24])[0] | |
| if delta > 0: | |
| current_trak['stts_delta'] = delta | |
| p += box_size | |
| try: | |
| read_boxes(data, 0, len(data)) | |
| except: | |
| pass | |
| if video_trak: | |
| codec = video_trak.get('codec') | |
| if 'stsd_w' in video_trak: | |
| width = video_trak['stsd_w'] | |
| height = video_trak['stsd_h'] | |
| elif 'tkhd_w' in video_trak: | |
| width = video_trak['tkhd_w'] | |
| height = video_trak['tkhd_h'] | |
| mdhd_ts = video_trak.get('mdhd_ts') | |
| stts_delta = video_trak.get('stts_delta') | |
| if mdhd_ts and stts_delta: | |
| fps = round(float(mdhd_ts) / stts_delta, 3) | |
| return width, height, codec, duration_sec, fps, audio_traks | |
| # --- Helper functions --- | |
| def _resLabel(h): | |
| if h >= 2160: return '4K UHD' | |
| if h >= 1440: return 'QHD' | |
| if h >= 1080: return 'Full HD' | |
| if h >= 720: return 'HD' | |
| if h >= 480: return 'SD' | |
| return '%dp' % h | |
| def _channelLabel(ch): | |
| if ch == 1: return 'Mono' | |
| if ch == 2: return 'Stereo' | |
| if ch == 6: return '5.1' | |
| if ch == 8: return '7.1' | |
| return '%d Kanäle' % ch | |
| def _codecName(raw): | |
| if not raw: return '' | |
| parts = [c.strip() for c in raw.split(',')] | |
| names = [] | |
| for p in parts: | |
| pl = p.lower() | |
| if pl.startswith('avc') or pl.startswith('h264') or pl == 'h.264': | |
| names.append('H.264') | |
| elif pl.startswith('hev') or pl.startswith('hvc') or pl.startswith('h265') or pl == 'h.265' or pl == 'hevc': | |
| names.append('H.265') | |
| elif pl.startswith('av01') or pl == 'av1': | |
| names.append('AV1') | |
| elif pl.startswith('vp9') or pl.startswith('vp09'): | |
| names.append('VP9') | |
| elif pl.startswith('mp4a.6b') or pl == 'mp3': | |
| names.append('MP3') | |
| elif pl.startswith('mp4a') or pl == 'aac': | |
| names.append('AAC') | |
| elif pl.startswith('ec-3') or pl.startswith('eac') or pl == 'e-ac-3': | |
| names.append('Dolby Digital+') | |
| elif pl.startswith('ac-3') or pl.startswith('ac3'): | |
| names.append('Dolby Digital') | |
| elif pl.startswith('dts'): | |
| names.append('DTS') | |
| elif pl.startswith('opus'): | |
| names.append('Opus') | |
| elif pl.startswith('flac'): | |
| names.append('FLAC') | |
| else: | |
| names.append(p) | |
| seen = set() | |
| unique = [] | |
| for n in names: | |
| if n not in seen: | |
| seen.add(n) | |
| unique.append(n) | |
| return ' + '.join(unique) | |
| def _langName(code): | |
| if not code: return '' | |
| names = { | |
| 'de': 'Deutsch', 'deu': 'Deutsch', 'ger': 'Deutsch', | |
| 'en': 'Englisch', 'eng': 'Englisch', | |
| 'fr': 'Französisch', 'fra': 'Französisch', 'fre': 'Französisch', | |
| 'es': 'Spanisch', 'spa': 'Spanisch', | |
| 'it': 'Italienisch', 'ita': 'Italienisch', | |
| 'ja': 'Japanisch', 'jpn': 'Japanisch', | |
| 'ko': 'Koreanisch', 'kor': 'Koreanisch', | |
| 'pt': 'Portugiesisch', 'por': 'Portugiesisch', | |
| 'ru': 'Russisch', 'rus': 'Russisch', | |
| 'tr': 'Türkisch', 'tur': 'Türkisch', | |
| 'zh': 'Chinesisch', 'zho': 'Chinesisch', 'chi': 'Chinesisch', | |
| 'ar': 'Arabisch', 'ara': 'Arabisch', | |
| 'hi': 'Hindi', 'hin': 'Hindi', | |
| 'nl': 'Niederländisch', 'nld': 'Niederländisch', 'dut': 'Niederländisch', | |
| 'pl': 'Polnisch', 'pol': 'Polnisch', | |
| 'sv': 'Schwedisch', 'swe': 'Schwedisch', | |
| 'da': 'Dänisch', 'dan': 'Dänisch', | |
| 'no': 'Norwegisch', 'nor': 'Norwegisch', | |
| 'fi': 'Finnisch', 'fin': 'Finnisch', | |
| 'cs': 'Tschechisch', 'ces': 'Tschechisch', 'cze': 'Tschechisch', | |
| 'el': 'Griechisch', 'ell': 'Griechisch', 'gre': 'Griechisch', | |
| 'he': 'Hebräisch', 'heb': 'Hebräisch', | |
| 'th': 'Thailändisch', 'tha': 'Thailändisch', | |
| 'uk': 'Ukrainisch', 'ukr': 'Ukrainisch', | |
| 'und': '', | |
| } | |
| return names.get(code.lower(), code.upper()) | |
| def _fmtBitrate(bw): | |
| if not bw or bw <= 0: return '' | |
| if bw >= 1000000: | |
| return '%.1f Mbit/s' % (bw / 1000000.0) | |
| return '%d kbit/s' % (bw / 1000) | |
| def _parseHeaders(url): | |
| headers = {} | |
| if '|' in url: | |
| try: | |
| header_str = url.split('|', 1)[1] | |
| headers = dict([item.split('=', 1) for item in header_str.split('&')]) | |
| for h in headers: | |
| headers[h] = control.unquote_plus(headers[h]) | |
| except: | |
| pass | |
| return headers |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # edit 2025-06-12 | |
| import sys | |
| import re, json, random, time | |
| from concurrent.futures import ThreadPoolExecutor | |
| from resources.lib import log_utils, utils, control | |
| from resources.lib.control import py2_decode, py2_encode, quote_plus, parse_qsl | |
| import resolveurl as resolver | |
| # from functools import reduce | |
| from resources.lib.control import getKodiVersion | |
| if int(getKodiVersion()) >= 20: from infotagger.listitem import ListItemInfoTag | |
| # für self.sysmeta - zur späteren verwendung als meta | |
| _params = dict(parse_qsl(sys.argv[2].replace('?',''))) if len(sys.argv) > 1 else dict() | |
| class sources: | |
| def __init__(self): | |
| self.getConstants() | |
| self.sources = [] | |
| self.current = int(time.time()) | |
| if 'sysmeta' in _params: self.sysmeta = _params['sysmeta'] # string zur späteren verwendung als meta | |
| self.watcher = False | |
| self.executor = ThreadPoolExecutor(max_workers=20) | |
| self.url = None | |
| def get(self, params): | |
| data = json.loads(params['sysmeta']) | |
| self.mediatype = data.get('mediatype') | |
| self.aliases = data.get('aliases') if 'aliases' in data else [] | |
| title = py2_encode(data.get('title')) | |
| originaltitle = py2_encode(data.get('originaltitle')) if 'originaltitle' in data else title | |
| year = data.get('year') if 'year' in data else None | |
| imdb = data.get('imdb_id') if 'imdb_id' in data else data.get('imdbnumber') if 'imdbnumber' in data else None | |
| if not imdb and 'imdb' in data: imdb = data.get('imdb') | |
| tmdb = data.get('tmdb_id') if 'tmdb_id' in data else None | |
| #if tmdb and not imdb: print 'hallo' #TODO | |
| season = data.get('season') if 'season' in data else 0 | |
| episode = data.get('episode') if 'episode' in data else 0 | |
| premiered = data.get('premiered') if 'premiered' in data else None | |
| meta = params['sysmeta'] | |
| select = data.get('select') if 'select' in data else None | |
| return title, year, imdb, season, episode, originaltitle, premiered, meta, select | |
| def play(self, params): | |
| title, year, imdb, season, episode, originaltitle, premiered, meta, select = self.get(params) | |
| try: | |
| url = None | |
| #Liste der gefundenen Streams | |
| items = self.getSources(title, year, imdb, season, episode, originaltitle, premiered) | |
| select = control.getSetting('hosts.mode') if select == None else select | |
| ## unnötig | |
| #select = '1' if control.getSetting('downloads') == 'true' and not (control.getSetting('download.movie.path') == '' or control.getSetting('download.tv.path') == '') else select | |
| # # TODO überprüfen wofür mal gedacht | |
| # if control.window.getProperty('PseudoTVRunning') == 'True': | |
| # return control.resolveUrl(int(sys.argv[1]), True, control.item(path=str(self.sourcesDirect(items)))) | |
| if len(items) > 0: | |
| # Auswahl Verzeichnis | |
| if select == '1' and 'plugin' in control.infoLabel('Container.PluginName'): | |
| control.window.clearProperty(self.itemsProperty) | |
| control.window.setProperty(self.itemsProperty, json.dumps(items)) | |
| control.window.clearProperty(self.metaProperty) | |
| control.window.setProperty(self.metaProperty, meta) | |
| control.sleep(2) | |
| return control.execute('Container.Update(%s?action=addItem&title=%s)' % (sys.argv[0], quote_plus(title))) | |
| # Auswahl Dialog | |
| elif select == '0' or select == '1': | |
| url = self.sourcesDialog(items) | |
| if url == 'close://': return | |
| # Autoplay | |
| else: | |
| url = self.sourcesDirect(items) | |
| if url == None: return self.errorForSources() | |
| try: meta = json.loads(meta) | |
| except: pass | |
| from resources.lib.player import player | |
| player().run(title, url, meta) | |
| except Exception as e: | |
| log_utils.log('Error %s' % str(e), log_utils.LOGERROR) | |
| # Liste gefundene Streams Indexseite|Hoster | |
| def addItem(self, title): | |
| control.playlist.clear() | |
| items = control.window.getProperty(self.itemsProperty) | |
| items = json.loads(items) | |
| if items == None or len(items) == 0: control.idle() ; sys.exit() | |
| sysaddon = sys.argv[0] | |
| syshandle = int(sys.argv[1]) | |
| systitle = sysname = quote_plus(title) | |
| meta = control.window.getProperty(self.metaProperty) | |
| meta = json.loads(meta) | |
| #TODO | |
| if meta['mediatype'] == 'movie': | |
| # downloads = True if control.getSetting('downloads') == 'true' and control.exists(control.translatePath(control.getSetting('download.movie.path'))) else False | |
| downloads = True if control.getSetting('downloads') == 'true' and control.getSetting('download.movie.path') else False | |
| else: | |
| # downloads = True if control.getSetting('downloads') == 'true' and control.exists(control.translatePath(control.getSetting('download.tv.path'))) else False | |
| downloads = True if control.getSetting('downloads') == 'true' and control.getSetting('download.tv.path') else False | |
| addonPoster, addonBanner = control.addonPoster(), control.addonBanner() | |
| addonFanart, settingFanart = control.addonFanart(), control.getSetting('fanart') | |
| if 'backdrop_url' in meta and 'http' in meta['backdrop_url']: fanart = meta['backdrop_url'] | |
| elif 'fanart' in meta and 'http' in meta['fanart']: fanart = meta['fanart'] | |
| else: fanart = addonFanart | |
| if 'cover_url' in meta and 'http' in meta['cover_url']: poster = meta['cover_url'] | |
| elif 'poster' in meta and 'http' in meta['poster']: poster = meta['poster'] | |
| else: poster = addonPoster | |
| sysimage = poster | |
| if 'season' in meta and 'episode' in meta: | |
| sysname += quote_plus(' S%02dE%02d' % (int(meta['season']), int(meta['episode']))) | |
| elif 'year' in meta: | |
| sysname += quote_plus(' (%s)' % meta['year']) | |
| for i in range(len(items)): | |
| try: | |
| label = items[i]['label'] | |
| syssource = quote_plus(json.dumps([items[i]])) | |
| item = control.item(label=label, offscreen=True) | |
| item.setProperty('IsPlayable', 'true') | |
| item.setArt({'poster': poster, 'banner': addonBanner}) | |
| if settingFanart == 'true': item.setProperty('Fanart_Image', fanart) | |
| cm = [] | |
| if downloads: | |
| cm.append(("Download", 'RunPlugin(%s?action=download&name=%s&image=%s&source=%s)' % (sysaddon, sysname, sysimage, syssource))) | |
| if control.getSetting('jd_enabled') == 'true': | |
| cm.append(("Sende zum JDownloader", 'RunPlugin(%s?action=sendToJD&name=%s&source=%s)' % (sysaddon, sysname, syssource))) | |
| if control.getSetting('jd2_enabled') == 'true': | |
| cm.append(("Sende zum JDownloader2", 'RunPlugin(%s?action=sendToJD2&name=%s&source=%s)' % (sysaddon, sysname, syssource))) | |
| if control.getSetting('myjd_enabled') == 'true': | |
| cm.append(("Sende zu My.JDownloader", 'RunPlugin(%s?action=sendToMyJD&name=%s&source=%s)' % (sysaddon, sysname, syssource))) | |
| if control.getSetting('pyload_enabled') == 'true': | |
| cm.append(("Sende zu PyLoad", 'RunPlugin(%s?action=sendToPyLoad&name=%s&source=%s)' % (sysaddon, sysname, syssource))) | |
| cm.append(("Medien-Info", 'RunPlugin(%s?action=mediaInfo&source=%s)' % (sysaddon, syssource))) | |
| cm.append(('Einstellungen', 'RunPlugin(%s?action=addonSettings)' % sysaddon)) | |
| item.addContextMenuItems(cm) | |
| url = "%s?action=playItem&title=%s&source=%s" % (sysaddon, systitle, syssource) | |
| # ## Notwendig für Library Exporte ## | |
| # ## Amazon Scraper Details ## | |
| # if "amazon" in label.lower(): | |
| # aid = re.search(r'asin%3D(.*?)%22%2C', url) | |
| # url = "plugin://plugin.video.amazon-test/?mode=PlayVideo&asin=" + aid.group(1) | |
| ##https: // codedocs.xyz / AlwinEsch / kodi / group__python__xbmcgui__listitem.html # ga0b71166869bda87ad744942888fb5f14 | |
| name = '%s%sStaffel: %s Episode: %s' % (title, "\n", meta['season'], meta['episode']) if 'season' in meta else title | |
| plot = meta['plot'] if 'plot' in meta and len(meta['plot'].strip()) >= 1 else '' | |
| plot = '[COLOR blue]%s[/COLOR]%s%s' % (name, "\n\n", py2_encode(plot)) | |
| if 'duration' in meta: | |
| infolable = {'plot': plot,'duration': meta['duration']} | |
| else: | |
| infolable = {'plot': plot} | |
| # TODO | |
| # if 'cast' in meta and meta['cast']: item.setCast(meta['cast']) | |
| # # # remove unsupported InfoLabels | |
| meta.pop('cast', None) # ersetzt durch item.setCast(i['cast']) | |
| meta.pop('number_of_seasons', None) | |
| meta.pop('imdb_id', None) | |
| meta.pop('tvdb_id', None) | |
| meta.pop('tmdb_id', None) | |
| ## Quality Video Stream from source.append quality - items[i]['quality'] | |
| video_streaminfo ={} | |
| if "4k" in items[i]['quality'].lower(): | |
| video_streaminfo.update({'width': 3840, 'height': 2160}) | |
| elif "1080p" in items[i]['quality'].lower(): | |
| video_streaminfo.update({'width': 1920, 'height': 1080}) | |
| elif "hd" in items[i]['quality'].lower() or "720p" in items[i]['quality'].lower(): | |
| video_streaminfo.update({'width': 1280,'height': 720}) | |
| else: | |
| # video_streaminfo.update({"width": 720, "height": 576}) | |
| video_streaminfo.update({}) | |
| ## Codec for Video Stream from extra info - items[i]['info'] | |
| if 'hevc' in items[i]['label'].lower(): | |
| video_streaminfo.update({'codec': 'hevc'}) | |
| elif '265' in items[i]['label'].lower(): | |
| video_streaminfo.update({'codec': 'h265'}) | |
| elif 'mkv' in items[i]['label'].lower(): | |
| video_streaminfo.update({'codec': 'mkv'}) | |
| elif 'mp4' in items[i]['label'].lower(): | |
| video_streaminfo.update({'codec': 'mp4'}) | |
| else: | |
| # video_streaminfo.update({'codec': 'h264'}) | |
| video_streaminfo.update({'codec': ''}) | |
| ## Quality & Channels Audio Stream from extra info - items[i]['info'] | |
| audio_streaminfo = {} | |
| if 'dts' in items[i]['label'].lower(): | |
| audio_streaminfo.update({'codec': 'dts'}) | |
| elif 'plus' in items[i]['label'].lower() or 'e-ac3' in items[i]['label'].lower(): | |
| audio_streaminfo.update({'codec': 'eac3'}) | |
| elif 'dolby' in items[i]['label'].lower() or 'ac3' in items[i]['label'].lower(): | |
| audio_streaminfo.update({'codec': 'ac3'}) | |
| else: | |
| # audio_streaminfo.update({'codec': 'aac'}) | |
| audio_streaminfo.update({'codec': ''}) | |
| ## Channel update ## | |
| if '7.1' in items[i].get('info','').lower(): | |
| audio_streaminfo.update({'channels': 8}) | |
| elif '5.1' in items[i].get('info','').lower(): | |
| audio_streaminfo.update({'channels': 6}) | |
| else: | |
| # audio_streaminfo.update({'channels': 2}) | |
| audio_streaminfo.update({'channels': ''}) | |
| if int(getKodiVersion()) <= 19: | |
| item.setInfo(type='Video', infoLabels=infolable) | |
| item.addStreamInfo('video', video_streaminfo) | |
| item.addStreamInfo('audio', audio_streaminfo) | |
| else: | |
| info_tag = ListItemInfoTag(item, 'video') | |
| info_tag.set_info(infolable) | |
| stream_details = { | |
| 'video': [video_streaminfo], | |
| 'audio': [audio_streaminfo]} | |
| info_tag.set_stream_details(stream_details) | |
| # info_tag.set_cast(aActors) | |
| control.addItem(handle=syshandle, url=url, listitem=item, isFolder=False) | |
| except: | |
| pass | |
| control.content(syshandle, 'videos') | |
| control.plugincategory(syshandle, control.addonVersion) | |
| control.endofdirectory(syshandle, cacheToDisc=True) | |
| def playItem(self, title, source): | |
| isDebug = False | |
| if isDebug: log_utils.log('start playItem', log_utils.LOGWARNING) | |
| try: | |
| meta = control.window.getProperty(self.metaProperty) | |
| meta = json.loads(meta) | |
| header = control.addonInfo('name') | |
| # control.idle() #ok | |
| progressDialog = control.progressDialog if control.getSetting('progress.dialog') == '0' else control.progressDialogBG | |
| progressDialog.create(header, '') | |
| progressDialog.update(0) | |
| item = json.loads(source)[0] | |
| #if isDebug: log_utils.log('playItem 237', log_utils.LOGWARNING) | |
| if item['source'] == None: raise Exception() | |
| future = self.executor.submit(self.sourcesResolve, item) | |
| waiting_time = 30 | |
| while waiting_time > 0: | |
| try: | |
| if control.abortRequested: return sys.exit() | |
| if progressDialog.iscanceled(): return progressDialog.close() | |
| except: | |
| pass | |
| if future.done(): break | |
| control.sleep(1) | |
| waiting_time = waiting_time - 1 | |
| progressDialog.update(int(100 - 100. / 30 * waiting_time), str(item['label'])) | |
| #if isDebug: log_utils.log('playItem 252', log_utils.LOGWARNING) | |
| if control.condVisibility('Window.IsActive(virtualkeyboard)') or \ | |
| control.condVisibility('Window.IsActive(yesnoDialog)'): | |
| # or control.condVisibility('Window.IsActive(PopupRecapInfoWindow)'): | |
| waiting_time = waiting_time + 1 # dont count down while dialog is presented | |
| if future.done(): break | |
| try: progressDialog.close() | |
| except: pass | |
| if isDebug: log_utils.log('playItem 261', log_utils.LOGWARNING) | |
| control.execute('Dialog.Close(virtualkeyboard)') | |
| control.execute('Dialog.Close(yesnoDialog)') | |
| if isDebug: log_utils.log('playItem url: %s' % self.url, log_utils.LOGWARNING) | |
| if self.url == None: | |
| #self.errorForSources() | |
| return | |
| from resources.lib.player import player | |
| player().run(title, self.url, meta) | |
| return self.url | |
| except Exception as e: | |
| log_utils.log('Error %s' % str(e), log_utils.LOGERROR) | |
| def getSources(self, title, year, imdb, season, episode, originaltitle, premiered, quality='HD', timeout=30): | |
| #TODO | |
| # self._getHostDict() | |
| control.idle() #ok | |
| progressDialog = control.progressDialog if control.getSetting('progress.dialog') == '0' else control.progressDialogBG | |
| progressDialog.create(control.addonInfo('name'), '') | |
| progressDialog.update(0) | |
| progressDialog.update(0, "Quellen werden vorbereitet") | |
| sourceDict = self.sourceDict | |
| sourceDict = [(i[0], i[1], i[1].priority) for i in sourceDict] | |
| random.shuffle(sourceDict) | |
| sourceDict = sorted(sourceDict, key=lambda i: i[2]) | |
| content = 'movies' if season == 0 or season == '' or season == None else 'shows' | |
| aliases, localtitle = utils.getAliases(imdb, content) | |
| if localtitle and title != localtitle and originaltitle != localtitle: | |
| if not title in aliases: aliases.append(title) | |
| title = localtitle | |
| for i in self.aliases: | |
| if not i in aliases: | |
| aliases.append(i) | |
| titles = utils.get_titles_for_search(title, originaltitle, aliases) | |
| futures = {self.executor.submit(self._getSource, titles, year, season, episode, imdb, provider[0], provider[1]): provider[0] for provider in sourceDict} | |
| provider_names = {provider[0].upper() for provider in sourceDict} | |
| string4 = "Total" | |
| try: timeout = int(control.getSetting('scrapers.timeout')) | |
| except: pass | |
| quality = control.getSetting('hosts.quality') | |
| if quality == '': quality = '0' | |
| source_4k = 0 | |
| source_1080 = 0 | |
| source_720 = 0 | |
| source_sd = 0 | |
| total = d_total = 0 | |
| total_format = '[COLOR %s][B]%s[/B][/COLOR]' | |
| pdiag_format = ' 4K: %s | 1080p: %s | 720p: %s | SD: %s | %s: %s '.split('|') | |
| for i in range(0, 4 * timeout): | |
| try: | |
| if control.abortRequested: return sys.exit() | |
| try: | |
| if progressDialog.iscanceled(): break | |
| except: | |
| pass | |
| if len(self.sources) > 0: | |
| if quality in ['0']: | |
| source_4k = len([e for e in self.sources if e['quality'] == '4K']) | |
| source_1080 = len([e for e in self.sources if e['quality'] in ['1440p','1080p']]) | |
| source_720 = len([e for e in self.sources if e['quality'] in ['720p','HD']]) | |
| source_sd = len([e for e in self.sources if e['quality'] not in ['4K','1440p','1080p','720p','HD']]) | |
| elif quality in ['1']: | |
| source_1080 = len([e for e in self.sources if e['quality'] in ['1440p','1080p']]) | |
| source_720 = len([e for e in self.sources if e['quality'] in ['720p','HD']]) | |
| source_sd = len([e for e in self.sources if e['quality'] not in ['4K','1440p','1080p','720p','HD']]) | |
| elif quality in ['2']: | |
| source_1080 = len([e for e in self.sources if e['quality'] in ['1080p']]) | |
| source_720 = len([e for e in self.sources if e['quality'] in ['720p','HD']]) | |
| source_sd = len([e for e in self.sources if e['quality'] not in ['4K','1440p','1080p','720p','HD']]) | |
| elif quality in ['3']: | |
| source_720 = len([e for e in self.sources if e['quality'] in ['720p','HD']]) | |
| source_sd = len([e for e in self.sources if e['quality'] not in ['4K','1440p','1080p','720p','HD']]) | |
| else: | |
| source_sd = len([e for e in self.sources if e['quality'] not in ['4K','1440p','1080p','720p','HD']]) | |
| total = source_4k + source_1080 + source_720 + source_sd | |
| source_4k_label = total_format % ('red', source_4k) if source_4k == 0 else total_format % ('lime', source_4k) | |
| source_1080_label = total_format % ('red', source_1080) if source_1080 == 0 else total_format % ('lime', source_1080) | |
| source_720_label = total_format % ('red', source_720) if source_720 == 0 else total_format % ('lime', source_720) | |
| source_sd_label = total_format % ('red', source_sd) if source_sd == 0 else total_format % ('lime', source_sd) | |
| source_total_label = total_format % ('red', total) if total == 0 else total_format % ('lime', total) | |
| try: | |
| info = [name.upper() for future, name in futures.items() if not future.done()] | |
| percent = int(100 * float(i) / (2 * timeout) + 1) | |
| if quality in ['0']: | |
| line1 = '|'.join(pdiag_format) % (source_4k_label, source_1080_label, source_720_label, source_sd_label, str(string4), source_total_label) | |
| elif quality in ['1']: | |
| line1 = '|'.join(pdiag_format[1:]) % (source_1080_label, source_720_label, source_sd_label, str(string4), source_total_label) | |
| elif quality in ['2']: | |
| line1 = '|'.join(pdiag_format[1:]) % (source_1080_label, source_720_label, source_sd_label, str(string4), source_total_label) | |
| elif quality in ['3']: | |
| line1 = '|'.join(pdiag_format[2:]) % (source_720_label, source_sd_label, str(string4), source_total_label) | |
| else: | |
| line1 = '|'.join(pdiag_format[3:]) % (source_sd_label, str(string4), source_total_label) | |
| if (i / 2) < timeout: | |
| string = "Verbleibende Indexseiten: %s" | |
| else: | |
| string = 'Waiting for: %s' | |
| if len(info) > 6: line = line1 + string % (str(len(info))) | |
| elif len(info) > 1: line = line1 + string % (', '.join(info)) | |
| elif len(info) == 1: line = line1 + string % (''.join(info)) | |
| else: line = line1 + 'Suche beendet!' | |
| progressDialog.update(max(1, percent), line) | |
| if len(info) == 0: break | |
| except Exception as e: | |
| log_utils.log('Exception Raised: %s' % str(e), log_utils.LOGERROR) | |
| control.sleep(1) | |
| except: | |
| pass | |
| time.sleep(1) | |
| try: progressDialog.close() | |
| except: pass | |
| self.sourcesFilter() | |
| return self.sources | |
| def _getSource(self, titles, year, season, episode, imdb, source, call): | |
| try: | |
| sources = call.run(titles, year, season, episode, imdb) # kasi self.hostDict | |
| if sources == None or sources == []: raise Exception() | |
| sources = [json.loads(t) for t in set(json.dumps(d, sort_keys=True) for d in sources)] | |
| for i in sources: | |
| i.update({'provider': source}) | |
| if not 'priority' in i: i.update({'priority': 100}) | |
| if not 'prioHoster' in i: i.update({'prioHoster': 100}) | |
| self.sources.extend(sources) | |
| except: | |
| pass | |
| def sourcesFilter(self): | |
| # hostblockDict = utils.getHostDict() | |
| # self.sources = [i for i in self.sources if i['source'].split('.')[0] not in str(hostblockDict)] # Hoster ausschließen (Liste) | |
| quality = control.getSetting('hosts.quality') | |
| if quality == '': quality = '0' | |
| random.shuffle(self.sources) | |
| self.sources = sorted(self.sources, key=lambda k: k['prioHoster'], reverse=False) | |
| for i in range(len(self.sources)): | |
| q = self.sources[i]['quality'] | |
| if q.lower() == 'hd': self.sources[i].update({'quality': '720p'}) | |
| filter = [] | |
| if quality in ['0']: filter += [i for i in self.sources if i['quality'] == '4K'] | |
| if quality in ['0', '1']: filter += [i for i in self.sources if i['quality'] == '1440p'] | |
| if quality in ['0', '1', '2']: filter += [i for i in self.sources if i['quality'] == '1080p'] | |
| if quality in ['0', '1', '2', '3']: filter += [i for i in self.sources if i['quality'] == '720p'] | |
| #filter += [i for i in self.sources if i['quality'] in ['SD', 'SCR', 'CAM']] | |
| filter += [i for i in self.sources if i['quality'] not in ['4k', '1440p', '1080p', '720p']] | |
| self.sources = filter | |
| if control.getSetting('hosts.sort.provider') == 'true': | |
| self.sources = sorted(self.sources, key=lambda k: k['provider']) | |
| if control.getSetting('hosts.sort.priority') == 'true' and self.mediatype == 'tvshow': self.sources = sorted(self.sources, key=lambda k: k['priority'], reverse=False) | |
| if str(control.getSetting('hosts.limit')) == 'true': | |
| self.sources = self.sources[:int(control.getSetting('hosts.limit.num'))] | |
| else: | |
| self.sources = self.sources[:100] | |
| for i in range(len(self.sources)): | |
| p = self.sources[i]['provider'] | |
| q = self.sources[i]['quality'] | |
| s = self.sources[i]['source'] | |
| ## s = s.rsplit('.', 1)[0] | |
| l = self.sources[i]['language'] | |
| try: f = (' | '.join(['[I]%s [/I]' % info.strip() for info in self.sources[i]['info'].split('|')])) | |
| except: f = '' | |
| label = '%02d | [B]%s[/B] | ' % (int(i + 1), p) | |
| if q in ['4K', '1440p', '1080p', '720p']: label += '%s | [B][I]%s [/I][/B] | %s' % (s, q, f) | |
| elif q == 'SD': label += '%s | %s' % (s, f) | |
| else: label += '%s | %s | [I]%s [/I]' % (s, f, q) | |
| label = label.replace('| 0 |', '|').replace(' | [I]0 [/I]', '') | |
| label = re.sub(r'\[I\]\s+\[/I\]', ' ', label) | |
| label = re.sub(r'\|\s+\|', '|', label) | |
| label = re.sub(r'\|(?:\s+|)$', '', label) | |
| self.sources[i]['label'] = label.upper() | |
| # ## EMBY shown as premium link ## | |
| # if self.sources[i]['provider']=="emby" or self.sources[i]['provider']=="amazon" or self.sources[i]['provider']=="netflix" or self.sources[i]['provider']=="maxdome": | |
| # prem_identify = 'blue' | |
| # self.sources[i]['label'] = ('[COLOR %s]' % (prem_identify)) + label.upper() + '[/COLOR]' | |
| self.sources = [i for i in self.sources if 'label' in i] | |
| return self.sources | |
| def sourcesResolve(self, item, info=False): | |
| try: | |
| self.url = None | |
| url = item['url'] | |
| direct = item['direct'] | |
| local = item.get('local', False) | |
| provider = item['provider'] | |
| call = [i[1] for i in self.sourceDict if i[0] == provider][0] | |
| url = call.resolve(url) | |
| if not direct == True: | |
| try: | |
| hmf = resolver.HostedMediaFile(url=url, include_disabled=True, include_universal=False) | |
| if hmf.valid_url(): | |
| url = hmf.resolve() | |
| if url == False or url == None or url == '': url = None # raise Exception() | |
| except: | |
| url = None | |
| if url == None or (not '://' in str(url) and not local): | |
| log_utils.log('Kein Video Link gefunden: Provider %s / %s / %s ' % (item['provider'], item['source'] , str(item['source'])), log_utils.LOGERROR) | |
| raise Exception() | |
| # if not utils.test_stream(url): | |
| # log_utils.log('URL Test Error: %s' % url, log_utils.LOGERROR) | |
| # raise Exception() | |
| # url = utils.m3u8_check(url) | |
| if url: | |
| self.url = url | |
| return url | |
| else: | |
| raise Exception() | |
| except: | |
| if info: self.errorForSources() | |
| return | |
| def sourcesDialog(self, items): | |
| labels = [i['label'] for i in items] | |
| select = control.selectDialog(labels) | |
| if select == -1: return 'close://' | |
| next = [y for x,y in enumerate(items) if x >= select] | |
| prev = [y for x,y in enumerate(items) if x < select][::-1] | |
| items = [items[select]] | |
| items = [i for i in items+next+prev][:40] | |
| header = control.addonInfo('name') | |
| header2 = header.upper() | |
| progressDialog = control.progressDialog if control.getSetting('progress.dialog') == '0' else control.progressDialogBG | |
| progressDialog.create(header, '') | |
| progressDialog.update(0) | |
| block = None | |
| try: | |
| for i in range(len(items)): | |
| try: | |
| if items[i]['source'] == block: raise Exception() | |
| future = self.executor.submit(self.sourcesResolve, items[i]) | |
| try: | |
| if progressDialog.iscanceled(): break | |
| progressDialog.update(int((100 / float(len(items))) * i), str(items[i]['label'])) | |
| except: | |
| progressDialog.update(int((100 / float(len(items))) * i), str(header2) + str(items[i]['label'])) | |
| waiting_time = 30 | |
| while waiting_time > 0: | |
| try: | |
| if control.abortRequested: return sys.exit() #xbmc.Monitor().abortRequested() | |
| if progressDialog.iscanceled(): return progressDialog.close() | |
| except: | |
| pass | |
| if future.done(): break | |
| control.sleep(1) | |
| waiting_time = waiting_time - 1 | |
| if control.condVisibility('Window.IsActive(virtualkeyboard)') or \ | |
| control.condVisibility('Window.IsActive(yesnoDialog)') or \ | |
| control.condVisibility('Window.IsActive(ProgressDialog)'): | |
| waiting_time = waiting_time + 1 #dont count down while dialog is presented ## control.condVisibility('Window.IsActive(PopupRecapInfoWindow)') or \ | |
| if not future.done(): block = items[i]['source'] | |
| if self.url == None: raise Exception() | |
| self.selectedSource = items[i]['label'] | |
| try: progressDialog.close() | |
| except: pass | |
| control.execute('Dialog.Close(virtualkeyboard)') | |
| control.execute('Dialog.Close(yesnoDialog)') | |
| return self.url | |
| except: | |
| pass | |
| try: progressDialog.close() | |
| except: pass | |
| except Exception as e: | |
| try: progressDialog.close() | |
| except: pass | |
| log_utils.log('Error %s' % str(e), log_utils.LOGINFO) | |
| def sourcesDirect(self, items): | |
| # TODO - OK | |
| # filter = [i for i in items if i['source'].lower() in self.hostcapDict and i['debrid'] == ''] | |
| # items = [i for i in items if not i in filter] | |
| # items = [i for i in items if ('autoplay' in i and i['autoplay'] == True) or not 'autoplay' in i] | |
| u = None | |
| header = control.addonInfo('name') | |
| header2 = header.upper() | |
| try: | |
| control.sleep(1) | |
| progressDialog = control.progressDialog if control.getSetting('progress.dialog') == '0' else control.progressDialogBG | |
| progressDialog.create(header, '') | |
| progressDialog.update(0) | |
| except: | |
| pass | |
| for i in range(len(items)): | |
| try: | |
| if progressDialog.iscanceled(): break | |
| progressDialog.update(int((100 / float(len(items))) * i), str(items[i]['label'])) | |
| except: | |
| progressDialog.update(int((100 / float(len(items))) * i), str(header2) + str(items[i]['label'])) | |
| try: | |
| if control.abortRequested: return sys.exit() | |
| url = self.sourcesResolve(items[i]) | |
| if u == None: u = url | |
| if not url == None: break | |
| except: | |
| pass | |
| try: progressDialog.close() | |
| except: pass | |
| return u | |
| def mediaInfo(self, source, dialog=None): | |
| import xbmcgui | |
| try: | |
| import urllib3 | |
| urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) | |
| except: pass | |
| try: | |
| item = json.loads(source)[0] | |
| if item['source'] is None: | |
| raise Exception() | |
| import time as _time | |
| from resources.lib.mediainfo import TOTAL_TIMEOUT | |
| deadline = _time.time() + TOTAL_TIMEOUT | |
| if dialog is None: | |
| dialog = xbmcgui.DialogProgress() | |
| dialog.create('Medien-Info', 'Löse Stream-URL auf... (%d Sek.)' % TOTAL_TIMEOUT) | |
| dialog.update(0) | |
| future = self.executor.submit(self.sourcesResolve, item) | |
| # Wait for resolve with responsive cancel (check every 250ms) | |
| for i in range(120): # 120 * 250ms = 30s max | |
| remaining = int(deadline - _time.time()) | |
| if remaining > 0: | |
| dialog.update(int(50.0 * i / 120), 'Löse Stream-URL auf... (%d Sek.)' % remaining) | |
| else: | |
| dialog.update(int(50.0 * i / 120), 'Löse Stream-URL auf...') | |
| try: | |
| if dialog.iscanceled(): | |
| try: dialog.close() | |
| except: pass | |
| return | |
| except: pass | |
| if future.done(): | |
| break | |
| control.sleep(250) | |
| # Don't count down while resolver shows interactive dialogs | |
| if control.condVisibility('Window.IsActive(virtualkeyboard)') or \ | |
| control.condVisibility('Window.IsActive(yesnoDialog)'): | |
| continue | |
| url = self.url if future.done() else None | |
| control.execute('Dialog.Close(virtualkeyboard)') | |
| control.execute('Dialog.Close(yesnoDialog)') | |
| try: | |
| if dialog.iscanceled(): | |
| try: dialog.close() | |
| except: pass | |
| return | |
| except: pass | |
| if url is None: | |
| try: dialog.close() | |
| except: pass | |
| control.infoDialog("Stream-URL konnte nicht aufgelöst werden", sound=False, icon='INFO') | |
| return | |
| dialog.update(50, 'Analysiere Stream...') | |
| from resources.lib import mediainfo | |
| info = mediainfo.getMediaInfo(url, dialog, deadline) | |
| try: dialog.close() | |
| except: pass | |
| if info: | |
| xbmcgui.Dialog().textviewer('Medien-Info', info) | |
| else: | |
| control.infoDialog("Auflösung konnte nicht ermittelt werden", sound=False, icon='INFO') | |
| except Exception as e: | |
| try: | |
| if dialog: dialog.close() | |
| except: pass | |
| log_utils.log('mediaInfo Error: %s' % str(e), log_utils.LOGERROR) | |
| control.infoDialog("Auflösung konnte nicht ermittelt werden", sound=False, icon='INFO') | |
| def errorForSources(self): | |
| control.infoDialog("Keine Streams verfügbar oder ausgewählt", sound=False, icon='INFO') | |
| def getTitle(self, title): | |
| title = utils.normalize(title) | |
| return title | |
| def getConstants(self): | |
| self.itemsProperty = '%s.container.items' % control.Addon.getAddonInfo('id') | |
| self.metaProperty = '%s.container.meta' % control.Addon.getAddonInfo('id') | |
| from scrapers import sources | |
| self.sourceDict = sources() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment