From cc5de9acf46e6cfe3db84a13e984df0c160dae90 Mon Sep 17 00:00:00 2001 From: Erik Kristensen Date: Wed, 18 Aug 2010 21:22:46 -0400 Subject: [PATCH] updated mythtv library --- launch.py | 3 +- mythboxee.py | 565 ++--- mythboxy.py | 336 ++- mythtv/.svn/all-wcprops | 24 +- mythtv/.svn/entries | 54 +- mythtv/.svn/text-base/MythData.py.svn-base | 4 +- mythtv/.svn/text-base/MythFunc.py.svn-base | 13 +- mythtv/.svn/text-base/MythStatic.py.svn-base | 3 +- mythtv/.svn/text-base/__init__.py.svn-base | 3 + mythtv/MythBase.pyc | Bin 70827 -> 0 bytes mythtv/MythData.py | 4 +- mythtv/MythData.pyc | Bin 59403 -> 0 bytes mythtv/MythFunc.py | 13 +- mythtv/MythFunc.pyc | Bin 43198 -> 0 bytes mythtv/MythStatic.py | 3 +- mythtv/MythStatic.pyc | Bin 396 -> 0 bytes mythtv/__init__.py | 3 + mythtv/__init__.pyc | Bin 1636 -> 0 bytes mythtv/tmdb/.svn/all-wcprops | 20 +- mythtv/tmdb/.svn/entries | 12 +- mythtv/ttvdb/.svn/all-wcprops | 28 +- mythtv/ttvdb/.svn/entries | 16 +- patches/MythBase.py.orig | 1868 +++++++++++++++++ patches/MythData.py.orig | 1504 +++++++++++++ patches/mythbase.patch | 131 ++ patches/mythdata.patch | 28 + skin/Boxee Skin NG/720p/load.xml | 22 - skin/Boxee Skin NG/720p/main.xml | 80 +- skin/Boxee Skin NG/720p/settings.xml | 0 skin/Boxee Skin NG/720p/setup.xml | 6 +- skin/Boxee Skin NG/720p/show.xml | 410 ++-- skin/Boxee Skin NG/media/mb_artwork_error.png | Bin 0 -> 13597 bytes 32 files changed, 4513 insertions(+), 640 deletions(-) delete mode 100755 mythtv/MythBase.pyc delete mode 100755 mythtv/MythData.pyc delete mode 100755 mythtv/MythFunc.pyc delete mode 100755 mythtv/MythStatic.pyc delete mode 100755 mythtv/__init__.pyc create mode 100755 patches/MythBase.py.orig create mode 100755 patches/MythData.py.orig create mode 100644 patches/mythbase.patch create mode 100644 patches/mythdata.patch delete mode 100644 skin/Boxee Skin NG/720p/load.xml create mode 100644 skin/Boxee Skin NG/720p/settings.xml create mode 100644 skin/Boxee Skin NG/media/mb_artwork_error.png diff --git a/launch.py b/launch.py index ae2e778..955e128 100644 --- a/launch.py +++ b/launch.py @@ -3,6 +3,7 @@ import sys import signal import mythtv import mythboxee +from mythboxee import MythBoxee # DEBUG # #mc.GetApp().GetLocalConfig().SetValue("dbconn", "") @@ -12,5 +13,5 @@ import mythboxee mc.ActivateWindow(14001) # Lets go ahead and launch the app -mythboxee.Launch() +MythBoxee() diff --git a/mythboxee.py b/mythboxee.py index ec237c7..14cce54 100644 --- a/mythboxee.py +++ b/mythboxee.py @@ -4,267 +4,344 @@ import mythtv from mythtv import MythError from operator import itemgetter, attrgetter -mbbe = None -mbdb = None -config = mc.GetApp().GetLocalConfig() +class MythBoxee: + logLevel = 1 + version = "4.0.beta" -titles = [] -recordings = [] -idbanners = {} -shows = {} + userAgent = "MythBoxee v4.0.beta" + tvdb_apikey = "6BEAB4CB5157AAE0" + + be = None + db = None + + recs = None + titles = [] + recordings = [] + banners = {} + shows = {} + series = {} -def DiscoverBackend(): - mc.ShowDialogNotification("DiscoverBackend") - - pin = config.GetValue("pin") - dbconn = config.GetValue("dbconn") - - if not pin: - pin = 0000 - - try: - db = mythtv.MythDB(SecurityPin=pin) - except Exception, e: - mc.ShowDialogNotification(e.message) - if e.ename == 'DB_CREDENTIALS' and count < 3: - mc.ActivateWindow(14002) - mc.GetWindow(14002).GetControl(6020).SetVisible(False) - mc.GetWindow(14002).GetControl(6010).SetVisible(True) - mc.GetWindow(14002).GetControl(6011).SetFocus() - elif e.ename == 'DB_CONNECTION' or e.ename == 'DB_CREDENTIALS' and count > 3: - mc.ActivateWindow(14002) - mc.GetWindow(14002).GetControl(6010).SetVisible(False) - mc.GetWindow(14002).GetControl(6020).SetVisible(True) - mc.GetWindow(14002).GetControl(6021).SetFocus() - return False - else: - mc.ShowDialogNotification(str(db.dbconn)) - config.SetValue("dbconn", str(db.dbconn)) - return True - - -def Launch(): - # If dbconn isn't set, we'll assume we haven't found the backend. - if not config.GetValue("dbconn"): - discoverBackend = False - while discoverBackend is False: - discoverBackend = DiscoverBackend() - - # Parse our DB info - dbconn = config.GetValue("dbconn") - dbconf = eval(dbconn) - - # Now that the backend has been discovered, lets connect. - try: - mbdb = mythtv.MythDB(**dbconf) - except MythError, e: - print e.message - mc.ShowDialogNotification("Failed to connect to the MythTV Backend") - else: - mbbe = mythtv.MythBE(db=mbdb) - mc.ActivateWindow(14010) - + """ + DiscoverBackend - just as it sounds -def LoadShows(): - del titles[:] - del recordings[:] - idbanners.clear() - shows.clear() + Attempt to discover the MythTV Backend using UPNP protocol, once found + try and gather MySQL database connection information using default PIN + via the XML interface. If that fails then prompt user to enter their + custom SecurityPin, if we fail to gather database information that way + finally prompt user to enter their credentials manually. + """ + def DiscoverBackend(): + self.log("def(DiscoverBackend)") - config = mc.GetApp().GetLocalConfig() - sg = mc.Http() - html = sg.Get("http://" + config.GetValue("server") + ":6544/Myth/GetRecorded") - results = re.compile("(.*?)").findall(html) - for title,subtitle,endtime,airdate,starttime,desc,chanid in results: - if title not in titles: - titles.append(title) - idbanners[title] = GetSeriesIDBanner(title) - shows[title] = [] + pin = self.config.GetValue("pin") + dbconn = self.config.GetValue("dbconn") - single = [title,subtitle,desc,chanid,airdate,starttime,endtime] - recordings.append(single) + if not pin: + pin = 0000 + + try: + self.db = mythtv.MythDB(SecurityPin=pin) + except Exception, e: + if e.ename == 'DB_CREDENTIALS' and count < 2: + mc.ActivateWindow(14002) + mc.GetWindow(14002).GetControl(6020).SetVisible(False) + mc.GetWindow(14002).GetControl(6010).SetVisible(True) + mc.GetWindow(14002).GetControl(6011).SetFocus() + elif e.ename == 'DB_CONNECTION' or e.ename == 'DB_CREDENTIALS' and count > 3: + mc.ActivateWindow(14002) + mc.GetWindow(14002).GetControl(6010).SetVisible(False) + mc.GetWindow(14002).GetControl(6020).SetVisible(True) + mc.GetWindow(14002).GetControl(6021).SetFocus() + return False + else: + self.config.SetValue("dbconn", str(self.db.dbconn)) + return True + + + """ + Lets make a connection to the backend! + """ + def __init__(self): + self.config = mc.GetApp().GetLocalConfig() + + self.log("def(__init__)") + + # If this is the first time the app is being run, lets set some default options. + if not self.config.GetValue("firstrun"): + self.config.SetValue("SortBy", "Original Air Date") + self.config.SetValue("SortDir", "Descending") + self.config.SetValue("firstrun", "1") + + # If dbconn isn't set, we'll assume we haven't found the backend. + if not self.config.GetValue("dbconn"): + discoverBackend = False + while discoverBackend is False: + print "discover" + discoverBackend = self.DiscoverBackend() + + # Parse our DB info + dbconn = self.config.GetValue("dbconn") + dbconf = eval(dbconn) + + # Now that the backend has been discovered, lets connect. + try: + self.db = mythtv.MythDB(**dbconf) + except MythError, e: + print e.message + mc.ShowDialogNotification("Failed to connect to the MythTV Backend") + else: + self.be = mythtv.MythBE(db=self.db) + + """ + GetRecordings - Pulls all of the recordings out of the backend. + + This function also creates some dictionarys and lists of information + that is used throughout the app for different functions. + """ + def GetRecordings(self): + self.titles = [] + self.banners = {} + self.series = {} + self.shows = {} + + self.log("def(GetRecordings)") + + self.recs = self.be.getRecordings() - shows[title].append(single) + x=0 + for recording in self.recs: + if recording.title not in self.titles: + self.titles.append(str(recording.title)) + self.banners[str(recording.title)] = self.GetRecordingArtwork(str(recording.title)) + self.series[str(recording.title)] = self.GetRecordingSeriesID(str(recording.title)) + self.shows[str(recording.title)] = [] + + single = [str(recording.title), str(recording.subtitle), str(recording.description), str(recording.chanid), str(recording.airdate), str(recording.starttime), str(recording.endtime), x] + self.shows[str(recording.title)].append(single) + x = x + 1 + + self.titles.sort() - titles.sort() + items = mc.ListItems() + for title in self.titles: + item = mc.ListItem( mc.ListItem.MEDIA_UNKNOWN ) + item.SetLabel(str(title)) + item.SetThumbnail(self.banners[title]) + item.SetProperty("videos", str(len(self.shows[title]))) + item.SetProperty("seriesid", str(self.series[title])) + items.append(item) + + mc.GetWindow(14001).GetList(1030).SetItems(items) + + + def GetRecordingArtwork(self, title): + sg = mc.Http() + sg.SetUserAgent('MythBoxee v4.0.beta') + html = sg.Get("http://www.thetvdb.com/api/GetSeries.php?seriesname=" + str(title.replace(" ", "%20"))) + banners = re.compile("(.*?)").findall(html) + + try: + artwork = "http://www.thetvdb.com/banners/" + banners[0] + except: + artwork = "mb_artwork_error.png" + + self.log("def(GetRecordingArtwork): " + str(artwork)) + + return artwork + + def GetRecordingSeriesID(self, title): + sg = mc.Http() + sg.SetUserAgent(self.userAgent) + html = sg.Get("http://www.thetvdb.com/api/GetSeries.php?seriesname=" + title.replace(" ", "%20")) + series = re.compile("(.*?)").findall(html) + + try: + seriesid = series[0] + except: + seriesid = 00000 + + self.log("def(GetRecordingSeriesID): title[" + title + "] - seriesid[" + str(seriesid) + "]") + + return seriesid + + + """ + DisplayShow + """ + def DisplayShow(self): + recordingList = mc.GetWindow(14001).GetList(1030) + item = recordingList.GetItem(recordingList.GetFocusedItem()) + title = item.GetLabel() + + # Save the Latest Show Title to what was clicked + # this way the show window has a way to load the data. + self.config.SetValue("LatestShowTitle", title) + self.config.SetValue("LatestShowID", item.GetProperty("seriesid")) + + self.log("def(DisplaySingleShow): Title[" + title + "]") + + # Show the Single Show Window + mc.ActivateWindow(14002) + + itemList = mc.ListItems() + itemList.append(item) + + mc.GetWindow(14002).GetList(2070).SetItems(itemList) + + + """ + LoadShow - items = mc.ListItems() - for title in titles: + Launch function to gather and setup the recordings for a single show. + """ + def LoadShow(self): + title = self.config.GetValue("LatestShowTitle") + seriesid = self.config.GetValue("LatestShowID") + + self.log("def(LoadSingleShow): Title[" + title + "]") + + self.SetSortableOptions() + self.SetSeriesDetails(title, seriesid) + self.LoadShowRecordings(title) + + + """ + LoadShowRecordings + + Determine which show is being displayed and find all the recordings for it. + Then populate the recording list for the singular show for viewer to watch. + """ + def LoadShowRecordings(self, title): + sortBy = self.config.GetValue("SortBy") + sortDir = self.config.GetValue("SortDir") + + episodes = None + + if sortBy == "Original Air Date" and sortDir == "Ascending": + episodes = sorted(self.shows[title], key=itemgetter(4)) + elif sortBy == "Original Air Date" and sortDir == "Descending": + episodes = sorted(self.shows[title], key=itemgetter(4), reverse=True) + elif sortBy == "Recorded Date" and sortDir == "Ascending": + episodes = sorted(self.shows[title], key=itemgetter(5)) + elif sortBy == "Recorded Date" and sortDir == "Descending": + episodes = sorted(self.shows[title], key=itemgetter(5), reverse=True) + elif sortBy == "Title" and sortDir == "Ascending": + episodes = sorted(self.shows[title], key=itemgetter(1)) + elif sortBy == "Title" and sortDir == "Descending": + episodes = sorted(self.shows[title], key=itemgetter(1), reverse=True) + else: + episodes = self.shows[title] + + showitems = mc.ListItems() + for title,subtitle,desc,chanid,airdate,starttime,endtime,ref in episodes: + print title + recording = self.recs[ref] + #showitem = mc.ListItem( mc.ListItem.MEDIA_VIDEO_EPISODE ) + #showitem = mc.ListItem( mc.ListItem.MEDIA_UNKNOWN ) + showitem = mc.ListItem() + showitem.SetLabel(str(recording.subtitle)) + showitem.SetTitle(str(recording.subtitle)) + showitem.SetTVShowTitle(str(recording.title)) + showitem.SetDescription(str(desc)) + showitem.SetProperty("starttime", str(starttime)) + showitem.SetProperty("ref", str(ref)) + + try: + date = str(airdate).split("-") + showitem.SetDate(int(date[0]), int(date[1]), int(date[2])) + except: + showitem.SetDate(2010, 01, 01) + + dbconf = eval(self.config.GetValue("dbconn")) + + #showitem.SetThumbnail("http://192.168.1.210:6544/Myth/GetPreviewImage?ChanId=1050&StartTime=2010-08-05%2021:00:00") + #showitem.SetThumbnail("http://192.168.1.210:6544/Myth/GetPreviewImage?ChanId=" + chanid + "&StartTime=" + starttime.replace("T", "%20")) + showitem.SetThumbnail("http://" + dbconf['DBHostName'] + ":6544/Myth/GetPreviewImage?ChanId=" + chanid + "&StartTime=" + starttime.replace(" ", "%20")) + + #showitem.SetPath("http://" + dbconf['DBHostName'] + ":6544/Myth/GetRecording?ChanId=" + chanid + "&StartTime=" + starttime.replace("T", "%20")) + showitem.SetPath("smb://guest:guest@192.168.1.210/recordings/1050_20100709010000.mpg") + + showitems.append(showitem) + + mc.GetWindow(14002).GetList(2040).SetItems(mc.ListItems()) + mc.GetWindow(14002).GetList(2040).SetItems(showitems) + + + """ + SetShowOptions + + Setup the show options; sort by, sort direction, watched vs unwatched + """ + def SetSortableOptions(self): + sortable = ['Original Air Date', 'Recorded Date', 'Title'] + items = mc.ListItems() + for sorttype in sortable: + item = mc.ListItem( mc.ListItem.MEDIA_UNKNOWN ) + item.SetLabel(sorttype) + items.append(item) + + mc.GetWindow(14002).GetList(2051).SetItems(items) + mc.GetWindow(14002).GetList(2051).SetSelected(1, True) + + sortableby = ['Ascending', 'Descending'] + items = mc.ListItems() + for sorttype in sortableby: + item = mc.ListItem( mc.ListItem.MEDIA_UNKNOWN ) + item.SetLabel(sorttype) + items.append(item) + + mc.GetWindow(14002).GetList(2061).SetItems(items) + mc.GetWindow(14002).GetList(2061).SetSelected(1, True) + + + def SetSeriesDetails(self, title, seriesid): + sg = mc.Http() + sg.SetUserAgent(self.userAgent) + html = sg.Get("http://thetvdb.com/api/" + self.tvdb_apikey + "/series/" + seriesid + "/") + overview = re.compile("(.*?)").findall(html) + poster = re.compile("(.*?)").findall(html) + items = mc.ListItems() item = mc.ListItem( mc.ListItem.MEDIA_UNKNOWN ) item.SetLabel(title) - item.SetThumbnail(idbanners[title][1]) - item.SetProperty("seriesid", idbanners[title][0]) - items.append(item) - - mc.GetWindow(14000).GetList(13).SetItems(items) - - -def LoadSingleShow(): - config = mc.GetApp().GetLocalConfig() - ilist = mc.GetActiveWindow().GetList(13) - item = ilist.GetItem(ilist.GetFocusedItem()) - name = item.GetLabel() - config.SetValue("seriesid", item.GetProperty("seriesid")) - config.SetValue("show", name) - mc.ActivateWindow(14001) - - SetSortables() - GetSetSeriesDetails(name, item.GetProperty("seriesid")) - LoadSeriesEpisodes(name) - - -def SetSortables(): - config.SetValue("SortBy", "Recorded Date") - config.SetValue("SortDir", "Descending") - sortable = ['Original Air Date', 'Recorded Date', 'Title'] - items = mc.ListItems() - for sorttype in sortable: - item = mc.ListItem( mc.ListItem.MEDIA_UNKNOWN ) - item.SetLabel(sorttype) - items.append(item) - - mc.GetActiveWindow().GetList(2014).SetItems(items) - mc.GetActiveWindow().GetList(2014).SetSelected(1, True) - - sortableby = ['Ascending', 'Descending'] - items = mc.ListItems() - for sorttype in sortableby: - item = mc.ListItem( mc.ListItem.MEDIA_UNKNOWN ) - item.SetLabel(sorttype) - items.append(item) + item.SetTitle(title) + try: + item.SetDescription(overview[0]) + item.SetProperty("description", overview[0]) + except: + item.SetDescription("No Description") + item.SetProperty("description", "No Description") - mc.GetActiveWindow().GetList(2015).SetItems(items) - mc.GetActiveWindow().GetList(2015).SetSelected(1, True) - - -def ShowEpisodeDetails(): - print "ShowEpisodeDetails" - - -def SortBySeriesEpisodes(): - sortByItems = sortByItemNumber = mc.GetWindow(14001).GetList(2014).GetSelected() - sortDirectionItems = sortDirectionItemNumber = mc.GetWindow(14001).GetList(2015).GetSelected() - - mc.GetActiveWindow().GetList(2014).UnselectAll() - mc.GetActiveWindow().GetList(2014).SetSelected(mc.GetActiveWindow().GetList(2014).GetFocusedItem(), True) - - config.SetValue("SortBy", mc.GetActiveWindow().GetList(2014).GetItem(mc.GetActiveWindow().GetList(2014).GetFocusedItem()).GetLabel()) - - LoadSeriesEpisodes(config.GetValue("name")) - - -def SortDirSeriesEpisodes(): - sortByItems = sortByItemNumber = mc.GetWindow(14001).GetList(2014).GetSelected() - - mc.GetActiveWindow().GetList(2015).UnselectAll() - mc.GetActiveWindow().GetList(2015).SetSelected(mc.GetActiveWindow().GetList(2015).GetFocusedItem(), True) - - config.SetValue("SortDir", mc.GetActiveWindow().GetList(2015).GetItem(mc.GetActiveWindow().GetList(2015).GetFocusedItem()).GetLabel()) - - LoadSeriesEpisodes(config.GetValue("name")) - -def GetSeriesIDBanner(name): - sg = mc.Http() - sg.SetUserAgent('MythBoxee v3.0.beta') - html = sg.Get("http://www.thetvdb.com/api/GetSeries.php?seriesname=" + name.replace(" ", "%20")) - series = re.compile("(.*?)").findall(html) - banners = re.compile("(.*?)").findall(html) - show = [] - if series: - show.append(series[0]) - show.append("http://www.thetvdb.com/banners/" + banners[0]) - else: - show.append("00000") - show.append("http://192.168.1.210/") - return show - - -def GetSetSeriesDetails(name, seriesid): - sg = mc.Http() - sg.SetUserAgent('MythBoxee v3.0.beta') - html = sg.Get("http://thetvdb.com/api/6BEAB4CB5157AAE0/series/" + seriesid + "/") - overview = re.compile("(.*?)").findall(html) - poster = re.compile("(.*?)").findall(html) - items = mc.ListItems() - item = mc.ListItem( mc.ListItem.MEDIA_UNKNOWN ) - item.SetLabel(name) - item.SetTitle(name) - if overview: - item.SetDescription(overview[0]) - item.SetProperty("description", overview[0]) - item.SetThumbnail("http://www.thetvdb.com/banners/" + poster[0]) - items.append(item) - - mc.GetWindow(14001).GetList(21).SetItems(items) - - -def LoadSeriesEpisodes(name): - config = mc.GetApp().GetLocalConfig() - config.SetValue("name", name) - showitems = mc.ListItems() - - sortBy = config.GetValue("SortBy") - sortDir = config.GetValue("SortDir") - - print shows[name] - - if sortBy == "Original Air Date" and sortDir == "Ascending": - episodes = sorted(shows[name], key=itemgetter(4)) - elif sortBy == "Original Air Date" and sortDir == "Descending": - episodes = sorted(shows[name], key=itemgetter(4), reverse=True) - elif sortBy == "Recorded Date" and sortDir == "Ascending": - episodes = sorted(shows[name], key=itemgetter(5)) - elif sortBy == "Recorded Date" and sortDir == "Descending": - episodes = sorted(shows[name], key=itemgetter(5), reverse=True) - elif sortBy == "Title" and sortDir == "Ascending": - episodes = sorted(shows[name], key=itemgetter(1)) - elif sortBy == "Title" and sortDir == "Descending": - episodes = sorted(shows[name], key=itemgetter(1), reverse=True) - else: - episodes = shows[name] - - for title,subtitle,desc,chanid,airdate,starttime,endtime in episodes: - showitem = mc.ListItem( mc.ListItem.MEDIA_VIDEO_EPISODE ) - showitem.SetLabel(subtitle) - showitem.SetTitle(subtitle) - showitem.SetTVShowTitle(name) - showitem.SetDescription(desc) - date = airdate.split("-") - showitem.SetProperty("starttime", starttime) - showitem.SetDate(int(date[0]), int(date[1]), int(date[2])) - showitem.SetThumbnail("http://" + config.GetValue("server") + ":6544/Myth/GetPreviewImage?ChanId=" + chanid + "&StartTime=" + starttime.replace("T", "%20")) - showitem.SetPath("http://" + config.GetValue("server") + ":6544/Myth/GetRecording?ChanId=" + chanid + "&StartTime=" + starttime.replace("T", "%20")) - showitems.append(showitem) - - mc.GetActiveWindow().GetList(2013).SetItems(showitems) - - - -def GetServer(): - config = mc.GetApp().GetLocalConfig() - server = config.GetValue("server") - response = mc.ShowDialogKeyboard("Enter IP Address of MythTV Backend Server", server, False) - url = "http://" + response + ":6544/Myth/GetServDesc" - if VerifyServer(url) == True: - config.SetValue("server", response) - -def VerifyServer(url): - config = mc.GetApp().GetLocalConfig() - http = mc.Http() - data = http.Get(url) - if http.GetHttpResponseCode() == 200: - config.SetValue("verified", "1") - return True - else: - return False - - + try: + item.SetThumbnail("http://www.thetvdb.com/banners/" + poster[0]) + except: + item.SetThumbnail("mb_poster_error.png") + + items.append(item) + + mc.GetWindow(14002).GetList(2070).SetItems(items) + def PlayRecording(self): + self.log("def(PlayRecording): ") + + sl = mc.GetWindow(14002).GetList(2040) + item = sl.GetItem(sl.GetFocusedItem()) + ref = item.GetProperty("ref") + + file = self.recs[int(ref)].open('r', self.db) + mc.ShowDialogNotification(item.GetProperty("ref")) + mc.ShowDialogNotification("Playing: " + item.GetLabel()) + def log(self, message): + if self.logLevel >= 2: + mc.ShowDialogNotification(message) + + if self.logLevel >= 1: + mc.LogInfo(">>> MythBoxee (" + self.version + ")\: " + message) + print ">>> MythBoxee (" + self.version + ")\: " + message diff --git a/mythboxy.py b/mythboxy.py index 1346ef0..bbd63c6 100644 --- a/mythboxy.py +++ b/mythboxy.py @@ -1,21 +1,329 @@ import mc +import re +import mythtv +from mythtv import MythError +from operator import itemgetter, attrgetter + + +class MythBoxee: + be = None + db = None + + def DiscoverBackend(): + config = mc.GetApp().GetLocalConfig() + mc.ShowDialogNotification("DiscoverBackend") + + pin = config.GetValue("pin") + dbconn = config.GetValue("dbconn") -def CreateConnection(): - try: - pin = mc.GetApp().GetLocalConfig().GetValue("pin") if not pin: pin = 0000 + + try: + self.db = mythtv.MythDB(SecurityPin=pin) + except Exception, e: + mc.ShowDialogNotification(e.message) + if e.ename == 'DB_CREDENTIALS' and count < 3: + mc.ActivateWindow(14002) + mc.GetWindow(14002).GetControl(6020).SetVisible(False) + mc.GetWindow(14002).GetControl(6010).SetVisible(True) + mc.GetWindow(14002).GetControl(6011).SetFocus() + elif e.ename == 'DB_CONNECTION' or e.ename == 'DB_CREDENTIALS' and count > 3: + mc.ActivateWindow(14002) + mc.GetWindow(14002).GetControl(6010).SetVisible(False) + mc.GetWindow(14002).GetControl(6020).SetVisible(True) + mc.GetWindow(14002).GetControl(6021).SetFocus() + return False + else: + mc.ShowDialogNotification(str(self.db.dbconn)) + config.SetValue("dbconn", str(self.db.dbconn)) + return True + + def __init__(self): + config = mc.GetApp().GetLocalConfig() + # If dbconn isn't set, we'll assume we haven't found the backend. + if not config.GetValue("dbconn"): + discoverBackend = False + while discoverBackend is False: + discoverBackend = self.DiscoverBackend() + + # Parse our DB info + dbconn = config.GetValue("dbconn") + dbconf = eval(dbconn) + + # Now that the backend has been discovered, lets connect. + try: + self.db = mythtv.MythDB(**dbconf) + except MythError, e: + print e.message + mc.ShowDialogNotification("Failed to connect to the MythTV Backend") + else: + self.be = mythtv.MythBE(db=self.db) + mc.ActivateWindow(14010) + + + +mbbe = None +mbdb = None + +config = mc.GetApp().GetLocalConfig() + +titles = [] +recordings = [] +idbanners = {} +shows = {} + + +def DiscoverBackend1(): + mc.ShowDialogNotification("DiscoverBackend") + + pin = config.GetValue("pin") + dbconn = config.GetValue("dbconn") + + if not pin: + pin = 0000 + + try: db = mythtv.MythDB(SecurityPin=pin) except Exception, e: - if e.ename == 'DB_CREDENTIALS': - mc.ShowDialogNotification("Unable to connect, try to manually set the security pin.") - mc.ActivateWindow(14006) - mc.GetWindow(14006).GetControl(6010).SetVisible(True) - mc.GetWindow(14006).GetControl(6020).SetVisible(False) - mc.GetWindow(14006).GetControl(6011).SetFocus() - elif e.ename == 'DB_CONNECTION': - mc.ActivateWindow(14006) - mc.GetWindow(14006).GetControl(6010).SetVisible(False) - mc.GetWindow(14006).GetControl(6020).SetVisible(True) - mc.GetWindow(14006).GetControl(6021).SetFocus() + mc.ShowDialogNotification(e.message) + if e.ename == 'DB_CREDENTIALS' and count < 3: + mc.ActivateWindow(14002) + mc.GetWindow(14002).GetControl(6020).SetVisible(False) + mc.GetWindow(14002).GetControl(6010).SetVisible(True) + mc.GetWindow(14002).GetControl(6011).SetFocus() + elif e.ename == 'DB_CONNECTION' or e.ename == 'DB_CREDENTIALS' and count > 3: + mc.ActivateWindow(14002) + mc.GetWindow(14002).GetControl(6010).SetVisible(False) + mc.GetWindow(14002).GetControl(6020).SetVisible(True) + mc.GetWindow(14002).GetControl(6021).SetFocus() + return False + else: + mc.ShowDialogNotification(str(db.dbconn)) + config.SetValue("dbconn", str(db.dbconn)) + return True + + +def Launch(): + # If dbconn isn't set, we'll assume we haven't found the backend. + if not config.GetValue("dbconn"): + discoverBackend = False + while discoverBackend is False: + discoverBackend = DiscoverBackend() + + # Parse our DB info + dbconn = config.GetValue("dbconn") + dbconf = eval(dbconn) + + # Now that the backend has been discovered, lets connect. + try: + mbdb = mythtv.MythDB(**dbconf) + except MythError, e: + print e.message + mc.ShowDialogNotification("Failed to connect to the MythTV Backend") + else: + mbbe = mythtv.MythBE(db=mbdb) + mc.ActivateWindow(14010) + + +def LoadShows(): + del titles[:] + del recordings[:] + idbanners.clear() + shows.clear() + + config = mc.GetApp().GetLocalConfig() + sg = mc.Http() + html = sg.Get("http://" + config.GetValue("server") + ":6544/Myth/GetRecorded") + results = re.compile("(.*?)").findall(html) + for title,subtitle,endtime,airdate,starttime,desc,chanid in results: + if title not in titles: + titles.append(title) + idbanners[title] = GetSeriesIDBanner(title) + shows[title] = [] + + single = [title,subtitle,desc,chanid,airdate,starttime,endtime] + recordings.append(single) + + shows[title].append(single) + + titles.sort() + + items = mc.ListItems() + for title in titles: + item = mc.ListItem( mc.ListItem.MEDIA_UNKNOWN ) + item.SetLabel(title) + item.SetThumbnail(idbanners[title][1]) + item.SetProperty("seriesid", idbanners[title][0]) + items.append(item) + + mc.GetWindow(14000).GetList(13).SetItems(items) + + +def LoadSingleShow(): + config = mc.GetApp().GetLocalConfig() + ilist = mc.GetActiveWindow().GetList(13) + item = ilist.GetItem(ilist.GetFocusedItem()) + name = item.GetLabel() + config.SetValue("seriesid", item.GetProperty("seriesid")) + config.SetValue("show", name) + mc.ActivateWindow(14001) + + SetSortables() + GetSetSeriesDetails(name, item.GetProperty("seriesid")) + LoadSeriesEpisodes(name) + + +def SetSortables(): + config.SetValue("SortBy", "Recorded Date") + config.SetValue("SortDir", "Descending") + sortable = ['Original Air Date', 'Recorded Date', 'Title'] + items = mc.ListItems() + for sorttype in sortable: + item = mc.ListItem( mc.ListItem.MEDIA_UNKNOWN ) + item.SetLabel(sorttype) + items.append(item) + + mc.GetActiveWindow().GetList(2014).SetItems(items) + mc.GetActiveWindow().GetList(2014).SetSelected(1, True) + + sortableby = ['Ascending', 'Descending'] + items = mc.ListItems() + for sorttype in sortableby: + item = mc.ListItem( mc.ListItem.MEDIA_UNKNOWN ) + item.SetLabel(sorttype) + items.append(item) + + mc.GetActiveWindow().GetList(2015).SetItems(items) + mc.GetActiveWindow().GetList(2015).SetSelected(1, True) + + +def ShowEpisodeDetails(): + print "ShowEpisodeDetails" + + +def SortBySeriesEpisodes(): + sortByItems = sortByItemNumber = mc.GetWindow(14001).GetList(2014).GetSelected() + sortDirectionItems = sortDirectionItemNumber = mc.GetWindow(14001).GetList(2015).GetSelected() + + mc.GetActiveWindow().GetList(2014).UnselectAll() + mc.GetActiveWindow().GetList(2014).SetSelected(mc.GetActiveWindow().GetList(2014).GetFocusedItem(), True) + + config.SetValue("SortBy", mc.GetActiveWindow().GetList(2014).GetItem(mc.GetActiveWindow().GetList(2014).GetFocusedItem()).GetLabel()) + + LoadSeriesEpisodes(config.GetValue("name")) + + +def SortDirSeriesEpisodes(): + sortByItems = sortByItemNumber = mc.GetWindow(14001).GetList(2014).GetSelected() + + mc.GetActiveWindow().GetList(2015).UnselectAll() + mc.GetActiveWindow().GetList(2015).SetSelected(mc.GetActiveWindow().GetList(2015).GetFocusedItem(), True) + + config.SetValue("SortDir", mc.GetActiveWindow().GetList(2015).GetItem(mc.GetActiveWindow().GetList(2015).GetFocusedItem()).GetLabel()) + + LoadSeriesEpisodes(config.GetValue("name")) + +def GetSeriesIDBanner(name): + sg = mc.Http() + sg.SetUserAgent('MythBoxee v3.0.beta') + html = sg.Get("http://www.thetvdb.com/api/GetSeries.php?seriesname=" + name.replace(" ", "%20")) + series = re.compile("(.*?)").findall(html) + banners = re.compile("(.*?)").findall(html) + show = [] + if series: + show.append(series[0]) + show.append("http://www.thetvdb.com/banners/" + banners[0]) + else: + show.append("00000") + show.append("http://192.168.1.210/") + return show + + +def GetSetSeriesDetails(name, seriesid): + sg = mc.Http() + sg.SetUserAgent('MythBoxee v3.0.beta') + html = sg.Get("http://thetvdb.com/api/6BEAB4CB5157AAE0/series/" + seriesid + "/") + overview = re.compile("(.*?)").findall(html) + poster = re.compile("(.*?)").findall(html) + items = mc.ListItems() + item = mc.ListItem( mc.ListItem.MEDIA_UNKNOWN ) + item.SetLabel(name) + item.SetTitle(name) + if overview: + item.SetDescription(overview[0]) + item.SetProperty("description", overview[0]) + item.SetThumbnail("http://www.thetvdb.com/banners/" + poster[0]) + items.append(item) + + mc.GetWindow(14001).GetList(21).SetItems(items) + + +def LoadSeriesEpisodes(name): + config = mc.GetApp().GetLocalConfig() + config.SetValue("name", name) + showitems = mc.ListItems() + + sortBy = config.GetValue("SortBy") + sortDir = config.GetValue("SortDir") + + print shows[name] + + if sortBy == "Original Air Date" and sortDir == "Ascending": + episodes = sorted(shows[name], key=itemgetter(4)) + elif sortBy == "Original Air Date" and sortDir == "Descending": + episodes = sorted(shows[name], key=itemgetter(4), reverse=True) + elif sortBy == "Recorded Date" and sortDir == "Ascending": + episodes = sorted(shows[name], key=itemgetter(5)) + elif sortBy == "Recorded Date" and sortDir == "Descending": + episodes = sorted(shows[name], key=itemgetter(5), reverse=True) + elif sortBy == "Title" and sortDir == "Ascending": + episodes = sorted(shows[name], key=itemgetter(1)) + elif sortBy == "Title" and sortDir == "Descending": + episodes = sorted(shows[name], key=itemgetter(1), reverse=True) + else: + episodes = shows[name] + + for title,subtitle,desc,chanid,airdate,starttime,endtime in episodes: + showitem = mc.ListItem( mc.ListItem.MEDIA_VIDEO_EPISODE ) + showitem.SetLabel(subtitle) + showitem.SetTitle(subtitle) + showitem.SetTVShowTitle(name) + showitem.SetDescription(desc) + date = airdate.split("-") + showitem.SetProperty("starttime", starttime) + showitem.SetDate(int(date[0]), int(date[1]), int(date[2])) + showitem.SetThumbnail("http://" + config.GetValue("server") + ":6544/Myth/GetPreviewImage?ChanId=" + chanid + "&StartTime=" + starttime.replace("T", "%20")) + showitem.SetPath("http://" + config.GetValue("server") + ":6544/Myth/GetRecording?ChanId=" + chanid + "&StartTime=" + starttime.replace("T", "%20")) + showitems.append(showitem) + + mc.GetActiveWindow().GetList(2013).SetItems(showitems) + + + +def GetServer(): + config = mc.GetApp().GetLocalConfig() + server = config.GetValue("server") + response = mc.ShowDialogKeyboard("Enter IP Address of MythTV Backend Server", server, False) + url = "http://" + response + ":6544/Myth/GetServDesc" + if VerifyServer(url) == True: + config.SetValue("server", response) + +def VerifyServer(url): + config = mc.GetApp().GetLocalConfig() + http = mc.Http() + data = http.Get(url) + if http.GetHttpResponseCode() == 200: + config.SetValue("verified", "1") + return True + else: + return False + + + + + + + + diff --git a/mythtv/.svn/all-wcprops b/mythtv/.svn/all-wcprops index 4b52ac5..edc90f4 100644 --- a/mythtv/.svn/all-wcprops +++ b/mythtv/.svn/all-wcprops @@ -1,35 +1,35 @@ K 25 svn:wc:ra_dav:version-url -V 67 -/svn/!svn/ver/24521/tags/release-0-23/mythtv/bindings/python/MythTV +V 69 +/svn/!svn/ver/25397/tags/release-0-23-1/mythtv/bindings/python/MythTV END MythStatic.py K 25 svn:wc:ra_dav:version-url -V 81 -/svn/!svn/ver/24521/tags/release-0-23/mythtv/bindings/python/MythTV/MythStatic.py +V 83 +/svn/!svn/ver/25397/tags/release-0-23-1/mythtv/bindings/python/MythTV/MythStatic.py END MythData.py K 25 svn:wc:ra_dav:version-url -V 79 -/svn/!svn/ver/24521/tags/release-0-23/mythtv/bindings/python/MythTV/MythData.py +V 81 +/svn/!svn/ver/25397/tags/release-0-23-1/mythtv/bindings/python/MythTV/MythData.py END MythBase.py K 25 svn:wc:ra_dav:version-url -V 79 -/svn/!svn/ver/24521/tags/release-0-23/mythtv/bindings/python/MythTV/MythBase.py +V 81 +/svn/!svn/ver/25397/tags/release-0-23-1/mythtv/bindings/python/MythTV/MythBase.py END MythFunc.py K 25 svn:wc:ra_dav:version-url -V 79 -/svn/!svn/ver/24521/tags/release-0-23/mythtv/bindings/python/MythTV/MythFunc.py +V 81 +/svn/!svn/ver/25397/tags/release-0-23-1/mythtv/bindings/python/MythTV/MythFunc.py END __init__.py K 25 svn:wc:ra_dav:version-url -V 79 -/svn/!svn/ver/24521/tags/release-0-23/mythtv/bindings/python/MythTV/__init__.py +V 81 +/svn/!svn/ver/25397/tags/release-0-23-1/mythtv/bindings/python/MythTV/__init__.py END diff --git a/mythtv/.svn/entries b/mythtv/.svn/entries index 5048e1f..8690d9b 100644 --- a/mythtv/.svn/entries +++ b/mythtv/.svn/entries @@ -1,15 +1,15 @@ 10 dir -25361 -http://svn.mythtv.org/svn/tags/release-0-23/mythtv/bindings/python/MythTV +25726 +http://svn.mythtv.org/svn/tags/release-0-23-1/mythtv/bindings/python/MythTV http://svn.mythtv.org/svn -2010-05-05T00:45:58.150174Z -24420 -wagnerrp +2010-07-22T02:21:00.112292Z +25396 +gigem @@ -32,11 +32,11 @@ file -2010-07-16T22:31:04.000000Z -c362740b39721bf47554a27b8dcb7255 -2010-01-30T01:25:12.140142Z -23365 -wagnerrp +2010-08-18T01:49:43.000000Z +2645324c83c882ef4d85662fe2c3c9d7 +2010-07-22T02:21:00.112292Z +25396 +gigem has-props @@ -58,7 +58,7 @@ has-props -233 +261 MythData.py file @@ -66,10 +66,10 @@ file -2010-07-16T22:31:04.000000Z -ea13afe13bb398e11e7895be6654e06e -2010-04-21T04:46:55.439637Z -24220 +2010-08-18T01:49:43.000000Z +f973584ab4647d8e5f7a0eb16d51bf16 +2010-06-07T00:03:02.104550Z +25014 wagnerrp has-props @@ -92,7 +92,7 @@ has-props -57313 +57441 ttvdb dir @@ -103,7 +103,7 @@ file -2010-07-16T22:31:04.000000Z +2010-08-18T01:49:43.000000Z 4e2f2010af14b04d6ebb3ead0606faa1 2010-05-05T00:45:58.150174Z 24420 @@ -137,10 +137,10 @@ file -2010-07-16T22:31:04.000000Z -80c438b974b0dadaed423f9308c04912 -2010-03-31T19:07:44.868318Z -23878 +2010-08-18T01:49:43.000000Z +3f6f4d43c4dc47e8c6ab2ab747d8042f +2010-05-23T19:52:40.992320Z +24818 wagnerrp has-props @@ -163,7 +163,7 @@ has-props -41463 +41550 tmdb dir @@ -174,10 +174,10 @@ file -2010-07-16T22:31:04.000000Z -5c15a4df6a8f262fb7bdd3f91b32119a -2010-05-03T05:03:57.011023Z -24346 +2010-08-18T01:49:43.000000Z +fc69a94c478228940c6be7eee98a8903 +2010-06-22T03:35:41.632640Z +25154 wagnerrp has-props @@ -200,5 +200,5 @@ has-props -1464 +1532 diff --git a/mythtv/.svn/text-base/MythData.py.svn-base b/mythtv/.svn/text-base/MythData.py.svn-base index 14b2f34..d1e4ac5 100644 --- a/mythtv/.svn/text-base/MythData.py.svn-base +++ b/mythtv/.svn/text-base/MythData.py.svn-base @@ -175,7 +175,7 @@ class FileTransfer( MythBEConn ): if not self.open: return self.control.backendCommand('QUERY_FILETRANSFER '\ - +BACKEND_SEP.join([str(self.sockno), 'JOIN'])) + +BACKEND_SEP.join([str(self.sockno), 'DONE'])) self.socket.shutdown(1) self.socket.close() self.open = False @@ -526,6 +526,8 @@ class Program( DictData ): if type != 'r': raise MythFileError(MythError.FILE_FAILED_WRITE, self.filename, 'Program () objects cannot be opened for writing') + if not self.filename.startswith('myth://'): + self.filename = 'myth://%s/%s' % (self.hostname, self.filename) return ftopen(self.filename, 'r') class Record( DBDataWrite ): diff --git a/mythtv/.svn/text-base/MythFunc.py.svn-base b/mythtv/.svn/text-base/MythFunc.py.svn-base index 2fe1dff..89b43e2 100644 --- a/mythtv/.svn/text-base/MythFunc.py.svn-base +++ b/mythtv/.svn/text-base/MythFunc.py.svn-base @@ -392,13 +392,16 @@ class MythBE( FileOps ): 'filenames' is a dictionary, where the values are the file sizes. """ def walk(self, host, sg, root, path): - dn, fn, fs = self.getSGList(host, sg, root+path+'/') - res = [list(dn), dict(zip(fn, fs))] + res = self.getSGList(host, sg, root+path+'/') + if res < 0: + return {} + dlist = list(res[0]) + res = [dlist, dict(zip(res[1],res[2]))] if path == '': res = {'/':res} else: res = {path:res} - for d in dn: + for d in dlist: res.update(walk(self, host, sg, root, path+'/'+d)) return res @@ -820,7 +823,7 @@ class MythDB( MythDBBase ): def getGuideData(self, chanid, date): return self.searchGuide(chanid=chanid, - custom=('DATE(starttime)=%s',date)) + custom=(('DATE(starttime)=%s',date),)) def getSetting(self, value, hostname=None): if not hostname: @@ -996,7 +999,7 @@ class MythVideo( MythDBBase ): tpath = sgfold[0][1:]+'/'+sgfile # filter by extension - if tpath.rsplit('.',1)[1].lower() not in extensions: + if tpath.rsplit('.',1)[-1].lower() not in extensions: #print 'skipping: '+tpath continue diff --git a/mythtv/.svn/text-base/MythStatic.py.svn-base b/mythtv/.svn/text-base/MythStatic.py.svn-base index 11f7ea6..b101dc2 100644 --- a/mythtv/.svn/text-base/MythStatic.py.svn-base +++ b/mythtv/.svn/text-base/MythStatic.py.svn-base @@ -4,9 +4,10 @@ Contains any static and global variables for MythTV Python Bindings """ +OWN_VERSION = (0,23,1,0) SCHEMA_VERSION = 1254 MVSCHEMA_VERSION = 1032 NVSCHEMA_VERSION = 1004 -PROTO_VERSION = 56 +PROTO_VERSION = 23056 PROGRAM_FIELDS = 47 BACKEND_SEP = '[]:[]' diff --git a/mythtv/.svn/text-base/__init__.py.svn-base b/mythtv/.svn/text-base/__init__.py.svn-base index d8115ff..e39b5bb 100644 --- a/mythtv/.svn/text-base/__init__.py.svn-base +++ b/mythtv/.svn/text-base/__init__.py.svn-base @@ -36,6 +36,9 @@ if version_info >= (2, 6): # 2.6 or newer else: exec(import25) +__version__ = OWN_VERSION +MythStatic.mysqldb = MySQLdb.__version__ + if __name__ == '__main__': banner = 'MythTV Python interactive shell.' import code diff --git a/mythtv/MythBase.pyc b/mythtv/MythBase.pyc deleted file mode 100755 index a3586d4e8606ed7b073c1c9e50009164dcafac90..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 70827 zcmeFa3v^vqdf#_00D=U?Hz`uo=y6321yDm$kI`sm7)moF07;x7LCygsjyNO3Anql3 zCE#8V2M|fyn%I`&nWoLd&clh_yqq*?oH&j1XcK!m88^05I}g`Qn>KB-S|?4Krg7}N z+O#WK?(hHq&g+5zLCT)Q=_;gqxX(WO?6dc`zx}=U-aq%xwr~AM-!y%8F3JCP^Y3T* zWy=Fe(n^xcTpLMiAbF>eTvTc>d1oNGJm6f?gURK=B0ZE`9xBqClFOTl^l)-{xJYkK zE^l_48n;A^L&-ZM$^1xic_eA*3!9R6wkGorB$ppZ=C>x7w?^)8^3JwoetU9xd)#kM z-r14N?@TW5jQcIgJDZaEUCHHLaX*s0vpbpJlU&}z{nn(tH@QmP4sTxd+SKN0QdVMeaw++((nvBSr3`W$t51>#-vD@iO=Eq}42P zpD1&iN$XJ3e!?w#vdle{v_6`&4>|X6nfqkYdaB4BEptDbw8o0uBW3R4r1f->`>`_j zsibwZfakF?cQk1oPuh?8YtNLqV?wU!v1f&SN4adhu+)3E(`skUE3;XW!zlt zEj4H7=Gs}->0WD|yS;q<;u~8#zs%oQgSVD-d3q|+TS&KNm)pyoh4vT-oh)zSlFhf< zi_1g0RleWa%-zb;d~dPcjSLmMP~C3h_TBbU*6DR;I^C=bHH{Z^5ug`8|;2aneR7}%OCM=U^BOmB>&>eqMU`Byx>c*o#&qb?dSz)!06HDa<92asyRD9AK%ZKE8XSZ%G~vKD+W<>u$k8r z)S}&L&Sn)TL9XL58XBR#_8&XegvJ~@R%TN6OQTic(YO3$Su{atd}g`5Gz$SJ=e#J2 z=lqlPIj=e8X|DuK)6*V~$$nA1f+(W!BM+2dSbh~JhZGfDDok>EPu9BN9SmMT2e z;gYx#)3;8y)xKr=w$N*}ozS$10z(wd+VfY_2S_Xrb6K2SV)BfUN_@%okXnGQF2DPX z$SYH?eSGn@$y+g5O{Dd5GBDB@Y0Rm|#m{ZmXK47e>Eu*Yt^(XQS4Q<8887f@m#=?sbeD05r!E{_hD&j$SrNh3yy@9Oc@0p9i~2^v!!^`A#v z)PXU9FWt&zS-11;ZYqRY*0=<&nZhg!VAIRJ#lSLXnMck8Z(#cEciOi%2C7=o1v6gc z-)tY3q_MN{NMmc`Kx0>96TfZz#>DvYvDVC(nORmFXJ){qm3eN{o!q9oba{|V^;xIZ zL7r5c@)>@adOh42*)-C~RM7s9jULWM#{{<=B4Q_i zKrpz22Y5SfuDbW6pmO-fAM@3r|^(flc1 z_du)py9e$ZRFRLqrsvbD7*wY3dIR?audX|AOoJFQ4RQ?77E+&Y-(EE|snGvk`^B(y zmfH(6GnaTN`ZdI`tT80dQJ}A1cMJ*I3z`+3y8|N+@oueStscC;+glco>)mX2+cz!S ztRA?>)DNMAX@R?yKB7E+ak)FnF=o-XZ>IcR$Lt^lmdoxnM9JRwjHD+8nO8&1ZzMhh z{51y{mTc9u&__3JqX^>63=;P8Y^R&e%v|R_)7T~z%h5N-l`oQ1l5-O8Lwh~xBm$)K>9i;SZT2@cI3s)fZJJ(lc<#8JL#8R{M` zW*0~~S8g}O;B)DwUraR*`cRNC0W5mZEg`aLPV_{-jmIJT?Q22ABqkeMA0|=Y8w!;V zjUVQXf=V=U<-@_{53B_jYF$_hEFedLEn}d|QGfXe@^EMA8;d#`;17p7V*Ub>yTr`Q zXH$hcf`2)OMPw)siL@>mv?Y6rB9hsw2~_A=tC8Jv-c(2>(!;!XKQsOi<@s{n#)^5B zZ4}`MIs>V%5kF0W^rKuVQkOodTYh&Z#WFr1dd$$buaaC%YsxQ+!mSORU&j+cr#_hq z_(a}ObQ%=awdq5&SPe}D=lcMcHHi4aZ{*Er`Wir!R9gb=V8Jg8G7_lY`fyT@V7u5`{9b)cxL0Gg8@D1o#tZk;Yau^hQ^&uL zWc`b3h<@jle}GG1P>8pPx{QA4R{;dIHG~c+87qsjI#jZeDZ=d1walE1sdkeIk!^@+ zsbtmP!>`^jeXpFAs^YrahWaV76`7@xAmypGRzH=4X>XOjuHla6DrJ>#S-_CaeYSUP zSyXercP;d;&=Gsr?gz44s9**GZ=!uQ`vasyyhB|_VOQ(*6L`n z5QGj|R|mHwyY!Q~Xa*Zjp(SB3lXh@ z1WkL<*>8Qe5$b{E?eUdKTIvUb@am;DH3hm+3c4U2CotKPC<+2OjWVco`VcBv<5z(~^BDqSub zswVfh8aP}+{lu81q28`?z>hN1W5S=XEdlRbu$KYOLS$vg=xD6Qc5q2Gz}6d=X1mwg zshmlkc2m7>+oDn!He}c|PGA5u1DWs5&CZ9V3VHZy`h+UeB(wFS&uHlSq=vhKZ|N*r zi>;=4%qYFv*%;kEMX?{`mwl8=g8gGZwu#-19oRwkH1;(f;`fpKU)>*GW8UoNMIZ;* znEG!g$${2TvX8q1)3&5!CwMLn^LDS(J%gF;dgt2pqfJyg3%oDBaO_HFSr*)*O?9)m ze6xqm^I8`^O=gBEi;ifbcA8wy@UyTTT~of{p-OWKvGF7`k;$9$W7H6YZE#L7(@)gM zGfhCDYjo&A{j(|AuYZP<{rDiTsNsOv_mFH{W1>{0fL3(0>Y966>v+|lj!c|or`Aq< zJ4LE8(Q%`OXeBU`;e5l9Het#Z=R3=c!GFn%d8fx7C7o&vQ}GvrW@e_IrLrl(C^1g9{F9aicoT|`dPL%JN% z2x+@A2J+?3+(H}cSSx)^k3Pqx>ZeodsP^d=imFdG5e*+09@xKkcwl7fi2k;W4D#P* z|2ITRzhUPMVU!yh*}HR-dp>bmCiFRK@t@}SX@1#tE^Q3hH8&Q<>^8n%+eqcJ!iBXt zX>XB37u>;tn|rbFP=yx@4;7j5Kkh8bFns%Gb#S690B7=@#; z)t1Jp7Iuo+h!6CUqsioe{Up05q^6ZuQeh0)9Hp9Z1bw$2U7m?QkuSns|C?(FD z*XdAsCquz>cuQLCtGL$a+ojhg(@8ncaB|t%CCItr`CjjxmBsR9gay~e^D?hiA%=;$ z-onBxz`|*Obk)N9veZ%gv%Ga}=>mR2YpJfUn$e>2(c{fgl^m;m@W^Pm+m_nPD@)zx zY;$=9IreBXqn_xGy>WdN=9VE0w$1tWtlVy09K~wl8os>Q+jqM1Y+RdZFDx$K?yGnl zlhaafK5Noq+)zt7CSj$x*}>-&HaRRfeiOuCx_0MqAQont3{TX;?2Hv7x)68fa^>9} zZl_Z+^kq%T&b^~i1KP7f)~@_$i}NejI^DdcEKqqo+++yPEAs|91;jt_h<0&RSH3O{ zciA*G@uEQ61RGj*rL~uEsxJWY=`WmZ-dJfb-9E}gwZSg9+#gy%uhs0UD+YBmN3Ze` z6R}L|lj-cuaBpH_k0Ivcn5srm-yyNhr-T3DcM>{{`rTA&Z675+hEc`h@&!y-(db-sNlB^QD}hg zHg3!%m-N68ytu9cD4l1mQy;Jxb>W$7`H63(9GO@U1;K9cB#@@z$99YAR?TBwrNoy!%8L=%R!J8yjA;Cf2+e36R zA9mZDtosama9Vz6W=ldRRTz#Hh@yfjHv>zPRyRoPQ{he#jK{1H8QOxop=-%EOQ*TF zx5Z4_oIfCV6}lo`mcFDnGztZ=^tgodp#i%O4bav9-@FsPq{hR5YOAzAfu;BvJevWS zlqydRb=9-uq*C^wDghF6Wp;1{K7*k@8Tp0A0OIYDr1vH39NdHJCD=$yd3q*Zc_8VO z+J?(%S}gBy#XI#LUzpjI+!$;$mWfYso8-6sW>3ldJI4!U?(NM_{dtueh<_=33|7HUxX+>ebF%N6{$?8(LaGtm}2hgt}IUr0h?eJ`Ix(tT56D z-Dk`PW`cEuNElqiJf)stwqlx?^BHlLkST>r*1~G8KG`!u&QwRmh2!0d^{2BuFjlH{ zwjl+T5Z7>O296W2nG*&BO~0Af>?{pmR5=N1WQY>UdQz(dU@0J3(}}$}r*}2B4s6Fo zeXy~AU>C@{oxD8*TN^tD<}|}K?bHdQ@OvaQvi#pq=0$A75zM0kiP2l6%?OcmN^e9C zn*kL3ebHtdb&&ln4h0|;?*6Sw=K+5R95!kSd-c>*AUj4gUbpE9kw~lzi2+m*3vLQ( za6Kz}g$Q;$lVfc)70*Ss!`alBkipoPrb>wfq_68DQsU7tp@=~f-7t|BB@xXtIjIaZ z9{8HFd|5>_P{AcgtUEmK@NB<^M^fQRY+$7ZOCAP=TyX0Xbz>= z;sQj0`ko2ys9G5a`f*Yl_xqbE^@RE@*d_z}8(U#AQ)9cisT9Zb^SXVRi$~;@*?A`P zyz;Cyw)8+2lIii+CeNLkd1Er2K68F5MUhIdGe=qfY2>WoeLIOh4UbUVp`=YjFS;GU zeCT#o^*$g?JmO&T0qUStJtg3gFva+v;6-%1NTc~t9?dV}7l};_g%8ay6h1V+h+nkk zr=iN;piLG8gRr{2_pko>wY7CXi<3tKjk1-+MS?i3AKe5p5`VAE&b=clFEO~Bux`<> zJc`i7p-)8q|JA;KTWTMg@$Aa{aut^;4Mi4#oV*}v=S7O}!qB~~F+py-&AFvEoE#KU zU`F347I@Nx=R4}Oc?t`IYo4D+u`>U*r*>5G-YD3IMyY^QiX&3D`f8i)wk%B)x|%h@ zRru%^{y(n__Cs+lbl>^*)n!6I6+zs=Qu8gL&{`b= zkR1`i(%bM?xRC&1pRl|Q&-fN2IpW@1Pe=`DIE#3}8r-I_F zxD>O?vd1cc(8`~5oqLSH8llpeY*yp@E$z>V4J+a)8Q5lBZ&%2OFy!r+=O4gyzgb3l z$%gug)sOSbWVJ=T3BgeES_~LtSa)b?@KZ6-RchG;{YG-TDPpA>e(NJvq_NN7ZJXv+}5DwVta z1*#!O13Y>#v&r$aEos35c`kw(iWXu@!7E+T2P8!GmO9sjP^(ZQgxpV4U}K?7RbNqm zG*J_@e}Jsf7P{!?9(|kw0c7mXntl7w_8feNxPAaMkWwjRK-rA~PZj+m3rs~dD1(A! z39|33QN$0&wBM8)lWsk0!jRj74kb13BXw6Zc>v1rE|c=zpbT4NC{iOa2Vs-~(io7? zFF*%ZrA)gh$;9Y1^q$3J^k=|7@z(a_J(`6yHk9nS@pqE1L}PF`QV9}P=;|oeXg!;g zs~Eaeuiv%SL#ds~dzh<8>{5-5Z`;B+%5s){65y4bNOmc6y!My|uCTDbT!tmun2b?8 z43pxf<6@AQg@f{z^ur`LH!g;TTvLlH`{^a|L@e{o+3xMS;!L_Zs>(*|+5pViN^reo z+4d`K)NTx@E7|qJqDdn9V7#VyryjTsz3x2PM(z^`K@rZ=&vAeb=7Fm5_9oUu z)?D_)nhQ)?w^>`U0$1m|nUsk3EuxgvvcigN*A!-A;q&3l^zO{m^kjOmd1mV3c}#rG zvBTMs<{PKZzCJmvWUh?NQa;o@$V!qb7A@`RB3U{dO^1~Ff-a>lX@V@DUHGUf%~I0{ zqaJ4Po1K<*N&ER~y8S*?Bn{H$z%X#Rx|uIM_mw(GEnj6Yx2 zu?U2z(s*=Db6t+LWMmgg%?_*ra>(v67uXFKc#!{|MJ0WdU(Nnka)2j!wWMwp2Y@q! z_M&4*zhJSV13^I?UsoN?}3Uk}R@-QTGrk-)kL$ zn&wO3_3mKhS1$0vm(*2gB0?&l4@r%`G#3 zn`%lDEB26wTRp7^tE0`QpDqHd?EIIaQ?Z6udg1AkJ*Rl@NHe=ll=rRU&BAzg2Sq;$ zGiaHP*A~>_jv&$mb?Uz8I7$IjyskB@rr}c|oY+`ulrTB{PI_IFTAt%JeO?z8*^r)@ z2LB!np71sq8OFYOa1GO$@I)}jg<~aPu~|}txX&p>Xcf76l9BNUh69VYv1|r~uqeR{ zR+ZtJ!bX^zKPyMqNX#=0uy7^Q+~^zN7JfS7>DCy}2|sCSt!~d+FC0^`ECtiAIO9}y zfNCdjSusg-hgCI2H6d0{QNw=KJS}-ta3#_B;7Xcw8;*kKaCGL6X)J8c9NxuPXc1JH zHP<%pnQ%z*Hj}%!v{EID}jvZJx`EKAcv&wJ^I>ggb1w%J)}n zpR+(zP9aH@MQCw|O72APCy3^XS1=>q<{=q=@eZSo)^=d0oWHGp&lVvPIF;r~;Wn>4 zTWrsDNGp!4d9!MZ@x9OT0fg=71tJqj)+GWlT!BEJs3Xt^*m$i&DPc5H!YYaq)EYYINlqBvXvSKK|k`P)F!!)PEp&HH#S+pyU>HSDxnvy@m=ktit znlfItM-%|rkXFznn!x{sGOKpjO^xuHzRk^jeA>G1%*;}IkrjP^O-)d3N#o9yeA0OX z8t;n_n^Y4*4D}~>CMb7J@(Vq56|CfM3HOJIki{MOBWlvWg3D^YACuN^Q69gztVfBY zZvU)$V8&%1a=z$SBdkmhtmtp_13`kWNIvz@Dor=+N>&#kpolZpXOSNzPaepFCwL-> zWGGRj>Yf`fCSO7F=o=Dg{Lv{(#q#lqsu%-^aI%Zhu=CuKzV8byN}{U)V;E0?N%r`8+v@1p2Wt9J2amh29W3=kn$ zE!rtN{T$DkwZc`9HWGx=gnb0cU^FIhAdi{Ai&RZ2aJ7;IhD;Rk>L*t;s1t{S&u1{92}wP6n( zCpYiqBZ?4CBZj!7au)~TDSL50LnJQqi`5W~c#z6g4)SyrP6j`MF#>BT<018bvl&uh zxrFqDno#V0kdLnRGbnl>Ak=ywm`6AE>Vcl#Ba%Hx(SNBPXkZdlmti=;&fsT)kbdri z2s2a=!cNm^5oAH_ifIKR1xeHc^8r1BO%!GfFRS3$mgLr`-y4BHJYrV~1w(_us(`7& ztl^~_Q-lmuwwnj|czQ(_6Alx>w{>gfU-(qT281qP;mFC5le{TIP0jO*u1qMfF-D48 z{%gTWVz2gC5R}+w0V3-0)^seVoVO++|^102@jsc%Ww? zA8e`ss7!#2#DIG~_$e=7_ZbKqFgIA6v9#Q};8G8W+)cc7+ANrd{9ndr1?8g=9?QZ; zu&vl&gBi8SteS5&Zv2LPAT-~n%wdma>S*)S)I@Vmn5M}nRF@W&J<8?Qes%pV%+SF) z*sI}<#*N{65Qu6!oV|pJC*Mazm6%2zf7a%d4M!v!;f<5m+H8KYxb*TC-nS?=5}H*W zdxNoPwMH7dN@)~h!k97H81y@o_d9hlUZhHiMb)fA#+o;jcS)D;;!+7DspPJ}NC{2m z1~!dv*q8LXDL?e_6?i|7X^GL3^Dgg5FApBc+x|yG6q^7yB#uTtd@?uJg z2}2;DMDE+5T|1IxjEbHY2ZZ681eGn30=x6}8k0BczU+cf zSS|M`Wg>R6YvF{j&!e<^OclmQq2WE*=fn0`vVM}iW&B!&P)4`cRb*P1 ziikP7OQ_{_T44b3H~vGF6IKP1l~zYnxClYq5X(Svx#h zLWayLB;p})sZ6%$50$;7)Gk%~lA0M2+)`47GP$Kp=C2ZK00T6!qK1}BgR0%tCbpZ` zRtX)I=#&~cj6}wt3OQCGQH4Lo8sS;UquW*eR$WRo2^4!;c|xKMQ6&X1$y34;^*`|B zaWLdl7@%cM6Mk$ZU{3N+O*#3zZa<-m2$jjBh4%Dox|L8@?J*Q4d$HA+JKwLAK3g@; zsr97e-$2nH5kpoBhqqx!V?`9Y%rL8=hDSDU+cL6kWWVLm5Z%Yuw4?oQUT$OAFrx|^ z$gh$ejotX}!H$;i;YZ=v!;iS-*u!s+Of5o`?28P84M(L4@~drPn_KE-*@kvDe8q%3 zMR?V{8QQRp9b2MgV88}A}`wltqDMJz3a+yX3mxv6hexn=AvSNkf97cJw-=Sq~& zH4I`39KCx@OJz-KmW#-lRW%kJ=(im%&&ZRl4z6JU6qO+K3m3I5b!*qKbRRXW+1e70 zH)jG8Yqn)wV8bhVf20cd#brG< z^qVL!t)7eFN-x~}E3_h}xNW0-l?gHZE#z(77TE#cR9ggrr11?f9llodv@{(Km-9$d z!=wBhiIxZY*Ouw<%WAwt*-)^`h6_dA@K;F$deZzg6)MTK_hULdK#jTSP=xg^w!;vD zzDoI^EZ?KlZ{u>mlx0@EjA{7qlf^Lo?dq!#H?$s2r;RaQBIUILCx4CG347JR{CjFA zT&W=4XD1wF6f|<4L`O;7tDTT(Rxnmg!nG`X=3qBxkf>^2F6%^6hc?C#DH!n1V&cng z=ZP;mAl24weiS(XbyWPdO!V99V7g+zSWpg#8I#%+8N&`bm7xoz)@thqK}0s!YtXU7Nv;mDQaK*1Qee?{iTgl-MU#zBMls&BhI}>l?!Y`EefHKR7q?0~@z$9n zSBgWU<%*3Y)a1CT*;cCy>))=L^>1@!{rjuA^-lz4D*axrHUS>5lwLzqqc1G@6`KIl z-$gAS#sK&mG{$NM20&Sh4`;`viRYpF4kM3MWqUyst$}S(pd+s1=qQexp)W z$z`fBHd-CYQz!vWS(vxSJr~ApmIbDf*nuIy3uce=*9%xNp zOG8|)S(3^IKcog@W7hPu1zu*#A}qn;qAHIr{D4wwXR6_;I+Olkz%nF7iC#LfeBrtTr4eG_jzUU&m&dF&09M{#mD-MF#8&rzZZYqlhzaE)%d@srBq zclQwxZDgOZ*`>Mbze5e*qBp#i(!Ll7RXxJb&HrN*5m@iX6rB@M`ncL6jWc;bI*+Wl zVfIzou>V1JAzYy)#24?hPXicg&8g~$y-K`~Mb*)DD+Yz=)>umDe5N;HV{_PQr&DHi|M5ToR7_Z#K{9d@C zN~q8*-rXlgQiK{lcn3TRPeBEMH9n?J_CbWj5!mfHioyYo_7eaX7MX6wPw@GHuf*7` zf(tDHK`OY^8e_N|sF`;=59KWlZ-6+&UAzG{?Eac#aX(lPkPUrYW`y^%+F(wG5UT%o z%*21B*29CeX5AI^V|u#Ugf44oD&CX+fG*#qi%Iw&QA*-swL&6Tx=rZ6Uk~hu=_++w zm;XeUKd8$e(&azpVlM98#v(S{UK)!CwzoSa3qlA6BKYC2v8*aZZk6XI{XP{I$C>%k z^XxOjpD*jM#8me2?-P6!02UVcCmWjvk6>!qG_Z5v!GT9Gx$MP*vu|Ke;~42?V`SjS z;KStX=eKF_$;QW>yLHekUj)9?eBWjKgPMx0traw*r}pcJzCemAx*YR61j;_T%*x5a z&jFK_b_Ew3-^W6$=p%f~hBbJ}f$qHsQ;u zNLAq^gmmZ59|_jf0ioeaS*K0uvHk6lRE;blUOQU8K_4i!*H@02QowalHkOb<_G1?k zmD`_m4wz@>UB)fW8d5!>E2MyRY{+OI#=Ux(evQo4|GqX?W#(8X0NjD0cM9KyxUC2P zujE|kbv+-GhiCO)khXvDj}LgyzX= zN30&K^7d~$f#2t)pBB)bCgBM@kWb#m9#2y_VRsFVaNWy9eatRcIobB=YYGhTc80Ml z#fNmVnG%YcrH0l0`rQ6w-u#rh@ED2V?e@9ttNGkEjqD(zaFCp5yqQgGgfs4qspfda zf4m?5grg+d{k}KmxZH0J9r%D4bRv%jHSw#+=c_o6968>* z#;$5aQnRq6t;0Jw0i@f~j(>j5#7}J)1a-74JJD7K+VdsWtU`rzL<6mqLRB4epxX7v4B3LqF{y{ z2D%Yyx(D)OpgLewhw{C>DWp>F-501ew6WoXLyEljsYrAP_+sgV#O)kR?*_M>jJX*gC4>ZeZC z!}e*Hx=$xVgnhidHR(N;G#XIQ#xgdFUAAN}K&H=%n4_XEdU!N25uo&(Z4}R2@hzNN z@Z&4mb&*W~b+Q|fccErzM}LZU(?4BYV#fUmZoRR}%nZ9o`jEJp^oNxx*e`-l=?8j4 zXb=eTf>IZCG1d7&rM{}mf2PZy)J3GnYda=y&2e6xnju*=e$t@GUUjZdKQ*M3Q;BMK_>=swn1?t;8*ZahD5m}t^RxidOR5*XoUbozM`-w1TN zbyCAS@zx84Zgy7`5mH%A2Ok^k_8mLtCbwxoXspxhl<(mzWP zV@Ud+D|O%4VN}Ss=3^nu?exD;HGfW*Kd+0?K_eCXs6vN9@>p(?{s{kWOi+H5Qlfr$ zVg-R_&*xY%wc301d0vW`bJ;2)CQda~-*aj`@%4u&`VWPQTSyEK;y)VNw#&3IXA;cI zIUe~>{W;CgtAp2O5o}Kj@#45O4lebw6ivzu^KCGPi`P3@lmE5J5A(h=*OWwV3Q*DB zd<{}8WqY%?(rhsun>S~<5LBRjRhLywi*@C5%k#IRT**IVBOKR9O(<;n$cbm8*gAfo zNDL;o7c4YFD-7YN8OJF=j!2PMIh8YX2U2pcOMB%VZhTS<8{0TyE& zBz}bD3Eg zn+YJ`S<1s}UMH!2j+}qJmo4Y{uaR`VNSe;}yV=U0XvcAF)o-oLymI1nS%Y?L%JVKL zDFVsYW>}y}9 zs>tD*q)tFf1->A423bYwQaunitg$TJdXTg|{5{5=sh-0EnXt6rT6^GG+u0#zBf2Cy zV>Q1Tn!D1aAEW#ZeN2NoJj9mTyX}UW>*<=pw!+N|lmsawZ1u1_Lkoj0{WNGm!Z zDk%cHz>bp3@0JwS-#ssJoQ;o5ajQ8uo3Cf&oX;gT=7!2HuwPe4yB1f)@@ny*kBS<*kyMGFsH0cUC~X>IL;8ys^1h zwez!Ho;Y0)S2s^t`U@($DytyRtFj9k+?PGgzQF90cl$!88)JTvrg$IV*wXAO2U4*) z7p$>9AT_9Ut}?nl;<=^$ro}=zanS=%eyFd@`PON?xAwNG;^MRK&UWUt?ymY(y`i1TVwt!=MM9JO9n`f@F6IQdV zX|&e&QGL~0*&JQ&srJ9D+^%ysf`; zXBw%rl8u}>&vc1Hn_pe(tt|T6$MQYF`DnS>b-hqNc_X5917Tzi4cF0n zBGcodaJpchgFT9s*&HeVyLldR)WLAZBauOH^If4~L=w8nHGagGN0j%Jk1aikGgxPX z=8OBRWQ|fY4CVK63D2;;A4=U9m6DEUi!b{mbeGfbQJ##w_Zzj74(2Qsl z6t;_0msu{MeN~Lder_a9AZ?O}ctikQ8A%ZI6)44X%p&^s42K@o9FdkBEGqD9WAn$G zf{v=O`9qZ#zj4ypc`nf$M7K2-Fy_x3VyMSEL;UeQ&+;RGEqjA6)3tu+o3V%Uf1{M7 zN{6vm!r;AadFLZWzN}uzbSRl-8{*&2I8#j;owOqN<&Tpn`vUUS+G{kA_97VOMsqT2 z9&OAdxAAn@fW_pXFye;}%57ZgZjnx)zJ2l5VHd&IhNU52rH#ahqG82Eiz*s77TC*- zdScgPZTjt*rMXtL<0M)6HtCrp!->i1aRMqNJ||=9XIcSGGF|#Nbg}W(D&!lwl^Z2Y zu5Xj7EvDMXtwn#M zesMxs>D{tOqojt|qnl;m2Q95dmQ)LqTk} z(nF;0mloe-Z1ND=xgp-yuWW?SA?7E)UfIxWw-cbR~bd?L~jbqyC~#TP?P_GqzQ?+NhTn}W9+Lp zbj>ErZL0n>DO6)V>d(EMr|d(azO(Ao_Tu1c!C#+tr~XG0CBPxs>H?7<>S4aOX7nkt zDVZ~z(!YrD9l1pW-SiqLc6kvYCbFVFoQ*tCn*Spap(PO~XvzT0_JfLon<7X|;1vi+ z+8a+c@Q3z3%0o)$QqiiWoy;G_8TZBx^>xZjeCqYHXHDtPOgEhtojZHx^OM@JF#A?+ zaz~#O$yj(>%;r*9yi=wmC%l2y1my7Z(qc^&5q&ty-CsU?=G>W!&F6ycjL?Wcl%G+` z*s_ZEboIbaK5?O-{>F20oLDF~tJGy(-ck{MaanI(YQlU~Xwp4US z$jY=GUKFsIgXc!#gmKX*VYLxx8>DV61j2!y&xk@cDfcO5*ot^4rm+ws@UA|bZ}a|3 zF{8j8qX{yBy!%_hE@tac?345? zMwg>PwW9^D9JxED#O|8fY4fQ6Kax#Rd%nG3S+O=5RILP$-o3ijh-O zC_JRhrRLaG(DG4*s6wL%QL&_CA<5#BgrwTqP9y3gDr_A6x>8@#Me!#aV{4OA-!E)E zL_*lQ8ytNYv!o7^8EzcFIL~H-?8m8PJi8jv)K&7)*LX9$0iye>4$rHa;M97v=5O-m zirTS*gvF%+Ha}xGnvs2TcQAi_zs~#>!P&?E#bZJ-tDve5-Y@t6lWLK8M-m2EZY%Du zR>VxJbAOXbwKz8ty8#-)8q+CReFdEL;36*nr_>5{X>EYsj=hyvd?mB&=GC z`AA)ohb{Z76a$}tMg$`SSRkOvK@W&WP*LmK+5r3GLE^D(pu_nz4+l;uv?X~_|BS>x zTjQSx_=gW{OJ3w_joZBp>3)tqvKb74Wx6F#bn7*|S8y!EuA zR27&?WGOvHo>R21168??2PbcQwb9^r=~5T2N^-Zr`$Y{~0g2v|bqF8G$LVy-;c5HD z3or1N-t@Tw*585X!4})(TIjpR>|)#9*2A=kU|)vPk#pZRqA>iA3=lO;$Z%^o8wkM}2FuO-&insFemp)X9f0r>;ZXHT$O%Gqx*LF? zlX?UHXZ~Uyi#_v%=}BAd^8aeB219wC)EzXpsIcCd^BktOqLJ3&l;`B zE#CDhX-~PLBFjcZL~awTnNdF*yGse<>GYUr=9EL+&h}<~DrxD*sLbZ}wHD8@nlF{! z2Zjf+y0Xe!%*{mN)-rgz^tm@LzJ}lOm5DQ{&sjX2)lA~)kMnB!6T19GUH+0TKgGoo zzD(q0IMYd|5 zp33X+hib5V`EYjfnfiSx@3Cg{wcL5_Lbcm^nZmP@mnm=SWePV-UZ%XQmyzo`f9_=c znOxw-yGrKIPCm0%hfhAU8a5}N2_T<*W)=9oe^QAmP0&UoW)ToR?D7IIH5GrY%OYc z-|c|xHb&Y|(_UU`$EkRniHl|+bqyECQm+>w2e_14o@->{)tqm!L#^%I%d^+~rDb;f zv*qv1xeMphi>Ib8y7-JXi)eRWzc6*d>x>-cAg5mz4SZ$h{O8?(g?DhyxF)%6oTc-t z^7G$5Lvzyqnv37hO!BslZe z?W|?^6LIfmxVzN~D365UBk!9UEkW$+;>N(k}TNUjke`Ij~;AS|;V|#Vd`+jQKs~QM- z+cB_DC&06R`XD=L?HxEUxN~sV;NHPQA>vD}6pb6_mwlD?qb8$LOC`nk$k2p$zCV!&%+9LmmyS#Fisw;tZ@9%3$Oi>>_WBQlp4 zE}`}xK)|^5>lHKiJ|gB8oqFGUJq4n~wbBG8NPZA)&=ID2dI_{3Y&$$K<^yI&*H!F$@h z#|~h`&Ra-B%#qkIMo$TQAQmcWLL$-q1I0ao_w?iBjc-A&`j*C}79soFEVFLLwL#+~ zXaK{#NLdfB&PTvQ^x)|MuY>Iz)OYwU;8m~L){a2bhb^A~Nx&kQ4ZOz!gWZy=5WUq~ zjxI&2K(gXR;%`_9ae)u(-e(u=xrv?c&7?&pdbhITPjA&QD)FfeGydVfGRl&YhY%(L9u8t;G|q zPBzDBQA>wxJU#ct6U|RpKhX;=fYUrZ!8_nI7k-J)ZoIy{ya*c@`^<%toyC{G^z5-u zfBE=ZA3JhF84BHU78%bRfBM?@{cipJ`OkFZ;L23bR?~{5Hm3#?C#IYe%oga7F1RE_ zm^do@L>zmX9SfiSvEI_P6Rq~Uow@djbDUq&Ub2iZ-Cm*tC!Whh`tSbM^!)6*?f#d< zVd^ifEOr{eSZA&GZU|-r_PxLoVjozJ%921%Mx%;{xZ2 z3(6e)z6e{Ae>_QzC}2`TK<2wTj=&trLH?B|-be1mF0`_+a{fA8UMcUW?$kBKD7XO< zm%e!JEV5CTGlR|Km5vaRGaT-l{ye!h7e`dMSg1KY0pM=na%Z8Dx%$T3_n+{}lgLCFS~R{4nfyiwu^Z&Soh%^R#*(o?`0b#0=%5c$7}zL`46Gyf5#K?xn%_8`V@N)NJADyGCa4W5KB{l@v(Z4ak~ z&9-2+L&>!vIXra!x`M=y+p2AgkZcXm&t=do4`Mt>t7XV8a&;yjz=&-gr_vv%`t&Ds zDRrDM#r2eDyXps|YMwZYLTVWUm7ihWzOgYBg&bFSEe};BND*l0?A+ZzGrhpmIaMk| zmdVWB{?{bL8W1O<6SqQbwO%Q+Cd?yzI!GHZO#6i3J+i5zQ?fn}{@3Ik>?RmC#6%D% zU|JXjYJt9DEh^$Q8oVV9TvAB+R`(&ZpS&s98+f}YL|ZuEjEzFiM%$2NY|7Dfdvcn= zLQr9tC?$1Vf(M&bMDr%6g)O#c-)SChHLvI>VrJhJtr2l<2G@-J4XtY!X;Nwm z{o{3|>_qx=N}0w$Ams`?(vR+1I*jK z%xGck+#qE{Uxlzn)_+7IFhtjoYzDQ89Te5ohDfc@5nkCX1e(bU#7zi7g|)F(D0c`& zp~|obS)~>UOCJNBOcLbea+X$tf^u506aqM(5Y~BBQ{;zfN{zzA_pD3X0Xp&4A=~kg zxop~0K)pqGdW77j_TKn9!7js~<_O)*q*#?2_*%hjW*_oR=0UWDwrNg2c-HSfO zM5-3PXGh4)C+X9Jh@BhTG8~=RP2cm8{JkD%kec_3W~5X2eCuV#+M2sq_^2 z$*?BUFpQ3%fc&b7Gy=1e-&g8sGnk$jpFZpf^)v}4#$q0V54Ps#ReE~GtkG114~U;J z+3_mz(VXsxcGyK|=_LX^9iCf@NE5x=m@o+B->=?i;1l;?YD_A^noCW8NDrP7=@DtT z3Dsmf8#`n!k$z)#ex+R>lIj2$sZwG%_()9s_!Rk{|YZX z2T&b0I{b)60uhF@EcQRL|Lo4z$Y(}IwmrD>BbHnxXcGWAzbn(^rW2 zWUJJBScc@?D)YsSxly*Zw$W0D23E~7-n~ngmxD!PqJTJ;=J0YqJ7{Yd!SzUiI4l(b zRZ)H=Y8@{m0ptw4N%C$`f`v#%);!Z&p3H1dy8jZ{A}kHT2M}3$Wd*U;KCC7o>7eOb z(gWuKvjiOZOkgSY*)%{j{+A`e{05^*aaICFyxyL-4l0ln?s zNI=|d`4L$RbY*EQ*T)KP17-w@lk>Q2PG+%j-&|c!@$3mphI;FeMKHLWoFH_hfyw47 zCL3z|s7*Fg*qLDD`H~>D^KjnU)3Qjd)y}63xbT4#oyeiU0hIiGXCfQR7r~wBF?leu zUt)rnoUxjbWBI%|+SCG}`e9)&vu@7{c2jC6r@CT;%=NPB)WOlb%xJ*~+w1|1_hhz= zwb06tGZ%F1>=H&+FBEY+ejdSJHc33TSk7VtL;3m6)uxU*jMaZCHmatMdR+obP9h9N z%vVMYmI@+F-qzecKHKebDC}H&Oy!RpZ)zz8OV;P!>HBCz@HZD(5!WXE{9KP?v3pCS zYWL`c`Ptj*;b^nPs*Juhcxr54Jvk)&8J51Veu2debJyEet~p9E)-rawiz~HLZSQlL zP2UICTXItY$_(B+p^ts$#Fty&`rOfvzxUvf$YX!3Kv)D=RhvckzC8IW?BOd+2aFWMlaGk4Z!PF0 zv)~DlBmS%&$e89Px=}y`p@J052$2xkId-{EYGxG-Yc`n8^wd zq+eJ5E-v+aHQdI7^a1hT-31elAZ@ER0z>+g9$1yp@!Mh6c|j@ZI5ynmrXUFi*pn`u zPd}e(xklRHVo}A`rRj_5B<3Z9LIKgXoTPt6m2Br?Y%LbsTaGKHP%P2hp&F$^xyB$O zv1VQ!3v*R%!KV0MM1=gZQh!|+1J6-YKfe!7f2!Slo(!{3)VvHXf!&1;q}S%%^8`qr%s)l^x>1v&nMYA#Ju>FiXG)r3*{0b<(h=Imb^O# zy~N8uFAUnlYYF2X3x7wzAeqnRcH3z`0C9x_Ab^5lq`6vPlZ<1N*+Irxa?W1s+F97d|@!OaMTtBo6tZKqbF;U^Q>19Fd?%f|-EL84Ip*ieWXrO>K8Opq9$@k?nSecX+)06Ipk-XE;UY? zv)L?{uQD@%WFz@56gf+}nz}Y=Fj5Q=&;`_tAXkk8ZpE)L#XOwa2EbMgO)}*&cF2<+ zXB`;Hw9n72`3^o+@-T@O{a9a}WrK{f*z3xKwJdp|5|pMDw9%Z^vOla}nvxjSHDfs8 zcb5EI#wsKrG^YF$6*r%w5$^d2|H@^W{ZTGzH~$+5B2X+)z&i#g9GnRdiiJmLvtPUh zl-TF;l+h3TW&cEibIK|LQUOO9gAB)qgz|g=Ncq0O`H_uFw-Ke$S4{HaQ=qdn=1W>c zz&a<#!o`#6FQTaBYh%V~atTRE0BL&`z-Zy6_&ze+GA<#&Rl7>EeCR<8V}jC2%))5nv#~neSy_BS(-HO$xKp`8Ljw>-0_xai#mtssPsY zIMwI){BfaKvK(v zCLw1UIZWf-C=TFd>xDSr$N?B8r>3N3Uo&2=nbbYY)he3|a&#NxBejs+^*STs@PDZ@ zsw5fSxo;=TRYCP8mprV3=Vj6te&Da34O(dZR(uT{7=jiC{-8Nh3VXG<8q`pCR`bDH z-%nZjD&CDDCr}FdbwnEqr zz>0S9n~KGH`}O$)x_n+23zwEFZLfJrO%jDP;ZCPX#aL4I?qv0OivE6$i=H{b8-;Eq#99Zq`7TZy4y>oR#qvfeMCeg;ckCedHsQ0 zp{+F8+y>OQy+U;sc148?`dq!41!4Zs+bkT9Z&nI4-u~WAv4}!!>erlw()rg(=8NZ- ztF``jz@sQ^Z5tEy!fcDp0P+nZ`ZVn0P3a-lr{{0Z-mZ++1xS}?akN(yx$x5j1gydq zjjwGIQIyI-tWVPlAGFN@+WEVo$rs0J2Om(Lc66v7eTi=Nni+>3!J+U!)8HPeeY^SM z3&*Z>mhTvYS`{R#_sW9a&vez-s*hDQ*WNV*ZuZJ5`oB@!Ro|+HbCuBI4jyZ+FPZd% zdV>#2lnLe?qgY5nG0+=WOQBHWVwKzL3^(wNruu!G91wYh-!iKTG(C26<_ z|7&;uVvB)?snc5wEHJMfN2wsnVz+_xXL)QcQQK@llESLZ2CR$(m-rq%{cT+CgB{o^ ze?ojf0(`#NfIgGkooqN3`PEOtQuf1K1P3!l@jiDkNhm&T&Wf7+E6U!%)~Yh|3ZD1z<;HkihWf;ugN$4MumzQuEU7ETIw@` zd?t4kz$1C3rfx}S z+ZY~t>GOhzI-Cs8X~>G7`!9hc@But~J7F=xP_hr~IFM^aicM4J3yOKYbM5-kCS1&$ z83jocm1NTxLWNe7PQVIa_3ciRi)OrOKr~zp3ER;tO1;Wu;~u?6sp+yuvAKgjKvs@>gZ9G7;#B>4fi&@hyM_#a6Uxe=|B+d+H( zBFGnc5>FtRJVKHr?Y$PZsM^24$2=1y-{pa1t0c&B+buL& zXZ~jRWK|6RhX4p<$qrwv%$SMcsN%)2=HUGn1qPHAOd84s;cH;B2op^dhM_{YB@mc_ zyji%EvGvGti^eUBG_@mIVC4K-q1Y?5o!Z#UpbU~D>KoIL%P~P^ z5`CBob{0$jOkZ_m$am&(7U+(4DpS~^hwTK>M4F+5!yHUOa!IAdRK(>i5w+N4ZyzOa(7MipFfQFhj9# z=QET|zpl$q>T*Sw7j$`1m!Hu^F0%ARuzXFgR~gS&dA<5Pe&v_L9P@)zW*bYse?V;q zqhHo{+$c9iUZ;w|t;gGBY+2A0eV#;uQwc`_M!cPDTeO{cy~61YX zgll1@7H5)pFFQ{5H@Pqmh1j0PU~F!P)yCc88K%NAzIgttT8A$i#s(Kov|FxE{3ejS6lodUq+o3gU`LY>rUZS zxhyvkBYlT?d{m`#mwpvQXC_;6x!_acFr#^OkZ&vkv#fh8c7ZJ8@Az6rySG_G5BmZ^ z0dm5(o3E#5tGc{GvyUmHPlB4>W<`9T<#vo48KTiX(F~VDl6=P3r>0`b`q#+`eD_4y zt=mhwXlYeC#3eUI&$f)UwbSMQo*qg8%==`=*2>a+9B;5sPbCYlTy3TIxx1q7}VBOqHrlC3nyJGSw8QSAFlT812Zqt9j#WqkWDB-fH?MI`bN#CNd zVM?u(@Hn!x;<+O6{DvgOABahz&io;bip}bT`R|9YXtx@pm^DxhgivjqU-mb+TsPYX z>@VV&(c*g`IvDHhH}Q{W@QX6LYAHV2H*4@USSB|tGh>atm@^SZ!ATh$R{q1Kms#Ea znwcvV#ano4YJfJi|T_q^s~D(=LN zyx_p03&m>J^e^eTNOG~@HT{X=4K4OHb9uGBT=8oyJMpAr6IKGslH`u3Y3}B8BfLuG z0>y&$u*pJ_{vX^1iv44y#1w0Y^`MIUHS+wvOp8tkfzyAbqWt2r9+%oj4L>AAl2I!m zf}GWrOl7UqJjmo$xWjgSIeA*87JiLaYLTFx4sNw-t-kHldJT}0_vHeUhaX_8y-jv(-PHoYbzVe{8WA!YKrT?e%nP2B=#$XeJH)fW>h;>{}zgZa4cD zw2!A-e2a1zwN6OE=?opKsarB5oh_Ey0y>yWh7+tZ{q5w;atDw-5RD zKyiD}w+|J!5Brt{lTr91zGbOo+&<#lj}*6$`u36H_A%c+THHSF+sBIArf&&A5AVlm9!2gmq(MonFZANoTa{BVor1e~K`FPSgl3ac!X+52M0Iocnv_7W%>i?t32b+@1WzI2g!2NvE z{&>=UUWDuN^GWNt-+jTEFF5msqK0Q&{u9ORv%Y;%&L|0c*$;!Gr7hts`=u`QS}XH- zpB9k=dR?ukLtW2Cv2no(a@l4cyMLaPXtMgRG(NEZs+M14ncCjH;jmB>`gSz>cI@#a)M?@Wp1V)H?0EB)l_kKw#J~_8s{tr# zo@3=lVFFR-dX$fN8=hcXDJ#Q7!f6TJYjS!yQw`3)n46shpg9^6TWo6#0aJ)IVf2wC zdbW4%IIoQz$v2%28I~|&BJuC?XWOJpJ^O4~tV-osc>!&vz4qTm1)kfWz;n;uy})zN z-n{_N?^;05?^eLi?@}P1uU{a4zHWiy`FaJ)=j#-xp08b?_I%9(_2+99=zG3Kf&S-r z7FhNCjsmNnuP$)M^Hl}zeBNJRjpuy@)_Ptqu;%kxfwiAk1=e|97Fh3jQDEKYd4ctx zM}fOMe-@n_ao<{w1TOS>F4E=@*SR37MVM6f4=MOYd+ACKD&J~fS-BPzQ?U%WA06VU zaL@&qombj7$FeqCu*@oAjj(!05goxhe*#i=sA=L^vOMmOHJhi-o~_Oc$iKq+Y{K4~ zeq{lfebp=Hlojn6>-GvG^-+i@5z0{Fg+Bh6b*=cPM&91aMy`rN5qEpUVyZC(P%Iv%S#V5OAJBmlfOn=XK z@pkVDV#rDz5})f``GT|7{zBIAE=EhW-b@!+YrlaHrD#zxRFiD7yLRDsuXI)ye&h6+ zbqW`=tJanRYa|JAkYY@7g{$UAp|xT@w6cY{x#B|s)`{tJt~k#iajZ-Ug+4ZnldczqIa)oz@-Gb^112uPm_C zmnZ!|$mvKXsWb;#K}V(~k7|s*{+urqjGw**m`i{Z?OcG1%+6TUX{dek>*h=Og!-qHK$&AXlUO-m7d8$g6CP#~aNISp`f6v=T= zRm+JDCZG{gR~qCs?;-9km^Mqo6YEawF=iz;$qP+!1)D1YX( z-^Pr{{S(*`<*)d4k#{J0Ja;q*tw6Y1#gu*=y(e$RsdK9RZ{eT#JN#%6ze^OK9vh{n zQ(oFxG7Muwt`>MUhV<`QSV;9QlQUXRQ)F=?mjr8%1N;jMxC;J z=>i3bk7{z^&>uk*^uI`Xepm=_uD~*x+aM{+3CT*ac0u2=T;mRf=;F65lDM!W2_soe zesB>>2L@5e<mj$-QJkc%v+w8Ah(M(2f0a9 zfZX*IAa|LfZTd7I_qOZ;xl0xxcgY;&eo%nij$4D=DTCYv3Xn%NImmsW2_W~sfZPuY zkh=nd+-(rZoe;>~gOEF2%z$@h0N$Afyk8OEy>_$c9KwK$81M#E33vz7xHGq+&Q}vTK5N>Fj$Lu}LN<_(H{hcpd*J{6?RH z@tCQJeG}c#B^p|rEYu!qLoLNUP?&wsM0T7~n9;B{;}D4}@Ky4tCcx*U;`A`3KmAlU=;{y1labUg$WL zyvv7r0;C|DevP22yko2r7Q?0~1X0nt;`0s>&3+olm!|1ry2`9IzVl9+0~> zT7*(y-mknDQy^Ygvn9um`e|;vM-nVu2~-@CCbo0SlHU3YgyPAOEjsL%@Z zFLRtdl)ThMRw?LThCIoBzqtBWa%+4pBgPqzSlX$}hW4VK(Y!$J8HrB0p~a_*9?RFv z*1(Fb%;nj9v1b3lpQ`v|IW=Eyo(!?=JzGWk1C~XKF{nEa05bKkKWPa z3NUM>vPbp++Tg2` zu*g$q4aanHoPE*!jSFW_y{T32hV}Ssr#N~1tgAXdJ>jaPVYsAr4V-u#bwle$oil#^ z+__iIo_f_taq9JnGv}Q;d*<}1H(cOz=TEbR!t43_XD*ydrzca_j$zOLVzcuh>3-3c zwP-)KGAF!gxmK&X03oIq-*68wKw*D!=DEpOsi%fp;VtUGg z-NiHKCZ{i+I(H#Hcv=7xbS6$uOrC!I)u{XQnW=6U2aK@?ils3skKL*lfZ<83_J?76OB~69+lQ@se8jFr;V~;^pF0 zNu6cB*$Zj6copqPqT|cAYW*y7!)2t0Z9c1fdvcjLlpV=s!cTVkeDYn6f!L$S2E`&~ z$7qBprWn3GC1ay@cy#8Pz9m|@)gE~joOY^g5Ko9uz?wuzgDgbI1hd6y&1FTcHk3p?VzwH9R@V%T-D`w>LL}X^Iz%m)4C|!c}}$q@)PxZm0$J!rX$Cdess%yTo;Wcu2Z%Q6r0)sPQY*7_~%=Q9JwC zviSwFEO9)%5fS^xC>nJk3KOpvEt?@wD&*j;wQt=R*rd%dz8uwg^`8o28Dn98)uPAy zT7;NOi)Q!?U#1uREm96R?Ygw?fL-MU7^AsEmUD@hjP2#49TClZp!(z=AFKmmyFZEA z&zvYfsRaESbq;X5-SfZ%%$wBy2crT!uF{cg8%*&#~zW2PwjQoc&)d8VW8gIeg2A?9^32%fa^_nWi!N) zaoPd3M&0{eYt{5lU!@0JDZ%AB3TVxy7nSNML1?l2UQx9^^N{x^u0F7(?*j+RR&y>= z`GI>^?K;d2ca$Kud>be99j#;A8sCqRhzNxR$N6Jb$ zglWx|mX$t!mr6(aDt*+IK2J-VwMvV5s?QL9_k^o^td3xR3H7e(@8hloJr(m;)wFso zgy}u$TKt*L6RQTD5Z+pKSm1K-eY93x9emMPns&HW>0_%Z)x>+Mr~n|l0h&cYXRxKa zdbC(k8%U9we%K3{#>CX8OoA6`ftO-qS)rE43(8Las>(mj<(2S$p{}A3YQsZmu7w3id`I^E z&+3x?1jW-I*NabZkrN452aDZwhP_=O+Efq^I3amt9j`V+Pq)xKZ|>{O=loej&puhlD)Gu38v*^^X)cQW6>QeVTD z<>R&T?*$fFX68+r;;qOsasKm23&jo~MQ1!_vRGd0cD_Xh4a5zEtD;iN!qOLJEE;Fh zQYIxqqUlWmUGkMvl%L=nhf@>A($(b;XO2~`m%W6nJ?R}Dqp;2>vh_my z-$>oH5N>_v%=j3zsj$<)Qx$S(zJ6Zyr)Dg`FfL@V#U8O*mW+o7kV7Nq#hXQQm^g$Nxdwj zvi?wDl949;#Ui^Hk@T1J@;_C!zQ5F-%{X!DpDFVPbdj=@{&S`Ng)U#!tu)ox9csI7 z-KGCWdEDLI=OW7Ud-+cGyHphAdZh6f8~*5Dsnj~_^#JQX_BluULOsOqDc|Xt$|>hj zryfH+ALRYjxr2=-SLIdjRH;4rclR`!dFduC?2N?B)>7IL$Bn9G4i&79(n>UNRmfKCO zP^a~~`j+xzomOe(&J#43)X_E4-q;We z5IM+H{2yToLgc9x$pR?XyTIAr3AVM&}1 zyg+_lJcLIF4~(_l*sVSKp-c{rL&C=Y4_5H196p@dh65;WRAod#)OssYnojBB)T~lA zpJ{JYyL;1drT(rS{Gu)cKr|I-(qGc;2fCPl_DI#X(fMt9@C98ytq1%z=4?ZPo+BYx z9KDO>(fb>R@c2!|4nHNijg9Q4Rri7}*L9HrB^^}bG!E4|wCc-Ftv7fg7XMs0BW$4&HEyq|w=w7Ix~ zo7nEH)dgd5<*e4&SWL`U@f2de>#;PMlc00>9`vwTz*Ml*MA5&VEV6KwlSTB1X`)jT zt$q@I9we%7dnzyaZk7TeoWjilIJY6?M!L@(QJ6YRXC&NdBTye@_?BR0X5OBo)wN6K~aE!OHqV z?4M?7`d?AF<~MOImW$oCuk%_pU)Gx{YS;nIR4)=m>U_v@r*^^Kh9tNP$B(4IIX>k; zIZ=*{_^sgadGhbf(+i3Nj)i^>u$4!@PHOZ8{fQ(I=~-VOk}b|^*<9I>^v|em z>ipqsDtNYW`4%}fCS=X(Tgh7%GR|&{+m|TzObOS`#$j%gt^5qvpCTcOVX!L3Ld+UD zLRNPEUk}Q_+E6}HK$&Ip5VIf`R0dIzu@}2}ti0EiZM{WS26Y%0GJpfn__U~8ArgEU zv%5{{`G$JzogfK;H_Hv;DQ&Thpp*AwgLp-Ol!sz{2Y@M|s^QHCq&B{J=yVKR6`V17 zi_^>H4q43&!jFR?V*Oz+zo_BPzgV1wVXVOZ<^T;F;y73x=4H#*Y*^zIpW9alD>$*< zK}4VB>)u$)x<(nlu{6sH)K;9;fqejE6ggBtb6+)KV<_J$Jv_y_)QIbr>B_;`|8HaG zuhTFPgYhb*p)C@M_~Qj8DpA-_Ck6(_N^GVNK!JfuAb~ouAQqm0l~+K5i3J{jrz+pi z*EV55R8^c)n%qTqKKnbLW8*lU^y#l!*j_4ki@K%woqCI;R=O2#Mk!QTIuKeoNX$$h z8ibMjEGCWw4^zol8=KcD#P6K-9VHr=OoCJSoQ85rZD^Ln>W_XVKDC@CqL$cC3?ob> zKiz!Bc&_LCQ?-asWre)-Wu5<@LD*{!nL!(Rs@4X@^@QTEPi$&qb7!%z#3hhpMurin zY&uGRX{RN<CUdz9!a}1myirLxGlv(Ym>pmC6w%DR)+rQULEe zN>7j%h(lf>UyyT1kJbl}bx1&g5B`wkm}>mUd%)V;Sq8`2*XfGIdMylHGnXTHXbeZ@ tSv%{?MpOugN2<_9k=eVQ*|R;^+xD(?Y*M?mM?12iecKhGy!Fd$>j#k0Jl_BS diff --git a/mythtv/MythData.py b/mythtv/MythData.py index 14b2f34..982172a 100755 --- a/mythtv/MythData.py +++ b/mythtv/MythData.py @@ -521,12 +521,12 @@ class Program( DictData ): """Program.getRecorded() -> Recorded object""" return Recorded((self.chanid,self.recstartts), db=self.db) - def open(self, type='r'): + def open(self, type='r', dba=None): """Program.open(type='r') -> file or FileTransfer object""" if type != 'r': raise MythFileError(MythError.FILE_FAILED_WRITE, self.filename, 'Program () objects cannot be opened for writing') - return ftopen(self.filename, 'r') + return ftopen(self.filename, 'r', forceremote=False, nooverwrite=False, db=dba) class Record( DBDataWrite ): """ diff --git a/mythtv/MythData.pyc b/mythtv/MythData.pyc deleted file mode 100755 index fd12f30a49c1c3b30fdf162a1f03841c42dcf1ba..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 59403 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%0DP`lgvKEIg3IuEI%j_w2zr8X%qj4ijyJsChsyB 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 diff --git a/mythtv/__init__.py b/mythtv/__init__.py index d8115ff..06e3475 100755 --- a/mythtv/__init__.py +++ b/mythtv/__init__.py @@ -36,6 +36,9 @@ if version_info >= (2, 6): # 2.6 or newer else: exec(import25) +__version__ = OWN_VERSION +#MythStatic.mysqldb = MySQLdb.__version__ + if __name__ == '__main__': banner = 'MythTV Python interactive shell.' import code diff --git a/mythtv/__init__.pyc b/mythtv/__init__.pyc deleted file mode 100755 index 8abb1d0de878cc8a141e7d3c4613853a75d91a33..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1636 zcmZ`(?`|7K5TCOh|BIbCg+eI>jucdtsh*7mmNU2Aup zDxdp>N8vqq0v>>u;00i2?YqPua`NrXZ+2#Wvoo{)``>o+&wn1Dq_BE+>HC;o*N*^@ zei2{^unbrM+yJZs)&T2(4ZtSgCSVJ&4Y&ok4cGzP0lWqH9^m_cw*fx@{1EUC;75RW z0Y3)32lxr#r+}XU#(d&$4!n|e zP;{iP;9W$KDZA(xGQeP){K;HvFxXPd(#LYr{X_c43ZMn67D#t~XgZ{qJx`6$|0NI&2UOhbt zxVFM4X5t$w`OsQpeT&e*8!IOA*qX)Mv+1!Fqmi^Bov}A_se|2-%H)|9+Kp+C!yH*D z2Xm3i;Pu>^i50Wpbt+S1F&u6wO7z?0k%Qcp6a78IT0;HZ+|MZmYLrm-SI-9 z4AMCXCZrRjJk<^vJ(K=iIc4beiOw-MvT6n4TQ> z9C>GQtTHcc_ko&dW95UrU^BL67GI4}yyAJdzS$Q}-mt|3cEi@uLZ??-F5z?B$X9Hv za~0}!o$uIb67ZOtYAypkZb)GbtPM37%XPhW-ki zp+Yc!Uav06aJ4vgnNdmo#kUFdK=~x;5gWVwJTth+ZNv6C2cvnM{k8tldL+?|0kWFX#fBK diff --git a/mythtv/tmdb/.svn/all-wcprops b/mythtv/tmdb/.svn/all-wcprops index b66c7a7..c7e3a95 100644 --- a/mythtv/tmdb/.svn/all-wcprops +++ b/mythtv/tmdb/.svn/all-wcprops @@ -1,29 +1,29 @@ K 25 svn:wc:ra_dav:version-url -V 72 -/svn/!svn/ver/24521/tags/release-0-23/mythtv/bindings/python/MythTV/tmdb +V 74 +/svn/!svn/ver/25397/tags/release-0-23-1/mythtv/bindings/python/MythTV/tmdb END __init__.py K 25 svn:wc:ra_dav:version-url -V 84 -/svn/!svn/ver/24521/tags/release-0-23/mythtv/bindings/python/MythTV/tmdb/__init__.py +V 86 +/svn/!svn/ver/25397/tags/release-0-23-1/mythtv/bindings/python/MythTV/tmdb/__init__.py END tmdb_api.py K 25 svn:wc:ra_dav:version-url -V 84 -/svn/!svn/ver/24521/tags/release-0-23/mythtv/bindings/python/MythTV/tmdb/tmdb_api.py +V 86 +/svn/!svn/ver/25397/tags/release-0-23-1/mythtv/bindings/python/MythTV/tmdb/tmdb_api.py END tmdb_ui.py K 25 svn:wc:ra_dav:version-url -V 83 -/svn/!svn/ver/24521/tags/release-0-23/mythtv/bindings/python/MythTV/tmdb/tmdb_ui.py +V 85 +/svn/!svn/ver/25397/tags/release-0-23-1/mythtv/bindings/python/MythTV/tmdb/tmdb_ui.py END tmdb_exceptions.py K 25 svn:wc:ra_dav:version-url -V 91 -/svn/!svn/ver/24521/tags/release-0-23/mythtv/bindings/python/MythTV/tmdb/tmdb_exceptions.py +V 93 +/svn/!svn/ver/25397/tags/release-0-23-1/mythtv/bindings/python/MythTV/tmdb/tmdb_exceptions.py END diff --git a/mythtv/tmdb/.svn/entries b/mythtv/tmdb/.svn/entries index dc72375..54fb221 100644 --- a/mythtv/tmdb/.svn/entries +++ b/mythtv/tmdb/.svn/entries @@ -1,8 +1,8 @@ 10 dir -25361 -http://svn.mythtv.org/svn/tags/release-0-23/mythtv/bindings/python/MythTV/tmdb +25726 +http://svn.mythtv.org/svn/tags/release-0-23-1/mythtv/bindings/python/MythTV/tmdb http://svn.mythtv.org/svn @@ -32,7 +32,7 @@ file -2010-07-16T22:31:04.000000Z +2010-08-18T01:49:43.000000Z d41d8cd98f00b204e9800998ecf8427e 2010-01-29T01:39:37.380922Z 23354 @@ -66,7 +66,7 @@ file -2010-07-16T22:31:04.000000Z +2010-08-18T01:49:43.000000Z 80afb5dbadea4de7d5ed5d0e1fa956bf 2010-04-29T22:38:50.878564Z 24305 @@ -100,7 +100,7 @@ file -2010-07-16T22:31:04.000000Z +2010-08-18T01:49:43.000000Z 89285d26d830a7a0fc71f92e4f60f815 2010-04-12T22:36:01.726283Z 24097 @@ -134,7 +134,7 @@ file -2010-07-16T22:31:04.000000Z +2010-08-18T01:49:43.000000Z d6d57ccfa5baa9fd79eb04eb3b6e7aff 2010-01-29T01:39:37.380922Z 23354 diff --git a/mythtv/ttvdb/.svn/all-wcprops b/mythtv/ttvdb/.svn/all-wcprops index 9ea226f..8302346 100644 --- a/mythtv/ttvdb/.svn/all-wcprops +++ b/mythtv/ttvdb/.svn/all-wcprops @@ -1,41 +1,41 @@ K 25 svn:wc:ra_dav:version-url -V 73 -/svn/!svn/ver/24521/tags/release-0-23/mythtv/bindings/python/MythTV/ttvdb +V 75 +/svn/!svn/ver/25397/tags/release-0-23-1/mythtv/bindings/python/MythTV/ttvdb END tvdb_api.py K 25 svn:wc:ra_dav:version-url -V 85 -/svn/!svn/ver/24521/tags/release-0-23/mythtv/bindings/python/MythTV/ttvdb/tvdb_api.py +V 87 +/svn/!svn/ver/25397/tags/release-0-23-1/mythtv/bindings/python/MythTV/ttvdb/tvdb_api.py END ttvdb-example.conf K 25 svn:wc:ra_dav:version-url -V 92 -/svn/!svn/ver/24521/tags/release-0-23/mythtv/bindings/python/MythTV/ttvdb/ttvdb-example.conf +V 94 +/svn/!svn/ver/25397/tags/release-0-23-1/mythtv/bindings/python/MythTV/ttvdb/ttvdb-example.conf END tvdb_ui.py K 25 svn:wc:ra_dav:version-url -V 84 -/svn/!svn/ver/24521/tags/release-0-23/mythtv/bindings/python/MythTV/ttvdb/tvdb_ui.py +V 86 +/svn/!svn/ver/25397/tags/release-0-23-1/mythtv/bindings/python/MythTV/ttvdb/tvdb_ui.py END __init__.py K 25 svn:wc:ra_dav:version-url -V 85 -/svn/!svn/ver/24521/tags/release-0-23/mythtv/bindings/python/MythTV/ttvdb/__init__.py +V 87 +/svn/!svn/ver/25397/tags/release-0-23-1/mythtv/bindings/python/MythTV/ttvdb/__init__.py END tvdb_exceptions.py K 25 svn:wc:ra_dav:version-url -V 92 -/svn/!svn/ver/24521/tags/release-0-23/mythtv/bindings/python/MythTV/ttvdb/tvdb_exceptions.py +V 94 +/svn/!svn/ver/25397/tags/release-0-23-1/mythtv/bindings/python/MythTV/ttvdb/tvdb_exceptions.py END cache.py K 25 svn:wc:ra_dav:version-url -V 82 -/svn/!svn/ver/24521/tags/release-0-23/mythtv/bindings/python/MythTV/ttvdb/cache.py +V 84 +/svn/!svn/ver/25397/tags/release-0-23-1/mythtv/bindings/python/MythTV/ttvdb/cache.py END diff --git a/mythtv/ttvdb/.svn/entries b/mythtv/ttvdb/.svn/entries index 50556bb..2afbb0b 100644 --- a/mythtv/ttvdb/.svn/entries +++ b/mythtv/ttvdb/.svn/entries @@ -1,8 +1,8 @@ 10 dir -25361 -http://svn.mythtv.org/svn/tags/release-0-23/mythtv/bindings/python/MythTV/ttvdb +25726 +http://svn.mythtv.org/svn/tags/release-0-23-1/mythtv/bindings/python/MythTV/ttvdb http://svn.mythtv.org/svn @@ -32,7 +32,7 @@ file -2010-07-16T22:31:04.000000Z +2010-08-18T01:49:43.000000Z 554f84950a1f7cc2bab09477c3798f18 2010-01-29T01:39:37.380922Z 23354 @@ -66,7 +66,7 @@ file -2010-07-16T22:31:04.000000Z +2010-08-18T01:49:43.000000Z 8a9a6d831a1892828eb38318b114f0b0 2010-01-29T01:39:37.380922Z 23354 @@ -100,7 +100,7 @@ file -2010-07-16T22:31:04.000000Z +2010-08-18T01:49:43.000000Z 305a22b10a8be8dfef8ff90c399d9a38 2010-01-29T01:39:37.380922Z 23354 @@ -134,7 +134,7 @@ file -2010-07-16T22:31:04.000000Z +2010-08-18T01:49:43.000000Z d41d8cd98f00b204e9800998ecf8427e 2010-01-29T01:39:37.380922Z 23354 @@ -168,7 +168,7 @@ file -2010-07-16T22:31:04.000000Z +2010-08-18T01:49:43.000000Z 90ce82c602de1cc41d603c01563d3bfb 2010-01-29T01:39:37.380922Z 23354 @@ -202,7 +202,7 @@ file -2010-07-16T22:31:04.000000Z +2010-08-18T01:49:43.000000Z e904998a5a3c1e088dc5b00a57947127 2010-01-29T01:39:37.380922Z 23354 diff --git a/patches/MythBase.py.orig b/patches/MythBase.py.orig new file mode 100755 index 0000000..d42c32b --- /dev/null +++ b/patches/MythBase.py.orig @@ -0,0 +1,1868 @@ +# -*- coding: utf-8 -*- + +""" +Provides base classes for accessing MythTV +""" + +from MythStatic import * + +import os, re, socket, sys, locale, weakref +import xml.etree.cElementTree as etree +from datetime import datetime +from time import sleep, time +from urllib import urlopen +from subprocess import Popen +from sys import version_info + +import MySQLdb, MySQLdb.cursors +MySQLdb.__version__ = tuple([v for v in MySQLdb.__version__.split('.')]) + +class DictData( object ): + """ + DictData.__init__(raw) -> DictData object + + Basic class for providing management of data resembling dictionaries. + Must be subclassed for use. + + Subclasses must provide: + field_order + string list of field names + field_type + integer list of field types by: + 0: integer - int(value) + 1: float - float(value) + 2: bool - bool(value) + 3: string - value + 4: date - datetime.fromtimestamp(int(value)) + can be the single string 'Pass', to pass all values untouched + + Data can be accessed as: + obj.field_name + -- or -- + obj['field_name'] + """ + + class DictIterator( object ): + def __init__(self, mode, parent): + # modes = 1:keys, 2:values, 3:items + self.index = 0 + self.mode = mode + self.data = parent + def __iter__(self): return self + def next(self): + self.index += 1 + if self.index > len(self.data.field_order): + raise StopIteration + key = self.data.field_order[self.index-1] + if self.mode == 1: + return key + elif self.mode == 2: + return self.data[key] + elif self.mode == 3: + return (key, self.data[key]) + + logmodule = 'Python DictData' + # class emulation functions + def __getattr__(self,name): + # function for class attribute access of data fields + # accesses real attributes, falls back to data fields, and errors + if name in self.__dict__: + return self.__dict__[name] + elif name in self.field_order: + return self.data[name] + else: + raise AttributeError("'DictData' object has no attribute '%s'"%name) + + def __setattr__(self,name,value): + # function for class attribute access of data fields + # sets value in data fields, falling back to real attributes + if name in self.field_order: + self.data[name] = value + else: + self.__dict__[name] = value + + # container emulation functions + def __getitem__(self,key): + # function for and dist-like access to content + # as a side effect of dual operation, dict data cannot be indexed + # keyed off integer values + if key in self.data: + return self.data[key] + else: + raise KeyError("'DictData' object has no key '%s'" %key) + + def __setitem__(self,key,value): + # function for dist-like access to content + # does not allow setting of data not already in field_order + if key in self.field_order: + self.data[key] = value + else: + raise KeyError("'DictData' does not allow new data") + + def __contains__(self,item): + return bool(item in self.data.keys()) + + def __iter__(self): + return self.DictIterator(2,self) + + def iterkeys(self): + """ + obj.iterkeys() -> an iterator over the keys of obj + ordered by self.field_order + """ + return self.DictIterator(1,self) + + def itervalues(self): + """ + obj.itervalues() -> an iterator over the values of obj + ordered by self.field_order + """ + return self.DictIterator(2,self) + + def iteritems(self): + """ + obj.iteritems() -> an iterator of over the (key,value) pairs of obj + ordered by self.field_order + """ + return self.DictIterator(3,self) + + def keys(self): + """obj.keys() -> list of self.field_order""" + return list(self.iterkeys()) + + def has_key(self,item): + return self.__contains__(item) + + def values(self): + """obj.values() -> list of values, ordered by self.field_order""" + return list(self.itervalues()) + + def get(self,key): + return self.data[key] + + def items(self): + """ + obj.items() -> list of (key,value) pairs, ordered by self.field_order + """ + return list(self.iteritems()) + + def pop(self,key): + raise NotImplementedError + + def popitem(self): + raise NotImplementedError + + def update(self, *args, **keywords): + self.data.update(*args, **keywords) + + ### additional management functions + def _setDefs(self): + self.data = {} + self.field = 0 + self.log = MythLog(self.logmodule) + + def __init__(self, raw): + self._setDefs() + self.data.update(self._process(raw)) + + def _process(self, data): + data = list(data) + for i in range(0, len(data)): + if data[i] == '': + data[i] = None + elif self.field_type == 'Pass': + data[i] = data[i] + elif self.field_type[i] == 0: + data[i] = int(data[i]) + elif self.field_type[i] == 1: + data[i] = locale.atof(data[i]) + elif self.field_type[i] == 2: + data[i] = bool(data[i]) + elif self.field_type[i] == 3: + data[i] = data[i] + elif self.field_type[i] == 4: + data[i] = datetime.fromtimestamp(int(data[i])) + return dict(zip(self.field_order,data)) + + @staticmethod + def joinInt(high,low): + """obj.joinInt(high, low) -> 64-bit int, from two signed ints""" + return (high + (low<0))*2**32 + low + + @staticmethod + def splitInt(integer): + """obj.joinInt(64-bit int) -> (high, low)""" + return integer/(2**32),integer%2**32 - (integer%2**32 > 2**31)*2**32 + +class DBData( DictData ): + """ + DBData.__init__(data=None, db=None, raw=None) --> DBData object + + Altered DictData adding several functions for dealing with individual rows. + Must be subclassed for use. + + Subclasses must provide: + table + Name of database table to be accessed + where + String defining WHERE clause for database lookup + setwheredat + String of comma separated variables to be evaluated to set + 'wheredat'. 'eval(setwheredat)' must return a tuple, so string must + contain at least one comma. + + Subclasses may provide: + allow_empty + Controls whether DBData() will allow an empty instance. + schema_value, schema_local, schema_name + Allows checking of alternate plugin schemas + + Can be populated in two manners: + data + Tuple used to perform a SQL query, using the subclass provided + 'where' clause + raw + Raw list as returned by 'select * from mytable' + """ + field_type = 'Pass' + allow_empty = False + logmodule = 'Python DBData' + schema_value = 'DBSchemaVer' + schema_local = SCHEMA_VERSION + schema_name = 'Database' + + def getAllEntries(self): + """obj.getAllEntries() -> tuple of DBData objects""" + c = self.db.cursor(self.log) + query = """SELECT * FROM %s""" % self.table + self.log(MythLog.DATABASE, query) + if c.execute(query) == 0: + return () + objs = [] + for row in c.fetchall(): + objs.append(self.__class__(db=self.db, raw=row)) + c.close() + return objs + + def _setDefs(self): + self.__dict__['field_order'] = self.db.tablefields[self.table] + DictData._setDefs(self) + self._fillNone() + self.wheredat = None + self.log = MythLog(self.logmodule, db=self.db) + + def __init__(self, data=None, db=None, raw=None): + self.__dict__['db'] = MythDBBase(db) + self.db._check_schema(self.schema_value, + self.schema_local, self.schema_name) + self._setDefs() + + if raw is not None: + if len(raw) == len(self.field_order): + self.data.update(self._process(raw)) + self.wheredat = eval(self.setwheredat) + else: + raise MythError('Incorrect raw input length to DBData()') + elif data is not None: + if None not in data: + self.wheredat = tuple(data) + self._pull() + else: + if self.allow_empty: + self._fillNone() + else: + raise MythError('DBData() not given sufficient information') + + def _pull(self): + """Updates table with data pulled from database.""" + c = self.db.cursor(self.log) + c.execute("""SELECT * FROM %s WHERE %s""" \ + % (self.table, self.where), self.wheredat) + data = c.fetchone() + c.close() + if data is None: + return + self.data.update(self._process(data)) + + def _fillNone(self): + """Fills out dictionary fields with empty data""" + self.field_order = self.db.tablefields[self.table] + for field in self.field_order: + self.data[field] = None + +class DBDataWrite( DBData ): + """ + DBDataWrite.__init__(data=None, db=None, raw=None) --> DBDataWrite object + + Altered DBData, with support for writing back to the database. + Must be subclassed for use. + + Subclasses must provide: + table + Name of database table to be accessed + where + String defining WHERE clause for database lookup + setwheredat + String of comma separated variables to be evaluated to set + 'wheredat'. 'eval(setwheredat)' must return a tuple, so string must + contain at least one comma. + + Subclasses may provide: + defaults + Dictionary of default values to be used when creating new + database entries. Additionally, values of 'None' will be stripped + and not used to alter the database. + + Can be populated in two manners: + data + Tuple used to perform a SQL query, using the subclass provided + 'where' clause + raw + Raw list as returned by 'select * from mytable' + Additionally, can be left uninitialized to allow creation of a new entry + """ + defaults = None + allow_empty = True + logmodule = 'Python DBDataWrite' + + def _sanitize(self, data, fill=True): + """Remove fields from dictionary that are not in database table.""" + data = data.copy() + for key in data.keys(): + if key not in self.field_order: + del data[key] + if self.defaults is not None: + for key in self.defaults: + if key in data: + if self.defaults[key] is None: + del data[key] + elif data[key] is None: + if fill: + data[key] = self.defaults[key] + return data + + def _setDefs(self): + DBData._setDefs(self) + self._fillDefs() + + def _fillDefs(self): + self._fillNone() + self.data.update(self.defaults) + + def __init__(self, data=None, db=None, raw=None): + DBData.__init__(self, data, db, raw) + if raw is not None: + self.origdata = self.data.copy() + + def create(self,data=None): + """ + obj.create(data=None) -> new database row + + Creates a new database entry using given information. + Will add any information in 'data' dictionary to local information + before pushing the entire set onto the database. + Will only function with an uninitialized object. + """ + if self.wheredat is not None: + raise MythError('DBDataWrite object already bound to '+\ + 'existing instance') + + if data is not None: + data = self._sanitize(data, False) + self.data.update(data) + data = self._sanitize(self.data) + for key in data.keys(): + if data[key] is None: + del data[key] + c = self.db.cursor(self.log) + fields = ', '.join(data.keys()) + format_string = ', '.join(['%s' for d in data.values()]) + c.execute("""INSERT INTO %s (%s) VALUES(%s)""" \ + % (self.table, fields, format_string), data.values()) + intid = c.lastrowid + c.close() + return intid + + def _pull(self): + DBData._pull(self) + self.origdata = self.data.copy() + + def _push(self): + if (self.where is None) or (self.wheredat is None): + return + c = self.db.cursor(self.log) + data = self._sanitize(self.data) + for key, value in data.items(): + if value == self.origdata[key]: + # filter unchanged data + del data[key] + if len(data) == 0: + # no updates + return + format_string = ', '.join(['%s = %%s' % d for d in data]) + sql_values = data.values() + sql_values.extend(self.wheredat) + + c.execute("""UPDATE %s SET %s WHERE %s""" \ + % (self.table, format_string, self.where), sql_values) + c.close() + self._pull() + + def update(self, *args, **keywords): + """ + obj.update(*args, **keywords) -> None + + Follows dict.update() syntax. Updates local information, and pushes + changes onto the database. + Will only function on an initialized object. + """ + + data = {} + data.update(*args, **keywords) + self.data.update(self._sanitize(data)) + self._push() + + def delete(self): + """ + obj.delete() -> None + + Delete video entry from database. + """ + if (self.where is None) or \ + (self.wheredat is None) or \ + (self.data is None): + return + c = self.db.cursor(self.log) + c.execute("""DELETE FROM %s WHERE %s""" \ + % (self.table, self.where), self.wheredat) + c.close() + +class DBDataRef( object ): + """ + DBDataRef.__init__(where, db=None) --> DBDataRef object + + Class for managing lists of referenced data, such as recordedmarkup + Subclasses must provide: + table + Name of database table to be accessed + wfield + list of fields for WHERE argument in lookup + + Can be populated by supplying a tuple, matching the fields specified in wfield + """ + logmodule = 'Python DBDataRef' + + class SubData( DictData ): + def _setDefs(self): + self.__dict__['field_order'] = [] + DictData._setDefs(self) + def __repr__(self): + return str(tuple(self)) + def __init__(self,data,fields): + self._setDefs() + self.field_order = fields + self.field_type = 'Pass' + self.data.update(self._process(data)) + def __hash__(self): + dat = self.data.copy() + keys = dat.keys() + keys.sort() + return hash(str([hash(dat[k]) for k in keys])) + def __cmp__(self, x): + return cmp(hash(self),hash(x)) + + def _setDefs(self): + self.data = None + self.hash = None + self.log = MythLog(self.logmodule, db=self.db) + + def __repr__(self): + if self.data is None: + self._fill() + return "" % \ + (str(tuple(self.data)), hex(id(self))) + + def __getitem__(self,index): + if self.data is None: + self._fill() + if index in range(-len(self.data),0)+range(len(self.data)): + return self.data[index] + else: + raise IndexError + + def __iter__(self): + self.field = 0 + if self.data is None: + self._fill() + return self + + def next(self): + if self.field == len(self.data): + del self.field + raise StopIteration + self.field += 1 + return self[self.field-1] + + def __init__(self,where,db=None): + self.db = MythDBBase(db) + self._setDefs() + self.where = tuple(where) + self.dfield = list(self.db.tablefields[self.table]) + for field in self.wfield: + del self.dfield[self.dfield.index(field)] + + def _fill(self): + self.data = [] + self.hash = [] + fields = ','.join(self.dfield) + where = ' AND '.join('%s=%%s' % d for d in self.wfield) + c = self.db.cursor(self.log) + c.execute("""SELECT %s FROM %s WHERE %s""" \ + % (fields, self.table, where), self.where) + for entry in c.fetchall(): + self.data.append(self.SubData(entry, self.dfield)) + self.hash.append(hash(self.data[-1])) + + def add(self, data): + """obj.add(data) -> None""" + if self.data is None: + self._fill() + if len(data) != len(self.wfield): + return + + dat = self.SubData(data, self.dfield) + if hash(dat) in self.hash: + return + + c = self.db.cursor(self.log) + fields = ', '.join(self.wfield+self.dfield) + format = ', '.join(['%s' for d in fields]) + c.execute("""INSERT INTO %s (%s) VALUES(%s)""" \ + % (self.table, fields, format), + self.where+list(data)) + c.close() + + self.data.append(dat) + self.hash.append(hash(dat)) + + def delete(self, index): + """obj.delete(data) -> None""" + if self.data is None: + self._fill() + if index not in range(0, len(self.data)): + return + + where = ' AND '.join(['%s=%%s' % d for d in self.wfield+self.dfield]) + values = self.where+list(self.data[index]) + c = self.db.cursor(self.log) + c.execute("""DELETE FROM %s WHERE %s""" \ + % (self.table, where), values) + c.close() + + del self.data[index] + del self.hash[index] + + +class DBDataCRef( object ): + """ + DBDataRef.__init__(where, db=None) --> DBDataRef object + + Class for managing lists of crossreferenced data, such as recordedcredits + Subclasses must provide: + table - primary data table + rtable - cross reference table + t_ref - primary table field to align with cross reference table + r_ref - cross reference table field to align with primary table + t_add - list of fields for data stored in primary table + r_add - list of fields for data stored in cross reference table + w_field - list of fields for WHERE argument in lookup + """ + logmodule = 'Python DBDataCRef' + + class SubData( DictData ): + def _setDefs(self): + self.__dict__['field_order'] = [] + DictData._setDefs(self) + def __repr__(self): + return str(tuple(self.data.values())) + def __init__(self,data,fields): + self._setDefs() + self.field_order = ['cross']+fields + self.field_type = 'Pass' + self.data.update(self._process(data)) + def __hash__(self): + dat = self.data.copy() + del dat['cross'] + keys = dat.keys() + keys.sort() + return hash(str([hash(dat[k]) for k in keys])) + def __cmp__(self, x): + return cmp(hash(self),hash(x)) + + def _setDefs(self): + self.data = None + self.hash = None + self.log = MythLog(self.logmodule, db=self.db) + + def __repr__(self): + if self.data is None: + self._fill() + return "" % \ + (str(tuple(self.data)), hex(id(self))) + + def __getitem__(self,index): + if self.data is None: + self._fill() + if index in range(-len(self.data),0)+range(len(self.data)): + return self.data[index] + else: + raise IndexError + + def __iter__(self): + self.field = 0 + if self.data is None: + self._fill() + return self + + def next(self): + if self.field == len(self.data): + del self.field + raise StopIteration + self.field += 1 + return self[self.field-1] + + def __init__(self,where,db=None): + self.db = MythDBBase(db) + self._setDefs() + self.w_dat = tuple(where) + + def _fill(self): + self.data = [] + self.hash = [] + fields = ','.join([self.table+'.'+self.t_ref]+self.t_add+self.r_add) + where = ' AND '.join('%s=%%s' % d for d in self.w_field) + join = '%s.%s=%s.%s' % (self.table,self.t_ref,self.rtable,self.r_ref) + c = self.db.cursor(self.log) + c.execute("""SELECT %s FROM %s JOIN %s ON %s WHERE %s"""\ + % (fields, self.table, self.rtable, join, where), + self.w_dat) + for entry in c.fetchall(): + self.data.append(self.SubData(entry, self.t_add+self.r_add)) + self.hash.append(hash(self.data[-1])) + + def _tdata(self, sub): + return tuple([sub[key] for key in self.t_add]) + + def _rdata(self, sub): + return tuple([sub[key] for key in self.r_add+['cross']]+list(self.w_dat)) + + def _search(self, sub): + for i in range(len(self.data)): + if self.data[i] == sub: + return i + + def add(self,data): + """obj.add(data) -> None""" + if self.data is None: + self._fill() + if len(data) != len(self.t_add+self.r_add): + return + + # check for existing + dat = self.SubData([0]+list(data),self.t_add+self.r_add) + if dat in self.data: + return + + tdata = self._tdata(dat) + + c = self.db.cursor(self.log) + # search for existing table data, add if needed + where = ' AND '.join('%s=%%s' % d for d in self.t_add) + if c.execute("""SELECT %s FROM %s WHERE %s""" \ + % (self.t_ref, self.table, where), + self._tdata(dat)) > 0: + id = c.fetchone()[0] + else: + fields = ', '.join(self.t_add) + format = ', '.join(['%s' for d in self.t_add]) + c.execute("""INSERT INTO %s (%s) VALUES(%s)""" \ + % (self.table, fields, format), self._tdata(dat)) + id = c.lastrowid + + dat = self.SubData([id]+list(data),self.t_add+self.r_add) + + # double check for existing + fields = self.r_add+[self.r_ref]+self.w_field + where = ' AND '.join('%s=%%s' % d for d in fields) + if c.execute("""SELECT * FROM %s WHERE %s""" % (self.rtable, where), + self._rdata(dat)) > 0: + return + + # add cross reference + fields = ', '.join(self.r_add+[self.r_ref]+self.w_field) + format = ', '.join(['%s' for d in fields.split(', ')]) + c.execute("""INSERT INTO %s (%s) VALUES(%s)"""\ + % (self.rtable, fields, format), self._rdata(dat)) + c.close() + + # add local entry + self.data.append(dat) + + def delete(self,data): + """obj.delete(data) -> None""" + if self.data is None: + self._fill() + # check for local entry + dat = self.SubData([0]+list(data),self.t_add+self.r_add) + if dat not in self.data: + return + # remove local entry + index = self._search(dat) + dat = self.data.pop(index) + + # remove database cross reference + fields = self.r_add+[self.r_ref]+self.w_field + where = ' AND '.join('%s=%%s' % d for d in fields) + c = self.db.cursor(self.log) + c.execute("""DELETE FROM %s WHERE %s""" % (self.rtable, where), + self._rdata(dat)) + + # remove primary entry if unused + if c.execute("""SELECT %s FROM %s WHERE %s=%%s""" \ + % (self.r_ref, self.rtable, self.r_ref), + dat.cross) == 0: + c.execute("""DELETE FROM %s WHERE %s=%%s""" \ + % (self.table, self.t_ref), dat.cross) + c.close() + + index = self.hash.index[dat] + del self.hash[index] + del self.data[index] + +class MythDBCursor( MySQLdb.cursors.Cursor ): + """ + Custom cursor, offering logging and error handling + """ + def __init__(self, connection): + self.log = None + MySQLdb.cursors.Cursor.__init__(self, connection) + + def execute(self, query, args=None): + if MySQLdb.__version__ >= ('1','2','2'): + self.connection.ping(True) + else: + self.connection.ping() + if self.log == None: + self.log = MythLog('Python Database Connection') + if args: + self.log(self.log.DATABASE, ' '.join(query.split()), str(args)) + else: + self.log(self.log.DATABASE, ' '.join(query.split())) + try: + return MySQLdb.cursors.Cursor.execute(self, query, args) + except Exception, e: + raise MythDBError(MythDBError.DB_RAW, e.args) + + def executemany(self, query, args): + if MySQLdb.__version__ >= ('1','2','2'): + self.connection.ping(True) + else: + self.connection.ping() + if self.log == None: + self.log = MythLog('Python Database Connection') + for arg in args: + self.log(self.log.DATABASE, ' '.join(query.split()), str(arg)) + try: + return MySQLdb.cursors.Cursor.executemany(self, query, args) + except Exception, e: + raise MythDBError(MythDBError.DB_RAW, e.args) + +class MythDBConn( object ): + """ + This is the basic database connection object. + You dont want to use this directly. + """ + + def __init__(self, dbconn): + self.log = MythLog('Python Database Connection') + self.tablefields = {} + self.settings = {} + try: + self.log(MythLog.DATABASE, "Attempting connection", + str(dbconn)) + self.db = MySQLdb.connect( user= dbconn['DBUserName'], + host= dbconn['DBHostName'], + passwd= dbconn['DBPassword'], + db= dbconn['DBName'], + port= dbconn['DBPort'], + use_unicode=True, + charset='utf8') + except: + raise MythDBError(MythError.DB_CONNECTION, dbconn) + + def cursor(self, log=None, type=MythDBCursor): + if MySQLdb.__version__ >= ('1','2','2'): + self.db.ping(True) + else: + self.db.ping() + c = self.db.cursor(type) + if log: + c.log = log + else: + c.log = self.log + return c + +class MythDBBase( object ): + """ + MythDBBase(db=None, args=None, **kwargs) -> database connection object + + Basic connection to the mythtv database + Offers connection caching to prevent multiple connections + + 'db' will accept an existing MythDBBase object, or any subclass there of + 'args' will accept a tuple of 2-tuples for connection settings + 'kwargs' will accept a series of keyword arguments for connection settings + 'args' and 'kwargs' accept the following values: + DBHostName + DBName + DBUserName + DBPassword + SecurityPin + + The class will first use an existing connection if provided, use the 'args' + and 'kwargs' values if sufficient information is available, falling + back to '~/.mythtv/config.xml' and finally UPnP detection. If no + 'SecurityPin' is given, UPnP detection will default to 0000, and if + successful, will populate '~/.mythtv/config.xml' with the necessary + information. + + Available methods: + obj.cursor() - open a cursor for direct database + manipulation + obj.getStorageGroup() - return a tuple of StorageGroup objects + """ + logmodule = 'Python Database Connection' + cursorclass = MythDBCursor + shared = weakref.WeakValueDictionary() + + def __repr__(self): + return "<%s '%s' at %s>" % \ + (str(self.__class__).split("'")[1].split(".")[-1], + self.ident, hex(id(self))) + + class _TableFields(object): + """Provides a dictionary-like list of table fieldnames""" + def __init__(self, db, log): + self._db = db + self._log = log + def __getattr__(self,key): + if key in self.__dict__: + return self.__dict__[key] + elif key in self._db.tablefields: + return self._db.tablefields[key] + else: + return self.__getitem__(key) + def __getitem__(self,key): + if key in self._db.tablefields: + return self._db.tablefields[key] + table_names = [] + c = self._db.cursor(self._log) + try: + c.execute("DESC %s" % (key,)) + except: + return None + + for name in c.fetchall(): + table_names.append(name[0]) + c.close() + self._db.tablefields[key] = tuple(table_names) + return table_names + + class _Settings(object): + """Provides dictionary-like list of hosts""" + class __Settings(object): + """Provides dictionary-like list of settings""" + def __repr__(self): + return str(self._shared.keys()) + def __init__(self, db, log, host): + self._db = db + self._log = log + self._host = host + def __getattr__(self,name): + if name in self.__dict__: + return self.__dict__[name] + else: + return self.__getitem__(name) + def __setattr__(self,name,value): + if name in ('_db','_host','_log'): + self.__dict__[name] = value + else: + self.__setitem__(name,value) + def __getitem__(self,key): + if key in self._db.settings[self._host]: + return self._db.settings[self._host][key] + if self._host == 'NULL': + where = 'IS NULL' + wheredat = (key,) + else: + where = 'LIKE(%s)' + wheredat = (key, self._host) + c = self._db.cursor(self._log) + c.execute("""SELECT data FROM settings + WHERE value=%%s AND hostname %s + LIMIT 1""" % where, wheredat) + row = c.fetchone() + c.close() + if row: + self._db.settings[self._host][key] = row[0] + return row[0] + else: + return None + def __setitem__(self, key, value): + if key not in self._db.settings[self._host]: + self.__getitem__(key) + c = self._db.cursor(self._log) + if key not in self._db.settings[self._host]: + host = self._host + if host is 'NULL': + host = None + c.execute("""INSERT INTO settings (value,data,hostname) + VALUES (%s,%s,%s)""", (key, value, host)) + else: + query = """UPDATE settings SET data=%s + WHERE value=%s AND""" + dat = [value, key] + if self._host == 'NULL': + query += ' hostname IS NULL' + else: + query += ' hostname=%s' + dat.append(self._host) + c.execute(query, dat) + self._db.settings[self._host][key] = value + def __repr__(self): + return str(self._db.settings.keys()) + def __init__(self, db, log): + self._db = db + self._log = log + def __getattr__(self,key): + if key in self.__dict__: + return self.__dict__[key] + else: + return self.__getitem__(key) + def __getitem__(self,key): + if key not in self._db.settings: + self._db.settings[key] = {} + return self.__Settings(self._db, self._log, key) + + def __init__(self, db=None, args=None, **dbconn): + self.db = None + self.log = MythLog(self.logmodule) + self.settings = None + if db is not None: + # load existing database connection + self.log(MythLog.DATABASE, "Loading existing connection", + str(db.dbconn)) + dbconn.update(db.dbconn) + if args is not None: + # load user defined arguments (takes dict, or key/value tuple) + self.log(MythLog.DATABASE, "Loading user settings", str(args)) + dbconn.update(args) + if 'SecurityPin' not in dbconn: + dbconn['SecurityPin'] = 0 + if not self._check_dbconn(dbconn): + # insufficient information for connection given + # try to read from config.xml + config_files = [ os.path.expanduser('~/.mythtv/config.xml') ] + if 'MYTHCONFDIR' in os.environ: + config_files.append('%s/config.xml' %os.environ['MYTHCONFDIR']) + + for config_file in config_files: + dbconn.update({ 'DBHostName':None, 'DBName':None, + 'DBUserName':None, 'DBPassword':None, + 'DBPort':0}) + try: + config = etree.parse(config_file).getroot() + for child in config.find('UPnP').find('MythFrontend').\ + find('DefaultBackend').getchildren(): + if child.tag in dbconn: + dbconn[child.tag] = child.text + except: + continue + + if self._check_dbconn(dbconn): + self.log(MythLog.IMPORTANT|MythLog.DATABASE, + "Using connection settings from %s" % config_file) + break + else: + # fall back to UPnP + dbconn = self._listenUPNP(dbconn['SecurityPin'], 5.0) + + # push data to new settings file + settings = [dbconn[key] for key in \ + ('SecurityPin','DBHostName','DBUserName', + 'DBPassword','DBName','DBPort')] + config = """ + + + + + + + + %s + %s + %s + %s + %s + %s + + + + +""" % tuple(settings) + mythdir = os.path.expanduser('~/.mythtv') + if not os.access(mythdir, os.F_OK): + os.mkdir(mythdir,0755) + fp = open(mythdir+'/config.xml', 'w') + fp.write(config) + fp.close() + + if 'DBPort' not in dbconn: + dbconn['DBPort'] = 3306 + else: + dbconn['DBPort'] = int(dbconn['DBPort']) + if dbconn['DBPort'] == 0: + dbconn['DBPort'] = 3306 + + self.dbconn = dbconn + self.ident = "sql://%s@%s:%d/" % \ + (dbconn['DBName'],dbconn['DBHostName'],dbconn['DBPort']) + if self.ident in self.shared: + # reuse existing database connection and update count + self.db = self.shared[self.ident] + else: + # attempt new connection + self.db = MythDBConn(dbconn) + + # check schema version + self._check_schema('DBSchemaVer',SCHEMA_VERSION) + + # add connection to cache + self.shared[self.ident] = self.db + + # connect to table name cache + self.tablefields = self._TableFields(self.shared[self.ident], self.log) + self.settings = self._Settings(self.shared[self.ident], self.log) + + def _listenUPNP(self, pin, timeout): + # open outbound socket + + upnpport = 1900 + upnptup = ('239.255.255.250', upnpport) + sreq = '\r\n'.join(['M-SEARCH * HTTP/1.1', + 'HOST: %s:%s' % upnptup, + 'MAN: "ssdp:discover"', + 'MX: 5', + 'ST: ssdp:all','']) + + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, + socket.IPPROTO_UDP) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + try: + sock.bind(('', upnpport)) + except: + raise MythDBError(MythError.DB_CREDENTIALS) + sock.setblocking(0) + + # spam the request a couple times + sock.sendto(sreq, upnptup) + sock.sendto(sreq, upnptup) + sock.sendto(sreq, upnptup) + + reLOC = re.compile('http://(?P[0-9\.]+):(?P[0-9]+)/.*') + + # listen for + atime = time()+timeout + while time() < atime: + sleep(0.1) + try: + sdata, saddr = sock.recvfrom(2048) + except socket.error: + continue # on fault from nonblocking recv + + lines = sdata.split('\n') + sdict = {'request':lines[0].strip()} + for line in lines[1:]: + fields = line.split(':',1) + if len(fields) == 2: + sdict[fields[0].strip().lower()] = fields[1].strip() + + if 'st' not in sdict: + continue + if sdict['st'] not in \ + ('urn:schemas-mythtv-org:device:MasterMediaServer:1', + 'urn:schemas-mythtv-org:device:SlaveMediaServer:1', + 'urn:schemas-upnp-org:device:MediaServer:1'): + continue + ip, port = reLOC.match(sdict['location']).group(1,2) + xml = MythXMLConn(backend=ip, port=port) + dbconn = xml.getConnectionInfo(pin) + if self._check_dbconn(dbconn): + break + + else: + sock.close() + raise MythDBError(MythError.DB_CREDENTIALS) + + sock.close() + return dbconn + + def _check_dbconn(self,dbconn): + reqs = ['DBHostName','DBName','DBUserName','DBPassword'] + for req in reqs: + if req not in dbconn: + return False + if dbconn[req] is None: + return False + return True + + def _check_schema(self, value, local, name='Database'): + if self.settings is None: + c = self.cursor(self.log) + lines = c.execute("""SELECT data FROM settings + WHERE value LIKE(%s)""",(value,)) + if lines == 0: + c.close() + raise MythDBError(MythError.DB_SETTING, value) + + sver = int(c.fetchone()[0]) + c.close() + else: + sver = int(self.settings['NULL'][value]) + + if local != sver: + self.log(MythLog.DATABASE|MythLog.IMPORTANT, + "%s schema mismatch: we speak %d but database speaks %s" \ + % (name, local, sver)) + self.db = None + raise MythDBError(MythError.DB_SCHEMAMISMATCH, value, sver, local) + + def getStorageGroup(self, groupname=None, hostname=None): + """ + obj.getStorageGroup(groupname=None, hostname=None) + -> tuple of StorageGroup objects + groupname and hostname can be used as optional filters + """ + c = self.cursor(self.log) + where = [] + wheredat = [] + if groupname: + where.append("groupname=%s") + wheredat.append(groupname) + if hostname: + where.append("hostname=%s") + wheredat.append(hostname) + if len(where): + where = 'WHERE '+' AND '.join(where) + c.execute("""SELECT * FROM storagegroup %s ORDER BY id""" \ + % where, wheredat) + else: + c.execute("""SELECT * FROM storagegroup ORDER BY id""") + + ret = [] + for row in c.fetchall(): + ret.append(StorageGroup(raw=row,db=self)) + return ret + + def cursor(self, log=None): + if not log: + log = self.log + return self.db.cursor(log, self.cursorclass) + +class MythBEConn( object ): + """ + This is the basic backend connection object. + You probably dont want to use this directly. + """ + logmodule = 'Python Backend Connection' + + def __init__(self, backend, type, db=None): + """ + MythBEConn(backend, type, db=None) -> backend socket connection + + 'backend' can be either a hostname or IP address, or will default + to the master backend if None. + 'type' is any value, as required by obj.announce(type). The stock + method passes 'Monitor' or 'Playback' during connection to + backend. There is no checking on this input. + 'db' will accept an existing MythDBBase object, + or any subclass there of. + """ + self.connected = False + self.db = MythDBBase(db) + self.log = MythLog(self.logmodule, db=self.db) + if backend is None: + # use master backend, no sanity checks, these should always be set + self.host = self.db.settings.NULL.MasterServerIP + self.port = self.db.settings.NULL.MasterServerPort + else: + if re.match('(?:\d{1,3}\.){3}\d{1,3}',backend): + # process ip address + c = self.db.cursor(self.log) + if c.execute("""SELECT hostname FROM settings + WHERE value='BackendServerIP' + AND data=%s""", backend) == 0: + c.close() + raise MythError(MythError.DB_SETTING, + 'BackendServerIP', backend) + backend = c.fetchone()[0] + c.close() + self.host = self.db.settings[backend].BackendServerIP + if not self.host: + raise MythDBError(MythError.DB_SETTING, + 'BackendServerIP', backend) + self.port = self.db.settings[backend].BackendServerPort + if not self.port: + raise MythDBError(MythError.DB_SETTING, + 'BackendServerPort', backend) + self.port = int(self.port) + + try: + self.log(MythLog.SOCKET|MythLog.NETWORK, + "Connecting to backend %s:%d" % (self.host, self.port)) + self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.socket.settimeout(10) + self.socket.connect((self.host, self.port)) + self.check_version() + self.announce(type) + self.connected = True + except socket.error, e: + self.log(MythLog.IMPORTANT|MythLog.SOCKET, + "Couldn't connect to backend %s:%d" \ + % (self.host, self.port)) + raise MythBEError(MythError.PROTO_CONNECTION, self.host, self.port) + except: + raise + + def announce(self,type): + res = self.backendCommand('ANN %s %s 0' % (type, socket.gethostname())) + if res != 'OK': + self.log(MythLog.IMPORTANT|MythLog.NETWORK, + "Unexpected answer to ANN", res) + raise MythBEError(MythError.PROTO_ANNOUNCE, + self.host, self.port, res) + else: + self.log(MythLog.SOCKET,"Successfully connected to backend", + "%s:%d" % (self.host, self.port)) + self.hostname = self.backendCommand('QUERY_HOSTNAME') + + def check_version(self): + res = self.backendCommand('MYTH_PROTO_VERSION %s' \ + % PROTO_VERSION).split(BACKEND_SEP) + if res[0] == 'REJECT': + self.log(MythLog.IMPORTANT|MythLog.NETWORK, + "Backend has version %s, and we speak %d" %\ + (res[1], PROTO_VERSION)) + raise MythBEError(MythError.PROTO_MISMATCH, + int(res[1]), PROTO_VERSION) + + def backendCommand(self, data): + """ + obj.backendCommand(data) -> response string + + Sends a formatted command via a socket to the mythbackend. + """ + def recv(): + """ + Reads the data returned from the backend. + """ + # The first 8 bytes of the response gives us the length + data = self.socket.recv(8) + try: + length = int(data) + except: + return u'' + data = [] + while length > 0: + chunk = self.socket.recv(length) + length = length - len(chunk) + data.append(chunk) + try: + return unicode(''.join(data),'utf8') + except: + return u''.join(data) + + data = data.encode('utf-8') + command = '%-8d%s' % (len(data), data) + self.log(MythLog.NETWORK, "Sending command", command) + self.socket.send(command) + return recv() + + def __del__(self): + if self.connected: + self.log(MythLog.SOCKET|MythLog.NETWORK, + "Terminating connection to %s:%d" % (self.host, self.port)) + self.backendCommand('DONE') + self.socket.shutdown(1) + self.socket.close() + self.connected = False + +class MythBEBase( object ): + """ + MythBEBase(backend=None, type='Monitor', db=None) + -> MythBackend connection object + + Basic class for mythbackend socket connections. + Offers connection caching to prevent multiple connections. + + 'backend' allows a hostname or IP address to connect to. If not provided, + connect will be made to the master backend. Port is always + taken from the database. + 'db' allows an existing database object to be supplied. + 'type' specifies the type of connection to declare to the backend. Accepts + 'Monitor' or 'Playback'. + + Available methods: + joinInt() - convert two signed ints to one 64-bit + signed int + splitInt() - convert one 64-bit signed int to two + signed ints + backendCommand() - Sends a formatted command to the backend + and returns the response. + """ + logmodule = 'Python Backend Connection' + shared = weakref.WeakValueDictionary() + + def __repr__(self): + return "<%s 'myth://%s:%d/' at %s>" % \ + (str(self.__class__).split("'")[1].split(".")[-1], + self.hostname, self.port, hex(id(self))) + + def __init__(self, backend=None, type='Monitor', db=None): + self.db = MythDBBase(db) + self.log = MythLog(self.logmodule, db=self.db) + self._ident = '%s@%s' % (type, backend) + if self._ident in self.shared: + self.be = self.shared[self._ident] + else: + self.be = MythBEConn(backend, type, db) + self.shared[self._ident] = self.be + self.hostname = self.be.hostname + self.port = self.be.port + + def backendCommand(self, data): + """ + obj.backendCommand(data) -> response string + + Sends a formatted command via a socket to the mythbackend. + """ + return self.be.backendCommand(data) + + def joinInt(self,high,low): + """obj.joinInt(high, low) -> 64-bit int, from two signed ints""" + return (high + (low<0))*2**32 + low + + def splitInt(self,integer): + """obj.joinInt(64-bit int) -> (high, low)""" + return integer/(2**32),integer%2**32 - (integer%2**32 > 2**31)*2**32 + + +class MythXMLConn( object ): + """ + MythXMLConn(backend=None, db=None, port=None) -> Backend status object + + Basic access to MythBackend status page and XML data server + + 'backend' allows a hostname or IP, defaulting to the master backend. + 'port' defines the port used to access the backend, retrieved from the + database if not given. + 'db' allows an existing database connection. Will only be used if + either 'backend' or 'port' is not defined. + """ + def __repr__(self): + return "<%s 'http://%s:%d/' at %s>" % \ + (str(self.__class__).split("'")[1].split(".")[-1], + self.host, self.port, hex(id(self))) + + def __init__(self, backend=None, db=None, port=None): + if backend and port: + self.db = None + self.host, self.port = backend, int(port) + return + self.db = MythDBBase(db) + self.log = MythLog('Python XML Connection', db=self.db) + if backend is None: + # use master backend + backend = self.db.settings.NULL.MasterServerIP + if re.match('(?:\d{1,3}\.){3}\d{1,3}',backend): + # process ip address + c = self.db.cursor(self.log) + if c.execute("""SELECT hostname FROM settings + WHERE value='BackendServerIP' + AND data=%s""", backend) == 0: + raise MythDBError(MythError.DB_SETTING, + backend+': BackendServerIP') + self.host = c.fetchone()[0] + self.port = int(self.db.settings[self.host].BackendStatusPort) + c.close() + else: + # assume given a hostname + self.host = backend + self.port = int(self.db.settings[self.host].BackendStatusPort) + if not self.port: + # try a truncated hostname + self.host = backend.split('.')[0] + self.port = int(self.db.setting[self.host].BackendStatusPort) + if not self.port: + raise MythDBError(MythError.DB_SETTING, + backend+': BackendStatusPort') + + def _query(self, path=None, **keyvars): + """ + obj._query(path=None, **keyvars) -> xml string + + 'path' is an optional page to access. + 'keyvars' are a series of optional variables to specify on the URL. + """ + url = 'http://%s:%d/' % (self.host, self.port) + if path == 'xml': + url += 'xml' + elif path is not None: + url += 'Myth/%s' % path + if len(keyvars) > 0: + fields = [] + for key in keyvars: + fields.append('%s=%s' % (key, keyvars[key])) + url += '?'+'&'.join(fields) + ufd = urlopen(url) + res = ufd.read() + ufd.close() + return res + + def _queryTree(self, path=None, **keyvars): + """obj._queryTree(path=None, **keyvars) -> xml element tree""" + xmlstr = self._query(path, **keyvars) + if xmlstr.find('xmlns') >= 0: + ind1 = xmlstr.find('xmlns') + ind2 = xmlstr.find('"', ind1+7) + 1 + xmlstr = xmlstr[:ind1] + xmlstr[ind2:] + return etree.fromstring(xmlstr) + + def getConnectionInfo(self, pin=0): + """Return dbconn dict from backend connection info.""" + dbconn = {'SecurityPin':pin} + conv = {'Host':'DBHostName', 'Port':'DBPort', + 'UserName':'DBUserName','Password':'DBPassword', + 'Name':'DBName'} + tree = self._queryTree('GetConnectionInfo', Pin=pin) + if tree.tag == 'GetConnectionInfoResponse': + for child in tree.find('Info').find('Database'): + if child.tag in conv: + dbconn[conv[child.tag]] = child.text + if 'DBPort' in dbconn: + dbconn['DBPort'] = int(dbconn['DBPort']) + return dbconn + + +class MythLog( object ): + """ + MythLog(module='pythonbindings', lstr=None, lbit=None, \ + db=None, logfile=None) -> logging object + + 'module' defines the source of the message in the logs + 'lstr' and 'lbit' define the message filter + 'lbit' takes a bitwise value + 'lstr' takes a string in the normal '-v level' form + default is set to 'important,general' + 'logfile' sets a file to open and subsequently log to + + The filter level and logfile are global values, shared between all logging + instances. Furthermode, the logfile can only be set once, and cannot + be changed. + + The logging object is callable, and implements the MythLog.log() method. + """ + + ALL = int('1111111111111111111111111111', 2) + MOST = int('0011111111101111111111111111', 2) + NONE = int('0000000000000000000000000000', 2) + + IMPORTANT = int('0000000000000000000000000001', 2) + GENERAL = int('0000000000000000000000000010', 2) + RECORD = int('0000000000000000000000000100', 2) + PLAYBACK = int('0000000000000000000000001000', 2) + CHANNEL = int('0000000000000000000000010000', 2) + OSD = int('0000000000000000000000100000', 2) + FILE = int('0000000000000000000001000000', 2) + SCHEDULE = int('0000000000000000000010000000', 2) + NETWORK = int('0000000000000000000100000000', 2) + COMMFLAG = int('0000000000000000001000000000', 2) + AUDIO = int('0000000000000000010000000000', 2) + LIBAV = int('0000000000000000100000000000', 2) + JOBQUEUE = int('0000000000000001000000000000', 2) + SIPARSER = int('0000000000000010000000000000', 2) + EIT = int('0000000000000100000000000000', 2) + VBI = int('0000000000001000000000000000', 2) + DATABASE = int('0000000000010000000000000000', 2) + DSMCC = int('0000000000100000000000000000', 2) + MHEG = int('0000000001000000000000000000', 2) + UPNP = int('0000000010000000000000000000', 2) + SOCKET = int('0000000100000000000000000000', 2) + XMLTV = int('0000001000000000000000000000', 2) + DVBCAM = int('0000010000000000000000000000', 2) + MEDIA = int('0000100000000000000000000000', 2) + IDLE = int('0001000000000000000000000000', 2) + CHANNELSCAN = int('0010000000000000000000000000', 2) + EXTRA = int('0100000000000000000000000000', 2) + TIMESTAMP = int('1000000000000000000000000000', 2) + + DBALL = 8 + DBDEBUG = 7 + DBINFO = 6 + DBNOTICE = 5 + DBWARNING = 4 + DBERROR = 3 + DBCRITICAL = 2 + DBALERT = 1 + DBEMERGENCY = 0 + + LOGLEVEL = IMPORTANT|GENERAL + LOGFILE = None + + helptext = """Verbose debug levels. + Accepts any combination (separated by comma) of: + + " all " - ALL available debug output + " most " - Most debug (nodatabase,notimestamp,noextra) + " important " - Errors or other very important messages + " general " - General info + " record " - Recording related messages + " playback " - Playback related messages + " channel " - Channel related messages + " osd " - On-Screen Display related messages + " file " - File and AutoExpire related messages + " schedule " - Scheduling related messages + " network " - Network protocol related messages + " commflag " - Commercial Flagging related messages + " audio " - Audio related messages + " libav " - Enables libav debugging + " jobqueue " - JobQueue related messages + " siparser " - Siparser related messages + " eit " - EIT related messages + " vbi " - VBI related messages + " database " - Display all SQL commands executed + " dsmcc " - DSMCC carousel related messages + " mheg " - MHEG debugging messages + " upnp " - upnp debugging messages + " socket " - socket debugging messages + " xmltv " - xmltv output and related messages + " dvbcam " - DVB CAM debugging messages + " media " - Media Manager debugging messages + " idle " - System idle messages + " channelscan " - Channel Scanning messages + " extra " - More detailed messages in selected levels + " timestamp " - Conditional data driven messages + " none " - NO debug output + + The default for this program appears to be: '-v "important,general" ' + + Most options are additive except for none, all, and important. + These three are semi-exclusive and take precedence over any + prior options given. You can however use something like + '-v none,jobqueue' to get only JobQueue related messages + and override the default verbosity level. + + The additive options may also be subtracted from 'all' by + prefixing them with 'no', so you may use '-v all,nodatabase' + to view all but database debug messages. + + Some debug levels may not apply to this program. +""" + + def __repr__(self): + return "<%s '%s','%s' at %s>" % \ + (str(self.__class__).split("'")[1].split(".")[-1], + self.module, bin(self.LOGLEVEL), hex(id(self))) + + def __init__(self, module='pythonbindings', lstr=None, lbit=None, \ + db=None, logfile=None): + self._setlevel(lstr, lbit) + self.module = module + self.db = db + if self.db: + self.db = MythDBBase(self.db) + if (logfile is not None) and (self.LOGFILE is None): + self.LOGFILE = open(logfile,'w') + + def _setlevel(lstr=None, lbit=None): + if lstr: + MythLog.LOGLEVEL = MythLog._parselevel(lstr) + elif lbit: + MythLog.LOGLEVEL = lbit + _setlevel = staticmethod(_setlevel) + + def _parselevel(lstr): + level = MythLog.NONE + bwlist = ( 'important','general','record','playback','channel','osd', + 'file','schedule','network','commflag','audio','libav', + 'jobqueue','siparser','eit','vbi','database','dsmcc', + 'mheg','upnp','socket','xmltv','dvbcam','media','idle', + 'channelscan','extra','timestamp') + for l in lstr.split(','): + if l in ('all','most','none'): + # set initial bitfield + level = eval('MythLog.'+l.upper()) + elif l in bwlist: + # update bitfield OR + level |= eval('MythLog.'+l.upper()) + elif len(l) > 2: + if l[0:2] == 'no': + if l[2:] in bwlist: + # update bitfield NOT + level &= level^eval('MythLog.'+l[2:].upper()) + return level + _parselevel = staticmethod(_parselevel) + + def log(self, level, message, detail=None, dblevel=None): + """ + MythLog.log(level, message, detail=None, dblevel=None) -> None + + 'level' sets the bitwise log level, to be matched against the log + filter. If any bits match true, the message will be logged. + 'message' and 'detail' set the log message content using the format: + : + ---- or ---- + : -- + """ + if level&self.LOGLEVEL: + if (version_info[0]>2) | (version_info[1]>5): # 2.6 or newer + nowstr = datetime.now().strftime('%Y-%m-%d %H:%M:%S.%f')[:-3] + else: + nowstr = datetime.now().strftime('%Y-%m-%d %H:%M:%S.000') + if detail is not None: + lstr = "%s %s: %s -- %s"%(nowstr, self.module, message, detail) + else: + lstr = "%s %s: %s" % (nowstr, self.module, message) + if self.LOGFILE: + self.LOGFILE.write(lstr+'\n') + self.LOGFILE.flush() + os.fsync(self.LOGFILE.fileno()) + else: + print lstr + return + + if dblevel is not None: + if self.db is None: + self.db = MythDBBase() + c = self.db.cursor(self.log) + c.execute("""INSERT INTO mythlog (module, priority, logdate, + host, message, details) + VALUES (%s, %s, %s, %s, %s, %s)""", + (self.module, dblevel, now(), + self.host, message, detail)) + c.close() + + def __call__(self, level, message, detail=None, dblevel=None): + self.log(level, message, detail, dblevel) + +class MythError( Exception ): + """ + MythError('Generic Error Code') -> Exception + MythError(error_code, additional_arguments) -> Exception + + Objects will have an error string available at obj.args as well as an + error code at obj.ecode. Additional attributes may be available + depending on the error code. + """ + GENERIC = 0 + SYSTEM = 1 + DB_RAW = 50 + DB_CONNECTION = 51 + DB_CREDENTIALS = 52 + DB_SETTING = 53 + DB_SCHEMAMISMATCH = 54 + PROTO_CONNECTION = 100 + PROTO_ANNOUNCE = 101 + PROTO_MISMATCH = 102 + FE_CONNECTION = 150 + FE_ANNOUNCE = 151 + FILE_ERROR = 200 + FILE_FAILED_READ = 201 + FILE_FAILED_WRITE = 202 + def __init__(self, *args): + if args[0] == self.SYSTEM: + self.ename = 'SYSTEM' + self.ecode, self.retcode, self.command, self.stderr = args + self.args = ("External system call failed: code %d" %self.retcode,) + elif args[0] == self.DB_RAW: + self.ename = 'DB_RAW' + self.ecode, sqlerr = args + if len(sqlerr) == 1: + self.sqlcode = 0 + self.sqlerr = sqlerr[0] + self.args = ("MySQL error: %s" % self.sqlerr,) + else: + self.sqlcode, self.sqlerr = sqlerr + self.args = ("MySQL error %d: %s" % sqlerr,) + elif args[0] == self.DB_CONNECTION: + self.ename = 'DB_CONNECTION' + self.ecode, self.dbconn = args + self.args = ("Failed to connect to database at '%s'@'%s'" \ + % (self.dbconn['DBName'], self.dbconn['DBHostName']) \ + +"for user '%s' with password '%s'." \ + % (self.dbconn['DBUserName'], self.dbconn['DBPassword']),) + elif args[0] == self.DB_CREDENTIALS: + self.ename = 'DB_CREDENTIALS' + self.ecode = args + self.args = ("Could not find database login credentials",) + elif args[0] == self.DB_SETTING: + self.ename = 'DB_SETTING' + self.ecode, self.setting, self.hostname = args + self.args = ("Could not find setting '%s' on host '%s'" \ + % (self.setting, self.hostname),) + elif args[0] == self.DB_SCHEMAMISMATCH: + self.ename = 'DB_SCHEMAMISMATCH' + self.ecode, self.setting, self.remote, self.local = args + self.args = ("Mismatched schema version for '%s': " % self.setting \ + + "database speaks version %d, we speak version %d"\ + % (self.remote, self.local),) + elif args[0] == self.PROTO_CONNECTION: + self.ename = 'PROTO_CONNECTION' + self.ecode, self.backend, self.port = args + self.args = ("Failed to connect to backend at %s:%d" % \ + (self.backend, self.port),) + elif args[0] == self.PROTO_ANNOUNCE: + self.ename = 'PROTO_ANNOUNCE' + self.ecode, self.backend, self.port, self.response = args + self.args = ("Unexpected response to ANN on %s:%d - %s" \ + % (self.backend, self.port, self.response),) + elif args[0] == self.PROTO_MISMATCH: + self.ename = 'PROTO_MISMATCH' + self.ecode, self.remote, self.local = args + self.args = ("Backend speaks version %s, we speak version %s" % \ + (self.remote, self.local),) + elif args[0] == self.FE_CONNECTION: + self.ename = 'FE_CONNECTION' + self.ecode, self.frontend, self.port = args + self.args = ('Connection to frontend %s:%d failed' \ + % (self.frontend, self.port),) + elif args[0] == self.FE_ANNOUNCE: + self.ename = 'FE_ANNOUNCE' + self.ecode, self.frontend, self.port = args + self.args = ('Open socket at %s:%d not recognized as mythfrontend'\ + % (self.frontend, self.port),) + elif args[0] == self.FILE_ERROR: + self.ename = 'FILE_ERROR' + self.ecode, self.reason = args + self.args = ("Error accessing file: " % self.reason,) + elif args[0] == self.FILE_FAILED_READ: + self.ename = 'FILE_FAILED_READ' + self.ecode, self.file = args + self.args = ("Error accessing %s" %(self.file,self.mode),) + elif args[0] == self.FILE_FAILED_WRITE: + self.ename = 'FILE_FAILED_WRITE' + self.ecode, self.file, self.reason = args + self.args = ("Error writing to %s, %s" % (self.file, self.reason),) + else: + self.ename = 'GENERIC' + self.ecode = self.GENERIC + self.args = args + self.message = str(self.args[0]) + +class MythDBError( MythError ): pass +class MythBEError( MythError ): pass +class MythFEError( MythError ): pass +class MythFileError( MythError ): pass + +class StorageGroup( DBData ): + """ + StorageGroup(id=None, db=None, raw=None) -> StorageGroup object + Represents a single storage group entry + """ + table = 'storagegroup' + where = 'id=%s' + setwheredat = 'self.id,' + logmodule = 'Python StorageGroup' + + def __str__(self): + return u" Grabber object + + 'path' sets the object to use a path to an external command + 'setting' pulls the external command from a setting in the database + """ + logmodule = 'Python Metadata Grabber' + + def __init__(self, path=None, setting=None, db=None): + MythDBBase.__init__(self, db=db) + self.log = MythLog(self.logmodule, db=self) + self.path = '' + if path is not None: + self.path = path + elif setting is not None: + host = socket.gethostname() + self.path = self.settings[host][setting] + if self.path is None: + raise MythDBError(MythError.DB_SETTING, setting, host) + else: + raise MythError('Invalid input to Grabber()') + self.returncode = 0 + self.stderr = '' + + def __str__(self): + return "<%s '%s' at %s>" % \ + (str(self.__class__).split("'")[1].split(".")[-1], + self.path, hex(id(self))) + + def __repr__(self): + return str(self).encode('utf-8') + + def append(self, *args): + """ + obj.append(*args) -> None + + Permenantly appends one or more strings to the command + path, separated by spaces. + """ + self.path += ' '+' '.join(['%s' % a for a in args]) + + def command(self, *args): + """ + obj.command(*args) -> output string + + Executes external command, adding one or more strings to the + command during the call. If call exits with a code not + equal to 0, a MythError will be raised. The error code and + stderr will be available in the exception and this object + as attributes 'returncode' and 'stderr'. + """ + if self.path is '': + return '' + arg = ' '+' '.join(['%s' % a for a in args]) + fd = Popen('%s %s' % (self.path, arg), stdout=-1, stderr=-1, + shell=True) + self.returncode = fd.wait() + stdout,self.stderr = fd.communicate() + + if self.returncode: + raise MythError(MythError.SYSTEM,self.returncode,arg,self.stderr) + return stdout + + diff --git a/patches/MythData.py.orig b/patches/MythData.py.orig new file mode 100755 index 0000000..d1e4ac5 --- /dev/null +++ b/patches/MythData.py.orig @@ -0,0 +1,1504 @@ +# -*- coding: utf-8 -*- + +""" +Provides data access classes for accessing and managing MythTV data +""" + +from MythStatic import * +from MythBase import * + +import re, sys, socket, os +import xml.etree.cElementTree as etree +from time import mktime, strftime, strptime +from datetime import date, time, datetime +from socket import gethostbyaddr, gethostname + +#### FILE ACCESS #### + +def ftopen(file, type, forceremote=False, nooverwrite=False, db=None): + """ + ftopen(file, type, forceremote=False, nooverwrite=False, db=None) + -> FileTransfer object + -> file object + Method will attempt to open file locally, falling back to remote access + over mythprotocol if necessary. + 'forceremote' will force a FileTransfer object if possible. + 'file' takes a standard MythURI: + myth://@:/ + 'type' takes a 'r' or 'w' + 'nooverwrite' will refuse to open a file writable, if a local file is found. + """ + db = MythDBBase(db) + log = MythLog('Python File Transfer', db=db) + reuri = re.compile(\ + 'myth://((?P.*)@)?(?P[a-zA-Z0-9\.]*)(:[0-9]*)?/(?P.*)') + reip = re.compile('(?:\d{1,3}\.){3}\d{1,3}') + + if type not in ('r','w'): + raise TypeError("File I/O must be of type 'r' or 'w'") + + # process URI (myth://@[:]/) + match = reuri.match(file) + if match is None: + raise MythError('Invalid FileTransfer input string: '+file) + host = match.group('host') + filename = match.group('file') + sgroup = match.group('group') + if sgroup is None: + sgroup = 'Default' + + # get full system name + if reip.match(host): + c = db.cursor(log) + if c.execute("""SELECT hostname FROM settings + WHERE value='BackendServerIP' + AND data=%s""", host) == 0: + c.close() + raise MythDBError(MythError.DB_SETTING, 'BackendServerIP', backend) + host = c.fetchone()[0] + c.close() + + # user forced to remote access + if forceremote: + if (type == 'w') and (filename.find('/') != -1): + raise MythFileError(MythError.FILE_FAILED_WRITE, file, + 'attempting remote write outside base path') + return FileTransfer(host, filename, sgroup, type, db) + + sgs = db.getStorageGroup(groupname=sgroup) + + if type == 'w': + # prefer local storage always + for i in range(len(sgs)-1,-1,-1): + if not sgs[i].local: + sgs.pop(i) + else: + st = os.statvfs(sgs[i].dirname) + sgs[i].free = st[0]*st[3] + if len(sgs) > 1: + # choose path with most free space + sg = sgs.pop() + while len(sgs): + if sgs[0].free > sg.free: sg = sgs.pop() + else: sgs.pop() + # check that folder exists + if filename.find('/') != -1: + path = sg.dirname+filename.rsplit('/',1)[0] + if not os.access(path, os.F_OK): + os.makedirs(path) + if nooverwrite: + if os.access(sg.dirname+filename, os.F_OK): + raise MythDBError(MythError.FILE_FAILED_WRITE, file, + 'refusing to overwrite existing file') + log(log.FILE, 'Opening local file (w)', sg.dirname+filename) + return open(sg.dirname+filename, 'w') + elif len(sgs) == 1: + if filename.find('/') != -1: + path = sgs[0].dirname+filename.rsplit('/',1)[0] + if not os.access(path, os.F_OK): + os.makedirs(path) + if nooverwrite: + if os.access(sgs[0].dirname+filename, os.F_OK): + raise MythFileError(MythError.FILE_FAILED_WRITE, file, + 'refusing to overwrite existing file') + log(log.FILE, 'Opening local file (w)', sgs[0].dirname+filename) + return open(sgs[0].dirname+filename, 'w') + else: + if filename.find('/') != -1: + raise MythFileError(MythError.FILE_FAILED_WRITE, file, + 'attempting remote write outside base path') + return FileTransfer(host, filename, sgroup, 'w', db) + else: + # search for file in local directories + for sg in sgs: + if sg.local: + if os.access(sg.dirname+filename, os.F_OK): + # file found, open local + log(log.FILE, 'Opening local file (r)', + sg.dirname+filename) + return open(sg.dirname+filename, type) + # file not found, open remote + return FileTransfer(host, filename, sgroup, type, db) + +class FileTransfer( MythBEConn ): + """ + A connection to mythbackend intended for file transfers. + Emulates the primary functionality of the local 'file' object. + """ + logmodule = 'Python FileTransfer' + def __repr__(self): + return "" % \ + (self.sgroup, self.host, self.filename, \ + self.type, hex(id(self))) + + def __init__(self, host, filename, sgroup, type, db=None): + if type not in ('r','w'): + raise MythError("FileTransfer type must be read ('r') "+ + "or write ('w')") + + self.host = host + self.filename = filename + self.sgroup = sgroup + self.type = type + + self.tsize = 2**15 + self.tmax = 2**17 + self.count = 0 + self.step = 2**12 + + self.open = False + # create control socket + self.control = MythBEBase(host, 'Playback', db=db) + self.control.log.module = 'Python FileTransfer Control' + # continue normal Backend initialization + MythBEConn.__init__(self, host, type, db=db) + self.open = True + + def announce(self, type): + # replacement announce for Backend object + if type == 'w': + self.w = True + elif type == 'r': + self.w = False + res = self.backendCommand('ANN FileTransfer %s %d %d %s' \ + % (socket.gethostname(), self.w, False, + BACKEND_SEP.join(['-1',self.filename,self.sgroup]))) + if res.split(BACKEND_SEP)[0] != 'OK': + raise MythError(MythError.PROTO_ANNOUNCE, self.host, self.port, res) + else: + sp = res.split(BACKEND_SEP) + self.sockno = int(sp[1]) + self.pos = 0 + self.size = (int(sp[2]) + (int(sp[3])<0))*2**32 + int(sp[3]) + + def __del__(self): + if not self.open: + return + self.control.backendCommand('QUERY_FILETRANSFER '\ + +BACKEND_SEP.join([str(self.sockno), 'DONE'])) + self.socket.shutdown(1) + self.socket.close() + self.open = False + + def tell(self): + """FileTransfer.tell() -> current offset in file""" + return self.pos + + def close(self): + """FileTransfer.close() -> None""" + self.__del__() + + def rewind(self): + """FileTransfer.rewind() -> None""" + self.seek(0) + + def read(self, size): + """ + FileTransfer.read(size) -> string of characters + Requests over 128KB will be buffered internally. + """ + + def recv(self, size): + buff = '' + while len(buff) < size: + buff += self.socket.recv(size-len(buff)) + return buff + + if self.w: + raise MythFileError('attempting to read from a write-only socket') + if size == 0: + return '' + + final = self.pos+size + if final > self.size: + final = self.size + + buff = '' + while self.pos < final: + self.count += 1 + size = final - self.pos + if size > self.tsize: + size = self.tsize + res = self.control.backendCommand('QUERY_FILETRANSFER '+\ + BACKEND_SEP.join([ str(self.sockno), + 'REQUEST_BLOCK', + str(size)])) + #print '%s - %s' % (int(res), size) + if int(res) == size: + if (self.count == 10) and (self.tsize < self.tmax) : + self.count = 0 + self.tsize += self.step + else: + if int(res) == -1: + self.seek(self.pos) + continue + size = int(res) + self.count = 0 + self.tsize -= self.step + if self.tsize < self.step: + self.tsize = self.step + + buff += recv(self, size) + self.pos += size + return buff + + def write(self, data): + """ + FileTransfer.write(data) -> None + Requests over 128KB will be buffered internally + """ + if not self.w: + raise MythFileError('attempting to write to a read-only socket') + while len(data) > 0: + size = len(data) + if size > self.tsize: + buff = data[self.tsize:] + data = data[:self.tsize] + else: + buff = data + data = '' + self.pos += int(self.socket.send(data)) + self.control.backendCommand('QUERY_FILETRANSFER '+BACKEND_SEP\ + .join([str(self.sockno),'WRITE_BLOCK',str(size)])) + return + + + def seek(self, offset, whence=0): + """ + FileTransfer.seek(offset, whence=0) -> None + Seek 'offset' number of bytes + whence == 0 - from start of file + 1 - from current position + 2 - from end of file + """ + if whence == 0: + if offset < 0: + offset = 0 + elif offset > self.size: + offset = self.size + elif whence == 1: + if offset + self.pos < 0: + offset = -self.pos + elif offset + self.pos > self.size: + offset = self.size - self.pos + elif whence == 2: + if offset > 0: + offset = 0 + elif offset < -self.size: + offset = -self.size + whence = 0 + offset = self.size+offset + + curhigh,curlow = self.control.splitInt(self.pos) + offhigh,offlow = self.control.splitInt(offset) + + res = self.control.backendCommand('QUERY_FILETRANSFER '+BACKEND_SEP\ + .join([str(self.sockno),'SEEK',str(offhigh), + str(offlow),str(whence),str(curhigh), + str(curlow)])\ + ).split(BACKEND_SEP) + self.pos = self.control.joinInt(int(res[0]),int(res[1])) + +class FileOps( MythBEBase ): + __doc__ = MythBEBase.__doc__+""" + getRecording() - return a Program object for a recording + deleteRecording() - notify the backend to delete a recording + forgetRecording() - allow a recording to re-record + deleteFile() - notify the backend to delete a file + in a storage group + getHash() - return the hash of a file in a storage group + reschedule() - trigger a run of the scheduler + """ + logmodule = 'Python Backend FileOps' + + def getRecording(self, chanid, starttime): + """FileOps.getRecording(chanid, starttime) -> Program object""" + res = self.backendCommand('QUERY_RECORDING TIMESLOT %d %d' \ + % (chanid, starttime)).split(BACKEND_SEP) + if res[0] == 'ERROR': + return None + else: + return Program(res[1:], db=self.db) + + def deleteRecording(self, program, force=False): + """ + FileOps.deleteRecording(program, force=False) -> retcode + 'force' will force a delete even if the file cannot be found + retcode will be -1 on success, -2 on failure + """ + command = 'DELETE_RECORDING' + if force: + command = 'FORCE_DELETE_RECORDING' + return self.backendCommand(BACKEND_SEP.join(\ + [command,program.toString()])) + + def forgetRecording(self, program): + """FileOps.forgetRecording(program) -> None""" + self.backendCommand(BACKEND_SEP.join(['FORGET_RECORDING', + program.toString()])) + + def deleteFile(self, file, sgroup): + """FileOps.deleteFile(file, storagegroup) -> retcode""" + return self.backendCommand(BACKEND_SEP.join(\ + ['DELETE_FILE',file,sgroup])) + + def getHash(self, file, sgroup): + """FileOps.getHash(file, storagegroup) -> hash string""" + return self.backendCommand(BACKEND_SEP.join((\ + 'QUERY_FILE_HASH',file, sgroup))) + + def reschedule(self, recordid=-1): + """FileOps.reschedule() -> None""" + self.backendCommand('RESCHEDULE_RECORDINGS '+str(recordid)) + + +class FreeSpace( DictData ): + """Represents a FreeSpace entry.""" + field_order = [ 'host', 'path', 'islocal', + 'disknumber', 'sgroupid', 'blocksize', + 'ts_high', 'ts_low', 'us_high', + 'us_low'] + field_type = [3, 3, 2, 0, 0, 0, 0, 0, 0, 0] + def __str__(self): + return ""\ + % (self.path, self.host, hex(id(self))) + + def __repr__(self): + return str(self).encode('utf-8') + + def __init__(self, raw): + DictData.__init__(self, raw) + self.totalspace = self.joinInt(self.ts_high, self.ts_low) + self.usedspace = self.joinInt(self.us_high, self.us_low) + self.freespace = self.totalspace - self.usedspace + + +#### RECORDING ACCESS #### + +class Program( DictData ): + """Represents a program with all detail returned by the backend.""" + + # recstatus + TUNERBUSY = -8 + LOWDISKSPACE = -7 + CANCELLED = -6 + DELETED = -5 + ABORTED = -4 + RECORDED = -3 + RECORDING = -2 + WILLRECORD = -1 + UNKNOWN = 0 + DONTRECORD = 1 + PREVIOUSRECORDING = 2 + CURRENTRECORDING = 3 + EARLIERSHOWING = 4 + TOOMANYRECORDINGS = 5 + NOTLISTED = 6 + CONFLICT = 7 + LATERSHOWING = 8 + REPEAT = 9 + INACTIVE = 10 + NEVERRECORD = 11 + + field_order = [ 'title', 'subtitle', 'description', + 'category', 'chanid', 'channum', + 'callsign', 'channame', 'filename', + 'fs_high', 'fs_low', 'starttime', + 'endtime', 'duplicate', 'shareable', + 'findid', 'hostname', 'sourceid', + 'cardid', 'inputid', 'recpriority', + 'recstatus', 'recordid', 'rectype', + 'dupin', 'dupmethod', 'recstartts', + 'recendts', 'repeat', 'programflags', + 'recgroup', 'commfree', 'outputfilters', + 'seriesid', 'programid', 'lastmodified', + 'stars', 'airdate', 'hasairdate', + 'playgroup', 'recpriority2', 'parentid', + 'storagegroup', 'audio_props', 'video_props', + 'subtitle_type','year'] + field_type = [ 3, 3, 3, + 3, 0, 3, + 3, 3, 3, + 0, 0, 4, + 4, 0, 0, + 0, 3, 0, + 0, 0, 0, + 0, 0, 3, + 0, 0, 4, + 4, 0, 3, + 3, 0, 3, + 3, 3, 3, + 1, 3, 0, + 3, 0, 3, + 3, 0, 0, + 0, 0] + def __str__(self): + return u"" % (self.title, + self.starttime.strftime('%Y-%m-%d %H:%M:%S'), hex(id(self))) + + def __repr__(self): + return str(self).encode('utf-8') + + def __init__(self, raw=None, db=None, etree=None): + if raw: + DictData.__init__(self, raw) + elif etree: + xmldat = etree.attrib + xmldat.update(etree.find('Channel').attrib) + if etree.find('Recording'): + xmldat.update(etree.find('Recording').attrib) + + dat = {} + if etree.text: + dat['description'] = etree.text.strip() + for key in ('title','subTitle','seriesId','programId','airdate', + 'category','hostname','chanNum','callSign','playGroup', + 'recGroup','rectype','programFlags','chanId','recStatus', + 'commFree','stars'): + if key in xmldat: + dat[key.lower()] = xmldat[key] + for key in ('startTime','endTime','lastModified', + 'recStartTs','recEndTs'): + if key in xmldat: + dat[key.lower()] = str(int(mktime(strptime( + xmldat[key], '%Y-%m-%dT%H:%M:%S')))) + if 'fileSize' in xmldat: + dat['fs_high'],dat['fs_low'] = \ + self.splitInt(int(xmldat['fileSize'])) + + raw = [] + defs = (0,0,0,'',0) + for i in range(len(self.field_order)): + if self.field_order[i] in dat: + raw.append(dat[self.field_order[i]]) + else: + raw.append(defs[self.field_type[i]]) + DictData.__init__(self, raw) + else: + raise InputError("Either 'raw' or 'etree' must be provided") + self.db = MythDBBase(db) + self.filesize = self.joinInt(self.fs_high,self.fs_low) + + def toString(self): + """ + Program.toString() -> string representation + for use with backend protocol commands + """ + data = [] + for i in range(0,PROGRAM_FIELDS): + if self.data[self.field_order[i]] == None: + datum = '' + elif self.field_type[i] == 0: + datum = str(self.data[self.field_order[i]]) + elif self.field_type[i] == 1: + datum = locale.format("%0.6f", self.data[self.field_order[i]]) + elif self.field_type[i] == 2: + datum = str(int(self.data[self.field_order[i]])) + elif self.field_type[i] == 3: + datum = self.data[self.field_order[i]] + elif self.field_type[i] == 4: + datum = str(int(mktime(self.data[self.field_order[i]].\ + timetuple()))) + data.append(datum) + return BACKEND_SEP.join(data) + + def delete(self, force=False, rerecord=False): + """ + Program.delete(force=False, rerecord=False) -> retcode + Informs backend to delete recording and all relevent data. + 'force' forces a delete if the file cannot be found. + 'rerecord' sets the file as recordable in oldrecorded + """ + be = FileOps(db=self.db) + res = int(be.deleteRecording(self, force=force)) + if res < -1: + raise MythBEError('Failed to delete file') + if rerecord: + be.forgetRecording(self) + return res + + def getRecorded(self): + """Program.getRecorded() -> Recorded object""" + return Recorded((self.chanid,self.recstartts), db=self.db) + + def open(self, type='r'): + """Program.open(type='r') -> file or FileTransfer object""" + if type != 'r': + raise MythFileError(MythError.FILE_FAILED_WRITE, self.filename, + 'Program () objects cannot be opened for writing') + if not self.filename.startswith('myth://'): + self.filename = 'myth://%s/%s' % (self.hostname, self.filename) + return ftopen(self.filename, 'r') + +class Record( DBDataWrite ): + """ + Record(id=None, db=None, raw=None) -> Record object + """ + + kNotRecording = 0 + kSingleRecord = 1 + kTimeslotRecord = 2 + kChannelRecord = 3 + kAllRecord = 4 + kWeekslotRecord = 5 + kFindOneRecord = 6 + kOverrideRecord = 7 + kDontRecord = 8 + kFindDailyRecord = 9 + kFindWeeklyRecord = 10 + + table = 'record' + where = 'recordid=%s' + setwheredat = 'self.recordid,' + defaults = {'recordid':None, 'type':kAllRecord, 'title':u'Unknown', + 'subtitle':'', 'description':'', 'category':'', + 'station':'', 'seriesid':'', 'search':0, + 'last_record':datetime(1900,1,1), + 'next_record':datetime(1900,1,1), + 'last_delete':datetime(1900,1,1)} + logmodule = 'Python Record' + + def __str__(self): + if self.wheredat is None: + return u"" % hex(id(self)) + return u"" \ + % (self.title, self.type, hex(id(self))) + + def __repr__(self): + return str(self).encode('utf-8') + + def __init__(self, id=None, db=None, raw=None): + DBDataWrite.__init__(self, (id,), db, raw) + + def create(self, data=None): + """Record.create(data=None) -> Record object""" + self.wheredat = (DBDataWrite.create(self, data),) + self._pull() + FileOps(db=self.db).reschedule(self.recordid) + return self + + def update(self, *args, **keywords): + DBDataWrite.update(*args, **keywords) + FileOps(db=self.db).reschedule(self.recordid) + +class Recorded( DBDataWrite ): + """ + Recorded(data=None, db=None, raw=None) -> Recorded object + 'data' is a tuple containing (chanid, storagegroup) + """ + table = 'recorded' + where = 'chanid=%s AND starttime=%s' + setwheredat = 'self.chanid,self.starttime' + defaults = {'title':u'Unknown', 'subtitle':'', 'description':'', + 'category':'', 'hostname':'', 'bookmark':0, + 'editing':0, 'cutlist':0, 'autoexpire':0, + 'commflagged':0, 'recgroup':'Default', 'seriesid':'', + 'programid':'', 'lastmodified':'CURRENT_TIMESTAMP', + 'filesize':0, 'stars':0, 'previouslyshown':0, + 'preserve':0, 'bookmarkupdate':0, + 'findid':0, 'deletepending':0, 'transcoder':0, + 'timestretch':1, 'recpriority':0, 'playgroup':'Default', + 'profile':'No', 'duplicate':1, 'transcoded':0, + 'watched':0, 'storagegroup':'Default'} + logmodule = 'Python Recorded' + + class _Cast( DBDataCRef ): + table = 'people' + rtable = 'recordedcredits' + t_ref = 'person' + t_add = ['name'] + r_ref = 'person' + r_add = ['role'] + w_field = ['chanid','starttime'] + def __repr__(self): + if self.data is None: + self._fill() + if len(self.data) == 0: + return 'No cast' + cast = [] + for member in self.data: + cast.append(member.name) + return ', '.join(cast).encode('utf-8') + + class _Seek( DBDataRef ): + table = 'recordedseek' + wfield = ['chanid','starttime'] + + class _Markup( DBDataRef ): + table = 'recordedmarkup' + wfield = ['chanid','starttime'] + + def __str__(self): + if self.wheredat is None: + return u"" % hex(id(self)) + return u"" % (self.title, + self.starttime.strftime('%Y-%m-%d %H:%M:%S'), hex(id(self))) + + def __repr__(self): + return str(self).encode('utf-8') + + def __init__(self, data=None, db=None, raw=None): + DBDataWrite.__init__(self, data, db, raw) + if (data is not None) or (raw is not None): + self.cast = self._Cast(self.wheredat, self.db) + self.seek = self._Seek(self.wheredat, self.db) + self.markup = self._Markup(self.wheredat, self.db) + + def create(self, data=None): + """Recorded.create(data=None) -> Recorded object""" + DBDataWrite.create(self, data) + self.wheredat = (self.chanid,self.starttime) + self._pull() + self.cast = self._Cast(self.wheredat, self.db) + self.seek = self._Seek(self.wheredat, self.db) + self.markup = self._Markup(self.wheredat, self.db) + return self + + def delete(self, force=False, rerecord=False): + """ + Recorded.delete(force=False, rerecord=False) -> retcode + Informs backend to delete recording and all relevent data. + 'force' forces a delete if the file cannot be found. + 'rerecord' sets the file as recordable in oldrecorded + """ + return self.getProgram().delete(force, rerecord) + + def open(self, type='r'): + """Recorded.open(type='r') -> file or FileTransfer object""" + return ftopen("myth://%s@%s/%s" % ( self.storagegroup, \ + self.hostname,\ + self.basename), type, db=self.db) + + def getProgram(self): + """Recorded.getProgram() -> Program object""" + be = FileOps(db=self.db) + return be.getRecording(self.chanid, + int(self.starttime.strftime('%Y%m%d%H%M%S'))) + + def getRecordedProgram(self): + """Recorded.getRecordedProgram() -> RecordedProgram object""" + return RecordedProgram((self.chanid,self.progstart), db=self.db) + + def formatPath(self, path, replace=None): + """ + Recorded.formatPath(path, replace=None) -> formatted path string + 'path' string is formatted as per mythrename.pl + """ + for (tag, data) in (('T','title'), ('S','subtitle'), + ('R','description'), ('C','category'), + ('U','recgroup'), ('hn','hostname'), + ('c','chanid') ): + tmp = unicode(self[data]).replace('/','-') + path = path.replace('%'+tag, tmp) + for (data, pre) in ( ('starttime','%'), ('endtime','%e'), + ('progstart','%p'),('progend','%pe') ): + for (tag, format) in (('y','%y'),('Y','%Y'),('n','%m'),('m','%m'), + ('j','%d'),('d','%d'),('g','%I'),('G','%H'), + ('h','%I'),('H','%H'),('i','%M'),('s','%S'), + ('a','%p'),('A','%p') ): + path = path.replace(pre+tag, self[data].strftime(format)) + for (tag, format) in (('y','%y'),('Y','%Y'),('n','%m'),('m','%m'), + ('j','%d'),('d','%d')): + path = path.replace('%o'+tag, + self['originalairdate'].strftime(format)) + path = path.replace('%-','-') + path = path.replace('%%','%') + path += '.'+self['basename'].split('.')[-1] + + # clean up for windows + if replace is not None: + for char in ('\\',':','*','?','"','<','>','|'): + path = path.replace(char, replace) + return path + +class RecordedProgram( DBDataWrite ): + """ + RecordedProgram(data=None, db=None, raw=None) -> RecordedProgram object + 'data' is a tuple containing (chanid, storagegroup) + """ + table = 'recordedprogram' + where = 'chanid=%s AND starttime=%s' + setwheredat = 'self.chanid,self.starttime' + defaults = {'title':'', 'subtitle':'', + 'category':'', 'category_type':'', 'airdate':0, + 'stars':0, 'previouslyshown':0, 'title_pronounce':'', + 'stereo':0, 'subtitled':0, 'hdtv':0, + 'partnumber':0, 'closecaptioned':0, 'parttotal':0, + 'seriesid':'', 'originalairdate':'', 'showtype':u'', + 'colorcode':'', 'syndicatedepisodenumber':'', + 'programid':'', 'manualid':0, 'generic':0, + 'first':0, 'listingsource':0, 'last':0, + 'audioprop':u'','videoprop':u'', + 'subtitletypes':u''} + logmodule = 'Python RecordedProgram' + + def __str__(self): + if self.wheredat is None: + return u"" % hex(id(self)) + return u"" % (self.title, + self.starttime.strftime('%Y-%m-%d %H:%M:%S'), hex(id(self))) + + def __repr__(self): + return str(self).encode('utf-8') + + def create(self, data=None): + """RecordedProgram.create(data=None) -> RecordedProgram object""" + DBDataWrite.create(self, data) + self.wheredat = (self.chanid, self.starttime) + self._pull() + return self + +class OldRecorded( DBDataWrite ): + """ + OldRecorded(data=None, db=None, raw=None) -> OldRecorded object + 'data' is a tuple containing (chanid, storagegroup) + """ + table = 'oldrecorded' + where = 'chanid=%s AND starttime=%s' + setwheredat = 'self.chanid,self.starttime' + defaults = {'title':'', 'subtitle':'', + 'category':'', 'seriesid':'', 'programid':'', + 'findid':0, 'recordid':0, 'station':'', + 'rectype':0, 'duplicate':0, 'recstatus':-3, + 'reactivate':0, 'generic':0} + logmodule = 'Python OldRecorded' + + def __str__(self): + if self.wheredat is None: + return u"" % hex(id(self)) + return u"" % (self.title, + self.starttime.strftime('%Y-%m-%d %H:%M:%S'), hex(id(self))) + + def __repr__(self): + return str(self).encode('utf-8') + + def create(self, data=None): + """OldRecorded.create(data=None) -> OldRecorded object""" + DBDataWrite.create(self, data) + self.wheredat = (self.chanid, self.starttime) + self._pull() + return self + + def setDuplicate(self, record=False): + """ + OldRecorded.setDuplicate(record=False) -> None + Toggles re-recordability + """ + c = self.db.cursor(self.log) + c.execute("""UPDATE oldrecorded SET duplicate=%%s + WHERE %s""" % self.where, \ + tuple([record]+list(self.wheredat))) + FileOps(db=self.db).reschedule(0) + + def update(self, *args, **keywords): + """OldRecorded entries can not be altered""" + return + def delete(self): + """OldRecorded entries cannot be deleted""" + return + +class Job( DBDataWrite ): + """ + Job(id=None, chanid=None, starttime=None, db=None, raw=None) -> Job object + Can be initialized with a Job id, or chanid and starttime. + """ + # types + NONE = 0x0000 + SYSTEMJOB = 0x00ff + TRANSCODE = 0x0001 + COMMFLAG = 0x0002 + USERJOB = 0xff00 + USERJOB1 = 0x0100 + USERJOB2 = 0x0200 + USERJOB3 = 0x0300 + USERJOB4 = 0x0400 + # cmds + RUN = 0x0000 + PAUSE = 0x0001 + RESUME = 0x0002 + STOP = 0x0004 + RESTART = 0x0008 + # flags + NO_FLAGS = 0x0000 + USE_CUTLIST = 0x0001 + LIVE_REC = 0x0002 + EXTERNAL = 0x0004 + # statuses + UNKNOWN = 0x0000 + QUEUED = 0x0001 + PENDING = 0x0002 + STARTING = 0x0003 + RUNNING = 0x0004 + STOPPING = 0x0005 + PAUSED = 0x0006 + RETRY = 0x0007 + ERRORING = 0x0008 + ABORTING = 0x0008 + DONE = 0x0100 + FINISHED = 0x0110 + ABORTED = 0x0120 + ERRORED = 0x0130 + CANCELLED = 0x0140 + + table = 'jobqueue' + logmodule = 'Python Jobqueue' + defaults = {'id': None} + + def __str__(self): + if self.wheredat is None: + return u"" % hex(id(self)) + return u"" % (self.id, hex(id(self))) + + def __repr__(self): + return str(self).encode('utf-8') + + def __init__(self, id=None, chanid=None, starttime=None, \ + db=None, raw=None): + self.__dict__['where'] = 'id=%s' + self.__dict__['setwheredat'] = 'self.id,' + + if raw is not None: + DBDataWrite.__init__(self, None, db, raw) + elif id is not None: + DBDataWrite.__init__(self, (id,), db, None) + elif (chanid is not None) and (starttime is not None): + self.__dict__['where'] = 'chanid=%s AND starttime=%s' + DBDataWrite.__init__(self, (chanid,starttime), db, None) + else: + DBDataWrite.__init__(self, None, db, None) + + def create(self, data=None): + """Job.create(data=None) -> Job object""" + id = DBDataWrite.create(self, data) + self.where = 'id=%s' + self.wheredat = (id,) + return self + + def setComment(self,comment): + """Job.setComment(comment) -> None, updates comment""" + self.comment = comment + self.update() + + def setStatus(self,status): + """Job.setStatus(Status) -> None, updates status""" + self.status = status + self.update() + +class Channel( DBDataWrite ): + """Channel(chanid=None, data=None, raw=None) -> Channel object""" + table = 'channel' + where = 'chanid=%s' + setwheredat = 'self.chanid,' + defaults = {'icon':'none', 'videofilters':'', 'callsign':u'', + 'xmltvid':'', 'recpriority':0, 'contrast':32768, + 'brightness':32768, 'colour':32768, 'hue':32768, + 'tvformat':u'Default', 'visible':1, 'outputfilters':'', + 'useonairguide':0, 'atsc_major_chan':0, + 'tmoffset':0, 'default_authority':'', + 'commmethod':-1, 'atsc_minor_chan':0, + 'last_record':datetime(1900,1,1)} + logmodule = 'Python Channel' + + def __str__(self): + if self.wheredat is None: + return u"" % hex(id(self)) + return u"" % \ + (self.chanid, self.name, hex(id(self))) + + def __repr__(self): + return str(self).encode('utf-8') + + def __init__(self, chanid=None, db=None, raw=None): + DBDataWrite.__init__(self, (chanid,), db, raw) + + def create(self, data=None): + """Channel.create(data=None) -> Channel object""" + DBDataWrite.create(self, data) + self.wheredat = (self.chanid,) + self._pull() + return self + +class Guide( DBData ): + """ + Guide(data=None, db=None, raw=None) -> Guide object + Data is a tuple of (chanid, starttime). + """ + table = 'program' + where = 'chanid=%s AND starttime=%s' + setwheredat = 'self.chanid,self.starttime' + logmodule = 'Python Guide' + + def __str__(self): + if self.wheredat is None: + return u"" % hex(id(self)) + return u"" % (self.title, + self.starttime.strftime('%Y-%m-%d %H:%M:%S'), hex(id(self))) + + def __repr__(self): + return str(self).encode('utf-8') + + def __init__(self, data=None, db=None, raw=None, etree=None): + if etree: + db = MythDBBase(db) + dat = {'chanid':etree[0]} + attrib = etree[1].attrib + for key in ('title','subTitle','category','seriesId', + 'hostname','programId','airdate'): + if key in attrib: + dat[key.lower()] = attrib[key] + if 'stars' in attrib: + dat['stars'] = locale.atof(attrib['stars']) + if etree[1].text: + dat['description'] = etree[1].text.strip() + for key in ('startTime','endTime','lastModified'): + if key in attrib: + dat[key.lower()] = datetime.strptime( + attrib[key],'%Y-%m-%dT%H:%M:%S') + + raw = [] + for key in db.tablefields.program: + if key in dat: + raw.append(dat[key]) + else: + raw.append(None) + DBData.__init__(self, db=db, raw=raw) + else: + DBData.__init__(self, data=data, db=db, raw=raw) + + def record(self, type=Record.kAllRecord): + rec = Record(db=self.db) + for key in ('chanid','title','subtitle','description', 'category', + 'seriesid','programid'): + rec[key] = self[key] + + rec.startdate = self.starttime.date() + rec.starttime = self.starttime-datetime.combine(rec.startdate, time()) + rec.enddate = self.endtime.date() + rec.endtime = self.endtime-datetime.combine(rec.enddate, time()) + + rec.station = Channel(self.chanid, db=self.db).callsign + rec.type = type + return rec.create() + + +#### MYTHVIDEO #### + +class Video( DBDataWrite ): + """Video(id=None, db=None, raw=None) -> Video object""" + table = 'videometadata' + where = 'intid=%s' + setwheredat = 'self.intid,' + defaults = {'subtitle':u'', 'director':u'Unknown', + 'rating':u'NR', 'inetref':u'00000000', + 'year':1895, 'userrating':0.0, + 'length':0, 'showlevel':1, + 'coverfile':u'No Cover', 'host':u'', + 'intid':None, 'homepage':u'', + 'watched':False, 'category':'none', + 'browse':True, 'hash':u'', + 'season':0, 'episode':0, + 'releasedate':date(1,1,1), 'childid':-1, + 'insertdate': datetime.now()} + logmodule = 'Python Video' + schema_value = 'mythvideo.DBSchemaVer' + schema_local = MVSCHEMA_VERSION + schema_name = 'MythVideo' + category_map = [{'None':0},{0:'None'}] + + def _fill_cm(self, name=None, id=None): + if name: + if name not in self.category_map[0]: + c = self.db.cursor(self.log) + q1 = """SELECT intid FROM videocategory WHERE category=%s""" + q2 = """INSERT INTO videocategory SET category=%s""" + if c.execute(q1, name) == 0: + c.execute(q2, name) + c.execute(q1, name) + id = c.fetchone()[0] + self.category_map[0][name] = id + self.category_map[1][id] = name + + elif id: + if id not in self.category_map[1]: + c = self.db.cursor(self.log) + if c.execute("""SELECT category FROM videocategory + WHERE intid=%s""", id) == 0: + raise MythDBError('Invalid ID found in videometadata.category') + else: + name = c.fetchone()[0] + self.category_map[0][name] = id + self.category_map[1][id] = name + + def _pull(self): + DBDataWrite._pull(self) + self._fill_cm(id=self.category) + self.category = self.category_map[1][self.category] + + def _push(self): + name = self.category + self._fill_cm(name=name) + self.category = self.category_map[0][name] + DBDataWrite._push(self) + self.category = name + + def __repr__(self): + return str(self).encode('utf-8') + + def __str__(self): + if self.wheredat is None: + return u"" % hex(id(self)) + res = self.title + if self.season and self.episode: + res += u' - %dx%02d' % (self.season, self.episode) + if self.subtitle: + res += u' - '+self.subtitle + return u"