From 4e840e7c38a321a3219ec0863dccdd76b7a0acec Mon Sep 17 00:00:00 2001 From: rols1 Date: Tue, 28 Jul 2020 17:45:48 +0200 Subject: [PATCH] =?UTF-8?q?=C3=84nderungen=20/=20Korrekturen=20siehe=20cha?= =?UTF-8?q?ngelog.txt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- addon.xml | 2 +- ardundzdf.py | 146 +++----- changelog.txt | 33 +- resources/lib/childs.py | 5 +- resources/lib/epgRecord.py | 198 +++++++++-- resources/lib/m3u8.py | 311 ++++++++++++++++++ resources/lib/util.py | 168 +++++++--- resources/lib/yt.py | 10 +- resources/livesenderTV.xml | 4 +- resources/settings.xml | 5 +- .../ttsMP3_Monitor_Aufnahme_gestartet.mp3 | Bin 0 -> 30608 bytes 11 files changed, 693 insertions(+), 189 deletions(-) create mode 100644 resources/lib/m3u8.py create mode 100644 resources/ttsMP3_Monitor_Aufnahme_gestartet.mp3 diff --git a/addon.xml b/addon.xml index 9c14362..431d4d3 100644 --- a/addon.xml +++ b/addon.xml @@ -1,5 +1,5 @@ - + diff --git a/ardundzdf.py b/ardundzdf.py index 495b6dd..6752e91 100644 --- a/ardundzdf.py +++ b/ardundzdf.py @@ -43,8 +43,8 @@ # +++++ ARDundZDF - Addon Kodi-Version, migriert von der Plexmediaserver-Version +++++ # VERSION -> addon.xml aktualisieren -VERSION = '3.1.9' -VDATE = '14.07.2020' +VERSION = '3.2.1' +VDATE = '28.07.2020' # # @@ -484,6 +484,7 @@ def InfoAndFilter(): PLog('InfoAndFilter:'); li = xbmcgui.ListItem() li = home(li, ID=NAME) # Home-Button + # Button changelog.txt tag= u'Störungsmeldungen via Kodinerds-Forum, Github-Issue oder rols1@gmx.de' summ = u'für weitere Infos (changelog.txt) klicken' @@ -691,9 +692,7 @@ def FilterToolsWork(action): MyDialog(msg1, '', '') else: msg1 = "Filterliste" - PLog('Mark0') msg2 = u'%s gelöscht. Anzahl: %d' % (item, filter_len) - PLog('Mark1') icon = R(ICON_FILTER) xbmc.executebuiltin('Container.Refresh') xbmcgui.Dialog().notification(msg1,msg2,icon,5000) @@ -757,9 +756,12 @@ def AddonInfos(): fname = SETTINGS.getSetting('pref_podcast_favorits') if os.path.isfile(fname) == False: fname = os.path.join("%s/resources/podcast-favorits.txt") % PluginAbsPath - h = "%s Podcast-Favoriten: %s" % (t,fname) - p3 = "%s\n%s\n%s\n%s\n%s\n%s\n%s\n%s\n%s\n" % (a,b,c,d1,d2,e,f,g,h) + h = "%s Podcast-Favoriten:\n%s%s" % (t,t,fname) # fname in 2. Zeile + log = xbmc.translatePath("special://logpath") + log = os.path.join("%s/kodi.log") % (log) + i = "%s Debug-Log: %s" % (t, log) + p3 = "%s\n%s\n%s\n%s\n%s\n%s\n%s\n%s\n%s\n%s\n" % (a,b,c,d1,d2,e,f,g,h,i) page = "%s\n%s\n%s" % (p1,p2,p3) PLog(page) dialog.textviewer("Addon-Infos", page,usemono=True) @@ -2090,8 +2092,8 @@ def ARDSport(title): SenderLiveListe(title=channel, listname=channel, fanart=img, onlySender=onlySender) PLog(onlySender) - channel = 'Regional' # zum Livestream: WDR/ARD Event Sportschau - onlySender = 'WDR/ARD Event Sportschau' + channel = 'Regional' # zum Livestream: WDR_ARD Event Sportschau + onlySender = 'WDR_ARD Event Sportschau' img = "https://www.sportschau.de/resources/img/sportschau/banner/logo_base.png" SenderLiveListe(title=channel, listname=channel, fanart=img, onlySender=onlySender) PLog(onlySender) @@ -3711,7 +3713,6 @@ def PageControl(cbKey, title, path, mode, ID, offset=0): # ID='ARD', 'POD', mod continue # Satz verwerfen else: continue # Satz verwerfen - PLog('Mark0') PLog('href: ' + href); PLog('title: ' + title) next_cbKey = 'SingleSendung' @@ -4636,6 +4637,8 @@ def DownloadTools(): addDir(li=li, label=title, action="dirList", dirID="resources.lib.epgRecord.JobMain", fanart=R("icon-record.png"), thumb=R("icon-record.png"), fparams=fparams) ''' + + xbmcplugin.endOfDirectory(HANDLE, cacheToDisc=False) #--------------------------- @@ -5526,8 +5529,8 @@ def SenderLiveListePre(title, offset=0): # Vorauswahl: Überregional, Regional, addDir(li=li, label=title, action="dirList", dirID="EPG_Sender", fanart=R(ICON_MAIN_TVLIVE), thumb=R('tv-EPG-single.png'), fparams=fparams, summary=summary, tagline=tagline) - PLog(str(SETTINGS.getSetting('pref_LiveRecord'))) - if SETTINGS.getSetting('pref_LiveRecord') == 'true': + PLog(str(SETTINGS.getSetting('pref_LiveRecord'))) + if SETTINGS.getSetting('pref_LiveRecord') == 'true' or SETTINGS.getSetting('pref_m3u8_get') == 'true': title = 'Recording TV-Live' # TVLiveRecord-Button anhängen laenge = SETTINGS.getSetting('pref_LiveRecord_duration') if SETTINGS.getSetting('pref_LiveRecord_input') == 'true': @@ -5589,8 +5592,9 @@ def TVLiveRecordSender(title): PLog('TVLiveRecordSender:') title = unquote(title) - if check_Setting('pref_LiveRecord_ffmpegCall') == False: - return + # nach Testphase ersetzen durch pref_m3u8_get: + #if check_Setting('pref_LiveRecord_ffmpegCall') == False: + # return li = xbmcgui.ListItem() li = home(li, ID=NAME) # Home-Button @@ -5607,10 +5611,11 @@ def TVLiveRecordSender(title): if u'://' not in img: # Logo lokal? -> wird aus Resources geladen, Unterverz. leider n.m. img = R(img) link = rec[3] - title = "record: %s" % title + title = "%s | ohne EPG" % title if SETTINGS.getSetting('pref_LiveRecord_input') == 'true': laenge = "wird manuell eingegeben" summ = 'Aufnahmedauer: %s' % laenge + summ = u"%s\n\nStart ohne Rückfrage!" % summ tag = 'Zielverzeichnis: %s' % SETTINGS.getSetting('pref_download_path') title=py2_encode(title); link=py2_encode(link); fparams="&fparams={'url': '%s', 'title': '%s', 'duration': '%s', 'laenge': '%s'}" \ @@ -5628,89 +5633,17 @@ def TVLiveRecordSender(title): xbmcplugin.endOfDirectory(HANDLE, cacheToDisc=True) #----------------------------- -def check_Setting(ID): - PLog('check_Setting: ' + ID) - - if ID == 'pref_LiveRecord_ffmpegCall': - PLog(SETTINGS.getSetting('pref_LiveRecord_ffmpegCall')) - # Test: Pfadanteil executable? - # Bsp.: "/usr/bin/ffmpeg -re -i %s -c copy -t %s %s -nostdin" - cmd = SETTINGS.getSetting('pref_LiveRecord_ffmpegCall') - if cmd.strip() == '': - msg1 = 'ffmpeg-Parameter fehlen in den Einstellungen!' - MyDialog(msg1, '', '') - return False - - if os.path.exists(cmd.split()[0]) == False: - msg1 = 'Pfad zu ffmpeg nicht gefunden.' - msg2 = 'Bitte ffmpeg-Parameter in den Einstellungen prüfen, aktuell:' - msg3 = SETTINGS.getSetting('pref_LiveRecord_ffmpegCall') - MyDialog(msg1, msg2, msg3) - return False - return True - - if ID == 'pref_download_path': - dest_path = SETTINGS.getSetting('pref_download_path') - msg1 = 'LiveRecord:' - if dest_path == None or dest_path.strip() == '': - msg2 = 'Downloadverzeichnis fehlt in den Einstellungen' - MyDialog(msg1, msg2, '') - return False - PLog(os.path.isdir(dest_path)) - - if os.path.isdir(dest_path) == False: - msg2 = 'Downloadverzeichnis existiert nicht' - msg3 = "Settings: " + dest_path - MyDialog(msg1, msg2, msg3) - return False - return True - - if ID == 'pref_use_downloads': - # Test auf Existenz curl/wget in DownloadExtern - if SETTINGS.getSetting('pref_use_downloads') == 'true': - dest_path = SETTINGS.getSetting('pref_download_path') - if os.path.isdir(dest_path) == False: - msg1 = u'test_downloads: Downloads nicht möglich' - msg2 = 'Downloadverzeichnis existiert nicht' - msg3 = "Settings: " + dest_path - MyDialog(msg1, msg2, msg3) - return False - else: - return False - return True -#----------------------------- # 30.08.2018 Start Recording TV-Live -# Problem: autom. Wiedereintritt hier + erneuter Popen-call nach Rückkehr zu TVLiveRecordSender -# (Ergebnis-Button nach subprocess.Popen, bei PHT vor Ausführung des Buttons) -# OS-übergreifender Abgleich der pid problematisch - siehe -# https://stackoverflow.com/questions/4084322/killing-a-process-created-with-pythons-subprocess-popen -# Der Wiedereintritt tritt sowohl unter Linux als auch Windows auf. -# Ursach n.b. - tritt in DownloadExtern mit curl/wget nicht auf. -# 1. Lösung: Verwendung des psutil-Moduls (../Contents/Libraries/Shared/psutil ca. 400 KB) -# und pid-Abgleich Dict['PID'] gegen psutil.pid_exists(pid) - s.u. -# verworfen - Modul lässt sich unter Windows nicht laden. Linux OK -# 2. Lösung: Dict['PIDffmpeg'] wird nach subprocess.Popen belegt. Beim ungewollten Wiedereintritt -# wird nach TVLiveRecordSender (Senderliste) zurück gesprungen und Dict['PIDffmpeg'] geleert. -# Beim nächsten manuellen Aufruf wird LiveRecord wieder frei gegeben ("Türsteherfunktion"). -# -# PHT-Problem: wie in TuneIn2017 (streamripper-Aufruf) sprignt PHT bereits vor dem Ergebnis-Buttons (DirectoryObject) -# in LiveRecord zurück. -# Lösung: Ersatz des Ergebnis-Buttons durch return ObjectContainer. PHT steigt allerdings danach mit -# "GetDirectory failed" aus (keine Abhilfe bisher). Der ungewollte Wiedereintritt findet trotzdem -# statt. +# Doku z. PHT-Problemen s. ältere Versionen # -# 20.12.2018 Plex-Probleme "autom. Wiedereintritt" in Kodi nicht beobachtet (Plex-Sandbox Phänomen?) - Code -# entfernt. # 29.04.0219 Erweiterung manuelle Eingabe der Aufnahmedauer -# # Check auf ffmpeg-Settings bereits in TVLiveRecordSender, Check auf LiveRecord-Setting # bereits in SenderLiveListePre # 04.07.2020 angepasst für epgRecord (Eingabe Dauer entf., Dateiname mit Datumformat # geändert, Notification statt Dialog. epgJob enthält Aufnahmestart (Unixformat) -# verlagert nach util (import aus ardundzdf klappt nicht in epgRecord). -# def LiveRecord(url, title, duration, laenge, epgJob=''): - -#----------------------------- +# LiveRecord verlagert nach util (import aus ardundzdf klappt nicht in epgRecord, +# dto. MakeDetailText). +# # 29.06.0219 Erweiterung Sendung aufnehmen, Call K-Menü <- EPG_ShowSingle # Check auf Setting pref_epgRecord in EPG_ShowSingle # @@ -5720,7 +5653,6 @@ def ProgramRecord(url, sender, title, descr, start_end): PLog(start_end); now = EPG.get_unixtime(onlynow=True) - setDateUnix = now # ID in Jobliste start, end = start_end.split('|') # 1593627300|1593633300 s = datetime.datetime.fromtimestamp(int(start)) @@ -5730,8 +5662,10 @@ def ProgramRecord(url, sender, title, descr, start_end): PLog("now %s, von %s, bis %s"% (now, von, bis)) #---------------------------------------------- # Voraussetzungen prüfen - if check_Setting('pref_LiveRecord_ffmpegCall') == False: # Dialog dort - return + # nach Testphase ersetzen durch pref_m3u8_get: + if SETTINGS.getSetting('pref_m3u8_get') == 'false': + if check_Setting('pref_LiveRecord_ffmpegCall') == False: # Dialog dort + return if check_Setting('pref_download_path') == False: # Dialog dort return @@ -5947,13 +5881,17 @@ def EPG_ShowAll(title, offset=0, Merk='false'): # sname = sname.replace(stime, sctime) tagline = '%s | Zeit: %s' % (tagline, vonbis) - tagline = "%s\n\n%s" % (tagline, u"Kontextmenü: Recording TV-Live") + descr = summ.replace('\n', '||') + duration = SETTINGS.getSetting('pref_LiveRecord_duration') + duration, laenge = duration.split('=') + laenge = laenge.strip() + summ = "%s\n\n%s" % (summ, u"Kontextmenü: Recording TV-Live (Aufnahmedauer: %s)" % laenge) title = unescape(title) PLog("title: " + title); PLog(summ) title=py2_encode(title); m3u8link=py2_encode(m3u8link); - img=py2_encode(img); summ=py2_encode(summ); + img=py2_encode(img); descr=py2_encode(descr); summ=py2_encode(summ); fparams="&fparams={'path': '%s', 'title': '%s', 'thumb': '%s', 'descr': '%s', 'Merk': '%s'}" %\ - (quote(m3u8link), quote(title), quote(img), quote_plus(summ), Merk) + (quote(m3u8link), quote(title), quote(img), quote_plus(descr), Merk) addDir(li=li, label=title, action="dirList", dirID="SenderLiveResolution", fanart=R('tv-EPG-all.png'), thumb=img, fparams=fparams, summary=summ, tagline=tagline, start_end="Recording TV-Live") @@ -6266,8 +6204,10 @@ def show_single_bandwith(url_m3u8, thumb, title, descr, ID): #----------------------------- # Ablage master.m3u8, einschl. Behandlung relativer Links # Button für "Bandbreite und Aufloesung automatisch" (master.m3u8) -# Die Dateiablage dient zur Auswertung der Einzelauflösungen, kann aber -# bei Kodi auch zum Videostart verwendet werden. +# Die Dateiablage dient zur Ablage der Einzelauflösungen, kann aber +# bei Kodi auch zum Videostart verwendet werden. +# Buttons für die Einzelauflösungen werden in Parseplaylist +# gefertigt. # descr = Plot, wird zu PlayVideo durchgereicht. def ParseMasterM3u(li, url_m3u8, thumb, title, descr, tagline='', sub_path=''): PLog('ParseMasterM3u:'); @@ -6393,7 +6333,6 @@ def BilderDasErste(path=''): fparams="&fparams={'path': '%s'}" % (quote(href)) addDir(li=li, label=title, action="dirList", dirID="BilderDasErste", fanart=R(ICON_MAIN_ARD), thumb=R('ard-bilderserien.png'), fparams=fparams) - PLog('Mark0') else: # ----------------------- # 10er-Seitenübersicht laden page, msg = get_page(path) @@ -7125,7 +7064,7 @@ def ZDFRubrikSingle(title, path, clus_title='', page=''): title = unescape(title) lable = stringextract('teaser-label">', '', rec) - lable = cleanhtml(lable) # Bsp. 2 Staffeln + lable = cleanhtml(lable.strip()) # Bsp. 2 Staffeln if lable == '': # label nicht in Nachlade-Beiträgen lable = title else: @@ -7206,8 +7145,9 @@ def ZDFRubrikSingle(title, path, clus_title='', page=''): clustertitle = stringextract('cluster-title"', '', '') - PLog(clustertitle); + clustertitle = clustertitle.replace('>', '') + label = unescape(clustertitle) + PLog("clustertitle: " + clustertitle); if 'Direkt zu ...' in clustertitle: # in zdf.de/kinder, hier nicht erreichbar continue if '" weiterschauen' in clustertitle: # dto. (Script-Link) @@ -7219,7 +7159,7 @@ def ZDFRubrikSingle(title, path, clus_title='', page=''): clustertitle=py2_encode(clustertitle); path=py2_encode(path); fparams="&fparams={'title': '%s', 'path': '%s', 'clus_title': '%s'}" % (quote(clustertitle), quote(path), quote(clustertitle)) - addDir(li=li, label=clustertitle, action="dirList", dirID="ZDFRubrikSingle", fanart=img_src, + addDir(li=li, label=label, action="dirList", dirID="ZDFRubrikSingle", fanart=img_src, thumb=img_src, fparams=fparams) #if offset: Code entfernt, in Kodi nicht nutzbar diff --git a/changelog.txt b/changelog.txt index 7f46323..8ccc053 100644 --- a/changelog.txt +++ b/changelog.txt @@ -12,7 +12,34 @@ CHANGE HISTORY max_col 97 -------------- -14.06.2020 3.1.9 +28.07.2020 3.2.1 experimentell: Modul m3u8 - ffmpeg-Ersatz für Recording/Aufnahmen + Neue Funktionen: get_m3u8_body, get_url_list, download_ts_file, + get_ts_startpos, download_ts, Main_m3u8. + LiveRecord (Modul util): Anpassung für Modul m3u8. + CalculateDuration (Modul util): Erweiterung für Format aus xbmcgui.INPUT_TIME + (manuelle Zeiteingabe für Recording). + JobMonitor (Modul epgRecord): Korrektur Dauer, falls Startzeit bereits über- + schritten. + SenderLiveListePre: Test auf ffmpeg-Setting für Live-Recording abgeschaltet + (beim m3u8-Verfahren). + Funktion check_Setting: verlegt ind Modul util (Mitnutzung durch LiveRecord). + +not on Github 3.2.0 + changelog.txt: Korektur Datum V3.1.8, V3.1.9 + EPG_ShowAll: Hinw. auf Kontextmenü ans Ende von summary verlagert (passte + thematisch nicht). + AddonInfos: Pfad für Debug-Log hinzugefügt. + JobRemove (Modul epgRecord): Bez. geändert Aufnahmeliste -> Jobliste + LiveRecord: beim epgJob Austausch der Notification gegen Sprachdatei + (notification fehlt bei bei minimiertem Fenster). + TVLiveRecordSender: Info in summary: "Start ohne Rückfrage!". + JobListe (Modul epgRecord): bei Anzeige der Liste sortiert (Datum). + ARDSport + livesenderTV.xml: Änderung WDR/ARD Event Sportschau -> + WDR_ARD Event Sportschau (Kompat. Dateiname für Debug-Tools). + ZDFRubrikSingle: lable.strip() (Zeilenumbr. in lable entfernt), + unescape clustertitle. + +14.07.2020 3.1.9 addDir (Modul util): Kodierungsbehandl. im Kontextmenü für EPG_ShowSingle und EPG_ShowAll (relevant für ID-Abgleich in JobRemove (epgRecord). JobRemove (Modul epgRecord): leere pid abgefangen (leer bei künftigen Aufnahmen). @@ -23,12 +50,12 @@ CHANGE HISTORY EPG_ShowAll + EPG_ShowSingle: zusätzl. Wechselbutton für 'Download- und Aufnahme-Tools'. DownloadTools: Infos zum Bearbeiten-Button: Anz. Dateien, Größe Verz. + Dateien. - EPG_ShowAll: Listobjekt für img in EPG-Rückgabe abgefangen (Ursache n.b.), + EPG_ShowAll: Listobjekt für img in EPG-Rückgabe abgefangen (Ursache n.b.). Addon-Wicki (Github) aktualisiert. livesenderTV.xml: alternative Streamurl für den Sender DasErste verwendet (An- passung in get_sort_playlist, relevant zum Aufnahmen). -11.06.2020 3.1.8 +11.07.2020 3.1.8 get_ZDFstreamlinks (Modul util): Verwendung apiToken geändert + für player2_url genutzt (ZDF-Streamlinks konnten fehlen). VideoTools: Dateigröße im Button "Ansehen"in tagline hinzugefügt. diff --git a/resources/lib/childs.py b/resources/lib/childs.py index 621bc01..0165581 100644 --- a/resources/lib/childs.py +++ b/resources/lib/childs.py @@ -334,7 +334,8 @@ def Kiraka_Live(): # zweiter Aufruf: Liste einer Gruppe # Info: die Blöcke 'teaser teaserIdent' enthaltenen die Meist geklickten, # Auswertung in Kika_VideosBeliebt -# getHrefList: nur hrefs der Bündelgruppen sammeln für Kika_Search +# getHrefList: nur hrefs der Bündelgruppen sammeln für Kika_Search - dort +# -> Dict-Cache # def Kika_VideosBuendelAZ(path='', getHrefList=False, button=''): PLog('Kika_VideosBuendelAZ: ' + path); PLog(button) @@ -347,7 +348,7 @@ def Kika_VideosBuendelAZ(path='', getHrefList=False, button=''): first=True else: fname = stringextract('allevideos-buendelgruppen100_', '.htm', path) - + page = Dict("load", fname, CacheTime=KikaCacheTime) if page == False: page, msg = get_page(path) diff --git a/resources/lib/epgRecord.py b/resources/lib/epgRecord.py index fa8dee2..c14b7b6 100644 --- a/resources/lib/epgRecord.py +++ b/resources/lib/epgRecord.py @@ -7,7 +7,7 @@ # #################################################################################################### # 01.07.2020 Start -# Stand 12.07.2020 +# Stand 27.07.2020 # Python3-Kompatibilität: from __future__ import absolute_import # sucht erst top-level statt im akt. Verz. @@ -31,13 +31,12 @@ from urllib.error import URLError import time, datetime +import glob from threading import Thread import random # Zufallswerte für JobID from resources.lib.util import * import resources.lib.EPG as EPG -#from ardundzdf import MakeDetailText, LiveRecord - ADDON_ID = 'plugin.video.ardundzdf' SETTINGS = xbmcaddon.Addon(id=ADDON_ID) @@ -75,7 +74,17 @@ # 2. Setting pref_epgRecord (direkt) # Lock: wegen mögl. konkur. Zugriffe auf die Jobdatei wird eine Lock- # datei verwendet (JobMain, Monitor) +# Job-Bereich: Aufnahme-Jobs (ffmpeg, m3u8) - Erzeugung: K-Menü +# EPG_ShowSingle -> ProgramRecord -> JobMain. +# Aufnahmestart nach Zeitabgleich mit Call LiveRecord: +# ffmpeg-Verfahren: Ablage PIDffmpeg im Job +# m3u8-Verfahren: LiveRecord startet direkt m3u8.Main_m3u8, Ablage +# Thread_JobID als PIDffmpeg im Job +# +# Verfahren Recording-TV-Live-Jobs: LiveRecord erzeugt Job via JobMain + +# startet direkt ffmpeg oder m3u8-Verfahren (je nach Setting) # + def JobMonitor(): PLog("JobMonitor:") pre_rec = SETTINGS.getSetting('pref_pre_rec') # Vorlauf (Bsp. 00:15:00 = 15 Minuten) @@ -116,6 +125,8 @@ def JobMonitor(): if os.path.exists(JOBFILE): # bei jedem Durchgang neu einlesen jobs = ReadJobs() + else: + jobs = [] now = EPG.get_unixtime(onlynow=True) now = int(now) @@ -137,7 +148,7 @@ def JobMonitor(): start_human = date_human("%Y.%m.%d_%H:%M:%S", now=start) mydate = date_human("%Y%m%d_%H%M%S", now=start) # Zeitstempel für titel in LiveRecord end_human= date_human("%Y.%m.%d_%H:%M:%S", now=end) - + duration = end - start # in Sekunden für ffmpeg diff = start - now vorz='' @@ -145,14 +156,16 @@ def JobMonitor(): vorz = "minus " diff = seconds_translate(diff) - laenge = "" # entfällt hier + laenge = ""; PIDffmpeg='' # laenge entfällt hier # PLog("now %s, start %s, end %s" % (now, start, end)) # Debug PLog("now %s, start %s, end %s, start-now: %s" % (now_human, start_human, end_human, diff)) #--------------------------------------------------- # 1 Job -> Aufnahme if (now >= start and now <= end) and status == 'waiting': # Job ist aufnahmereif PLog("Job ready: " + start_end) + duration = end - now # Korrektur, falls start schon überschritten url = stringextract('', '', myjob) + JobID = stringextract('', '', myjob) sender = stringextract('', '', myjob) title = stringextract('', '', myjob) title = "%s: %s" % (sender, title) # Titel: Sender + Sendung @@ -182,12 +195,17 @@ def JobMonitor(): myjob = myjob.replace('waiting', 'gestartet') PLog("Job %d started" % cnt) job_changed = True - PIDffmpeg = LiveRecord(url, title, duration, laenge='', epgJob=mydate) # Aufnehmen + + PIDffmpeg = LiveRecord(url, title, duration, laenge='', epgJob=mydate, JobID=JobID) # Aufnehmen + # m3u8-Verfahren statt ffmpeg - LiveRecord startet direkt m3u8.Main_m3u8: + if SETTINGS.getSetting('pref_m3u8_get') == 'true': + PIDffmpeg = "Thread_%s" % JobID # -> KillFile (JobRemove) + myjob = myjob.replace('', '%s' % PIDffmpeg) #--------------------------------------------------- # Job zurück in Liste jobs[cnt] = JOB_TEMPL % myjob # Job -> Listenelement - PLog("Job %d loopend: %s" % (cnt+1, jobs[cnt][:40])) + PLog("Job %d PIDffmpeg: %s" % (cnt+1, PIDffmpeg)) cnt=cnt+1 # und nächster Job #--------------------------------------------------- # Jobliste speichern, falls geändert @@ -199,6 +217,7 @@ def JobMonitor(): PLog(page[:80]) open(JOBFILE_LOCK, 'w').close() # Lock ein err_msg = RSave(JOBFILE, page) # Jobliste speichern + xbmc.sleep(500) if os.path.exists(JOBFILE_LOCK): # Lock aus os.remove(JOBFILE_LOCK) @@ -212,19 +231,19 @@ def JobMonitor(): # Aufrufer: # action init: bei jedem Start ardundzdf.py (bei Setting pref_epgRecord) # action stop: DownloadTools -# action setjob: ProgramRecord +# action setjob: ProgramRecord, LiveRecord (ohne EPG) # # Checks auf ffmpegCall + download_path in ProgramRecord # Problem : bei Abstürzen (network error) kann das Lebendsignal # MONITOR_ALIVE als Ruine stehenbleiben. Lösung: falls mtime # mehr als JOBDELAY zurückliegt, gilt Monitor als tot - init ist # wieder möglich. -# threading.enumerate() hier nicht geeignet (iefert nur MainThread) +# threading.enumerate() hier nicht geeignet (liefert nur MainThread) # -def JobMain(action, start_end='', title='', descr='', sender='', url='', setSetting=''): +def JobMain(action, start_end='', title='', descr='', sender='', url='', setSetting='', PIDffmpeg=''): PLog("JobMain:") PLog(action); PLog(sender); PLog(title); - PLog(descr); PLog(start_end); + PLog(descr); PLog(start_end); PLog(PIDffmpeg); # mythreads = threading.enumerate() # liefert in Kodi nur MainThread status = os.path.exists(MONITOR_ALIVE) @@ -277,7 +296,7 @@ def JobMain(action, start_end='', title='', descr='', sender='', url='', setSet if int(start) > int(now): job_active = True break - PLog('Mark1') + if job_active: title = 'Aufnahme-Monitor stoppen' msg1 = "Mindestens ein Aufnahmejob ist noch aktiv!" @@ -295,13 +314,23 @@ def JobMain(action, start_end='', title='', descr='', sender='', url='', setSet return #------------------------ + # die für Recording Live (LiveRecord) erzeugten Jobs werden nicht im JobMonitor + # abgearbeitet, sondern direkt in m3u8.Main_m3u8 if action == 'setjob': # neuen Job an Aufnahmeliste anhängen + Bereinigung: Doppler # verhindern, Einträge auf pref_max_reclist beschränken - status = 'waiting' # -> , JobMonitor aktualisiert - title = cleanmark(title) # Farbe/fett aus ProgramRecord - pid = '' # nimmt im Monitor PIDffmpeg auf + title = cleanmark(title) # Farbe/fett aus ProgramRecord block = '4Yp2C09aF1k5YC3d' JobID = ''.join(random.choice(block) for i in range(len(block))) # 16 stel. Job-ID + if "Recording Live" in descr: # Aufruf: LiveRecord, Start ohne EPG + status = 'gestartet' # -> , für JobMonitor tabu + pid = "Thread_%s" % JobID # -> KillFile (JobRemove) - wie JobMonitor + else: + status = 'waiting' + if PIDffmpeg: # Aufruf: LiveRecord via ffmpeg + status = 'gestartet' # -> , für JobMonitor tabu + pid = PIDffmpeg # aus LiveRecord direkt oder via JobMonitor + + job_line = JOBLINE_TEMPL % (start_end,title,descr,sender,url,status,pid,JobID) new_job = JOB_TEMPL % job_line PLog(new_job[:80]) @@ -340,11 +369,14 @@ def JobMain(action, start_end='', title='', descr='', sender='', url='', setSet os.remove(JOBFILE_LOCK) xbmcgui.Dialog().notification("Aufnahme-Monitor:", "Job hinzugefügt",MSG_ICON,3000) - - if os.path.exists(MONITOR_ALIVE) == False: # JobMonitor läuft bereits? - bg_thread = Thread(target=JobMonitor, # sonst Thread JobMonitor starten - args=()) - bg_thread.start() + PLog("JobID: %s" % JobID) + if "Recording Live" or "ffmpeg-recording" in descr: # LiveRecord ffmpeg oder -> m3u8.Main_m3u8 + return JobID # mit JobID + else: + if os.path.exists(MONITOR_ALIVE) == False: # JobMonitor läuft bereits? + bg_thread = Thread(target=JobMonitor, # sonst Thread JobMonitor starten + args=()) + bg_thread.start() return #------------------------ if action == 'listJobs': # Liste, Job-Status, Jobs löschen @@ -363,6 +395,8 @@ def JobMain(action, start_end='', title='', descr='', sender='', url='', setSet ################################################################## #---------------------------------------------------------------- +# 26.07.2020 Bereinigung KillFile-Ruinen hinzugefügt +# def JobListe(): # Liste, Job-Status, Jobs löschen PLog("JobListe:") @@ -374,16 +408,27 @@ def JobListe(): # Liste, Job-Status, Jobs löschen if len(jobs) == 0: xbmcgui.Dialog().notification("Jobliste:", "keine Aufnahme-Jobs vorhanden",MSG_ICON,3000) else: - xbmcgui.Dialog().notification("Jobliste:", "nicht gefunden",MSG_ICON,3000) - + xbmcgui.Dialog().notification("Jobliste:", "nicht gefunden",MSG_ICON,3000) + now = EPG.get_unixtime(onlynow=True) now = int(now) + + globFiles = "%s/ThreadKill_*" % ADDON_DATA # KillFile-Ruinen löschen + files = glob.glob(globFiles) + if len(files) > 0: + max_rec_time = 43200 # 12 Std. = max. Setting pref_LiveRecord_duration + OldKillFile = files[0] # 1 reicht, ev. Rest wird bei Folge-Calls abgeräumt + if os.stat(OldKillFile).st_mtime < (now - max_rec_time): # falls älter als max_rec_time + PLog("entferne OldKillFile: %s" % OldKillFile) + os.remove(OldKillFile) + now_human = date_human("%d.%m.%Y, %H:%M", now='') pre_rec = SETTINGS.getSetting('pref_pre_rec') # Vorlauf (Bsp. 00:15:00 = 15 Minuten) post_rec = SETTINGS.getSetting('pref_post_rec') # Nachlauf (dto.) pre_rec = re.search('= (\d+) Min', pre_rec).group(1) post_rec = re.search('= (\d+) Min', post_rec).group(1) anz_jobs = len(jobs) + jobs.sort() for cnt in range(len(jobs)): myjob = jobs[cnt] @@ -394,9 +439,14 @@ def JobListe(): # Liste, Job-Status, Jobs löschen start_end = stringextract('', '', myjob) start, end = start_end.split('|') # 1593627300|1593633300 - start = int(start); end = int(end) - start = int(start) - int(pre_rec) * 60 # Vorlauf (Min -> Sek) abziehen - end = int(end) + int(post_rec) * 60 # Nachlauf (Min -> Sek) aufschlagen + start = int(start); end = int(end) + descr = stringextract('', '', myjob) + PLog(descr) + if "Recording Live" in descr == False: # Vor- und Nachlauf entfallen + pass + else: + start = int(start) - int(pre_rec) * 60 # Vorlauf (Min -> Sek) abziehen + end = int(end) + int(post_rec) * 60 # Nachlauf (Min -> Sek) aufschlagen mydate = date_human("%Y%m%d_%H%M%S", now=start) # Zeitstempel für titel in LiveRecord start_human = date_human("%d.%m.%Y, %H:%M", now=start) end_human = date_human("%d.%m.%Y, %H:%M", now=end) @@ -406,7 +456,9 @@ def JobListe(): # Liste, Job-Status, Jobs löschen job_title = title # Abgleich in JobRemove alt JobID = stringextract('', '', myjob) # Abgleich in JobRemove neu sender = stringextract('', '', myjob) - dfname = "%s: %s" % (sender, title) # Titel: Sender + Sendung (mit Mark.) + dfname = title.strip() # Recording Live: ohne Sender + if sender: + dfname = "%s: %s" % (sender, title) # Titel: Sender + Sendung (mit Mark.) dfname = make_filenames(dfname.strip()) + ".mp4" # Name aus Titel dfname = "%s_%s" % (mydate, dfname) # wie LiveRecord dest_path = SETTINGS.getSetting('pref_download_path') @@ -435,7 +487,9 @@ def JobListe(): # Liste, Job-Status, Jobs löschen job_active = True status_real = "Aufnahme geplant: %s" % start_human - label = u'Job löschen: %s' % title + label = u'Job löschen: %s' % title + if job_active: + label = u'Job stoppen / löschen: %s' % title tag = u'Start: [B]%s[/B], Ende: [I][B]%s[/B][/I]' % (start_human, end_human) tag = u'%s\n%s' % (tag, status_real) @@ -447,7 +501,7 @@ def JobListe(): # Liste, Job-Status, Jobs löschen (sender, job_title, start_end, job_active, pid, JobID) addDir(li=li, label=label, action="dirList", dirID="resources.lib.epgRecord.JobRemove", fanart=R(ICON_DOWNL_DIR), thumb=img, fparams=fparams, tagline=tag, summary=summ) - + xbmcplugin.endOfDirectory(HANDLE, cacheToDisc=False) #---------------------------------------------------------------- @@ -461,9 +515,23 @@ def JobListe(): # Liste, Job-Status, Jobs löschen # def JobRemove(sender, job_title, start_end, job_active, pid, JobID): PLog("JobRemove:") - PLog(pid); PLog(JobID) + PLog(pid); PLog(JobID); PLog(job_active), PLog(start_end) + + if job_active == 'True': # Abzweig Stoppen + heading = u"Job stoppen / löschen" + msg1 = "Job nur stoppen?" + msg2 = "Job verbleibt in der Liste, kann aber nicht mehr gestartet werden" + ret = MyDialog(msg1=msg1, msg2=msg2, msg3='', ok=False, cancel=u'Nein - löschen', + yes='JA - nur stoppen', heading=heading) + if ret ==1: + JobStop(sender, job_title, start_end, job_active, pid, JobID) + return + + if sender: # fehlt beim Recording + msg1 = "%s: %s" % (sender, job_title) + else: + msg1 = job_title - msg1 = "%s: %s" % (sender, job_title) heading = u"Job aus Aufnahmeliste löschen" pidtxt='' if pid: @@ -481,9 +549,15 @@ def JobRemove(sender, job_title, start_end, job_active, pid, JobID): if ret !=1: return + KillFile = os.path.join("%s/ThreadKill_%s") % (ADDON_DATA, JobID) # Stopfile, Ausführung download_ts + PLog("KillFile: %s, %s" % (KillFile, os.path.exists(KillFile))) if job_active == 'True' and pid != '': - os.kill(int(pid), signal.SIGTERM) # auch Windows10 OK (aber Teilvideo beschäd.) - PLog("kill_pid: %s" % str(pid)) + if 'Thread_' in pid: # in JobMonitor ergänzt mit JobID + PLog("setze: %s" % KillFile) + open(KillFile, 'w').close() # KillFile anlegen + else: + PLog("kill_pid: %s" % str(pid)) + os.kill(int(pid), signal.SIGTERM) # auch Windows10 OK (aber Teilvideo beschäd.) jobs = ReadJobs() # s. util newjob_list = []; # newjob_list: Liste nach Änderungen @@ -502,19 +576,73 @@ def JobRemove(sender, job_title, start_end, job_active, pid, JobID): continue newjob_list.append(JOB_TEMPL % job) # job -> Marker + save_Joblist(jobs, newjob_list, "Jobliste:", u"Job gelöscht") + return + +#---------------------------------------------------------------- +# wie JobRemove - nur stoppen, ohne Änderung der Jobliste +# Aufruf: Kontextmenü Jobliste +# +def JobStop(sender, job_title, start_end, job_active, pid, JobID): + PLog("JobStop:") + PLog(pid); PLog(JobID); PLog(job_active), PLog(start_end) + + if sender: # fehlt beim Recording + msg1 = "%s: %s" % (sender, job_title) + else: + msg1 = job_title + + KillFile = os.path.join("%s/ThreadKill_%s") % (ADDON_DATA, JobID) # Stopfile, Ausführung download_ts + PLog("KillFile: %s, %s" % (KillFile, os.path.exists(KillFile))) + if job_active == 'True' and pid != '': + if 'Thread_' in pid: # in JobMonitor ergänzt mit JobID + PLog("setze: %s" % KillFile) + open(KillFile, 'w').close() # KillFile anlegen + else: + PLog("kill_pid: %s" % str(pid)) + os.kill(int(pid), signal.SIGTERM) # auch Windows10 OK (aber Teilvideo beschäd.) + + icon = MSG_ICON + xbmcgui.Dialog().notification("Jobliste:", u"Job wird gestoppt",icon,3000) + + now = EPG.get_unixtime(onlynow=True) + jobs = ReadJobs() # s. util + newjob_list = []; # newjob_list: Liste nach Änderungen + job_title=py2_encode(job_title); # type kann vom code-Format in jobs abweichen + for job in jobs: + my_JobID = stringextract('', '', job) + if JobID in my_JobID: # sonst unverändert + my_start_end = stringextract('', '', job) + start, end = my_start_end.split('|') + end = int(now)-1 # end anpassen (Job abgelaufen) + new_start_end = "%s|%d" % (start, end) + job = job.replace(my_start_end, new_start_end) # job: ändern + PLog(my_start_end); PLog(new_start_end); + + newjob_list.append(JOB_TEMPL % job) # job -> Marker + + save_Joblist(jobs, newjob_list, "Jobliste:", "") # ohne notification + return +#---------------------------------------------------------------- +def save_Joblist(jobs, newjob_list, header, msg): + PLog("save_Joblist") + PLog(len(jobs)) jobs = "\n".join(newjob_list) page = JOBLIST_TEMPL % jobs # Jobliste -> Marker page = py2_encode(page) if doLock(JOBFILE_LOCK): err_msg = RSave(JOBFILE, page) # Jobliste speichern + xbmc.sleep(500) doLock(JOBFILE_LOCK, remove=True) if err_msg == '': - xbmcgui.Dialog().notification("Aufnahmeliste:",u"Job gelöscht",icon,3000) # Notification im Monitor + if msg: # ohne notification bei Stoppen (msg='') + icon = R("icon-record-grey.png") + xbmcgui.Dialog().notification(header,msg,icon,3000) else: - xbmcgui.Dialog().notification("Aufnahmeliste:",u"Problem beim Speichern",icon,3000) # Notification im Monitor + icon = MSG_ICON + xbmcgui.Dialog().notification(header,u"Problem beim Speichern",icon,3000) return - #---------------------------------------------------------------- # simpler Lock-Mechanismus # Aufrufer: 1. checkLock (remove=False) diff --git a/resources/lib/m3u8.py b/resources/lib/m3u8.py new file mode 100644 index 0000000..325baea --- /dev/null +++ b/resources/lib/m3u8.py @@ -0,0 +1,311 @@ +# -*- coding: utf-8 -*- + +################################################################################################### +# m3u8.py - Teil von Kodi-Addon-ARDundZDF +# ersetzt ffmpeg für Recording- und Aufnahme- +# funktionen im Addon. +# Lokale Testumgebung: ../Codestuecke/m3u8_download +# +#################################################################################################### +# Start 16.07.2020 +# Stand 27.07.2020 + + +# Python3-Kompatibilität: +from __future__ import absolute_import # sucht erst top-level statt im akt. Verz. +from __future__ import division # // -> int, / -> float +from __future__ import print_function # PYTHON2-Statement -> Funktion +from kodi_six import xbmc, xbmcaddon, xbmcplugin, xbmcgui, xbmcvfs + +# o. Auswirkung auf die unicode-Strings in PYTHON3: +from kodi_six.utils import py2_encode, py2_decode + +import os, sys +PYTHON2 = sys.version_info.major == 2 +PYTHON3 = sys.version_info.major == 3 +if PYTHON2: + from urllib import urlretrieve + from urllib2 import Request, urlopen +elif PYTHON3: + from urllib.request import Request, urlopen, urlretrieve + +import time, datetime +import ssl, re + +from resources.lib.util import * +import resources.lib.EPG as EPG + +ADDON_ID = 'plugin.video.ardundzdf' +SETTINGS = xbmcaddon.Addon(id=ADDON_ID) +ADDON_PATH = SETTINGS.getAddonInfo('path') +ADDON_NAME = SETTINGS.getAddonInfo('name') +USERDATA = xbmc.translatePath("special://userdata") +ADDON_DATA = os.path.join("%sardundzdf_data") % USERDATA + +if check_AddonXml('"xbmc.python" version="3.0.0"'): + ADDON_DATA = os.path.join("%s", "%s", "%s") % (USERDATA, "addon_data", ADDON_ID) +DICTSTORE = os.path.join("%s/Dict") % ADDON_DATA + + +#---------------------------------------------------------------- +def get_m3u8_body(m3u8_url): # Master m3u8 + PLog('hole Inhalt m3u8-Datei: ' + m3u8_url) + req = Request(m3u8_url, headers={'user-agent': 'Mozilla/5.0'}) + r = urlopen(req) + new_url = r.geturl() # follow redirects + PLog("new_url: " + new_url) # -> msg s.u. + page = r.read() + return page.decode('utf-8'),new_url # 'utf-8' für python3 erf. + +#---------------------------------------------------------------- +def get_url_list(m3u8_url, body): # Url-Liste für Einzelauflösungen + PLog("get_url_list: " + m3u8_url) + + pos=m3u8_url.rfind('/') + host=m3u8_url[:pos] + PLog("host: " + host) + + lines = body.split('\n') + ts_url_list = [] + cnt=0; + for cnt in range(len(lines)): + line = lines[cnt] + bw='0' + if line.startswith('#EXT-X-STREAM-INF'): + bw = re.search('BANDWIDTH=(\d+)', line).group(1) + url= lines[cnt+1] # nächste Zeile + PLog("%s|%s" % (bw, url)) + if url.startswith('http'): + ts_url_list.append("%s|%s" % (bw, url)) + else: + if '/' in url: # normaler Pfad + ts_url_list.append('%s|%s/%s' % (bw, host, url)) + else: # rel. Pfad: url anpassen + url_m3u8_path = m3u8_url.split('/')[-1] # Bsp.: ..de/master.m3u8 -> ..de/master_320.m3u8 + url = m3u8_url.replace(url_m3u8_path, url) + url = "%s|%s" % (bw, url) + ts_url_list.append(url) + cnt=cnt+1 + ts_url_list.sort(key=lambda x:int(x.split('|')[0])) # sortiert nach BANDWIDTH aufsteigend + return ts_url_list + +#---------------------------------------------------------------- +def download_ts_file(ts_url): # TS-Listen für Einzelauflösungen + PLog('hole Inhalt ts-Datei: ' + ts_url) + + req = Request(ts_url, headers={'user-agent': 'Mozilla/5.0'}) + r = urlopen(req) + new_url = r.geturl() # follow redirects + PLog("new_url: " + new_url) # -> msg s.u. + page = r.read() + PLog("len(page): " + str(len(page))) + return page.decode('utf-8') # 'utf-8' für python3 erf. + +#---------------------------------------------------------------- +# get_ts_startpos +# ts-Liste muss bereinigt sein (nur ts-Pfade) +# Bsp.-Berechnung: Länge ts-Liste=720, ts_dur=4 +# 60/4=15 (Puffer/ts_dur), 720-15=705, +# neue Startposition: 705 - es werden 15 ts-Segmente mit +# je 4 sec Dauer geladen, bevor die nächste ts-Liste +# nachgeladen wird - gilt nur beim 1. Durchlauf. +# +def get_ts_startpos(ts_list, last_ts_path, ts_dur): # neue Startpos. (von unten) + PLog('get_ts_startpos') + ts_dur = int(ts_dur) + puffer = 60 # Puffer 60 sec / 1 min + ts_startpos = 4 # Fallback + + if ts_dur > 0 and ts_dur <= puffer: # i.d.R. zw. 4 und 10 (3sat: 2) + ts_startpos = int(puffer / ts_dur) + ts_startpos = len(ts_list) - ts_startpos # Puffer-Start + ts_startpos = max(ts_startpos, 0) # Sicherung gegen < 0 + if last_ts_path == '': # beim Downloadstart + PLog("Start: ts_startpos=%d" % ts_startpos) + return ts_startpos + else: # Folgedurchläufe + cnt=0; found=False # Fallback: Startpos=Listen-Anfang + for line in ts_list: # Abgleich letzte geladene ts-url + if last_ts_path in line: # (last_ts_path) + PLog("last_ts_path in neuer ts-list, pos %d von %d lines" % (cnt, len(ts_list))) + ts_startpos = cnt+1 + found=True + break + cnt = cnt+1 + if found == False: # neue Liste startet vermutl. direkt mit Folgepfad + PLog("last_ts_path fehlt in neuer ts-list: %s" % last_ts_path) + # PLog(ts_list) # Debug + ts_startpos = 0 + return ts_startpos + +#---------------------------------------------------------------- +# duration: Dauer des gesamten Videos. +# TARGETDURATION gilt, falls > als duration (sonst müssten wir +# das geladene Segment framegenau kürzen). +# +# In den Tests war die TARGETDURATION-Pause (ts_dur) zu +# lang. Um ein "Enteilen" der ts-Listen zu verhindern +# (ges. bei ServusTV), wird die Pause um 0,1 sec reduziert. +# threadID: Verwendung für KillFile, da für threads kein +# sicheres Stop-Verfahren existiert (JobRemove erzeugt). +# +def download_ts(host, ts_page, dest_video, duration, ts_dur, JobID): + PLog('download_ts, Video: ' + dest_video) + duration = int(duration) + lines = ts_page.splitlines() + + KillFile = os.path.join("%s/ThreadKill_%s") % (ADDON_DATA, JobID) # Stopfile, gesetzt in JobRemove + PLog("KillFile: %s" % KillFile) + # if 'PROGRAM-DATE-TIME:' in ts_page: # Abgleich Zeitmarken entfällt + new_lines=[] # ts-Pfad Zeilen sammeln + Startmarke suchen + for line in lines: # bereinigen + if line.startswith('#') or line == '': + continue + new_lines.append(line) + + cnt_line=0; cnt_ts=1 # Pos. in ts-Liste; ts-Zähler + last_ts_path='' + ts_startpos = get_ts_startpos(new_lines, last_ts_path, ts_dur) # Startpos. am Anfang ca. 1 Minute + PLog("startpos: %d, new_lines: %d" % (ts_startpos, len(new_lines))) + lines = new_lines # weiter mit bereinigter Liste + cnt_line = ts_startpos + + #----------------------------- + dt = datetime.datetime.now() # s. EPG get_unixtime + now = time.mktime(dt.timetuple()) + start = str(now).split('.')[0] + f = open(dest_video, 'wb') + while True: + if cnt_line >= len(lines): # Sicherung + cnt_line = len(lines)-1 + line = lines[cnt_line] + # PLog("line %d: %s" % (cnt_line, line)) # Debug + if line.startswith('http'): + ts_url = line + else: + ts_url = "%s/%s" % (host, line) + PLog("ts_url: " + ts_url) + + req = Request(ts_url, headers={'user-agent': 'Mozilla/5.0'}) + gcontext = ssl.create_default_context() + gcontext.check_hostname = False + r = urlopen(req, context=gcontext, timeout=3) + new_url = r.geturl() # follow redirects + meta = r.info() + PLog("%d. ts-file, Zeile %d, Video %s, ts: %s" % (cnt_ts, cnt_line, dest_video, ts_url)) + cnt_ts=cnt_ts+1 # ts-Zähler + file_size_dl = 0 + block_sz = 8192 + cnt_line=cnt_line+1 # Pos. in ts-Liste + while True: # Puffer füllen - Checks nach Schreiben + buffer = r.read(block_sz) + if not buffer: + break + file_size_dl += len(buffer) + f.write(buffer) + + dt = datetime.datetime.now() # s. EPG get_unixtime + now = time.mktime(dt.timetuple()) + now = str(now).split('.')[0] + diff = int(now) - int(start) + PLog("now: %s, diff: %d, duration: %d, ts_dur: %d" % (now, diff, duration, int(ts_dur))) + PLog("exists: %s | %s" % (KillFile, str(os.path.exists(KillFile)))) + if diff > duration or os.path.exists(KillFile): # Check: Gesamtdauer erreicht ? + PLog("closing: %s" % dest_video) + f.close() + if os.path.exists(KillFile): + PLog("entferne: %s" % KillFile) + os.remove(KillFile) + break + + #----------------------------- + if cnt_line >= len(lines)-1: # rechtz. nachladen (Bsp. ServusTV) + last_ts_path = line + cnt_load=1 + while True: + PLog("lade ts neu, cnt_line: %d, last_ts_path: %s" % (cnt_line, last_ts_path)) + ts_page = download_ts_file(SESSION_TS_URL) + new_lines=[] + lines = ts_page.splitlines() + for line in lines: # ts-Liste bereinigen + if line.startswith('#') or line == '': + continue + new_lines.append(line) + + ts_startpos = get_ts_startpos(new_lines, last_ts_path, ts_dur=ts_dur) + lines = new_lines + cnt_line = ts_startpos + PLog("new_startpos: %d, lines: %d, last_ts_path %s" % (ts_startpos, len(lines), last_ts_path)) + PLog(lines[-3:]) # letzte 3 Zeilen + if ts_startpos < len(lines): # OK, Folge-ts vorhanden + break + else: # Rettung: Folge-ts fehlt noch + "ts_startpos=len(lines): delay %s" % cnt_load + time.sleep(1) + cnt_load=cnt_load+1 + if cnt_load >= ts_dur: # wenn Folge-ts immer noch fehlt, weiter mit 1. akt. + break # ts, Video fehlerhaft + + # die Pause richtet sich nach EXT-X-TARGETDURATION. Problem: bei int-Werten kann die Synchr. mit + # den ts-Listen verlorengehen (Bsp. ServusTV). Abzug 0.1 war bisher ausreichend. + # time.sleep(ts_dur) + s = float(ts_dur - 0.1) + PLog("sleep: %s sec" % str(s)) + time.sleep(s) + f.close() + return +#----------------------------------------------------------------------- +# nur in Testumgebung (ohne ZDF-Sender) - holt die ts-Listen +# aller Sender in ../resources/livesenderTV.xml: +# def get_all_tsfiles(): +#----------------------------------------------------------------------- +# threadID: "%Y%m%d_%H%M%S" aus LiveRecord - in download_ts +# für KillFile verwendet (gesetzt durch JobRemove in epgRecord). +# +def Main_m3u8(m3u8_url, dest_video, duration, JobID): + PLog("Main_m3u8:") + PLog(m3u8_url); PLog(dest_video);PLog(duration); + + body, new_url = get_m3u8_body(m3u8_url) # gesamte m3u8-Seite + # PLog(body) + if '#EXT-X-TARGETDURATION' in body: # reclink in livesenderTV.xml (DasErste) + PLog('reclink, ohne master.m3u8') # master.m3u8 entfällt + ts_page = body + ts_url = m3u8_url + else: + ts_url_list= get_url_list(new_url, body) # alle TS-Listen, abst. sortiert + # PLog(ts_url_list) # Debug + # exit() + # todo: Auswahl ermöglichen (optional) + ts_url = ts_url_list[-1] # 1. Qual. (Liste aufst. sortiert) + # ts_url = ts_url_list[0] # Debug kleinste Qual. + bw, ts_url = ts_url.split('|') + global SESSION_TS_URL + SESSION_TS_URL= ts_url # zum Nachladen ts_file in download_ts + PLog('Anzahl ts-Quellen: %d' % len(ts_url_list)) + PLog("BANDWIDTH: %s, SESSION_TS_URL: %s" % (bw, SESSION_TS_URL)) + ts_page = download_ts_file(ts_url) # nur 1. Liste (höchste Qual.) + + try: + ts_dur = re.search('TARGETDURATION:(\d+)', ts_page).group(1) + ts_dur = int(ts_dur) + except Exception as exception: + PLog(str(exception)) + ts_dur=4 # Default ARD & Co + PLog("TARGETDURATION: %s" % (ts_dur)) + #with open("/tmp/ts_liste.txt",'w') as f: # Debug + # f.write(ts_page) + + pos=ts_url.rfind('/') + host=ts_url[:pos] + PLog("host: " + host) + + from threading import Thread + background_thread = Thread(target=download_ts, args=(host, ts_page, dest_video, duration, ts_dur, JobID)) + background_thread.start() # ts-Dateien laden + verketten + #return # verhindert hier Thread-Modus + xbmcplugin.endOfDirectory(HANDLE, cacheToDisc=True) +#----------------------------------------------------------------------- + + + diff --git a/resources/lib/util.py b/resources/lib/util.py index ad1fdb8..c778674 100644 --- a/resources/lib/util.py +++ b/resources/lib/util.py @@ -11,7 +11,7 @@ # 02.11.2019 Migration Python3 Modul future # 17.11.2019 Migration Python3 Modul kodi_six + manuelle Anpassungen # -# Stand 14.07.2020 +# Stand 26.07.2020 # Python3-Kompatibilität: from __future__ import absolute_import @@ -1420,8 +1420,10 @@ def humanbytes(B): return '{0:.2f} GB'.format(B/GB) elif TB <= B: return '{0:.2f} TB'.format(B/TB) -#---------------------------------------------------------------- -def CalculateDuration(timecode): # 3 verschiedene Formate (s.u.) +#---------------------------------------------------------------- +# 3 verschiedene Formate (s.u.) - Rückgabe in milliseconds +# Eingaben aus xbmcgui.INPUT_TIME (Recording): Format '00:00:05' +def CalculateDuration(timecode): PLog("CalculateDuration:") timecode = up_low(timecode) # Min -> min milliseconds = 0 @@ -1437,12 +1439,21 @@ def CalculateDuration(timecode): # 3 verschiedene Formate (s.u.) seconds = int ( d.group(3) ) milliseconds = int ( d.group(4) ) - if len(timecode) == 9: # Formate: '00:30 min' + if len(timecode) == 8: # Eingaben xbmcgui.INPUT_TIME + d = re.search('([0-9]{1,2}):([0-9]{1,2}):([0-9]{1,2})', timecode) # 2. Format: '00:00:05' + if(None != d): + hours = int( d.group(1) ) + minutes = int( d.group(2) ) + seconds = int ( d.group(3) ) + PLog(seconds) + + + if len(timecode) == 9: d = re.search('([0-9]{1,2}):([0-9]{1,2}) MIN', timecode) # 2. Format: '00:30 min' if(None != d): hours = int( d.group(1) ) minutes = int( d.group(2) ) - Log(minutes) + PLog(minutes) if len(timecode) == 11: # 3. Format: '1:50:30.000' d = re.search('([0-9]{1,2}):([0-9]{1,2}):([0-9]{1,2}).([0-9]{1,3})', timecode) @@ -1882,11 +1893,12 @@ def get_summary_pre(path, ID='ZDF', skip_verf=False, skip_pubDate=False): # # Beachte: Blank hinter title_sender zur Abgrenz. der ZDF-Sender. # Abgleich kompl. Titel nicht sicher (Bsp. 2 Blanks bei Arte) -# 26.06.2020 skip_log hinzugefügt (relevant für loop in -# get_sort_playlist) +# 26.06.2020 skip_log hinzugefügt (True in get_sort_playlist +# loop, Ausgabe der Liste in "Überregional") #----------------------------------------------- def get_ZDFstreamlinks(skip_log=False): PLog('get_ZDFstreamlinks:') + PLog(skip_log) ZDFlinks_CacheTime = 86400 # 24 Std.: (60*60)*24 page = Dict("load", 'zdf_streamlinks', CacheTime=ZDFlinks_CacheTime) @@ -2035,45 +2047,34 @@ def MakeDetailText(title, summary,tagline,quality,thumb,url): # Textdatei für D #--------------------------------------------------------------------------------------------------- # 30.08.2018 Start Recording TV-Live -# Problem: autom. Wiedereintritt hier + erneuter Popen-call nach Rückkehr zu TVLiveRecordSender -# (Ergebnis-Button nach subprocess.Popen, bei PHT vor Ausführung des Buttons) -# OS-übergreifender Abgleich der pid problematisch - siehe -# https://stackoverflow.com/questions/4084322/killing-a-process-created-with-pythons-subprocess-popen -# Der Wiedereintritt tritt sowohl unter Linux als auch Windows auf. -# Ursach n.b. - tritt in DownloadExtern mit curl/wget nicht auf. -# 1. Lösung: Verwendung des psutil-Moduls (../Contents/Libraries/Shared/psutil ca. 400 KB) -# und pid-Abgleich Dict['PID'] gegen psutil.pid_exists(pid) - s.u. -# verworfen - Modul lässt sich unter Windows nicht laden. Linux OK -# 2. Lösung: Dict['PIDffmpeg'] wird nach subprocess.Popen belegt. Beim ungewollten Wiedereintritt -# wird nach TVLiveRecordSender (Senderliste) zurück gesprungen und Dict['PIDffmpeg'] geleert. -# Beim nächsten manuellen Aufruf wird LiveRecord wieder frei gegeben ("Türsteherfunktion"). -# -# PHT-Problem: wie in TuneIn2017 (streamripper-Aufruf) sprignt PHT bereits vor dem Ergebnis-Buttons (DirectoryObject) -# in LiveRecord zurück. -# Lösung: Ersatz des Ergebnis-Buttons durch return ObjectContainer. PHT steigt allerdings danach mit -# "GetDirectory failed" aus (keine Abhilfe bisher). Der ungewollte Wiedereintritt findet trotzdem -# statt. -# -# 20.12.2018 Plex-Probleme "autom. Wiedereintritt" in Kodi nicht beobachtet (Plex-Sandbox Phänomen?) - Code -# entfernt. -# 29.04.0219 Erweiterung manuelle Eingabe der Aufnahmedauer +# Kopfdoku + Code zu Plex-Problemen entfernt (bei Bedarf s. Github) - +# unter Kodi nicht relevant # # Aufrufer: TVLiveRecordSender, JobMonitor (epgRecord), EPG_ShowAll via Kontextmenü # Check auf ffmpeg-Settings bereits in TVLiveRecordSender, Check auf LiveRecord-Setting # bereits in SenderLiveListePre +# Zur Arbeitsteilung JobMonitor / m3u8-Verfahren siehe Kopfdoku zu JobMonitor +# +# 29.04.0219 Erweiterung manuelle Eingabe der Aufnahmedauer # 04.07.2020 angepasst für epgRecord (Eingabe Dauer entf., Dateiname mit Datumformat # geändert, Notification statt Dialog. epgJob enthält mydate (bereits für # detailtxt verwendet). -# duration-Format: Sekunden (statt "00:00:00") -# verlagert nach util (import aus ardundzdf klappt nicht in epgRecord). -# -def LiveRecord(url, title, duration, laenge, epgJob=''): +# duration-Format: Sekunden (statt "00:00:00", für man. Eingaben konvertiert). +# Verlagert nach util (import aus ardundzdf klappt nicht in epgRecord). +# 24.07.2020 Anpassung für Modul m3u8: JobID wird für KillFile verwendet, für +# LiveRecording wird neuer Aufnahme-Job erzeugt (via JobMain 'setjob') +# +def LiveRecord(url, title, duration, laenge, epgJob='', JobID=''): PLog('LiveRecord:') PLog(url); PLog(title); PLog('duration: %s, laenge: %s' % (duration, laenge)) + + import resources.lib.EPG as EPG # -> now + import resources.lib.epgRecord as epgRecord # setjob in epgRecord.JobMain li = xbmcgui.ListItem() - li = home(li, ID=NAME) # Home-Button + li = home(li, ID=NAME) # Home-Button + icon = R("icon-record.png") if epgJob == '': # epgRecord: o. Eingabe Dauer @@ -2091,6 +2092,10 @@ def LiveRecord(url, title, duration, laenge, epgJob=''): laenge = "%s (Stunden:Minuten)" % duration[:5] # Info nach Start, s.u. PLog('manuell_duration: %s, laenge: %s' % (duration, laenge)) + if ':' in str(duration): # manu. Eingabe (TARGETDURATION gilt, falls größer) + duration = CalculateDuration(duration) + duration = int(duration / 1000) + dest_path = SETTINGS.getSetting('pref_download_path') dest_path = dest_path # Downloadverzeichnis fuer curl/wget verwenden now = datetime.datetime.now() @@ -2099,23 +2104,44 @@ def LiveRecord(url, title, duration, laenge, epgJob=''): if epgJob: dfname = "%s_%s.mp4" % (epgJob, dfname) else: - dfname = "%s_%s.mp4" % (mydate, dfname) + dfname = "%s_%s.mp4" % (mydate, dfname) + PLog("dfname: %s" % dfname) dest_file = os.path.join(dest_path, dfname) if url.startswith('http') == False: # Pfad bilden für lokale m3u8-Datei if url.startswith('rtmp') == False: url = os.path.join(M3U8STORE, url) # rtmp-Url's nicht lokal url = '"%s"' % url # Pfad enthält Leerz. - für ffmpeg in "" kleiden + if SETTINGS.getSetting('pref_m3u8_get') == 'true': + from threading import Thread + import resources.lib.m3u8 as m3u8 + + if epgJob: # Job existiert bereits + play_url = R('ttsMP3_Monitor_Aufnahme_gestartet.mp3') + PlayAudio(play_url, title='', thumb='', Plot='') # ersetzt notification, falls Fenster minimiert + else: # Job für Recording erzeugen + action="setjob" + now = EPG.get_unixtime(onlynow=True) + start_end = "%s|%d" % (now, (int(now) + int(duration))) + descr = "Recording Live ohne EPG" # JobMain: -> 'gestartet' + sender = '' + JobID = epgRecord.JobMain(action, start_end, title, descr, sender, url) + + xbmcgui.Dialog().notification('Aufnahme gestartet:', dfname,icon,3000) + m3u8.Main_m3u8(url, dest_file, duration, JobID) # Thread-Start in Main_m3u8 + return + + if check_Setting('pref_LiveRecord_ffmpegCall') == False: + return cmd = SETTINGS.getSetting('pref_LiveRecord_ffmpegCall') % (url, duration, dest_file) PLog("cmd: " + cmd); - icon = R("icon-record.png") PLog(sys.platform) if sys.platform == 'win32': args = cmd else: args = shlex.split(cmd) - + try: PIDffmpeg = '' sp = subprocess.Popen(args, shell=False) @@ -2130,7 +2156,21 @@ def LiveRecord(url, title, duration, laenge, epgJob=''): # msg3 = "Aufnahmedauer: %s" % laenge PLog('Aufnahme gestartet: %s' % dfname) # MyDialog(msg1, msg2, msg3) - xbmcgui.Dialog().notification(msg1, msg2,icon,3000) + # Kodi unterlässt notification bei minimiertem Fenster, daher + # beim epgJob Austausch gegen Sprachdatei: "ARDundZDF informiert: + # die Aufnahme einer Sendung wurde gestartet" + if epgJob: + url = R('ttsMP3_Monitor_Aufnahme_gestartet.mp3') + PlayAudio(url, title='', thumb='', Plot='') + else: # Job für Recording erzeugen (wie m3u8-Verfahren) + action="setjob" + now = EPG.get_unixtime(onlynow=True) + start_end = "%s|%d" % (now, (int(now) + int(duration))) + descr = "ffmpeg-recording" # JobMain -> return ohne thread-Start + sender = ''; setSetting=''; + JobID = epgRecord.JobMain(action, start_end, title, descr, sender, url, setSetting, PIDffmpeg) + + xbmcgui.Dialog().notification(msg1, msg2,icon,3000) return PIDffmpeg @@ -2143,6 +2183,58 @@ def LiveRecord(url, title, duration, laenge, epgJob=''): xbmcgui.Dialog().notification(msg1, msg2,icon,3000) return li +#--------------------------------------------------------------------------------------------------- +def check_Setting(ID): + PLog('check_Setting: ' + ID) + + if ID == 'pref_LiveRecord_ffmpegCall': + PLog(SETTINGS.getSetting('pref_LiveRecord_ffmpegCall')) + # Test: Pfadanteil executable? + # Bsp.: "/usr/bin/ffmpeg -re -i %s -c copy -t %s %s -nostdin" + cmd = SETTINGS.getSetting('pref_LiveRecord_ffmpegCall') + if cmd.strip() == '': + msg1 = 'ffmpeg-Parameter fehlen in den Einstellungen!' + MyDialog(msg1, '', '') + return False + + if os.path.exists(cmd.split()[0]) == False: + msg1 = 'Pfad zu ffmpeg nicht gefunden.' + msg2 = 'Bitte ffmpeg-Parameter in den Einstellungen prüfen, aktuell:' + msg3 = SETTINGS.getSetting('pref_LiveRecord_ffmpegCall') + MyDialog(msg1, msg2, msg3) + return False + return True + + if ID == 'pref_download_path': + dest_path = SETTINGS.getSetting('pref_download_path') + msg1 = 'LiveRecord:' + if dest_path == None or dest_path.strip() == '': + msg2 = 'Downloadverzeichnis fehlt in den Einstellungen' + MyDialog(msg1, msg2, '') + return False + PLog(os.path.isdir(dest_path)) + + if os.path.isdir(dest_path) == False: + msg2 = 'Downloadverzeichnis existiert nicht' + msg3 = "Settings: " + dest_path + MyDialog(msg1, msg2, msg3) + return False + return True + + if ID == 'pref_use_downloads': + # Test auf Existenz curl/wget in DownloadExtern + if SETTINGS.getSetting('pref_use_downloads') == 'true': + dest_path = SETTINGS.getSetting('pref_download_path') + if os.path.isdir(dest_path) == False: + msg1 = u'test_downloads: Downloads nicht möglich' + msg2 = 'Downloadverzeichnis existiert nicht' + msg3 = "Settings: " + dest_path + MyDialog(msg1, msg2, msg3) + return False + else: + return False + return True + #################################################################################################### # PlayVideo aktuell 23.03.2019: # Sofortstart + Resumefunktion, einschl. Anzeige der Medieninfo: diff --git a/resources/lib/yt.py b/resources/lib/yt.py index 2208e0e..7a6cd69 100644 --- a/resources/lib/yt.py +++ b/resources/lib/yt.py @@ -81,11 +81,13 @@ def yt_get(url, vid, title, tag, summ, thumb): # duration = get_duration(page) # Bsp.: "1:06" oder leer duration = yt_init.millisecs duration = seconds_translate(int(int(duration) / 1000)) - PLog(duration) + PLog("duration: %d" % duration) except Exception as exception: - msg1 = u"Video ist nicht verfügbar" - msg2 = 'Fehler: %s' % str(exception) - MyDialog(msg1, msg2, '') + PLog(str(exception)) + msg1 = u"Youtube-Video nicht verfügbar." + msg2 = 'Fehler: %s' % str(exception) + msg3 = "Video-ID: watch?v=%s" % vid + MyDialog(msg1, msg2, msg3) return # nur mp4-Videos laden diff --git a/resources/livesenderTV.xml b/resources/livesenderTV.xml index 38d224c..2835507 100644 --- a/resources/livesenderTV.xml +++ b/resources/livesenderTV.xml @@ -293,13 +293,13 @@ 07.09.2018 - WDR/ARD Event Sportschau + WDR_ARD Event Sportschau https://wdrardevent1-lh.akamaihd.net/i/ardevent1_weltweit@566648/master.m3u8 https://www.sportschau.de/resources/img/sportschau/banner/logo_base.png Arauco - 22.04.2019 + 18.07.2020 diff --git a/resources/settings.xml b/resources/settings.xml index 9320c82..260ae0a 100644 --- a/resources/settings.xml +++ b/resources/settings.xml @@ -39,13 +39,16 @@ - + + + + diff --git a/resources/ttsMP3_Monitor_Aufnahme_gestartet.mp3 b/resources/ttsMP3_Monitor_Aufnahme_gestartet.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..dee6e1d9ca71cb212d51917fecbd6527eeace097 GIT binary patch literal 30608 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 f_J9Ar