Updated kodi settings on Lenovo

This commit is contained in:
2026-03-22 22:28:43 +01:00
parent 725dfa7157
commit 32b5a81da6
10925 changed files with 575678 additions and 5511 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 449 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 476 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 265 KiB

View File

@@ -0,0 +1,204 @@
# 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 <http://www.gnu.org/licenses/>.
# -*- coding: utf-8 -*-
from globals import *
from library import Library
from channels import Channels
class Autotune:
def __init__(self, sysARG=sys.argv):
self.log('__init__, sysARG = %s'%(sysARG))
self.sysARG = sysARG
self.channels = Channels()
self.library = Library()
def log(self, msg, level=xbmc.LOGDEBUG):
return log('%s: %s'%(self.__class__.__name__,msg),level)
def getCustom(self) -> dict:
#return autotuned channels ie. channels > CHANNEL_LIMIT
channels = self.channels.getCustom()
self.log('getCustom, channels = %s'%(len(channels)))
return channels
def getAutotuned(self) -> dict:
#return autotuned channels ie. channels > CHANNEL_LIMIT
channels = self.channels.getAutotuned()
self.log('getAutotuned, channels = %s'%(len(channels)))
return channels
def _runTune(self, prompt: bool=False, rebuild: bool=False, dia=None):
customChannels = self.getCustom()
autoChannels = self.getAutotuned()
hasLibrary = PROPERTIES.hasLibrary()
if len(autoChannels) > 0 or hasLibrary: rebuild = PROPERTIES.setEXTPropertyBool('%s.has.Predefined'%(ADDON_ID),True) #rebuild existing autotune, no prompt needed, refresh paths and logos
if len(customChannels) == 0: prompt = True #begin check if prompt or recovery is needed
self.log('_runTune, customChannels = %s, autoChannels = %s'%(len(customChannels),len(autoChannels)))
if prompt:
opt = ''
msg = '%s?'%(LANGUAGE(32042)%(ADDON_NAME))
hasBackup = PROPERTIES.hasBackup()
hasServers = PROPERTIES.hasServers()
hasM3U = FileAccess.exists(M3UFLEPATH) if not hasLibrary else False
if (hasBackup or hasServers or hasM3U):
opt = LANGUAGE(32254)
msg = '%s\n%s'%(LANGUAGE(32042)%(ADDON_NAME),LANGUAGE(32255))
retval = DIALOG.yesnoDialog(message=msg,customlabel=opt)
if retval == 1: dia = DIALOG.progressBGDialog(header='%s, %s'%(ADDON_NAME,LANGUAGE(32021))) #Yes
elif retval == 2: #Custom
with BUILTIN.busy_dialog(), PROPERTIES.interruptActivity():
menu = [LISTITEMS.buildMenuListItem(LANGUAGE(30107),LANGUAGE(33310),url='special://home/addons/%s/resources/lib/utilities.py, Channel_Manager'%(ADDON_ID))]
if hasM3U: menu.append(LISTITEMS.buildMenuListItem(LANGUAGE(32257),LANGUAGE(32256),url='special://home/addons/%s/resources/lib/autotune.py, Recover_M3U'%(ADDON_ID)))
if hasBackup: menu.append(LISTITEMS.buildMenuListItem(LANGUAGE(32112),LANGUAGE(32111),url='special://home/addons/%s/resources/lib/backup.py, Recover_Backup'%(ADDON_ID)))
if hasServers: menu.append(LISTITEMS.buildMenuListItem(LANGUAGE(30173),LANGUAGE(32215),url='special://home/addons/%s/resources/lib/multiroom.py, Select_Server'%(ADDON_ID)))
select = DIALOG.selectDialog(menu,multi=False)
if not select is None: return BUILTIN.executescript(menu[select].getPath())
else: return True #No
else: return True
for idx, ATtype in enumerate(AUTOTUNE_TYPES):
if dia: dia = DIALOG.progressBGDialog(int((idx+1)*100//len(AUTOTUNE_TYPES)),dia,ATtype,'%s, %s'%(ADDON_NAME,LANGUAGE(32021)))
self.selectAUTOTUNE(ATtype, autoSelect=prompt, rebuildChannels=rebuild)
return True
def selectAUTOTUNE(self, ATtype: str, autoSelect: bool=False, rebuildChannels: bool=False):
self.log('selectAUTOTUNE, ATtype = %s, autoSelect = %s, rebuildChannels = %s'%(ATtype,autoSelect,rebuildChannels))
def __buildMenuItem(item):
return LISTITEMS.buildMenuListItem(item['name'],item['type'],item['logo'])
def _match(enabledItems):
for item in enabledItems:
for idx, liz in enumerate(lizlst):
if item.get('name','').lower() == liz.getLabel().lower():
yield idx
def _set(ATtype, selects=[]):
for item in items:
item['enabled'] = False #disable everything before selecting new items.
for select in selects:
if item.get('name','').lower() == lizlst[select].getLabel().lower():
item['enabled'] = True
self.library.setLibrary(ATtype, items)
items = self.library.getLibrary(ATtype)
if len(items) == 0 and (not rebuildChannels and not autoSelect):
if SETTINGS.getSettingBool('Debug_Enable'): DIALOG.notificationDialog(LANGUAGE(32018)%(ATtype))
return
lizlst = poolit(__buildMenuItem)(items)
if rebuildChannels:#rebuild channels.json entries
selects = list(_match(self.library.getEnabled(ATtype)))
elif autoSelect:#build sample channels
if len(items) >= AUTOTUNE_LIMIT:
selects = sorted(set(random.sample(list(set(range(0,len(items)))),AUTOTUNE_LIMIT)))
else:
selects = list(range(0,len(items)))
else:
selects = DIALOG.selectDialog(lizlst,LANGUAGE(32017)%(ATtype),preselect=list(_match(self.library.getEnabled(ATtype))))
if not selects is None: _set(ATtype, selects)
return self.buildAUTOTUNE(ATtype, self.library.getEnabled(ATtype))
def buildAUTOTUNE(self, ATtype: str, items: list=[]):
if not list: return
def buildAvailableRange(existing):
# create number array for given type, excluding existing channel numbers.
if existing: existingNUMBERS = [eitem.get('number') for eitem in existing if eitem.get('number',0) > 0] # existing channel numbers
else: existingNUMBERS = []
start = ((CHANNEL_LIMIT+1)*(AUTOTUNE_TYPES.index(ATtype)+1))
stop = (start + CHANNEL_LIMIT)
self.log('buildAUTOTUNE, ATtype = %s, range = %s-%s, existingNUMBERS = %s'%(ATtype,start,stop,existingNUMBERS))
return [num for num in range(start,stop) if num not in existingNUMBERS]
existingAUTOTUNE = self.channels.popChannels(ATtype,self.getAutotuned())
usesableNUMBERS = iter(buildAvailableRange(existingAUTOTUNE)) # available channel numbers
for item in items:
music = isRadio(item)
citem = self.channels.getTemplate()
citem.update({"id" : "",
"type" : ATtype,
"number" : 0,
"name" : getChannelSuffix(item['name'], ATtype),
"logo" : item.get('logo',LOGO),
"path" : item.get('path',''),
"group" : [item.get('type','')],
"rules" : item.get('rules',{}),
"catchup" : ('vod' if not music else ''),
"radio" : music,
"favorite": True})
match, eitem = self.channels.findAutotuned(citem, channels=existingAUTOTUNE)
if match is None: #new autotune
citem['id'] = getChannelID(citem['name'],citem['path'],citem['number']) #generate new channelid
citem['number'] = next(usesableNUMBERS,0) #first available channel number
PROPERTIES.setUpdateChannels(citem['id'])
else: #update existing autotune
citem['id'] = eitem.get('id')
citem['number'] = eitem.get('number')
citem['logo'] = chkLogo(eitem.get('logo',''),citem.get('logo',LOGO))
citem['favorite'] = eitem.get('favorite',False)
self.log('[%s] buildAUTOTUNE, number = %s, match = %s'%(citem['id'],citem['number'],match))
self.channels.addChannel(citem)
return self.channels.setChannels()
def recoverM3U(self, autotune={}):
from m3u import M3U
stations = M3U().getStations()
[autotune.setdefault(AUTOTUNE_TYPES[station.get('number')//1000],[]).append(station.get('name')) for station in stations if station.get('number') > CHANNEL_LIMIT]
[self.library.enableByName(type, names) for type, names in list(autotune.items()) if len(names) > 0]
return BUILTIN.executescript('special://home/addons/%s/resources/lib/utilities.py, Run_Autotune'%(ADDON_ID))
def clearLibrary(self):
self.library.resetLibrary()
DIALOG.notificationDialog(LANGUAGE(32025))
def clearBlacklist(self):
SETTINGS.setSetting('Clear_BlackList','')
DIALOG.notificationDialog(LANGUAGE(32025))
def run(self):
with BUILTIN.busy_dialog():
ctl = (1,1) #settings return focus
try: param = self.sysARG[1]
except: param = None
if param.replace('_',' ') in AUTOTUNE_TYPES:
ctl = (1,AUTOTUNE_TYPES.index(param.replace('_',' '))+1)
self.selectAUTOTUNE(param.replace('_',' '))
elif param == 'Clear_Autotune' : self.clearLibrary()
elif param == 'Clear_BlackList': self.clearBlacklist()
elif param == 'Recover_M3U': self.recoverM3U()
elif param == None: return
return SETTINGS.openSettings(ctl)
if __name__ == '__main__': timerit(Autotune(sys.argv).run)(0.1)

View File

@@ -0,0 +1,98 @@
# 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 <http://www.gnu.org/licenses/>.
# https://github.com/kodi-community-addons/script.module.simplecache/blob/master/README.md
# -*- coding: utf-8 -*-
from globals import *
from library import Library
from channels import Channels
class Backup:
def __init__(self, sysARG=sys.argv):
self.sysARG = sysARG
def log(self, msg, level=xbmc.LOGDEBUG):
return log('%s: %s'%(self.__class__.__name__,msg),level)
def getFileDate(self, file: str) -> str:
try: return datetime.datetime.fromtimestamp(pathlib.Path(FileAccess.translatePath(file)).stat().st_mtime).strftime(BACKUP_TIME_FORMAT)
except: return LANGUAGE(32105) #Unknown
def hasBackup(self, file: str=CHANNELFLE_BACKUP) -> bool:
self.log('hasBackup')
if PROPERTIES.setBackup(FileAccess.exists(file)):
if file == CHANNELFLE_BACKUP:#main backup file, set meta.
if (SETTINGS.getSetting('Backup_Channels') or 'Last Backup: Unknown') == 'Last Backup: Unknown':
SETTINGS.setSetting('Backup_Channels' ,'%s: %s'%(LANGUAGE(32106),self.getFileDate(file)))
if not SETTINGS.getSetting('Recover_Backup'):
SETTINGS.setSetting('Recover_Backup','%s [B]%s[/B] Channels?'%(LANGUAGE(32107),len(self.getChannels())))
return True
SETTINGS.setSetting('Backup_Channels' ,'')
SETTINGS.setSetting('Recover_Backup','')
return False
def getChannels(self, file: str=CHANNELFLE_BACKUP) -> list:
self.log('getChannels')
channels = Channels()
citems = channels._load(file).get('channels',[])
del channels
return citems
def backupChannels(self, file: str=CHANNELFLE_BACKUP) -> bool:
self.log('backupChannels')
if FileAccess.exists(file):
if not DIALOG.yesnoDialog('%s\n%s?'%(LANGUAGE(32108),SETTINGS.getSetting('Backup_Channels'))):
return False
with BUILTIN.busy_dialog(), PROPERTIES.interruptActivity():
if FileAccess.copy(CHANNELFLEPATH,file):
if file == CHANNELFLE_BACKUP: #main backup file, set meta.
PROPERTIES.setBackup(True)
SETTINGS.setSetting('Backup_Channels' ,'%s: %s'%(LANGUAGE(32106),datetime.datetime.now().strftime(BACKUP_TIME_FORMAT)))
SETTINGS.setSetting('Recover_Backup','%s [B]%s[/B] Channels?'%(LANGUAGE(32107),len(self.getChannels())))
return DIALOG.notificationDialog('%s %s'%(LANGUAGE(32110),LANGUAGE(32025)))
self.hasBackup()
SETTINGS.openSettings(ctl)
def recoverChannels(self, file: str=CHANNELFLE_BACKUP) -> bool:
self.log('recoverChannels, file = %s'%(file))
if not DIALOG.yesnoDialog('%s'%(LANGUAGE(32109)%(SETTINGS.getSetting('Recover_Backup').replace(LANGUAGE(30216),''),SETTINGS.getSetting('Backup_Channels')))):
return False
with BUILTIN.busy_dialog(), PROPERTIES.interruptActivity():
FileAccess.move(CHANNELFLEPATH,CHANNELFLE_RESTORE)
if FileAccess.copy(file,CHANNELFLEPATH):
Library().resetLibrary()
PROPERTIES.setPendingRestart()
def run(self):
with BUILTIN.busy_dialog():
ctl = (0,1) #settings return focus
try: param = self.sysARG[1]
except: param = None
if param == 'Recover_Backup': self.recoverChannels()
elif param == 'Backup_Channels': self.backupChannels()
if __name__ == '__main__': timerit(Backup(sys.argv).run)(0.1)

View File

@@ -0,0 +1,590 @@
# 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 <http://www.gnu.org/licenses/>.
# -*- coding: utf-8 -*-
from globals import *
from channels import Channels
from xmltvs import XMLTVS
from xsp import XSP
from m3u import M3U
from fillers import Fillers
from resources import Resources
from seasonal import Seasonal
from rules import RulesList
class Service:
from jsonrpc import JSONRPC
player = PLAYER()
monitor = MONITOR()
jsonRPC = JSONRPC()
def _interrupt(self) -> bool:
return PROPERTIES.isPendingInterrupt()
def _suspend(self) -> bool:
return PROPERTIES.isPendingSuspend()
class Builder:
loopback = {}
def __init__(self, service=None):
if service is None: service = Service()
self.service = service
self.jsonRPC = service.jsonRPC
self.cache = service.jsonRPC.cache
self.channels = Channels()
#global dialog
self.pDialog = None
self.pCount = 0
self.pMSG = ''
self.pName = ''
self.pErrors = []
#global rules
self.accurateDuration = bool(SETTINGS.getSettingInt('Duration_Type'))
self.enableEven = bool(SETTINGS.getSettingInt('Enable_Even'))
self.interleaveValue = SETTINGS.getSettingInt('Interleave_Value')
self.incStrms = SETTINGS.getSettingBool('Enable_Strms')
self.inc3D = SETTINGS.getSettingBool('Enable_3D')
self.incExtras = SETTINGS.getSettingBool('Enable_Extras')
self.fillBCTs = SETTINGS.getSettingBool('Enable_Fillers')
self.saveDuration = SETTINGS.getSettingBool('Store_Duration')
self.epgArt = SETTINGS.getSettingInt('EPG_Artwork')
self.enableGrouping = SETTINGS.getSettingBool('Enable_Grouping')
self.minDuration = SETTINGS.getSettingInt('Seek_Tolerance')
self.limit = SETTINGS.getSettingInt('Page_Limit')
self.filelistQuota = False
self.schedulingQuota = True
self.filters = {}#{"and": [{"operator": "contains", "field": "title", "value": "Star Wars"},{"operator": "contains", "field": "tag", "value": "Good"}],"or":[]}
self.sort = {}#{"ignorearticle":True,"method":"random","order":"ascending","useartistsortname":True}
self.limits = {"end":-1,"start":0,"total":0}
self.bctTypes = {"ratings" :{"min":-1, "max":SETTINGS.getSettingInt('Enable_Preroll'), "auto":SETTINGS.getSettingInt('Enable_Preroll') == -1, "enabled":bool(SETTINGS.getSettingInt('Enable_Preroll')), "chance":SETTINGS.getSettingInt('Random_Pre_Chance'),
"sources" :{"ids":SETTINGS.getSetting('Resource_Ratings').split('|'),"paths":[os.path.join(FILLER_LOC,'Ratings' ,'')]},"items":{}},
"bumpers" :{"min":-1, "max":SETTINGS.getSettingInt('Enable_Preroll'), "auto":SETTINGS.getSettingInt('Enable_Preroll') == -1, "enabled":bool(SETTINGS.getSettingInt('Enable_Preroll')), "chance":SETTINGS.getSettingInt('Random_Pre_Chance'),
"sources" :{"ids":SETTINGS.getSetting('Resource_Bumpers').split('|'),"paths":[os.path.join(FILLER_LOC,'Bumpers' ,'')]},"items":{}},
"adverts" :{"min":SETTINGS.getSettingInt('Enable_Postroll'), "max":PAGE_LIMIT, "auto":SETTINGS.getSettingInt('Enable_Postroll') == -1, "enabled":bool(SETTINGS.getSettingInt('Enable_Postroll')), "chance":SETTINGS.getSettingInt('Random_Post_Chance'),
"sources" :{"ids":SETTINGS.getSetting('Resource_Adverts').split('|'),"paths":[os.path.join(FILLER_LOC,'Adverts' ,'')]},"items":{}},
"trailers":{"min":SETTINGS.getSettingInt('Enable_Postroll'), "max":PAGE_LIMIT, "auto":SETTINGS.getSettingInt('Enable_Postroll') == -1, "enabled":bool(SETTINGS.getSettingInt('Enable_Postroll')), "chance":SETTINGS.getSettingInt('Random_Post_Chance'),
"sources" :{"ids":SETTINGS.getSetting('Resource_Trailers').split('|'),"paths":[os.path.join(FILLER_LOC,'Trailers','')]},"items":{}, "incKODI":SETTINGS.getSettingBool('Include_Trailers_KODI')}}
self.xsp = XSP()
self.xmltv = XMLTVS()
self.m3u = M3U()
self.resources = Resources(service=self.service)
self.runActions = RulesList(self.channels.getChannels()).runActions
def log(self, msg, level=xbmc.LOGDEBUG):
return log('%s: %s'%(self.__class__.__name__,msg),level)
def updateProgress(self, percent, message, header):
if self.pDialog: self.pDialog = DIALOG.updateProgress(percent, self.pDialog, message=message, header=header)
def getVerifiedChannels(self):
return sorted(self.verify(), key=itemgetter('number'))
def verify(self, channels=[]):
if not channels: channels = self.channels.getChannels()
for idx, citem in enumerate(channels):
if not citem.get('name') or len(citem.get('path',[])) == 0 or not citem.get('number'):
self.log('[%s] SKIPPING - missing necessary channel meta\n%s'%(citem.get('id'),citem))
continue
elif not citem.get('id'): citem['id'] = getChannelID(citem['name'],citem['path'],citem['number']) #generate new channelid
citem['logo'] = self.resources.getLogo(citem,citem.get('logo',LOGO))
self.log('[%s] VERIFIED - channel %s: %s'%(citem['id'],citem['number'],citem['name']))
yield self.runActions(RULES_ACTION_CHANNEL_CITEM, citem, citem, inherited=self) #inject persistent citem changes here
def build(self, channels: list=[], preview=False):
def __hasProgrammes(citem: dict) -> bool:
try: return dict(self.xmltv.hasProgrammes([citem])).get(citem['id'],False)
except: return False
def __hasFileList(fileList: list) -> bool:
if isinstance(fileList,list):
if len(fileList) > 0: return True
return False
def __clrChannel(citem: dict) -> bool:
self.log('[%s] __clrChannel'%(citem['id']))
return self.m3u.delStation(citem) & self.xmltv.delBroadcast(citem)
def __addStation(citem: dict) -> bool:
self.log('[%s] __addStation'%(citem['id']))
citem['logo'] = self.resources.buildWebImage(cleanImage(citem['logo']))
citem['group'] = cleanGroups(citem, self.enableGrouping)
sitem = self.m3u.getStationItem(citem)
return self.m3u.addStation(sitem) & self.xmltv.addChannel(sitem)
def __addProgrammes(citem: dict, fileList: list) -> bool:
self.log('[%s] __addProgrammes, fileList = %s'%(citem['id'],len(fileList)))
for idx, item in enumerate(fileList): self.xmltv.addProgram(citem['id'], self.xmltv.getProgramItem(citem, item))
return True
def __setChannels():
self.log('__setChannels')
return self.xmltv._save() & self.m3u._save()
if not PROPERTIES.isRunning('builder.build'):
with PROPERTIES.legacy(), PROPERTIES.chkRunning('builder.build'):
try:
if len(channels) == 0: raise Exception('No individual channels to update, updating all!')
else: channels = sorted(self.verify(channels), key=itemgetter('number'))
except: channels = self.getVerifiedChannels()
if len(channels) > 0:
complete = True
updated = set()
now = getUTCstamp()
start = roundTimeDown(now,offset=60)#offset time to start bottom of the hour
fallback = datetime.datetime.fromtimestamp(start).strftime(DTFORMAT)
clrIDS = SETTINGS.getResetChannels()
if preview: self.pDialog = DIALOG.progressDialog()
else: self.pDialog = DIALOG.progressBGDialog()
for idx, citem in enumerate(channels):
self.pCount = int(idx*100//len(channels))
citem = self.runActions(RULES_ACTION_CHANNEL_TEMP_CITEM, citem, citem, inherited=self) #inject temporary citem changes here
self.log('[%s] build, preview = %s, rules = %s'%(citem['id'],preview,citem.get('rules',{})))
if self.service._interrupt():
self.log("[%s] build, _interrupt"%(citem['id']))
complete = False
self.pErrors = [LANGUAGE(32160)]
self.updateProgress(self.pCount, message='%s: %s'%(LANGUAGE(32144),LANGUAGE(32213)), header=ADDON_NAME)
break
else:
self.pMSG = '%s: %s'%(LANGUAGE(32144),LANGUAGE(32212))
self.pName = citem['name']
self.runActions(RULES_ACTION_CHANNEL_START, citem, inherited=self)
if not preview and citem['id'] in clrIDS: __clrChannel({'id':clrIDS.pop(clrIDS.index(citem['id']))}) #clear channel xmltv
stopTimes = dict(self.xmltv.loadStopTimes([citem], fallback=fallback)) #check last stop times
if preview: self.pMSG = LANGUAGE(32236) #Preview
elif (stopTimes.get(citem['id']) or start) > (now + ((MAX_GUIDEDAYS * 86400) - 43200)): self.pMSG = '%s %s'%(LANGUAGE(32028),LANGUAGE(32023)) #Checking
elif (stopTimes.get(citem['id']) or fallback) == fallback: self.pMSG = '%s %s'%(LANGUAGE(30014),LANGUAGE(32023)) #Building
elif stopTimes.get(citem['id']): self.pMSG = '%s %s'%(LANGUAGE(32022),LANGUAGE(32023)) #Updating
else: self.pMSG = '%s %s'%(LANGUAGE(32245),LANGUAGE(32023)) #Parsing
self.updateProgress(self.pCount, message='%s: %s'%(LANGUAGE(32248),self.pName), header='%s, %s'%(ADDON_NAME,self.pMSG))
response = self.getFileList(citem, now, (stopTimes.get(citem['id']) or start))# {False:'In-Valid Channel', True:'Valid Channel w/o programmes', list:'Valid Channel w/ programmes}
if preview: return response
elif response:
if __addStation(citem) and __hasFileList(response): updated.add(__addProgrammes(citem, response)) #added xmltv lineup entries.
else:
if complete: self.pErrors.append(LANGUAGE(32026))
chanErrors = ' | '.join(list(sorted(set(self.pErrors))))
self.log('[%s] build, In-Valid Channel (%s) %s'%(citem['id'],self.pName,chanErrors))
self.updateProgress(self.pCount, message='%s: %s'%(self.pName,chanErrors),header='%s, %s'%(ADDON_NAME,'%s %s'%(LANGUAGE(32027),LANGUAGE(32023))))
if not __hasProgrammes(citem):
self.updateProgress(self.pCount, message=self.pName,header='%s, %s'%(ADDON_NAME,'%s %s'%(LANGUAGE(32244),LANGUAGE(32023))))
__clrChannel(citem) #remove m3u/xmltv references when no valid programmes found. # todo del citem causes issues down the road with citem missing params. reeval need to remove here
self.runActions(RULES_ACTION_CHANNEL_STOP, citem, inherited=self)
SETTINGS.setResetChannels(clrIDS)
self.pDialog = DIALOG.updateProgress(100, self.pDialog, message='%s %s'%(self.pMSG,LANGUAGE(32025) if complete else LANGUAGE(32135)))
self.log('build, complete = %s, updated = %s, saved = %s'%(complete,bool(updated),__setChannels()))
return complete, bool(updated)
else: self.log('build, no verified channels found!')
return False, False
def getFileList(self, citem: dict, now: time, start: time) -> bool and list:
self.log('[%s] getFileList, start = %s'%(citem['id'],start))
try:
if start > (now + ((MAX_GUIDEDAYS * 86400) - 43200)): #max guidedata days to seconds, minus fill buffer (12hrs) in seconds.
self.updateProgress(self.pCount, message=self.pName, header='%s, %s'%(ADDON_NAME,self.pMSG))
self.log('[%s] getFileList, programmes over MAX_DAYS! start = %s'%(citem['id'],datetime.datetime.fromtimestamp(start)),xbmc.LOGINFO)
return True# prevent over-building
multi = len(citem.get('path',[])) > 1 #multi-path source
radio = True if citem.get('radio',False) else False
media = 'music' if radio else 'video'
self.log('[%s] getFileList, multipath = %s, radio = %s, media = %s, path = %s'%(citem['id'],multi,radio,media,citem.get('path')),xbmc.LOGINFO)
if radio: response = self.buildRadio(citem)
else: response = self.buildChannel(citem)
if isinstance(response,list): return sorted(self.addScheduling(citem, response, now, start), key=itemgetter('start'))
elif self.service._interrupt():
self.log("[%s] getFileList, _interrupt"%(citem['id']))
self.updateProgress(self.pCount, message='%s: %s'%(LANGUAGE(32144),LANGUAGE(32213)), header=ADDON_NAME)
return True
else:
return response
except Exception as e: self.log("[%s] getFileList, failed! %s"%(citem['id'],e), xbmc.LOGERROR)
return False
def buildCells(self, citem: dict={}, duration: int=10800, type: str='video', entries: int=3, info: dict={}) -> list:
tmpItem = {'label' : (info.get('title') or citem['name']),
'episodetitle': (info.get('episodetitle') or '|'.join(citem['group'])),
'plot' : (info.get('plot') or LANGUAGE(32020)),
'genre' : (info.get('genre') or ['Undefined']),
'file' : (info.get('path') or info.get('file') or info.get('originalpath') or '|'.join(citem.get('path'))),
'art' : (info.get('art') or {"thumb":COLOR_LOGO,"fanart":FANART,"logo":LOGO,"icon":LOGO}),
'type' : type,
'duration' : duration,
'start' : 0,
'stop' : 0}
info.update(tmpItem)
return [info.copy() for idx in range(entries)]
def addScheduling(self, citem: dict, fileList: list, now: time, start: time) -> list: #quota meet MIN_EPG_DURATION requirements.
self.log("[%s] addScheduling, IN fileList = %s, now = %s, start = %s"%(citem['id'],len(fileList),now,start))
totDur = 0
tmpList = []
fileList = self.runActions(RULES_ACTION_CHANNEL_BUILD_TIME_PRE, citem, fileList, inherited=self)
for idx, item in enumerate(fileList):
item["idx"] = idx
item['start'] = start
item['stop'] = start + item['duration']
start = item['stop']
tmpList.append(item)
if len(tmpList) > 0:
iters = cycle(fileList)
while not self.service.monitor.abortRequested() and tmpList[-1].get('stop') <= (now + MIN_EPG_DURATION):
if self.service.monitor.waitForAbort(0.0001): break
elif tmpList[-1].get('stop') >= (now + MIN_EPG_DURATION):
self.log("[%s] addScheduling, OUT fileList = %s, stop = %s"%(citem['id'],len(tmpList),tmpList[-1].get('stop')))
break
else:
idx += 1
item = next(iters).copy()
item["idx"] = idx
item['start'] = start
item['stop'] = start + item['duration']
start = item['stop']
totDur += item['duration']
tmpList.append(item)
self.updateProgress(self.pCount, message="%s: %s %s/%s"%(self.pName,LANGUAGE(33085),totDur,MIN_EPG_DURATION),header='%s, %s'%(ADDON_NAME,self.pMSG))
self.log("[%s] addScheduling, ADD fileList = %s, totDur = %s/%s, stop = %s"%(citem['id'],len(tmpList),totDur,MIN_EPG_DURATION,tmpList[-1].get('stop')))
return self.runActions(RULES_ACTION_CHANNEL_BUILD_TIME_POST, citem, tmpList, inherited=self) #adv. scheduling second pass and cleanup.
def buildRadio(self, citem: dict) -> list:
self.log("[%s] buildRadio"%(citem['id']))
#todo insert custom radio labels,plots based on genre type?
# https://www.musicgenreslist.com/
# https://www.musicgateway.com/blog/how-to/what-are-the-different-genres-of-music
return self.buildCells(citem, MIN_EPG_DURATION, 'music', ((MAX_GUIDEDAYS * 8)), info={'genre':["Music"],'art':{'thumb':citem['logo'],'icon':citem['logo'],'fanart':citem['logo']},'plot':LANGUAGE(32029)%(citem['name'])})
def buildChannel(self, citem: dict) -> bool and list:
def _validFileList(fileArray):
for fileList in fileArray:
if len(fileList) > 0: return True
def _injectFillers(citem, fileList, enable=False):
self.log("[%s] buildChannel: _injectFillers, fileList = %s, enable = %s"%(citem['id'],len(fileList),enable))
if enable: return Fillers(self,citem).injectBCTs(fileList)
else: return fileList
def _injectRules(citem):
def __chkEvenDistro(citem):
if self.enableEven and not citem.get('rules',{}).get("1000"):
nrules = {"1000":{"values":{"0":SETTINGS.getSettingInt('Enable_Even'),"1":SETTINGS.getSettingInt('Page_Limit'),"2":SETTINGS.getSettingBool('Enable_Force_Episode')}}}
self.log(" [%s] buildChannel: _injectRules, __chkEvenDistro, new rules = %s"%(citem['id'],nrules))
citem.setdefault('rules',{}).update(nrules)
return citem
return __chkEvenDistro(citem)
citem = _injectRules(citem) #inject temporary adv. channel rules here
fileArray = self.runActions(RULES_ACTION_CHANNEL_BUILD_FILEARRAY_PRE, citem, list(), inherited=self) #inject fileArray thru adv. channel rules here
self.log("[%s] buildChannel, channel pre fileArray items = %s"%(citem['id'],len(fileArray)),xbmc.LOGINFO)
#Primary rule for handling fileList injection bypassing channel building below.
if not _validFileList(fileArray): #if valid array bypass channel building
for idx, file in enumerate(citem.get('path',[])):
if self.service._interrupt():
self.log("[%s] buildChannel, _interrupt"%(citem['id']))
self.updateProgress(self.pCount, message='%s: %s'%(LANGUAGE(32144),LANGUAGE(32213)), header=ADDON_NAME)
return []
else:
if len(citem.get('path',[])) > 1: self.pName = '%s %s/%s'%(citem['name'],idx+1,len(citem.get('path',[])))
fileList = self.buildFileList(citem, self.runActions(RULES_ACTION_CHANNEL_BUILD_PATH, citem, file, inherited=self), 'video', self.limit, self.sort, self.limits)
fileArray.append(fileList)
self.log("[%s] buildChannel, path = %s, fileList = %s"%(citem['id'],file,len(fileList)))
fileArray = self.runActions(RULES_ACTION_CHANNEL_BUILD_FILEARRAY_POST, citem, fileArray, inherited=self) #flatten fileArray here to pass as fileList below
#Primary rule for handling adv. interleaving, must return single list to avoid default interleave() below. Add adv. rule to setDictLST duplicates
if isinstance(fileArray, list):
self.log("[%s] buildChannel, channel post fileArray items = %s"%(citem['id'],len(fileArray)),xbmc.LOGINFO)
if not _validFileList(fileArray):#check that at least one fileList in array contains meta
self.log("[%s] buildChannel, channel fileArray In-Valid!"%(citem['id']),xbmc.LOGINFO)
return False
self.log("[%s] buildChannel, fileArray = %s"%(citem['id'],','.join(['[%s]'%(len(fileList)) for fileList in fileArray])))
fileList = self.runActions(RULES_ACTION_CHANNEL_BUILD_FILELIST_PRE, citem, interleave(fileArray, self.interleaveValue), inherited=self)
self.log('[%s] buildChannel, pre fileList items = %s'%(citem['id'],len(fileList)),xbmc.LOGINFO)
fileList = self.runActions(RULES_ACTION_CHANNEL_BUILD_FILELIST_POST, citem, _injectFillers(citem, fileList, self.fillBCTs), inherited=self)
self.log('[%s] buildChannel, post fileList items = %s'%(citem['id'],len(fileList)),xbmc.LOGINFO)
else: fileList = fileArray
return self.runActions(RULES_ACTION_CHANNEL_BUILD_FILELIST_RETURN, citem, fileList, inherited=self)
def buildFileList(self, citem: dict, path: str, media: str='video', page: int=SETTINGS.getSettingInt('Page_Limit'), sort: dict={}, limits: dict={}) -> list: #build channel via vfs path.
self.log("[%s] buildFileList, media = %s, path = %s\nlimit = %s, sort = %s, page = %s"%(citem['id'],media,path,page,sort,limits))
self.loopback = {}
def __padFileList(fileItems, page):
if page > len(fileItems):
tmpList = fileItems * (page // len(fileItems))
tmpList.extend(fileItems[:page % len(fileItems)])
return tmpList
return fileItems
fileArray = []
if path.endswith('.xsp'): #smartplaylist - parse xsp for path, sort info
paths, media, sort, page = self.xsp.parseXSP(citem.get('id',''), path, media, sort, page)
if len(paths) > 0:
for idx, npath in enumerate(paths):
self.pName = '%s %s/%s'%(citem['name'],idx+1,len(paths))
fileArray.append(self.buildFileList(citem, npath, media, page, sort, limits))
return interleave(fileArray, self.interleaveValue)
elif 'db://' in path and '?xsp=' in path: #dynamicplaylist - parse xsp for path, filter and sort info
path, media, sort, filter = self.xsp.parseDXSP(citem.get('id',''), path, sort, {}, self.incExtras) #todo filter adv. rules
fileList = []
dirList = [{'file':path}]
npath = path
nlimits = limits
self.log("[%s] buildFileList, page = %s, sort = %s, limits = %s\npath = %s"%(citem['id'],page,sort,limits,path))
while not self.service.monitor.abortRequested() and len(fileList) < page:
#Not all results are flat hierarchies; walk all paths until fileList page is reached. ie. folders with pagination and/or directories
if self.service._interrupt():
self.log("[%s] buildFileList, _interrupt"%(citem['id']))
self.updateProgress(self.pCount, message='%s: %s'%(LANGUAGE(32144),LANGUAGE(32213)), header=ADDON_NAME)
return []
elif self.service._suspend():
self.log("[%s] buildFileList, _suspend"%(citem['id']))
self.updateProgress(self.pCount, message='%s: %s'%(LANGUAGE(32144),LANGUAGE(32145)), header=ADDON_NAME)
self.service.monitor.waitForAbort(SUSPEND_TIMER)
continue
elif len(dirList) > 0:
dir = dirList.pop(0)
npath = dir.get('file')
subfileList, subdirList, nlimits, errors = self.buildList(citem, npath, media, abs(page - len(fileList)), sort, limits, dir) #parse all directories under root. Flattened hierarchies required to stream line channel building.
fileList += subfileList
dirList = setDictLST(dirList + subdirList)
self.log('[%s] buildFileList, adding = %s/%s remaining dirs (%s)\npath = %s, limits = %s'%(citem['id'],len(fileList),page,len(dirList),npath,nlimits))
elif len(dirList) == 0:
if len(fileList) > 0 and nlimits.get('total',0) > 0:
dirList.insert(0,{'file':npath})
self.log('[%s] buildFileList, reparse path %s'%(citem['id'],npath))
else:
self.log('[%s] buildFileList, no more folders to parse'%(citem['id']))
break
self.log("[%s] buildFileList, returning fileList %s/%s"%(citem['id'],len(fileList),page))
return fileList
def buildList(self, citem: dict, path: str, media: str='video', page: int=SETTINGS.getSettingInt('Page_Limit'), sort: dict={}, limits: dict={}, dirItem: dict={}, query: dict={}):
self.log("[%s] buildList, media = %s, path = %s\npage = %s, sort = %s, query = %s, limits = %s\ndirItem = %s"%(citem['id'],media,path,page,sort,query,limits,dirItem))
dirList, fileList, seasoneplist, trailersdict = [], [], [], {}
items, nlimits, errors = self.jsonRPC.requestList(citem, path, media, page, sort, limits, query)
if errors.get('message'):
self.pErrors.append(errors['message'])
return fileList, dirList, nlimits, errors
elif items == self.loopback and limits != nlimits:# malformed jsonrpc queries will return root response, catch a re-parse and return.
self.log("[%s] buildList, loopback detected using path = %s\nreturning: fileList (%s), dirList (%s)"%(citem['id'],path,len(fileList),len(dirList)))
self.pErrors.append(LANGUAGE(32030))
return fileList, dirList, nlimits, errors
elif not items and len(fileList) == 0:
self.log("[%s] buildList, no request items found using path = %s\nreturning: fileList (%s), dirList (%s)"%(citem['id'],path,len(fileList),len(dirList)))
self.pErrors.append(LANGUAGE(32026))
return fileList, dirList, nlimits, errors
elif items:
self.loopback = items
for idx, item in enumerate(items):
file = item.get('file','')
fileType = item.get('filetype','file')
if not item.get('type'): item['type'] = query.get('key','files')
if self.service._interrupt():
self.log("[%s] buildList, _interrupt"%(citem['id']))
self.updateProgress(self.pCount, message='%s: %s'%(LANGUAGE(32144),LANGUAGE(32213)), header=ADDON_NAME)
self.jsonRPC.autoPagination(citem['id'], path, query, limits) #rollback pagination limits
return [], [], nlimits, errors
elif fileType == 'directory':
dirList.append(item)
# self.updateProgress(self.pCount, message=f'{self.pName}: {int(idx*100)//page}% appending: {item.get("label")}',header=f'{ADDON_NAME}, {self.pMSG}')
self.log("[%s] buildList, IDX = %s, appending directory: %s"%(citem['id'],idx,file),xbmc.LOGINFO)
elif fileType == 'file':
if file.startswith('pvr://'): #parse encoded fileitem otherwise no relevant meta provided via org. query. playable pvr:// paths are limited in Kodi.
self.log("[%s] buildList, IDX = %s, PVR item => FileItem! file = %s"%(citem['id'],idx,file),xbmc.LOGINFO)
item = decodePlot(item.get('plot',''))
file = item.get('file')
if not file:
self.pErrors.append(LANGUAGE(32031))
self.log("[%s] buildList, IDX = %s, skipping missing playable file! path = %s"%(citem['id'],idx,path),xbmc.LOGINFO)
continue
elif (file.lower().endswith('strm') and not self.incStrms):
self.pErrors.append('%s STRM'%(LANGUAGE(32027)))
self.log("[%s] buildList, IDX = %s, skipping strm file! file = %s"%(citem['id'],idx,file),xbmc.LOGINFO)
continue
if not item.get('streamdetails',{}).get('video',[]) and not file.startswith(tuple(VFS_TYPES)): #parsing missing meta, kodi rpc bug fails to return streamdetails during Files.GetDirectory.
item['streamdetails'] = self.jsonRPC.getStreamDetails(file, media)
if (self.is3D(item) and not self.inc3D):
self.pErrors.append('%s 3D'%(LANGUAGE(32027)))
self.log("[%s] buildList, IDX = %s skipping 3D file! file = %s"%(citem['id'],idx,file),xbmc.LOGINFO)
continue
title = (item.get("title") or item.get("label") or dirItem.get('label') or '')
tvtitle = (item.get("showtitle") or item.get("label") or dirItem.get('label') or '')
if (item['type'].startswith(tuple(TV_TYPES)) or item.get("showtitle")):# This is a TV show
season = int(item.get("season","0"))
episode = int(item.get("episode","0"))
if not file.startswith(tuple(VFS_TYPES)) and not self.incExtras and (season == 0 or episode == 0):
self.pErrors.append('%s Extras'%(LANGUAGE(32027)))
self.log("[%s] buildList, IDX = %s skipping extras! file = %s"%(citem['id'],idx,file),xbmc.LOGINFO)
continue
label = tvtitle
item["tvshowtitle"] = tvtitle
item["episodetitle"] = title
item["episodelabel"] = '%s%s'%(title,' (%sx%s)'%(season,str(episode).zfill(2))) #Episode Title (SSxEE) Mimic Kodi's PVR label format
item["showlabel"] = '%s%s'%(item["tvshowtitle"],' - %s'%(item['episodelabel']) if item['episodelabel'] else '')
else: # This is a Movie
label = title
item["episodetitle"] = item.get("tagline","")
item["episodelabel"] = item.get("tagline","")
item["showlabel"] = '%s%s'%(item["title"], ' - %s'%(item['episodelabel']) if item['episodelabel'] else '')
if not label:
self.pErrors.append(LANGUAGE(32018)(LANGUAGE(30188)))
continue
dur = self.jsonRPC.getDuration(file, item, self.accurateDuration, self.saveDuration)
if dur > self.minDuration: #include media that's duration is above the players seek tolerance & users adv. rule
self.updateProgress(self.pCount, message='%s: %s'%(self.pName,int(idx*100)//page)+'%',header='%s, %s'%(ADDON_NAME,self.pMSG))
item['duration'] = dur
item['media'] = media
item['originalpath'] = path #use for path sorting/playback verification
item['friendly'] = SETTINGS.getFriendlyName()
item['remote'] = PROPERTIES.getRemoteHost()
if item.get("year",0) == 1601: item['year'] = 0 #detect kodi bug that sets a fallback year to 1601 https://github.com/xbmc/xbmc/issues/15554
spTitle, spYear = splitYear(label)
item['label'] = spTitle
if item.get('year',0) == 0 and spYear: item['year'] = spYear #replace missing item year with one parsed from show title
item['plot'] = (item.get("plot","") or item.get("plotoutline","") or item.get("description","") or LANGUAGE(32020)).strip()
if query.get('holiday'):
citem['holiday'] = query.get('holiday')
holiday = "[B]%s[/B] - [I]%s[/I]"%(query["holiday"]["name"],query["holiday"]["tagline"]) if query["holiday"]["tagline"] else "[B]%s[/B]"%(query["holiday"]["name"])
item["plot"] = "%s \n%s"%(holiday,item["plot"])
item['art'] = (item.get('art',{}) or dirItem.get('art',{}))
item.get('art',{})['icon'] = citem['logo']
if item.get('trailer') and self.bctTypes['trailers'].get('enabled',False):
titem = item.copy()
tdur = self.jsonRPC.getDuration(titem.get('trailer'), accurate=True, save=False)
if tdur > 0:
titem.update({'label':'%s - %s'%(item["label"],LANGUAGE(30187)),'episodetitle':'%s - %s'%(item["episodetitle"],LANGUAGE(30187)),'episodelabel':'%s - %s'%(item["episodelabel"],LANGUAGE(30187)),'duration':tdur, 'runtime':tdur, 'file':titem['trailer'], 'streamdetails':{}})
[trailersdict.setdefault(genre.lower(),[]).append(titem) for genre in (titem.get('genre',[]) or ['resources'])]
if sort.get("method","") == 'episode' and (int(item.get("season","0")) + int(item.get("episode","0"))) > 0:
seasoneplist.append([int(item.get("season","0")), int(item.get("episode","0")), item])
else:
fileList.append(item)
else:
self.pErrors.append(LANGUAGE(32032))
self.log("[%s] buildList, IDX = %s skipping content no duration meta found! or runtime below minDuration (%s/%s) file = %s"%(citem['id'],idx,dur,self.minDuration,file),xbmc.LOGINFO)
if sort.get("method","").startswith('episode'):
self.log("[%s] buildList, sorting by episode"%(citem['id']))
seasoneplist.sort(key=lambda seep: seep[1])
seasoneplist.sort(key=lambda seep: seep[0])
for seepitem in seasoneplist:
fileList.append(seepitem[2])
elif sort.get("method","") == 'random':
self.log("[%s] buildList, random shuffling"%(citem['id']))
dirList = randomShuffle(dirList)
fileList = randomShuffle(fileList)
self.getTrailers(trailersdict)
self.log("[%s] buildList, returning (%s) files, (%s) dirs; parsed (%s) trailers"%(citem['id'],len(fileList),len(dirList),len(trailersdict)))
return fileList, dirList, nlimits, errors
def isHD(self, item: dict) -> bool:
if 'isHD' in item: return item['isHD']
elif not item.get('streamdetails',{}).get('video',[]) and not item.get('file','').startswith(tuple(VFS_TYPES)):
item['streamdetails'] = self.jsonRPC.getStreamDetails(item.get('file'), item.get('media','video'))
details = item.get('streamdetails',{})
if 'video' in details and len(details.get('video')) > 0:
videowidth = int(details['video'][0]['width'] or '0')
videoheight = int(details['video'][0]['height'] or '0')
if videowidth >= 1280 or videoheight >= 720: return True
return False
def isUHD(self, item: dict) -> bool:
if 'isUHD' in item: return item['isUHD']
elif not item.get('streamdetails',{}).get('video',[]) and not item.get('file','').startswith(tuple(VFS_TYPES)):
item['streamdetails'] = self.jsonRPC.getStreamDetails(item.get('file'), item.get('media','video'))
details = item.get('streamdetails',{})
if 'video' in details and len(details.get('video')) > 0:
videowidth = int(details['video'][0]['width'] or '0')
videoheight = int(details['video'][0]['height'] or '0')
if videowidth > 1920 or videoheight > 1080: return True
return False
def is3D(self, item: dict) -> bool:
if 'is3D' in item: return item['is3D']
elif not item.get('streamdetails',{}).get('video',[]) and not item.get('file','').startswith(tuple(VFS_TYPES)):
item['streamdetails'] = self.jsonRPC.getStreamDetails(item.get('file'), item.get('media','video'))
details = item.get('streamdetails',{})
if 'video' in details and details.get('video') != [] and len(details.get('video')) > 0:
stereomode = (details['video'][0]['stereomode'] or [])
if len(stereomode) > 0: return True
return False
def getTrailers(self, nitems: dict={}) -> dict:
return self.cache.set('kodiTrailers', mergeDictLST((self.cache.get('kodiTrailers', json_data=True) or {}),nitems), expiration=datetime.timedelta(days=28), json_data=True)

View File

@@ -0,0 +1,115 @@
# 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 <http://www.gnu.org/licenses/>.
#
# -*- coding: utf-8 -*-
from globals import *
from functools import wraps
from fileaccess import FileAccess
try: from simplecache import SimpleCache
except: from simplecache.simplecache import SimpleCache #pycharm stub
def cacheit(expiration=datetime.timedelta(days=MIN_GUIDEDAYS), checksum=ADDON_VERSION, json_data=False):
def internal(method):
@wraps(method)
def wrapper(*args, **kwargs):
method_class = args[0]
cacheName = "%s.%s"%(method_class.__class__.__name__, method.__name__)
for item in args[1:]: cacheName += u".%s"%item
for k, v in list(kwargs.items()): cacheName += u".%s"%(v)
results = method_class.cache.get(cacheName.lower(), checksum, json_data)
if results: return results
return method_class.cache.set(cacheName.lower(), method(*args, **kwargs), checksum, expiration, json_data)
return wrapper
return internal
class Service:
monitor = MONITOR()
def _interrupt(self) -> bool:
return xbmcgui.Window(10000).getProperty('%s.pendingInterrupt'%(ADDON_ID)) == "true"
def _suspend(self) -> bool:
return xbmcgui.Window(10000).getProperty('%s.suspendActivity'%(ADDON_ID)) == "true"
class Cache:
lock = Lock()
cache = SimpleCache()
service = Service()
@contextmanager
def cacheLocker(self, wait=0.0001): #simplecache is not thread safe, threadlock not avoiding collisions? Hack/Lazy avoidance.
while not self.service.monitor.abortRequested():
if self.service.monitor.waitForAbort(wait) or self.service._interrupt(): break
elif xbmcgui.Window(10000).getProperty('%s.cacheLocker'%(ADDON_ID)) != 'true': break
xbmcgui.Window(10000).setProperty('%s.cacheLocker'%(ADDON_ID),'true')
try: yield
finally:
xbmcgui.Window(10000).setProperty('%s.cacheLocker'%(ADDON_ID),'false')
def __init__(self, mem_cache=False, is_json=False, disable_cache=False):
self.cache.enable_mem_cache = mem_cache
self.cache.data_is_json = is_json
self.disable_cache = (disable_cache | REAL_SETTINGS.getSettingBool('Disable_Cache'))
def log(self, msg, level=xbmc.LOGDEBUG):
log('%s: %s'%(self.__class__.__name__,msg),level)
def getname(self, name):
if not name.startswith(ADDON_ID): name = '%s.%s'%(ADDON_ID,name)
return name.lower()
def set(self, name, value, checksum=ADDON_VERSION, expiration=datetime.timedelta(minutes=15), json_data=False):
if not self.disable_cache or (not isinstance(value,(bool,list,dict)) and not value):
with self.cacheLocker():
self.log('set, name = %s, value = %s'%(self.getname(name),'%s...'%(str(value)[:128])))
self.cache.set(self.getname(name),value,checksum,expiration,json_data)
return value
def get(self, name, checksum=ADDON_VERSION, json_data=False):
if not self.disable_cache:
with self.cacheLocker():
try:
value = self.cache.get(self.getname(name),checksum,json_data)
self.log('get, name = %s, value = %s'%(self.getname(name),'%s...'%(str(value)[:128])))
return value
except Exception as e:
self.log("get, name = %s failed! simplecacheDB %s"%(self.getname(name),e), xbmc.LOGERROR)
self.clear(name)
def clear(self, name, wait=15):
import sqlite3
self.log('clear, name = %s'%self.getname(name))
sc = FileAccess.translatePath(xbmcaddon.Addon(id='script.module.simplecache').getAddonInfo('profile'))
dbpath = os.path.join(sc, 'simplecache.db')
try:
connection = sqlite3.connect(dbpath, timeout=wait, isolation_level=None)
connection.execute('DELETE FROM simplecache WHERE id LIKE ?', (self.getname(name) + '%',))
connection.commit()
except sqlite3.Error as e: self.log('clear, failed! %s' % e, xbmc.LOGERROR)
finally:
if connection:
connection.close()
del connection
del sqlite3

View File

@@ -0,0 +1,162 @@
# 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 <http://www.gnu.org/licenses/>.
# -*- coding: utf-8 -*-
from globals import *
#todo create dataclasses for all jsons
# https://pypi.org/project/dataclasses-json/
class Channels:
def __init__(self):
self.channelDATA = getJSON(CHANNELFLE_DEFAULT)
self.channelTEMP = getJSON(CHANNEL_ITEM)
self.channelDATA.update(self._load())
def log(self, msg, level=xbmc.LOGDEBUG):
return log('%s: %s'%(self.__class__.__name__,msg),level)
def _load(self, file=CHANNELFLEPATH) -> dict:
channelDATA = getJSON(file)
self.log('_load, channels = %s'%(len(channelDATA.get('channels',[]))))
return channelDATA
def _verify(self, channels: list=[]):
for idx, citem in enumerate(self.channelDATA.get('channels',[])):
if not citem.get('name') or not citem.get('id') or len(citem.get('path',[])) == 0:
self.log('_verify, in-valid citem [%s]\n%s'%(citem.get('id'),citem))
continue
else: yield citem
def _save(self, file=CHANNELFLEPATH) -> bool:
self.channelDATA['uuid'] = SETTINGS.getMYUUID()
self.channelDATA['channels'] = self.sortChannels(self.channelDATA['channels'])
self.log('_save, channels = %s'%(len(self.channelDATA['channels'])))
return setJSON(file,self.channelDATA)
def getTemplate(self) -> dict:
return self.channelTEMP.copy()
def getChannels(self) -> list:
return sorted(self.channelDATA['channels'], key=itemgetter('number'))
def popChannels(self, type: str, channels: list=[]) -> list:
return [self.channelDATA['channels'].pop(self.channelDATA['channels'].index(citem)) for citem in list([c for c in channels if c.get('type') == type])]
def getCustom(self) -> list:
channels = self.getChannels()
return list([citem for citem in channels if citem.get('number') <= CHANNEL_LIMIT])
def getAutotuned(self) -> list:
channels = self.getChannels()
return list([citem for citem in channels if citem.get('number') > CHANNEL_LIMIT])
def getChannelbyID(self, id: str) -> list:
channels = self.getChannels()
return list([c for c in channels if c.get('id') == id])
def getType(self, type: str):
channels = self.getChannels()
return list([citem for citem in channels if citem.get('type') == type])
def sortChannels(self, channels: list) -> list:
try: return sorted(channels, key=itemgetter('number'))
except: return channels
def setChannels(self, channels: list=[]) -> bool:
if len(channels) == 0: channels = self.channelDATA['channels']
self.channelDATA['channels'] = channels
SETTINGS.setSetting('Select_Channels','[B]%s[/B] Channels'%(len(channels)))
PROPERTIES.setChannels(len(channels)>0)
return self._save()
def getImports(self) -> list:
return self.channelDATA.get('imports',[])
def setImports(self, data: list=[]) -> bool:
self.channelDATA['imports'] = data
return self.setChannels()
def clearChannels(self):
self.channelDATA['channels'] = []
def delChannel(self, citem: dict={}) -> bool:
self.log('delChannel,[%s]'%(citem['id']), xbmc.LOGINFO)
idx, channel = self.findChannel(citem)
if idx is not None: self.channelDATA['channels'].pop(idx)
return True
def delChannels(self, channels: list=[]) -> bool:
return [self.delChannel(channel) for channel in channels]
def addChannel(self, citem: dict={}) -> bool:
idx, channel = self.findChannel(citem)
if idx is not None:
for key in ['id','rules','number','favorite','logo']:
if channel.get(key): citem[key] = channel[key] # existing id found, reuse channel meta.
if citem.get('favorite',False):
citem['group'].append(LANGUAGE(32019))
citem['group'] = sorted(set(citem['group']))
self.log('addChannel, [%s] updating channel %s'%(citem["id"],citem["name"]), xbmc.LOGINFO)
self.channelDATA['channels'][idx] = citem
else:
self.log('addChannel, [%s] adding channel %s'%(citem["id"],citem["name"]), xbmc.LOGINFO)
self.channelDATA.setdefault('channels',[]).append(citem)
return True
def addChannels(self, channels: list=[]) -> bool:
return [self.addChannel(channel) for channel in channels]
def findChannel(self, citem: dict={}, channels: list=[]) -> tuple:
if len(channels) == 0: channels = self.getChannels()
for idx, eitem in enumerate(channels):
if citem.get('id') == eitem.get('id',str(random.random())):
return idx, eitem
return None, {}
def findAutotuned(self, citem: dict={}, channels: list=[]) -> tuple:
if len(channels) == 0: channels = self.getAutotuned()
for idx, eitem in enumerate(channels):
if (citem.get('id') == eitem.get('id',str(random.random()))) or (citem.get('type') == eitem.get('type',str(random.random())) and citem.get('name','').lower() == eitem.get('name',str(random.random())).lower()):
return idx, eitem
return None, {}

View File

@@ -0,0 +1,385 @@
# 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 <http://www.gnu.org/licenses/>.
#
# -*- coding: utf-8 -*-
import os
from kodi_six import xbmc, xbmcaddon
#info
ADDON_ID = 'plugin.video.pseudotv.live'
REAL_SETTINGS = xbmcaddon.Addon(id=ADDON_ID)
ADDON_NAME = REAL_SETTINGS.getAddonInfo('name')
ADDON_VERSION = REAL_SETTINGS.getAddonInfo('version')
ICON = REAL_SETTINGS.getAddonInfo('icon')
FANART = REAL_SETTINGS.getAddonInfo('fanart')
SETTINGS_LOC = REAL_SETTINGS.getAddonInfo('profile')
ADDON_PATH = REAL_SETTINGS.getAddonInfo('path')
ADDON_AUTHOR = REAL_SETTINGS.getAddonInfo('author')
ADDON_URL = 'https://raw.githubusercontent.com/PseudoTV/PseudoTV_Live/master/plugin.video.pseudotv.live/addon.xml'
LANGUAGE = REAL_SETTINGS.getLocalizedString
#api
MONITOR = xbmc.Monitor
PLAYER = xbmc.Player
#constants
FIFTEEN = 15 #unit
DISCOVERY_TIMER = 60 #secs
SUSPEND_TIMER = 2 #secs
DISCOVER_INTERVAL = 30 #secs
MIN_EPG_DURATION = 10800 #secs
ONNEXT_TIMER = 15
DTFORMAT = '%Y%m%d%H%M%S'
DTZFORMAT = '%Y%m%d%H%M%S +%z'
DTJSONFORMAT = '%Y-%m-%d %H:%M:%S'
BACKUP_TIME_FORMAT = '%Y-%m-%d %I:%M %p'
LANG = 'en' #todo parse kodi region settings
DEFAULT_ENCODING = "utf-8"
PROMPT_DELAY = 4000 #msecs
AUTOCLOSE_DELAY = 300 #secs
SELECT_DELAY = 900 #secs
RADIO_ITEM_LIMIT = 250
CHANNEL_LIMIT = 999
AUTOTUNE_LIMIT = 3
FILLER_LIMIT = 250
QUEUE_CHUNK = 25
FILLER_TYPE = ['Rating',
'Bumper',
'Advert',
'Trailer',
'Pre-Roll',
'Post-Roll']
FILLER_TYPES = ['Ratings',
'Bumpers',
'Adverts',
'Trailers']
AUTOTUNE_TYPES = ["Playlists",
"TV Networks",
"TV Shows",
"TV Genres",
"Movie Genres",
"Movie Studios",
"Mixed Genres",
"Music Genres",
"Mixed",
"Recommended",
"Services"]
GROUP_TYPES = ['Addon',
'Custom',
'Directory',
'TV',
'Movies',
'Music',
'Miscellaneous',
'PVR',
'Plugin',
'Radio',
'Smartplaylist',
'UPNP',
'IPTV'] + AUTOTUNE_TYPES
DB_TYPES = ["videodb://",
"musicdb://",
"library://",
"special://"]
WEB_TYPES = ["http",
"ftp://",
"pvr://"
"upnp://",]
VFS_TYPES = ["plugin://",
"pvr://",
"resource://",
"special://home/addons/resource"]
TV_TYPES = ['episode',
'episodes',
'tvshow',
'tvshows']
MOVIE_TYPES = ['movie',
'movies']
MUSIC_TYPES = ['songs',
'albums',
'artists',
'music']
HTML_ESCAPE = {"&": "&amp;",
'"': "&quot;",
"'": "&apos;",
">": "&gt;",
"<": "&lt;"}
ALT_PLAYLISTS = [".cue",
".m3u",
".m3u8",
".strm",
".pls",
".wpl"]
IGNORE_CHTYPE = ['TV Shows',
'Mixed',
'Recommended',
'Services',
'Music Genres']
MOVIE_CHTYPE = ["Movie Genres",
"Movie Studios"]
TV_CHTYPE = ["TV Networks",
"TV Genres",
"Mixed Genre"]
TV_URL = 'plugin://{addon}/?mode=tv&name={name}&chid={chid}.pvr'
RESUME_URL = 'plugin://{addon}/?mode=resume&name={name}&chid={chid}.pvr'
RADIO_URL = 'plugin://{addon}/?mode=radio&name={name}&chid={chid}&radio={radio}&vid={vid}.pvr'
LIVE_URL = 'plugin://{addon}/?mode=live&name={name}&chid={chid}&vid={vid}&now={now}&start={start}&duration={duration}&stop={stop}.pvr'
BROADCAST_URL = 'plugin://{addon}/?mode=broadcast&name={name}&chid={chid}&vid={vid}.pvr'
VOD_URL = 'plugin://{addon}/?mode=vod&title={title}&chid={chid}&vid={vid}&name={name}.pvr'
DVR_URL = 'plugin://{addon}/?mode=dvr&title={title}&chid={chid}&vid={vid}&seek={seek}&duration={duration}.pvr'
PTVL_REPO = 'repository.pseudotv'
PVR_CLIENT_ID = 'pvr.iptvsimple'
PVR_CLIENT_NAME = 'IPTV Simple Client'
PVR_CLIENT_LOC = 'special://profile/addon_data/%s'%(PVR_CLIENT_ID)
#docs
README_FLE = os.path.join(ADDON_PATH,'README.md')
CHANGELOG_FLE = os.path.join(ADDON_PATH,'changelog.txt')
LICENSE_FLE = os.path.join(ADDON_PATH,'LICENSE')
#files
M3UFLE = 'pseudotv.m3u'
XMLTVFLE = 'pseudotv.xml'
GENREFLE = 'genres.xml'
REMOTEFLE = 'remote.json'
BONJOURFLE = 'bonjour.json'
SERVERFLE = 'servers.json'
CHANNELFLE = 'channels.json'
LIBRARYFLE = 'library.json'
TVGROUPFLE = 'tv_groups.xml'
RADIOGROUPFLE = 'radio_groups.xml'
PROVIDERFLE = 'providers.xml'
CHANNELBACKUPFLE = 'channels.backup'
CHANNELRESTOREFLE = 'channels.restore'
#exts
VIDEO_EXTS = xbmc.getSupportedMedia('video').split('|')[:-1]
MUSIC_EXTS = xbmc.getSupportedMedia('music').split('|')[:-1]
IMAGE_EXTS = xbmc.getSupportedMedia('picture').split('|')[:-1]
IMG_EXTS = ['.png','.jpg','.gif']
TEXTURES = 'Textures.xbt'
#folders
IMAGE_LOC = os.path.join(ADDON_PATH,'resources','images')
MEDIA_LOC = os.path.join(ADDON_PATH,'resources','skins','default','media')
SFX_LOC = os.path.join(MEDIA_LOC,'sfx')
SERVER_LOC = os.path.join(SETTINGS_LOC,SERVERFLE)
BACKUP_LOC = os.path.join(SETTINGS_LOC,'backup')
CACHE_LOC = os.path.join(SETTINGS_LOC,'cache')
TEMP_LOC = os.path.join(SETTINGS_LOC,'temp')
#file paths
SETTINGS_FLE = os.path.join(SETTINGS_LOC,'settings.xml')
CHANNELFLE_BACKUP = os.path.join(BACKUP_LOC,CHANNELBACKUPFLE)
CHANNELFLE_RESTORE = os.path.join(BACKUP_LOC,CHANNELRESTOREFLE)
#sfx
BING_WAV = os.path.join(SFX_LOC,'bing.wav')
NOTE_WAV = os.path.join(SFX_LOC,'notify.wav')
#remotes
IMPORT_ASSET = os.path.join(ADDON_PATH,'remotes','asset.json')
RULEFLE_ITEM = os.path.join(ADDON_PATH,'remotes','rule.json')
CHANNEL_ITEM = os.path.join(ADDON_PATH,'remotes','channel.json')
M3UFLE_DEFAULT = os.path.join(ADDON_PATH,'remotes','m3u.json')
SEASONS = os.path.join(ADDON_PATH,'remotes','seasons.json')
HOLIDAYS = os.path.join(ADDON_PATH,'remotes','holidays.json')
GROUPFLE_DEFAULT = os.path.join(ADDON_PATH,'remotes','groups.xml')
LIBRARYFLE_DEFAULT = os.path.join(ADDON_PATH,'remotes',LIBRARYFLE)
CHANNELFLE_DEFAULT = os.path.join(ADDON_PATH,'remotes',CHANNELFLE)
GENREFLE_DEFAULT = os.path.join(ADDON_PATH,'remotes',GENREFLE)
PROVIDERFLE_DEFAULT = os.path.join(ADDON_PATH,'remotes',PROVIDERFLE)
#colors
PRIMARY_BACKGROUND = 'FF11375C'
SECONDARY_BACKGROUND = '334F4F9E'
DIALOG_TINT = 'FF181B1E'
BUTTON_FOCUS = 'FF2866A4'
SELECTED = 'FF5BE5EE'
COLOR_BACKGROUND = '01416b'
COLOR_TEXT = 'FFFFFF'
COLOR_UNAVAILABLE_CHANNEL = 'dimgray'
COLOR_AVAILABLE_CHANNEL = 'white'
COLOR_LOCKED_CHANNEL = 'orange'
COLOR_WARNING_CHANNEL = 'red'
COLOR_NEW_CHANNEL = 'green'
COLOR_RADIO_CHANNEL = 'cyan'
COLOR_FAVORITE_CHANNEL = 'yellow'
#https://github.com/xbmc/xbmc/blob/656052d108297e4dd8c5c6fc7db86606629e457e/system/colors.xml
#urls
URL_WIKI = 'https://github.com/PseudoTV/PseudoTV_Live/wiki'
URL_SUPPORT = 'https://forum.kodi.tv/showthread.php?tid=346803'
URL_WIN_BONJOUR = 'https://support.apple.com/en-us/106380'
URL_README = 'https://github.com/PseudoTV/PseudoTV_Live/blob/master/plugin.video.pseudotv.live/README.md'
# https://github.com/xbmc/xbmc/blob/master/system/colors.xml
#images
LOGO = os.path.join(MEDIA_LOC,'wlogo.png')
DIM_LOGO = os.path.join(MEDIA_LOC,'dimlogo.png')
COLOR_LOGO = os.path.join(MEDIA_LOC,'logo.png')
COLOR_FANART = os.path.join(MEDIA_LOC,'fanart.jpg')
HOST_LOGO = 'http://github.com/PseudoTV/PseudoTV_Live/blob/master/plugin.video.pseudotv.live/resources/skins/default/media/logo.png?raw=true'
DUMMY_ICON = 'https://dummyimage.com/512x512/%s/%s.png&text={text}'%(COLOR_BACKGROUND,COLOR_TEXT)
#skins
BUSY_XML = '%s.busy.xml'%(ADDON_ID)
ONNEXT_XML = '%s.onnext.xml'%(ADDON_ID)
RESTART_XML = '%s.restart.xml'%(ADDON_ID)
ONNEXT_XML = '%s.onnext.xml'%(ADDON_ID)
BACKGROUND_XML = '%s.background.xml'%(ADDON_ID)
MANAGER_XML = '%s.manager.xml'%(ADDON_ID)
OVERLAYTOOL_XML = '%s.overlaytool.xml'%(ADDON_ID)
DIALOG_SELECT = '%s.dialogselect.xml'%(ADDON_ID)
# https://github.com/xbmc/xbmc/blob/master/xbmc/addons/kodi-dev-kit/include/kodi/c-api/gui/input/action_ids.h
# Actions
ACTION_MOVE_LEFT = 1
ACTION_MOVE_RIGHT = 2
ACTION_MOVE_UP = 3
ACTION_MOVE_DOWN = 4
ACTION_INVALID = 999
ACTION_SELECT_ITEM = [7,135]
ACTION_PREVIOUS_MENU = [92,10,110,521,ACTION_SELECT_ITEM]
#rules
##builder
RULES_ACTION_CHANNEL_CITEM = 1 #Persistent citem changes
RULES_ACTION_CHANNEL_START = 2 #Set channel global
RULES_ACTION_CHANNEL_BUILD_FILEARRAY_PRE = 3 #Initial FileArray (build bypass)
RULES_ACTION_CHANNEL_BUILD_PATH = 4 #Alter parsing directory prior to build
RULES_ACTION_CHANNEL_BUILD_FILELIST_PRE = 5 #Initial FileList prior to fillers, after interleaving
RULES_ACTION_CHANNEL_BUILD_FILELIST_POST = 6 #Final FileList after fillers
RULES_ACTION_CHANNEL_BUILD_TIME_PRE = 7 #FileList prior to scheduling
RULES_ACTION_CHANNEL_BUILD_TIME_POST = 8 #FileList after scheduling
RULES_ACTION_CHANNEL_BUILD_FILEARRAY_POST = 9 #FileArray prior to interleaving and fillers
RULES_ACTION_CHANNEL_STOP = 10#restore channel global
RULES_ACTION_CHANNEL_TEMP_CITEM = 11 #Temporary citem changes, rule injection
RULES_ACTION_CHANNEL_BUILD_FILELIST_RETURN = 12
##player
RULES_ACTION_PLAYER_START = 20 #Playback started
RULES_ACTION_PLAYER_CHANGE = 21 #Playback changed/ended
RULES_ACTION_PLAYER_STOP = 22 #Playback stopped
##overlay/background
RULES_ACTION_OVERLAY_OPEN = 30 #Overlay opened
RULES_ACTION_OVERLAY_CLOSE = 31 #Overlay closed
##playback
RULES_ACTION_PLAYBACK_RESUME = 40 #Prior to playback trigger resume to received a FileList
HEADER = {'User-Agent': "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/42.0.2311.135 Safari/537.36 Edge/12.246"}
MUSIC_LISTITEM_TYPES = {'tracknumber' : (int,), #integer (8)
'discnumber' : (int,), #integer (2)
'duration' : (int,), #integer (245) - duration in seconds
'year' : (int,), #integer (1998)
'genre' : (tuple,list),
'album' : (str,),
'artist' : (str,),
'title' : (str,),
'rating' : (float,),#float - range is between 0 and 10
'userrating' : (int,), #integer - range is 1..10
'lyrics' : (str,),
'playcount' : (int,), #integer (2) - number of times this item has been played
'lastplayed' : (str,), #string (Y-m-d h:m:s = 2009-04-05 23:16:04)
'mediatype' : (str,), #string - "music", "song", "album", "artist"
'dbid' : (int,), #integer (23) - Only add this for items which are part of the local db. You also need to set the correct 'mediatype'!
'listeners' : (int,), #integer (25614)
'musicbrainztrackid' : (str,list),
'musicbrainzartistid' : (str,list),
'musicbrainzalbumid' : (str,list),
'musicbrainzalbumartistid': (str,list),
'comment' : (str,),
'count' : (int,), #integer (12) - can be used to store an id for later, or for sorting purposes
# 'size' : (int,), #long (1024) - size in bytes
'date' : (str,),} #string (d.m.Y / 01.01.2009) - file date
VIDEO_LISTITEM_TYPES = {'genre' : (tuple,list),
'country' : (str,list),
'year' : (int,), #integer (2009)
'episode' : (int,), #integer (4)
'season' : (int,), #integer (1)
'sortepisode' : (int,), #integer (4)
'sortseason' : (int,), #integer (1)
'episodeguide' : (str,),
'showlink' : (str,list),
'top250' : (int,), #integer (192)
'setid' : (int,), #integer (14)
'tracknumber' : (int,), #integer (3)
'rating' : (float,),#float (6.4) - range is 0..10
'userrating' : (int,), #integer (9) - range is 1..10 (0 to reset)
'playcount' : (int,), #integer (2) - number of times this item has been played
'overlay' : (int,), #integer (2) - range is 0..7. See Overlay icon types for values
'cast' : (list,),
'castandrole' : (list,tuple),
'director' : (str,list),
'mpaa' : (str,),
'plot' : (str,),
'plotoutline' : (str,),
'title' : (str,),
'originaltitle' : (str,),
'sorttitle' : (str,),
'duration' : (int,), #integer (245) - duration in seconds
'studio' : (str,list),
'tagline' : (str,),
'writer' : (str,list),
'tvshowtitle' : (str,list),
'premiered' : (str,), #string (2005-03-04)
'status' : (str,),
'set' : (str,),
'setoverview' : (str,),
'tag' : (str,list),
'imdbnumber' : (str,), #string (tt0110293) - IMDb code
'code' : (str,), #string (101) - Production code
'aired' : (str,), #string (2008-12-07)
'credits' : (str,list),
'lastplayed' : (str,), #string (Y-m-d h:m:s = 2009-04-05 23:16:04)
'album' : (str,),
'artist' : (list,),
'votes' : (str,),
'path' : (str,),
'trailer' : (str,),
'dateadded' : (str,), #string (Y-m-d h:m:s = 2009-04-05 23:16:04)
'mediatype' : (str,), #mediatype string - "video", "movie", "tvshow", "season", "episode" or "musicvideo"
'dbid' : (int,), #integer (23) - Only add this for items which are part of the local db. You also need to set the correct 'mediatype'!
'count' : (int,), #integer (12) - can be used to store an id for later, or for sorting purposes
# 'size' : (int,), #long (1024) - size in bytes
'date' : (str,),} #string (d.m.Y / 01.01.2009) - file date

View File

@@ -0,0 +1,68 @@
# 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 <http://www.gnu.org/licenses/>.
# -*- coding: utf-8 -*-
from globals import *
from manager import Manager
class Create:
def __init__(self, sysARG: dict={}, listitem: xbmcgui.ListItem=xbmcgui.ListItem(), fitem: dict={}):
log('Create: __init__, sysARG = %s, fitem = %s\npath = %s'%(sysARG,fitem,listitem.getPath()))
self.sysARG = sysARG
self.fitem = fitem
self.listitem = listitem
def add(self):
if not self.listitem.getPath(): return DIALOG.notificationDialog(LANGUAGE(32030))
elif DIALOG.yesnoDialog('Would you like to add:\n[B]%s[/B]\nto the first available %s channel?'%(self.listitem.getLabel(),ADDON_NAME)):
if not PROPERTIES.isRunning('Create.add'):
with PROPERTIES.chkRunning('Create.add'), BUILTIN.busy_dialog(), PROPERTIES.interruptActivity():
manager = Manager(MANAGER_XML, ADDON_PATH, "default", start=False, channel=-1)
channelData = manager.newChannel
channelData['type'] = 'Custom'
channelData['favorite'] = True
channelData['number'] = manager.getFirstAvailChannel()
channelData['radio'] = True if self.listitem.getPath().startswith('musicdb://') else False
channelData['name'], channelData = manager.validateLabel(cleanLabel(self.listitem.getLabel()),channelData)
path, channelData = manager.validatePath(unquoteString(self.listitem.getPath()),channelData,spinner=False)
if path is None: return
channelData['path'] = [path.strip('/')]
channelData['id'] = getChannelID(channelData['name'], channelData['path'], channelData['number'])
manager.channels.addChannel(channelData)
manager.channels.setChannels()
PROPERTIES.setUpdateChannels(channelData['id'])
manager.closeManager()
del manager
manager = Manager(MANAGER_XML, ADDON_PATH, "default", channel=channelData['number'])
del manager
def open(self):
if not PROPERTIES.isRunning('Create.open'):
with PROPERTIES.chkRunning('Create.open'), BUILTIN.busy_dialog(), PROPERTIES.interruptActivity():
manager = Manager(MANAGER_XML, ADDON_PATH, "default", channel=self.fitem.get('citem',{}).get('number',1))
del manager
if __name__ == '__main__':
log('Create: __main__, param = %s'%(sys.argv))
try: mode = sys.argv[1]
except: mode = ''
if mode == 'manage': Create(sys.argv,listitem=sys.listitem,fitem=decodePlot(BUILTIN.getInfoLabel('Plot'))).open()
else: Create(sys.argv,listitem=sys.listitem,fitem=decodePlot(BUILTIN.getInfoLabel('Plot'))).add()

View File

@@ -0,0 +1,87 @@
# 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 <http://www.gnu.org/licenses/>.
# -*- coding: utf-8 -*-
from globals import *
from seasonal import Seasonal
class Info:
def __init__(self, sysARG: dict={}, listitem: xbmcgui.ListItem=xbmcgui.ListItem(), fitem: dict={}):
with BUILTIN.busy_dialog():
log('Info: __init__, sysARG = %s'%(sysARG))
listitem = LISTITEMS.buildItemListItem(fitem,fitem.get('media','video'),oscreen=True)
DIALOG.infoDialog(listitem)
class Browse:
def __init__(self, sysARG: dict={}, listitem: xbmcgui.ListItem=xbmcgui.ListItem(), fitem: dict={}):
log('Browse: __init__, sysARG = %s'%(sysARG))
with BUILTIN.busy_dialog():
media = '%ss'%(fitem.get('media','video'))
path = fitem.get('citem',{}).get('path')
if isinstance(path,list): path = path[0]
if '?xsp=' in path:
path, params = path.split('?xsp=')
path = '%s?xsp=%s'%(path,quoteString(unquoteString(params)))
#todo create custom container window with channel listitems.
log('Browse: target = %s, path = %s'%(media,path))
BUILTIN.executebuiltin('ReplaceWindow(%s,%s,return)'%(media,path))
class Match:
SEARCH_SCRIPT = None
GLOBAL_SCRIPT = 'script.globalsearch'
SIMILAR_SCRIPT = 'script.embuary.helper'
def __init__(self, sysARG: dict={}, listitem: xbmcgui.ListItem=xbmcgui.ListItem(), fitem: dict={}):
with BUILTIN.busy_dialog():
title = BUILTIN.getInfoLabel('Title')
name = BUILTIN.getInfoLabel('EpisodeName')
dbtype = fitem.get('type').replace('episodes','tvshow').replace('tvshows','tvshow').replace('movies','movie')
dbid = (fitem.get('tvshowid') or fitem.get('movieid'))
log('Match: __init__, sysARG = %s, title = %s, dbtype = %s, dbid = %s'%(sysARG,'%s - %s'%(title,name),dbtype,dbid))
if hasAddon(self.SIMILAR_SCRIPT,install=True) and dbid:
self.SEARCH_SCRIPT = self.SIMILAR_SCRIPT
elif hasAddon(self.GLOBAL_SCRIPT,install=True):
self.SEARCH_SCRIPT = self.GLOBAL_SCRIPT
else:
DIALOG.notificationDialog(LANGUAGE(32000))
log('Match: SEARCH_SCRIPT = %s'%(self.SEARCH_SCRIPT))
hasAddon(self.SEARCH_SCRIPT,enable=True)
if self.SEARCH_SCRIPT == self.SIMILAR_SCRIPT:
# plugin://script.embuary.helper/?info=getsimilar&dbid=$INFO[ListItem.DBID]&type=tvshow&tag=HDR
# plugin://script.embuary.helper/?info=getsimilar&dbid=$INFO[ListItem.DBID]&type=movie&tag=HDR
# tag = optional, additional filter option to filter by library tag
BUILTIN.executebuiltin('ReplaceWindow(%s,%s,return)'%('%ss'%(fitem.get('media','video')),'plugin://%s/?info=getsimilar&dbid=%d&type=%s'%(self.SEARCH_SCRIPT,dbid,dbtype)))
else:
# - the addon is executed by another addon/skin: RunScript(script.globalsearch,searchstring=foo)
# You can specify which categories should be searched (this overrides the user preferences set in the addon settings):
# RunScript(script.globalsearch,movies=true)
# RunScript(script.globalsearch,tvshows=true&amp;musicvideos=true&amp;songs=true)
# availableeoptions: movies, tvshows, episodes, musicvideos, artists, albums, songs, livetv, actors, directors
BUILTIN.executebuiltin('RunScript(%s)'%('%s,searchstring=%s'%(self.SEARCH_SCRIPT,escapeString('%s,movies=True,episodes=True,tvshows=True,livetv=True'%(quoteString(title))))))
if __name__ == '__main__':
param = sys.argv[1]
log('Info: __main__, param = %s'%(param))
if param == 'info': Info(sys.argv ,listitem=sys.listitem,fitem=decodePlot(BUILTIN.getInfoLabel('Plot')))
elif param == 'browse': Browse(sys.argv,listitem=sys.listitem,fitem=decodePlot(BUILTIN.getInfoLabel('Plot')))
elif param == 'match': Match(sys.argv ,listitem=sys.listitem,fitem=decodePlot(BUILTIN.getInfoLabel('Plot')))

View File

@@ -0,0 +1,40 @@
# 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 <http://www.gnu.org/licenses/>.
# -*- coding: utf-8 -*-
from globals import *
from plugin import Plugin
def run(sysARG, fitem: dict={}, nitem: dict={}):
with BUILTIN.busy_dialog():
mode = sysARG[1]
params = {}
params['fitem'] = fitem
params['nitem'] = nitem
params['vid'] = decodeString(params.get("vid",''))
params["chid"] = (params.get("chid") or fitem.get('citem',{}).get('id'))
params['title'] = (params.get('title') or BUILTIN.getInfoLabel('label'))
params['name'] = (unquoteString(params.get("name",'')) or fitem.get('citem',{}).get('name') or BUILTIN.getInfoLabel('ChannelName'))
params['isPlaylist'] = (mode == 'playlist')
log("Context_Play: run, params = %s"%(params))
if mode == 'play': threadit(Plugin(sysARG, sysInfo=params).playTV)(params["name"],params["chid"])
elif mode == 'playlist': threadit(Plugin(sysARG, sysInfo=params).playPlaylist)(params["name"],params["chid"])
if __name__ == '__main__': run(sys.argv, fitem=decodePlot(BUILTIN.getInfoLabel('Plot')), nitem=decodePlot(BUILTIN.getInfoLabel('NextPlot')))

View File

@@ -0,0 +1,82 @@
# 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 <http://www.gnu.org/licenses/>.
# -*- coding: utf-8 -*-
from globals import *
from m3u import M3U
from xmltvs import XMLTVS
class Record:
def __init__(self, sysARG: dict={}, listitem: xbmcgui.ListItem=xbmcgui.ListItem(), fitem: dict={}):
with BUILTIN.busy_dialog():
log('Record: __init__, sysARG = %s, fitem = %s\npath = %s'%(sysARG,fitem,listitem.getPath()))
self.sysARG = sysARG
self.fitem = fitem
self.listitem = listitem
self.fitem['label'] = (fitem.get('label') or listitem.getLabel())
def add(self):
if not PROPERTIES.isRunning('Record.add'):
with PROPERTIES.chkRunning('Record.add'):
now = timeString2Seconds(BUILTIN.getInfoLabel('Time(hh:mm:ss)','System'))
start = timeString2Seconds(BUILTIN.getInfoLabel('StartTime').split(' ')[0] +':00')
stop = timeString2Seconds(BUILTIN.getInfoLabel('EndTime').split(' ')[0] +':00')
if (now > start and now < stop):
opt ='Incl. Resume'
seek = (now - start) - OSD_TIMER #add rollback buffer
msg = '%s or %s'%(LANGUAGE(30119),LANGUAGE(30152))
else:
opt = ''
seek = 0
msg = LANGUAGE(30119)
retval = DIALOG.yesnoDialog('Would you like to add:\n[B]%s[/B]\nto %s recordings?'%(self.fitem['label'],msg),customlabel=opt)
if retval or int(retval) > 0:
with BUILTIN.busy_dialog(), PROPERTIES.interruptActivity():
m3u = M3U()
ritem = m3u.getRecordItem(self.fitem, seek)
added = (m3u.addRecording(ritem), XMLTVS().addRecording(ritem,self.fitem))
del m3u
if added:
log('Record: add, ritem = %s'%(ritem))
DIALOG.notificationDialog('%s\n%s'%(ritem['label'],LANGUAGE(30116)))
PROPERTIES.setPropTimer('chkPVRRefresh')
else: DIALOG.notificationDialog(LANGUAGE(32000))
def remove(self):
if not PROPERTIES.isRunning('Record.remove'):
with PROPERTIES.chkRunning('Record.remove'):
if DIALOG.yesnoDialog('Would you like to remove:\n[B]%s[/B]\nfrom recordings?'%(self.fitem['label'])):
with BUILTIN.busy_dialog(), PROPERTIES.interruptActivity():
ritem = (self.fitem.get('citem') or {"name":self.fitem['label'],"path":self.listitem.getPath()})
removed = (M3U().delRecording(ritem), XMLTVS().delRecording(ritem))
if removed:
log('Record: remove, ritem = %s'%(ritem))
DIALOG.notificationDialog('%s\n%s'%(ritem['name'],LANGUAGE(30118)))
PROPERTIES.setPropTimer('chkPVRRefresh')
else: DIALOG.notificationDialog(LANGUAGE(32000))
if __name__ == '__main__':
try: param = sys.argv[1]
except: param = None
log('Record: __main__, param = %s'%(param))
if param == 'add': Record(sys.argv,listitem=sys.listitem,fitem=decodePlot(BUILTIN.getInfoLabel('Plot'))).add()
elif param == 'del': Record(sys.argv,listitem=sys.listitem,fitem=decodePlot(BUILTIN.getInfoLabel('Plot'))).remove()

View File

@@ -0,0 +1,207 @@
# 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 <http://www.gnu.org/licenses/>.
#
# -*- coding: utf-8 -*-
from globals import *
from pool import ExecutorPool
from collections import defaultdict
class LlNode:
def __init__(self, package: tuple, priority: int=0, delay: int=0):
self.prev = None
self.next = None
self.package = package
self.priority = priority
self.wait = delay
class CustomQueue:
isRunning = False
def __init__(self, fifo: bool=False, lifo: bool=False, priority: bool=False, delay: bool=False, service=None):
self.log("__init__, fifo = %s, lifo = %s, priority = %s, delay = %s"%(fifo, lifo, priority, delay))
self.service = service
self.lock = Lock()
self.fifo = fifo
self.lifo = lifo
self.priority = priority
self.delay = delay
self.head = None
self.tail = None
self.qsize = 0
self.min_heap = []
self.itemCount = defaultdict(int)
self.popThread = Thread(target=self.__pop)
self.pool = ExecutorPool()
self.executor = SETTINGS.getSettingBool('Enable_Executors')
def log(self, msg, level=xbmc.LOGDEBUG):
return log('%s: %s'%(self.__class__.__name__,msg),level)
def __manage(self, thread, target_function, name, daemon=True):
if thread.is_alive():
if hasattr(thread, 'cancel'): thread.cancel()
try: thread.join()
except Exception: pass
new_thread = Thread(target=target_function)
new_thread.name = name
new_thread.daemon = daemon
new_thread.start()
return new_thread
def __start(self):
self.log("__starting popThread")
self.popThread = self.__manage(self.popThread, self.__pop, "popThread")
def __run(self, func, *args, **kwargs):
self.log(f"__run, func = {func.__name__}, executor = {self.executor}")
try:
if self.executor:
return self.pool.executor(func, None, *args, **kwargs)
else:
thread = Thread(target=func, args=args, kwargs=kwargs)
thread.start()
except Exception as e:
self.log(f"__run, func = {func.__name__} failed! {e}\nargs = {args}, kwargs = {kwargs}", xbmc.LOGERROR)
def __exists(self, priority, package):
for idx, item in enumerate(self.min_heap):
epriority,_,epackage = item
if package == epackage:
if priority < epriority:
try:
self.min_heap.pop(idx)
heapq.heapify(self.min_heap) # Ensure heap property is maintained
self.log("__exists, replacing queue: func = %s, priority %s => %s"%(epackage[0].__name__,epriority,priority))
except: self.log("__exists, replacing queue: func = %s, idx = %s failed!"%(epackage[0].__name__,idx))
else: return True
return False
def _push(self, package: tuple, priority: int = 0, delay: int = 0):
node = LlNode(package, priority, delay)
if self.priority:
if not self.__exists(priority, package):
try:
self.qsize += 1
self.itemCount[priority] += 1
self.log(f"_push, func = {package[0].__name__}, priority = {priority}")
heapq.heappush(self.min_heap, (priority, self.itemCount[priority], package))
except Exception as e:
self.log(f"_push, func = {package[0].__name__} failed! {e}", xbmc.LOGFATAL)
else:
if self.head:
self.tail.next = node
node.prev = self.tail
self.tail = node
else:
self.head = node
self.tail = node
self.log(f"_push, func = {package[0].__name__}")
if not self.isRunning:
self.log("_push, starting __pop")
self.__start()
def __process(self, node, fifo=True):
package = node.package
self.log(f"process_node, package = {package}")
next_node = node.__next__ if fifo else node.prev
if next_node: next_node.prev = None if fifo else next_node.prev
if node.prev: node.prev.next = None if fifo else node.prev
if fifo: self.head = next_node
else: self.tail = next_node
return package
def __pop(self):
self.isRunning = True
self.log("__pop, starting")
self.executor = SETTINGS.getSettingBool('Enable_Executors')
while not self.service.monitor.abortRequested():
if self.service.monitor.waitForAbort(0.0001):
self.log("__pop, waitForAbort")
break
elif self.service._interrupt():
self.log("__pop, _interrupt")
break
elif self.service._suspend():
self.log("__pop, _suspend")
self.service.monitor.waitForAbort(SUSPEND_TIMER)
continue
elif not self.head and not self.priority:
self.log("__pop, The queue is empty!")
break
elif self.priority:
if not self.min_heap:
self.log("__pop, The priority queue is empty!")
break
else:
try: priority, _, package = heapq.heappop(self.min_heap)
except Exception as e: continue
self.qsize -= 1
self.__run(package[0],*package[1],**package[2])
elif self.fifo or self.lifo:
curr_node = self.head if self.fifo else self.tail
if curr_node is None:
break
else:
package = self.__process(curr_node, fifo=self.fifo)
if not self.delay: self.__run(*package)
else: timerit(curr_node.wait, [*package])
else:
self.log("__pop, queue undefined!")
break
self.isRunning = False
self.log("__pop, finished: shutting down!")
# def quePriority(package: tuple, priority: int=0):
# q_priority = CustomQueue(priority=True)
# q_priority.log("quePriority")
# q_priority._push(package, priority)
# def queFIFO(package: tuple, delay: int=0):
# q_fifo = CustomQueue(fifo=True, delay=bool(delay))
# q_fifo.log("queFIFO")
# q_fifo._push(package, delay)
# def queLIFO(package: tuple, delay: int=0):
# q_lifo = CustomQueue(lifo=True, delay=bool(delay))
# q_lifo.log("queLIFO")
# q_lifo._push(package, delay)
# def queThread(packages, delay=0):
# q_fifo = CustomQueue(fifo=True)
# q_fifo.log("queThread")
# def thread_function(*package):
# q_fifo._push(package)
# for package in packages:
# t = Thread(target=thread_function, args=(package))
# t.daemon = True
# t.start()

View File

@@ -0,0 +1,37 @@
# 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 <http://www.gnu.org/licenses/>.
# -*- coding: utf-8 -*-
from globals import *
class Channel():
def __init__(self, id: str="", type: str="", number: int=0, name: str="", logo: str="", path: list=[], group: list=[], rules: list=[], catchup: str="vod", radio: bool=False, favorite: bool=False):
self.id = id
self.type = type
self.number = number
self.name = name
self.logo = logo
self.path = path
self.group = group
self.rules = rules
self.catchup = catchup
self.radio = radio
self.favorite = favorite
#todo convert json to dataclasses https://dataclass-wizard.readthedocs.io/en/latest/

View File

@@ -0,0 +1,137 @@
from dataclasses import asdict
from typing import List, Dict
import random
from operator import itemgetter
from globals import getJSON, setJSON, SETTINGS, PROPERTIES, LANGUAGE, xbmc, log
@dataclass
class Channel:
id: str
type: str
number: int
name: str
logo: str
path: List[str] = field(default_factory=list)
group: List[str] = field(default_factory=list)
rules: Dict = field(default_factory=dict)
catchup: str = "vod"
radio: bool = False
favorite: bool = False
class Channels:
def __init__(self):
self.channelDATA: Dict[str, List[Channel]] = getJSON(CHANNELFLE_DEFAULT)
self.channelTEMP: Dict = getJSON(CHANNEL_ITEM)
self.channelDATA.update(self._load())
def log(self, msg, level=xbmc.LOGDEBUG):
return log('%s: %s' % (self.__class__.__name__, msg), level)
def _load(self, file=CHANNELFLEPATH) -> Dict[str, List[Channel]]:
channelDATA = getJSON(file)
self.log('_load, channels = %s' % (len(channelDATA.get('channels', []))))
return channelDATA
def _verify(self, channels: List[Channel] = []):
for idx, citem in enumerate(self.channelDATA.get('channels', [])):
if not citem.name or not citem.id or len(citem.path) == 0:
self.log('_verify, in-valid citem [%s]\n%s' % (citem.id, citem))
continue
else:
yield citem
def _save(self, file=CHANNELFLEPATH) -> bool:
self.channelDATA['uuid'] = SETTINGS.getMYUUID()
self.channelDATA['channels'] = self.sortChannels(self.channelDATA['channels'])
self.log('_save, channels = %s' % (len(self.channelDATA['channels'])))
return setJSON(file, self.channelDATA)
def getTemplate(self) -> Dict:
return self.channelTEMP.copy()
def getChannels(self) -> List[Channel]:
return sorted(self.channelDATA['channels'], key=itemgetter('number'))
def popChannels(self, type: str, channels: List[Channel] = []) -> List[Channel]:
return [self.channelDATA['channels'].pop(self.channelDATA['channels'].index(citem)) for citem in list([c for c in channels if c.type == type])]
def getCustom(self) -> List[Channel]:
channels = self.getChannels()
return list([citem for citem in channels if citem.number <= CHANNEL_LIMIT])
def getAutotuned(self) -> List[Channel]:
channels = self.getChannels()
return list([citem for citem in channels if citem.number > CHANNEL_LIMIT])
def getChannelbyID(self, id: str) -> List[Channel]:
channels = self.getChannels()
return list([c for c in channels if c.id == id])
def getType(self, type: str) -> List[Channel]:
channels = self.getChannels()
return list([citem for citem in channels if citem.type == type])
def sortChannels(self, channels: List[Channel]) -> List[Channel]:
try:
return sorted(channels, key=itemgetter('number'))
except:
return channels
def setChannels(self, channels: List[Channel] = []) -> bool:
if len(channels) == 0:
channels = self.channelDATA['channels']
self.channelDATA['channels'] = channels
SETTINGS.setSetting('Select_Channels', '[B]%s[/B] Channels' % (len(channels)))
PROPERTIES.setChannels(len(channels) > 0)
return self._save()
def getImports(self) -> List:
return self.channelDATA.get('imports', [])
def setImports(self, data: List = []) -> bool:
self.channelDATA['imports'] = data
return self.setChannels()
def clearChannels(self):
self.channelDATA['channels'] = []
def delChannel(self, citem: Channel) -> bool:
self.log('delChannel,[%s]' % (citem.id), xbmc.LOGINFO)
idx, channel = self.findChannel(citem)
if idx is not None:
self.channelDATA['channels'].pop(idx)
return True
def addChannel(self, citem: Channel) -> bool:
idx, channel = self.findChannel(citem)
if idx is not None:
for key in ['id', 'rules', 'number', 'favorite', 'logo']:
if getattr(channel, key):
setattr(citem, key, getattr(channel, key)) # existing id found, reuse channel meta.
if citem.favorite:
citem.group.append(LANGUAGE(32019))
citem.group = sorted(set(citem.group))
self.log('addChannel, [%s] updating channel %s' % (citem.id, citem.name), xbmc.LOGINFO)
self.channelDATA['channels'][idx] = citem
else:
self.log('addChannel, [%s] adding channel %s' % (citem.id, citem.name), xbmc.LOGINFO)
self.channelDATA.setdefault('channels', []).append(citem)
return True
def findChannel(self, citem: Channel, channels: List[Channel] = []) -> tuple:
if len(channels) == 0:
channels = self.getChannels()
for idx, eitem in enumerate(channels):
if citem.id == eitem.id:
return idx, eitem
return None, {}
def findAutotuned(self, citem: Channel, channels: List[Channel] = []) -> tuple:
if len(channels) == 0:
channels = self.getAutotuned()
for idx, eitem in enumerate(channels):
if citem.id == eitem.id or (citem.type == eitem.type and citem.name.lower() == eitem.name.lower()):
return idx, eitem
return None, {}

View File

@@ -0,0 +1,26 @@
# 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 <http://www.gnu.org/licenses/>.
# -*- coding: utf-8 -*-
from globals import *
class Program():
def __init__(self):
...
#todo convert json to dataclasses https://dataclass-wizard.readthedocs.io/en/latest/

View File

@@ -0,0 +1,52 @@
# 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 <http://www.gnu.org/licenses/>.
# -*- coding: utf-8 -*-
from globals import *
class Station():
def __init__(self):
self.id = id
self.number = number
self.name = name
self.logo = logo
self.group = group
self.catchup = catchup
self.radio = radio
self.favorite = favorite
self.realtime = realtime
self.media = media
self.label = label
self.url = url
self.tvg-shift = tvg-shift
self.x-tvg-url = x-tvg-url
self.media-dir = media-dir
self.media-size = media-size
self.media-type = media-type
self.catchup-source = catchup-source
self.catchup-days = catchup-days
self.catchup-correction = catchup-correction
self.provider = provider
self.provider-type = provider-type
self.provider-logo = provider-logo
self.provider-countries = provider-countries
self.provider-languages = provider-languages
self.x-playlist-type = x-playlist-type
self.kodiprops = kodiprops
#todo convert json to dataclasses https://dataclass-wizard.readthedocs.io/en/latest/

View File

@@ -0,0 +1,95 @@
# 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 <http://www.gnu.org/licenses/>.
# -*- coding: utf-8 -*-
from globals import *
from plugin import Plugin
def run(sysARG, fitem: dict={}, nitem: dict={}):
"""
Main entry point for PseudoTV Live's functionality.
This function handles various modes of playback and interaction based on the parameters passed.
These modes include live TV, video-on-demand (VOD), DVR playback, guide display, and more.
It also processes system arguments and settings to determine the appropriate behavior.
Args:
sysARG (list): System arguments passed by the Kodi interface.
fitem (dict, optional): Dictionary containing information about the current (featured) item. Defaults to an empty dictionary.
nitem (dict, optional): Dictionary containing information about the next item. Defaults to an empty dictionary.
Behavior:
- Parses system arguments and determines the mode of operation.
- Depending on the mode, it invokes the appropriate plugin functionality (e.g., play live TV, VOD, DVR, etc.).
- Utilizes utility functions like `threadit` for threading and `PROPERTIES` for managing app states.
Supported Modes:
- 'live': Plays live TV or a playlist based on the provided parameters.
- 'vod': Plays video-on-demand content.
- 'dvr': Plays DVR recordings.
- 'resume': Resumes paused playback.
- 'broadcast': Simulates broadcast playback.
- 'radio': Plays radio streams.
- 'guide': Opens the TV guide using the PVR client.
- 'settings': Opens the settings menu.
Notifications:
- Displays notification dialogs for unsupported modes or errors.
"""
with BUILTIN.busy_dialog(), PROPERTIES.suspendActivity():
params = dict(urllib.parse.parse_qsl(sysARG[2][1:].replace('.pvr','')))
mode = (params.get("mode") or 'guide')
params['fitem'] = fitem
params['nitem'] = nitem
params['vid'] = decodeString(params.get("vid",''))
params["chid"] = (params.get("chid") or fitem.get('citem',{}).get('id'))
params['title'] = (params.get('title') or BUILTIN.getInfoLabel('label'))
params['name'] = (unquoteString(params.get("name",'')) or BUILTIN.getInfoLabel('ChannelName'))
params['isPlaylist'] = bool(SETTINGS.getSettingInt('Playback_Method'))
log("Default: run, params = %s"%(params))
if PROPERTIES.isRunning('togglePVR'): DIALOG.notificationDialog(LANGUAGE(32166))
elif mode == 'live':
if params.get('start') == '{utc}':
PROPERTIES.setPropTimer('chkPVRRefresh')
params.update({'start':0,'stop':0,'duration':0})
if params['isPlaylist']: threadit(Plugin(sysARG, sysInfo=params).playPlaylist)(params["name"],params["chid"])
elif params['vid'] : threadit(Plugin(sysARG, sysInfo=params).playTV)(params["name"],params["chid"])
elif params['isPlaylist']: threadit(Plugin(sysARG, sysInfo=params).playPlaylist)(params["name"],params["chid"])
elif params['vid'] : threadit(Plugin(sysARG, sysInfo=params).playLive)(params["name"],params["chid"],params["vid"])
else: threadit(Plugin(sysARG, sysInfo=params).playTV)(params["name"],params["chid"])
elif mode == 'vod': threadit(Plugin(sysARG, sysInfo=params).playVOD)(params["title"],params["vid"])
elif mode == 'dvr': threadit(Plugin(sysARG, sysInfo=params).playDVR)(params["title"],params["vid"])
elif mode == 'resume': threadit(Plugin(sysARG, sysInfo=params).playPaused)(params["name"],params["chid"])
elif mode == 'broadcast': threadit(Plugin(sysARG, sysInfo=params).playBroadcast)(params["name"],params["chid"],params["vid"])
elif mode == 'radio': threadit(Plugin(sysARG, sysInfo=params).playRadio)(params["name"],params["chid"],params["vid"])
elif mode == 'guide' and hasAddon(PVR_CLIENT_ID,install=True,enable=True): SETTINGS.openGuide()
elif mode == 'settings' and hasAddon(PVR_CLIENT_ID,install=True,enable=True): SETTINGS.openSettings()
else: DIALOG.notificationDialog(LANGUAGE(32000))
MONITOR().waitForAbort(float(SETTINGS.getSettingInt('RPC_Delay')/1000)) #delay to avoid thread crashes when fast channel changing.
if __name__ == '__main__':
"""
Runs the script when executed as the main module.
It decodes information about the current and next items using the `decodePlot` function
and then invokes the `run` function with the appropriate arguments.
"""
run(sys.argv, fitem=decodePlot(BUILTIN.getInfoLabel('Plot')), nitem=decodePlot(BUILTIN.getInfoLabel('NextPlot')))

View File

@@ -0,0 +1,382 @@
# 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 <http://www.gnu.org/licenses/>.
#
# -*- coding: utf-8 -*-
from globals import *
#constants
DEFAULT_ENCODING = "utf-8"
FILE_LOCK_MAX_FILE_TIMEOUT = 10
FILE_LOCK_NAME = "pseudotv"
class FileAccess:
@staticmethod
def open(filename, mode, encoding=DEFAULT_ENCODING):
fle = 0
try: return VFSFile(filename, mode)
except UnicodeDecodeError: return FileAccess.open(filename, mode, encoding)
return fle
@staticmethod
def _getFolderPath(path):
head, tail = os.path.split(path)
last_folder = os.path.basename(head)
return os.path.join(last_folder, tail)
@staticmethod
def listdir(path):
return xbmcvfs.listdir(path)
@staticmethod
def translatePath(path):
if '@' in path: path = path.split('@')[1]
return xbmcvfs.translatePath(path)
@staticmethod
def copyFolder(src, dir, dia=None, delete=False):
log('FileAccess: copyFolder %s to %s'%(src,dir))
if not FileAccess.exists(dir): FileAccess.makedirs(dir)
if dia:
from kodi import Dialog
DIALOG = Dialog()
subs, files = FileAccess.listdir(src)
pct = 0
if dia: dia = DIALOG.progressDialog(pct, control=dia, message='%s\n%s'%(LANGUAGE(32051),src))
for fidx, file in enumerate(files):
if dia: dia = DIALOG.progressDialog(pct, control=dia, message='%s: (%s%)\n%s'%(LANGUAGE(32051),(int(fidx*100)//len(files)),FileAccess._getFolderPath(file)))
if delete: FileAccess.move(os.path.join(src, file), os.path.join(dir, file))
else: FileAccess.copy(os.path.join(src, file), os.path.join(dir, file))
for sidx, sub in enumerate(subs):
pct = int(sidx)//len(subs)
FileAccess.copyFolder(os.path.join(src, sub), os.path.join(dir, sub), dia, delete)
@staticmethod
def copy(orgfilename, newfilename):
log('FileAccess: copying %s to %s'%(orgfilename,newfilename))
dir, file = os.path.split(newfilename)
if not FileAccess.exists(dir): FileAccess.makedirs(dir)
return xbmcvfs.copy(orgfilename, newfilename)
@staticmethod
def move(orgfilename, newfilename):
log('FileAccess: moving %s to %s'%(orgfilename,newfilename))
if FileAccess.copy(orgfilename, newfilename):
return FileAccess.delete(orgfilename)
return False
@staticmethod
def delete(filename):
return xbmcvfs.delete(filename)
@staticmethod
def exists(filename):
if filename.startswith('stack://'):
try: filename = (filename.split('stack://')[1].split(' , '))[0]
except Exception as e: log('FileAccess: exists failed! %s'%(e), xbmc.LOGERROR)
try:
return xbmcvfs.exists(filename)
except UnicodeDecodeError:
return os.path.exists(xbmcvfs.translatePath(filename))
return False
@staticmethod
def openSMB(filename, mode, encoding=DEFAULT_ENCODING):
fle = 0
if os.name.lower() == 'nt':
newname = '\\\\' + filename[6:]
try: fle = codecs.open(newname, mode, encoding)
except: fle = 0
return fle
@staticmethod
def existsSMB(filename):
if os.name.lower() == 'nt':
filename = '\\\\' + filename[6:]
return FileAccess.exists(filename)
return False
@staticmethod
def rename(path, newpath):
log("FileAccess: rename %s to %s"%(path,newpath))
if not FileAccess.exists(path):
return False
try:
if xbmcvfs.rename(path, newpath):
return True
except Exception as e:
log("FileAccess: rename, failed! %s"%(e), xbmc.LOGERROR)
try:
if FileAccess.move(path, newpath):
return True
except Exception as e:
log("FileAccess: move, failed! %s"%(e), xbmc.LOGERROR)
if path[0:6].lower() == 'smb://' or newpath[0:6].lower() == 'smb://':
if os.name.lower() == 'nt':
log("FileAccess: Modifying name")
if path[0:6].lower() == 'smb://':
path = '\\\\' + path[6:]
if newpath[0:6].lower() == 'smb://':
newpath = '\\\\' + newpath[6:]
if not os.path.exist(xbmcvfs.translatePath(path)):
return False
try:
log("FileAccess: os.rename")
os.rename(xbmcvfs.translatePath(path), xbmcvfs.translatePath(newpath))
return True
except Exception as e:
log("FileAccess: os.rename, failed! %s"%(e), xbmc.LOGERROR)
try:
log("FileAccess: shutil.move")
shutil.move(xbmcvfs.translatePath(path), xbmcvfs.translatePath(newpath))
return True
except Exception as e:
log("FileAccess: shutil.move, failed! %s"%(e), xbmc.LOGERROR)
log("FileAccess: OSError")
raise OSError()
@staticmethod
def removedirs(path, force=True):
if len(path) == 0: return False
elif(xbmcvfs.exists(path)):
return True
try:
success = xbmcvfs.rmdir(dir, force=force)
if success: return True
else: raise
except:
try:
os.rmdir(xbmcvfs.translatePath(path))
if os.path.exists(xbmcvfs.translatePath(path)):
return True
except: log("FileAccess: removedirs failed!", xbmc.LOGERROR)
return False
@staticmethod
def makedirs(directory):
try:
os.makedirs(xbmcvfs.translatePath(directory))
return os.path.exists(xbmcvfs.translatePath(directory))
except:
return FileAccess._makedirs(directory)
@staticmethod
def _makedirs(path):
if len(path) == 0:
return False
if(xbmcvfs.exists(path)):
return True
success = xbmcvfs.mkdir(path)
if success == False:
if path == os.path.dirname(xbmcvfs.translatePath(path)):
return False
if FileAccess._makedirs(os.path.dirname(xbmcvfs.translatePath(path))):
return xbmcvfs.mkdir(path)
return xbmcvfs.exists(path)
class VFSFile:
monitor = MONITOR()
def __init__(self, filename, mode):
if mode == 'w':
if not FileAccess.exists(filename):
FileAccess.makedirs(os.path.split(filename)[0])
self.currentFile = xbmcvfs.File(filename, 'wb')
else:
self.currentFile = xbmcvfs.File(filename, 'r')
log("VFSFile: Opening %s"%filename, xbmc.LOGDEBUG)
if self.currentFile == None:
log("VFSFile: Couldnt open %s"%filename, xbmc.LOGERROR)
def read(self, bytes=0):
try: return self.currentFile.read(bytes)
except: return self.currentFile.readBytes(bytes)
def readBytes(self, bytes=0):
return self.currentFile.readBytes(bytes)
def write(self, data):
if isinstance(data,bytes):
data = data.decode(DEFAULT_ENCODING, 'backslashreplace')
return self.currentFile.write(data)
def close(self):
return self.currentFile.close()
def seek(self, bytes, offset=1):
return self.currentFile.seek(bytes, offset)
def size(self):
return self.currentFile.size()
def readlines(self):
return self.read().split('\n')
# return list(self.readline())
def readline(self):
for line in self.read_in_chunks():
yield line
def tell(self):
try: return self.currentFile.tell()
except: return self.currentFile.seek(0, 1)
def read_in_chunks(self, chunk_size=1024):
"""Lazy function (generator) to read a file piece by piece."""
while not self.monitor.abortRequested():
data = self.read(chunk_size)
if not data: break
yield data
class FileLock(object):
monitor = MONITOR()
# https://github.com/dmfrey/FileLock
""" A file locking mechanism that has context-manager support so
you can use it in a with statement. This should be relatively cross
compatible as it doesn't rely on msvcrt or fcntl for the locking.
"""
def __init__(self, file_name=FILE_LOCK_NAME, timeout=FILE_LOCK_MAX_FILE_TIMEOUT, delay: float=0.5):
""" Prepare the file locker. Specify the file to lock and optionally
the maximum timeout and the delay between each attempt to lock.
"""
if timeout is not None and delay is None:
raise ValueError("If timeout is not None, then delay must not be None.")
self.is_locked = False
self.file_name = file_name
self.lockpath = self.checkpath()
self.lockfile = os.path.join(self.lockpath, "%s.lock" % self.file_name)
self.timeout = timeout
self.delay = delay
def checkpath(self):
lockpath = os.path.join(REAL_SETTINGS.getSetting('User_Folder'))
if not FileAccess.exists(lockpath):
if FileAccess.makedirs(lockpath):
return lockpath
else:#fallback to local folder.
#todo log error with lock path
lockpath = os.path.join(SETTINGS_LOC,'cache')
if not FileAccess.exists(lockpath):
FileAccess.makedirs(lockpath)
return lockpath
def acquire(self):
""" Acquire the lock, if possible. If the lock is in use, it check again
every `wait` seconds. It does this until it either gets the lock or
exceeds `timeout` number of seconds, in which case it throws
an exception.
"""
start_time = time.time()
while not self.monitor.abortRequested():
if self.monitor.waitForAbort(self.delay): break
else:
try:
self.fd = FileAccess.open(self.lockfile, 'w')
self.is_locked = True #moved to ensure tag only when locked
break;
except OSError as e:
if e.errno != errno.EEXIST:
raise
if self.timeout is None:
raise FileLockException("Could not acquire lock on {}".format(self.file_name))
if (time.time() - start_time) >= self.timeout:
raise FileLockException("Timeout occured.")
def release(self):
""" Get rid of the lock by deleting the lockfile.
When working in a `with` statement, this gets automatically
called at the end.
"""
if self.is_locked:
self.fd.close()
self.is_locked = False
def __enter__(self):
""" Activated when used in the with statement.
Should automatically acquire a lock to be used in the with block.
"""
if not self.is_locked:
self.acquire()
return self
def __exit__(self, type, value, traceback):
""" Activated at the end of the with statement.
It automatically releases the lock if it isn't locked.
"""
if self.is_locked:
self.release()
def __del__(self):
""" Make sure that the FileLock instance doesn't leave a lockfile
lying around.
"""
self.release()
FileAccess.delete(self.lockfile)
class FileLockException(Exception):
pass

View File

@@ -0,0 +1,209 @@
# 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 <http://www.gnu.org/licenses/>.
# -*- coding: utf-8 -*-
from globals import *
from resources import Resources
#Ratings - resource only, Movie Type only any channel type
#Bumpers - plugin, path only, tv type, tv network, custom channel type
#Adverts - plugin, path only, tv type, any tv channel type
#Trailers - plug, path only, movie type, any movie channel.
class Fillers:
def __init__(self, builder, citem={}):
self.builder = builder
self.bctTypes = builder.bctTypes
self.runActions = builder.runActions
self.jsonRPC = builder.jsonRPC
self.cache = builder.jsonRPC.cache
self.citem = citem
self.resources = Resources(service=builder.service)
self.log('[%s] __init__, bctTypes = %s'%(self.citem.get('id'),builder.bctTypes))
self.fillSources(citem, builder.bctTypes)
def log(self, msg, level=xbmc.LOGDEBUG):
return log('%s: %s'%(self.__class__.__name__,msg),level)
def fillSources(self, citem={}, bctTypes={}):
for ftype, values in list(bctTypes.items()):
if values.get('enabled',False):
self.builder.updateProgress(self.builder.pCount,message='%s %s'%(LANGUAGE(30014),ftype.title()),header='%s, %s'%(ADDON_NAME,self.builder.pMSG))
if values.get('incKODI',False): values["items"] = mergeDictLST(values.get('items',{}), self.builder.getTrailers())
for id in values["sources"].get("ids",[]):
values['items'] = mergeDictLST(values.get('items',{}),self.buildSource(ftype,id)) #parse resource packs
for path in values["sources"].get("paths",[]):
values['items'] = mergeDictLST(values.get('items',{}),self.buildSource(ftype,path)) #parse vfs paths
values['items'] = lstSetDictLst(values['items'])
self.log('[%s] fillSources, type = %s, items = %s'%(self.citem.get('id'),ftype,len(values['items'])))
@cacheit(expiration=datetime.timedelta(minutes=30),json_data=False)
def buildSource(self, ftype, path=''):
self.log('[%s] buildSource, type = %s, path = %s'%(self.citem.get('id'),ftype, path))
def _parseResource(id):
if hasAddon(id, install=True): return self.jsonRPC.walkListDirectory(os.path.join('special://home/addons/%s'%id,'resources'),exts=VIDEO_EXTS,depth=CHANNEL_LIMIT,checksum=SETTINGS.getAddonDetails(id).get('version',ADDON_VERSION),expiration=datetime.timedelta(days=MAX_GUIDEDAYS))
def _parseVFS(path):
if path.startswith('plugin://'):
if not hasAddon(path, install=True): return {}
return self.jsonRPC.walkFileDirectory(path, depth=CHANNEL_LIMIT, chkDuration=True, retItem=True)
def _parseLocal(path):
if FileAccess.exists(path): return self.jsonRPC.walkListDirectory(path,exts=VIDEO_EXTS,depth=CHANNEL_LIMIT,chkDuration=True)
def __sortItems(data, stype='folder'):
tmpDCT = {}
if data:
for path, files in list(data.items()):
if stype == 'file': key = file.split('.')[0].lower()
elif stype == 'folder': key = (os.path.basename(os.path.normpath(path)).replace('\\','/').strip('/').split('/')[-1:][0]).lower()
for file in files:
if isinstance(file,dict): [tmpDCT.setdefault(key.lower(),[]).append(file) for key in (file.get('genre',[]) or ['resources'])]
else:
dur = self.jsonRPC.getDuration(os.path.join(path,file), accurate=True)
if dur > 0: tmpDCT.setdefault(key.lower(),[]).append({'file':os.path.join(path,file),'duration':dur,'label':'%s - %s'%(path.strip('/').split('/')[-1:][0],file.split('.')[0])})
self.log('[%s] buildSource, __sortItems: stype = %s, items = %s'%(self.citem.get('id'),stype,len(tmpDCT)))
return tmpDCT
try:
if path.startswith('resource.'): return __sortItems(_parseResource(path))
elif path.startswith(tuple(VFS_TYPES+DB_TYPES)): return __sortItems(_parseVFS(path))
else: return __sortItems(_parseLocal(path))
except Exception as e: self.log("[%s] buildSource, failed! %s\n path = %s"%(self.citem.get('id'),e,path), xbmc.LOGERROR)
return {}
def convertMPAA(self, ompaa):
try:
ompaa = ompaa.upper()
mpaa = re.compile(":(.*?)/", re.IGNORECASE).search(ompaa).group(1).strip()
except: return ompaa
mpaa = mpaa.replace('TV-Y','G').replace('TV-Y7','G').replace('TV-G','G').replace('NA','NR').replace('TV-PG','PG').replace('TV-14','PG-13').replace('TV-MA','R')
return mpaa
#todo always add a bumper for pseudo/kodi (based on build ver.)
# resource.videos.bumpers.kodi
# resource.videos.bumpers.pseudotv
def getSingle(self, type, keys=['resources'], chance=False):
items = [random.choice(tmpLST) for key in keys if (tmpLST := self.bctTypes.get(type, {}).get('items', {}).get(key.lower(), []))]
if not items and chance:
items.extend(self.getSingle(type))
self.log('[%s] getSingle, type = %s, keys = %s, chance = %s, returning = %s' % (self.citem.get('id'),type, keys, chance, len(items)))
return setDictLST(items)
def getMulti(self, type, keys=['resources'], count=1, chance=False):
items = []
tmpLST = []
for key in keys:
tmpLST.extend(self.bctTypes.get(type, {}).get('items', {}).get(key.lower(), []))
if len(tmpLST) >= count:
items = random.sample(tmpLST, count)
elif tmpLST:
items = setDictLST(random.choices(tmpLST, k=count))
if len(items) < count and chance:
items.extend(self.getMulti(type, count=(count - len(items))))
self.log('[%s] getMulti, type = %s, keys = %s, count = %s, chance = %s, returning = %s' % (self.citem.get('id'),type, keys, count, chance, len(items)))
return setDictLST(items)
def injectBCTs(self, fileList):
nfileList = []
for idx, fileItem in enumerate(fileList):
if not fileItem: continue
else:
runtime = fileItem.get('duration',0)
if runtime == 0: continue
chtype = self.citem.get('type','')
chname = self.citem.get('name','')
fitem = fileItem.copy()
dbtype = fileItem.get('type','')
fmpaa = (self.convertMPAA(fileItem.get('mpaa')) or 'NR')
fcodec = (fileItem.get('streamdetails',{}).get('audio') or [{}])[0].get('codec','')
fgenre = (fileItem.get('genre') or self.citem.get('group') or '')
if isinstance(fgenre,list) and len(fgenre) > 0: fgenre = fgenre[0]
#pre roll - bumpers/ratings
if dbtype.startswith(tuple(MOVIE_TYPES)):
ftype = 'ratings'
preKeys = [fmpaa, fcodec]
elif dbtype.startswith(tuple(TV_TYPES)):
ftype = 'bumpers'
preKeys = [chname, fgenre]
else:
ftype = None
if ftype:
preFileList = []
if self.bctTypes[ftype].get('enabled',False) and chtype not in IGNORE_CHTYPE:
preFileList.extend(self.getSingle(ftype, preKeys, chanceBool(self.bctTypes[ftype].get('chance',0))))
for i, item in enumerate(setDictLST(preFileList)):
if (item.get('duration') or 0) > 0:
runtime += item.get('duration')
self.log('[%s] injectBCTs, adding pre-roll %s - %s'%(self.citem.get('id'),item.get('duration'),item.get('file')))
self.builder.updateProgress(self.builder.pCount,message='Filling Pre-Rolls %s%%'%(int(i*100//len(preFileList))),header='%s, %s'%(ADDON_NAME,self.builder.pMSG))
item.update({'title':'Pre-Roll','episodetitle':item.get('label'),'genre':['Pre-Roll'],'plot':item.get('plot',item.get('file')),'path':item.get('file')})
nfileList.append(self.builder.buildCells(self.citem,item.get('duration'),entries=1,info=item)[0])
# original media
nfileList.append(fileItem)
self.log('[%s] injectBCTs, adding media %s - %s'%(self.citem.get('id'),fileItem.get('duration'),fileItem.get('file')))
# post roll - adverts/trailers
postFileList = []
for ftype in ['adverts','trailers']:
postIgnoreTypes = {'adverts':IGNORE_CHTYPE + MOVIE_CHTYPE,'trailers':IGNORE_CHTYPE}[ftype]
postFillRuntime = diffRuntime(runtime) if self.bctTypes[ftype]['auto'] else MIN_EPG_DURATION
if self.bctTypes[ftype].get('enabled',False) and chtype not in postIgnoreTypes:
postFileList.extend(self.getMulti(ftype, [chname, fgenre], self.bctTypes[ftype]['max'] if self.bctTypes[ftype]['auto'] else self.bctTypes[ftype]['min'], chanceBool(self.bctTypes[ftype].get('chance',0))))
postAuto = (self.bctTypes['adverts']['auto'] | self.bctTypes['trailers']['auto'])
postCounter = 0
if len(postFileList) > 0:
i = 0
postFileList = randomShuffle(postFileList)
self.log('[%s] injectBCTs, post-roll current runtime %s, available runtime %s, available content %s'%(self.citem.get('id'),runtime, postFillRuntime,len(postFileList)))
while not self.builder.service.monitor.abortRequested() and postFillRuntime > 0 and len(postFileList) > 0:
if self.builder.service.monitor.waitForAbort(0.0001): break
else:
i += 1
item = postFileList.pop(0)
if (item.get('duration') or 0) == 0: continue
elif postAuto and postCounter >= len(postFileList):
self.log('[%s] injectBCTs, unused post roll runtime %s %s/%s'%(self.citem.get('id'),postFillRuntime,postCounter,len(postFileList)))
break
elif postFillRuntime >= item.get('duration'):
postFillRuntime -= item.get('duration')
self.log('[%s] injectBCTs, adding post-roll %s - %s'%(self.citem.get('id'),item.get('duration'),item.get('file')))
self.builder.updateProgress(self.builder.pCount,message='Filling Post-Rolls %s%%'%(int(i*100//len(postFileList))),header='%s, %s'%(ADDON_NAME,self.builder.pMSG))
item.update({'title':'Post-Roll','episodetitle':item.get('label'),'genre':['Post-Roll'],'plot':item.get('plot',item.get('file')),'path':item.get('file')})
nfileList.append(self.builder.buildCells(self.citem,item.get('duration'),entries=1,info=item)[0])
elif postFillRuntime < item.get('duration'):
postFileList.append(item)
postCounter += 1
self.log('[%s] injectBCTs, finished'%(self.citem.get('id')))
return nfileList

View File

@@ -0,0 +1,509 @@
# 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 <http://www.gnu.org/licenses/>.
#
# -*- 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

View File

@@ -0,0 +1,176 @@
# -*- coding: utf-8 -*-
"""
JSON 2 HTML Converter
=====================
(c) Varun Malhotra 2013-2024
Source Code: https://github.com/softvar/json2html
Contributors:
-------------
1. Michel Müller (@muellermichel), https://github.com/softvar/json2html/pull/2
2. Daniel Lekic (@lekic), https://github.com/softvar/json2html/pull/17
LICENSE: MIT
--------
"""
import sys
from collections import OrderedDict
import json as json_parser
if sys.version_info[:2] < (3, 0):
from cgi import escape as html_escape
text = unicode
text_types = (unicode, str)
else:
from html import escape as html_escape
text = str
text_types = (str,)
class Json2Html:
def convert(self, json="", table_attributes='border="1"', clubbing=True, encode=False, escape=True):
"""
Convert JSON to HTML Table format
"""
# table attributes such as class, id, data-attr-*, etc.
# eg: table_attributes = 'class = "table table-bordered sortable"'
self.table_init_markup = "<table %s>" % table_attributes
self.clubbing = clubbing
self.escape = escape
json_input = None
if not json:
json_input = {}
elif type(json) in text_types:
try:
json_input = json_parser.loads(json, object_pairs_hook=OrderedDict)
except ValueError as e:
#so the string passed here is actually not a json string
# - let's analyze whether we want to pass on the error or use the string as-is as a text node
if u"Expecting property name" in text(e):
#if this specific json loads error is raised, then the user probably actually wanted to pass json, but made a mistake
raise e
json_input = json
else:
json_input = json
converted = self.convert_json_node(json_input)
if encode:
return converted.encode('ascii', 'xmlcharrefreplace')
return converted
def column_headers_from_list_of_dicts(self, json_input):
"""
This method is required to implement clubbing.
It tries to come up with column headers for your input
"""
if not json_input \
or not hasattr(json_input, '__getitem__') \
or not hasattr(json_input[0], 'keys'):
return None
column_headers = list(json_input[0].keys())
for entry in json_input:
if not hasattr(entry, 'keys') \
or not hasattr(entry, '__iter__') \
or len(list(entry.keys())) != len(column_headers):
return None
for header in column_headers:
if header not in entry:
return None
return column_headers
def convert_json_node(self, json_input):
"""
Dispatch JSON input according to the outermost type and process it
to generate the super awesome HTML format.
We try to adhere to duck typing such that users can just pass all kinds
of funky objects to json2html that *behave* like dicts and lists and other
basic JSON types.
"""
if type(json_input) in text_types:
if self.escape:
return html_escape(text(json_input))
else:
return text(json_input)
if hasattr(json_input, 'items'):
return self.convert_object(json_input)
if hasattr(json_input, '__iter__') and hasattr(json_input, '__getitem__'):
return self.convert_list(json_input)
return text(json_input)
def convert_list(self, list_input):
"""
Iterate over the JSON list and process it
to generate either an HTML table or a HTML list, depending on what's inside.
If suppose some key has array of objects and all the keys are same,
instead of creating a new row for each such entry,
club such values, thus it makes more sense and more readable table.
@example:
jsonObject = {
"sampleData": [
{"a":1, "b":2, "c":3},
{"a":5, "b":6, "c":7}
]
}
OUTPUT:
_____________________________
| | | | |
| | a | c | b |
| sampleData |---|---|---|
| | 1 | 3 | 2 |
| | 5 | 7 | 6 |
-----------------------------
@contributed by: @muellermichel
"""
if not list_input:
return ""
converted_output = ""
column_headers = None
if self.clubbing:
column_headers = self.column_headers_from_list_of_dicts(list_input)
if column_headers is not None:
converted_output += self.table_init_markup
converted_output += '<thead>'
converted_output += '<tr><th>' + '</th><th>'.join(column_headers) + '</th></tr>'
converted_output += '</thead>'
converted_output += '<tbody>'
for list_entry in list_input:
converted_output += '<tr><td>'
converted_output += '</td><td>'.join([self.convert_json_node(list_entry[column_header]) for column_header in
column_headers])
converted_output += '</td></tr>'
converted_output += '</tbody>'
converted_output += '</table>'
return converted_output
#so you don't want or need clubbing eh? This makes @muellermichel very sad... ;(
#alright, let's fall back to a basic list here...
converted_output = '<ul><li>'
converted_output += '</li><li>'.join([self.convert_json_node(child) for child in list_input])
converted_output += '</li></ul>'
return converted_output
def convert_object(self, json_input):
"""
Iterate over the JSON object and process it
to generate the super awesome HTML Table format
"""
if not json_input:
return "" #avoid empty tables
converted_output = self.table_init_markup + "<tr>"
converted_output += "</tr><tr>".join([
"<th>%s</th><td>%s</td>" %(
self.convert_json_node(k),
self.convert_json_node(v)
)
for k, v in list(json_input.items())
])
converted_output += '</tr></table>'
return converted_output
json2html = Json2Html()

View File

@@ -0,0 +1,668 @@
# 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 <http://www.gnu.org/licenses/>.
# -*- coding: utf-8 -*-
from globals import *
from videoparser import VideoParser
class Service:
player = PLAYER()
monitor = MONITOR()
def _interrupt(self) -> bool:
return PROPERTIES.isPendingInterrupt()
def _suspend(self) -> bool:
return PROPERTIES.isPendingSuspend()
class JSONRPC:
def __init__(self, service=None):
if service is None: service = Service()
self.service = service
self.cache = SETTINGS.cacheDB
self.videoParser = VideoParser()
def log(self, msg, level=xbmc.LOGDEBUG):
return log('%s: %s' % (self.__class__.__name__, msg), level)
def sendJSON(self, param, timeout=-1):
command = param
command["jsonrpc"] = "2.0"
command["id"] = ADDON_ID
self.log('sendJSON, timeout = %s, command = %s'%(timeout,dumpJSON(command)))
if timeout > 0: response = loadJSON((killit(BUILTIN.executeJSONRPC)(timeout,dumpJSON(command))) or {'error':{'message':'JSONRPC timed out!'}})
else: response = loadJSON(BUILTIN.executeJSONRPC(dumpJSON(command)))
if response.get('error'):
self.log('sendJSON, failed! error = %s\n%s'%(dumpJSON(response.get('error')),command), xbmc.LOGWARNING)
response.setdefault('result',{})['error'] = response.pop('error')
#throttle calls, low power devices suffer segfault during rpc flood
self.service.monitor.waitForAbort(float(SETTINGS.getSettingInt('RPC_Delay')/1000))
return response
def queueJSON(self, param):
queuePool = (SETTINGS.getCacheSetting('queueJSON', json_data=True) or {})
params = queuePool.setdefault('params',[])
params.append(param)
queuePool['params'] = sorted(setDictLST(params), key=lambda d: d.get('params',{}).get('setting',''))
queuePool['params'] = sorted(setDictLST(params), key=lambda d: d.get('params',{}).get('playcount',0))
queuePool['params'].reverse() #prioritize setsetting,playcount rollback over duration amendments.
self.log("queueJSON, saving = %s\n%s"%(len(queuePool['params']),param))
SETTINGS.setCacheSetting('queueJSON', queuePool, json_data=True)
def cacheJSON(self, param, life=datetime.timedelta(minutes=15), checksum=ADDON_VERSION, timeout=-1):
cacheName = 'cacheJSON.%s'%(getMD5(dumpJSON(param)))
cacheResponse = self.cache.get(cacheName, checksum=checksum, json_data=True)
if not cacheResponse:
cacheResponse = self.sendJSON(param, timeout)
if cacheResponse.get('result',{}): self.cache.set(cacheName, cacheResponse, checksum=checksum, expiration=life, json_data=True)
return cacheResponse
def walkFileDirectory(self, path, media='video', depth=5, chkDuration=False, retItem=False, checksum=ADDON_VERSION, expiration=datetime.timedelta(minutes=15)):
walk = dict()
self.log('walkFileDirectory, walking %s, depth = %s'%(path,depth))
items = self.getDirectory({"directory":path,"media":media},True,checksum,expiration).get('files',[])
for idx, item in enumerate(items):
if item.get('filetype') == 'file':
if chkDuration:
item['duration'] = self.getDuration(item.get('file'),item, accurate=bool(SETTINGS.getSettingInt('Duration_Type')))
if item['duration'] == 0: continue
walk.setdefault(path,[]).append(item if retItem else item.get('file'))
elif item.get('filetype') == 'directory' and depth > 0:
depth -= 1
walk.update(self.walkFileDirectory(item.get('file'), media, depth, chkDuration, retItem, checksum, expiration))
return walk
def walkListDirectory(self, path, exts='', depth=5, chkDuration=False, appendPath=False, checksum=ADDON_VERSION, expiration=datetime.timedelta(minutes=15)):
def _chkfile(path, f):
if exts and not f.lower().endswith(tuple(exts)): return
if chkDuration:
dur = self.getDuration(os.path.join(path,f), accurate=bool(SETTINGS.getSettingInt('Duration_Type')))
if dur == 0: return
return {True:os.path.join(path,f).replace('\\','/'),False:f}[appendPath]
def _parseXBT(resource):
self.log('walkListDirectory, parsing XBT = %s'%(resource))
walk.setdefault(resource,[]).extend(self.getListDirectory(resource,checksum,expiration)[1])
return walk
walk = dict()
path = path.replace('\\','/')
subs, files = self.getListDirectory(path,checksum,expiration)
if len(files) > 0 and TEXTURES in files: return _parseXBT(re.sub('/resources','',path).replace('special://home/addons/','resource://'))
nfiles = [_f for _f in [_chkfile(path, file) for file in files] if _f]
self.log('walkListDirectory, walking %s, found = %s, appended = %s, depth = %s, ext = %s'%(path,len(files),len(nfiles),depth,exts))
walk.setdefault(path,[]).extend(nfiles)
for sub in subs:
if depth == 0: break
depth -= 1
walk.update(self.walkListDirectory(os.path.join(path,sub), exts, depth, chkDuration, appendPath, checksum, expiration))
return walk
def getListDirectory(self, path, checksum=ADDON_VERSION, expiration=datetime.timedelta(minutes=15)):
cacheName = 'getListDirectory.%s'%(getMD5(path))
results = self.cache.get(cacheName, checksum)
if not results:
try:
results = self.cache.set(cacheName, FileAccess.listdir(path), checksum, expiration)
self.log('getListDirectory path = %s, checksum = %s'%(path, checksum))
except Exception as e:
self.log("getListDirectory, failed! %s\npath = %s"%(e,path), xbmc.LOGERROR)
results = [],[]
self.log('getListDirectory return dirs = %s, files = %s\n%s'%(len(results[0]), len(results[1]),path))
return results
def getIntrospect(self, id):
param = {"method":"JSONRPC.Introspect","params":{"filter":{"id":id,"type":"method"}}}
return self.cacheJSON(param,datetime.timedelta(days=28),BUILTIN.getInfoLabel('BuildVersion','System')).get('result',{})
def getEnums(self, id, type='', key='enums'):
self.log('getEnums id = %s, type = %s, key = %s' % (id, type, key))
param = {"method":"JSONRPC.Introspect","params":{"getmetadata":True,"filterbytransport":True,"filter":{"getreferences":False,"id":id,"type":"type"}}}
json_response = self.cacheJSON(param,datetime.timedelta(days=28),BUILTIN.getInfoLabel('BuildVersion','System')).get('result',{}).get('types',{}).get(id,{})
return (json_response.get('properties',{}).get(type,{}).get(key) or json_response.get(type,{}).get(key) or json_response.get(key,[]))
def notifyAll(self, message, data, sender=ADDON_ID):
param = {"method":"JSONRPC.NotifyAll","params":{"sender":sender,"message":message,"data":[data]}}
return self.sendJSON(param).get('result') == 'OK'
def playerOpen(self, params={}):
param = {"method":"Player.Open","params":params}
return self.sendJSON(param).get('result') == 'OK'
def getSetting(self, category, section, cache=False):
param = {"method":"Settings.GetSettings","params":{"filter":{"category":category,"section":section}}}
if cache: return self.cacheJSON(param).get('result',{}).get('settings',[])
else: return self.sendJSON(param).get('result', {}).get('settings',[])
def getSettingValue(self, key, default='', cache=False):
param = {"method":"Settings.GetSettingValue","params":{"setting":key}}
if cache: return (self.cacheJSON(param).get('result',{}).get('value') or default)
else: return (self.sendJSON(param).get('result',{}).get('value') or default)
def setSettingValue(self, key, value, queue=False):
param = {"method":"Settings.SetSettingValue","params":{"setting":key,"value":value}}
if queue: self.queueJSON(param)
else: self.sendJSON(param)
def getSources(self, media='video', cache=True):
param = {"method":"Files.GetSources","params":{"media":media}}
if cache: return self.cacheJSON(param).get('result', {}).get('sources', [])
else: return self.sendJSON(param).get('result', {}).get('sources', [])
def getAddonDetails(self, addonid=ADDON_ID, cache=True):
param = {"method":"Addons.GetAddonDetails","params":{"addonid":addonid,"properties":self.getEnums("Addon.Fields", type='items')}}
if cache: return self.cacheJSON(param).get('result', {}).get('addon', {})
else: return self.sendJSON(param).get('result', {}).get('addon', {})
def getAddons(self, param={"content":"video","enabled":True,"installed":True}, cache=True):
param["properties"] = self.getEnums("Addon.Fields", type='items')
param = {"method":"Addons.GetAddons","params":param}
if cache: return self.cacheJSON(param).get('result', {}).get('addons', [])
else: return self.sendJSON(param).get('result', {}).get('addons', [])
def getSongs(self, cache=True):
param = {"method":"AudioLibrary.GetSongs","params":{"properties":self.getEnums("Audio.Fields.Song", type='items')}}
if cache: return self.cacheJSON(param).get('result', {}).get('songs', [])
else: return self.sendJSON(param).get('result', {}).get('songs', [])
def getArtists(self, cache=True):
param = {"method":"AudioLibrary.GetArtists","params":{"properties":self.getEnums("Audio.Fields.Artist", type='items')}}
if cache: return self.cacheJSON(param).get('result', {}).get('artists', [])
else: return self.sendJSON(param).get('result', {}).get('artists', [])
def getAlbums(self, cache=True):
param = {"method":"AudioLibrary.GetAlbums","params":{"properties":self.getEnums("Audio.Fields.Album", type='items')}}
if cache: return self.cacheJSON(param).get('result', {}).get('albums', [])
else: return self.sendJSON(param).get('result', {}).get('albums', [])
def getEpisodes(self, cache=True):
param = {"method":"VideoLibrary.GetEpisodes","params":{"properties":self.getEnums("Video.Fields.Episode", type='items')}}
if cache: return self.cacheJSON(param).get('result', {}).get('episodes', [])
else: return self.sendJSON(param).get('result', {}).get('episodes', [])
def getTVshows(self, cache=True):
param = {"method":"VideoLibrary.GetTVShows","params":{"properties":self.getEnums("Video.Fields.TVShow", type='items')}}
if cache: return self.cacheJSON(param).get('result', {}).get('tvshows', [])
else: return self.sendJSON(param).get('result', {}).get('tvshows', [])
def getMovies(self, cache=True):
param = {"method":"VideoLibrary.GetMovies","params":{"properties":self.getEnums("Video.Fields.Movie", type='items')}}
if cache: return self.cacheJSON(param).get('result', {}).get('movies', [])
else: return self.sendJSON(param).get('result', {}).get('movies', [])
def getVideoGenres(self, type="movie", cache=True): #type = "movie"/"tvshow"
param = {"method":"VideoLibrary.GetGenres","params":{"type":type,"properties":self.getEnums("Library.Fields.Genre", type='items')}}
if cache: return self.cacheJSON(param).get('result', {}).get('genres', [])
else: return self.sendJSON(param).get('result', {}).get('genres', [])
def getMusicGenres(self, cache=True):
param = {"method":"AudioLibrary.GetGenres","params":{"properties":self.getEnums("Library.Fields.Genre", type='items')}}
if cache: return self.cacheJSON(param).get('result', {}).get('genres', [])
else: return self.sendJSON(param).get('result', {}).get('genres', [])
def getDirectory(self, param={}, cache=True, checksum=ADDON_VERSION, expiration=datetime.timedelta(minutes=15), timeout=-1):
param["properties"] = self.getEnums("List.Fields.Files", type='items')
param = {"method":"Files.GetDirectory","params":param}
if cache: return self.cacheJSON(param, expiration, checksum, timeout).get('result', {})
else: return self.sendJSON(param, timeout).get('result', {})
def getLibrary(self, method, param={}, cache=True):
param = {"method":method,"params":param}
if cache: return self.cacheJSON(param).get('result', {})
else: return self.sendJSON(param).get('result', {})
def getStreamDetails(self, path, media='video'):
if isStack(path): path = splitStacks(path)[0]
param = {"method":"Files.GetFileDetails","params":{"file":path,"media":media,"properties":["streamdetails"]}}
return self.cacheJSON(param, life=datetime.timedelta(days=MAX_GUIDEDAYS), checksum=getMD5(path)).get('result',{}).get('filedetails',{}).get('streamdetails',{})
def getFileDetails(self, file, media='video', properties=["duration","runtime"]):
return self.cacheJSON({"method":"Files.GetFileDetails","params":{"file":file,"media":media,"properties":properties}})
def getViewMode(self):
default = {"nonlinearstretch":False,"pixelratio":1,"verticalshift":0,"viewmode":"custom","zoom": 1.0}
return self.cacheJSON({"method":"Player.GetViewMode","params":{}},datetime.timedelta(seconds=FIFTEEN)).get('result',default)
def setViewMode(self, params={}):
return self.sendJSON({"method":"Player.SetViewMode","params":params})
def getPlayerItem(self, playlist=False):
self.log('getPlayerItem, playlist = %s' % (playlist))
if playlist: param = {"method":"Playlist.GetItems","params":{"playlistid":self.getActivePlaylist(),"properties":self.getEnums("List.Fields.All", type='items')}}
else: param = {"method":"Player.GetItem" ,"params":{"playerid":self.getActivePlayer() ,"properties":self.getEnums("List.Fields.All", type='items')}}
result = self.sendJSON(param).get('result', {})
return (result.get('item', {}) or result.get('items', []))
def getPVRChannels(self, radio=False):
param = {"method":"PVR.GetChannels","params":{"channelgroupid":{True:'allradio',False:'alltv'}[radio],"properties":self.getEnums("PVR.Fields.Channel", type='items')}}
return self.sendJSON(param).get('result', {}).get('channels', [])
def getPVRChannelsDetails(self, id):
param = {"method":"PVR.GetChannelDetails","params":{"channelid":id,"properties":self.getEnums("PVR.Fields.Channel", type='items')}}
return self.sendJSON(param).get('result', {}).get('channels', [])
def getPVRBroadcasts(self, id):
param = {"method":"PVR.GetBroadcasts","params":{"channelid":id,"properties":self.getEnums("PVR.Fields.Broadcast", type='items')}}
return self.sendJSON(param).get('result', {}).get('broadcasts', [])
def getPVRBroadcastDetails(self, id):
param = {"method":"PVR.GetBroadcastDetails","params":{"broadcastid":id,"properties":self.getEnums("PVR.Fields.Broadcast", type='items')}}
return self.sendJSON(param).get('result', {}).get('broadcastdetails', [])
def getPVRRecordings(self, media='video', cache=True):
param = {"method":"Files.GetDirectory","params":{"directory":"pvr://recordings/tv/active/","media":media,"properties":self.getEnums("List.Fields.Files", type='items')}}
if cache: return self.cacheJSON(param).get('result', {}).get('files', [])
else: return self.sendJSON(param).get('result', {}).get('files', [])
def getPVRSearches(self, media='video', cache=True):
param = {"method":"Files.GetDirectory","params":{"directory":"pvr://search/tv/savedsearches/","media":media,"properties":self.getEnums("List.Fields.Files", type='items')}}
if cache: return self.cacheJSON(param).get('result', {}).get('files', [])
else: return self.sendJSON(param).get('result', {}).get('files', [])
def getPVRSearchItems(self, id, media='video', cache=True):
param = {"method":"Files.GetDirectory","params":{"directory":f"pvr://search/tv/savedsearches/{id}/","media":media,"properties":self.getEnums("List.Fields.Files", type='items')}}
if cache: return self.cacheJSON(param).get('result', {}).get('files', [])
else: return self.sendJSON(param).get('result', {}).get('files', [])
def getSmartPlaylists(self, type='video', cache=True):
param = {"method":"Files.GetDirectory","params":{"directory":f"special://profile/playlists/{type}/","media":"video","properties":self.getEnums("List.Fields.Files", type='items')}}
if cache: return self.cacheJSON(param).get('result', {}).get('files', [])
else: return self.sendJSON(param).get('result', {}).get('files', [])
def getInfoLabel(self, key, cache=False):
param = {"method":"XBMC.GetInfoLabels","params":{"labels":[key]}}
if cache: return self.cacheJSON(param).get('result', {}).get(key)
else: return self.sendJSON(param).get('result', {}).get(key)
def getInfoBool(self, key, cache=False):
param = {"method":"XBMC.GetInfoBooleans","params":{"booleans":[key]}}
if cache: return self.cacheJSON(param).get('result', {}).get(key)
else: return self.sendJSON(param).get('result', {}).get(key)
def _setRuntime(self, item={}, runtime=0, save=SETTINGS.getSettingBool('Store_Duration')): #set runtime collected by player, accurate meta.
self.cache.set('getRuntime.%s'%(getMD5(item.get('file'))), runtime, checksum=getMD5(item.get('file')), expiration=datetime.timedelta(days=28), json_data=False)
if not item.get('file','plugin://').startswith(tuple(VFS_TYPES)) and save and runtime > 0: self.queDuration(item, runtime=runtime)
def _getRuntime(self, item={}): #get runtime collected by player, else less accurate provider meta
runtime = self.cache.get('getRuntime.%s'%(getMD5(item.get('file'))), checksum=getMD5(item.get('file')), json_data=False)
return (runtime or item.get('resume',{}).get('total') or item.get('runtime') or item.get('duration') or (item.get('streamdetails',{}).get('video',[]) or [{}])[0].get('duration') or 0)
def _setDuration(self, path, item={}, duration=0, save=SETTINGS.getSettingBool('Store_Duration')):#set VideoParser cache
self.cache.set('getDuration.%s'%(getMD5(path)), duration, checksum=getMD5(path), expiration=datetime.timedelta(days=28), json_data=False)
if save and item: self.queDuration(item, duration)
return duration
def _getDuration(self, path): #get VideoParser cache
return (self.cache.get('getDuration.%s'%(getMD5(path)), checksum=getMD5(path), json_data=False) or 0)
def getDuration(self, path, item={}, accurate=bool(SETTINGS.getSettingInt('Duration_Type')), save=SETTINGS.getSettingBool('Store_Duration')):
self.log("getDuration, accurate = %s, path = %s, save = %s" % (accurate, path, save))
if not item: item = {'file':path}
runtime = self._getRuntime(item)
if runtime == 0 or accurate:
duration = 0
if isStack(path):# handle "stacked" videos
for file in splitStacks(path): duration += self.__parseDuration(runtime, file)
else: duration = self.__parseDuration(runtime, path, item, save)
if duration > 0: runtime = duration
self.log("getDuration, returning path = %s, runtime = %s" % (path, runtime))
return runtime
def getTotRuntime(self, items=[]):
total = sum([self._getRuntime(item) for item in items])
self.log("getTotRuntime, items = %s, total = %s" % (len(items), total))
return total
def getTotDuration(self, items=[]):
total = sum([self.getDuration(item.get('file'),item) for item in items])
self.log("getTotDuration, items = %s, total = %s" % (len(items), total))
return total
def __parseDuration(self, runtime, path, item={}, save=SETTINGS.getSettingBool('Store_Duration')):
self.log("__parseDuration, runtime = %s, path = %s, save = %s" % (runtime, path, save))
duration = self.videoParser.getVideoLength(path.replace("\\\\", "\\"), item, self)
if not path.startswith(tuple(VFS_TYPES)):
## duration diff. safe guard, how different are the two values? if > 45% don't save to Kodi.
rundiff = int(percentDiff(runtime, duration))
runsafe = False
if (rundiff <= 45 and rundiff > 0) or (rundiff == 100 and (duration == 0 or runtime == 0)) or (rundiff == 0 and (duration > 0 and runtime > 0)) or (duration > runtime): runsafe = True
self.log("__parseDuration, path = %s, runtime = %s, duration = %s, difference = %s%%, safe = %s" % (path, runtime, duration, rundiff, runsafe))
## save parsed duration to Kodi database, if enabled.
if runsafe:
runtime = duration
if save and not path.startswith(tuple(VFS_TYPES)): self.queDuration(item, duration)
else: runtime = duration
self.log("__parseDuration, returning runtime = %s" % (runtime))
return runtime
def queDuration(self, item={}, duration=0, runtime=0):
mtypes = {'video' : {},
'movie' : {"method":"VideoLibrary.SetMovieDetails" ,"params":{"movieid" :item.get('id',-1) ,"runtime": runtime,"resume": {"position": item.get('position',0.0),"total": duration}}},
'movies' : {"method":"VideoLibrary.SetMovieDetails" ,"params":{"movieid" :item.get('movieid',-1) ,"runtime": runtime,"resume": {"position": item.get('position',0.0),"total": duration}}},
'episode' : {"method":"VideoLibrary.SetEpisodeDetails" ,"params":{"episodeid" :item.get('id',-1) ,"runtime": runtime,"resume": {"position": item.get('position',0.0),"total": duration}}},
'episodes' : {"method":"VideoLibrary.SetEpisodeDetails" ,"params":{"episodeid" :item.get('episodeid',-1) ,"runtime": runtime,"resume": {"position": item.get('position',0.0),"total": duration}}},
'musicvideo' : {"method":"VideoLibrary.SetMusicVideoDetails","params":{"musicvideoid":item.get('id',-1) ,"runtime": runtime,"resume": {"position": item.get('position',0.0),"total": duration}}},
'musicvideos': {"method":"VideoLibrary.SetMusicVideoDetails","params":{"musicvideoid":item.get('musicvideoid',-1) ,"runtime": runtime,"resume": {"position": item.get('position',0.0),"total": duration}}},
'song' : {"method":"AudioLibrary.SetSongDetails" ,"params":{"songid" :item.get('id',-1) ,"runtime": runtime,"resume": {"position": item.get('position',0.0),"total": duration}}},
'songs' : {"method":"AudioLibrary.SetSongDetails" ,"params":{"songid" :item.get('songid',-1) ,"runtime": runtime,"resume": {"position": item.get('position',0.0),"total": duration}}}}
try:
mtype = mtypes.get(item.get('type'))
if mtype.get('params'):
if duration == 0: mtype['params'].pop('resume') #save file duration meta
elif runtime == 0: mtype['params'].pop('runtime') #save player runtime meta
id = (item.get('id') or item.get('movieid') or item.get('episodeid') or item.get('musicvideoid') or item.get('songid'))
self.log('[%s] queDuration, media = %s, duration = %s, runtime = %s'%(id,item['type'],duration,runtime))
self.queueJSON(mtype['params'])
except Exception as e: self.log("queDuration, failed! %s\nmtype = %s\nitem = %s"%(e,mtype,item), xbmc.LOGERROR)
def quePlaycount(self, item, save=SETTINGS.getSettingBool('Rollback_Watched')):
param = {'video' : {},
'movie' : {"method":"VideoLibrary.SetMovieDetails" ,"params":{"movieid" :item.get('id',-1) ,"playcount": item.get('playcount',0),"resume": {"position": item.get('position',0.0),"total": item.get('total',0.0)}}},
'movies' : {"method":"VideoLibrary.SetMovieDetails" ,"params":{"movieid" :item.get('movieid',-1) ,"playcount": item.get('playcount',0),"resume": {"position": item.get('position',0.0),"total": item.get('total',0.0)}}},
'episode' : {"method":"VideoLibrary.SetEpisodeDetails" ,"params":{"episodeid" :item.get('id',-1) ,"playcount": item.get('playcount',0),"resume": {"position": item.get('position',0.0),"total": item.get('total',0.0)}}},
'episodes' : {"method":"VideoLibrary.SetEpisodeDetails" ,"params":{"episodeid" :item.get('episodeid',-1) ,"playcount": item.get('playcount',0),"resume": {"position": item.get('position',0.0),"total": item.get('total',0.0)}}},
'musicvideo' : {"method":"VideoLibrary.SetMusicVideoDetails","params":{"musicvideoid":item.get('id',-1) ,"playcount": item.get('playcount',0),"resume": {"position": item.get('position',0.0),"total": item.get('total',0.0)}}},
'musicvideos': {"method":"VideoLibrary.SetMusicVideoDetails","params":{"musicvideoid":item.get('musicvideoid',-1) ,"playcount": item.get('playcount',0),"resume": {"position": item.get('position',0.0),"total": item.get('total',0.0)}}},
'song' : {"method":"AudioLibrary.SetSongDetails" ,"params":{"songid" :item.get('id',-1) ,"playcount": item.get('playcount',0),"resume": {"position": item.get('position',0.0),"total": item.get('total',0.0)}}},
'songs' : {"method":"AudioLibrary.SetSongDetails" ,"params":{"songid" :item.get('songid',-1) ,"playcount": item.get('playcount',0),"resume": {"position": item.get('position',0.0),"total": item.get('total',0.0)}}}}
if not item.get('file','plugin://').startswith(tuple(VFS_TYPES)):
try:
params = param.get(item.get('type'))
self.log('quePlaycount, params = %s'%(params.get('params',{})))
self.queueJSON(params)
except: pass
def requestList(self, citem, path, media='video', page=SETTINGS.getSettingInt('Page_Limit'), sort={}, limits={}, query={}):
# {"method": "VideoLibrary.GetEpisodes",
# "params": {
# "properties": ["title"],
# "sort": {"ignorearticle": true,
# "method": "label",
# "order": "ascending",
# "useartistsortname": true},
# "limits": {"end": 0, "start": 0},
# "filter": {"and": [{"field": "title", "operator": "contains", "value": "Star Wars"}]}}}
##################################
# {"method": "Files.GetDirectory",
# "params": {
# "directory": "videodb://tvshows/studios/-1/-1/-1/",
# "media": "video",
# "properties": ["title"],
# "sort": {"ignorearticle": true,
# "method": "label",
# "order": "ascending",
# "useartistsortname": true},
# "limits": {"end": 25, "start": 0}}}
param = {}
if query: #library query
getDirectory = False
param['filter'] = query.get('filter',{})
param["properties"] = self.getEnums(query['enum'], type='items')
else: #vfs path
getDirectory = True
param["media"] = media
param["directory"] = escapeDirJSON(path)
param["properties"] = self.getEnums("List.Fields.Files", type='items')
self.log("requestList, id: %s, getDirectory = %s, media = %s, limit = %s, sort = %s, query = %s, limits = %s\npath = %s"%(citem['id'],getDirectory,media,page,sort,query,limits,path))
if limits.get('end',-1) == -1: #-1 unlimited pagination, replace with autoPagination.
limits = self.autoPagination(citem['id'], path, query) #get
self.log('[%s] requestList, autoPagination limits = %s'%(citem['id'],limits))
if limits.get('total',0) > page and sort.get("method","") == "random":
limits = self.randomPagination(page,limits)
self.log('[%s] requestList, generating random limits = %s'%(citem['id'],limits))
param["limits"] = {}
param["limits"]["start"] = 0 if limits.get('end', 0) == -1 else limits.get('end', 0)
param["limits"]["end"] = abs(limits.get('end', 0) + page)
param["sort"] = sort
self.log('[%s] requestList, page = %s\nparam = %s'%(citem['id'], page, param))
if getDirectory:
results = self.getDirectory(param,timeout=float(SETTINGS.getSettingInt('RPC_Timer')*60))
if 'filedetails' in results: key = 'filedetails'
else: key = 'files'
else:
results = self.getLibrary(query['method'],param, cache=False)
key = query.get('key',list(results.keys())[0])
items, limits, errors = results.get(key,[]), results.get('limits',param["limits"]), results.get('error',{})
if (limits.get('end',0) >= limits.get('total',0) or limits.get('start',0) >= limits.get('total',0)):
# restart page to 0, exceeding boundaries.
self.log('[%s] requestList, resetting limits to 0'%(citem['id']))
limits = {"end": 0, "start": 0, "total": limits.get('total',0)}
if len(items) == 0 and limits.get('total',0) > 0:
# retry last request with fresh limits when no items are returned.
self.log("[%s] requestList, trying again with start limits at 0"%(citem['id']))
return self.requestList(citem, path, media, page, sort, {"end": 0, "start": 0, "total": limits.get('total',0)}, query)
else:
self.autoPagination(citem['id'], path, query, limits) #set
self.log("[%s] requestList, return items = %s" % (citem['id'], len(items)))
return items, limits, errors
def resetPagination(self, id, path, query={}, limits={"end": 0, "start": 0, "total":0}):
return self.autoPagination(id, path, query, limits)
def autoPagination(self, id, path, query={}, limits={}):
if not limits: return (self.cache.get('autoPagination.%s.%s.%s'%(id,getMD5(path),getMD5(dumpJSON(query))), checksum=id, json_data=True) or {"end": 0, "start": 0, "total":0})
else: return self.cache.set('autoPagination.%s.%s.%s'%(id,getMD5(path),getMD5(dumpJSON(query))), limits, checksum=id, expiration=datetime.timedelta(days=28), json_data=True)
def randomPagination(self, page=SETTINGS.getSettingInt('Page_Limit'), limits={}, start=0):
if limits.get('total',0) > page: start = random.randrange(0, (limits.get('total',0)-page), page)
return {"end": start, "start": start, "total":limits.get('total',0)}
@cacheit(checksum=PROPERTIES.getInstanceID())
def buildWebBase(self, local=False):
port = 80
username = 'kodi'
password = ''
secure = False
enabled = True
settings = self.getSetting('control','services')
for setting in settings:
if setting.get('id','').lower() == 'services.webserver' and not setting.get('value'):
enabled = False
DIALOG.notificationDialog(LANGUAGE(32131))
break
if setting.get('id','').lower() == 'services.webserverusername': username = setting.get('value')
elif setting.get('id','').lower() == 'services.webserverport': port = setting.get('value')
elif setting.get('id','').lower() == 'services.webserverpassword': password = setting.get('value')
elif setting.get('id','').lower() == 'services.webserverssl' and setting.get('value'): secure = True
username = '{0}:{1}@'.format(username, password) if username and password else ''
protocol = 'https' if secure else 'http'
if local: ip = 'localhost'
else: ip = SETTINGS.getIP()
webURL = '{0}://{1}{2}:{3}'.format(protocol,username,ip, port)
self.log("buildWebBase; returning %s"%(webURL))
return webURL
def padItems(self, files, page=SETTINGS.getSettingInt('Page_Limit')):
# Balance media limits, by filling with duplicates to meet min. pagination.
self.log("padItems; files In = %s"%(len(files)))
if len(files) < page:
iters = cycle(files)
while not self.service.monitor.abortRequested() and (len(files) < page and len(files) > 0):
item = next(iters).copy()
if self.service.monitor.waitForAbort(0.0001): break
elif self.getDuration(item.get('file'),item) == 0:
try: files.pop(files.index(item))
except: break
else: files.append(item)
self.log("padItems; files Out = %s"%(len(files)))
return files
def inputFriendlyName(self):
friendly = self.getSettingValue("services.devicename")
self.log("inputFriendlyName, name = %s"%(friendly))
if not friendly or friendly.lower() == 'kodi':
with PROPERTIES.interruptActivity():
if DIALOG.okDialog(LANGUAGE(32132)%(friendly)):
friendly = DIALOG.inputDialog(LANGUAGE(30122), friendly)
if not friendly or friendly.lower() == 'kodi':
return self.inputFriendlyName()
else:
self.setSettingValue("services.devicename",friendly,queue=False)
self.log('inputFriendlyName, setting device name = %s'%(friendly))
return friendly
def getCallback(self, sysInfo={}):
self.log('[%s] getCallback, mode = %s, radio = %s, isPlaylist = %s'%(sysInfo.get('chid'),sysInfo.get('mode'),sysInfo.get('radio',False),sysInfo.get('isPlaylist',False)))
def _matchJSON():#requires 'pvr://' json whitelisting
results = self.getDirectory(param={"directory":"pvr://channels/{dir}/".format(dir={'True':'radio','False':'tv'}[str(sysInfo.get('radio',False))])}, cache=False).get('files',[])
for dir in [ADDON_NAME,'All channels']: #todo "All channels" may not work with non-English translations!
for result in results:
if result.get('label','').lower().startswith(dir.lower()):
self.log('getCallback: _matchJSON, found dir = %s'%(result.get('file')))
channels = self.getDirectory(param={"directory":result.get('file')},checksum=PROPERTIES.getInstanceID(),expiration=datetime.timedelta(minutes=FIFTEEN)).get('files',[])
for item in channels:
if item.get('label','').lower() == sysInfo.get('name','').lower() and decodePlot(item.get('plot','')).get('citem',{}).get('id') == sysInfo.get('chid'):
self.log('[%s] getCallback: _matchJSON, found file = %s'%(sysInfo.get('chid'),item.get('file')))
return item.get('file')
if sysInfo.get('mode','').lower() == 'live' and sysInfo.get('chpath'):
callback = sysInfo.get('chpath')
elif sysInfo.get('isPlaylist'):
callback = sysInfo.get('citem',{}).get('url')
elif sysInfo.get('mode','').lower() == 'vod' and sysInfo.get('nitem',{}).get('file'):
callback = sysInfo.get('nitem',{}).get('file')
else:
callback = sysInfo.get('callback','')
if not callback: callback = _matchJSON()
self.log('getCallback: returning callback = %s'%(callback))
return callback# or (('%s%s'%(self.sysARG[0],self.sysARG[2])).split('%s&'%(slugify(ADDON_NAME))))[0])
def matchChannel(self, chname: str, id: str, radio: bool=False, extend=True):
self.log('[%s] matchChannel, chname = %s, radio = %s'%(id,chname,radio))
def __match():
channels = self.getPVRChannels(radio)
for channel in channels:
if channel.get('label','').lower() == chname.lower():
for key in ['broadcastnow', 'broadcastnext']:
if decodePlot(channel.get(key,{}).get('plot','')).get('citem',{}).get('id') == id:
channel['broadcastnext'] = [channel.get('broadcastnext',{})]
self.log('[%s] matchChannel: __match, found pvritem = %s'%(id,channel))
return channel
def __extend(pvritem: dict={}) -> dict:
channelItem = {}
def _parseBroadcast(broadcast={}):
if broadcast.get('progresspercentage',0) == 100:
channelItem.setdefault('broadcastpast',[]).append(broadcast)
elif broadcast.get('progresspercentage',0) > 0 and broadcast.get('progresspercentage',100) < 100:
channelItem['broadcastnow'] = broadcast
elif broadcast.get('progresspercentage',0) == 0 and broadcast.get('progresspercentage',100) < 100:
channelItem.setdefault('broadcastnext',[]).append(broadcast)
broadcasts = self.getPVRBroadcasts(pvritem.get('channelid',{}))
[_parseBroadcast(broadcast) for broadcast in broadcasts]
pvritem['broadcastnext'] = channelItem.get('broadcastnext',pvritem['broadcastnext'])
self.log('matchChannel: __extend, broadcastnext = %s entries'%(len(pvritem['broadcastnext'])))
return pvritem
cacheName = 'matchChannel.%s'%(getMD5('%s.%s.%s.%s'%(chname,id,radio,extend)))
cacheResponse = (self.cache.get(cacheName, checksum=PROPERTIES.getInstanceID(), json_data=True) or {})
if not cacheResponse:
pvrItem = __match()
if pvrItem:
if extend: pvrItem = __extend(pvrItem)
cacheResponse = self.cache.set(cacheName, pvrItem, checksum=PROPERTIES.getInstanceID(), expiration=datetime.timedelta(seconds=FIFTEEN), json_data=True)
else: return {}
return cacheResponse
def getNextItem(self, citem={}, nitem={}): #return next broadcast ignoring fillers
if not nitem: nitem = decodePlot(BUILTIN.getInfoLabel('NextPlot','VideoPlayer'))
nextitems = sorted(self.matchChannel(citem.get('name',''), citem.get('id',''), citem.get('radio',False)).get('broadcastnext',[]), key=itemgetter('starttime'))
for nextitem in nextitems:
if not isFiller(nextitem): return decodePlot(nextitem.get('plot',''))
return nitem
def toggleShowLog(self, state=False):
self.log('toggleShowLog, state = %s'%(state))
if SETTINGS.getSettingBool('Enable_PVR_RELOAD'): #check that users allow alternations to kodi.
opState = not bool(state)
if self.getSettingValue("debug.showloginfo") == opState:
self.setSettingValue("debug.showloginfo",state,queue=False)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,507 @@
# 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 <http://www.gnu.org/licenses/>.
# -*- coding: utf-8 -*-
from globals import *
from predefined import Predefined
from resources import Resources
from channels import Channels
#constants
REG_KEY = 'PseudoTV_Recommended.%s'
class Service:
from jsonrpc import JSONRPC
player = PLAYER()
monitor = MONITOR()
jsonRPC = JSONRPC()
def _interrupt(self) -> bool:
return PROPERTIES.isPendingInterrupt()
def _suspend(self) -> bool:
return PROPERTIES.isPendingSuspend()
class Library:
def __init__(self, service=None):
if service is None: service = Service()
self.service = service
self.jsonRPC = service.jsonRPC
self.cache = service.jsonRPC.cache
self.predefined = Predefined()
self.channels = Channels()
self.resources = Resources(service=self.service)
self.pCount = 0
self.pDialog = None
self.pMSG = ''
self.pHeader = ''
self.libraryDATA = getJSON(LIBRARYFLE_DEFAULT)
self.libraryTEMP = self.libraryDATA['library'].pop('Item')
self.libraryDATA.update(self._load())
def log(self, msg, level=xbmc.LOGDEBUG):
return log('%s: %s'%(self.__class__.__name__,msg),level)
def _load(self, file=LIBRARYFLEPATH):
return getJSON(file)
def _save(self, file=LIBRARYFLEPATH):
self.libraryDATA['uuid'] = SETTINGS.getMYUUID()
return setJSON(file, self.libraryDATA)
def getLibrary(self, type=None):
self.log('getLibrary, type = %s'%(type))
if type is None: return self.libraryDATA.get('library',{})
else: return self.libraryDATA.get('library',{}).get(type,[])
def enableByName(self, type, names=[]):
self.log('enableByName, type = %s, names = %s'%(type, names))
items = self.getLibrary(type)
for name in names:
for item in items:
if name.lower() == item.get('name','').lower(): item['enabled'] = True
else: item['enabled'] = False
return self.setLibrary(type, items)
def setLibrary(self, type, items=[]):
self.log('setLibrary, type = %s, items = %s'%(type,len(items)))
self.libraryDATA['library'][type] = items
enabled = self.getEnabled(type, items)
PROPERTIES.setEXTPropertyBool('%s.has.%s'%(ADDON_ID,slugify(type)),len(items) > 0)
PROPERTIES.setEXTPropertyBool('%s.has.%s.enabled'%(ADDON_ID,slugify(type)),len(enabled) > 0)
SETTINGS.setSetting('Select_%s'%(slugify(type)),'[COLOR=orange][B]%s[/COLOR][/B]/[COLOR=dimgray]%s[/COLOR]'%(len(enabled),len(items)))
return self._save()
def getEnabled(self, type, items=None):
if items is None: items = self.getLibrary(type)
return [item for item in items if item.get('enabled',False)]
def updateLibrary(self, force: bool=False) -> bool:
def __funcs():
return {
"Playlists" :{'func':self.getPlaylists ,'life':datetime.timedelta(minutes=FIFTEEN)},
"TV Networks" :{'func':self.getNetworks ,'life':datetime.timedelta(days=MAX_GUIDEDAYS)},
"TV Shows" :{'func':self.getTVShows ,'life':datetime.timedelta(hours=MAX_GUIDEDAYS)},
"TV Genres" :{'func':self.getTVGenres ,'life':datetime.timedelta(days=MAX_GUIDEDAYS)},
"Movie Genres" :{'func':self.getMovieGenres ,'life':datetime.timedelta(days=MAX_GUIDEDAYS)},
"Movie Studios":{'func':self.getMovieStudios,'life':datetime.timedelta(days=MAX_GUIDEDAYS)},
"Mixed Genres" :{'func':self.getMixedGenres ,'life':datetime.timedelta(days=MAX_GUIDEDAYS)},
"Mixed" :{'func':self.getMixed ,'life':datetime.timedelta(minutes=FIFTEEN)},
"Recommended" :{'func':self.getRecommend ,'life':datetime.timedelta(hours=MAX_GUIDEDAYS)},
"Services" :{'func':self.getServices ,'life':datetime.timedelta(hours=MAX_GUIDEDAYS)},
"Music Genres" :{'func':self.getMusicGenres ,'life':datetime.timedelta(days=MAX_GUIDEDAYS)}
}
def __fill(type, func):
try: items = func()
except Exception as e:
self.log("__fill, %s failed! %s"%(type,e), xbmc.LOGERROR)
items = []
self.log('__fill, returning %s (%s)'%(type,len(items)))
return items
def __update(type, items, existing=[]):
if not existing: existing = self.channels.getType(type)
self.log('__update, type = %s, items = %s, existing = %s'%(type,len(items),len(existing)))
for item in items:
if not item.get('enabled',False):
for eitem in existing:
if getChannelSuffix(item.get('name'), type).lower() == eitem.get('name','').lower():
if eitem['logo'] not in [LOGO,COLOR_LOGO] and item['logo'] in [LOGO,COLOR_LOGO]: item['logo'] = eitem['logo']
item['enabled'] = True
break
item['logo'] = self.resources.getLogo(item,item.get('logo',LOGO)) #update logo
entry = self.libraryTEMP.copy()
entry.update(item)
yield entry
if force: #clear library cache.
with BUILTIN.busy_dialog():
for label, params in list(__funcs().items()):
DIALOG.notificationDialog(LANGUAGE(30070)%(label),time=5)
self.cache.clear("%s.%s"%(self.__class__.__name__,params['func'].__name__),wait=5)
complete = True
types = list(__funcs().keys())
for idx, type in enumerate(types):
self.pMSG = type
self.pCount = int(idx*100//len(types))
self.pHeader = '%s, %s %s'%(ADDON_NAME,LANGUAGE(32028),LANGUAGE(32041))
self.pDialog = DIALOG.progressBGDialog(header=self.pHeader)
if (self.service._interrupt() or self.service._suspend()) and PROPERTIES.hasFirstRun():
self.log("updateLibrary, _interrupt")
complete = False
self.pDialog = DIALOG.progressBGDialog(self.pCount, self.pDialog, '%s: %s'%(LANGUAGE(32144),LANGUAGE(32213)), self.pHeader)
break
self.pDialog = DIALOG.progressBGDialog(self.pCount, self.pDialog, self.pMSG, self.pHeader)
cacheResponse = self.cache.get("%s.%s"%(self.__class__.__name__,__funcs()[type]['func'].__name__))
if not cacheResponse:
self.pHeader = '%s, %s %s'%(ADDON_NAME,LANGUAGE(32022),LANGUAGE(32041))
cacheResponse = self.cache.set("%s.%s"%(self.__class__.__name__,__funcs()[type]['func'].__name__), __fill(type, __funcs()[type]['func']), expiration=__funcs()[type]['life'])
if complete:
self.setLibrary(type, list(__update(type,cacheResponse,self.getEnabled(type))))
self.log("updateLibrary, type = %s, saved items = %s"%(type,len(cacheResponse)))
self.pDialog = DIALOG.progressBGDialog(100, self.pDialog, header='%s, %s %s'%(ADDON_NAME,LANGUAGE(32041),LANGUAGE(32025)))
self.log('updateLibrary, force = %s, complete = %s'%(force, complete))
return complete
def resetLibrary(self, ATtypes=AUTOTUNE_TYPES):
self.log('resetLibrary')
for ATtype in ATtypes:
items = self.getLibrary(ATtype)
for item in items:
item['enabled'] = False #disable everything before selecting new items.
self.setLibrary(ATtype, items)
def updateProgress(self, percent, message, header):
if self.pDialog: self.pDialog = DIALOG.progressBGDialog(percent, self.pDialog, message=message, header=header)
def getNetworks(self):
return self.getTVInfo().get('studios',[])
def getTVGenres(self):
return self.getTVInfo().get('genres',[])
def getTVShows(self):
return self.getTVInfo().get('shows',[])
def getMovieStudios(self):
return self.getMovieInfo().get('studios',[])
def getMovieGenres(self):
return self.getMovieInfo().get('genres',[])
def getMusicGenres(self):
return self.getMusicInfo().get('genres',[])
def getMixedGenres(self):
MixedGenreList = []
tvGenres = self.getTVGenres()
movieGenres = self.getMovieGenres()
for tv in [tv for tv in tvGenres for movie in movieGenres if tv.get('name','').lower() == movie.get('name','').lower()]:
MixedGenreList.append({'name':tv.get('name'),'type':"Mixed Genres",'path':self.predefined.createGenreMixedPlaylist(tv.get('name')),'logo':tv.get('logo'),'rules':{"800":{"values":{"0":tv.get('name')}}}})
self.log('getMixedGenres, genres = %s' % (len(MixedGenreList)))
return sorted(MixedGenreList,key=itemgetter('name'))
def getMixed(self):
MixedList = []
MixedList.append({'name':LANGUAGE(32001), 'type':"Mixed",'path':self.predefined.createMixedRecent() ,'logo':self.resources.getLogo({'name':LANGUAGE(32001),'type':"Mixed"})}) #"Recently Added"
MixedList.append({'name':LANGUAGE(32002), 'type':"Mixed",'path':self.predefined.createSeasonal() ,'logo':self.resources.getLogo({'name':LANGUAGE(32002),'type':"Mixed"}),'rules':{"800":{"values":{"0":LANGUAGE(32002)}}}}) #"Seasonal"
MixedList.extend(self.getPVRRecordings())#"PVR Recordings"
MixedList.extend(self.getPVRSearches()) #"PVR Searches"
self.log('getMixed, mixed = %s' % (len(MixedList)))
return sorted(MixedList,key=itemgetter('name'))
def getPVRRecordings(self):
recordList = []
json_response = self.jsonRPC.getPVRRecordings()
paths = [item.get('file') for idx, item in enumerate(json_response) if item.get('label','').endswith('(%s)'%(ADDON_NAME))]
if len(paths) > 0: recordList.append({'name':LANGUAGE(32003),'type':"Mixed",'path':[paths],'logo':self.resources.getLogo({'name':LANGUAGE(32003),'type':"Mixed"})})
self.log('getPVRRecordings, recordings = %s' % (len(recordList)))
return sorted(recordList,key=itemgetter('name'))
def getPVRSearches(self):
searchList = []
json_response = self.jsonRPC.getPVRSearches()
for idx, item in enumerate(json_response):
if not item.get('file'): continue
searchList.append({'name':"%s (%s)"%(item.get('label',LANGUAGE(32241)),LANGUAGE(32241)),'type':"Mixed",'path':[item.get('file')],'logo':self.resources.getLogo({'name':item.get('label',LANGUAGE(32241)),'type':"Mixed"})})
self.log('getPVRSearches, searches = %s' % (len(searchList)))
return sorted(searchList,key=itemgetter('name'))
def getPlaylists(self):
PlayList = []
for type in ['video','mixed','music']:
self.updateProgress(self.pCount,'%s: %s'%(self.pMSG,LANGUAGE(32140)),self.pHeader)
results = self.jsonRPC.getSmartPlaylists(type)
for idx, result in enumerate(results):
self.updateProgress(self.pCount,'%s (%s): %s%%'%(self.pMSG,type.title(),int((idx)*100//len(results))),self.pHeader)
if not result.get('label'): continue
logo = result.get('thumbnail')
if not logo: logo = self.resources.getLogo({'name':result.get('label',''),'type':"Custom"})
PlayList.append({'name':result.get('label'),'type':"%s Playlist"%(type.title()),'path':[result.get('file')],'logo':logo})
self.log('getPlaylists, PlayList = %s' % (len(PlayList)))
PlayList = sorted(PlayList,key=itemgetter('name'))
PlayList = sorted(PlayList,key=itemgetter('type'))
return PlayList
@cacheit()
def getTVInfo(self, sortbycount=True):
self.log('getTVInfo')
if BUILTIN.hasTV():
NetworkList = Counter()
ShowGenreList = Counter()
TVShows = Counter()
self.updateProgress(self.pCount,'%s: %s'%(self.pMSG,LANGUAGE(32140)),self.pHeader)
json_response = self.jsonRPC.getTVshows()
for idx, info in enumerate(json_response):
self.updateProgress(self.pCount,'%s: %s%%'%(self.pMSG,int((idx)*100//len(json_response))),self.pHeader)
if not info.get('label'): continue
TVShows.update({json.dumps({'name': info.get('label'), 'type':"TV Shows", 'path': self.predefined.createShowPlaylist(info.get('label')), 'logo': info.get('art', {}).get('clearlogo', ''),'rules':{"800":{"values":{"0":info.get('label')}}}}): info.get('episode', 0)})
NetworkList.update([studio for studio in info.get('studio', [])])
ShowGenreList.update([genre for genre in info.get('genre', [])])
if sortbycount:
TVShows = [json.loads(x[0]) for x in sorted(TVShows.most_common(250))]
NetworkList = [x[0] for x in sorted(NetworkList.most_common(50))]
ShowGenreList = [x[0] for x in sorted(ShowGenreList.most_common(25))]
else:
TVShows = (sorted(map(json.loads, list(TVShows.keys())), key=itemgetter('name')))
del TVShows[250:]
NetworkList = (sorted(set(list(NetworkList.keys()))))
del NetworkList[250:]
ShowGenreList = (sorted(set(list(ShowGenreList.keys()))))
#search resources for studio/genre logos
nNetworkList = []
for idx, network in enumerate(NetworkList):
self.updateProgress(self.pCount,'%s: %s%%'%(self.pMSG,int((idx)*100//len(NetworkList))),self.pHeader)
nNetworkList.append({'name':network, 'type':"TV Networks", 'path': self.predefined.createNetworkPlaylist(network),'logo':self.resources.getLogo({'name':network,'type':"TV Networks"}),'rules':{"800":{"values":{"0":network}}}})
NetworkList = nNetworkList
nShowGenreList = []
for idx, tvgenre in enumerate(ShowGenreList):
self.updateProgress(self.pCount,'%s: %s%%'%(self.pMSG,int((idx)*100//len(ShowGenreList))),self.pHeader)
nShowGenreList.append({'name':tvgenre, 'type':"TV Genres" , 'path': self.predefined.createTVGenrePlaylist(tvgenre),'logo':self.resources.getLogo({'name':tvgenre,'type':"TV Genres"}),'rules':{"800":{"values":{"0":tvgenre}}}})
ShowGenreList = nShowGenreList
else: NetworkList = ShowGenreList = TVShows = []
self.log('getTVInfo, networks = %s, genres = %s, shows = %s' % (len(NetworkList), len(ShowGenreList), len(TVShows)))
return {'studios':NetworkList,'genres':ShowGenreList,'shows':TVShows}
@cacheit()
def getMovieInfo(self, sortbycount=True):
self.log('getMovieInfo')
if BUILTIN.hasMovie():
StudioList = Counter()
MovieGenreList = Counter()
self.updateProgress(self.pCount,'%s: %s'%(self.pMSG,LANGUAGE(32140)),self.pHeader)
json_response = self.jsonRPC.getMovies() #we can't parse for genres directly from Kodi json ie.getGenres; because we need the weight of each genre to prioritize list.
for idx, info in enumerate(json_response):
StudioList.update([studio for studio in info.get('studio', [])])
MovieGenreList.update([genre for genre in info.get('genre', [])])
if sortbycount:
StudioList = [x[0] for x in sorted(StudioList.most_common(25))]
MovieGenreList = [x[0] for x in sorted(MovieGenreList.most_common(25))]
else:
StudioList = (sorted(set(list(StudioList.keys()))))
del StudioList[250:]
MovieGenreList = (sorted(set(list(MovieGenreList.keys()))))
#search resources for studio/genre logos
nStudioList = []
for idx, studio in enumerate(StudioList):
self.updateProgress(self.pCount,'%s: %s%%'%(self.pMSG,int((idx)*100//len(StudioList))),self.pHeader)
nStudioList.append({'name':studio, 'type':"Movie Studios", 'path': self.predefined.createStudioPlaylist(studio) ,'logo':self.resources.getLogo({'name':studio,'type':"Movie Studios"}),'rules':{"800":{"values":{"0":studio}}}})
StudioList = nStudioList
nMovieGenreList = []
for idx, genre in enumerate(MovieGenreList):
self.updateProgress(self.pCount,'%s: %s%%'%(self.pMSG,int((idx)*100//len(MovieGenreList))),self.pHeader)
nMovieGenreList.append({'name':genre, 'type':"Movie Genres" , 'path': self.predefined.createMovieGenrePlaylist(genre) ,'logo':self.resources.getLogo({'name':genre,'type':"Movie Genres"}) ,'rules':{"800":{"values":{"0":genre}}}})
MovieGenreList = nMovieGenreList
else: StudioList = MovieGenreList = []
self.log('getMovieInfo, studios = %s, genres = %s' % (len(StudioList), len(MovieGenreList)))
return {'studios':StudioList,'genres':MovieGenreList}
@cacheit()
def getMusicInfo(self, sortbycount=True):
self.log('getMusicInfo')
if BUILTIN.hasMusic():
MusicGenreList = Counter()
self.updateProgress(self.pCount,'%s: %s'%(self.pMSG,LANGUAGE(32140)),self.pHeader)
json_response = self.jsonRPC.getMusicGenres()
for idx, info in enumerate(json_response):
MusicGenreList.update([genre.strip() for genre in info.get('label','').split(';')])
if sortbycount:
MusicGenreList = [x[0] for x in sorted(MusicGenreList.most_common(50))]
else:
MusicGenreList = (sorted(set(list(MusicGenreList.keys()))))
del MusicGenreList[250:]
MusicGenreList = (sorted(set(list(MusicGenreList.keys()))))
#search resources for studio/genre logos
nMusicGenreList = []
for idx, genre in enumerate(MusicGenreList):
self.updateProgress(self.pCount,'%s: %s%%'%(self.pMSG,int((idx)*100//len(MusicGenreList))),self.pHeader)
nMusicGenreList.append({'name':genre, 'type':"Music Genres", 'path': self.predefined.createMusicGenrePlaylist(genre),'logo':self.resources.getLogo({'name':genre,'type':"Music Genres"})})
MusicGenreList = nMusicGenreList
else: MusicGenreList = []
self.log('getMusicInfo, found genres = %s' % (len(MusicGenreList)))
return {'genres':MusicGenreList}
def getRecommend(self):
self.log('getRecommend')
PluginList = []
WhiteList = self.getWhiteList()
AddonsList = self.searchRecommended()
for addonid, item in list(AddonsList.items()):
if addonid not in WhiteList: continue
items = item.get('data',{}).get('vod',[])
items.extend(item.get('data',{}).get('live',[]))
for vod in items:
path = vod.get('path')
if not isinstance(path,list): path = [path]
PluginList.append({'id':item['meta'].get('name'), 'name':vod.get('name'), 'type':"Recommended", 'path': path, 'logo':vod.get('icon',item['meta'].get('thumbnail'))})
self.log('getRecommend, found (%s) vod items.' % (len(PluginList)))
PluginList = sorted(PluginList,key=itemgetter('name'))
PluginList = sorted(PluginList,key=itemgetter('id'))
return PluginList
def getRecommendInfo(self, addonid):
self.log('getRecommendInfo, addonid = %s'%(addonid))
return self.searchRecommended().get(addonid,{})
def searchRecommended(self):
return {} #todo
# def _search(addonid):
# cacheName = 'searchRecommended.%s'%(getMD5(addonid))
# addonMeta = SETTINGS.getAddonDetails(addonid)
# payload = PROPERTIES.getEXTProperty(REG_KEY%(addonid))
# if not payload: #startup services may not be broadcasting beacon; use last cached beacon instead.
# payload = self.cache.get(cacheName, checksum=addonMeta.get('version',ADDON_VERSION), json_data=True)
# else:
# payload = loadJSON(payload)
# self.cache.set(cacheName, payload, checksum=addonMeta.get('version',ADDON_VERSION), expiration=datetime.timedelta(days=MAX_GUIDEDAYS), json_data=True)
# if payload:
# self.log('searchRecommended, found addonid = %s, payload = %s'%(addonid,payload))
# return addonid,{"data":payload,"meta":addonMeta}
# addonList = sorted(list(set([_f for _f in [addon.get('addonid') for addon in list([k for k in self.jsonRPC.getAddons() if k.get('addonid','') not in self.getBlackList()])] if _f])))
# return dict([_f for _f in [_search(addonid) for addonid in addonList] if _f])
def getServices(self):
self.log('getServices')
return []
def getWhiteList(self):
#whitelist - prompt shown, added to import list and/or manager dropdown.
return self.libraryDATA.get('whitelist',[])
def setWhiteList(self, data=[]):
self.libraryDATA['whitelist'] = sorted(set(data))
return self._save()
def getBlackList(self):
#blacklist - plugin ignored for the life of the list.
return self.libraryDATA.get('blacklist',[])
def setBlackList(self, data=[]):
self.libraryDATA['blacklist'] = sorted(set(data))
return self._save()
def addWhiteList(self, addonid):
self.log('addWhiteList, addonid = %s'%(addonid))
whiteList = self.getWhiteList()
whiteList.append(addonid)
whiteList = sorted(set(whiteList))
if len(whiteList) > 0: PROPERTIES.setEXTPropertyBool('%s.has.WhiteList'%(ADDON_ID),len(whiteList) > 0)
return self.setWhiteList(whiteList)
def addBlackList(self, addonid):
self.log('addBlackList, addonid = %s'%(addonid))
blackList = self.getBlackList()
blackList.append(addonid)
blackList = sorted(set(blackList))
return self.setBlackList(blackList)
def clearBlackList(self):
return self.setBlackList()
def importPrompt(self):
addonList = self.searchRecommended()
ignoreList = self.getWhiteList()
ignoreList.extend(self.getBlackList()) #filter addons previously parsed.
addonNames = sorted(list(set([_f for _f in [item.get('meta',{}).get('name') for addonid, item in list(addonList.items()) if not addonid in ignoreList] if _f])))
self.log('importPrompt, addonNames = %s'%(len(addonNames)))
try:
if len(addonNames) > 1:
retval = DIALOG.yesnoDialog('%s'%(LANGUAGE(32055)%(ADDON_NAME,', '.join(addonNames))), customlabel=LANGUAGE(32056))
self.log('importPrompt, prompt retval = %s'%(retval))
if retval == 1: raise Exception('Single Entry')
elif retval == 2:
for addonid, item in list(addonList.items()):
if item.get('meta',{}).get('name') in addonNames:
self.addWhiteList(addonid)
else: raise Exception('Single Entry')
except Exception as e:
self.log('importPrompt, %s'%(e))
for addonid, item in list(addonList.items()):
if item.get('meta',{}).get('name') in addonNames:
if not DIALOG.yesnoDialog('%s'%(LANGUAGE(32055)%(ADDON_NAME,item['meta'].get('name','')))):
self.addBlackList(addonid)
else:
self.addWhiteList(addonid)
PROPERTIES.setEXTPropertyBool('%s.has.WhiteList'%(ADDON_ID),len(self.getWhiteList()) > 0)
PROPERTIES.setEXTPropertyBool('%s.has.BlackList'%(ADDON_ID),len(self.getBlackList()) > 0)
SETTINGS.setSetting('Clear_BlackList','|'.join(self.getBlackList()))

View File

@@ -0,0 +1,92 @@
# 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 <http://www.gnu.org/licenses/>.
#
# -*- coding: utf-8 -*-
import json,traceback
from globals import *
def log(event, level=xbmc.LOGDEBUG):
"""
Logs an event or message using Kodi's logging system.
This function is designed to capture and log debug events based on the specified log level.
If debugging is enabled or the log level is critical (e.g., errors), the event will be
recorded in Kodi's log file. Additionally, it stores events in a custom debug window property
for debugging purposes.
Args:
event (str): The message or event to log.
level (int, optional): The log level (default is xbmc.LOGDEBUG). Supported levels:
- xbmc.LOGDEBUG: Debug messages (low priority).
- xbmc.LOGINFO: Informational messages.
- xbmc.LOGWARNING: Warnings.
- xbmc.LOGERROR: Errors.
- xbmc.LOGFATAL: Fatal errors.
Behavior:
- Logs the event if debugging is enabled or if the log level is above the configured threshold.
- Appends a traceback for error-level logs (level >= xbmc.LOGERROR).
- Formats the log message with the add-on ID and version for context.
- Stores the log entry in the global debug window property for later retrieval if debugging is enabled.
Example Usage:
log("This is a debug message", xbmc.LOGDEBUG)
log("An error occurred", xbmc.LOGERROR)
Notes:
- The `REAL_SETTINGS.getSetting('Debug_Enable')` setting determines whether to log debug-level messages.
- The log entries are stored in a JSON object with timestamps and log levels for easy parsing.
Returns:
None
"""
if REAL_SETTINGS.getSetting('Debug_Enable') == 'true' or level >= 3:
DEBUG_NAMES = {0: 'LOGDEBUG', 1: 'LOGINFO', 2: 'LOGWARNING', 3: 'LOGERROR', 4: 'LOGFATAL'}
DEBUG_LEVELS = {0: xbmc.LOGDEBUG, 1: xbmc.LOGINFO, 2: xbmc.LOGWARNING, 3: xbmc.LOGERROR, 4: xbmc.LOGFATAL}
DEBUG_LEVEL = DEBUG_LEVELS[int((REAL_SETTINGS.getSetting('Debug_Level') or "3"))]
# Add traceback for error-level events
if level >= 3:
event = '%s\n%s' % (event, traceback.format_exc())
# Format event with add-on ID and version
event = '%s-%s-%s' % (ADDON_ID, ADDON_VERSION, event)
# Log the event if the level is above the configured debug level
if level >= DEBUG_LEVEL:
xbmc.log(event, level)
try:
entries = json.loads(xbmcgui.Window(10000).getProperty('%s.debug.log' % (ADDON_ID))).get('DEBUG', {})
except:
entries = {}
# Add the event to the debug entries
entries.setdefault(DEBUG_NAMES[DEBUG_LEVEL], []).append(
'%s - %s: %s' % (datetime.datetime.fromtimestamp(time.time()).strftime(DTFORMAT), DEBUG_NAMES[level], event)
)
# Store the debug entries in the window property
try:
xbmcgui.Window(10000).setProperty('%s.debug.log' % (ADDON_ID), json.dumps({'DEBUG': entries}, indent=4))
except:
pass
# Mark the debug property as active
if not xbmcgui.Window(10000).getProperty('%s.has.debug' % (ADDON_ID)) == 'true':
xbmcgui.Window(10000).setProperty('%s.has.debug' % (ADDON_ID), 'true')

View File

@@ -0,0 +1,451 @@
# 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 <http://www.gnu.org/licenses/>.
# https://github.com/kodi-pvr/pvr.iptvsimple#supported-m3u-and-xmltv-elements
# -*- coding: utf-8 -*-
from globals import *
from channels import Channels
M3U_TEMP = {"id" : "",
"number" : 0,
"name" : "",
"logo" : "",
"group" : [],
"catchup" : "vod",
"radio" : False,
"favorite" : False,
"realtime" : False,
"media" : "",
"label" : "",
"url" : "",
"tvg-shift" : "",
"x-tvg-url" : "",
"media-dir" : "",
"media-size" : "",
"media-type" : "",
"catchup-source" : "",
"catchup-days" : "",
"catchup-correction": "",
"provider" : "",
"provider-type" : "",
"provider-logo" : "",
"provider-countries": "",
"provider-languages": "",
"x-playlist-type" : "",
"kodiprops" : []}
M3U_MIN = {"id" : "",
"number" : 0,
"name" : "",
"logo" : "",
"group" : [],
"catchup" : "vod",
"radio" : False,
"label" : "",
"url" : ""}
class M3U:
def __init__(self):
stations, recordings = self.cleanSelf(list(self._load()))
self.M3UDATA = {'data':'#EXTM3U tvg-shift="" x-tvg-url="" x-tvg-id="" catchup-correction=""', 'stations':stations, 'recordings':recordings}
# self.M3UTEMP = getJSON(M3UFLE_DEFAULT)
def log(self, msg, level=xbmc.LOGDEBUG):
return log('%s: %s'%(self.__class__.__name__,msg),level)
def _load(self, file=M3UFLEPATH):
self.log('_load, file = %s'%file)
if file.startswith('http'):
url = file
file = os.path.join(TEMP_LOC,slugify(url))
saveURL(url,file)
if FileAccess.exists(file):
fle = FileAccess.open(file, 'r')
lines = (fle.readlines())
fle.close()
chCount = 0
data = {}
filter = []
for idx, line in enumerate(lines):
line = line.rstrip()
if line.startswith('#EXTM3U'):
data = {'tvg-shift' :re.compile('tvg-shift=\"(.*?)\"' , re.IGNORECASE).search(line),
'x-tvg-url' :re.compile('x-tvg-url=\"(.*?)\"' , re.IGNORECASE).search(line),
'catchup-correction':re.compile('catchup-correction=\"(.*?)\"' , re.IGNORECASE).search(line)}
# if SETTINGS.getSettingInt('Import_XMLTV_TYPE') == 2 and file == os.path.join(TEMP_LOC,slugify(SETTINGS.getSetting('Import_M3U_URL'))):
# if data.get('x-tvg-url').group(1):
# self.log('_load, using #EXTM3U "x-tvg-url"')
# SETTINGS.setSetting('Import_XMLTV_M3U',data.get('x-tvg-url').group(1))
elif line.startswith('#EXTINF:'):
chCount += 1
match = {'label' :re.compile(',(.*)' , re.IGNORECASE).search(line),
'id' :re.compile('tvg-id=\"(.*?)\"' , re.IGNORECASE).search(line),
'name' :re.compile('tvg-name=\"(.*?)\"' , re.IGNORECASE).search(line),
'group' :re.compile('group-title=\"(.*?)\"' , re.IGNORECASE).search(line),
'number' :re.compile('tvg-chno=\"(.*?)\"' , re.IGNORECASE).search(line),
'logo' :re.compile('tvg-logo=\"(.*?)\"' , re.IGNORECASE).search(line),
'radio' :re.compile('radio=\"(.*?)\"' , re.IGNORECASE).search(line),
'tvg-shift' :re.compile('tvg-shift=\"(.*?)\"' , re.IGNORECASE).search(line),
'catchup' :re.compile('catchup=\"(.*?)\"' , re.IGNORECASE).search(line),
'catchup-source' :re.compile('catchup-source=\"(.*?)\"' , re.IGNORECASE).search(line),
'catchup-days' :re.compile('catchup-days=\"(.*?)\"' , re.IGNORECASE).search(line),
'catchup-correction':re.compile('catchup-correction=\"(.*?)\"' , re.IGNORECASE).search(line),
'provider' :re.compile('provider=\"(.*?)\"' , re.IGNORECASE).search(line),
'provider-type' :re.compile('provider-type=\"(.*?)\"' , re.IGNORECASE).search(line),
'provider-logo' :re.compile('provider-logo=\"(.*?)\"' , re.IGNORECASE).search(line),
'provider-countries':re.compile('provider-countries=\"(.*?)\"' , re.IGNORECASE).search(line),
'provider-languages':re.compile('provider-languages=\"(.*?)\"' , re.IGNORECASE).search(line),
'media' :re.compile('media=\"(.*?)\"' , re.IGNORECASE).search(line),
'media-dir' :re.compile('media-dir=\"(.*?)\"' , re.IGNORECASE).search(line),
'media-size' :re.compile('media-size=\"(.*?)\"' , re.IGNORECASE).search(line),
'realtime' :re.compile('realtime=\"(.*?)\"' , re.IGNORECASE).search(line)}
if match['id'].group(1) in filter:
self.log('_load, filtering duplicate %s'%(match['id'].group(1)))
continue
filter.append(match['id'].group(1)) #filter dups, todo find where dups originate from.
mitem = self.getMitem()
mitem.update({'number' :chCount,
'logo' :LOGO,
'catchup':''}) #set default parameters
for key, value in list(match.items()):
if value is None:
if data.get(key,None) is not None:
self.log('_load, using #EXTM3U "%s" value for #EXTINF'%(key))
value = data[key] #no local EXTINF value found; use global EXTM3U if applicable.
else: continue
if value.group(1) is None:
continue
elif key == 'logo':
mitem[key] = value.group(1)
elif key == 'number':
try: mitem[key] = int(value.group(1))
except: mitem[key] = float(value.group(1))#todo why was this needed?
elif key == 'group':
mitem[key] = [_f for _f in sorted(list(set((value.group(1)).split(';')))) if _f]
elif key in ['radio','favorite','realtime','media']:
mitem[key] = (value.group(1)).lower() == 'true'
else:
mitem[key] = value.group(1)
for nidx in range(idx+1,len(lines)):
try:
nline = lines[nidx].rstrip()
if nline.startswith('#EXTINF:'): break
elif nline.startswith('#EXTGRP'):
grop = re.compile('^#EXTGRP:(.*)$', re.IGNORECASE).search(nline)
if grop is not None:
mitem['group'].append(grop.group(1).split(';'))
mitem['group'] = sorted(set(mitem['group']))
elif nline.startswith('#KODIPROP:'):
prop = re.compile('^#KODIPROP:(.*)$', re.IGNORECASE).search(nline)
if prop is not None: mitem.setdefault('kodiprops',[]).append(prop.group(1))
elif nline.startswith('#EXTVLCOPT'):
copt = re.compile('^#EXTVLCOPT:(.*)$', re.IGNORECASE).search(nline)
if copt is not None: mitem.setdefault('extvlcopt',[]).append(copt.group(1))
elif nline.startswith('#EXT-X-PLAYLIST-TYPE'):
xplay = re.compile('^#EXT-X-PLAYLIST-TYPE:(.*)$', re.IGNORECASE).search(nline)
if xplay is not None: mitem['x-playlist-type'] = xplay.group(1)
elif nline.startswith('##'): continue
elif not nline: continue
else: mitem['url'] = nline
except Exception as e: self.log('_load, error parsing m3u! %s'%(e))
#Fill missing with similar parameters.
mitem['name'] = (mitem.get('name') or mitem.get('label') or '')
mitem['label'] = (mitem.get('label') or mitem.get('name') or '')
mitem['favorite'] = (mitem.get('favorite') or False)
#Set Fav. based on group value.
if LANGUAGE(32019) in mitem['group'] and not mitem['favorite']:
mitem['favorite'] = True
#Core m3u parameters missing, ignore entry.
if not mitem.get('id') or not mitem.get('name') or not mitem.get('number'):
self.log('_load, SKIPPED MISSING META m3u item = %s'%mitem)
continue
self.log('_load, m3u item = %s'%mitem)
yield mitem
def _save(self, file=M3UFLEPATH):
with FileLock():
fle = FileAccess.open(file, 'w')
fle.write('%s\n'%(self.M3UDATA['data']))
opts = list(self.getMitem().keys())
mins = [opts.pop(opts.index(key)) for key in list(M3U_MIN.keys()) if key in opts] #min required m3u entries.
line = '#EXTINF:-1 tvg-chno="%s" tvg-id="%s" tvg-name="%s" tvg-logo="%s" group-title="%s" radio="%s" catchup="%s" %s,%s\n'
self.M3UDATA['stations'] = self.sortStations(self.M3UDATA.get('stations',[]))
self.M3UDATA['recordings'] = self.sortStations(self.M3UDATA.get('recordings',[]), key='name')
self.log('_save, saving %s stations and %s recordings to %s'%(len(self.M3UDATA['stations']),len(self.M3UDATA['recordings']),file))
for station in (self.M3UDATA['recordings'] + self.M3UDATA['stations']):
optional = ''
xplaylist = ''
kodiprops = {}
extvlcopt = {}
# write optional m3u parameters.
if 'kodiprops' in station: kodiprops = station.pop('kodiprops')
if 'extvlcopt' in station: extvlcopt = station.pop('extvlcopt')
if 'x-playlist-type' in station: xplaylist = station.pop('x-playlist-type')
for key, value in list(station.items()):
if key in opts and str(value):
optional += '%s="%s" '%(key,value)
fle.write(line%(station['number'],
station['id'],
station['name'],
station['logo'],
';'.join(station['group']),
station['radio'],
station['catchup'],
optional,
station['label']))
if kodiprops: fle.write('%s\n'%('\n'.join(['#KODIPROP:%s'%(prop) for prop in kodiprops])))
if extvlcopt: fle.write('%s\n'%('\n'.join(['#EXTVLCOPT:%s'%(prop) for prop in extvlcopt])))
if xplaylist: fle.write('%s\n'%('#EXT-X-PLAYLIST-TYPE:%s'%(xplaylist)))
fle.write('%s\n'%(station['url']))
fle.close()
return self._reload()
def _reload(self):
self.log('_reload')
self.__init__()
return True
def _verify(self, stations=[], recordings=[], chkPath=SETTINGS.getSettingBool('Clean_Recordings')):
if stations: #remove abandoned m3u entries; Stations that are not found in the channel list
channels = Channels().getChannels()
stations = [station for station in stations for channel in channels if channel.get('id') == station.get('id',str(random.random()))]
self.log('_verify, stations = %s'%(len(stations)))
return stations
elif recordings:#remove recordings that no longer exists on disk
if chkPath: recordings = [recording for recording in recordings if hasFile(decodeString(dict(urllib.parse.parse_qsl(recording.get('url',''))).get('vid').replace('.pvr','')))]
else: recordings = [recording for recording in recordings if recording.get('media',False)]
self.log('_verify, recordings = %s, chkPath = %s'%(len(recordings),chkPath))
return recordings
return []
def cleanSelf(self, items, key='id', slug='@%s'%(slugify(ADDON_NAME))): # remove m3u imports (Non PseudoTV Live)
if not slug: return items
stations = self.sortStations(self._verify(stations=[station for station in items if station.get(key,'').endswith(slug) and not station.get('media',False)]))
recordings = self.sortStations(self._verify(recordings=[recording for recording in items if recording.get(key,'').endswith(slug) and recording.get('media',False)]), key='name')
self.log('cleanSelf, slug = %s, key = %s: returning: stations = %s, recordings = %s'%(slug,key,len(stations),len(recordings)))
return stations, recordings
def sortStations(self, stations, key='number'):
try: return sorted(stations, key=itemgetter(key))
except: return stations
def getM3U(self):
return self.M3UDATA
def getMitem(self):
return M3U_TEMP.copy()
def getTZShift(self):
self.log('getTZShift')
return ((time.mktime(time.localtime()) - time.mktime(time.gmtime())) / 60 / 60)
def getStations(self):
stations = self.sortStations(self.M3UDATA.get('stations',[]))
self.log('getStations, stations = %s'%(len(stations)))
return stations
def getRecordings(self):
recordings = self.sortStations(self.M3UDATA.get('recordings',[]), key='name')
self.log('getRecordings, recordings = %s'%(len(recordings)))
return recordings
def findStation(self, citem):
for idx, eitem in enumerate(self.M3UDATA.get('stations',[])):
if (citem.get('id',str(random.random())) == eitem.get('id') or citem.get('url',str(random.random())).lower() == eitem.get('url','').lower()):
self.log('findStation, found eitem = %s'%(eitem))
return idx, eitem
return None, {}
def findRecording(self, ritem):
for idx, eitem in enumerate(self.M3UDATA.get('recordings',[])):
if (ritem.get('id',str(random.random())) == eitem.get('id')) or (ritem.get('label',str(random.random())).lower() == eitem.get('label','').lower()) or (ritem.get('path',str(random.random())).endswith('%s.pvr'%(eitem.get('name')))):
self.log('findRecording, found eitem = %s'%(eitem))
return idx, eitem
return None, {}
def getStationItem(self, sitem):
if sitem.get('resume',False):
sitem['url'] = RESUME_URL.format(addon=ADDON_ID,name=quoteString(sitem['name']),chid=quoteString(sitem['id']))
elif sitem['catchup']:
sitem['catchup-source'] = BROADCAST_URL.format(addon=ADDON_ID,name=quoteString(sitem['name']),chid=quoteString(sitem['id']),vid='{catchup-id}')
sitem['url'] = LIVE_URL.format(addon=ADDON_ID,name=quoteString(sitem['name']),chid=quoteString(sitem['id']),vid='{catchup-id}',now='{lutc}',start='{utc}',duration='{duration}',stop='{utcend}')
elif sitem['radio']: sitem['url'] = RADIO_URL.format(addon=ADDON_ID,name=quoteString(sitem['name']),chid=quoteString(sitem['id']),radio=str(sitem['radio']),vid='{catchup-id}')
else: sitem['url'] = TV_URL.format(addon=ADDON_ID,name=quoteString(sitem['name']),chid=quoteString(sitem['id']))
return sitem
def getRecordItem(self, fitem, seek=0):
if seek <= 0: group = LANGUAGE(30119)
else: group = LANGUAGE(30152)
ritem = self.getMitem()
ritem['provider'] = '%s (%s)'%(ADDON_NAME,SETTINGS.getFriendlyName())
ritem['provider-type'] = 'addon'
ritem['provider-logo'] = HOST_LOGO
ritem['label'] = (fitem.get('showlabel') or '%s%s'%(fitem.get('label',''),' - %s'%(fitem.get('episodelabel','')) if fitem.get('episodelabel','') else ''))
ritem['name'] = ritem['label']
ritem['number'] = random.Random(str(fitem.get('id',1))).random()
ritem['logo'] = cleanImage((getThumb(fitem,opt=EPG_ARTWORK) or {0:FANART,1:COLOR_LOGO}[EPG_ARTWORK]))
ritem['media'] = True
ritem['media-size'] = str(fitem.get('size',0))
ritem['media-dir'] = ''#todo optional add parent directory via user prompt?
ritem['group'] = ['%s (%s)'%(group,ADDON_NAME)]
ritem['id'] = getRecordID(ritem['name'], (fitem.get('originalfile') or fitem.get('file','')), ritem['number'])
ritem['url'] = DVR_URL.format(addon=ADDON_ID,title=quoteString(ritem['label']),chid=quoteString(ritem['id']),vid=quoteString(encodeString((fitem.get('originalfile') or fitem.get('file','')))),seek=seek,duration=fitem.get('duration',0))#fitem.get('catchup-id','')
return ritem
def addStation(self, citem):
idx, line = self.findStation(citem)
self.log('addStation,\nchannel item = %s\nfound existing = %s'%(citem,line))
mitem = self.getMitem()
mitem.update(citem)
mitem['label'] = citem['name'] #todo channel manager opt to change channel 'label' leaving 'name' static for channelid purposes
mitem['logo'] = citem['logo']
mitem['realtime'] = False
mitem['provider'] = '%s (%s)'%(ADDON_NAME,SETTINGS.getFriendlyName())
mitem['provider-type'] = 'addon'
mitem['provider-logo'] = HOST_LOGO
if not idx is None: self.M3UDATA['stations'].pop(idx)
self.M3UDATA.get('stations',[]).append(mitem)
self.log('addStation, channels = %s'%(len(self.M3UDATA.get('stations',[]))))
return True
def addRecording(self, ritem):
# https://github.com/kodi-pvr/pvr.iptvsimple/blob/Omega/README.md#media
idx, line = self.findRecording(ritem)
self.log('addRecording,\nrecording ritem = %s\nfound existing = %s'%(ritem,idx))
if not idx is None: self.M3UDATA['recordings'].pop(idx)
self.M3UDATA.get('recordings',[]).append(ritem)
return self._save()
def delStation(self, citem):
self.log('[%s] delStation'%(citem['id']))
idx, line = self.findStation(citem)
if not idx is None: self.M3UDATA['stations'].pop(idx)
return True
def delRecording(self, ritem):
self.log('[%s] delRecording'%((ritem.get('id') or ritem.get('label'))))
idx, line = self.findRecording(ritem)
if not idx is None:
self.M3UDATA['recordings'].pop(idx)
return self._save()
def importM3U(self, file, filters={}, multiplier=1):
self.log('importM3U, file = %s, filters = %s, multiplier = %s'%(file,filters,multiplier))
try:
importChannels = []
if file.startswith('http'):
url = file
file = os.path.join(TEMP_LOC,'%s'%(slugify(url)))
setURL(url,file)
stations = self._load(file)
for key, value in list(filters.items()):
if key == 'slug' and value:
importChannels.extend(self.cleanSelf(stations,'id',value)[0])
elif key == 'providers' and value:
for provider in value:
importChannels.extend(self.cleanSelf(stations,'provider',provider)[0])
#no filter found, import all stations.
if not importChannels: importChannels.extend(stations)
importChannels = self.sortStations(list(self.chkImport(importChannels,multiplier)))
self.log('importM3U, found import stations = %s'%(len(importChannels)))
self.M3UDATA.get('stations',[]).extend(importChannels)
except Exception as e: self.log("importM3U, failed! %s"%(e), xbmc.LOGERROR)
return importChannels
def chkImport(self, stations, multiplier=1):
def roundup(x):
return x if x % 1000 == 0 else x + 1000 - x % 1000
def frange(start, stop, step):
while not MONITOR().abortRequested() and start < stop:
yield float(start)
start += decimal.Decimal(step)
stations = self.sortStations(stations)
chstart = roundup((CHANNEL_LIMIT * len(CHAN_TYPES)+1))
chmin = int(chstart + (multiplier*1000))
chmax = int(chmin + (CHANNEL_LIMIT))
chrange = list(frange(chmin,chmax,0.1))
leftovers = []
self.log('chkImport, stations = %s, multiplier = %s, chstart = %s, chmin = %s, chmax = %s'%(len(stations),multiplier,chstart,chmin,chmax))
## check tvg-chno for conflict, use multiplier to modify org chnum.
for mitem in stations:
if len(chrange) == 0:
self.log('chkImport, reached max import')
break
elif mitem['number'] < CHANNEL_LIMIT:
newnumber = (chmin+mitem['number'])
if newnumber in chrange:
chrange.remove(newnumber)
mitem['number'] = newnumber
yield mitem
else: leftovers.append(mitem)
else: leftovers.append(mitem)
for mitem in leftovers:
if len(chrange) == 0:
self.log('chkImport, reached max import')
break
else:
mitem['number'] = chrange.pop(0)
yield mitem

View File

@@ -0,0 +1,974 @@
# 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 <http://www.gnu.org/licenses/>.
# -*- coding: utf-8 -*-
from globals import *
from cache import Cache
from channels import Channels
from jsonrpc import JSONRPC
from rules import RulesList
from resources import Resources
from xsp import XSP
from infotagger.listitem import ListItemInfoTag
# Actions
ACTION_MOVE_LEFT = 1
ACTION_MOVE_RIGHT = 2
ACTION_MOVE_UP = 3
ACTION_MOVE_DOWN = 4
ACTION_SELECT_ITEM = 7
ACTION_INVALID = 999
ACTION_SHOW_INFO = [11,24,401]
ACTION_PREVIOUS_MENU = [92, 10,110,521] #+ [9, 92, 216, 247, 257, 275, 61467, 61448]
class Manager(xbmcgui.WindowXMLDialog):
monitor = MONITOR()
focusIndex = -1
newChannels = []
def __init__(self, *args, **kwargs):
xbmcgui.WindowXMLDialog.__init__(self, *args, **kwargs)
def __get1stChannel(channelList):
for channel in channelList:
if not channel.get('id'): return channel.get('number')
return 1
def __findChannel(chnum, retitem=False, channels=[]):
for idx, channel in enumerate(channels):
if channel.get('number') == (chnum or 1):
if retitem: return channel
else: return idx
with BUILTIN.busy_dialog():
self.server = {}
self.lockAutotune = True
self.madeChanges = False
self.madeItemchange = False
self.lastActionTime = time.time()
self.cntrlStates = {}
self.showingList = True
self.startChannel = kwargs.get('channel',-1)
self.openChannel = kwargs.get('open')
self.cache = SETTINGS.cache
self.channels = Channels()
self.rule = RulesList()
self.jsonRPC = JSONRPC()
self.resource = Resources()
self.host = PROPERTIES.getRemoteHost()
self.friendly = SETTINGS.getFriendlyName()
self.newChannel = self.channels.getTemplate()
self.eChannels = self.loadChannels(SETTINGS.getSetting('Default_Channels'))
try:
if self.eChannels is None: raise Exception("No Channels Found!")
else:
self.channelList = self.channels.sortChannels(self.createChannelList(self.buildArray(), self.eChannels))
self.newChannels = self.channelList.copy()
if self.startChannel == -1: self.startChannel = __get1stChannel(self.channelList)
if self.startChannel <= CHANNEL_LIMIT: self.focusIndex = (self.startChannel - 1) #Convert from Channel number to array index
else: self.focusIndex = __findChannel(self.startChannel,channels=self.channelList)
if self.openChannel: self.openChannel = self.channelList[self.focusIndex]
self.log('Manager, startChannel = %s, focusIndex = %s, openChannel = %s'%(self.startChannel, self.focusIndex, self.openChannel))
if kwargs.get('start',True): self.doModal()
except Exception as e:
self.log('Manager failed! %s'%(e), xbmc.LOGERROR)
self.closeManager()
def log(self, msg, level=xbmc.LOGDEBUG):
return log('%s: %s'%(self.__class__.__name__,msg),level)
def onInit(self):
try:
self.focusItems = dict()
self.spinner = self.getControl(4)
self.chanList = self.getControl(5)
self.itemList = self.getControl(6)
self.right_button1 = self.getControl(9001)
self.right_button2 = self.getControl(9002)
self.right_button3 = self.getControl(9003)
self.right_button4 = self.getControl(9004)
self.fillChanList(self.newChannels,focus=self.focusIndex,channel=self.openChannel)
except Exception as e:
log("onInit, failed! %s"%(e), xbmc.LOGERROR)
self.closeManager()
def getServers(self):
from multiroom import Multiroom
return Multiroom().getDiscovery()
def loadChannels(self, name=''):
self.log('loadChannels, name = %s'%(name))
channels = self.channels.getChannels()
if name == self.friendly: return channels
elif name == LANGUAGE(30022):#Auto
if len(channels) > 0: return channels
else: return self.loadChannels('Ask')
elif name == 'Ask':
def __buildItem(servers, server):
if server.get('online',False):
return self.buildListItem(server.get('name'),'%s - %s: Channels (%s)'%(LANGUAGE(32211)%({True:'green',False:'red'}[server.get('online',False)],{True:LANGUAGE(32158),False:LANGUAGE(32253)}[server.get('online',False)]),server.get('host'),len(server.get('channels',[]))),icon=DUMMY_ICON.format(text=str(servers.index(server)+1)))
servers = self.getServers()
lizlst = poolit(__buildItem)(*(list(servers.values()),list(servers.values())))
lizlst.insert(0,self.buildListItem(self.friendly,'%s - %s: Channels (%s)'%('[B]Local[/B]',self.host,len(channels)),icon=ICON))
select = DIALOG.selectDialog(lizlst, LANGUAGE(30173), None, True, SELECT_DELAY, False)
if not select is None: return self.loadChannels(lizlst[select].getLabel())
else: return
elif name:
self.server = self.getServers().get(name,{})
return self.server.get('channels',[])
return channels
@cacheit(json_data=True)
def buildArray(self):
self.log('buildArray') # Create blank array of citem templates.
def __create(idx):
newChannel = self.newChannel.copy()
newChannel['number'] = idx + 1
return newChannel
return poolit(__create)(list(range(CHANNEL_LIMIT)))
def createChannelList(self, channelArray, channelList):
self.log('createChannelList') # Fill blank array with citems from channels.json
def __update(item):
try: channelArray[item["number"]-1].update(item) #CUSTOM
except: channelArray.append(item) #AUTOTUNE
checksum = getMD5(dumpJSON(channelList))
cacheName = 'createChannelList.%s'%(checksum)
cacheResponse = self.cache.get(cacheName, checksum=checksum, json_data=True)
if not cacheResponse:
poolit(__update)(channelList)
cacheResponse = self.cache.set(cacheName, channelArray, checksum=checksum, json_data=True)
return cacheResponse
def buildListItem(self, label: str="", label2: str="", icon: str="", paths: list=[], items: dict={}):
if not icon: icon = (items.get('citem',{}).get('logo') or COLOR_LOGO)
if not paths: paths = (items.get('citem',{}).get("path") or [])
return LISTITEMS.buildMenuListItem(label, label2, icon, url='|'.join(paths), props=items)
def fillChanList(self, channelList, refresh=False, focus=None, channel=None):
self.log('fillChanList, focus = %s, channel = %s'%(focus,channel))
def __buildItem(citem):
isPredefined = citem["number"] > CHANNEL_LIMIT
isFavorite = citem.get('favorite',False)
isRadio = citem.get('radio',False)
isLocked = isPredefined #todo parse channel lock rule
channelColor = COLOR_UNAVAILABLE_CHANNEL
labelColor = COLOR_UNAVAILABLE_CHANNEL
if citem.get("path"):
if isPredefined: channelColor = COLOR_LOCKED_CHANNEL
else:
labelColor = COLOR_AVAILABLE_CHANNEL
if isLocked: channelColor = COLOR_LOCKED_CHANNEL
elif isFavorite: channelColor = COLOR_FAVORITE_CHANNEL
elif isRadio: channelColor = COLOR_RADIO_CHANNEL
else: channelColor = COLOR_AVAILABLE_CHANNEL
return self.buildListItem('[COLOR=%s][B]%s|[/COLOR][/B]'%(channelColor,citem["number"]),'[COLOR=%s]%s[/COLOR]'%(labelColor,citem.get("name",'')),items={'citem':citem,'chname':citem["name"],'chnum':'%i'%(citem["number"]),'radio':citem.get('radio',False),'description':LANGUAGE(32169)%(citem["number"],self.server.get('name',self.friendly))})
self.togglechanList(reset=refresh)
with self.toggleSpinner():
listitems = poolit(__buildItem)(channelList)
self.chanList.addItems(listitems)
if focus is None: self.chanList.selectItem(self.setFocusPOS(listitems))
else: self.chanList.selectItem(focus)
self.setFocus(self.chanList)
if channel: self.buildChannelItem(channel)
@contextmanager
def toggleSpinner(self, state=True, allow=True):
if allow:
self.setVisibility(self.spinner,state)
try: yield
finally: self.setVisibility(self.spinner,False)
else: yield
def togglechanList(self, state=True, focus=0, reset=False):
self.log('togglechanList, state = %s, focus = %s, reset = %s'%(state,focus,reset))
with self.toggleSpinner():
if state: # channellist
if reset:
self.setVisibility(self.chanList,False)
self.chanList.reset()
self.setVisibility(self.itemList,False)
self.setVisibility(self.chanList,True)
self.setFocus(self.chanList)
self.chanList.selectItem(focus)
if self.madeChanges:
self.setLabels(self.right_button1,LANGUAGE(32059))#Save
self.setLabels(self.right_button2,LANGUAGE(32060))#Cancel
self.setLabels(self.right_button3,LANGUAGE(32136))#Move
self.setLabels(self.right_button4,LANGUAGE(32061))#Delete
self.setEnableCondition(self.right_button1,'[!String.IsEmpty(Container(5).ListItem(Container(5).Position).Property(chnum))]')
self.setEnableCondition(self.right_button2,'[!String.IsEmpty(Container(5).ListItem(Container(5).Position).Property(chnum))]')
else:
self.setLabels(self.right_button1,LANGUAGE(32062))#Close
self.setLabels(self.right_button2,LANGUAGE(32235))#Preview
self.setLabels(self.right_button3,LANGUAGE(32136))#Move
self.setLabels(self.right_button4,LANGUAGE(32061))#Delete
self.setEnableCondition(self.right_button1,'[!String.IsEmpty(Container(5).ListItem(Container(5).Position).Property(chnum))]')
self.setEnableCondition(self.right_button2,'[!String.IsEmpty(Container(5).ListItem(Container(5).Position).Path) + String.IsEqual(Container(5).ListItem(Container(5).Position).Property(radio),False)]')
self.setFocus(self.right_button1)
self.setEnableCondition(self.right_button3,'[!String.IsEmpty(Container(5).ListItem(Container(5).Position).Path)]')# + Integer.IsLessOrEqual(Container(5).ListItem(Container(5).Position).Property(chnum),CHANNEL_LIMIT)]')
self.setEnableCondition(self.right_button4,'[!String.IsEmpty(Container(5).ListItem(Container(5).Position).Path)]')# + Integer.IsLessOrEqual(Container(5).ListItem(Container(5).Position).Property(chnum),CHANNEL_LIMIT)]')
else: # channelitems
self.itemList.reset()
self.setVisibility(self.chanList,False)
self.setVisibility(self.itemList,True)
self.itemList.selectItem(focus)
self.setFocus(self.itemList)
if self.madeItemchange:
self.setLabels(self.right_button1,LANGUAGE(32240))#Confirm
self.setLabels(self.right_button2,LANGUAGE(32060))#Cancel
self.setEnableCondition(self.right_button1,'[!String.IsEmpty(Container(6).ListItem(Container(6).Position).Label) + !String.IsEmpty(Container(6).ListItem(Container(6).Position).Path)]')
self.setEnableCondition(self.right_button2,'[!String.IsEmpty(Container(6).ListItem(Container(6).Position).Property(chnum))]')
else:
self.setLabels(self.right_button1,LANGUAGE(32062))#Close
self.setLabels(self.right_button2,LANGUAGE(32060))#Cancel
self.setEnableCondition(self.right_button1,'[!String.IsEmpty(Container(6).ListItem(Container(6).Position).Property(chnum))]')
self.setEnableCondition(self.right_button2,'[!String.IsEmpty(Container(6).ListItem(Container(6).Position).Path)]')
self.setLabels(self.right_button3,LANGUAGE(32235))#Preview
self.setLabels(self.right_button4,LANGUAGE(32239))#Clear
self.setEnableCondition(self.right_button3,'[!String.IsEmpty(Container(6).ListItem(Container(6).Position).Path) + String.IsEqual(Container(6).ListItem(Container(6).Position).Property(radio),False)]')
self.setEnableCondition(self.right_button4,'[!String.IsEmpty(Container(6).ListItem(Container(6).Position).Path)]')
def setFocusPOS(self, listitems, chnum=None, ignore=True):
for idx, listitem in enumerate(listitems):
chnumber = int(cleanLabel(listitem.getLabel()).strip('|'))
if ignore and chnumber > CHANNEL_LIMIT: continue
elif chnum is not None and chnum == chnumber: return idx
elif chnum is None and cleanLabel(listitem.getLabel2()): return idx
return 0
def getRuleAbbr(self, citem, myId, optionindex):
value = citem.get('rules',{}).get(str(myId),{}).get('values',{}).get(str(optionindex))
self.log('getRuleAbbr, id = %s, myId = %s, optionindex = %s, optionvalue = %s'%(citem.get('id',-1),myId,optionindex,value))
return value
def getLogoColor(self, citem):
self.log('getLogoColor, id = %s'%(citem.get('id',-1)))
if (citem.get('logo') and citem.get('name')) is None: return 'FFFFFFFF'
elif citem.get('rules',{}).get("1"):
if (self.getRuleAbbr(citem,1,4) or self.resource.isMono(citem['logo'])):
return self.getRuleAbbr(citem,1,3)
return SETTINGS.getSetting('ChannelBug_Color')
def buildChannelItem(self, citem: dict={}, focuskey: str='path'):
self.log('buildChannelItem, id = %s, focuskey = %s'%(citem.get('id'),focuskey))
def __buildItem(key):
key = key.lower()
value = citem.get(key,' ')
if key in ["number","type","logo","id","catchup"]: return # keys to ignore, internal use only.
elif isinstance(value,(list,dict)):
if key == "group" : value = ('|'.join(sorted(set(value))) or LANGUAGE(30127))
elif key == "path" : value = '|'.join(value)
elif key == "rules" : value = '|'.join([rule.name for key, rule in list(self.rule.loadRules([citem]).get(citem['id'],{}).items())])#todo load rule names
elif not isinstance(value,str): value = str(value)
elif not value: value = ' '
return self.buildListItem(LABEL.get(key,' '),value,items={'key':key,'value':value,'citem':citem,'chname':citem["name"],'chnum':'%i'%(citem["number"]),'radio':citem.get('radio',False),'description':DESC.get(key,''),'colorDiffuse':self.getLogoColor(citem)})
self.togglechanList(False)
with self.toggleSpinner():
LABEL = {'name' : LANGUAGE(32092),
'path' : LANGUAGE(32093),
'group' : LANGUAGE(32094),
'rules' : LANGUAGE(32095),
'radio' : LANGUAGE(32091),
'favorite': LANGUAGE(32090)}
DESC = {'name' : LANGUAGE(32085),
'path' : LANGUAGE(32086),
'group' : LANGUAGE(32087),
'rules' : LANGUAGE(32088),
'radio' : LANGUAGE(32084),
'favorite': LANGUAGE(32083)}
listitems = poolit(__buildItem)(list(self.newChannel.keys()))
self.itemList.addItems(listitems)
self.itemList.selectItem([idx for idx, liz in enumerate(listitems) if liz.getProperty('key')== focuskey][0])
self.setFocus(self.itemList)
def itemInput(self, channelListItem=xbmcgui.ListItem()):
def __getName(citem: dict={}, name: str=''):
return DIALOG.inputDialog(message=LANGUAGE(32079),default=name), citem
def __getPath(citem: dict={}, paths: list=[]):
return self.getPaths(citem, paths)
def __getGroups(citem: dict={}, groups: list=[]):
groups = list([_f for _f in groups if _f])
ngroups = sorted([_f for _f in set(SETTINGS.getSetting('User_Groups').split('|') + GROUP_TYPES + groups) if _f])
ngroups.insert(0, '-%s'%(LANGUAGE(30064)))
selects = DIALOG.selectDialog(ngroups,header=LANGUAGE(32081),preselect=findItemsInLST(ngroups,groups),useDetails=False)
if 0 in selects:
SETTINGS.setSetting('User_Groups',DIALOG.inputDialog(LANGUAGE(32044), default=SETTINGS.getSetting('User_Groups')))
return __getGroups(citem, groups)
elif len(ngroups) > 0: groups = [ngroups[idx] for idx in selects]
if not groups: groups = [LANGUAGE(30127)]
return groups, citem
def __getRule(citem: dict={}, rules: dict={}):
return self.getRules(citem, rules)
def __getBool(citem: dict={}, state: bool=False):
return not bool(state), citem
key = channelListItem.getProperty('key')
value = channelListItem.getProperty('value')
citem = loadJSON(channelListItem.getProperty('citem'))
self.log('itemInput, In value = %s, key = %s\ncitem = %s'%(value,key,citem))
KEY_INPUT = {"name" : {'func':__getName , 'kwargs':{'citem':citem, 'name' :citem.get('name','')}},
"path" : {'func':__getPath , 'kwargs':{'citem':citem, 'paths' :citem.get('path',[])}},
"group" : {'func':__getGroups, 'kwargs':{'citem':citem, 'groups':citem.get('group',[])}},
"rules" : {'func':__getRule , 'kwargs':{'citem':citem, 'rules' :self.rule.loadRules([citem],incRez=False).get(citem['id'],{})}},
"radio" : {'func':__getBool , 'kwargs':{'citem':citem, 'state' :citem.get('radio',False)}},
"favorite" : {'func':__getBool , 'kwargs':{'citem':citem, 'state' :citem.get('favorite',False)}}}
action = KEY_INPUT.get(key)
retval, citem = action['func'](*action.get('args',()),**action.get('kwargs',{}))
retval, citem = self.validateInputs(key,retval,citem)
if not retval is None:
self.madeItemchange = True
if key in list(self.newChannel.keys()): citem[key] = retval
self.log('itemInput, Out value = %s, key = %s\ncitem = %s'%(retval,key,citem))
return citem
def getPaths(self, citem: dict={}, paths: list=[]):
select = -1
epaths = paths.copy()
pathLST = list([_f for _f in paths if _f])
lastOPT = None
if not citem.get('radio',False) and isRadio({'path':paths}): citem['radio'] = True #set radio on music paths
if citem.get('radio',False): excLST = [10,12,21,22]
else: excLST = [11,13,21]
while not self.monitor.abortRequested() and not select is None:
with self.toggleSpinner():
npath = None
lizLST = [self.buildListItem('%s|'%(idx+1),path,paths=[path],icon=DUMMY_ICON.format(text=str(idx+1)),items={'citem':citem,'idx':idx+1}) for idx, path in enumerate(pathLST) if path]
lizLST.insert(0,self.buildListItem('[COLOR=white][B]%s[/B][/COLOR]'%(LANGUAGE(32100)),LANGUAGE(33113),icon=ICON,items={'key':'add','citem':citem,'idx':0}))
if len(pathLST) > 0 and epaths != pathLST: lizLST.insert(1,self.buildListItem('[COLOR=white][B]%s[/B][/COLOR]'%(LANGUAGE(32101)),LANGUAGE(33114),icon=ICON,items={'key':'save','citem':citem}))
select = DIALOG.selectDialog(lizLST, header=LANGUAGE(32086), preselect=lastOPT, multi=False)
with self.toggleSpinner():
if not select is None:
key, path = lizLST[select].getProperty('key'), lizLST[select].getPath()
try: lastOPT = int(lizLST[select].getProperty('idx'))
except: lastOPT = None
if key == 'add':
retval = DIALOG.browseSources(heading=LANGUAGE(32080), exclude=excLST, monitor=True)
if not retval is None:
npath, citem = self.validatePaths(retval,citem)
if npath: pathLST.append(npath)
elif key == 'save':
paths = pathLST
break
elif path in pathLST:
retval = DIALOG.yesnoDialog(LANGUAGE(32102), customlabel=LANGUAGE(32103))
if retval in [1,2]: pathLST.pop(pathLST.index(path))
if retval == 2:
with self.toggleSpinner():
npath, citem = self.validatePaths(DIALOG.browseSources(heading=LANGUAGE(32080), default=path, monitor=True, exclude=excLST), citem)
pathLST.append(npath)
self.log('getPaths, paths = %s'%(paths))
return paths, citem
def getRules(self, citem: dict={}, rules: dict={}):
if citem.get('id') is None or len(citem.get('path',[])) == 0: DIALOG.notificationDialog(LANGUAGE(32071))
else:
select = -1
erules = rules.copy()
ruleLST = rules.copy()
lastIDX = None
lastXID = None
while not self.monitor.abortRequested() and not select is None:
with self.toggleSpinner():
nrule = None
crules = self.rule.loadRules([citem],append=True,incRez=False).get(citem['id'],{}) #all rule instances w/ channel rules
arules = [rule for key, rule in list(crules.items()) if not ruleLST.get(key)] #all unused rule instances
lizLST = [self.buildListItem(rule.name,rule.getTitle(),icon=DUMMY_ICON.format(text=str(rule.myId)),items={'myId':rule.myId,'citem':citem,'idx':list(ruleLST.keys()).index(key)+1}) for key, rule in list(ruleLST.items()) if rule.myId]
lizLST.insert(0,self.buildListItem('[COLOR=white][B]%s[/B][/COLOR]'%(LANGUAGE(32173)),"",icon=ICON,items={'key':'add' ,'citem':citem,'idx':0}))
if len(ruleLST) > 0 and erules != ruleLST: lizLST.insert(1,self.buildListItem('[COLOR=white][B]%s[/B][/COLOR]'%(LANGUAGE(32174)),"",icon=ICON,items={'key':'save','citem':citem}))
select = DIALOG.selectDialog(lizLST, header=LANGUAGE(32095), preselect=lastIDX, multi=False)
if not select is None:
key, myId = lizLST[select].getProperty('key'), int(lizLST[select].getProperty('myId') or '-1')
try: lastIDX = int(lizLST[select].getProperty('idx'))
except: lastIDX = None
if key == 'add':
with self.toggleSpinner():
lizLST = [self.buildListItem(rule.name,rule.description,icon=DUMMY_ICON.format(text=str(rule.myId)),items={'idx':idx,'myId':rule.myId,'citem':citem}) for idx, rule in enumerate(arules) if rule.myId]
select = DIALOG.selectDialog(lizLST, header=LANGUAGE(32072), preselect=lastXID, multi=False)
try: lastXID = int(lizLST[select].getProperty('idx'))
except: lastXID = -1
nrule, citem = self.getRule(citem, arules[lastXID])
if not nrule is None: ruleLST.update({str(nrule.myId):nrule})
elif key == 'save':
rules = ruleLST
break
elif ruleLST.get(str(myId)):
retval = DIALOG.yesnoDialog(LANGUAGE(32175), customlabel=LANGUAGE(32176))
if retval in [1,2]: ruleLST.pop(str(myId))
if retval == 2:
nrule, citem = self.getRule(citem, crules.get(str(myId),{}))
if not nrule is None: ruleLST.update({str(nrule.myId):nrule})
# elif not ruleLST.get(str(myId)):
# nrule, citem = self.getRule(citem, crules.get(str(myId),{}))
# if not nrule is None: ruleLST.update({str(nrule.myId):nrule})
self.log('getRules, rules = %s'%(len(rules)))
return self.rule.dumpRules(rules), citem
def getRule(self, citem={}, rule={}):
self.log('getRule, name = %s'%(rule.name))
if rule.exclude and True in list(set([True for p in citem.get('path',[]) if p.endswith('.xsp')])): return DIALOG.notificationDialog(LANGUAGE(32178))
else:
select = -1
while not self.monitor.abortRequested() and not select is None:
with self.toggleSpinner():
lizLST = [self.buildListItem('%s = %s'%(rule.optionLabels[idx],rule.optionValues[idx]),rule.optionDescriptions[idx],DUMMY_ICON.format(text=str(idx+1)),[str(rule.myId)],{'value':optionValue,'idx':idx,'myId':rule.myId,'citem':citem}) for idx, optionValue in enumerate(rule.optionValues)]
select = DIALOG.selectDialog(lizLST, header='%s %s - %s'%(LANGUAGE(32176),rule.myId,rule.name), multi=False)
if not select is None:
try: rule.onAction(int(lizLST[select].getProperty('idx') or "0"))
except Exception as e:
self.log("getRule, onAction failed! %s"%(e), xbmc.LOGERROR)
DIALOG.okDialog(LANGUAGE(32000))
return rule, citem
def setID(self, citem: dict={}) -> dict:
if not citem.get('id') and citem.get('name') and citem.get('path') and citem.get('number'):
citem['id'] = getChannelID(citem['name'], citem['path'], citem['number'])
self.log('setID, id = %s'%(citem['id']))
return citem
def setName(self, path, citem: dict={}) -> dict:
with self.toggleSpinner():
if citem.get('name'): return citem
elif path.strip('/').endswith(('.xml','.xsp')): citem['name'] = XSP().getName(path)
elif path.startswith(tuple(DB_TYPES+WEB_TYPES+VFS_TYPES)): citem['name'] = self.getMontiorList().getLabel()
else: citem['name'] = os.path.basename(os.path.dirname(path)).strip('/')
self.log('setName, id = %s, name = %s'%(citem['id'],citem['name']))
return citem
def setLogo(self, name=None, citem={}, force=False):
name = (name or citem.get('name'))
if name:
if force: logo = ''
else: logo = citem.get('logo')
if not logo or logo in [LOGO,COLOR_LOGO,ICON]:
with self.toggleSpinner():
citem['logo'] = self.resource.getLogo(citem,auto=True)
self.log('setLogo, id = %s, logo = %s, force = %s'%(citem.get('id'),citem.get('logo'),force))
return citem
def validateInputs(self, key, value, citem):
self.log('validateInputs, key = %s, value = %s'%(key,value))
def __validateName(name, citem):
if name and (len(name) > 1 or len(name) < 128):
citem['name'] = validString(name)
self.log('__validateName, name = %s'%(citem['name']))
return citem['name'], self.setLogo(name, citem, force=True)
return None, citem
def __validatePath(paths, citem):
if len(paths) > 0:
name, citem = __validateName(citem.get('name',''),self.setName(paths[0], citem))
self.log('__validatePath, name = %s, paths = %s'%(name,paths))
return paths, citem
return None, citem
def __validateGroup(groups, citem):
return groups, citem #todo check values
def __validateRules(rules, citem):
return rules, citem #todo check values
def __validateBool(state, citem):
if isinstance(state,bool): return state, citem
return None, citem
KEY_VALIDATION = {'name' :__validateName,
'path' :__validatePath,
'group' :__validateGroup,
'rules' :__validateRules,
'radio' :__validateBool,
'favorite':__validateBool}.get(key,None)
try:
with toggleSpinner():
retval, citem = KEY_VALIDATION(value,citem)
if retval is None:
DIALOG.notificationDialog(LANGUAGE(32077)%key.title())
return None , citem
return retval, self.setID(citem)
except Exception as e:
self.log("validateInputs, key = %s no action! %s"%(key,e))
return value, citem
def validatePaths(self, path, citem, spinner=True):
self.log('validatePaths, path = %s'%path)
def __set(path, citem):
citem = self.setName(path, citem)
return path, self.setLogo(citem.get('name'),citem)
def __seek(item, citem, cnt, dia, passed=False):
player = PLAYER()
if player.isPlaying(): return DIALOG.notificationDialog(LANGUAGE(30136))
# todo test seek for support disable via adv. rule if fails.
# todo set seeklock rule if seek == False
liz = xbmcgui.ListItem('Seek Test', path=item.get('file'))
liz.setProperty('startoffset', str(int(item.get('duration')//8)))
infoTag = ListItemInfoTag(liz, 'video')
infoTag.set_resume_point({'ResumeTime':int(item.get('duration')/4),'TotalTime':int(item.get('duration')*60)})
getTime = 0
waitTime = FIFTEEN
player.play(item.get('file'),liz)
while not self.monitor.abortRequested():
waitTime -= 1
self.log('validatePaths _seek, waiting (%s) to seek %s'%(waitTime, item.get('file')))
if self.monitor.waitForAbort(1.0) or waitTime < 1: break
elif not player.isPlaying(): continue
elif ((int(player.getTime()) > getTime) or BUILTIN.getInfoBool('SeekEnabled','Player')):
self.log('validatePaths _seek, found playable and seek-able file %s'%(item.get('file')))
passed = True
break
player.stop()
del player
if not passed:
retval = DIALOG.yesnoDialog(LANGUAGE(30202),customlabel='Try Again (%s)'%(cnt))
if retval == 1: passed = True
elif retval == 2: passed = None
self.log('validatePaths _seek, passed = %s'%(passed))
return passed
def __vfs(path, citem, valid=False):
if isRadio({'path':[path]}) or isMixed_XSP({'path':[path]}): return True #todo check mixed xsp.
with BUILTIN.busy_dialog():
items = self.jsonRPC.walkFileDirectory(path, 'music' if isRadio({'path':[path]}) else 'video', depth=5, retItem=True)
if len(items) > 0:
cnt = 3
msg = '%s %s, %s..\n%s'%(LANGUAGE(32098),'Path',LANGUAGE(32099),'%s...'%(str(path)))
dia = DIALOG.progressDialog(message=msg)
for idx, dir in enumerate(items):
if self.monitor.waitForAbort(0.0001): break
elif cnt <= 3 and cnt > 0:
item = random.choice(items.get(dir,[]))
msg = '%s %s...\n%s\n%s'%(LANGUAGE(32098),'Duration','%s...'%(dir),'%s...'%(item.get('file','')))
dia = DIALOG.progressDialog(int((idx*100)//len(items)), control=dia, message=msg)
item.update({'duration':self.jsonRPC.getDuration(item.get('file'), item, accurate=bool(SETTINGS.getSettingInt('Duration_Type')))})
if item.get('duration',0) == 0: continue
msg = '%s %s...\n%s\n%s'%(LANGUAGE(32098),'Seeking','%s...'%(str(dir)),'%s...'%(str(item.get('file',''))))
dia = DIALOG.progressDialog(int((idx*100)//len(items)), control=dia, message=msg)
valid = __seek(item, citem, cnt, dia)
if valid is None: cnt -=1
else: break
DIALOG.progressDialog(100,control=dia)
return valid
with self.toggleSpinner(allow=spinner):
if __vfs(path, citem): return __set(path, citem)
DIALOG.notificationDialog(LANGUAGE(32030))
return None, citem
def openEditor(self, path):
self.log('openEditor, path = %s'%(path))
if '|' in path:
path = path.split('|')
path = path[0]#prompt user to select:
media = 'video' if 'video' in path else 'music'
if '.xsp' in path: return self.openEditor(path,media)
elif '.xml' in path: return self.openNode(path,media)
def previewChannel(self, citem, retCntrl=None):
def __buildItem(fileList, fitem):
return self.buildListItem('%s| %s'%(fileList.index(fitem),fitem.get('showlabel',fitem.get('label'))), fitem.get('file') ,icon=(getThumb(fitem,opt=EPG_ARTWORK) or {0:FANART,1:COLOR_LOGO}[EPG_ARTWORK]))
def __fileList(citem):
from builder import Builder
builder = Builder()
fileList = []
start_time = 0
end_time = 0
if PROPERTIES.isInterruptActivity(): PROPERTIES.setInterruptActivity(False)
while not self.monitor.abortRequested() and PROPERTIES.isRunning('OVERLAY_MANAGER'):
if self.monitor.waitForAbort(1.0): break
elif not PROPERTIES.isRunning('builder.build') and not PROPERTIES.isInterruptActivity():
DIALOG.notificationDialog('%s: [B]%s[/B]\n%s'%(LANGUAGE(32236),citem.get('name','Untitled'),LANGUAGE(32140)))
tmpcitem = citem.copy()
tmpcitem['id'] = getChannelID(citem['name'], citem['path'], random.random())
start_time = time.time()
fileList = builder.build([tmpcitem],preview=True)
end_time = time.time()
if not fileList or isinstance(fileList,list): break
del builder
if not PROPERTIES.isInterruptActivity(): PROPERTIES.setInterruptActivity(True)
return fileList, round(abs(end_time-start_time),2)
if not PROPERTIES.isRunning('previewChannel'):
with PROPERTIES.chkRunning('previewChannel'), self.toggleSpinner():
listitems = []
fileList, run_time = __fileList(citem)
if not isinstance(fileList,list) and not fileList: DIALOG.notificationDialog('%s or\n%s'%(LANGUAGE(32030),LANGUAGE(32000)))
else:
listitems = poolit(__buildItem)(*(fileList,fileList))
self.log('previewChannel, id = %s, listitems = %s'%(citem['id'],len(listitems)))
if len(listitems) > 0: return DIALOG.selectDialog(listitems, header='%s: [B]%s[/B] - Build Time: [B]%ss[/B]'%(LANGUAGE(32235),citem.get('name','Untitled'),f"{run_time:.2f}"))
if retCntrl: self.setFocusId(retCntrl)
def getMontiorList(self):
self.log('getMontiorList')
try:
with self.toggleSpinner():
labels = sorted(set([cleanLabel(value).title() for info in DIALOG.getInfoMonitor() for key, value in list(info.items()) if value not in ['','..'] and key not in ['path','logo']]))
itemLST = [self.buildListItem(label,icon=ICON) for label in labels]
if len(itemLST) == 0: raise Exception()
itemSEL = DIALOG.selectDialog(itemLST,LANGUAGE(32078)%('Name'),useDetails=True,multi=False)
if itemSEL is not None: return itemLST[itemSEL]
else: raise Exception()
except: return xbmcgui.ListItem(LANGUAGE(32079))
def clearChannel(self, item, prompt=True, open=False):
self.log('clearChannel, channelPOS = %s'%(item['number'] - 1))
with self.toggleSpinner():
if item['number'] > CHANNEL_LIMIT: return DIALOG.notificationDialog(LANGUAGE(32238))
elif prompt and not DIALOG.yesnoDialog(LANGUAGE(32073)): return item
self.madeItemchange = True
nitem = self.newChannel.copy()
nitem['number'] = item['number'] #preserve channel number
self.saveChannelItems(nitem, open)
def moveChannel(self, citem, channelPOS):
self.log('moveChannel, channelPOS = %s'%(channelPOS))
if citem['number'] > CHANNEL_LIMIT: return DIALOG.notificationDialog(LANGUAGE(32064))
retval = DIALOG.inputDialog(LANGUAGE(32137), key=xbmcgui.INPUT_NUMERIC, opt=citem['number'])
if retval:
retval = int(retval)
if (retval > 0 and retval < CHANNEL_LIMIT) and retval != channelPOS + 1:
if DIALOG.yesnoDialog('%s %s %s from [B]%s[/B] to [B]%s[/B]?'%(LANGUAGE(32136),citem['name'],LANGUAGE(32023),citem['number'],retval)):
with self.toggleSpinner():
if retval in [channel.get('number') for channel in self.newChannels if channel.get('path')]: DIALOG.notificationDialog(LANGUAGE(32138))
else:
self.madeItemchange = True
nitem = self.newChannel.copy()
nitem['number'] = channelPOS + 1
self.newChannels[channelPOS] = nitem
citem['number'] = retval
self.saveChannelItems(citem)
def switchLogo(self, channelData, channelPOS):
def __cleanLogo(chlogo):
#todo convert resource from vfs to fs
# return chlogo.replace('resource://','special://home/addons/')
# resource = path.replace('/resources','').replace(,)
# resource://resource.images.studios.white/Amazon.png
return chlogo
def __select():
def _build(logos, logo):
label = os.path.splitext(os.path.basename(logo))[0]
return self.buildListItem('%s| %s'%(logos.index(logo)+1, label.upper() if len(label) <= 4 else label.title()), unquoteString(logo), logo, [logo])
DIALOG.notificationDialog(LANGUAGE(32140))
with self.toggleSpinner():
chname = channelData.get('name')
logos = self.resource.selectLogo(channelData)
listitems = poolit(_build)(*(logos,logos))
select = DIALOG.selectDialog(listitems,'%s (%s)'%(LANGUAGE(32066).split('[CR]')[1],chname),useDetails=True,multi=False)
if select is not None: return listitems[select].getPath()
def __browse():
with self.toggleSpinner():
chname = channelData.get('name')
retval = DIALOG.browseSources(type=1,heading='%s (%s)'%(LANGUAGE(32066).split('[CR]')[0],chname), default=channelData.get('icon',''), shares='files', mask=xbmc.getSupportedMedia('picture'), exclude=[12,13,14,15,16,17,21,22])
if FileAccess.copy(__cleanLogo(retval), os.path.join(LOGO_LOC,'%s%s'%(chname,retval[-4:])).replace('\\','/')):
if FileAccess.exists(os.path.join(LOGO_LOC,'%s%s'%(chname,retval[-4:])).replace('\\','/')):
return os.path.join(LOGO_LOC,'%s%s'%(chname,retval[-4:])).replace('\\','/')
return retval
def __match():
with self.toggleSpinner():
return self.resource.getLogo(channelData,auto=True)
if not channelData.get('name'): return DIALOG.notificationDialog(LANGUAGE(32065))
chlogo = None
retval = DIALOG.yesnoDialog(LANGUAGE(32066), heading ='%s - %s'%(ADDON_NAME,LANGUAGE(32172)),
nolabel = LANGUAGE(32067), #Select
yeslabel = LANGUAGE(32068), #Browse
customlabel = LANGUAGE(30022)) #Auto
if retval == 0: chlogo = __select()
elif retval == 1: chlogo = __browse()
elif retval == 2: chlogo = __match()
else: DIALOG.notificationDialog(LANGUAGE(32070))
if chlogo and chlogo != LOGO:
self.log('switchLogo, chname = %s, chlogo = %s'%(channelData.get('name'),chlogo))
DIALOG.notificationDialog(LANGUAGE(32139))
self.madeChanges = True
channelData['logo'] = chlogo
self.newChannels[channelPOS] = channelData
self.fillChanList(self.newChannels,refresh=True,focus=channelPOS)
def isVisible(self, cntrl):
try:
if isinstance(cntrl, int): cntrl = self.getControl(cntrl)
state = cntrl.isVisible()
except: state = self.cntrlStates.get(cntrl.getId(),False)
self.log('isVisible, cntrl = %s, state = %s'%(cntrl.getId(),state))
return state
def setVisibility(self, cntrl, state):
try:
if isinstance(cntrl, int): cntrl = self.getControl(cntrl)
cntrl.setVisible(state)
self.cntrlStates[cntrl.getId()] = state
self.log('setVisibility, cntrl = ' + str(cntrl.getId()) + ', state = ' + str(state))
except Exception as e: self.log("setVisibility, failed! %s"%(e), xbmc.LOGERROR)
def getLabels(self, cntrl):
try:
if isinstance(cntrl, int): cntrl = self.getControl(cntrl)
return cntrl.getLabel(), cntrl.getLabel2()
except Exception as e: return '',''
def setImages(self, cntrl, image='NA.png'):
try:
if isinstance(cntrl, int): cntrl = self.getControl(cntrl)
cntrl.setImage(image)
except Exception as e: self.log("setImages, failed! %s"%(e), xbmc.LOGERROR)
def setLabels(self, cntrl, label='', label2=''):
try:
if isinstance(cntrl, int): cntrl = self.getControl(cntrl)
cntrl.setLabel(str(label), str(label2))
self.setVisibility(cntrl,(len(label) > 0 or len(label2) > 0))
except Exception as e: self.log("setLabels, failed! %s"%(e), xbmc.LOGERROR)
def setEnableCondition(self, cntrl, condition):
try:
if isinstance(cntrl, int): cntrl = self.getControl(cntrl)
cntrl.setEnableCondition(condition)
except Exception as e: self.log("setEnableCondition, failed! %s"%(e), xbmc.LOGERROR)
def resetPagination(self, citem):
if isinstance(citem, list): [self.resetPagination(item) for item in citem]
else:
with self.toggleSpinner():
self.log('resetPagination, citem = %s'%(citem))
[self.jsonRPC.resetPagination(citem.get('id'), path) for path in citem.get('path',[]) if citem.get('id')]
def saveChannelItems(self, citem: dict={}, open=False):
self.log('saveChannelItems [%s], open = %s'%(citem.get('id'),open))
if self.madeItemchange:
self.madeChanges = True
self.newChannels[citem['number'] - 1] = citem
self.fillChanList(self.newChannels,True,(citem['number'] - 1),citem if open else None)
self.madeItemchange = False
return citem
def closeChannel(self, citem, focus=0, open=False):
self.log('closeChannel')
if self.madeItemchange:
if DIALOG.yesnoDialog(LANGUAGE(32243)): return self.saveChannelItems(citem, open)
self.togglechanList(focus=focus)
def saveChanges(self):
self.log("saveChanges")
def __validateChannels(channelList):
def _validate(citem):
if citem.get('name') and citem.get('path'):
if citem['number'] <= CHANNEL_LIMIT: citem['type'] = "Custom"
return self.setID(citem)
channelList = setDictLST(self.channels.sortChannels([_f for _f in [_validate(channel) for channel in channelList] if _f]))
self.log('__validateChannels, channelList = %s'%(len(channelList)))
return channelList
if self.madeChanges:
if DIALOG.yesnoDialog(LANGUAGE(32076)):
with self.toggleSpinner():
channels = __validateChannels(self.newChannels)
changes = diffLSTDICT(__validateChannels(self.channelList),channels)
added = [citem.get('id') for citem in changes.get('added',[])]
removed = [citem.get('id') for citem in changes.get('removed',[])]
ids = added+removed
citems = changes.get('added',[])+changes.get('removed',[])
self.log("saveChanges, channels = %s, added = %s, removed = %s"%(len(channels), len(added), len(removed)))
if self.server:
payload = {'uuid':SETTINGS.getMYUUID(),'name':self.friendly,'payload':channels}
requestURL('http://%s/%s'%(self.server.get('host'),CHANNELFLE), data=payload, header=HEADER, json_data=True)
else:
self.channels.setChannels(channels) #save changes
self.resetPagination(citems) #clear auto pagination cache
SETTINGS.setResetChannels(ids) #clear guidedata
PROPERTIES.setUpdateChannels(ids) #update channel meta.
self.madeChanges = False
self.closeManager()
def closeManager(self):
self.log('closeManager')
if self.madeChanges: self.saveChanges()
else: self.close()
def __exit__(self):
self.log('__exit__')
del self.resource
del self.jsonRPC
del self.rule
del self.channels
def getFocusItems(self, controlId=None):
focusItems = dict()
if controlId in [5,6,7,9001,9002,9003,9004]:
label, label2 = self.getLabels(controlId)
try: snum = int(cleanLabel(label.replace("|",'')))
except: snum = 1
if self.isVisible(self.chanList):
cntrl = controlId
sitem = self.chanList.getSelectedItem()
citem = loadJSON(sitem.getProperty('citem'))
chnum = (citem.get('number') or snum)
chpos = self.chanList.getSelectedPosition()
itpos = -1
elif self.isVisible(self.itemList):
cntrl = controlId
sitem = self.itemList.getSelectedItem()
citem = loadJSON(sitem.getProperty('citem'))
chnum = (citem.get('number') or snum)
chpos = chnum - 1
itpos = self.itemList.getSelectedPosition()
else:
sitem = xbmcgui.ListItem()
cntrl = (self.focusItems.get('cntrl') or controlId)
citem = (self.focusItems.get('citem') or {})
chnum = (self.focusItems.get('number') or snum)
chpos = (self.focusItems.get('chpos') or chnum - 1)
itpos = (self.focusItems.get('itpos') or -1)
self.focusItems.update({'retCntrl':cntrl,'label':label,'label2':label2,'number':chnum,'chpos':chpos,'itpos':itpos,'item':sitem,'citem':citem})
self.log('getFocusItems, controlId = %s, focusItems = %s'%(controlId,self.focusItems))
return self.focusItems
def onAction(self, act):
actionId = act.getId()
if (time.time() - self.lastActionTime) < .5 and actionId not in ACTION_PREVIOUS_MENU: action = ACTION_INVALID # during certain times we just want to discard all input
else:
if actionId in ACTION_PREVIOUS_MENU:
self.log('onAction: actionId = %s'%(actionId))
if xbmcgui.getCurrentWindowDialogId() == "13001": BUILTIN.executebuiltin("Action(Back)")
elif self.isVisible(self.itemList): self.closeChannel(self.getFocusItems().get('citem'),self.getFocusItems().get('position'))
elif self.isVisible(self.chanList): self.closeManager()
def onFocus(self, controlId):
self.log('onFocus: controlId = %s'%(controlId))
def onClick(self, controlId):
focusItems = self.getFocusItems(controlId)
focusID = focusItems.get('retCntrl')
focusLabel = focusItems.get('label')
focusNumber = focusItems.get('number',0)
focusCitem = focusItems.get('citem')
focusPOS = focusItems.get('chpos',0)
autoTuned = focusNumber > CHANNEL_LIMIT
self.log('onClick: controlId = %s\nitems = %s'%(controlId,focusItems))
if controlId == 0: self.closeManager()
elif controlId == 5: self.buildChannelItem(focusCitem) #item list
elif controlId == 6:
if self.lockAutotune and autoTuned: DIALOG.notificationDialog(LANGUAGE(32064))
else: self.buildChannelItem(self.itemInput(focusItems.get('item')),focusItems.get('item').getProperty('key'))
elif controlId == 10: #logo button
if self.lockAutotune and autoTuned: DIALOG.notificationDialog(LANGUAGE(32064))
else: self.switchLogo(focusCitem,focusPOS)
elif controlId in [9001,9002,9003,9004]: #side buttons
if focusLabel == LANGUAGE(32059): self.saveChanges() #Save
elif focusLabel == LANGUAGE(32061): self.clearChannel(focusCitem) #Delete
elif focusLabel == LANGUAGE(32239): self.clearChannel(focusCitem,open=True)#Clear
elif focusLabel == LANGUAGE(32136): self.moveChannel(focusCitem,focusPOS) #Move
elif focusLabel == LANGUAGE(32062): #Close
if self.isVisible(self.itemList): self.closeChannel(focusCitem,focus=focusPOS)
elif self.isVisible(self.chanList): self.closeManager()
elif focusLabel == LANGUAGE(32060): #Cancel
if self.isVisible(self.itemList): self.closeChannel(focusCitem)
elif self.isVisible(self.chanList): self.closeManager()
elif focusLabel == LANGUAGE(32240): #Confirm
if self.isVisible(self.itemList): self.saveChannelItems(focusCitem)
elif self.isVisible(self.chanList): self.saveChanges()
elif focusLabel == LANGUAGE(32235): #Preview
if self.isVisible(self.itemList) and self.madeItemchange: self.closeChannel(focusCitem, open=True)
self.previewChannel(focusCitem, focusID)

View File

@@ -0,0 +1,211 @@
# 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 <http://www.gnu.org/licenses/>.
#
# -*- coding: utf-8 -*-
from globals import *
from server import Discovery
class Service:
from jsonrpc import JSONRPC
player = PLAYER()
monitor = MONITOR()
jsonRPC = JSONRPC()
def _interrupt(self) -> bool:
return PROPERTIES.isPendingInterrupt()
def _suspend(self) -> bool:
return PROPERTIES.isPendingSuspend()
class Multiroom:
def __init__(self, sysARG=sys.argv, service=None):
self.log('__init__, sysARG = %s'%(sysARG))
if service is None: service = Service()
self.service = service
self.jsonRPC = service.jsonRPC
self.cache = service.jsonRPC.cache
self.sysARG = sysARG
self.uuid = SETTINGS.getMYUUID()
self.friendly = SETTINGS.getFriendlyName()
self.remoteHost = PROPERTIES.getRemoteHost()
def log(self, msg, level=xbmc.LOGDEBUG):
return log('%s: %s'%(self.__class__.__name__,msg),level)
@cacheit(checksum=PROPERTIES.getInstanceID(), expiration=datetime.timedelta(minutes=FIFTEEN))
def _getStatus(self):
return self.jsonRPC.getSettingValue("services.zeroconf",default=False)
def _chkDiscovery(self):
self.log('_chkDiscovery')
Discovery(service=self.service, multiroom=self)
def chkServers(self, servers={}):
def __chkSettings(settings):
[hasAddon(id,install=True,enable=True) for k,addons in list(settings.items()) for id in addons if id.startswith(('resource','plugin'))]
if isinstance(servers,bool): servers = {} #temp fix remove after a by next build
if not servers: servers = self.getDiscovery()
PROPERTIES.setServers(len(servers) > 0)
for server in list(servers.values()):
online = server.get('online',False)
response = self.getRemote(server.get('remotes',{}).get('bonjour'))
if response: server['online'] = True
else: server['online'] = False
if server.get('enabled',False):
if online != server.get('online',False): DIALOG.notificationDialog('%s: %s'%(server.get('name'),LANGUAGE(32211)%({True:'green',False:'red'}[server.get('online',False)],{True:LANGUAGE(32158),False:LANGUAGE(32253)}[server.get('online',False)])))
__chkSettings(loadJSON(server.get('settings')))
SETTINGS.setSetting('Select_server','|'.join([LANGUAGE(32211)%({True:'green',False:'red'}[server.get('online',False)],server.get('name')) for server in self.getEnabled(servers)]))
self.log('chkServers, servers = %s'%(len(servers)))
self.setDiscovery(servers)
return servers
def getDiscovery(self):
servers = getJSON(SERVER_LOC).get('servers',{})
if isinstance(servers,bool): servers = {} #temp fix remove after a by next build
return servers
def setDiscovery(self, servers={}):
return setJSON(SERVER_LOC,{"servers":servers})
def getEnabled(self, servers={}):
if not servers: servers = self.getDiscovery()
enabled = [server for server in list(servers.values()) if server.get('enabled',False)]
PROPERTIES.setEnabledServers(len(enabled) > 0)
return enabled
@cacheit(expiration=datetime.timedelta(minutes=FIFTEEN), json_data=True)
def getRemote(self, remote):
self.log("getRemote, remote = %s"%(remote))
cacheName = 'getRemote.%s'%(remote)
return requestURL(remote, header={'Accept':'application/json'}, json_data=True, cache=self.cache, checksum=self.uuid, life=datetime.timedelta(days=MAX_GUIDEDAYS))
def addServer(self, payload={}):
self.log('addServer, name = %s'%(payload.get('name')))
if payload and payload.get('name') and payload.get('host'):
payload['online'] = True
servers = self.getDiscovery()
server = servers.get(payload.get('name'),{})
if not server:
payload['enabled'] = not bool(SETTINGS.getSettingBool('Debug_Enable')) #set enabled by default when not debugging.
self.log('addServer, adding server = %s'%(payload))
DIALOG.notificationDialog('%s: %s'%(LANGUAGE(32047),payload.get('name')))
servers[payload['name']] = payload
else:
payload['enabled'] = server.get('enabled',False)
if payload.get('md5',server.get('md5')) != server.get('md5'):
self.log('addServer, updating server = %s'%(server))
servers.update({payload['name']:payload})
if self.setDiscovery(self.chkServers(servers)):
instancePath = SETTINGS.hasPVRInstance(server.get('name'))
if payload.get('enabled',False) and not instancePath: changed = SETTINGS.setPVRRemote(payload.get('host'),payload.get('name'))
elif not payload.get('enabled',False) and instancePath: changed = FileAccess.delete(instancePath)
else: changed = False
if changed: PROPERTIES.setPropTimer('chkPVRRefresh')
self.log('addServer, payload changed, chkPVRRefresh = %s'%(changed))
return True
def delServer(self, servers={}):
self.log('delServer')
def __build(idx, payload):
return LISTITEMS.buildMenuListItem(payload.get('name'),'%s - %s: Channels (%s)'%(LANGUAGE(32211)%({True:'green',False:'red'}[payload.get('online',False)],{True:LANGUAGE(32158),False:LANGUAGE(32253)}[payload.get('online',False)]),payload.get('host'),len(payload.get('channels',[]))),icon=DUMMY_ICON.format(text=str(idx+1)),url=dumpJSON(payload))
with BUILTIN.busy_dialog():
if not servers: servers = self.getDiscovery()
lizlst = [__build(idx, server) for idx, server in enumerate(list(servers.values()))]
selects = DIALOG.selectDialog(lizlst,LANGUAGE(32183))
if not selects is None:
[servers.pop(liz.getLabel()) for idx, liz in enumerate(lizlst) if idx in selects]
if self.setDiscovery(self.chkServers(servers)):
return DIALOG.notificationDialog(LANGUAGE(30046))
def selServer(self):
self.log('selServer')
def __build(idx, payload):
return LISTITEMS.buildMenuListItem(payload.get('name'),'%s - %s: Channels (%s)'%(LANGUAGE(32211)%({True:'green',False:'red'}[payload.get('online',False)],{True:LANGUAGE(32158),False:LANGUAGE(32253)}[payload.get('online',False)]),payload.get('host'),len(payload.get('channels',[]))),icon=DUMMY_ICON.format(text=str(idx+1)),url=dumpJSON(payload))
with BUILTIN.busy_dialog():
servers = self.getDiscovery()
lizlst = [__build(idx, server) for idx, server in enumerate(list(servers.values()))]
if len(lizlst) > 0: lizlst.insert(0,LISTITEMS.buildMenuListItem('[COLOR=white][B]- %s[/B][/COLOR]'%(LANGUAGE(30046)),LANGUAGE(33046)))
else: return
selects = DIALOG.selectDialog(lizlst,LANGUAGE(30130),preselect=[idx for idx, listitem in enumerate(lizlst) if loadJSON(listitem.getPath()).get('enabled',False)])
if not selects is None:
if 0 in selects: return self.delServer(servers)
else:
for idx, liz in enumerate(lizlst):
if idx == 0: continue
instancePath = SETTINGS.hasPVRInstance(liz.getLabel())
if idx in selects:
if not servers[liz.getLabel()].get('enabled',False):
DIALOG.notificationDialog(LANGUAGE(30099)%(liz.getLabel()))
servers[liz.getLabel()]['enabled'] = True
if not instancePath:
if SETTINGS.setPVRRemote(servers[liz.getLabel()].get('host'),liz.getLabel()): PROPERTIES.setPropTimer('chkPVRRefresh')
else:
if servers[liz.getLabel()].get('enabled',False):
DIALOG.notificationDialog(LANGUAGE(30100)%(liz.getLabel()))
servers[liz.getLabel()]['enabled'] = False
if instancePath: FileAccess.delete(instancePath)
with BUILTIN.busy_dialog():
return self.setDiscovery(self.chkServers(servers))
def enableZeroConf(self):
self.log('enableZeroConf')
if SETTINGS.getSetting('ZeroConf_Status') == '[COLOR=red][B]%s[/B][/COLOR]'%(LANGUAGE(32253)):
if BUILTIN.getInfoLabel('Platform.Windows','System'):
BUILTIN.executescript('special://home/addons/%s/resources/lib/utilities.py, Show_ZeroConf_QR'%(ADDON_ID))
if DIALOG.yesnoDialog(message=LANGUAGE(30129)):
with PROPERTIES.interruptActivity():
if self.jsonRPC.setSettingValue("services.zeroconf",True,queue=False):
DIALOG.notificationDialog(LANGUAGE(32219)%(LANGUAGE(30035)))
PROPERTIES.setEpochTimer('chkKodiSettings')
else: DIALOG.notificationDialog(LANGUAGE(32219)%(LANGUAGE(30034)))
def run(self):
try: param = self.sysARG[1]
except: param = None
if param == 'Enable_ZeroConf':
ctl = (5,1)
self.enableZeroConf()
elif param == 'Select_Server':
ctl = (5,11)
self.selServer()
elif param == 'Remove_server':
ctl = (5,12)
return SETTINGS.openSettings(ctl)
if __name__ == '__main__': timerit(Multiroom(sys.argv).run)(0.1)

View File

@@ -0,0 +1,433 @@
# Copyright (C) 2025 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 <http://www.gnu.org/licenses/>.
# https://github.com/xbmc/xbmc/blob/master/xbmc/input/actions/ActionIDs.h
# https://github.com/xbmc/xbmc/blob/master/xbmc/input/Key.h
# -*- coding: utf-8 -*-
from globals import *
from resources import Resources
WH, WIN = BUILTIN.getResolution()
class Busy(xbmcgui.WindowXMLDialog):
def __init__(self, *args, **kwargs):
xbmcgui.WindowXMLDialog.__init__(self, *args, **kwargs)
def onAction(self, act):
actionId = act.getId()
log('Busy: onAction: actionId = %s'%(actionId))
if actionId in ACTION_PREVIOUS_MENU: self.close()
class Background(xbmcgui.WindowXMLDialog):
def __init__(self, *args, **kwargs):
xbmcgui.WindowXMLDialog.__init__(self, *args, **kwargs)
self.player = kwargs.get('player', None)
self.sysInfo = kwargs.get('sysInfo', self.player.sysInfo)
self.citem = self.sysInfo.get('citem',{})
self.fitem = self.sysInfo.get('fitem',{})
self.nitem = self.player.jsonRPC.getNextItem(self.citem,self.sysInfo.get('nitem'))
def log(self, msg, level=xbmc.LOGDEBUG):
return log('%s: %s'%(self.__class__.__name__,msg),level)
def onInit(self):
try:
self.log('onInit, citem = %s\nfitem = %ss\nnitem = %s'%(self.citem,self.fitem,self.nitem))
logo = (self.citem.get('logo') or BUILTIN.getInfoLabel('Art(icon)','Player') or LOGO)
land = (getThumb(self.nitem) or COLOR_FANART)
chname = (self.citem.get('name') or BUILTIN.getInfoLabel('ChannelName','VideoPlayer'))
nowTitle = (self.fitem.get('label') or BUILTIN.getInfoLabel('Title','VideoPlayer'))
nextTitle = (self.nitem.get('showlabel') or BUILTIN.getInfoLabel('NextTitle','VideoPlayer') or chname)
try: nextTime = epochTime(self.nitem['start']).strftime('%I:%M%p')
except Exception as e:
self.log("__init__, nextTime failed! %s\nstart = %s"%(e,self.nitem.get('start')), xbmc.LOGERROR)
nextTime = BUILTIN.getInfoLabel('NextStartTime','VideoPlayer')
onNow = '%s on %s'%(nowTitle,chname) if chname not in validString(nowTitle) else nowTitle
onNext = '@ %s: %s'%(nextTime,nextTitle)
window_w, window_h = WH # window_h, window_w = (self.getHeight(), self.getWidth())
onNextX, onNextY = abs(int(window_w // 9)), abs(int(window_h // 16) - window_h) - 356 #auto
self.getControl(40001).setPosition(onNextX, onNextY)
self.getControl(40001).setVisibleCondition('[Player.Playing + !Window.IsVisible(fullscreeninfo) + Window.IsVisible(fullscreenvideo)]')
self.getControl(40001).setAnimations([('WindowOpen' , 'effect=zoom start=80 end=100 center=%s,%s delay=160 tween=back time=240 reversible=false'%(onNextX, onNextY)),
('WindowOpen' , 'effect=fade start=0 end=100 delay=160 time=240 reversible=false'),
('WindowClose', 'effect=zoom start=100 end=80 center=%s,%s delay=160 tween=back time=240 reversible=false'%(onNextX, onNextY)),
('WindowClose', 'effect=fade start=100 end=0 time=240 reversible=false'),
('Visible' , 'effect=zoom start=80 end=100 center=%s,%s delay=160 tween=back time=240 reversible=false'%(onNextX, onNextY)),
('Visible' , 'effect=fade end=100 time=240 reversible=false')])
self.getControl(40002).setImage(COLOR_LOGO if logo.endswith('wlogo.png') else logo)
self.getControl(40003).setText('%s %s[CR]%s [B]%s[/B]'%(LANGUAGE(32104),onNow,LANGUAGE(32116),onNext))
self.getControl(40004).setImage(land)
except Exception as e:
self.log("onInit, failed! %s"%(e), xbmc.LOGERROR)
self.close()
class Restart(xbmcgui.WindowXMLDialog):
closing = False
def __init__(self, *args, **kwargs):
xbmcgui.WindowXMLDialog.__init__(self, *args, **kwargs)
self.player = kwargs.get('player', None)
self.sysInfo = kwargs.get('sysInfo', self.player.sysInfo)
self.monitor = self.player.service.monitor
if bool(self.player.restartPercentage) and self.sysInfo.get('fitem'):
progress = self.player.getPlayerProgress()
self.log("__init__, restartPercentage = %s, progress = %s"%(self.player.restartPercentage, progress))
if (progress >= self.player.restartPercentage and progress < self.player.maxProgress) and not isFiller(self.sysInfo.get('fitem',{})):
self.doModal()
def log(self, msg, level=xbmc.LOGDEBUG):
return log('%s: %s'%(self.__class__.__name__,msg),level)
def _isVisible(self, control):
try: return control.isVisible()
except: return (BUILTIN.getInfoBool('Playing','Player') and not bool(BUILTIN.getInfoBool('IsVisible(fullscreeninfo)','Window')) | BUILTIN.getInfoBool('IsVisible(fullscreenvideo)','Window'))
def onInit(self):
self.log("onInit")
try:
prog = 0
wait = OSD_TIMER*2
tot = wait
control = self.getControl(40000)
control.setVisibleCondition('[Player.Playing + !Window.IsVisible(fullscreeninfo) + Window.IsVisible(fullscreenvideo)]')
xpos = control.getX()
while not self.monitor.abortRequested():
if (self.monitor.waitForAbort(0.5) or self._isVisible(control) or self.closing): break
while not self.monitor.abortRequested():
if (self.monitor.waitForAbort(0.5) or wait < 0 or self.closing or not self.player.isPlaying()): break
else:
prog = int((abs(wait-tot)*100)//tot)
if prog > 0: control.setAnimations([('Conditional', 'effect=zoom start=%s,100 end=%s,100 time=1000 center=%s,100 condition=True'%((prog-20),(prog),xpos))])
wait -= 1
control.setAnimations([('Conditional', 'effect=fade start=%s end=0 time=240 delay=0.240 condition=True'%(prog))])
control.setVisible(False)
self.setFocusId(40001)
except Exception as e: self.log("onInit, failed! %s\ncitem = %s"%(e,self.sysInfo), xbmc.LOGERROR)
self.log("onInit, closing")
self.close()
def onAction(self, act):
actionId = act.getId()
self.log('onAction: actionId = %s'%(actionId))
self.closing = True
if actionId in ACTION_SELECT_ITEM and self.getFocusId(40001):
if self.sysInfo.get('isPlaylist',False): self.player.seekTime(0)
elif self.sysInfo.get('fitem'):
with BUILTIN.busy_dialog():
liz = LISTITEMS.buildItemListItem(self.sysInfo.get('fitem',{}))
liz.setProperty('sysInfo',encodeString(dumpJSON(self.sysInfo)))
self.player.stop()
timerit(self.player.play)(1.0,[self.sysInfo.get('fitem',{}).get('catchup-id'),liz])
else: DIALOG.notificationDialog(LANGUAGE(30154))
elif actionId == ACTION_MOVE_UP: timerit(BUILTIN.executebuiltin)(0.1,['AlarmClock(up,Action(up),.5,true,false)'])
elif actionId == ACTION_MOVE_DOWN: timerit(BUILTIN.executebuiltin)(0.1,['AlarmClock(down,Action(down),.5,true,false)'])
elif actionId in ACTION_PREVIOUS_MENU: timerit(BUILTIN.executebuiltin)(0.1,['AlarmClock(back,Action(back),.5,true,false)'])
def onClose(self):
self.log("onClose")
self.closing = True
class Overlay():
channelBug = None
vignette = None
controlManager = dict()
def __init__(self, *args, **kwargs):
self.log("__init__")
self.player = kwargs.get('player', None)
self.sysInfo = kwargs.get('sysInfo', self.player.sysInfo)
self.service = self.player.service
self.jsonRPC = self.player.jsonRPC
self.runActions = self.player.runActions
self.resources = Resources(service=self.service)
self.window = xbmcgui.Window(12005)
self.window_w, self.window_h = WH
#vignette rules
self.enableVignette = False
self.defaultView = self.jsonRPC.getViewMode()
self.vinView = self.defaultView
self.vinImage = ''
#channelBug rules
self.enableChannelBug = SETTINGS.getSettingBool('Enable_ChannelBug')
self.forceBugDiffuse = SETTINGS.getSettingBool('Force_Diffuse')
self.channelBugColor = '0x%s'%((SETTINGS.getSetting('ChannelBug_Color') or 'FFFFFFFF'))
self.channelBugFade = SETTINGS.getSettingInt('ChannelBug_Transparency')
try: self.channelBugX, self.channelBugY = eval(SETTINGS.getSetting("Channel_Bug_Position_XY")) #user
except: self.channelBugX, self.channelBugY = abs(int(self.window_w // 9) - self.window_w) - 128, abs(int(self.window_h // 16) - self.window_h) - 128 #auto
def log(self, msg, level=xbmc.LOGDEBUG):
return log('%s: %s'%(self.__class__.__name__,msg),level)
def _hasControl(self, control):
return control in self.controlManager
def _isVisible(self, control):
return self.controlManager.get(control,False)
def _setVisible(self, control, state: bool=False):
self.log('_setVisible, %s = %s'%(control,state))
try:
control.setVisible(state)
return state
except Exception as e:
self.log('_setVisible, failed! control = %s %s'%(control,e))
self._delControl(control)
return False
def _addControl(self, control):
if not self._hasControl(control):
try:
self.log('_addControl, %s'%(control))
self.window.addControl(control)
self.controlManager[control] = self._setVisible(control,True)
except Exception as e:
self.log('_addControl failed! control = %s %s'%(control, e), xbmc.LOGERROR)
self._delControl(control)
def _delControl(self, control):
if self._hasControl(control):
self.log('_delControl, %s'%(control))
try: self.window.removeControl(control)
except Exception as e: self.log('_delControl failed! control = %s %s'%(control, e), xbmc.LOGERROR)
self.controlManager.pop(control)
def open(self):
if self.sysInfo.get('citem',{}):
self.runActions(RULES_ACTION_OVERLAY_OPEN, self.sysInfo.get('citem',{}), inherited=self)
self.log("open, enableVignette = %s, enableChannelBug = %s"%(self.enableVignette, self.enableChannelBug))
if self.enableVignette:
window_h, window_w = (self.window.getHeight(), self.window.getWidth())
self.vignette = xbmcgui.ControlImage(0, 0, window_w, window_h, ' ', aspectRatio=0)
self._addControl(self.vignette)
self.vignette.setImage(self.vinImage)
if self.vinView != self.defaultView: timerit(self.jsonRPC.setViewMode)(0.5,[self.vinView])
self.vignette.setAnimations([('Conditional', 'effect=fade start=0 end=100 time=240 delay=160 condition=True reversible=True')])
self.log('enableVignette, vinImage = %s, vinView = %s'%(self.vinImage,self.vinView))
if self.enableChannelBug:
self.channelBug = xbmcgui.ControlImage(self.channelBugX, self.channelBugY, 128, 128, ' ', aspectRatio=2)
self._addControl(self.channelBug)
logo = self.sysInfo.get('citem',{}).get('logo',(BUILTIN.getInfoLabel('Art(icon)','Player') or LOGO))
if self.forceBugDiffuse: self.channelBug.setColorDiffuse(self.channelBugColor)
elif self.resources.isMono(logo): self.channelBug.setColorDiffuse(self.channelBugColor)
self.channelBug.setImage(logo)
self.channelBug.setAnimations([('Conditional', 'effect=fade start=0 end=100 time=2000 delay=1000 condition=Control.IsVisible(%i) reversible=false'%(self.channelBug.getId())),
('Conditional', 'effect=fade start=100 end=%i time=1000 delay=3000 condition=Control.IsVisible(%i) reversible=false'%(self.channelBugFade,self.channelBug.getId()))])
self.log('enableChannelBug, logo = %s, channelBugColor = %s, window = (%s,%s)'%(logo,self.channelBugColor,self.window_h, self.window_w))
else: self.close()
def close(self):
self.log("close")
self._delControl(self.vignette)
self._delControl(self.channelBug)
if self.vinView != self.defaultView: timerit(self.jsonRPC.setViewMode)(0.5,[self.defaultView])
self.runActions(RULES_ACTION_OVERLAY_CLOSE, self.sysInfo.get('citem',{}), inherited=self)
class OnNext(xbmcgui.WindowXMLDialog):
closing = False
totalTime = 0
threshold = 0
remaining = 0
intTime = 0
def __init__(self, *args, **kwargs):
xbmcgui.WindowXMLDialog.__init__(self, *args, **kwargs)
self.player = kwargs.get('player' , None)
self.sysInfo = kwargs.get('sysInfo' , self.player.sysInfo)
self.onNextMode = kwargs.get('mode' , SETTINGS.getSettingInt('OnNext_Mode'))
self.onNextPosition = kwargs.get('position' , SETTINGS.getSetting("OnNext_Position_XY"))
self.jsonRPC = self.player.jsonRPC
self.monitor = self.player.service.monitor
self.citem = self.sysInfo.get('citem',{})
self.fitem = self.sysInfo.get('fitem',{})
self.nitem = self.jsonRPC.getNextItem(self.citem,self.sysInfo.get('nitem'))
self.window = xbmcgui.Window(12005)
self.window_w, self.window_h = WH #self.window_h, self.window_w = (self.window.getHeight(), self.window.getWidth())
try: self.onNextX, self.onNextY = eval(self.onNextPosition) #user
except: self.onNextX, self.onNextY = abs(int(self.window_w // 9)), abs(int(self.window_h // 16) - self.window_h) - 356 #auto
self.log('__init__, enableOnNext = %s, onNextMode = %s, onNextX = %s, onNextY = %s'%(bool(self.onNextMode),self.onNextMode,self.onNextX,self.onNextY))
if not isFiller(self.fitem):
self.totalTime = int(self.player.getPlayerTime() * (self.player.maxProgress / 100))
self.threshold = abs((self.totalTime - (self.totalTime * .75)) - (ONNEXT_TIMER*3))
self.remaining = floor(self.totalTime - self.player.getPlayedTime())
self.intTime = roundupDIV(self.threshold,3)
self.log('__init__, totalTime = %s, threshold = %s, remaining = %s, intTime = %s'%(self.totalTime,self.threshold,self.remaining,self.intTime))
if self.remaining >= self.intTime:
self.doModal()
def log(self, msg, level=xbmc.LOGDEBUG):
return log('%s: %s'%(self.__class__.__name__,msg),level)
def onInit(self):
def __chkCitem():
return sorted(self.citem) == sorted(self.player.sysInfo.get('citem',{}))
try:
self.log('onInit, citem = %s\nfitem = %ss\nnitem = %s'%(self.citem,self.fitem,self.nitem))
if self.onNextMode in [1,2]:
logo = (self.citem.get('logo') or BUILTIN.getInfoLabel('Art(icon)','Player') or LOGO)
land = (getThumb(self.nitem) or COLOR_FANART)
chname = (self.citem.get('name') or BUILTIN.getInfoLabel('ChannelName','VideoPlayer'))
nowTitle = (self.fitem.get('label') or BUILTIN.getInfoLabel('Title','VideoPlayer'))
nextTitle = (self.nitem.get('showlabel') or BUILTIN.getInfoLabel('NextTitle','VideoPlayer') or chname)
try: nextTime = epochTime(self.nitem['start']).strftime('%I:%M%p')
except Exception as e:
self.log("__init__, nextTime failed! %s\nstart = %s"%(e,self.nitem.get('start')), xbmc.LOGERROR)
nextTime = BUILTIN.getInfoLabel('NextStartTime','VideoPlayer')
onNow = '%s on %s'%(nowTitle,chname) if chname not in validString(nowTitle) else nowTitle
onNext = '@ %s: %s'%(nextTime,nextTitle)
self.getControl(40001).setPosition(self.onNextX, self.onNextY)
self.getControl(40001).setVisibleCondition('[Player.Playing + !Window.IsVisible(fullscreeninfo) + Window.IsVisible(fullscreenvideo)]')
self.getControl(40001).setAnimations([('WindowOpen' , 'effect=zoom start=80 end=100 center=%s,%s delay=160 tween=back time=240 reversible=false'%(self.onNextX, self.onNextY)),
('WindowOpen' , 'effect=fade start=0 end=100 delay=160 time=240 reversible=false'),
('WindowClose', 'effect=zoom start=100 end=80 center=%s,%s delay=160 tween=back time=240 reversible=false'%(self.onNextX, self.onNextY)),
('WindowClose', 'effect=fade start=100 end=0 time=240 reversible=false'),
('Visible' , 'effect=zoom start=80 end=100 center=%s,%s delay=160 tween=back time=240 reversible=false'%(self.onNextX, self.onNextY)),
('Visible' , 'effect=fade end=100 time=240 reversible=false')])
self.onNext_Text = self.getControl(40003)
self.onNext_Text.setVisible(False)
self.onNext_Text.setText('%s %s[CR]%s [B]%s[/B]'%(LANGUAGE(32104),onNow,LANGUAGE(32116),onNext))
if self.onNextMode == 2:
self.onNext_Artwork = self.getControl(40004)
self.onNext_Artwork.setVisible(False)
self.onNext_Artwork.setImage(land)
self.onNext_Text.setVisible(True)
self.onNext_Artwork.setVisible(True)
xbmc.playSFX(BING_WAV)
show = ONNEXT_TIMER*2
while not self.monitor.abortRequested() and not self.closing:
self.log('onInit, showing (%s)'%(show))
if self.monitor.waitForAbort(0.5) or not self.player.isPlaying() or show < 1 or not __chkCitem(): break
else: show -= 1
self.onNext_Text.setVisible(False)
self.onNext_Artwork.setVisible(False)
elif self.onNextMode == 3: self.player.toggleInfo()
elif self.onNextMode == 4: self._updateUpNext(self.fitem,self.nitem)
wait = self.intTime*2
while not self.monitor.abortRequested() and not self.closing:
self.log('onInit, waiting (%s)'%(wait))
if self.monitor.waitForAbort(0.5) or not self.player.isPlaying() or wait < 1 or not __chkCitem(): break
else: wait -= 1
except Exception as e: self.log("onInit, failed! %s"%(e), xbmc.LOGERROR)
self.log("onInit, closing")
self.close()
def _updateUpNext(self, nowItem: dict={}, nextItem: dict={}):
self.log('_updateUpNext')
data = dict()
try:# https://github.com/im85288/service.upnext/wiki/Example-source-code
data.update({"notification_offset":int(floor(self.player.getRemainingTime())) + OSD_TIMER})
current_episode = {"current_episode":{"episodeid" :(nowItem.get("id") or ""),
"tvshowid" :(nowItem.get("tvshowid") or ""),
"title" :(nowItem.get("title") or ""),
"art" :(nowItem.get("art") or ""),
"season" :(nowItem.get("season") or ""),
"episode" :(nowItem.get("episode") or ""),
"showtitle" :(nowItem.get("tvshowtitle") or ""),
"plot" :(nowItem.get("plot") or ""),
"playcount" :(nowItem.get("playcount") or ""),
"rating" :(nowItem.get("rating") or ""),
"firstaired":(nowItem.get("firstaired") or ""),
"runtime" :(nowItem.get("runtime") or "")}}
data.update(current_episode)
except: pass
try:
next_episode = {"next_episode" :{"episodeid" :(nextItem.get("id") or ""),
"tvshowid" :(nextItem.get("tvshowid") or ""),
"title" :(nextItem.get("title") or ""),
"art" :(nextItem.get("art") or ""),
"season" :(nextItem.get("season") or ""),
"episode" :(nextItem.get("episode") or ""),
"showtitle" :(nextItem.get("tvshowtitle") or ""),
"plot" :(nextItem.get("plot" ) or ""),
"playcount" :(nextItem.get("playcount") or ""),
"rating" :(nextItem.get("rating") or ""),
"firstaired":(nextItem.get("firstaired") or ""),
"runtime" :(nextItem.get("runtime") or "")}}
data.update(next_episode)
except: pass
if data: timerit(self.jsonRPC.notifyAll)(0.5,['upnext_data', binascii.hexlify(json.dumps(data).encode('utf-8')).decode('utf-8'), '%s.SIGNAL'%(ADDON_ID)])
def onAction(self, act):
actionId = act.getId()
self.log('onAction: actionId = %s'%(actionId))
self.closing = True
if actionId == ACTION_MOVE_UP: timerit(BUILTIN.executebuiltin)(0.1,['AlarmClock(up,Action(up),.5,true,false)'])
elif actionId == ACTION_MOVE_DOWN: timerit(BUILTIN.executebuiltin)(0.1,['AlarmClock(down,Action(down),.5,true,false)'])
elif actionId in ACTION_PREVIOUS_MENU: timerit(BUILTIN.executebuiltin)(0.1,['AlarmClock(back,Action(back),.5,true,false)'])
def onClose(self):
self.log('onClose')
self.closing = True

View File

@@ -0,0 +1,192 @@
# 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 <http://www.gnu.org/licenses/>.
# https://github.com/xbmc/xbmc/blob/master/xbmc/input/actions/ActionIDs.h
# https://github.com/xbmc/xbmc/blob/master/xbmc/input/Key.h
# -*- coding: utf-8 -*-
from globals import *
from jsonrpc import JSONRPC
WH, WIN = BUILTIN.getResolution()
class OverlayTool(xbmcgui.WindowXMLDialog):
focusControl = None
focusCycle = None
focusCycleLST = []
focusCNTRLST = {}
lastActionTime = time.time()
posx, posy = 0, 0
def __init__(self, *args, **kwargs):
xbmcgui.WindowXMLDialog.__init__(self, *args, **kwargs)
self.log('__init__, args = %s, kwargs = %s'%(args,kwargs))
with BUILTIN.busy_dialog():
self.jsonRPC = JSONRPC()
if BUILTIN.getInfoBool('Playing','Player'): self.window = xbmcgui.Window(12005)
else: self.window = xbmcgui.Window(10000)
self.window_w, self.window_h = WH
self.advRule = (kwargs.get("ADV_RULES") or False)
self.focusIDX = (kwargs.get("Focus_IDX") or 1)
self.defaultView = {}#self.jsonRPC.getViewMode()
self.vinView = (kwargs.get("Vignette_VideoMode") or self.defaultView)
self.vinImage = (kwargs.get("Vignette_Image") or os.path.join(MEDIA_LOC,'overlays','ratio.png'))
self.channelBugColor = '0x%s'%((kwargs.get("ChannelBug_Color") or SETTINGS.getSetting('ChannelBug_Color')))
try:
self.autoBugX, self.autoBugY = abs(int(self.window_w // 9) - self.window_w) - 128, abs(int(self.window_h // 16) - self.window_h) - 128 #auto
self.channelBugX, self.channelBugY = eval(SETTINGS.getSetting("Channel_Bug_Position_XY")) #user
except:
self.channelBugX, self.channelBugY = self.autoBugX, self.autoBugY
try:
self.autoNextX, self.autoNextY = abs(int(self.window_w // 9)), abs(int(self.window_h // 16) - self.window_h) - 356 #auto
self.onNextX, self.onNextY = eval(kwargs.get("OnNext_Position_XY",SETTINGS.getSetting("OnNext_Position_XY")))
except: self.onNextX, self.onNextY = self.autoNextX, self.autoNextY
try:
# self.runActions(RULES_ACTION_OVERLAY_OPEN, self.sysInfo.get('citem',{}), inherited=self)
# if self.vinView != self.defaultView: timerit(self.jsonRPC.setViewMode)(0.1,[self.vinView])
if BUILTIN.getInfoBool('Playing','Player'): BUILTIN.executebuiltin('ActivateWindow(fullscreenvideo)')
self.doModal()
except Exception as e:
self.log("__init__, failed! %s"%(e), xbmc.LOGERROR)
self.close()
def log(self, msg, level=xbmc.LOGDEBUG):
return log('%s: %s'%(self.__class__.__name__,msg),level)
def onInit(self):
if not BUILTIN.getInfoBool('IsFullscreen','System'):
DIALOG.okDialog(LANGUAGE(32097)%(BUILTIN.getInfoLabel('ScreenResolution','System')))
self.posx, self.posy = 0, 0
self.vignetteControl = xbmcgui.ControlImage(0, 0, self.window_w, self.window_h, self.vinImage, aspectRatio=0) #IDX 0
self.channelBug = xbmcgui.ControlImage(self.channelBugX, self.channelBugY, 128, 128, COLOR_LOGO, 2, self.channelBugColor) #IDX 1
self.onNext_Artwork = xbmcgui.ControlImage(self.onNextX, self.onNextY, 256, 128, COLOR_FANART, 0) #IDX 2
self.onNext_Text = xbmcgui.ControlTextBox(self.onNextX, (self.onNextY + 140), 960, 72, 'font27', '0xFFFFFFFF')
self.onNext_Text.setText('%s %s[CR]%s: %s'%(LANGUAGE(32104),ADDON_NAME,LANGUAGE(32116),ADDON_NAME))
self._addCntrl(self.vignetteControl)
self._addCntrl(self.channelBug)
self._addCntrl(self.onNext_Artwork)
self._addCntrl(self.onNext_Text, incl=False)
self.focusCycleLST.insert(0,self.focusCycleLST.pop(self.focusIDX))
self.focusCycle = cycle(self.focusCycleLST).__next__
self.focusControl = self.focusCycle()
self.switch(self.focusControl)
def _addCntrl(self, cntrl, incl=True):
self.log('_addCntrl cntrl = %s'%(cntrl))
self.addControl(cntrl)
cntrl.setVisible(True)
if incl and not cntrl in self.focusCycleLST: self.focusCycleLST.append(cntrl)
if not cntrl in self.focusCNTRLST: self.focusCNTRLST[cntrl] = cntrl.getX(),cntrl.getY()
def switch(self, cntrl=None):
self.log('switch cntrl = %s'%(cntrl))
if not self.focusCycle is None:
if cntrl is None: self.focusControl = self.focusCycle()
else: self.focusControl = cntrl
self._setFocus(self.focusControl)
for cntrl in self.focusCNTRLST:
if self.focusControl != cntrl: cntrl.setAnimations([('Conditional', 'effect=fade start=100 end=25 time=240 delay=160 condition=True reversible=False')])
else:
self.posx, self.posy = cntrl.getX(),cntrl.getY()
cntrl.setAnimations([('Conditional', 'effect=fade start=25 end=100 time=240 delay=160 condition=True reversible=False')])
def move(self, cntrl):
posx, posy = self.focusCNTRLST[cntrl]
if (self.posx != posx or self.posy != posy):
cntrl.setPosition(self.posx, self.posy)
if cntrl == self.onNext_Artwork:
self.onNext_Text.setPosition(self.posx, (self.posy + 140))
def save(self):
changes = {}
for cntrl in self.focusCNTRLST:
posx, posy = cntrl.getX(), cntrl.getY()
if cntrl == self.channelBug:
if (posx != self.channelBugX or posy != self.channelBugY):
changes[cntrl] = posx, posy, (posx == self.autoBugX & posy == self.autoBugY)
elif cntrl == self.onNext_Artwork:
if (posx != self.onNextX or posy != self.onNextY):
changes[cntrl] = posx, posy, (posx == self.autoNextX & posy == self.autoNextY)
if changes:
self.log('save, saving %s'%(changes))
if DIALOG.yesnoDialog(LANGUAGE(32096)):
for cntrl, value in list(changes.items()): self.set(cntrl,value[0],value[1],value[2])
# if self.vinView != self.defaultView: timerit(self.jsonRPC.setViewMode)(0.1,[self.defaultView])
self.close()
def set(self, cntrl, posx, posy, auto=False):
self.log('set, cntrl = %s, posx,posy = (%s,%s) %s? %s'%(cntrl, posx, posy, LANGUAGE(30022), auto))
if self.advRule: save = PROPERTIES.setProperty
else: save = SETTINGS.setSetting
if cntrl == self.channelBug:
if auto: save("Channel_Bug_Position_XY",LANGUAGE(30022))
else: save("Channel_Bug_Position_XY","(%s,%s)"%(posx, posy))
elif cntrl == self.onNext_Artwork:
if auto: save("OnNext_Position_XY",LANGUAGE(30022))
else: save("OnNext_Position_XY","(%s,%s)"%(posx, posy))
def _setFocus(self, cntrl):
self.log('_setFocus cntrl = %s'%(cntrl))
try: self.setFocus(cntrl)
except: pass
def _getFocus(self, cntrl):
self.log('_getFocus cntrl = %s'%(cntrl))
try: self.getFocus(cntrl)
except: pass
def onAction(self, act):
actionId = act.getId()
self.log('onAction: actionId = %s'%(actionId))
lastaction = time.time() - self.lastActionTime
# during certain times we just want to discard all input
if lastaction < 3 and lastaction > 1 and actionId not in ACTION_PREVIOUS_MENU:
self.log('Not allowing actions')
action = ACTION_INVALID
elif actionId in ACTION_SELECT_ITEM: self.switch(self.focusCycle())
elif actionId in ACTION_PREVIOUS_MENU: self.save()
else:
if actionId == ACTION_MOVE_UP: self.posy-=1
elif actionId == ACTION_MOVE_DOWN: self.posy+=1
elif actionId == ACTION_MOVE_LEFT: self.posx-=1
elif actionId == ACTION_MOVE_RIGHT: self.posx+=1
else: return
self.move((self.focusControl))

View File

@@ -0,0 +1,254 @@
# Copyright (C) 2024 Jason Anderson, 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 <http://www.gnu.org/licenses/>.
from globals import *
class AVIChunk:
def __init__(self):
self.empty()
def empty(self):
self.size = 0
self.fourcc = ''
self.datatype = 1
self.chunk = ''
def read(self, thefile):
data = thefile.readBytes(4)
try: self.size = struct.unpack('<i', data)[0]
except: self.size = 0
# Putting an upper limit on the chunk size, in case the file is corrupt
if self.size > 0 and self.size < 10000:
self.chunk = thefile.readBytes(self.size)
else:
self.chunk = ''
self.size = 0
class AVIList:
def __init__(self):
self.empty()
def empty(self):
self.size = 0
self.fourcc = ''
self.datatype = 2
def read(self, thefile):
data = thefile.readBytes(4)
self.size = struct.unpack('<i', data)[0]
try: self.size = struct.unpack('<i', data)[0]
except: self.size = 0
self.fourcc = thefile.read(4)
class AVIHeader:
def __init__(self):
self.empty()
def empty(self):
self.dwMicroSecPerFrame = 0
self.dwMaxBytesPerSec = 0
self.dwPaddingGranularity = 0
self.dwFlags = 0
self.dwTotalFrames = 0
self.dwInitialFrames = 0
self.dwStreams = 0
self.dwSuggestedBufferSize = 0
self.dwWidth = 0
self.dwHeight = 0
class AVIStreamHeader:
def __init__(self):
self.empty()
def empty(self):
self.fccType = ''
self.fccHandler = ''
self.dwFlags = 0
self.wPriority = 0
self.wLanguage = 0
self.dwInitialFrame = 0
self.dwScale = 0
self.dwRate = 0
self.dwStart = 0
self.dwLength = 0
self.dwSuggestedBuffer = 0
self.dwQuality = 0
self.dwSampleSize = 0
self.rcFrame = ''
class AVIParser:
def __init__(self):
self.Header = AVIHeader()
self.StreamHeader = AVIStreamHeader()
def determineLength(self, filename: str) -> int and float:
log("AVIParser: determineLength %s"%filename)
try: self.File = FileAccess.open(filename, "rb", None)
except:
log("AVIParser: Unable to open the file")
return 0
dur = int(self.readHeader())
self.File.close()
log('AVIParser: Duration is %s'%(dur))
return dur
def readHeader(self):
# AVI Chunk
data = self.getChunkOrList()
if data.datatype != 2:
log("AVIParser: Not an avi")
return 0
#todo fix
if data.fourcc[0:4] != "AVI ":
log("AVIParser: Wrong FourCC")
return 0
# Header List
data = self.getChunkOrList()
if data.fourcc != "hdrl":
log("AVIParser: Header not found")
return 0
# Header chunk
data = self.getChunkOrList()
if data.fourcc != 'avih':
log('Header chunk not found')
return 0
self.parseHeader(data)
# Stream list
data = self.getChunkOrList()
if self.Header.dwStreams > 10:
self.Header.dwStreams = 10
for i in range(self.Header.dwStreams):
if data.datatype != 2:
log("AVIParser: Unable to find streams")
return 0
listsize = data.size
# Stream chunk number 1, the stream header
data = self.getChunkOrList()
if data.datatype != 1:
log("AVIParser: Broken stream header")
return 0
self.StreamHeader.empty()
self.parseStreamHeader(data)
# If this is the video header, determine the duration
if self.StreamHeader.fccType == 'vids':
return self.getStreamDuration()
# If this isn't the video header, skip through the rest of these
# stream chunks
try:
if listsize - data.size - 12 > 0:
self.File.seek(listsize - data.size - 12, 1)
data = self.getChunkOrList()
except:
log("AVIParser: Unable to seek")
log("AVIParser: Video stream not found")
return 0
def getStreamDuration(self):
try:
return int(self.StreamHeader.dwLength / (float(self.StreamHeader.dwRate) / float(self.StreamHeader.dwScale)))
except:
return 0
def parseHeader(self, data):
try:
header = struct.unpack('<iiiiiiiiiiiiii', data.chunk)
self.Header.dwMicroSecPerFrame = header[0]
self.Header.dwMaxBytesPerSec = header[1]
self.Header.dwPaddingGranularity = header[2]
self.Header.dwFlags = header[3]
self.Header.dwTotalFrames = header[4]
self.Header.dwInitialFrames = header[5]
self.Header.dwStreams = header[6]
self.Header.dwSuggestedBufferSize = header[7]
self.Header.dwWidth = header[8]
self.Header.dwHeight = header[9]
except:
self.Header.empty()
log("AVIParser: Unable to parse the header")
def parseStreamHeader(self, data):
try:
self.StreamHeader.fccType = data.chunk[0:4].decode('utf-8')
self.StreamHeader.fccHandler = data.chunk[4:8].decode('utf-8')
header = struct.unpack('<ihhiiiiiiiid', data.chunk[8:])
self.StreamHeader.dwFlags = header[0]
self.StreamHeader.wPriority = header[1]
self.StreamHeader.wLanguage = header[2]
self.StreamHeader.dwInitialFrame = header[3]
self.StreamHeader.dwScale = header[4]
self.StreamHeader.dwRate = header[5]
self.StreamHeader.dwStart = header[6]
self.StreamHeader.dwLength = header[7]
self.StreamHeader.dwSuggestedBuffer = header[8]
self.StreamHeader.dwQuality = header[9]
self.StreamHeader.dwSampleSize = header[10]
self.StreamHeader.rcFrame = ''
except:
self.StreamHeader.empty()
log("AVIParser: Error reading stream header")
def getChunkOrList(self):
try: data = self.File.readBytes(4).decode('utf-8')
except: data = self.File.read(4)
if data == "RIFF" or data == "LIST":
dataclass = AVIList()
elif len(data) == 0:
dataclass = AVIChunk()
dataclass.datatype = 3
else:
dataclass = AVIChunk()
dataclass.fourcc = data
# Fill in the chunk or list info
dataclass.read(self.File)
return dataclass

View File

@@ -0,0 +1,31 @@
# 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 <http://www.gnu.org/licenses/>.
from globals import *
class FFProbe:
def determineLength(self, filename: str) -> int and float :
try:
import ffmpeg
log("FFProbe: determineLength %s"%(filename))
dur = ffmpeg.probe(FileAccess.translatePath(filename))["format"]["duration"]
log('FFProbe: Duration is %s'%(dur))
return dur
except Exception as e:
log("FFProbe: failed! %s"%(e), xbmc.LOGERROR)
return 0

View File

@@ -0,0 +1,144 @@
# Copyright (C) 2024 Jason Anderson, 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 <http://www.gnu.org/licenses/>.
from globals import *
class FLVTagHeader:
def __init__(self):
self.tagtype = 0
self.datasize = 0
self.timestamp = 0
self.timestampext = 0
def readHeader(self, thefile):
try:
data = struct.unpack('B', thefile.readBytes(1))[0]
self.tagtype = (data & 0x1F)
self.datasize = struct.unpack('>H', thefile.readBytes(2))[0]
data = struct.unpack('>B', thefile.readBytes(1))[0]
self.datasize = (self.datasize << 8) | data
self.timestamp = struct.unpack('>H', thefile.readBytes(2))[0]
data = struct.unpack('>B', thefile.readBytes(1))[0]
self.timestamp = (self.timestamp << 8) | data
self.timestampext = struct.unpack('>B', thefile.readBytes(1))[0]
except:
self.tagtype = 0
self.datasize = 0
self.timestamp = 0
self.timestampext = 0
class FLVParser:
def __init__(self):
self.monitor = MONITOR()
def determineLength(self, filename: str) -> int and float:
log("FLVParser: determineLength %s"%filename)
try: self.File = FileAccess.open(filename, "rb", None)
except:
log("FLVParser: Unable to open the file")
return 0
if self.verifyFLV() == False:
log("FLVParser: Not a valid FLV")
self.File.close()
return 0
tagheader = self.findLastVideoTag()
if tagheader is None:
log("FLVParser: Unable to find a video tag")
self.File.close()
return 0
dur = int(self.getDurFromTag(tagheader))
self.File.close()
log("FLVParser: Duration is %s"%(dur))
return dur
def verifyFLV(self):
data = self.File.read(3)
if data != 'FLV':
return False
return True
def findLastVideoTag(self):
try:
self.File.seek(0, 2)
curloc = self.File.tell()
except:
log("FLVParser: Exception seeking in findLastVideoTag")
return None
# Go through a limited amount of the file before quiting
maximum = curloc - (2 * 1024 * 1024)
if maximum < 0:
maximum = 8
while not self.monitor.abortRequested() and curloc > maximum:
try:
self.File.seek(-4, 1)
data = int(struct.unpack('>I', self.File.readBytes(4))[0])
if data < 1:
log('FLVParser: Invalid packet data')
return None
if curloc - data <= 0:
log('FLVParser: No video packet found')
return None
self.File.seek(-4 - data, 1)
curloc = curloc - data
tag = FLVTagHeader()
tag.readHeader(self.File)
if tag.datasize <= 0:
log('FLVParser: Invalid packet header')
return None
if curloc - 8 <= 0:
log('FLVParser: No video packet found')
return None
self.File.seek(-8, 1)
log("FLVParser: detected tag type %s"%(tag.tagtype))
curloc = self.File.tell()
if tag.tagtype == 9:
return tag
except:
log('FLVParser: Exception in findLastVideoTag')
return None
return None
def getDurFromTag(self, tag):
tottime = tag.timestamp | (tag.timestampext << 24)
tottime = int(tottime / 1000)
return tottime

View File

@@ -0,0 +1,35 @@
# 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 <http://www.gnu.org/licenses/>.
from globals import *
class Hachoir:
def determineLength(self, filename: str) -> int and float:
try:
meta = {}
from hachoir.parser import createParser
from hachoir.metadata import extractMetadata
log("Hachoir: determineLength %s"%(filename))
meta = extractMetadata(createParser(FileAccess.open(filename,'r')))
if not meta: raise Exception('No meta found')
dur = meta.get('duration').total_seconds()
log('Hachoir: Duration is %s'%(dur))
return dur
except Exception as e:
log("Hachoir: failed! %s\nmeta = %s"%(e,meta), xbmc.LOGERROR)
return 0

View File

@@ -0,0 +1,210 @@
# Copyright (C) 2024Jason Anderson, 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 <http://www.gnu.org/licenses/>.
from globals import *
class MKVParser:
monitor = xbmc.Monitor()
def determineLength(self, filename: str) -> int and float:
log("MKVParser: determineLength %s"%filename)
try: self.File = FileAccess.open(filename, "rb", None)
except:
log("MKVParser: Unable to open the file")
log(traceback.format_exc(), xbmc.LOGERROR)
return
size = self.findHeader()
if size == 0:
log('MKVParser: Unable to find the segment info')
dur = 0
else:
dur = int(round(self.parseHeader(size)))
log("MKVParser: Duration is %s"%(dur))
return dur
def parseHeader(self, size):
duration = 0
timecode = 0
fileend = self.File.tell() + size
datasize = 1
data = 1
while not self.monitor.abortRequested() and self.File.tell() < fileend and datasize > 0 and data > 0:
data = self.getEBMLId()
datasize = self.getDataSize()
if data == 0x2ad7b1:
timecode = 0
try:
for x in range(datasize):
timecode = (timecode << 8) + struct.unpack('B', self.getData(1))[0]
except:
timecode = 0
if duration != 0 and timecode != 0:
break
elif data == 0x4489:
try:
if datasize == 4:
duration = int(struct.unpack('>f', self.getData(datasize))[0])
else:
duration = int(struct.unpack('>d', self.getData(datasize))[0])
except:
log("MKVParser: Error getting duration in header, size is " + str(datasize))
duration = 0
if timecode != 0 and duration != 0:
break
else:
try:
self.File.seek(datasize, 1)
except:
log('MKVParser: Error while seeking')
return 0
if duration > 0 and timecode > 0:
dur = (duration * timecode) / 1000000000
return dur
return 0
def findHeader(self):
log("MKVParser: findHeader")
filesize = self.getFileSize()
if filesize == 0:
log("MKVParser: Empty file")
return 0
data = self.getEBMLId()
# Check for 1A 45 DF A3
if data != 0x1A45DFA3:
log("MKVParser: Not a proper MKV")
return 0
datasize = self.getDataSize()
try:
self.File.seek(datasize, 1)
except:
log('MKVParser: Error while seeking')
return 0
data = self.getEBMLId()
# Look for the segment header
while not self.monitor.abortRequested() and data != 0x18538067 and self.File.tell() < filesize and data > 0 and datasize > 0:
datasize = self.getDataSize()
try:
self.File.seek(datasize, 1)
except:
log('MKVParser: Error while seeking')
return 0
data = self.getEBMLId()
datasize = self.getDataSize()
data = self.getEBMLId()
# Find segment info
while not self.monitor.abortRequested() and data != 0x1549A966 and self.File.tell() < filesize and data > 0 and datasize > 0:
datasize = self.getDataSize()
try:
self.File.seek(datasize, 1)
except:
log('MKVParser: Error while seeking')
return 0
data = self.getEBMLId()
datasize = self.getDataSize()
if self.File.tell() < filesize:
return datasize
return 0
def getFileSize(self):
size = 0
try:
pos = self.File.tell()
self.File.seek(0, 2)
size = self.File.tell()
self.File.seek(pos, 0)
except:
pass
return size
def getData(self, datasize):
data = self.File.readBytes(datasize)
return data
def getDataSize(self):
data = self.File.readBytes(1)
try:
firstbyte = struct.unpack('>B', data)[0]
datasize = firstbyte
mask = 0xFFFF
for i in range(8):
if datasize >> (7 - i) == 1:
mask = mask ^ (1 << (7 - i))
break
datasize = datasize & mask
if firstbyte >> 7 != 1:
for i in range(1, 8):
datasize = (datasize << 8) + struct.unpack('>B', self.File.readBytes(1))[0]
if firstbyte >> (7 - i) == 1:
break
except:
datasize = 0
return datasize
def getEBMLId(self):
data = self.File.readBytes(1)
try:
firstbyte = struct.unpack('>B', data)[0]
ID = firstbyte
if firstbyte >> 7 != 1:
for i in range(1, 4):
ID = (ID << 8) + struct.unpack('>B', self.File.readBytes(1))[0]
if firstbyte >> (7 - i) == 1:
break
except:
ID = 0
return ID

View File

@@ -0,0 +1,185 @@
# Copyright (C) 2024 Jason Anderson, 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 <http://www.gnu.org/licenses/>.
from globals import *
class MP4DataBlock:
def __init__(self):
self.size = -1
self.boxtype = ''
self.data = ''
class MP4MovieHeader:
def __init__(self):
self.version = 0
self.flags = 0
self.created = 0
self.modified = 0
self.scale = 0
self.duration = 0
class MP4Parser:
def __init__(self):
self.MovieHeader = MP4MovieHeader()
self.monitor = MONITOR()
def determineLength(self, filename: str) -> int and float:
log("MP4Parser: determineLength %s"%filename)
try: self.File = FileAccess.open(filename, "rb", None)
except:
log("MP4Parser: Unable to open the file")
return 0
dur = self.readHeader()
if not dur:
log("MP4Parser - Using New Parser")
boxes = self.find_boxes(self.File)
# Sanity check that this really is a movie file.
if (boxes.get(b"ftyp",[-1])[0] == 0):
try:
moov_boxes = self.find_boxes(self.File, boxes[b"moov"][0] + 8, boxes[b"moov"][1])
trak_boxes = self.find_boxes(self.File, moov_boxes[b"trak"][0] + 8, moov_boxes[b"trak"][1])
udta_boxes = self.find_boxes(self.File, moov_boxes[b"udta"][0] + 8, moov_boxes[b"udta"][1])
dur = self.scan_mvhd(self.File, moov_boxes[b"mvhd"][0])
except Exception as e:
log("MP4Parser, failed! %s\nboxes = %s"%(e,boxes), xbmc.LOGERROR)
dur = 0
self.File.close()
log("MP4Parser: Duration is %s"%(dur))
return dur
def find_boxes(self, f, start_offset=0, end_offset=float("inf")):
"""Returns a dictionary of all the data boxes and their absolute starting
and ending offsets inside the mp4 file. Specify a start_offset and end_offset to read sub-boxes."""
s = struct.Struct("> I 4s")
boxes = {}
offset = start_offset
last_offset = -1
f.seek(offset, 0)
while not self.monitor.abortRequested() and offset < end_offset:
try:
if last_offset == offset: break
else: last_offset = offset
data = f.readBytes(8) # read box header
if data == b"": break # EOF
length, text = s.unpack(data)
f.seek(length - 8, 1) # skip to next box
boxes[text] = (offset, offset + length)
offset += length
except: pass
return boxes
def scan_mvhd(self, f, offset):
f.seek(offset, 0)
f.seek(8, 1) # skip box header
data = f.readBytes(1) # read version number
version = int.from_bytes(data, "big")
word_size = 8 if version == 1 else 4
f.seek(3, 1) # skip flags
f.seek(word_size * 2, 1) # skip dates
timescale = int.from_bytes(f.readBytes(4), "big")
if timescale == 0: timescale = 600
duration = int.from_bytes(f.readBytes(word_size), "big")
duration = round(duration / timescale)
return duration
def readHeader(self):
data = self.readBlock()
if data.boxtype != 'ftyp':
log("MP4Parser: No file block")
return 0
# Skip past the file header
try:
self.File.seek(data.size, 1)
except:
log('MP4Parser: Error while seeking')
return 0
data = self.readBlock()
while not self.monitor.abortRequested() and data.boxtype != 'moov' and data.size > 0:
try: self.File.seek(data.size, 1)
except:
log('MP4Parser: Error while seeking')
return 0
data = self.readBlock()
data = self.readBlock()
while not self.monitor.abortRequested() and data.boxtype != 'mvhd' and data.size > 0:
try: self.File.seek(data.size, 1)
except:
log('MP4Parser: Error while seeking')
return 0
data = self.readBlock()
self.readMovieHeader()
if self.MovieHeader.scale > 0 and self.MovieHeader.duration > 0:
return int(self.MovieHeader.duration / self.MovieHeader.scale)
return 0
def readMovieHeader(self):
try:
self.MovieHeader.version = struct.unpack('>b', self.File.readBytes(1))[0]
self.File.read(3) #skip flags for now
if self.MovieHeader.version == 1:
data = struct.unpack('>QQIQQ', self.File.readBytes(36))
else:
data = struct.unpack('>IIIII', self.File.readBytes(20))
self.MovieHeader.created = data[0]
self.MovieHeader.modified = data[1]
self.MovieHeader.scale = data[2]
self.MovieHeader.duration = data[3]
except:
self.MovieHeader.duration = 0
def readBlock(self):
box = MP4DataBlock()
try:
data = self.File.readBytes(4)
box.size = struct.unpack('>I', data)[0]
box.boxtype = self.File.read(4)
if box.size == 1:
box.size = struct.unpack('>q', self.File.readBytes(8))[0]
box.size -= 8
box.size -= 8
if box.boxtype == 'uuid':
box.boxtype = self.File.read(16)
box.size -= 16
except:
pass
return box

View File

@@ -0,0 +1,50 @@
# 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 <http://www.gnu.org/licenses/>.
from globals import *
class MediaInfo:
def determineLength(self, filename: str) -> int and float:
try:
from pymediainfo import MediaInfo
dur = 0
mi = None
fileXML = filename.replace('.%s'%(filename.rsplit('.',1)[1]),'-mediainfo.xml')
if FileAccess.exists(fileXML):
log("MediaInfo: parsing XML %s"%(fileXML))
fle = FileAccess.open(fileXML, 'rb')
mi = MediaInfo(fle.read())
fle.close()
else:
log("MediaInfo: parsing %s"%(FileAccess.translatePath(filename)))
mi = MediaInfo.parse(FileAccess.translatePath(filename))
if not mi is None and mi.tracks:
for track in mi.tracks:
if track.track_type == 'General':
dur = track.duration / 1000
break
log("MediaInfo: determineLength %s Duration is %s"%(filename,dur))
return dur
except Exception as e:
log("MediaInfo: failed! %s"%(e), xbmc.LOGERROR)
return 0

View File

@@ -0,0 +1,31 @@
# 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 <http://www.gnu.org/licenses/>.
from globals import *
class MoviePY:
def determineLength(self, filename: str) -> int and float:
try:
from moviepy.editor import VideoFileClip
log("MoviePY: determineLength %s"%(filename))
dur = VideoFileClip(FileAccess.translatePath(filename)).duration
log('MoviePY: Duration is %s'%(dur))
return dur
except Exception as e:
log("MoviePY: failed! %s"%(e), xbmc.LOGERROR)
return 0

View File

@@ -0,0 +1,73 @@
# Copyright (C) 2024 Lunatixz
#
#
# This file is part of PseudoTV Live.
#
# PseudoTV Live 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 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 Live. If not, see <http://www.gnu.org/licenses/>.
from globals import *
class NFOParser:
## NFO EXAMPLE ##
##<episodedetails>
## <runtime>25</runtime>
## <duration>1575</duration>
## <fileinfo>
## <streamdetails>
## <video>
## <durationinseconds>1575</durationinseconds>
## </video>
## </streamdetails>
## </fileinfo>
##</episodedetails>
def determineLength(self, filename: str) -> int and float:
duration = 0
fleName, fleExt = os.path.splitext(filename)
fleName += '.nfo'
if not FileAccess.exists(fleName):
log("NFOParser: Unable to locate NFO %s"%(fleName), xbmc.LOGERROR)
return 0
log("NFOParser: determineLength, file = %s, nfo = %s"%(filename,fleName))
try:
File = FileAccess.open(fleName, "rb")
dom = parse(File)
File.close()
except:
log("NFOParser: Unable to open the file %s"%(fleName), xbmc.LOGERROR)
return duration
try:
xmldurationinseconds = dom.getElementsByTagName('durationinseconds')[0].toxml()
duration = int(xmldurationinseconds.replace('<durationinseconds>','').replace('</durationinseconds>',''))
except Exception as e:
log("NFOParser: <durationinseconds> not found")
if duration == 0:
try:
xmlruntime = dom.getElementsByTagName('runtime')[0].toxml()
duration = int(xmlruntime.replace('<runtime>','').replace('</runtime>','').replace(' min.','')) * 60
except Exception as e:
log("NFOParser: <runtime> not found")
if duration == 0:
try:
xmlruntime = dom.getElementsByTagName('duration')[0].toxml()
duration = int(xmlruntime.replace('<duration>','').replace('</duration>','')) * 60
except Exception as e:
log("NFOParser: <duration> not found")
log("NFOParser: Duration is %s"%(duration))
return duration

View File

@@ -0,0 +1,31 @@
# 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 <http://www.gnu.org/licenses/>.
from globals import *
class OpenCV:
def determineLength(self, filename: str) -> int and float:
try:
import cv2
log("OpenCV: determineLength %s"%(filename))
dur = cv2.VideoCapture(FileAccess.translatePath(filename)).get(cv2.CAP_PROP_POS_MSEC)
log('OpenCV: Duration is %s'%(dur))
return dur
except Exception as e:
log("OpenCV: failed! %s"%(e), xbmc.LOGERROR)
return 0

View File

@@ -0,0 +1,247 @@
# Copyright (C) 2024 Jason Anderson, 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 <http://www.gnu.org/licenses/>.
from globals import *
class TSPacket:
def __init__(self):
self.pid = 0
self.errorbit = 1
self.pesstartbit = 0
self.adaption = 1
self.adaptiondata = ''
self.pesdata = ''
class TSParser:
def __init__(self):
self.monitor = MONITOR()
def determineLength(self, filename: str) -> int and float:
log("TSParser: determineLength %s"%filename)
self.pid = -1
try: self.File = FileAccess.open(filename, "rb", None)
except:
log("TSParser: Unable to open the file")
return 0
self.filesize = self.getFileSize()
self.packetLength = self.findPacketLength()
if self.packetLength <= 0:
return 0
start = self.getStartTime()
log('TSParser: Start %s'%(start))
end = self.getEndTime()
log('TSParser: End - %s'%(end))
if end > start:
dur = int((end - start) / 90000)
else:
dur = 0
self.File.close()
log("TSParser: Duration is %s"%(dur))
return dur
def findPacketLength(self):
log('TSParser: findPacketLength')
maxbytes = 600
start = 0
self.packetLength = 0
while not self.monitor.abortRequested() and maxbytes > 0:
maxbytes -= 1
try:
data = self.File.readBytes(1)
data = struct.unpack('B', data)
if data[0] == 71:
if start > 0:
end = self.File.tell()
break
else:
start = self.File.tell()
# A minimum of 188, so skip the rest
self.File.seek(187, 1)
except:
log('TSParser: Exception in findPacketLength')
return
if (start > 0) and (end > start):
log('TSParser: Packet Length: %s'%(end - start))
return (end - start)
return
def getFileSize(self):
size = 0
try:
pos = self.File.tell()
self.File.seek(0, 2)
size = self.File.tell()
self.File.seek(pos, 0)
except:
pass
return size
def getStartTime(self):
# A reasonably high number of retries in case the PES starts in the middle
# and is it's maximum length
maxpackets = 12000
log('TSParser: getStartTime')
try:
self.File.seek(0, 0)
except:
return 0
while not self.monitor.abortRequested() and maxpackets > 0:
packet = self.readTSPacket()
maxpackets -= 1
if packet == None:
return 0
if packet.errorbit == 0 and packet.pesstartbit == 1:
ret = self.getPTS(packet)
if ret > 0:
self.pid = packet.pid
log('TSParser: PID: %s'%(self.pid))
return ret
return 0
def getEndTime(self):
log('TSParser: getEndTime')
packetcount = int(self.filesize / self.packetLength)
try:
self.File.seek((packetcount * self.packetLength)- self.packetLength, 0)
except:
return 0
maxpackets = 12000
while not self.monitor.abortRequested() and maxpackets > 0:
packet = self.readTSPacket()
maxpackets -= 1
if packet == None:
log('TSParser: getEndTime got a null packet')
return 0
if packet.errorbit == 0 and packet.pesstartbit == 1 and packet.pid == self.pid:
ret = self.getPTS(packet)
if ret > 0:
log('TSParser: getEndTime returning time')
return ret
else:
try:
self.File.seek(-1 * (self.packetLength * 2), 1)
except:
log('TSParser: exception')
return 0
log('TSParser: getEndTime no found end time')
return 0
def getPTS(self, packet):
timestamp = 0
log('TSParser: getPTS')
try:
data = struct.unpack('19B', packet.pesdata[:19])
# start code
if data[0] == 0 and data[1] == 0 and data[2] == 1:
# cant be a navigation packet
if data[3] != 190 and data[3] != 191:
offset = 0
if (data[9] >> 4) == 3:
offset = 5
# a little dangerous...ignoring the LSB of the timestamp
timestamp = ((data[9 + offset] >> 1) & 7) << 30
timestamp = timestamp | (data[10 + offset] << 22)
timestamp = timestamp | ((data[11 + offset] >> 1) << 15)
timestamp = timestamp | (data[12 + offset] << 7)
timestamp = timestamp | (data[13 + offset] >> 1)
return timestamp
except:
log('TSParser: exception in getPTS')
pass
log('TSParser: getPTS returning 0')
return 0
def readTSPacket(self):
packet = TSPacket()
pos = 0
try:
data = self.File.readBytes(4)
pos = 4
data = struct.unpack('4B', data)
if data[0] == 71:
packet.pid = (data[1] & 31) << 8
packet.pid = packet.pid | data[2]
# skip tables and null packets
if packet.pid < 21 or packet.pid == 8191:
self.File.seek(self.packetLength - 4, 1)
else:
packet.adaption = (data[3] >> 4) & 3
packet.errorbit = data[1] >> 7
packet.pesstartbit = (data[1] >> 6) & 1
if packet.adaption > 1:
data = self.File.readBytes(1)
length = struct.unpack('B', data)[0]
if length > 0:
data = self.File.readBytes(length)
else:
length = 0
pos += length + 1
if pos < 188:
# read the PES data
packet.pesdata = self.File.readBytes(self.packetLength - pos)
except:
log('TSParser: readTSPacket exception')
return None
return packet

View File

@@ -0,0 +1,29 @@
# Copyright (C) 2024 Lunatixz
#
#
# This file is part of PseudoTV Live.
#
# PseudoTV Live 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 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 Live. If not, see <http://www.gnu.org/licenses/>.
from globals import *
class VFSParser:
def determineLength(self, filename: str, fileitem: dict={}, jsonRPC=None)-> int and float:
log("VFSParser: determineLength, file = %s\nitem = %s"%(filename,fileitem))
duration = (fileitem.get('resume',{}).get('total') or fileitem.get('runtime') or fileitem.get('duration') or (fileitem.get('streamdetails',{}).get('video',[]) or [{}])[0].get('duration') or 0)
if duration == 0 and not filename.lower().startswith(fileitem.get('originalpath','').lower()) and not filename.lower().startswith(tuple(self.VFSPaths)):
metadata = self.jsonRPC.getFileDetails((fileitem.get('originalpath') or fileitem.get('file') or filename))
duration = (metadata.get('resume',{}).get('total') or metadata.get('runtime') or metadata.get('duration') or (metadata.get('streamdetails',{}).get('video',[]) or [{}])[0].get('duration') or 0)
log("VFSParser: Duration is %s"%(duration))
return duration

View File

@@ -0,0 +1,38 @@
# 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 <http://www.gnu.org/licenses/>.
from globals import *
class YTParser:
def determineLength(self, filename: str) -> int and float:
try:
dur = 0
if hasAddon('script.module.youtube.dl'):
from youtube_dl import YoutubeDL
if 'videoid' in filename: vID = (re.compile(r'videoid\=(.*)' , re.IGNORECASE).search(filename)).group(1)
elif 'video_id' in filename: vID = (re.compile(r'video_id\=(.*)', re.IGNORECASE).search(filename)).group(1)
else: raise Exception('No video_id found!')
log("YTParser: determineLength, file = %s, id = %s"%(filename,vID))
ydl = YoutubeDL({'no_color': True, 'format': 'best', 'outtmpl': '%(id)s.%(ext)s', 'no-mtime': True, 'add-header': HEADER})
with ydl:
dur = ydl.extract_info("https://www.youtube.com/watch?v={sID}".format(sID=vID), download=False).get('duration',0)
log('YTParser: Duration is %s'%(dur))
return dur
except Exception as e:
log("YTParser: failed! %s\nfile = %s"%(e,filename), xbmc.LOGERROR)
return 0

View File

@@ -0,0 +1,385 @@
# 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 <http://www.gnu.org/licenses/>.
# -*- coding: utf-8 -*-
from globals import *
from jsonrpc import JSONRPC
from rules import RulesList
class Plugin:
@contextmanager
def preparingPlayback(self):
if self.playCheck(loadJSON(decodeString(PROPERTIES.getEXTProperty('%s.lastPlayed.sysInfo'%(ADDON_ID))))):
try: yield
finally: PROPERTIES.setEXTProperty('%s.lastPlayed.sysInfo'%(ADDON_ID),encodeString(dumpJSON(self.sysInfo)))
else: #todo evaluate potential for error handling.
if REAL_SETTINGS.getSetting('Debug_Enable').lower() == 'true':
yield self.playError()
def __init__(self, sysARG=sys.argv, sysInfo={}):
with BUILTIN.busy_dialog():
self.sysARG = sysARG
self.sysInfo = sysInfo
self.jsonRPC = JSONRPC()
self.cache = SETTINGS.cache
self.debugEnabled = SETTINGS.getSettingBool('Debug_Enable')
self.sysInfo['radio'] = sysInfo.get('mode','').lower() == "radio"
self.sysInfo['now'] = int(sysInfo.get('now') or int(getUTCstamp()))
self.sysInfo['start'] = int(sysInfo.get('start') or '-1')
self.sysInfo['stop'] = int(sysInfo.get('stop') or '-1')
self.sysInfo['citem'] = (sysInfo.get('citem') or combineDicts({'id':sysInfo.get("chid")},sysInfo.get('fitem',{}).get('citem',{})))
if sysInfo.get('fitem'):
if sysInfo.get("nitem"): self.sysInfo.update({'citem':combineDicts(self.sysInfo["nitem"].pop('citem'),self.sysInfo["fitem"].pop('citem'))})
else: self.sysInfo.update({'citem':combineDicts(self.sysInfo["citem"],self.sysInfo["fitem"].pop('citem'))})
if self.sysInfo.get('start') == -1:
self.sysInfo['start'] = self.sysInfo['fitem'].get('start')
self.sysInfo['stop'] = self.sysInfo['fitem'].get('stop')
self.sysInfo['duration'] = float(sysInfo.get('duration') or self.jsonRPC._getRuntime(self.sysInfo['fitem']) or timeString2Seconds(BUILTIN.getInfoLabel('Duration(hh:mm:ss)')))
else:
self.sysInfo['duration'] = float((sysInfo.get('duration') or '-1'))
try:
self.sysInfo['seek'] = int(sysInfo.get('seek') or (abs(self.sysInfo['start'] - self.sysInfo['now']) if self.sysInfo['start'] > 0 else -1))
self.sysInfo["progresspercentage"] = -1 if self.sysInfo['seek'] == -1 else (self.sysInfo["seek"]/self.sysInfo["duration"]) * 100
except:
self.sysInfo['seek'] = int(sysInfo.get('seek','-1'))
self.sysInfo["progresspercentage"] = -1
self.log('__init__, sysARG = %s\nsysInfo = %s'%(sysARG,self.sysInfo))
def log(self, msg, level=xbmc.LOGDEBUG):
return log('%s: %s'%(self.__class__.__name__,msg),level)
def _resolveURL(self, found, listitem):
xbmcplugin.setResolvedUrl(int(self.sysARG[1]), found, listitem)
def _setResume(self, chid, liz):
if self.sysInfo.get('seek',0) > SETTINGS.getSettingInt('Seek_Tolerance') and self.sysInfo.get('progresspercentage',100) < 100:
self.log('[%s] _setResume, seek = %s, progresspercentage = %s\npath = %s'%(chid, self.sysInfo.get('seek',0), self.sysInfo.get('progresspercentage',100), liz.getPath()))
liz.setProperty('startoffset', str(self.sysInfo['seek'])) #secs
infoTag = ListItemInfoTag(liz,'video')
infoTag.set_resume_point({'ResumeTime':self.sysInfo['seek'],'TotalTime':(self.sysInfo['duration'] * 60)})
else:
self.sysInfo["seek"] = -1
self.sysInfo["progresspercentage"] = -1
return liz
def _quePlaylist(self, chid, listitems, pltype=xbmc.PLAYLIST_VIDEO, shuffle=BUILTIN.isPlaylistRandom()):
self.log('[%s] _quePlaylist, listitems = %s, shuffle = %s'%(chid, len(listitems),shuffle))
channelPlaylist = xbmc.PlayList(pltype)
channelPlaylist.clear()
xbmc.sleep(100) #give channelPlaylist.clear() enough time to clear queue.
[channelPlaylist.add(liz.getPath(),liz,idx) for idx,liz in enumerate(listitems) if liz.getPath()]
self.log('[%s] _quePlaylist, Playlist size = %s, shuffle = %s'%(chid, channelPlaylist.size(),shuffle))
if shuffle: channelPlaylist.shuffle()
else: channelPlaylist.unshuffle()
return channelPlaylist
def getRadioItems(self, name, chid, vid, limit=RADIO_ITEM_LIMIT):
self.log('[%s] getRadioItems'%(chid))
return interleave([self.jsonRPC.requestList({'id':chid}, path, 'music', page=limit, sort={"method":"random"})[0] for path in vid.split('|')], SETTINGS.getSettingInt('Interleave_Value'))
def getPausedItems(self, name, chid):
self.log('[%s] getPausedItems'%(chid))
def __buildfItem(idx, item):
if 'citem' in item: item.pop('citem')
sysInfo = self.sysInfo.copy()
sysInfo['isPlaylist'] = True
liz = LISTITEMS.buildItemListItem(item,'video')
if item.get('file') == item.get('resume',{}).get('file',str(random.random())):
seektime = int(item.get('resume',{}).get('position',0.0))
runtime = int(item.get('resume',{}).get('total',0.0))
self.log('[%s] getPausedItems, within seek tolerance setting seek totaltime = %s, resumetime = %s'%(chid, runtime, seektime))
liz.setProperty('startoffset', str(seektime)) #secs
infoTag = ListItemInfoTag(liz, 'video')
infoTag.set_resume_point({'ResumeTime':seektime, 'TotalTime':runtime * 60})
sysInfo.update({'fitem':item,'resume':{"idx":idx}})
liz.setProperty('sysInfo',encodeString(dumpJSON(sysInfo)))
return liz
nextitems = RulesList([self.sysInfo.get('citem',{'name':name,'id':chid})]).runActions(RULES_ACTION_PLAYBACK_RESUME, self.sysInfo.get('citem',{'name':name,'id':chid}))
if nextitems:
nextitems = nextitems[:SETTINGS.getSettingInt('Page_Limit')]# list of upcoming items, truncate for speed
self.log('[%s] getPausedItems, building nextitems (%s)'%(chid, len(nextitems)))
return [__buildfItem(idx, nextitem) for idx, nextitem in enumerate(nextitems)]
else: DIALOG.notificationDialog(LANGUAGE(32000))
return []
def getPVRItems(self, name: str, chid: str) -> list:
self.log('[%s] getPVRItems, chname = %s'%(chid,name))
def __buildfItem(idx, item):
sysInfo = self.sysInfo.copy()
nowitem = decodePlot(item.get('plot',''))
if 'citem' in nowitem: nowitem.pop('citem')
nowitem['pvritem'] = item
sysInfo.update({'fitem':nowitem,'position':idx})
try: #next broadcast
nextitem = decodePlot(nextitems[idx+1][1].get('plot',''))
if 'citem' in nextitem: nextitem.pop('citem')
nextitem.get('customproperties',{})['pvritem'] = nextitems[idx + 1]
sysInfo.update({'nitem':nextitem})
except: pass
liz = LISTITEMS.buildItemListItem(nowitem,'video')
if (item.get('progress',0) > 0 and item.get('runtime',0) > 0):
self.log('[%s] getPVRItems, within seek tolerance setting seek totaltime = %s, resumetime = %s'%(chid,(item['runtime'] * 60),item['progress']))
liz.setProperty('startoffset', str(item['progress'])) #secs
infoTag = ListItemInfoTag(liz, 'video')
infoTag.set_resume_point({'ResumeTime':item['progress'],'TotalTime':(item['runtime'] * 60)})
liz.setProperty('sysInfo',encodeString(dumpJSON(sysInfo)))
return liz
found = False
pvritem = self.jsonRPC.matchChannel(name,chid,radio=False)
if pvritem:
pastItems = pvritem.get('broadcastpast',[])
nowitem = pvritem.get('broadcastnow',{})
nextitems = pvritem.get('broadcastnext',[]) # upcoming items
nextitems.insert(0,nowitem)
nextitems = pastItems + nextitems
if (self.sysInfo.get('fitem') or self.sysInfo.get('vid')):
for pos, nextitem in enumerate(nextitems):
fitem = decodePlot(nextitem.get('plot',{}))
file = self.sysInfo.get('fitem',{}).get('file') if self.sysInfo.get('fitem') else self.sysInfo.get('vid')
if file == fitem.get('file') and self.sysInfo.get('citem',{}).get('id') == fitem.get('citem',{}).get('id',str(random.random())):
found = True
self.log('[%s] getPVRItems found matching fitem'%(chid))
del nextitems[0:pos] # start array at correct position
break
elif self.sysInfo.get('now') and self.sysInfo.get('vid'):
for pos, nextitem in enumerate(nextitems):
fitem = decodePlot(nextitem.get('plot',{}))
ntime = datetime.datetime.fromtimestamp(float(self.sysInfo.get('now')))
if ntime >= strpTime(nextitem.get('starttime')) and ntime < strpTime(nextitem.get('endtime')) and chid == fitem.get('citem',{}).get('id',str(random.random())):
found = True
self.log('[%s] getPVRItems found matching starttime'%(chid))
del nextitems[0:pos] # start array at correct position
break
elif nowitem: found = True
if found:
nowitem = nextitems.pop(0)
if round(nowitem['progresspercentage']) > SETTINGS.getSettingInt('Seek_Threshold'):
self.log('[%s] getPVRItems, progress past threshold advance to nextitem'%(chid))
nowitem = nextitems.pop(0)
if round(nowitem['progress']) < SETTINGS.getSettingInt('Seek_Tolerance'):
self.log('[%s] getPVRItems, progress start at the beginning'%(chid))
nowitem['progress'] = 0
nowitem['progresspercentage'] = 0
self.sysInfo.update({'citem':decodePlot(nowitem.get('plot','')).get('citem',self.sysInfo.get('citem'))})
self.sysInfo['callback'] = self.jsonRPC.getCallback(self.sysInfo)
nextitems = nextitems[:SETTINGS.getSettingInt('Page_Limit')]# list of upcoming items, truncate for speed
nextitems.insert(0,nowitem)
self.log('[%s] getPVRItems, building nextitems (%s)'%(chid,len(nextitems)))
return [__buildfItem(idx, item) for idx, item in enumerate(nextitems)]
else: DIALOG.notificationDialog(LANGUAGE(32164))
else: DIALOG.notificationDialog(LANGUAGE(32000))
return [xbmcgui.ListItem()]
def playTV(self, name: str, chid: str):
self.log('[%s] playTV'%(chid))
with self.preparingPlayback(), PROPERTIES.suspendActivity():
if self.sysInfo.get('fitem') and (self.sysInfo.get('fitem').get('file','-1') == self.sysInfo.get('vid','0')): #-> live
liz = self._setResume(chid, LISTITEMS.buildItemListItem(self.sysInfo['fitem']))
else:
liz = self.getPVRItems(name, chid)[0]
liz.setProperty('sysInfo',encodeString(dumpJSON(self.sysInfo)))
self._resolveURL(True, liz)
def playLive(self, name: str, chid: str, vid: str):
self.log('[%s] playLive, name = %s'%(chid, name))
with self.preparingPlayback(), PROPERTIES.suspendActivity():
if self.sysInfo.get('fitem').get('file','-1') == self.sysInfo.get('vid','0'):#-> live playback from UI incl. listitem
liz = self._setResume(chid, LISTITEMS.buildItemListItem(self.sysInfo['fitem']))
liz.setProperty('sysInfo',encodeString(dumpJSON(self.sysInfo)))
self._resolveURL(True, liz)
elif self.sysInfo.get('fitem') and self.sysInfo.get('start') <= self.sysInfo.get('now') <= self.sysInfo.get('stop'):#-> VOD called by non-current EPG cell. (Unreliable during playback)
self.sysInfo['mode'] = 'vod'
self.sysInfo['name'] = self.sysInfo['fitem'].get('label')
self.sysInfo['vid'] = self.sysInfo['fitem'].get('file')
self.sysInfo["seek"] = -1
self.sysInfo["progresspercentage"] = -1
self.log('[%s] playLive, VOD = %s'%(chid, self.sysInfo['vid']))
DIALOG.notificationDialog(LANGUAGE(32185)%(self.sysInfo['name']))
liz = LISTITEMS.buildItemListItem(self.sysInfo.get('fitem'))
liz.setProperty("IsPlayable","true")
liz.setProperty('sysInfo',encodeString(dumpJSON(self.sysInfo)))
timerit(PLAYER().play)(1.0,[self.sysInfo['vid'],liz,True])
self._resolveURL(False, liz)
elif vid:#-> onChange callback from "live" or widget or channel switch (change via input not ui)
self.log('[%s] playLive, VID = %s'%(chid, vid))
liz = self._setResume(chid, xbmcgui.ListItem(name,path=vid))
liz.setProperty("IsPlayable","true")
liz.setProperty('sysInfo',encodeString(dumpJSON(self.sysInfo)))
self._resolveURL(True, liz)
else:
self.playTV(name, chid)
def playBroadcast(self, name: str, chid: str, vid: str): #-> catchup-source
self.log('[%s] playBroadcast'%(chid))
with self.preparingPlayback(), PROPERTIES.suspendActivity():
if self.sysInfo.get('fitem'): #-> catchup-id called via ui "play programme"
liz = LISTITEMS.buildItemListItem(self.sysInfo.get('fitem'))
else:
liz = xbmcgui.ListItem(name,path=vid)
liz.setProperty("IsPlayable","true")
self.sysInfo["seek"] = -1
self.sysInfo["progresspercentage"] = -1
liz.setProperty('sysInfo',encodeString(dumpJSON(self.sysInfo)))
self._resolveURL(True, liz)
def playVOD(self, title: str, vid: str): #-> catchup-id
self.log('[%s] playVOD, title = %s'%(vid,title))
with self.preparingPlayback(), PROPERTIES.suspendActivity():
liz = LISTITEMS.buildItemListItem(self.sysInfo.get('fitem'))
liz.setProperty("IsPlayable","true")
liz.setProperty('sysInfo',encodeString(dumpJSON(self.sysInfo)))
self._resolveURL(True, liz)
def playDVR(self, title: str, vid: str): #-> catchup-id
self.log('[%s] playDVR, title = %s'%(vid, title))
with self.preparingPlayback(), PROPERTIES.suspendActivity():
liz = self._setResume(vid, LISTITEMS.buildItemListItem(self.sysInfo.get('fitem')))
liz.setProperty('sysInfo',encodeString(dumpJSON(self.sysInfo)))
self._resolveURL(True, liz)
def playRadio(self, name: str, chid: str, vid: str):
self.log('[%s] playRadio'%(chid))
def __buildfItem(idx, item: dict={}):
return LISTITEMS.buildItemListItem(item, 'music')
with self.preparingPlayback(), PROPERTIES.suspendActivity():
items = randomShuffle(self.getRadioItems(name, chid, vid))
listitems = [__buildfItem(idx, item) for idx, item in enumerate(items)]
if len(listitems) > 0:
playlist = self._quePlaylist(chid, listitems, pltype=xbmc.PLAYLIST_MUSIC, shuffle=True)
timerit(BUILTIN.executeWindow)(OSD_TIMER,['ReplaceWindow(visualisation)'])
BUILTIN.executebuiltin("Dialog.Close(all)")
PLAYER().play(playlist,windowed=True)
self._resolveURL(False, xbmcgui.ListItem())
def playPlaylist(self, name: str, chid: str):
self.log('[%s] playPlaylist'%(chid))
with self.preparingPlayback(), PROPERTIES.suspendActivity():
listitems = self.getPVRItems(name, chid)
if len(listitems) > 0:
playlist = self._quePlaylist(chid, listitems,shuffle=False)
if BUILTIN.getInfoBool('Playing','Player'): BUILTIN.executebuiltin('PlayerControl(Stop)')
timerit(BUILTIN.executeWindow)(OSD_TIMER,['ReplaceWindow(fullscreenvideo)'])
BUILTIN.executebuiltin("Dialog.Close(all)")
PLAYER().play(playlist,windowed=True)
self._resolveURL(False, xbmcgui.ListItem())
def playPaused(self, name: str, chid: str):
self.log('[%s] playPaused'%(chid))
with self.preparingPlayback(), PROPERTIES.suspendActivity():
listitems = self.getPausedItems(name, chid)
if len(listitems) > 0:
playlist = self._quePlaylist(chid, listitems,shuffle=False)
if BUILTIN.getInfoBool('Playing','Player'): BUILTIN.executebuiltin('PlayerControl(Stop)')
timerit(BUILTIN.executeWindow)(OSD_TIMER,['ReplaceWindow(fullscreenvideo)'])
BUILTIN.executebuiltin("Dialog.Close(all)")
PLAYER().play(playlist,windowed=True)
self._resolveURL(False, xbmcgui.ListItem())
def playCheck(self, oldInfo: dict={}) -> bool:
def _chkPath():
status = True
if oldInfo.get('isPlaylist') or not self.sysInfo.get('vid',''): status = True
elif self.sysInfo.get('vid','').startswith(tuple(VFS_TYPES)): status = hasAddon(self.sysInfo['vid'])
elif not self.sysInfo.get('vid','').startswith(tuple(WEB_TYPES)): status = FileAccess.exists(self.sysInfo['vid'])
self.log('[%s] playCheck _chkPath, valid = %s\npath %s'%(self.sysInfo.get('citem',{}).get('id'),status,self.sysInfo.get('vid')))
if not status: DIALOG.notificationDialog(LANGUAGE(32167),show=self.debugEnabled)
return status
def _chkLoop():
if self.sysInfo.get('chid') == oldInfo.get('chid',random.random()):
if self.sysInfo.get('start') == oldInfo.get('start',random.random()):
self.sysInfo['playcount'] = oldInfo.get('playcount',0) + 1 #carry over playcount
self.sysInfo['runtime'] = oldInfo.get('runtime',0) #carry over previous player runtime
if self.sysInfo['mode'] == 'live':
if self.sysInfo['now'] >= self.sysInfo['stop']:
self.log('[%s] playCheck _chkLoop, failed! Current time (%s) is past the contents stop time (%s).'%(self.sysInfo.get('citem',{}).get('id'),self.sysInfo['now'],self.sysInfo['stop']))
DIALOG.notificationDialog("Current time (%s) is past the contents stop time (%s)."%(self.sysInfo['now'],self.sysInfo['stop']),show=self.debugEnabled)
return False
elif self.sysInfo['runtime'] > 0 and self.sysInfo['duration'] > self.sysInfo['runtime']:
self.log('[%s] playCheck _chkLoop, failed! Duration error between player (%s) and pvr (%s).'%(self.sysInfo.get('citem',{}).get('id'),self.sysInfo['duration'],self.sysInfo['runtime']))
DIALOG.notificationDialog("Duration error between player (%s) and pvr (%s)."%(self.sysInfo['runtime'],self.sysInfo['duration']),show=self.debugEnabled)
return False
elif self.sysInfo['seek'] >= oldInfo.get('runtime',self.sysInfo['duration']):
self.log('[%s] playCheck _chkLoop, failed! Seeking to a position (%s) past media runtime (%s).'%(self.sysInfo.get('citem',{}).get('id'),self.sysInfo['seek'],oldInfo.get('runtime',self.sysInfo['duration'])))
DIALOG.notificationDialog("Seeking to a position (%s) past media runtime (%s)."%(self.sysInfo['seek'],oldInfo.get('runtime',self.sysInfo['duration'])),show=self.debugEnabled)
return False
elif self.sysInfo['seek'] == oldInfo.get('seek',self.sysInfo['seek']):
self.log('[%s] playCheck _chkLoop, failed! Seeking to same position.'%(self.sysInfo.get('citem',{}).get('id')))
DIALOG.notificationDialog("Playback Failed: Seeking to same position",show=self.debugEnabled)
return False
return True
status = _chkPath()
if status: status = _chkLoop()
self.log('[%s] playCheck, status = %s\nsysInfo=%s\noldInfo = %s'%(self.sysInfo.get('citem',{}).get('id'),status, self.sysInfo,oldInfo))
return True
def playError(self):
PROPERTIES.setEXTProperty('%s.lastPlayed.sysInfo'%(ADDON_ID),encodeString(dumpJSON(self.sysInfo)))
self.log('[%s] playError, attempt = %s\n%s'%(self.sysInfo.get('chid','-1'),self.sysInfo.get('playcount'),self.sysInfo))
self._resolveURL(False, xbmcgui.ListItem()) #release pending playback
if self.sysInfo.get('playcount',0) == 0:
DIALOG.notificationWait(LANGUAGE(32038)%(self.sysInfo.get('playcount',0)))
timerit(BUILTIN.executebuiltin)(0.1,['AlarmClock(last,Number(0),.5,true,false)']) #last channel
elif self.sysInfo.get('playcount',3) < 3:
DIALOG.notificationWait(LANGUAGE(32038)%(self.sysInfo.get('playcount',3)))
timerit(BUILTIN.executebuiltin)(0.1,['PlayMedia(%s%s)'%(self.sysARG[0],self.sysARG[2])]) #retry channel
else:
DIALOG.notificationDialog(LANGUAGE(32000))
PROPERTIES.setPropTimer('chkPVRRefresh')
timerit(DIALOG.okDialog)(0.1,[LANGUAGE(32134)%(ADDON_NAME)])

View File

@@ -0,0 +1,166 @@
# 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 <http://www.gnu.org/licenses/>.
#
# -*- coding: utf-8 -*-
from globals import *
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor, TimeoutError
from itertools import repeat, count
from functools import partial, wraps, reduce, update_wrapper
try:
import multiprocessing
cpu_count = multiprocessing.cpu_count()
ENABLE_POOL = False #True force disable multiproc. until monkeypatch/wrapper to fix pickling error.
except:
ENABLE_POOL = False
cpu_count = os.cpu_count()
def wrapped_partial(func, *args, **kwargs):
partial_func = partial(func, *args, **kwargs)
update_wrapper(partial_func, func)
return partial_func
def timeit(method):
@wraps(method)
def wrapper(*args, **kwargs):
start_time = time.time()
result = method(*args, **kwargs)
end_time = time.time()
if REAL_SETTINGS.getSetting('Debug_Enable').lower() == 'true':
log('%s timeit => %.2f ms'%(method.__qualname__.replace('.',': '),(end_time-start_time)*1000))
return result
return wrapper
def killit(method):
@wraps(method)
def wrapper(wait=30, *args, **kwargs):
class waiter(Thread):
def __init__(self):
Thread.__init__(self)
self.result = None
self.error = None
def run(self):
try: self.result = method(*args, **kwargs)
except: self.error = sys.exc_info()[0]
timer = waiter()
timer.name = '%s.%s'%('killit',method.__qualname__.replace('.',': '))
timer.daemon=True
timer.start()
try: timer.join(wait)
except: pass
log('%s, starting %s waiting (%s)'%(method.__qualname__.replace('.',': => -:'),timer.name,wait))
if (timer.is_alive() or timer.error): log('%s, Timed out! Errors: %s'%(method.__qualname__.replace('.',': '),timer.error), xbmc.LOGERROR)
return timer.result
return wrapper
def poolit(method):
@wraps(method)
def wrapper(items=[], *args, **kwargs):
try:
pool = ExecutorPool()
name = '%s.%s'%('poolit',method.__qualname__.replace('.',': '))
log('%s, starting %s'%(method.__qualname__.replace('.',': '),name))
results = pool.executors(method, items, *args, **kwargs)
except Exception as e:
log('poolit, failed! %s'%(e), xbmc.LOGERROR)
results = pool.generator(method, items, *args, **kwargs)
log('%s poolit => %s'%(pool.__class__.__name__, method.__qualname__.replace('.',': ')))
return list([_f for _f in results if _f])
return wrapper
def threadit(method):
@wraps(method)
def wrapper(*args, **kwargs):
thread_name = 'threadit.%s'%(method.__qualname__.replace('.',': '))
for thread in thread_enumerate():
if thread.name == thread_name and thread.is_alive():
if hasattr(thread, 'cancel'):
thread.cancel()
log('%s, canceling %s'%(method.__qualname__.replace('.',': '),thread_name))
thread = Thread(None, method, None, args, kwargs)
thread.name = thread_name
thread.daemon=True
thread.start()
log('%s, starting %s'%(method.__qualname__.replace('.',': '),thread.name))
return thread
return wrapper
def timerit(method):
@wraps(method)
def wrapper(wait, *args, **kwargs):
thread_name = 'timerit.%s'%(method.__qualname__.replace('.',': '))
for timer in thread_enumerate():
if timer.name == thread_name and timer.is_alive():
if hasattr(timer, 'cancel'):
timer.cancel()
log('%s, canceling %s'%(method.__qualname__.replace('.',': '),thread_name))
try:
timer.join()
log('%s, joining %s'%(method.__qualname__.replace('.',': '),thread_name))
except: pass
timer = Timer(float(wait), method, *args, **kwargs)
timer.name = thread_name
timer.start()
log('%s, starting %s wait = %s'%(method.__qualname__.replace('.',': '),thread_name,wait))
return timer
return wrapper
def executeit(method):
@wraps(method)
def wrapper(*args, **kwargs):
pool = ExecutorPool()
log('%s executeit => %s'%(pool.__class__.__name__, method.__qualname__.replace('.',': ')))
return pool.executor(method, None, *args, **kwargs)
return wrapper
class ExecutorPool:
def __init__(self):
self.CPUCount = cpu_count
if ENABLE_POOL: self.pool = ProcessPoolExecutor
else: self.pool = ThreadPoolExecutor
self.log(f"__init__, multiprocessing = {ENABLE_POOL}, CORES = {self.CPUCount}, THREADS = {self._calculate_thread_count()}")
def _calculate_thread_count(self):
if ENABLE_POOL: return self.CPUCount
else: return int(os.getenv('THREAD_COUNT', self.CPUCount * 2))
def log(self, msg, level=xbmc.LOGDEBUG):
return log('%s: %s'%(self.__class__.__name__,msg),level)
def executor(self, func, timeout=None, *args, **kwargs):
self.log("executor, func = %s, timeout = %s"%(func.__name__,timeout))
with self.pool(self._calculate_thread_count()) as executor:
try: return executor.submit(func, *args, **kwargs).result(timeout)
except Exception as e: self.log("executor, func = %s failed! %s\nargs = %s, kwargs = %s"%(func.__name__,e,args,kwargs), xbmc.LOGERROR)
def executors(self, func, items=[], *args, **kwargs):
self.log("executors, func = %s, items = %s"%(func.__name__,len(items)))
with self.pool(self._calculate_thread_count()) as executor:
try: return executor.map(wrapped_partial(func, *args, **kwargs), items)
except Exception as e: self.log("executors, func = %s, items = %s failed! %s\nargs = %s, kwargs = %s"%(func.__name__,len(items),e,args,kwargs), xbmc.LOGERROR)
def generator(self, func, items=[], *args, **kwargs):
self.log("generator, items = %s"%(len(items)))
try: return [wrapped_partial(func, *args, **kwargs)(i) for i in items]
except Exception as e: self.log("generator, func = %s, items = %s failed! %s\nargs = %s, kwargs = %s"%(func.__name__,len(items),e,args,kwargs), xbmc.LOGERROR)

View File

@@ -0,0 +1,119 @@
# 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 <http://www.gnu.org/licenses/>.
# -*- coding: utf-8 -*--
from globals import *
class Predefined:
def __init__(self):
...
def log(self, msg, level=xbmc.LOGDEBUG):
return log('%s: %s'%(self.__class__.__name__,msg),level)
def getParams(self) -> dict:
params = {}
params["order"] = {"direction" :"ascending",
"method" :"random",
"ignorearticle" :True,
"useartistsortname":True}
return params.copy()
def createRECOMMENDED(self, type: str) -> list:
return []
def createMixedRecent(self) -> list:
param = self.getParams()
param["order"]["method"] = "episode"
return ['videodb://recentlyaddedepisodes/?xsp=%s'%(dumpJSON(param)),
'videodb://recentlyaddedmovies/?xsp=%s'%(dumpJSON(self.getParams()))]
def createMusicRecent(self) -> list:
return ['musicdb://recentlyaddedalbums/?xsp=%s'%(dumpJSON(self.getParams()))]
def createNetworkPlaylist(self, network: str, method: str='episode') -> list:
param = self.getParams()
param["type"] = "episodes"
param["order"]["method"] = method
param.setdefault("rules",{}).setdefault("and",[]).append({"field":"studio","operator":"contains","value":[quoteString(network)]})
return ['videodb://tvshows/studios/-1/-1/-1/?xsp=%s'%(dumpJSON(param))]
def createShowPlaylist(self, show: str, method: str='episode') -> list:
param = self.getParams()
param["type"] = "episodes"
param["order"]["method"] = method
try:
match = re.compile(r'(.*) \((.*)\)', re.IGNORECASE).search(show)
year, title = int(match.group(2)), match.group(1)
param.setdefault("rules",{}).setdefault("and",[]).extend([{"field":"year","operator":"is","value":[year]},{"field":"tvshow","operator":"is","value":[quoteString(title)]}])
except:
param.setdefault("rules",{}).setdefault("and",[]).append({"field":"tvshow","operator":"is","value":[quoteString(show)]})
return ['videodb://tvshows/titles/-1/-1/-1/?xsp=%s'%(dumpJSON(param))]
def createTVGenrePlaylist(self, genre: str, method: str='episode') -> list:
param = self.getParams()
param["type"] = "episodes"
param["order"]["method"] = method
param.setdefault("rules",{}).setdefault("and",[]).append({"field":"genre","operator":"contains","value":[quoteString(genre)]})
return ['videodb://tvshows/genres/-1/-1/-1/?xsp=%s'%(dumpJSON(param))]
def createMovieGenrePlaylist(self, genre: str, method: str='year') -> list:
param = self.getParams()
param["type"] = "movies"
param["order"]["method"] = method
param.setdefault("rules",{}).setdefault("and",[]).append({"field":"genre","operator":"contains","value":[quoteString(genre)]})
return ['videodb://movies/genres/?xsp=%s'%(dumpJSON(param))]
def createStudioPlaylist(self, studio: str, method: str='random') -> list:
param = self.getParams()
param["type"] = "movies"
param["order"]["method"] = method
param.setdefault("rules",{}).setdefault("and",[]).append({"field":"studio","operator":"contains","value":[quoteString(studio)]})
return ['videodb://movies/studios/?xsp=%s'%(dumpJSON(param))]
def createMusicGenrePlaylist(self, genre: str, method: str='random') -> list:
param = self.getParams()
param["type"] = "music"
param["order"]["method"] = method
param.setdefault("rules",{}).setdefault("and",[]).append({"field":"genre","operator":"contains","value":[quoteString(genre)]})
return ['musicdb://songs/?xsp=%s'%(dumpJSON(param))]
def createGenreMixedPlaylist(self, genre: str) -> list:
mixed = self.createTVGenrePlaylist(genre)
mixed.extend(self.createMovieGenrePlaylist(genre))
return mixed
def createSeasonal(self) -> list:
return ["{Seasonal}"]
def createProvisional(self, value: str) -> list:
return ["{%s}"%(value)]

View File

@@ -0,0 +1,244 @@
# 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 <http://www.gnu.org/licenses/>.
# -*- coding: utf-8 -*-
from globals import *
from functools import reduce
from difflib import SequenceMatcher
from seasonal import Seasonal
LOCAL_FOLDERS = [LOGO_LOC, IMAGE_LOC]
MUSIC_RESOURCE = ["resource.images.musicgenreicons.text"]
GENRE_RESOURCE = ["resource.images.moviegenreicons.transparent"]
STUDIO_RESOURCE = ["resource.images.studios.white"]
class Service:
from jsonrpc import JSONRPC
player = PLAYER()
monitor = MONITOR()
jsonRPC = JSONRPC()
def _interrupt(self) -> bool:
return PROPERTIES.isPendingInterrupt()
def _suspend(self) -> bool:
return PROPERTIES.isPendingSuspend()
class Resources:
queuePool = {}
def __init__(self, service=None):
if service is None: service = Service()
self.service = service
self.jsonRPC = service.jsonRPC
self.cache = service.jsonRPC.cache
self.baseURL = service.jsonRPC.buildWebBase()
self.remoteHost = PROPERTIES.getRemoteHost()
def log(self, msg, level=xbmc.LOGDEBUG):
return log('%s: %s'%(self.__class__.__name__,msg),level)
def getLogo(self, citem: dict, fallback=LOGO, auto=False) -> str:
if citem.get('name') == LANGUAGE(32002): logo = Seasonal().getHoliday().get('logo') #seasonal
else: logo = self.getLocalLogo(citem.get('name')) #local
if not logo: logo = self.getCachedLogo(citem) #cache
if not logo and auto: logo = self.getLogoResources(citem) #resources
if not logo and auto: logo = self.getTVShowLogo(citem.get('name')) #tvshow
if not logo: logo = (fallback or LOGO) #fallback
self.log('getLogo, name = %s, logo = %s, auto = %s'%(citem.get('name'), logo, auto))
return logo
@cacheit(expiration=datetime.timedelta(days=MAX_GUIDEDAYS), json_data=True)
def selectLogo(self, citem: dict) -> list:
logos = []
logos.extend(self.getLocalLogo(citem.get('name'),select=True))
logos.extend(self.getLogoResources(citem, select=True))
logos.extend(self.getTVShowLogo(citem.get('name'), select=True))
self.log('selectLogo, chname = %s, logos = %s'%(citem.get('name'), len(logos)))
return list([_f for _f in logos if _f])
def queueLOGO(self, param):
params = self.queuePool.setdefault('params',[])
params.append(param)
self.queuePool['params'] = setDictLST(params)
self.log("queueLOGO, saving = %s, param = %s"%(len(self.queuePool['params']),param))
timerit(SETTINGS.setCacheSetting)(5.0,['queueLOGO', self.queuePool, ADDON_VERSION, True])
def getCachedLogo(self, citem, select=False):
cacheFuncs = [{'name':'getLogoResources.%s.%s'%(getMD5(citem.get('name')),select), 'args':(citem,select) ,'checksum':getMD5('|'.join([SETTINGS.getAddonDetails(id).get('version',ADDON_VERSION) for id in self.getResources(citem)]))},
{'name':'getTVShowLogo.%s.%s'%(getMD5(citem.get('name')),select) , 'args':(citem.get('name'), select),'checksum':ADDON_VERSION}]
for cacheItem in cacheFuncs:
cacheResponse = self.cache.get(cacheItem.get('name',''),cacheItem.get('checksum',ADDON_VERSION))
if cacheResponse:
self.log('getCachedLogo, chname = %s, type = %s, logo = %s'%(citem.get('name'), citem.get('type'), cacheResponse))
return cacheResponse
else: self.queueLOGO(cacheItem)
def getLocalLogo(self, chname: str, select: bool=False) -> list:
logos = []
for path in LOCAL_FOLDERS:
for ext in IMG_EXTS:
if FileAccess.exists(os.path.join(path,'%s%s'%(chname,ext))):
self.log('getLocalLogo, found %s'%(os.path.join(path,'%s%s'%(chname,ext))))
if select: logos.append(os.path.join(path,'%s%s'%(chname,ext)))
else: return os.path.join(path,'%s%s'%(chname,ext))
if select: return logos
def fillLogoResource(self, id):
results = {}
response = self.jsonRPC.walkListDirectory(os.path.join('special://home/addons/%s/resources'%id), exts=IMG_EXTS, checksum=SETTINGS.getAddonDetails(id).get('version',ADDON_VERSION), expiration=datetime.timedelta(days=28))
for path, images in list(response.items()):
for image in images:
name, ext = os.path.splitext(image)
results[name] = '%s/%s'%(path,image)
return results
def getResources(self, citem={}):
resources = SETTINGS.getSetting('Resource_Logos').split('|').copy()
if citem.get('type') in ["TV Genres","Movie Genres"]: resources.extend(GENRE_RESOURCE)
elif citem.get('type') in ["TV Networks","Movie Studios"]: resources.extend(STUDIO_RESOURCE)
elif citem.get('type') in ["Music Genres","Radio"] or isRadio(citem): resources.extend(MUSIC_RESOURCE)
else: resources.extend(GENRE_RESOURCE + STUDIO_RESOURCE)
self.log('getResources, type = %s, resources = %s'%(citem.get('type'),resources))
return resources
def getLogoResources(self, citem: dict, select: bool=False) -> dict and None:
self.log('getLogoResources, chname = %s, type = %s, select = %s'%(citem.get('name'), citem.get('type'),select))
logos = []
resources = self.getResources(citem)
cacheName = 'getLogoResources.%s.%s'%(getMD5(citem.get('name')),select)
cacheResponse = self.cache.get(cacheName, checksum=getMD5('|'.join([SETTINGS.getAddonDetails(id).get('version',ADDON_VERSION) for id in resources])))
if not cacheResponse:
for id in list(dict.fromkeys(resources)):
if not hasAddon(id):
self.log('getLogoResources, missing %s'%(id))
continue
else:
results = self.fillLogoResource(id)
self.log('getLogoResources, checking %s, results = %s'%(id,len(results)))
for name, logo in list(results.items()):
if self.matchName(citem.get('name'), name, auto=select):
self.log('getLogoResources, found %s'%(logo))
if select: logos.append(logo)
else: return self.cache.set(cacheName, logo, checksum=getMD5('|'.join(resources)), expiration=datetime.timedelta(days=MAX_GUIDEDAYS))
if select: return self.cache.set(cacheName, logos, checksum=getMD5('|'.join(resources)), expiration=datetime.timedelta(days=MAX_GUIDEDAYS))
return cacheResponse
def getTVShowLogo(self, chname: str, select: bool=False) -> dict and None:
self.log('getTVShowLogo, chname = %s, select = %s'%(chname,select))
logos = []
items = self.jsonRPC.getTVshows()
cacheName = 'getTVShowLogo.%s.%s'%(getMD5(chname),select)
cacheResponse = self.cache.get(cacheName)
if not cacheResponse:
for item in items:
if self.matchName(chname, item.get('title',''), auto=select):
keys = ['clearlogo','logo','logos','clearart','icon']
for key in keys:
logo = item.get('art',{}).get(key,'').replace('image://DefaultFolder.png/','').rstrip('/')
if logo:
self.log('getTVShowLogo, found %s'%(logo))
if select: logos.append(logo)
else: return self.cache.set(cacheName, logo, expiration=datetime.timedelta(days=MAX_GUIDEDAYS))
if select: return self.cache.set(cacheName, logos, expiration=datetime.timedelta(days=MAX_GUIDEDAYS))
return cacheResponse
#todo refactor this mess, proper pattern matching...
def matchName(self, chname: str, name: str, type: str='Custom', auto: bool=False) -> bool and None: #todo auto setting SETTINGS.getSettingBool('')
chnames = list(set([chname, splitYear(chname)[0], stripRegion(chname), getChannelSuffix(chname, type), cleanChannelSuffix(chname, type), chname.replace('and', '&'), chname.replace('&','and'), slugify(chname), validString(chname)]))
renames = list(set([name, splitYear(name)[0], stripRegion(name), slugify(name), validString(name)]))
for chname in chnames:
if not chname: continue
elif auto: return SequenceMatcher(None, chname.lower(), name.lower()).ratio() >= .75
elif chname.lower() == name.lower(): return True
for rename in renames:
if not rename: continue
elif chname.lower() == rename.lower(): return True
return False
def buildWebImage(self, image: str) -> str:
#convert any local images to url via local server and/or kodi web server.
if image.startswith(LOGO_LOC) and self.remoteHost:
image = 'http://%s/images/%s'%(self.remoteHost,quoteString(os.path.split(image)[1]))
elif image.startswith(('image://','image%3A')) and self.baseURL and not ('smb' in image or 'nfs' in image or 'http' in image):
image = '%s/image/%s'%(self.baseURL,quoteString(image))
self.log('buildWebImage, returning image = %s'%(image))
return image
def isMono(self, file: str) -> bool:
if file.startswith('resource://') and (bool(set([match in file.lower() for match in ['transparent','white','mono']]))): return True
elif hasAddon('script.module.pil'):
try:
from PIL import Image, ImageStat
file = unquoteString(file.replace('resource://','special://home/addons/').replace('image://','')).replace('\\','/')
mono = reduce(lambda x, y: x and y < 0.005, ImageStat.Stat(Image.open(FileAccess.open(file.encode('utf-8').strip(),'r'),mode='r')).var, True)
self.log('isMono, mono = %s, file = %s'%(mono,file))
return mono
except Exception as e: self.log("isMono, failed! %s\nfile = %s"%(e,file), xbmc.LOGWARNING)
return False
def generate_placeholder(self, text, background_image_path=FileAccess.translatePath(os.path.join(MEDIA_LOC,'blank.png')), output_path=TEMP_LOC, font_path=FileAccess.translatePath(os.path.join('special://skin','fonts','NotoSans-Regular.ttf')), font_size=30, text_color=(255, 255, 255)):
"""
Generates a placeholder image with text on a background image.
Args:
text: The text to display on the placeholder.
background_image_path: Path to the background image.
output_path: Path to save the generated placeholder image.
font_path: Path to the font file (optional).
font_size: Font size for the text (optional).
text_color: Color of the text (optional).
"""
if hasAddon('script.module.pil'):
from PIL import Image, ImageDraw, ImageFont
# Open the background image
background_image = Image.open(background_image_path)
# Create a drawing object
draw = ImageDraw.Draw(background_image)
# Choose a font
font = ImageFont.truetype(font_path, font_size)
# Calculate text size
text_width, text_height = draw.textsize(text, font)
# Calculate text position for centering
x = (background_image.width - text_width) // 2
y = (background_image.height - text_height) // 2
# Draw the text on the image
draw.text((x, y), text, font=font, fill=text_color)
# Save the image
file_name = os.path.join(output_path,'%s.png'%(text))
fle = FileAccess.open(file_name,'wb')
background_image.save(fle,'png')
fle.close()
# Example usage
# generate_placeholder("Product Image", "background.jpg", "placeholder.jpg")

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,196 @@
# 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 <http://www.gnu.org/licenses/>.
# -*- coding: utf-8 -*-
# Adapted from https://github.com/sualfred/script.embuary.helper/blob/matrix
# https://www.holidaysmart.com/category/fandom
# https://www.holidaysmart.com/holidays/daily/fandom
# https://www.holidaysmart.com/holidays/daily/tv-movies
# https://tvtropes.org/pmwiki/pmwiki.php/Main/PopCultureHoliday
# https://fanlore.org/wiki/List_of_Annual_Holidays,_Observances,_and_Events_in_Fandom
from globals import *
KEY_QUERY = {"method":"","order":"","field":'',"operator":'',"value":[]}
LIMITS = {"end":-1,"start":0,"total":0}
FILTER = {"field":"","operator":"","value":[]}
SORT = {"method":"","order":"","ignorearticle":True,"useartistsortname":True}
TV_QUERY = {"path":"videodb://tvshows/titles/", "method":"VideoLibrary.GetEpisodes","enum":"Video.Fields.Episode","key":"episodes","limits":LIMITS,"sort":SORT,"filter":FILTER}
MOVIE_QUERY = {"path":"videodb://movies/titles/" , "method":"VideoLibrary.GetMovies" ,"enum":"Video.Fields.Movie" ,"key":"movies" ,"limits":LIMITS,"sort":SORT,"filter":FILTER}
class Seasonal:
def __init__(self):
"""
Initializes the Seasonal class. Sets up logging and caching.
"""
self.log('__init__')
self.cache = SETTINGS.cacheDB
def log(self, msg, level=xbmc.LOGDEBUG):
"""
Logs a message to the system log with the specified logging level.
:param msg: The message to log.
:param level: The log level (default: xbmc.LOGDEBUG).
"""
return log('%s: %s' % (self.__class__.__name__, msg), level)
def getYear(self):
"""
Get the current year.
This function retrieves the current year using the `datetime` module.
returns:
int: The current year.
"""
return datetime.datetime.now().year
def getMonth(self, name=False):
"""
Get the current month in either name or numeric format.
Args:
name (bool): If True, returns the full name of the current month (e.g., 'April').
If False, returns the numeric representation of the current month (e.g., 4).
Returns:
str/int: The current month as a string (full name) or as an integer (numeric format).
"""
if name: return datetime.datetime.now().strftime('%B') # Full month name
else: return datetime.datetime.now().month # Numeric month
def getDay(self):
"""
Calculate and return the adjusted day of the month.
This function adds the current day of the month to the weekday of the first day of the current month.
The result can be used to determine the week number or other date-based calculations.
Returns:
int: Adjusted day of the month.
"""
return datetime.datetime.now().day
def getDOM(self, year, month):
"""
Get all days of the specified month for a given year.
This function uses the `calendar.Calendar` class to iterate through all days of the specified month and year.
It extracts only the valid days (ignoring placeholder zeros for days outside the month) and returns them as a list.
Args:
year (int): The year of the desired month.
month (int): The month (1-12) for which to retrieve the days.
Returns:
list: A list of integers representing the days in the specified month.
"""
cal = calendar.Calendar()
days_in_month = []
for day in cal.itermonthdays2(year, month):
if day[0] != 0: # Exclude placeholder days (zeros)
days_in_month.append(day[0])
return days_in_month
def getSeason(self, key):
self.log('getSeason, key = %s' % (key))
return getJSON(HOLIDAYS).get(key,{})
def getSeasons(self, month):
self.log('getSeasons, month = %s' % (month))
return getJSON(SEASONS).get(month,{})
@cacheit(expiration=datetime.timedelta(minutes=15), checksum=PROPERTIES.getInstanceID())
def getHoliday(self, nearest=SETTINGS.getSettingBool('Nearest_Holiday')):
"""
Retrieves the current or nearest holiday based on user settings.
:param nearest: Boolean indicating whether to return the nearest holiday (default: True).
:return: A dictionary representing the holiday details.
"""
self.log('getHoliday, nearest = %s' % (nearest))
if nearest: return self.getNearestHoliday()
else: return self.getCurrentHoliday()
def getCurrentHoliday(self):
"""
Retrieves the holiday for the current month and week.
:return: A dictionary representing the holiday details for the current month and week.
"""
return self.getSeasons(self.getMonth(name=True)).get(self.getDay(),{})
def getSpecialHolidays(self, month, day): #todo check if month, day of week, day match holiday exceptions.
return {"Friday":{"13":{ "name": "Friday The 13th", "tagline": "", "keyword": "", "logo": ""}}}
def getNearestHoliday(self, fallback=True):
"""
Retrieves the nearest holiday. If no holiday is found in the current week, it searches
forward and optionally backward for the nearest holiday.
:param fallback: Boolean indicating whether to search backward if no holiday is found forward (default: True).
:return: A dictionary representing the nearest holiday.
"""
holiday = {}
month = self.getMonth(name=True)
day = self.getDay()
dom = self.getDOM(self.getYear(),self.getMonth())
curr = dom[day - 1:]
days = curr
if fallback:
past = dom[:day - 1]
past.reverse()
days = days + past
for next in days:
holiday = self.getSeasons(month).get(str(next),{})
if holiday.get('keyword'): break
self.log('getNearestHoliday, using fallback = %s, month = %s, day = %s, nearest day = %s, returning = %s' %(fallback, month, day, next, holiday))
return holiday
def buildSeasonal(self):
"""
Builds a generator that provides seasonal content queries. Each query is augmented
with holiday-specific metadata, including sorting and filtering options.
:yield: A dictionary representing a seasonal content query.
"""
holiday = self.getHoliday()
season = self.getSeason(holiday.get('keyword'))
for type, params in list(season.items()):
for param in params:
item = {'episodes':TV_QUERY,'movies':MOVIE_QUERY}[type.lower()].copy()
item["holiday"] = holiday
item["sort"].update(param.get("sort"))
item["filter"].update(param.get("filter"))
self.log('buildSeasonal, %s - item = %s'%(holiday.get('name'),item))
yield item

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