# 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 . # 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