Files
DevOps/Kodi/Lenovo/addons/plugin.video.pseudotv.live/resources/lib/m3u.py

452 lines
24 KiB
Python

# 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