Updated kodi settings on Lenovo
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 8.1 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 449 KiB |
|
After Width: | Height: | Size: 476 KiB |
|
After Width: | Height: | Size: 166 KiB |
|
After Width: | Height: | Size: 265 KiB |
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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, {}
|
||||
|
||||
@@ -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 = {"&": "&",
|
||||
'"': """,
|
||||
"'": "'",
|
||||
">": ">",
|
||||
"<": "<"}
|
||||
|
||||
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
|
||||
@@ -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()
|
||||
@@ -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&musicvideos=true&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')))
|
||||
|
||||
|
||||
@@ -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')))
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
@@ -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/
|
||||
@@ -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, {}
|
||||
@@ -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/
|
||||
@@ -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/
|
||||
@@ -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')))
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
1894
Kodi/Lenovo/addons/plugin.video.pseudotv.live/resources/lib/kodi.py
Normal 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()))
|
||||
@@ -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')
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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))
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)])
|
||||
|
||||
@@ -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)
|
||||
@@ -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)]
|
||||
@@ -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")
|
||||
1771
Kodi/Lenovo/addons/plugin.video.pseudotv.live/resources/lib/rules.py
Normal 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
|
||||
|
||||