# Copyright (C) 2024 Lunatixz # # # This file is part of PseudoTV Live. # # PseudoTV Live is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # PseudoTV Live is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with PseudoTV Live. If not, see . # # -*- coding: utf-8 -*- import os, sys, re, json, struct, errno, zlib import shutil, subprocess, io, platform import codecs, random import uuid, base64, binascii, hashlib import time, datetime, calendar import heapq, requests, pyqrcode import xml.sax.saxutils from six.moves import urllib from io import StringIO, BytesIO from threading import Lock, Thread, Event, Timer, BoundedSemaphore from threading import enumerate as thread_enumerate from xml.dom.minidom import parse, parseString, Document from xml.etree.ElementTree import ElementTree, Element, SubElement, XMLParser, fromstringlist, fromstring, tostring from xml.etree.ElementTree import parse as ETparse from typing import Dict, List, Union, Optional from variables import * from kodi_six import xbmc, xbmcaddon, xbmcplugin, xbmcgui, xbmcvfs from contextlib import contextmanager, closing from socket import gethostbyname, gethostname from itertools import cycle, chain, zip_longest, islice from xml.sax.saxutils import escape, unescape from operator import itemgetter from logger import * from cache import Cache, cacheit from pool import killit, timeit, poolit, executeit, timerit, threadit from kodi import * from fileaccess import FileAccess, FileLock from collections import defaultdict, Counter, OrderedDict from six.moves import urllib from math import ceil, floor from infotagger.listitem import ListItemInfoTag from requests.adapters import HTTPAdapter, Retry DIALOG = Dialog() PROPERTIES = DIALOG.properties SETTINGS = DIALOG.settings LISTITEMS = DIALOG.listitems BUILTIN = DIALOG.builtin def slugify(s, lowercase=False): if lowercase: s = s.lower() s = s.strip() s = re.sub(r'[^\w\s-]', '', s) s = re.sub(r'[\s_-]+', '_', s) s = re.sub(r'^-+|-+$', '', s) return s def validString(s): return "".join(x for x in s if (x.isalnum() or x not in '\\/:*?"<>|')) def stripNumber(s): return re.sub(r'\d+','',s) def stripRegion(s): match = re.compile(r'(.*) \((.*)\)', re.IGNORECASE).search(s) try: return match.group(1) except: return s def chanceBool(percent=25): return random.randrange(100) <= percent def decodePlot(text: str = '') -> dict: plot = re.search(r'\[COLOR item=\"(.+?)\"]\[/COLOR]', text) if plot: return loadJSON(decodeString(plot.group(1))) return {} def encodePlot(plot, text): return '%s [COLOR item="%s"][/COLOR]'%(plot,encodeString(dumpJSON(text))) def escapeString(text, table=HTML_ESCAPE): return escape(text,table) def unescapeString(text, table=HTML_ESCAPE): return unescape(text,{v:k for k, v in list(table.items())}) def getJSON(file): data = {} try: fle = FileAccess.open(file,'r') data = loadJSON(fle.read()) except Exception as e: log('Globals: getJSON failed! %s\nfile = %s'%(e,file), xbmc.LOGERROR) fle.close() return data def setJSON(file, data): with FileLock(): fle = FileAccess.open(file, 'w') fle.write(dumpJSON(data, idnt=4, sortkey=False)) fle.close() return True def requestURL(url, params={}, payload={}, header=HEADER, timeout=FIFTEEN, json_data=False, cache=None, checksum=ADDON_VERSION, life=datetime.timedelta(minutes=15)): def __error(json_data): return {} if json_data else "" def __getCache(key,json_data,cache,checksum): return (cache.get('requestURL.%s'%(key), checksum, json_data) or __error(json_data)) def __setCache(key,results,json_data,cache,checksum,life): return cache.set('requestURL.%s'%(key), results, checksum, life, json_data) complete = False cacheKey = '.'.join([url,dumpJSON(params),dumpJSON(payload),dumpJSON(header)]) session = requests.Session() retries = Retry(total=3, backoff_factor=1, status_forcelist=[500, 502, 503, 504]) adapter = HTTPAdapter(max_retries=retries) session.mount("http://", adapter) session.mount("https://", adapter) try: headers = HEADER.copy() headers.update(header) if payload: response = session.post(url, data=dumpJSON(payload), headers=headers, timeout=timeout) else: response = session.get(url, params=params, headers=headers, timeout=timeout) response.raise_for_status() # Raise an exception for HTTP errors log("Globals: requestURL, url = %s, status = %s"%(url,response.status_code)) complete = True if json_data: results = response.json() else: results = response.content if results and cache: return __setCache(cacheKey,results,json_data,cache,checksum,life) else: return results except requests.exceptions.ConnectionError as e: log("Globals: requestURL, failed! Error connecting to the server: %s"%('Returning cache' if cache else 'No Response')) return __getCache(cacheKey,json_data,cache,checksum) if cache else __error(json_data) except requests.exceptions.HTTPError as e: log("Globals: requestURL, failed! HTTP error occurred: %s"%('Returning cache' if cache else 'No Response')) return __getCache(cacheKey,json_data,cache,checksum) if cache else __error(json_data) except Exception as e: log("Globals: requestURL, failed! An error occurred: %s"%(e), xbmc.LOGERROR) return __error(json_data) finally: if not complete and payload: queueURL({"url":url, "params":params, "payload":payload, "header":header, "timeout":timeout, "json_data":json_data, "cache":cache, "checksum":checksum, "life":life}) #retry post def queueURL(param): queuePool = (SETTINGS.getCacheSetting('queueURL', json_data=True) or {}) params = queuePool.setdefault('params',[]) params.append(param) queuePool['params'] = setDictLST(params) log("Globals: queueURL, saving = %s\n%s"%(len(queuePool['params']),param)) SETTINGS.setCacheSetting('queueURL', queuePool, json_data=True) def setURL(url, file): try: contents = requestURL(url) fle = FileAccess.open(file, 'w') fle.write(contents) fle.close() return FileAccess.exists(file) except Exception as e: log('Globals: setURL failed! %s\nurl = %s'%(e,url), xbmc.LOGERROR) def diffLSTDICT(old, new): set1 = {dumpJSON(d, sortkey=True) for d in old} set2 = {dumpJSON(d, sortkey=True) for d in new} return {"added": [loadJSON(s) for s in set2 - set1], "removed": [loadJSON(s) for s in set1 - set2]} def getChannelID(name, path, number): if isinstance(path, list): path = '|'.join(path) tmpid = '%s.%s.%s.%s'%(number, name, hashlib.md5(path.encode(DEFAULT_ENCODING)),SETTINGS.getMYUUID()) return '%s@%s'%((binascii.hexlify(tmpid.encode(DEFAULT_ENCODING))[:32]).decode(DEFAULT_ENCODING),slugify(ADDON_NAME)) def getRecordID(name, path, number): if isinstance(path, list): path = '|'.join(path) tmpid = '%s.%s.%s.%s'%(number, name, hashlib.md5(path.encode(DEFAULT_ENCODING)),SETTINGS.getMYUUID()) return '%s@%s'%((binascii.hexlify(tmpid.encode(DEFAULT_ENCODING))[:16]).decode(DEFAULT_ENCODING),slugify(ADDON_NAME)) def splitYear(label): try: match = re.compile(r'(.*) \((.*)\)', re.IGNORECASE).search(label) if match and match.group(2): label, year = match.groups() if year.isdigit(): return label, int(year) except: pass return label, None def getChannelSuffix(name, type): name = validString(name) if type == "TV Genres" and not LANGUAGE(32014).lower() in name.lower(): suffix = LANGUAGE(32014) #TV elif type == "Movie Genres" and not LANGUAGE(32015).lower() in name.lower(): suffix = LANGUAGE(32015) #Movies elif type == "Mixed Genres" and not LANGUAGE(32010).lower() in name.lower(): suffix = LANGUAGE(32010) #Mixed elif type == "Music Genres" and not LANGUAGE(32016).lower() in name.lower(): suffix = LANGUAGE(32016) #Music else: return name return '%s %s'%(name,suffix) def cleanChannelSuffix(name, type): if type == "TV Genres" : name = name.split(' %s'%LANGUAGE(32014))[0]#TV elif type == "Movie Genres" : name = name.split(' %s'%LANGUAGE(32015))[0]#Movies elif type == "Mixed Genres" : name = name.split(' %s'%LANGUAGE(32010))[0]#Mixed elif type == "Music Genres" : name = name.split(' %s'%LANGUAGE(32016))[0]#Music return name def getLabel(item, addYear=False): label = (item.get('name') or item.get('label') or item.get('showtitle') or item.get('title')) if not label: return '' label, year = splitYear(label) year = (item.get('year') or year) if year and addYear: return '%s (%s)'%(label, year) return label def hasFile(file): if not file.startswith(tuple(VFS_TYPES + WEB_TYPES)): state = FileAccess.exists(file) elif file.startswith('plugin://'): state = hasAddon(file) else: state = True log("Globals: hasFile, file = %s (%s)"%(file,state)) return state def hasAddon(id, install=False, enable=False, force=False, notify=False): if '://' in id: id = getIDbyPath(id) if BUILTIN.getInfoBool('HasAddon(%s)'%(id),'System'): if BUILTIN.getInfoBool('AddonIsEnabled(%s)'%(id),'System'): return True elif enable: if not force: if not DIALOG.yesnoDialog(message=LANGUAGE(32156)%(id)): return False return BUILTIN.executebuiltin('EnableAddon(%s)'%(id),wait=True) elif install: return BUILTIN.executebuiltin('InstallAddon(%s)'%(id),wait=True) if notify: DIALOG.notificationDialog(LANGUAGE(32034)%(id)) return False def diffRuntime(dur, roundto=15): def ceil_dt(dt, delta): return dt + (datetime.datetime.min - dt) % delta now = datetime.datetime.fromtimestamp(dur) return (ceil_dt(now, datetime.timedelta(minutes=roundto)) - now).total_seconds() def roundTimeDown(dt, offset=30): # round the given time down to the nearest n = datetime.datetime.fromtimestamp(dt) delta = datetime.timedelta(minutes=offset) if n.minute > (offset-1): n = n.replace(minute=offset, second=0, microsecond=0) else: n = n.replace(minute=0, second=0, microsecond=0) return time.mktime(n.timetuple()) def roundTimeUp(dt=None, roundTo=60): if dt == None : dt = datetime.datetime.now() seconds = (dt.replace(tzinfo=None) - dt.min).seconds rounding = (seconds+roundTo/2) // roundTo * roundTo return dt + datetime.timedelta(0,rounding-seconds,-dt.microsecond) def strpTime(datestring, format=DTJSONFORMAT): #convert pvr infolabel datetime string to datetime obj, thread safe! try: return datetime.datetime.strptime(datestring, format) except TypeError: return datetime.datetime.fromtimestamp(time.mktime(time.strptime(datestring, format))) except: return '' def epochTime(timestamp, tz=True): #convert pvr json datetime string to datetime obj if tz: timestamp -= getTimeoffset() return datetime.datetime.fromtimestamp(timestamp) def getTimeoffset(): return (int((datetime.datetime.now() - datetime.datetime.utcnow()).days * 86400 + round((datetime.datetime.now() - datetime.datetime.utcnow()).seconds, -1))) def getUTCstamp(): return time.time() - getTimeoffset() def getGMTstamp(): return time.time() def randomShuffle(items=[]): if len(items) > 0: #reseed random for a "greater sudo random" random.seed(random.randint(0,999999999999)) random.shuffle(items) return items def isStack(path): #is path a stack return path.startswith('stack://') def splitStacks(path): #split stack path for indv. files. if not isStack(path): return [path] return [_f for _f in ((path.split('stack://')[1]).split(' , ')) if _f] def escapeDirJSON(path): mydir = path if (mydir.find(":")): mydir = mydir.replace("\\", "\\\\") return mydir def KODI_LIVETV_SETTINGS(): #recommended Kodi LiveTV settings return {'pvrmanager.preselectplayingchannel' :'true', 'pvrmanager.syncchannelgroups' :'true', 'pvrmanager.backendchannelorder' :'true', 'pvrmanager.usebackendchannelnumbers':'true', 'pvrplayback.autoplaynextprogramme' :'true', # 'pvrmenu.iconpath':'', # 'pvrplayback.switchtofullscreenchanneltypes':1, # 'pvrplayback.confirmchannelswitch':'true', # 'epg.selectaction':2, # 'epg.epgupdate':120, 'pvrmanager.startgroupchannelnumbersfromone':'false'} def togglePVR(state=True, reverse=False, wait=FIFTEEN): if SETTINGS.getSettingBool('Enable_PVR_RELOAD'): isEnabled = BUILTIN.getInfoBool('AddonIsEnabled(%s)'%(PVR_CLIENT_ID),'System') if (state and isEnabled) or (not state and not isEnabled): return elif not PROPERTIES.isRunning('togglePVR'): with PROPERTIES.chkRunning('togglePVR'): BUILTIN.executebuiltin("Dialog.Close(all)") log('globals: togglePVR, state = %s, reverse = %s, wait = %s'%(state,reverse,wait)) BUILTIN.executeJSONRPC('{"jsonrpc":"2.0","method":"Addons.SetAddonEnabled","params":{"addonid":"%s","enabled":%s}, "id": 1}'%(PVR_CLIENT_ID,str(state).lower())) if reverse: with BUILTIN.busy_dialog(): MONITOR().waitForAbort(1.0) timerit(togglePVR)(wait,[not bool(state)]) DIALOG.notificationWait('%s: %s'%(PVR_CLIENT_NAME,LANGUAGE(32125)),wait=wait) else: DIALOG.notificationWait(LANGUAGE(30023)%(PVR_CLIENT_NAME)) def isRadio(item): if item.get('radio',False) or item.get('type') == "Music Genres": return True for path in item.get('path',[item.get('file','')]): if path.lower().startswith(('musicdb://','special://profile/playlists/music/','special://musicplaylists/')): return True return False def isMixed_XSP(item): for path in item.get('path',[item.get('file','')]): if path.lower().startswith('special://profile/playlists/mixed/'): return True return False def cleanLabel(text): text = re.sub(r'\[COLOR=(.+?)\]', '', text) text = re.sub(r'\[/COLOR\]', '', text) text = text.replace("[B]",'').replace("[/B]",'') text = text.replace("[I]",'').replace("[/I]",'') return text.replace(":",'') def cleanImage(image=LOGO): if not image: image = LOGO if not image.startswith(('image://','resource://','special://','smb://','nfs://','https://','http://')): realPath = FileAccess.translatePath('special://home/addons/') if image.startswith(realPath):# convert real path. to vfs image = image.replace(realPath,'special://home/addons/').replace('\\','/') elif image.startswith(realPath.replace('\\','/')): image = image.replace(realPath.replace('\\','/'),'special://home/addons/').replace('\\','/') return image def cleanGroups(citem, enableGrouping=SETTINGS.getSettingBool('Enable_Grouping')): if not enableGrouping: citem['group'] = [ADDON_NAME] else: citem['group'].append(ADDON_NAME) if citem.get('favorite',False) and not LANGUAGE(32019) in citem['group']: citem['group'].append(LANGUAGE(32019)) elif not citem.get('favorite',False) and LANGUAGE(32019) in citem['group']: citem['group'].remove(LANGUAGE(32019)) return sorted(set(citem['group'])) def cleanMPAA(mpaa): orgMPA = mpaa mpaa = mpaa.lower() if ':' in mpaa: mpaa = re.split(':',mpaa)[1] #todo prop. regex if 'rated ' in mpaa: mpaa = re.split('rated ',mpaa)[1] #todo prop. regex #todo regex, detect other region rating formats # re.compile(':(.*)', re.IGNORECASE).search(text)) text = mpaa.upper() try: text = re.sub('/ US', '' , text) text = re.sub('Rated ', '', text) mpaa = text.strip() except: mpaa = mpaa.strip() return mpaa def getIDbyPath(url): try: if url.startswith('special://'): return re.compile('special://home/addons/(.*?)/resources', re.IGNORECASE).search(url).group(1) elif url.startswith('plugin://'): return re.compile('plugin://(.*?)/', re.IGNORECASE).search(url).group(1) except Exception as e: log('Globals: getIDbyPath failed! url = %s, %s'%(url,e), xbmc.LOGERROR) return url def combineDicts(dict1={}, dict2={}): for k,v in list(dict1.items()): if dict2.get(k): k = dict2.pop(k) dict1.update(dict2) return dict1 def mergeDictLST(dict1={},dict2={}): for k, v in list(dict2.items()): dict1.setdefault(k,[]).extend(v) setDictLST() return dict1 def lstSetDictLst(lst=[]): items = dict() for key, dictlst in list(lst.items()): if isinstance(dictlst, list): dictlst = setDictLST(dictlst) items[key] = dictlst return items def compareDict(dict1,dict2,sortKey): a = sorted(dict1, key=itemgetter(sortKey)) b = sorted(dict2, key=itemgetter(sortKey)) return a == b def subZoom(number,percentage,multi=100): return round(number * (percentage*multi) / 100) def addZoom(number,percentage,multi=100): return round((number - (number * (percentage*multi) / 100)) + number) def frange(start,stop,inc): return [x/10.0 for x in range(start,stop,inc)] def timeString2Seconds(string): #hh:mm:ss try: return int(sum(x*y for x, y in zip(list(map(float, string.split(':')[::-1])), (1, 60, 3600, 86400)))) except: return -1 def chunkLst(lst, n): for i in range(0, len(lst), n): yield lst[i:i + n] def chunkDict(items, n): it = iter(items) for i in range(0, len(items), n): yield {k:items[k] for k in islice(it, n)} def roundupDIV(p, q): try: d, r = divmod(p, q) if r: d += 1 return d except ZeroDivisionError: return 1 def interleave(seqs, sets=1, repeats=False): #evenly interleave multi-lists of different sizes, while preserving seq order and by sets of x # In [[1,2,3,4],['a','b','c'],['A','B','C','D','E']] # repeats = False # Out sets=0 [1, 2, 3, 4, 'a', 'b', 'c', 'A', 'B', 'C', 'D', 'E'] # Out sets=1 [1, 'a', 'A', 2, 'b', 'B', 3, 'c', 'C', 4, 'D', 'E'] # Out sets=2 [1, 2, 'a', 'b', 'A', 'B', 3, 4, 'c', 'C', 'D', 'E'] # repeats = True # Out sets=0 [1, 2, 3, 4, 'a', 'b', 'c', 'A', 'B', 'C', 'D', 'E'] # Out sets=1 [1, 'a', 'A', 2, 'b', 'B', 3, 'c', 'C', 4, 'a', 'D', 1, 'b', 'E'] # Out sets=2 [1, 2, 'a', 'b', 'A', 'B', 3, 4, 'c', 'a', 'C', 'D', 1, 2, 'b', 'c', 'E', 'A'] if sets > 0: # if repeats: # # Create cyclical iterators for each list # cyclical_iterators = [cycle(lst) for lst in seqs] # interleaved = [] # # Determine the length of the longest list # max_len = max(len(lst) for lst in seqs) # # Calculate the number of blocks needed # num_blocks = (max_len + sets - 1) // sets # # Interleave in blocks # for i in range(num_blocks): # for iterator in cyclical_iterators: # # Use islice to take a block of elements from the current iterator # block = list(islice(iterator, sets)) # interleaved.extend(block) # return interleaved # else: seqs = [list(zip_longest(*[iter(seqs)] * sets, fillvalue=None)) for seqs in seqs] return list([_f for _f in sum([_f for _f in chain.from_iterable(zip_longest(*seqs)) if _f], ()) if _f]) else: return list(chain.from_iterable(seqs)) def percentDiff(org, new): try: return (abs(float(org) - float(new)) / float(new)) * 100.0 except ZeroDivisionError: return -1 def pagination(list, end): for start in range(0, len(list), end): yield seq[start:start+end] def isCenterlized(): default = 'special://profile/addon_data/plugin.video.pseudotv.live/cache' if REAL_SETTINGS.getSetting('User_Folder') == default: return False return True def isFiller(item={}): for genre in item.get('genre',[]): if genre.lower() in ['pre-roll','post-roll']: return True return False def isShort(item={}, minDuration=SETTINGS.getSettingInt('Seek_Tolerance')): if item.get('duration', minDuration) < minDuration: return True else: return False def isEnding(progress=100): if progress >= SETTINGS.getSettingInt('Seek_Threshold'): return True else: return False def chkLogo(old, new=LOGO): if new.endswith('wlogo.png') and not old.endswith('wlogo.png'): return old return new