From 056b266613a858d41c18e4a3f87eb4b78727c768 Mon Sep 17 00:00:00 2001 From: echel0n Date: Mon, 10 Mar 2014 16:58:37 -0700 Subject: [PATCH] Auto-detection of indexer code added in for adding existing shows along with TV Cache lookups, few bugfixes as well. --- gui/slick/js/newShow.js | 6 +- lib/dateutil/zoneinfo/.gitignore | 1 + sickbeard/common.py | 4 +- sickbeard/helpers.py | 89 ++++++++++++++++++++++-------- sickbeard/indexers/indexer_api.py | 16 +++--- sickbeard/metadata/generic.py | 9 +-- sickbeard/metadata/mediabrowser.py | 15 ++--- sickbeard/metadata/tivo.py | 18 +++--- sickbeard/metadata/wdtv.py | 34 ++++++------ sickbeard/metadata/xbmc_12plus.py | 10 ++-- sickbeard/postProcessor.py | 3 +- sickbeard/tv.py | 2 +- sickbeard/webserve.py | 10 ++-- 13 files changed, 127 insertions(+), 90 deletions(-) create mode 100644 lib/dateutil/zoneinfo/.gitignore diff --git a/gui/slick/js/newShow.js b/gui/slick/js/newShow.js index 48fcbb23..7de2fa77 100644 --- a/gui/slick/js/newShow.js +++ b/gui/slick/js/newShow.js @@ -24,12 +24,12 @@ $(document).ready(function () { } $('#tvdbLangSelect').html(resultStr); - $('#tvdbLangSelect').change(function () { searchTvdb(); }); + $('#tvdbLangSelect').change(function () { searchIndexers(); }); }); } } - function searchTvdb() { + function searchIndexers() { if (!$('#nameToSearch').val().length) { return; } @@ -91,7 +91,7 @@ $(document).ready(function () { }); } - $('#searchName').click(function () { searchTvdb(); }); + $('#searchName').click(function () { searchIndexers(); }); if ($('#nameToSearch').length && $('#nameToSearch').val().length) { $('#searchName').click(); diff --git a/lib/dateutil/zoneinfo/.gitignore b/lib/dateutil/zoneinfo/.gitignore new file mode 100644 index 00000000..335ec957 --- /dev/null +++ b/lib/dateutil/zoneinfo/.gitignore @@ -0,0 +1 @@ +*.tar.gz diff --git a/sickbeard/common.py b/sickbeard/common.py index daa682b1..e447e137 100644 --- a/sickbeard/common.py +++ b/sickbeard/common.py @@ -80,8 +80,8 @@ multiEpStrings[NAMING_LIMITED_EXTEND] = "Extend (Limited)" multiEpStrings[NAMING_LIMITED_EXTEND_E_PREFIXED] = "Extend (Limited, E-prefixed)" ### Notification Types -INDEXER_TVDB = 'Tvdb' -INDEXER_TVRAGE = 'TVRage' +INDEXER_TVDB = "Tvdb" +INDEXER_TVRAGE = "TVRage" indexerStrings = {} indexerStrings[INDEXER_TVDB] = "TheTVDB" diff --git a/sickbeard/helpers.py b/sickbeard/helpers.py index eea0c7f9..a249027a 100644 --- a/sickbeard/helpers.py +++ b/sickbeard/helpers.py @@ -58,7 +58,7 @@ import sickbeard from sickbeard.exceptions import MultipleShowObjectsException, ex from sickbeard import logger, classes -from sickbeard.common import USER_AGENT, mediaExtensions, subtitleExtensions, XML_NSMAP +from sickbeard.common import USER_AGENT, mediaExtensions, subtitleExtensions, XML_NSMAP, indexerStrings from sickbeard import db from sickbeard import encodingKludge as ek @@ -303,7 +303,7 @@ def makeDir(path): return True -def searchDBForShow(regShowName): +def searchDBForShow(regShowName, useIndexer=False): showNames = [re.sub('[. -]', ' ', regShowName),regShowName] @@ -313,7 +313,7 @@ def searchDBForShow(regShowName): for showName in showNames: - show = get_show_by_name(showName,sickbeard.showList) + show = get_show_by_name(showName,sickbeard.showList, useIndexer==useIndexer) if show: sqlResults = myDB.select("SELECT * FROM tv_shows WHERE show_name LIKE ? OR show_name LIKE ?", [show.name, show.name]) else: @@ -339,6 +339,42 @@ def searchDBForShow(regShowName): else: return (sqlResults[0]["indexer"], int(sqlResults[0]["indexer_id"]), sqlResults[0]["show_name"]) + return None + +def searchIndexersForShow(regShowName): + + showNames = [re.sub('[. -]', ' ', regShowName),regShowName] + + yearRegex = "([^()]+?)\s*(\()?(\d{4})(?(2)\))$" + + for name in showNames: + for indexer in indexerStrings: + logger.log(u"Trying to find the " + name + " on " + indexer, logger.DEBUG) + + # try each indexer till we find a match + sickbeard.INDEXER_API_PARMS['indexer'] = indexer + + try: + t = indexer_api.indexerApi(custom_ui=classes.ShowListUI, **sickbeard.INDEXER_API_PARMS) + showObj = t[name] + return indexer + except (indexer_exceptions.indexer_exception, IOError): + # if none found, search on all languages + try: + # There's gotta be a better way of doing this but we don't wanna + # change the language value elsewhere + lINDEXER_API_PARMS = sickbeard.INDEXER_API_PARMS.copy() + + lINDEXER_API_PARMS['search_all_languages'] = True + t = indexer_api.indexerApi(custom_ui=classes.ShowListUI, **lINDEXER_API_PARMS) + showObj = t[name] + return indexer + except (indexer_exceptions.indexer_exception, IOError): + pass + + continue + except (IOError): + continue return None @@ -908,7 +944,7 @@ def _check_against_names(name, show): return False -def get_show_by_name(name, showList, useTvdb=False): +def get_show_by_name(name, showList, useIndexer=False): logger.log(u"Trying to get the indexerid for "+name, logger.DEBUG) if showList: @@ -917,30 +953,35 @@ def get_show_by_name(name, showList, useTvdb=False): logger.log(u"Matched "+name+" in the showlist to the show "+show.name, logger.DEBUG) return show - if useTvdb: - try: - t = indexer_api.indexerApi(custom_ui=classes.ShowListUI, **sickbeard.INDEXER_API_PARMS) - showObj = t[name] - except (indexer_exceptions): - # if none found, search on all languages - try: - # There's gotta be a better way of doing this but we don't wanna - # change the language value elsewhere - lINDEXER_API_PARMS = sickbeard.INDEXER_API_PARMS.copy() + if useIndexer: + showResult = None + for indexer in indexerStrings: + # try each indexer till we find a match + sickbeard.INDEXER_API_PARMS['indexer'] = indexer - lINDEXER_API_PARMS['search_all_languages'] = True - t = indexer_api.indexerApi(custom_ui=classes.ShowListUI, **lINDEXER_API_PARMS) + try: + t = indexer_api.indexerApi(custom_ui=classes.ShowListUI, **sickbeard.INDEXER_API_PARMS) showObj = t[name] except (indexer_exceptions.indexer_exception, IOError): - pass + # if none found, search on all languages + try: + # There's gotta be a better way of doing this but we don't wanna + # change the language value elsewhere + lINDEXER_API_PARMS = sickbeard.INDEXER_API_PARMS.copy() - return None - except (IOError): - return None - else: - show = findCertainShow(sickbeard.showList, int(showObj["id"])) - if show: - return show + lINDEXER_API_PARMS['search_all_languages'] = True + t = indexer_api.indexerApi(custom_ui=classes.ShowListUI, **lINDEXER_API_PARMS) + showObj = t[name] + except (indexer_exceptions.indexer_exception, IOError): + pass + + continue + except (IOError): + continue + + showResult = findCertainShow(sickbeard.showList, int(showObj["id"])) + if showResult is not None: + return showResult return None diff --git a/sickbeard/indexers/indexer_api.py b/sickbeard/indexers/indexer_api.py index 5504d5a3..b2cf341d 100644 --- a/sickbeard/indexers/indexer_api.py +++ b/sickbeard/indexers/indexer_api.py @@ -21,17 +21,11 @@ from lib.tvrage_api.tvrage_api import TVRage class indexerApi: def __new__(self, indexer): - cls = type(eval(indexer)) + cls = type(indexer) new_type = type(cls.__name__ + '_wrapped', (indexerApi, cls), {}) return object.__new__(new_type) - def __init__(self, indexer=None, language=None, *args, **kwargs): - if indexer is not None: - self.name = indexer - self._wrapped = eval(indexer)(*args, **kwargs) - else: - self.name = "Indexer" - + def __init__(self, indexer=None, language=None, custom_ui=None, *args, **kwargs): self.config = {} self.config['valid_languages'] = [ @@ -54,6 +48,12 @@ class indexerApi: else: self.config['language'] = language + if indexer is not None: + self.name = indexer + self._wrapped = eval(indexer)(custom_ui=custom_ui, language=language, *args, **kwargs) + else: + self.name = "Indexer" + def __getattr__(self, attr): return getattr(self._wrapped, attr) diff --git a/sickbeard/metadata/generic.py b/sickbeard/metadata/generic.py index 642fd5ac..886d02f2 100644 --- a/sickbeard/metadata/generic.py +++ b/sickbeard/metadata/generic.py @@ -359,7 +359,7 @@ class GenericMetadata(): try: myEp = indexer_show_obj[cur_ep.season][cur_ep.episode] except (indexer_exceptions.indexer_episodenotfound, indexer_exceptions.indexer_seasonnotfound): - logger.log(u"Unable to find episode " + str(cur_ep.season) + "x" + str(cur_ep.episode) + " on tvdb... has it been removed? Should I delete from db?") + logger.log(u"Unable to find episode " + str(cur_ep.season) + "x" + str(cur_ep.episode) + " on " + ep_obj.show.indexer + ".. has it been removed? Should I delete from db?") continue thumb_url = getattr(myEp, 'filename', None) @@ -873,8 +873,8 @@ class GenericMetadata(): + str(showXML.findtext('id'))) return empty_return - indexer = None name = showXML.findtext('title') + indexer = showXML.findtext('indexer') if showXML.findtext('tvdbid') != None: indexer_id = int(showXML.findtext('tvdbid')) elif showXML.findtext('id') != None: @@ -883,11 +883,6 @@ class GenericMetadata(): logger.log(u"Empty or field in NFO, unable to find a ID", logger.WARNING) return empty_return - if indexer is None: - indexer = showXML.findtext('indexer') - if indexer is None: - indexer = 'Tvdb' - if indexer_id is None: logger.log(u"Invalid indexer ID (" + str(indexer_id) + "), not using metadata file", logger.WARNING) return empty_return diff --git a/sickbeard/metadata/mediabrowser.py b/sickbeard/metadata/mediabrowser.py index 449e6bcd..4c0c4895 100644 --- a/sickbeard/metadata/mediabrowser.py +++ b/sickbeard/metadata/mediabrowser.py @@ -418,17 +418,17 @@ class MediaBrowserMetadata(generic.GenericMetadata): try: myEp = myShow[curEpToWrite.season][curEpToWrite.episode] except (indexer_exceptions.indexer_episodenotfound, indexer_exceptions.indexer_seasonnotfound): - logger.log(u"Unable to find episode " + str(curEpToWrite.season) + "x" + str(curEpToWrite.episode) + " on tvdb... has it been removed? Should I delete from db?") + logger.log(u"Unable to find episode " + str(curEpToWrite.season) + "x" + str(curEpToWrite.episode) + " on " + ep_obj.show.indexer + ".. has it been removed? Should I delete from db?") return None if curEpToWrite == ep_obj: # root (or single) episode # default to today's date for specials if firstaired is not set - if getattr(myShow, 'firstaired', None) is not None and ep_obj.season == 0: + if getattr(myEp, 'firstaired', None) is None and ep_obj.season == 0: myEp['firstaired'] = str(datetime.date.fromordinal(1)) - if getattr(myShow, 'episodename', None) is None or getattr(myShow, 'firstaired', None) is not None: + if getattr(myEp, 'episodename', None) is None or getattr(myEp, 'firstaired', None) is None: return None episode = rootNode @@ -470,7 +470,8 @@ class MediaBrowserMetadata(generic.GenericMetadata): if not ep_obj.relatedEps: Rating = etree.SubElement(episode, "Rating") - rating_text = myEp['rating'] + if getattr(myEp, 'rating', None) is not None: + rating_text = myEp['rating'] if rating_text != None: Rating.text = rating_text @@ -518,11 +519,11 @@ class MediaBrowserMetadata(generic.GenericMetadata): Overview.text = Overview.text + "\r" + curEpToWrite.description # collect all directors, guest stars and writers - if getattr(myShow, 'director', None) is not None: + if getattr(myEp, 'director', None) is not None: persons_dict['Director'] += [x.strip() for x in myEp['director'].split('|') if x] - if getattr(myShow, 'gueststars', None) is not None: + if getattr(myEp, 'gueststars', None) is not None: persons_dict['GuestStar'] += [x.strip() for x in myEp['gueststars'].split('|') if x] - if getattr(myShow, 'writer', None) is not None: + if getattr(myEp, 'writer', None) is not None: persons_dict['Writer'] += [x.strip() for x in myEp['writer'].split('|') if x] # fill in Persons section with collected directors, guest starts and writers diff --git a/sickbeard/metadata/tivo.py b/sickbeard/metadata/tivo.py index 35230b64..ecd18c3b 100644 --- a/sickbeard/metadata/tivo.py +++ b/sickbeard/metadata/tivo.py @@ -192,16 +192,16 @@ class TIVOMetadata(generic.GenericMetadata): try: myEp = myShow[curEpToWrite.season][curEpToWrite.episode] except (indexer_exceptions.indexer_episodenotfound, indexer_exceptions.indexer_seasonnotfound): - logger.log(u"Unable to find episode " + str(curEpToWrite.season) + "x" + str(curEpToWrite.episode) + " on tvdb... has it been removed? Should I delete from db?") + logger.log(u"Unable to find episode " + str(curEpToWrite.season) + "x" + str(curEpToWrite.episode) + " on " + ep_obj.show.indexer + "... has it been removed? Should I delete from db?") return None - if myEp["firstaired"] == None and ep_obj.season == 0: + if getattr(myEp, 'firstaired', None) is None and ep_obj.season == 0: myEp["firstaired"] = str(datetime.date.fromordinal(1)) - if myEp["episodename"] == None or myEp["firstaired"] == None: + if getattr(myEp, 'episodename', None) is None or getattr(myEp, 'firstaired', None) is None: return None - if myShow["seriesname"] != None: + if getattr(myShow, 'seriesname', None) is not None: data += ("title : " + myShow["seriesname"] + "\n") data += ("seriesTitle : " + myShow["seriesname"] + "\n") @@ -236,11 +236,11 @@ class TIVOMetadata(generic.GenericMetadata): # Usually starts with "SH" and followed by 6-8 digits. # Tivo uses zap2it for thier data, so the series id is the zap2it_id. - if myShow["zap2it_id"] != None: + if getattr(myShow, 'zap2it_id', None) is not None: data += ("seriesId : " + myShow["zap2it_id"] + "\n") # This is the call sign of the channel the episode was recorded from. - if myShow["network"] != None: + if getattr(myShow, 'network', None) is not None: data += ("callsign : " + myShow["network"] + "\n") # This must be entered as yyyy-mm-ddThh:mm:ssZ (the t is capitalized and never changes, the Z is also @@ -250,13 +250,13 @@ class TIVOMetadata(generic.GenericMetadata): data += ("originalAirDate : " + str(curEpToWrite.airdate) + "T00:00:00Z\n") # This shows up at the beginning of the description on the Program screen and on the Details screen. - if myShow["actors"]: + if getattr(myShow, 'actors', None) is not None: for actor in myShow["actors"].split('|'): if actor: data += ("vActor : " + actor + "\n") # This is shown on both the Program screen and the Details screen. - if myEp["rating"] != None: + if getattr(myEp, 'rating', None) is not None: try: rating = float(myEp['rating']) except ValueError: @@ -268,7 +268,7 @@ class TIVOMetadata(generic.GenericMetadata): # This is shown on both the Program screen and the Details screen. # It uses the standard TV rating system of: TV-Y7, TV-Y, TV-G, TV-PG, TV-14, TV-MA and TV-NR. - if myShow["contentrating"]: + if getattr(myShow, 'contentrating', None) is not None: data += ("tvRating : " + str(myShow["contentrating"]) + "\n") # This field can be repeated as many times as necessary or omitted completely. diff --git a/sickbeard/metadata/wdtv.py b/sickbeard/metadata/wdtv.py index a0028ce1..f0bac074 100644 --- a/sickbeard/metadata/wdtv.py +++ b/sickbeard/metadata/wdtv.py @@ -203,13 +203,13 @@ class WDTVMetadata(generic.GenericMetadata): try: myEp = myShow[curEpToWrite.season][curEpToWrite.episode] except (indexer_exceptions.indexer_episodenotfound, indexer_exceptions.indexer_seasonnotfound): - logger.log(u"Unable to find episode " + str(curEpToWrite.season) + "x" + str(curEpToWrite.episode) + " on tvdb... has it been removed? Should I delete from db?") + logger.log(u"Unable to find episode " + str(curEpToWrite.season) + "x" + str(curEpToWrite.episode) + " on " + ep_obj.show.indexer + "... has it been removed? Should I delete from db?") return None - if myEp["firstaired"] == None and ep_obj.season == 0: + if getattr(myEp, 'firstaired', None) is None and ep_obj.season == 0: myEp["firstaired"] = str(datetime.date.fromordinal(1)) - if myEp["episodename"] == None or myEp["firstaired"] == None: + if getattr(myEp, 'episodename', None) is None or getattr(myEp, 'firstaired', None) is None: return None if len(eps_to_write) > 1: @@ -225,7 +225,7 @@ class WDTVMetadata(generic.GenericMetadata): title.text = ep_obj.prettyName() seriesName = etree.SubElement(episode, "series_name") - if myShow["seriesname"] != None: + if getattr(myShow, 'seriesname', None) is not None: seriesName.text = myShow["seriesname"] episodeName = etree.SubElement(episode, "episode_name") @@ -244,7 +244,7 @@ class WDTVMetadata(generic.GenericMetadata): firstAired.text = str(curEpToWrite.airdate) year = etree.SubElement(episode, "year") - if myShow["firstaired"] != None: + if getattr(myShow, 'firstaired', None) is not None: try: year_text = str(datetime.datetime.strptime(myShow["firstaired"], '%Y-%m-%d').year) if year_text: @@ -254,26 +254,28 @@ class WDTVMetadata(generic.GenericMetadata): runtime = etree.SubElement(episode, "runtime") if curEpToWrite.season != 0: - if myShow["runtime"] != None: + if getattr(myShow, 'runtime', None) is not None: runtime.text = myShow["runtime"] genre = etree.SubElement(episode, "genre") - if myShow["genre"] != None: + if getattr(myShow, 'genre', None) is not None: genre.text = " / ".join([x for x in myShow["genre"].split('|') if x]) director = etree.SubElement(episode, "director") - director_text = myEp['director'] + if getattr(myEp, 'director', None) is not None: + director_text = myEp['director'] if director_text != None: director.text = director_text - for actor in myShow['_actors']: - cur_actor = etree.SubElement(episode, "actor") - cur_actor_name = etree.SubElement(cur_actor, "name") - cur_actor_name.text = actor['name'] - cur_actor_role = etree.SubElement(cur_actor, "role") - cur_actor_role_text = actor['role'] - if cur_actor_role_text != None: - cur_actor_role.text = cur_actor_role_text + if getattr(myShow, '_actors', None) is not None: + for actor in myShow['_actors']: + cur_actor = etree.SubElement(episode, "actor") + cur_actor_name = etree.SubElement(cur_actor, "name") + cur_actor_name.text = actor['name'] + cur_actor_role = etree.SubElement(cur_actor, "role") + cur_actor_role_text = actor['role'] + if cur_actor_role_text != None: + cur_actor_role.text = cur_actor_role_text overview = etree.SubElement(episode, "overview") if curEpToWrite.description != None: diff --git a/sickbeard/metadata/xbmc_12plus.py b/sickbeard/metadata/xbmc_12plus.py index de9b811c..77748c6b 100644 --- a/sickbeard/metadata/xbmc_12plus.py +++ b/sickbeard/metadata/xbmc_12plus.py @@ -127,7 +127,7 @@ class XBMC_12PlusMetadata(generic.GenericMetadata): # check for title and id try: if getattr(myShow, 'seriesname', None) is None or getattr(myShow, 'id') is None: - logger.log(u"Incomplete info for show with id " + str(show_ID) + " on tvdb, skipping it", logger.ERROR) + logger.log(u"Incomplete info for show with id " + str(show_ID) + " on " + show_obj.indexer + ", skipping it", logger.ERROR) return False except indexer_exceptions.indexer_attributenotfound: @@ -189,7 +189,7 @@ class XBMC_12PlusMetadata(generic.GenericMetadata): if getattr(myShow, 'network', None) is not None: studio.text = myShow["network"] - if getattr(myShow, 'actors', None) is not None: + if getattr(myShow, '_actors', None) is not None: for actor in myShow['_actors']: cur_actor = etree.SubElement(tv_node, "actor") @@ -258,10 +258,10 @@ class XBMC_12PlusMetadata(generic.GenericMetadata): logger.log(u"Unable to find episode " + str(curEpToWrite.season) + "x" + str(curEpToWrite.episode) + " on " + ep_obj.show.indexer + ".. has it been removed? Should I delete from db?") return None - if getattr(myShow, 'firstaired', None) is not None: + if getattr(myEp, 'firstaired', None) is None: myEp["firstaired"] = str(datetime.date.fromordinal(1)) - if getattr(myShow, 'episodename', None) is not None: + if getattr(myEp, 'episodename', None) is None: logger.log(u"Not generating nfo because the ep has no title", logger.DEBUG) return None @@ -301,7 +301,7 @@ class XBMC_12PlusMetadata(generic.GenericMetadata): runtime = etree.SubElement(episode, "runtime") if curEpToWrite.season != 0: - if getattr(myEp, 'runtime', None) is not None: + if getattr(myShow, 'runtime', None) is not None: runtime.text = myShow["runtime"] displayseason = etree.SubElement(episode, "displayseason") diff --git a/sickbeard/postProcessor.py b/sickbeard/postProcessor.py index bf74a476..6c3bdddf 100644 --- a/sickbeard/postProcessor.py +++ b/sickbeard/postProcessor.py @@ -547,7 +547,6 @@ class PostProcessor(object): _finalize(parse_result) return to_return - def _find_info(self): """ For a given file try to find the showid, season, and episode. @@ -823,7 +822,7 @@ class PostProcessor(object): indexer_id = season = episodes = None if 'auto' in self.indexer: for indexer in indexerStrings: - self.indexer = indexer[0] + self.indexer = indexer sickbeard.INDEXER_API_PARMS['indexer'] = self.indexer # try to find the file info diff --git a/sickbeard/tv.py b/sickbeard/tv.py index 0725e215..8235fec1 100644 --- a/sickbeard/tv.py +++ b/sickbeard/tv.py @@ -1335,7 +1335,7 @@ class TVEpisode(object): logger.log(u"" + self.indexer + " timed out, unable to create the episode", logger.ERROR) return False except (indexer_exceptions.indexer_episodenotfound, indexer_exceptions.indexer_seasonnotfound): - logger.log(u"Unable to find the episode on tvdb... has it been removed? Should I delete from db?", logger.DEBUG) + logger.log(u"Unable to find the episode on " + self.idexer + "... has it been removed? Should I delete from db?", logger.DEBUG) # if I'm no longer on TVDB but I once was then delete myself from the DB if self.indexerid != -1: self.deleteEpisode() diff --git a/sickbeard/webserve.py b/sickbeard/webserve.py index 9d86f36a..861e19b3 100644 --- a/sickbeard/webserve.py +++ b/sickbeard/webserve.py @@ -1992,10 +1992,6 @@ class NewHomeAddShows: t = PageTemplate(file="home_massAddTable.tmpl") t.submenu = HomeMenu() - for curIndexer in sorted(indexerStrings.items(), key=lambda x: x[1]): - a = curIndexer[0] - b = curIndexer[1] - myDB = db.DBConnection() if not rootDir: @@ -2052,12 +2048,14 @@ class NewHomeAddShows: show_name = '' for cur_provider in sickbeard.metadata_provider_dict.values(): (indexer_id, show_name, indexer) = cur_provider.retrieveShowMetadata(cur_path) - if indexer_id and indexer and show_name: + if indexer_id and show_name: break # default to TVDB if indexer was not detected if indexer is None: - indexer = 'Tvdb' + found_info = helpers.searchIndexersForShow(show_name) + if found_info is not None: + indexer = found_info cur_dir['existing_info'] = (indexer_id, show_name, indexer)