From d9d047ee325c7103889bbf0bbdfa8d75a25d0ff9 Mon Sep 17 00:00:00 2001 From: oddluck Date: Thu, 5 Dec 2019 07:02:35 +0000 Subject: [PATCH] Add TVMaze, NFLScores. --- NFLScores/README.md | 1 + NFLScores/__init__.py | 52 ++++ NFLScores/config.py | 33 +++ NFLScores/plugin.py | 518 +++++++++++++++++++++++++++++++++++++ NFLScores/requirements.txt | 3 + NFLScores/test.py | 15 ++ TVMaze/LICENSE | 21 ++ TVMaze/README.md | 42 +++ TVMaze/__init__.py | 54 ++++ TVMaze/accountsdb.py | 112 ++++++++ TVMaze/config.py | 39 +++ TVMaze/plugin.py | 417 +++++++++++++++++++++++++++++ TVMaze/requirements.txt | 2 + TVMaze/test.py | 15 ++ 14 files changed, 1324 insertions(+) create mode 100644 NFLScores/README.md create mode 100644 NFLScores/__init__.py create mode 100644 NFLScores/config.py create mode 100644 NFLScores/plugin.py create mode 100644 NFLScores/requirements.txt create mode 100644 NFLScores/test.py create mode 100644 TVMaze/LICENSE create mode 100644 TVMaze/README.md create mode 100644 TVMaze/__init__.py create mode 100644 TVMaze/accountsdb.py create mode 100644 TVMaze/config.py create mode 100644 TVMaze/plugin.py create mode 100644 TVMaze/requirements.txt create mode 100644 TVMaze/test.py diff --git a/NFLScores/README.md b/NFLScores/README.md new file mode 100644 index 0000000..8f490d3 --- /dev/null +++ b/NFLScores/README.md @@ -0,0 +1 @@ +Fetches scores and game information from NFL.com diff --git a/NFLScores/__init__.py b/NFLScores/__init__.py new file mode 100644 index 0000000..68cffcb --- /dev/null +++ b/NFLScores/__init__.py @@ -0,0 +1,52 @@ +### +# Copyright (c) 2019, cottongin +# All rights reserved. +# +# +### + +""" +NFLScores: Fetches scores and game information from NFL.com +""" + +import sys +import supybot +from supybot import world + +# Use this for the version of this plugin. You may wish to put a CVS keyword +# in here if you're keeping the plugin in CVS or some similar system. +__version__ = "" + +# XXX Replace this with an appropriate author or supybot.Author instance. +__author__ = supybot.Author('cottongin', 'cottongin', + 'cottongin@cottongin.club') +__maintainer__ = getattr(supybot.authors, 'oddluck', + supybot.Author('oddluck', 'oddluck', 'oddluck@riseup.net')) + +# This is a dictionary mapping supybot.Author instances to lists of +# contributions. +__contributors__ = {} + +# This is a url where the most recent plugin package can be downloaded. +__url__ = 'https://github.com/oddluck/limnoria-plugins/' + +from . import config +from . import plugin +if sys.version_info >= (3, 4): + from importlib import reload +else: + from imp import reload +# In case we're being reloaded. +reload(config) +reload(plugin) +# Add more reloads here if you add third-party modules and want them to be +# reloaded when this plugin is reloaded. Don't forget to import them as well! + +if world.testing: + from . import test + +Class = plugin.Class +configure = config.configure + + +# vim:set shiftwidth=4 tabstop=4 expandtab textwidth=79: diff --git a/NFLScores/config.py b/NFLScores/config.py new file mode 100644 index 0000000..efd909e --- /dev/null +++ b/NFLScores/config.py @@ -0,0 +1,33 @@ +### +# Copyright (c) 2019, cottongin +# All rights reserved. +# +# +### + +from supybot import conf, registry +try: + from supybot.i18n import PluginInternationalization + _ = PluginInternationalization('NFLScores') +except: + # Placeholder that allows to run the plugin on a bot + # without the i18n module + _ = lambda x: x + + +def configure(advanced): + # This will be called by supybot to configure this module. advanced is + # a bool that specifies whether the user identified themself as an advanced + # user or not. You should effect your configuration by manipulating the + # registry as appropriate. + from supybot.questions import expect, anything, something, yn + conf.registerPlugin('NFLScores', True) + + +NFLScores = conf.registerPlugin('NFLScores') +# This is where your configuration variables (if any) should go. For example: +# conf.registerGlobalValue(NFLScores, 'someConfigVariableName', +# registry.Boolean(False, _("""Help for someConfigVariableName."""))) + + +# vim:set shiftwidth=4 tabstop=4 expandtab textwidth=79: diff --git a/NFLScores/plugin.py b/NFLScores/plugin.py new file mode 100644 index 0000000..bb04b38 --- /dev/null +++ b/NFLScores/plugin.py @@ -0,0 +1,518 @@ +### +# Copyright (c) 2019, cottongin +# All rights reserved. +# +# +### + +import pendulum +import requests, json +from roman_numerals import convert_to_numeral + +from supybot import utils, plugins, ircutils, callbacks +from supybot.commands import * +try: + from supybot.i18n import PluginInternationalization + _ = PluginInternationalization('NFLScores') +except ImportError: + # Placeholder that allows to run the plugin on a bot + # without the i18n module + _ = lambda x: x + +BASE_URL = "https://feeds.nfl.com/feeds-rs{}.json" + +def getValidDateFmt(irc, msg, args, state): + date = args[0] + valid = ['yesterday', 'tomorrow'] + check = None + try: + if date.lower() in valid: + if date.lower() == 'yesterday': + check = pendulum.yesterday().format('MM/DD/YYYY') + else: + check = pendulum.tomorrow().format('MM/DD/YYYY') + else: + check = pendulum.parse(date, strict=False).format('MM/DD/YYYY') + except: + pass + if not check: + state.errorInvalid(_('date format'), str(date)) + else: + state.args.append(check) + del args[0] +addConverter('validDate', getValidDateFmt) + +class NFLScores(callbacks.Plugin): + """Fetches scores and game information from NFL.com""" + threaded = True + + def __init__(self, irc): + super().__init__(irc) + self.GOOG = irc.getCallback('Google') + + @wrap([getopts({"week": "positiveInt", + "season": "positiveInt", + "type": ("literal", ("hof","pre", "reg", "post", "pro", "sb")), + "inp": "", + "pro": "", + "date": "validDate"}), optional("somethingWithoutSpaces")]) + def nfl(self, irc, msg, args, options, team): + """(--week <#> | --type
 | --inp | --date ) ()
+        Fetches scores.
+        """
+        options = dict(options)
+        inp = options.get('inp')
+        seasonType = options.get('type')
+        date = options.get('date') or pendulum.now().format('MM/DD/YYYY')
+        week = options.get('week')
+        season = options.get('season')
+        gameIds = []
+        network = None
+
+        date = dict(zip(['month', 'day', 'year'], date.split('/')))
+        if 1 <= int(date['month']) <= 6:
+            url = BASE_URL.format(f"/schedules/{int(date['year'])-1}")
+        else:
+            url = BASE_URL.format(f"/schedules/{date['year']}")
+        data = requests.get(url).json()
+        
+        if not week:
+            url = BASE_URL.format('/currentWeek')
+            week = requests.get(url).json()['week']
+        if not season:
+            url = BASE_URL.format('/currentWeek')
+            season = requests.get(url).json()['seasonId']
+        if not seasonType:
+            url = BASE_URL.format('/currentWeek')
+            tmp = requests.get(url).json()['seasonType']
+            if tmp == "PRO":
+                if not options.get('pro'):
+                    tmp = "POST"
+                    week = 22 if not week or week == 4 or week == 21 else week
+            seasonType = tmp
+
+        if options.get('date'):
+            found = False
+            for game in data['gameSchedules']:
+                if game['gameDate'] == f"{'/'.join(date.values())}":
+                    if team:
+                        teams = [game['visitorTeamAbbr'], game['homeTeamAbbr']]
+                        if team.upper() in teams:
+                            gameIds.append(game['gameId'])
+                            network = ' :: {}'.format(game['networkChannel'])
+                            week = str(game['week'])
+                            season = game['season']
+                            seasonType = game['seasonType']
+                            found = True
+                            break
+                    else:
+                        gameIds.append(game['gameId'])
+                        network = ' :: {}'.format(game['networkChannel'])
+                        week = str(game['week'])
+                        season = game['season']
+                        seasonType = game['seasonType']
+                        found = True
+            if not found:
+                date = '/'.join(date.values())
+                irc.reply('Error: No games found on {}'.format(
+                    f"{date if not team else date + ' for ' + team.upper()}"))
+                return
+
+        if seasonType.upper() in ['POST']:
+            if int(week) <= 5: week += 17
+        url = BASE_URL.format('/scores/{}/{}/{}'.format(
+            season, seasonType.upper(), week
+        ))
+        try:
+            scores = requests.get(url).json()['gameScores']
+        except json.decoder.JSONDecodeError:
+            irc.error('invalid input', Raise=True)
+        except Exception as e:
+            print(e)
+            irc.error('something went wrong parsing data', Raise=True)
+
+        new_scores = []
+        if gameIds or team:
+            for game in scores:
+                if gameIds and not team:
+                    if game['gameSchedule']['gameId'] in gameIds:
+                        new_scores.append(game)
+                if team:
+                    teams = [game['gameSchedule']['visitorTeamAbbr'], game['gameSchedule']['homeTeamAbbr']]
+                    if team.upper() in teams:
+                        new_scores.append(game)
+                        break
+        else:
+            new_scores = scores
+                    
+        week = int(week)
+        if week >= 18: week -= 17
+        if seasonType in ['PRE']:
+            if week != 0:
+                prefix = self._bold("Preseason Week {}:".format(week))
+            else:
+                prefix = self._bold("Hall of Fame Game:")
+        elif seasonType in ['REG']:
+            prefix = self._bold("Week {}:".format(week))
+        else:
+            prefix = self._bold("{}:")
+            if new_scores[0]['gameSchedule']['gameType'] == "WC":
+                prefix = prefix.format("Wildcard Weekend")
+            elif new_scores[0]['gameSchedule']['gameType'] == "DIV":
+                prefix = prefix.format("Divisional Round")
+            elif new_scores[0]['gameSchedule']['gameType'] == "CON":
+                prefix = prefix.format("Conference Finals")
+            elif new_scores[0]['gameSchedule']['gameType'] == "PRO":
+                prefix = prefix.format("Pro Bowl")
+            elif new_scores[0]['gameSchedule']['gameType'] == "SB":
+                prefix = prefix.format(f"Super Bowl {convert_to_numeral(int(season)-1965)}")
+            else:
+                prefix = prefix.format("Week {}".format(week))
+        
+        games = []
+        print(new_scores)
+        for game in new_scores:
+            if len(new_scores) == 1:
+                long_ = True
+                home = "homeDisplayName"
+                away = "visitorDisplayName"
+                time_format = "dddd, M/D, h:mm A zz"
+                sep = " :: "
+                if not network:
+                    for s in data['gameSchedules']:
+                        if game['gameSchedule']['gameId'] == s['gameId']:
+                            network = ' :: {}'.format(s['networkChannel'])
+                            break
+            else:
+                long_ = False
+                home = "homeTeamAbbr"
+                away = "visitorTeamAbbr"
+                time_format = "ddd h:mm A zz"
+                sep = " "
+                network = ''
+            score = game['score']
+            info = game['gameSchedule']
+            time = f"{pendulum.from_timestamp(info['isoTime']/1000).in_tz('US/Eastern').format(time_format)}"
+            if not score:
+                string = (f"{info[away]} @ {info[home]}{sep}"
+                          f"{time}{network}")
+                if info['gameType'] == "SB":
+                    string += f" :: {info['site']['siteFullname']}{' ({})'.format(info['site']['roofType'].title()) if info['site']['roofType'] else ''}, {info['site']['siteCity']}, {info['site']['siteState']}"
+                games.append(string)
+            else:
+                if "FINAL" in score['phase']:
+                    phase = score['phase']
+                    if "OVERTIME" in phase:
+                        phase = "Final/Overtime" if long_ else "F/OT"
+                    else:
+                        phase = "Final" if long_ else "F"
+                    phase = self._color(phase, 'red')
+                    h_score = score['homeTeamScore']['pointTotal']
+                    v_score = score['visitorTeamScore']['pointTotal']
+                    if v_score > h_score:
+                        v_str = self._bold(f"{info[away]} {v_score}")
+                        h_str = f"{info[home]} {h_score}"
+                    elif h_score > v_score:
+                        v_str = f"{info[away]} {v_score}"
+                        h_str = self._bold(f"{info[home]} {h_score}")
+                    else:
+                        v_str = f"{info[away]} {v_score}"
+                        h_str = f"{info[home]} {h_score}"
+                    string = (f"{v_str} @ {h_str}{sep}{phase}")
+                    if info['gameType'] == "SB":
+                        string += f" :: {info['site']['siteFullname']}{' ({})'.format(info['site']['roofType'].title()) if info['site']['roofType'] else ''}, {info['site']['siteCity']}, {info['site']['siteState']}"
+                    games.append(string)
+                elif "PRE" in score['phase']:
+                    string = (f"{info[away]} @ {info[home]}{sep}"
+                              f"{time}{network}")
+                    if info['gameType'] == "SB":
+                        string += f" :: {info['site']['siteFullname']}{' ({})'.format(info['site']['roofType'].title()) if info['site']['roofType'] else ''}, {info['site']['siteCity']}, {info['site']['siteState']}"
+                    games.append(string)
+                elif "HALFTIME" in score['phase']:
+                    phase = "Halftime" if long_ else "HT"
+                    phase = self._color(phase, 'orange')
+                    h_score = score['homeTeamScore']['pointTotal']
+                    v_score = score['visitorTeamScore']['pointTotal']
+                    if v_score > h_score:
+                        v_str = self._bold(f"{info[away]} {v_score}")
+                        h_str = f"{info[home]} {h_score}"
+                    elif h_score > v_score:
+                        v_str = f"{info[away]} {v_score}"
+                        h_str = self._bold(f"{info[home]} {h_score}")
+                    else:
+                        v_str = f"{info[away]} {v_score}"
+                        h_str = f"{info[home]} {h_score}"
+                    string = (f"{v_str} @ {h_str}{sep}{phase}")
+                    games.append(string)
+                else:
+                    phase = score['phaseDescription'] if long_ else score['phase']
+                    phase = self._color(phase, 'green')
+                    time = self._color(score['time'], 'green')
+                    h_score = score['homeTeamScore']['pointTotal']
+                    v_score = score['visitorTeamScore']['pointTotal']
+                    if v_score > h_score:
+                        v_str = self._bold(f"{info[away]} {v_score}")
+                        h_str = f"{info[home]} {h_score}"
+                    elif h_score > v_score:
+                        v_str = f"{info[away]} {v_score}"
+                        h_str = self._bold(f"{info[home]} {h_score}")
+                    else:
+                        v_str = f"{info[away]} {v_score}"
+                        h_str = f"{info[home]} {h_score}"
+                    string = (f"{v_str} @ {h_str}{sep}{time} {phase}")
+                    status = None
+                    try:
+                        pos_team = score.get('possessionTeamAbbr')
+                        at = score['yardline']
+                        down = "{} and {}".format(score['down'], score['yardsToGo'])
+                        status = " :: {}".format(down)
+                        last_play = None
+                        if pos_team:
+                            status += " :: {} has the ball at {}".format(pos_team, at)
+                        if len(new_scores) == 1:
+                            gameId = info['gameId']
+                            url = BASE_URL.format('/playbyplay/{}/latest'.format(gameId))
+                            try:
+                                last_play = requests.get(url).json()
+                                last_play = last_play['plays'][-1]['playDescription']
+                            except:
+                                pass
+                        if last_play:
+                            status += " :: {}".format(last_play)
+                    except:
+                        pass
+                    if status:
+                        string += status
+                    games.append(string)
+
+        irc.reply(f"{prefix} {' | '.join(games)}")
+        
+
+
+    @wrap([ "text"])
+    def nflgame(self, irc, msg, args, player):
+        """
+        Fetches current/previous game stats for given player.
+        """
+        player_id = None
+        try:
+            try:
+                burl = "site:nfl.com {} stats".format(player.lower())
+                search = self.GOOG.search('{0}'.format(burl),'#reddit-nfl',{'smallsearch': True})
+                search = self.GOOG.decode(search)
+                if search:
+                    url = search[0]['url']
+                    print(url)
+                    player_id = url.split('/')[-2]
+                    player_id = player_id.replace('-', ' ')
+                    print(player_id)
+                
+            except:
+                self.log.exception("ERROR :: NFLScores :: failed to get link for {0}".format(burl))
+                pass
+        except Exception as e:
+            self.log.info("ERROR :: NFLScores :: {0}".format(e))
+            pass
+        
+        if not player_id:
+            irc.reply('ERROR: Could not find a player id for {}'.format(player))
+            return
+        
+        endpoint = '/playerGameStats/{}'.format(player_id)
+        data = requests.get(BASE_URL.format(endpoint)).json()
+        game_stats = data['playerGameStats']
+        player_info = data['teamPlayer']
+
+        if not game_stats:
+            irc.reply("I couln't find any current or previous game stats for {}".format(player_info['displayName']))
+            return
+        
+        recent = game_stats[-1]
+        
+        name = (f"{self._bold(self._color(player_info['displayName'], 'red'))}"
+                f" (#{player_info['jerseyNumber']} {player_info['position']})"
+                f" [{player_info['yearsOfExperience']}yrs exp]"
+                f" :: {player_info['teamFullName']}")
+        
+        game_time = recent['gameSchedule']['isoTime'] / 1000
+        info = (f"{recent['gameSchedule']['visitorTeamAbbr']} "
+                f"{recent['score']['visitorTeamScore']['pointTotal']} @ "
+                f"{recent['gameSchedule']['homeTeamAbbr']} "
+                f"{recent['score']['homeTeamScore']['pointTotal']} - "
+                f"{pendulum.from_timestamp(game_time).in_tz('US/Eastern').format('ddd MM/DD h:mm A zz')}")
+
+        if player_info['positionGroup'] == 'QB':
+            #passing, rush, fumble
+            tmp = recent['playerPassingStat']
+            stats = [(f"{self._ul('Passing')}: {self._bold('Comp')} {tmp.get('passingCompletions', '-')} "
+                      f"{self._bold('Att')} {tmp.get('passingAttempts', '-')} "
+                      f"{self._bold('Pct')} {tmp.get('passingCompletionPercentage', '-')} "
+                      f"{self._bold('Yds')} {tmp.get('passingYards', '-')} "
+                      f"{self._bold('Avg')} {tmp.get('passingYardsPerAttempts', '-')} "
+                      f"{self._bold('TD')} {tmp.get('passingTouchdowns', '-')} "
+                      f"{self._bold('Int')} {tmp.get('passingInterceptions', '-')} "
+                      f"{self._bold('Sck')} {tmp.get('passingSacked', '-')} "
+                      f"{self._bold('SckY')} {tmp.get('passingSackedYardsLost', '-')} "
+                      f"{self._bold('Rate')} {tmp.get('passingRating', '-')}")]
+            tmp = recent['playerRushingStat']
+            line2 = []
+            line2.append(
+                     (f"{self._ul('Rushing')}: {self._bold('Att')} {tmp.get('rushingAttempts', '-')} "
+                      f"{self._bold('Yds')} {tmp.get('rushingYards', '-')} "
+                      f"{self._bold('Avg')} {tmp.get('rushingYardsPerAttempt', '-')} "
+                      f"{self._bold('TD')} {tmp.get('rushingTouchdowns', '-')}"))
+            tmp = recent['playerFumbleStat']
+            line2.append(
+                     (f"{self._ul('Fumbles')}: {self._bold('Fum')} {tmp.get('fumbles', '-')} "
+                      f"{self._bold('Lst')} {tmp.get('fumblesLost', '-')}"))
+            stats.append(' :: '.join(line2))
+        elif player_info['positionGroup'] == 'RB':
+            #rush, recev, fumble
+            line1 = []
+            line2 = []
+            stats = []
+            tmp = recent['playerRushingStat']
+            line1 = [(f"{self._ul('Rushing')}: {self._bold('Att')} {tmp.get('rushingAttempts', '-')} "
+                      f"{self._bold('Att')} {tmp.get('rushingYards', '-')} "
+                      f"{self._bold('Avg')} {tmp.get('rushingYardsPerAttempt', '-')} "
+                      f"{self._bold('Lng')} {tmp.get('rushingLong', '-')} "
+                      f"{self._bold('TD')} {tmp.get('rushingTouchdowns', '-')}")] if tmp else []
+            tmp = recent['playerReceivingStat']
+            if tmp: line1.append(
+                     (f"{self._ul('Receiving')}: {self._bold('Rec')} {tmp.get('receivingReceptions', '-')} "
+                      f"{self._bold('Yds')} {tmp.get('receivingYards', '-')} "
+                      f"{self._bold('Avg')} {tmp.get('receivingYardsPerReception', '-')} "
+                      f"{self._bold('Lng')} {tmp.get('receivingLong', '-')} "
+                      f"{self._bold('TD')} {tmp.get('receivingTouchdowns', '-')}"))
+            tmp = recent['playerFumbleStat']
+            line2.append(
+                     (f"{self._ul('Fumbles')}: {self._bold('Fum')} {tmp.get('fumbles', '-')} "
+                      f"{self._bold('Lst')} {tmp.get('fumblesLost', '-')}"))
+            if len(line1) == 1 and len(line2) == 1:
+                stats.append('{} :: {}'.format(line1[0], line2[0]))
+            else:
+                if line1: stats.append(' :: '.join(line1))
+                if line2: stats.append(' :: '.join(line2))
+        elif player_info['positionGroup'] in ['WR', 'TE']:
+            #recv, rush, fumble
+            line1 = []
+            line2 = []
+            stats = []
+            tmp = recent['playerReceivingStat']
+            line1 = [(f"{self._ul('Receiving')}: {self._bold('Rec')} {tmp.get('receivingReceptions', '-')} "
+                      f"{self._bold('Yds')} {tmp.get('receivingYards', '-')} "
+                      f"{self._bold('Avg')} {tmp.get('receivingYardsPerReception', '-')} "
+                      f"{self._bold('Lng')} {tmp.get('receivingLong', '-')} "
+                      f"{self._bold('TD')} {tmp.get('receivingTouchdowns', '-')}")] if tmp else []
+            tmp = recent['playerRushingStat']
+            if tmp: line1.append(
+                     (f"{self._ul('Rushing')}: {self._bold('Att')} {tmp.get('rushingAttempts', '-')} "
+                      f"{self._bold('Att')} {tmp.get('rushingYards', '-')} "
+                      f"{self._bold('Avg')} {tmp.get('rushingYardsPerAttempt', '-')} "
+                      f"{self._bold('Lng')} {tmp.get('rushingLong', '-')} "
+                      f"{self._bold('TD')} {tmp.get('rushingTouchdowns', '-')}"))
+            tmp = recent['playerFumbleStat']
+            line2.append(
+                     (f"{self._ul('Fumbles')}: {self._bold('Fum')} {tmp.get('fumbles', '-')} "
+                      f"{self._bold('Lst')} {tmp.get('fumblesLost', '-')}"))
+            if len(line1) == 1 and len(line2) == 1:
+                stats.append('{} :: {}'.format(line1[0], line2[0]))
+            else:
+                if line1: stats.append(' :: '.join(line1))
+                if line2: stats.append(' :: '.join(line2))
+        elif player_info['position'] == 'K':
+            #overall fg, pats, koffs
+            line1 = []
+            line2 = []
+            stats = []
+            tmp = recent['playerKickingStat']
+            line1 = [(f"{self._ul('Field Goals')}: {self._bold('FG Att')} {tmp.get('kickingFgAttempts', '-')} "
+                      f"{self._bold('FGM')} {tmp.get('kickingFgMade', '-')} "
+                      f"{self._bold('Pct')} {tmp.get('kickingFgPercentage', '-')} "
+                      f"{self._bold('Lng')} {tmp.get('kickingFgLong', '-')} "
+                      f"{self._bold('Blk')} {tmp.get('kickingFgBlocked', '-')}")] if tmp else []
+            if tmp: line1.append(
+                     (f"{self._ul('PATs')}: {self._bold('XP Att')} {tmp.get('kickingXkAttempts', '-')} "
+                      f"{self._bold('XPM')} {tmp.get('kickingXkMade', '-')} "
+                      f"{self._bold('Pct')} {tmp.get('kickingXkPercentage', '-')} "
+                      f"{self._bold('Blk')} {tmp.get('kickingXkBlocked', '-')} "))
+            line2.append(
+                     (f"{self._ul('Kickoffs')}: {self._bold('KO')} {tmp.get('kickoffs', '-')} "
+                      f"{self._bold('Avg')} {tmp.get('kickoffAverageYards', '-')} "
+                      f"{self._bold('TB')} {tmp.get('kickoffTouchbacks', '-')} "
+                      f"{self._bold('Ret')} {tmp.get('kickoffReturns', '-')} "
+                      f"{self._bold('Avg')} {tmp.get('kickoffReturnsAverageYards', '-  ')}"))
+            if len(line1) == 1 and len(line2) == 1:
+                stats.append('{} :: {}'.format(line1[0], line2[0]))
+            else:
+                if line1: stats.append(' :: '.join(line1))
+                if line2: stats.append(' :: '.join(line2))
+        elif player_info['positionGroup'] in ['LB', 'DB', 'DL']:
+            #defense
+            line1 = []
+            line2 = []
+            stats = []
+            tmp = recent['playerDefensiveStat']
+            line1 = [(f"{self._ul('Tackles')}: {self._bold('Comb')} {tmp.get('defensiveCombineTackles', '-')} "
+                      f"{self._bold('Total')} {tmp.get('defensiveTotalTackles', '-')} "
+                      f"{self._bold('Ast')} {tmp.get('defensiveAssist', '-')} "
+                      f"{self._bold('Sck')} {tmp.get('defensiveSacks', '-')} "
+                      f"{self._bold('SFTY')} {tmp.get('defensiveSafeties', '-')}")] if tmp else []
+            if tmp: line1.append(
+                     (f"{self._ul('Interceptions')}: {self._bold('PDef')} {tmp.get('defensivePassesDefensed', '-')} "
+                      f"{self._bold('Int')} {tmp.get('defensiveInterceptions', '-')} "
+                      f"{self._bold('Yds')} {tmp.get('defensiveInterceptionYards', '-')} "
+                      f"{self._bold('Avg')} {tmp.get('defensiveInterceptionsAvgyds', '-')} "
+                      f"{self._bold('Lng')} {tmp.get('defensiveInterceptionsLong', '-')} "
+                      f"{self._bold('TDs')} {tmp.get('defensiveInterceptionsTds', '-')} "))
+            line2.append(
+                     (f"{self._ul('Fumbles')}: {self._bold('FF')} {tmp.get('kickoffs', '-')} "))
+            if len(line1) == 1 and len(line2) == 1:
+                stats.append('{} :: {}'.format(line1[0], line2[0]))
+            else:
+                if line1: stats.append(' :: '.join(line1))
+                if line2: stats.append(' :: '.join(line2))
+        elif player_info['position'] == 'P':
+            line1 = []
+            stats = []
+            tmp = recent['playerPuntingStat']
+            line1 = [(f"{self._ul('Punting')}: {self._bold('Punts')} {tmp.get('puntingPunts', '-')} "
+                      f"{self._bold('Yds')} {tmp.get('puntingYards', '-')} "
+                      f"{self._bold('Net Yds')} {tmp.get('puntingNetYardage', '-')} "
+                      f"{self._bold('Lng')} {tmp.get('puntingLong', '-')} "
+                      f"{self._bold('Avg')} {tmp.get('puntingAverageYards', '-')} "
+                      f"{self._bold('Net Avg')} {tmp.get('puntingNetAverage', '-')} "
+                      f"{self._bold('Blk')} {tmp.get('puntingBlocked', '-')} "
+                      f"{self._bold('OOB')} {tmp.get('puntingOutOfBounds', '-')} "
+                      f"{self._bold('Dn')} {tmp.get('puntingDowned', '-')} "
+                      f"{self._bold('In 20')} {tmp.get('puntingPuntsInside20', '-')} "
+                      f"{self._bold('TB')} {tmp.get('puntingTouchbacks', '-')} "
+                      f"{self._bold('FC')} {tmp.get('puntingPuntsFairCaught', '-')} "
+                      f"{self._bold('Ret')} {tmp.get('puntingNumberReturned', '-')} "
+                      f"{self._bold('RetY')} {tmp.get('puntingReturnYards', '-')} "
+                      f"{self._bold('TD')} {tmp.get('puntingReturnTouchdowns', '-')}")] if tmp else []
+            stats.append(' :: '.join(line1))
+        else:
+            stats = ["No stats found"]
+        
+        strings = [f"{name} :: {info}"]
+        
+        for string in strings:
+            irc.reply(string)
+        for stat in stats:
+            irc.reply(stat)
+
+    def _color(self, string, color):
+        return ircutils.mircColor(string, color)
+
+    def _bold(self, string):
+        return ircutils.bold(string)
+
+    def _ul(self, string):
+        return ircutils.underline(string)        
+        
+
+Class = NFLScores
+
+
+# vim:set shiftwidth=4 softtabstop=4 expandtab textwidth=79:
diff --git a/NFLScores/requirements.txt b/NFLScores/requirements.txt
new file mode 100644
index 0000000..bbcdbef
--- /dev/null
+++ b/NFLScores/requirements.txt
@@ -0,0 +1,3 @@
+pendulum
+requests
+roman_numerals
diff --git a/NFLScores/test.py b/NFLScores/test.py
new file mode 100644
index 0000000..8a42ab7
--- /dev/null
+++ b/NFLScores/test.py
@@ -0,0 +1,15 @@
+###
+# Copyright (c) 2019, cottongin
+# All rights reserved.
+#
+#
+###
+
+from supybot.test import *
+
+
+class NFLScoresTestCase(PluginTestCase):
+    plugins = ('NFLScores',)
+
+
+# vim:set shiftwidth=4 tabstop=4 expandtab textwidth=79:
diff --git a/TVMaze/LICENSE b/TVMaze/LICENSE
new file mode 100644
index 0000000..9464006
--- /dev/null
+++ b/TVMaze/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2019 cottongin
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/TVMaze/README.md b/TVMaze/README.md
new file mode 100644
index 0000000..01a3aee
--- /dev/null
+++ b/TVMaze/README.md
@@ -0,0 +1,42 @@
+# TVMaze
+
+## Limnoria plugin to fetch TV show information and schedules from tvmaze.com API
+
+### Instructions
+
+#### This plugin requires Python 3 and Limnoria
+
+1. Install with PluginDownloader @install oddluck TVMaze
+
+2. Install requirements for the plugin via pip
+```
+pip install -r requirements.txt
+```
+
+3. Load the plugin on your bot
+```
+@load TVMaze
+```
+
+### Example Usage
+```
+ @schedule --country GB --tz GMT
+ Today's Shows: Cuckoo: Ivy Arrives [S05E01] (10:00 AM GMT), Cuckoo: Ivy Nanny [S05E02] (10:00 AM GMT), Cuckoo: Weed Farm [S05E03] (10:00 AM GMT), Cuckoo: Macbeth [S05E04] (10:00 AM GMT), Cuckoo: Divorce Party [S05E05] (10:00 AM GMT), Cuckoo: Two Engagements and a Funeral [S05E06] (10:00 AM GMT), Cuckoo: Election [S05E07] (10:00 AM GMT), The Dumping Ground: Rage [S08E01] (5:00 PM GMT),  (1 more message)      
+
+ @tvshow the orville
+ The Orville (2017) | Next: Home [S02E03] (2019-01-10 in 6 days) | Prev: Primal Urges [S02E02] (2019-01-03) | Running | English | 60m | FOX | Comedy/Adventure/Science-Fiction | http://www.tvmaze.com/shows/20263/the-orville | https://imdb.com/title/tt5691552/ | http://www.fox.com/the-orville
+```
+Use @help tvshow|schedule to see details on each command.
+
+---
+
+You can use @settvmazeoptions to save common command options to make using commands easier:
+```
+@settvmazeoptions --country GB
+@settvmazeoptions --tz US/Central
+@settvmazeoptions --country AU --tz US/Pacific
+```
+This stores settings per nick, you can clear them via --clear:
+```
+@settvmazeoptions --clear
+```
diff --git a/TVMaze/__init__.py b/TVMaze/__init__.py
new file mode 100644
index 0000000..ec5fd2e
--- /dev/null
+++ b/TVMaze/__init__.py
@@ -0,0 +1,54 @@
+###
+# Copyright (c) 2019, cottongin
+# All rights reserved.
+#
+#
+###
+
+"""
+TVMaze: Limnoria plugin to fetch TV show information and schedules from tvmaze.com API
+"""
+
+import sys
+import supybot
+from supybot import world
+
+# Use this for the version of this plugin.  You may wish to put a CVS keyword
+# in here if you're keeping the plugin in CVS or some similar system.
+__version__ = ""
+
+# XXX Replace this with an appropriate author or supybot.Author instance.
+__author__ = supybot.Author('cottongin', 'cottongin',
+                            'cottongin@cottongin.club')
+__maintainer__ = getattr(supybot.authors, 'oddluck',
+                         supybot.Author('oddluck', 'oddluck', 'oddluck@riseup.net'))
+
+# This is a dictionary mapping supybot.Author instances to lists of
+# contributions.
+__contributors__ = {}
+
+# This is a url where the most recent plugin package can be downloaded.
+__url__ = 'https://github.com/oddluck/limnoria-plugins/'
+
+from . import config
+from . import plugin
+if sys.version_info >= (3, 4):
+    from importlib import reload
+else:
+    from imp import reload
+# In case we're being reloaded.
+reload(config)
+reload(plugin)
+# Add more reloads here if you add third-party modules and want them to be
+# reloaded when this plugin is reloaded.  Don't forget to import them as well!
+from . import accountsdb
+reload(accountsdb)
+
+if world.testing:
+    from . import test
+
+Class = plugin.Class
+configure = config.configure
+
+
+# vim:set shiftwidth=4 tabstop=4 expandtab textwidth=79:
diff --git a/TVMaze/accountsdb.py b/TVMaze/accountsdb.py
new file mode 100644
index 0000000..371c098
--- /dev/null
+++ b/TVMaze/accountsdb.py
@@ -0,0 +1,112 @@
+###
+# Copyright (c) 2019, James Lu 
+# All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are met:
+#
+#   * Redistributions of source code must retain the above copyright notice,
+#     this list of conditions, and the following disclaimer.
+#   * Redistributions in binary form must reproduce the above copyright notice,
+#     this list of conditions, and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#   * Neither the name of the author of this software nor the name of
+#     contributors to this software may be used to endorse or promote products
+#     derived from this software without specific prior written consent.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+# ARE DISCLAIMED.  IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
+# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+# POSSIBILITY OF SUCH DAMAGE.
+###
+
+"""
+accountsdb: Provides storage for user-specific data via Supybot accounts, ident@host, or nicks.
+"""
+
+import pickle
+
+from supybot import ircdb, log, conf, registry
+
+MODES = ["accounts", "identhost", "nicks"]
+DEFAULT_MODE = MODES[2]
+
+class _AccountsDBAddressConfig(registry.OnlySomeStrings):
+    validStrings = MODES
+
+CONFIG_OPTION_NAME = "DBAddressingMode"
+CONFIG_OPTION = _AccountsDBAddressConfig(DEFAULT_MODE, """Sets the DB addressing mode.
+    This requires reloading the plugin to take effect. Valid settings include accounts
+    (save users by Supybot accounts and ident@host if not registered), identhost
+    (save users by ident@host), and nicks (save users by nicks).
+    When changing addressing modes, existing keys will be left intact, but migration between
+    addressing modes is NOT supported.""")
+
+class AccountsDB():
+    """
+    Abstraction to map users to third-party account names.
+
+    This stores users by their bot account first, falling back to their
+    ident@host if they are not logged in.
+    """
+
+    def __init__(self, plugin_name, filename, addressing_mode=DEFAULT_MODE):
+        """
+        Loads the existing database, creating a new one in memory if none
+        exists.
+        """
+        self.db = {}
+        self._plugin_name = plugin_name
+        self.filename = conf.supybot.directories.data.dirize(filename)
+
+        self.addressing_mode = addressing_mode
+
+        try:
+            with open(self.filename, 'rb') as f:
+               self.db = pickle.load(f)
+        except Exception as e:
+            log.debug('%s: Unable to load database, creating '
+                      'a new one: %s', self._plugin_name, e)
+
+    def flush(self):
+        """Exports the database to a file."""
+        try:
+            with open(self.filename, 'wb') as f:
+                pickle.dump(self.db, f, 2)
+        except Exception as e:
+            log.warning('%s: Unable to write database: %s', self._plugin_name, e)
+
+    def _get_key(self, prefix):
+        nick, identhost = prefix.split('!', 1)
+
+        if self.addressing_mode == "accounts":
+            try:  # Try to first look up the caller as a bot account.
+                userobj = ircdb.users.getUser(prefix)
+                return userobj.name
+            except KeyError:  # If that fails, store them by nick@host.
+                return identhost
+        elif self.addressing_mode == "identhost":
+            return identhost
+        elif self.addressing_mode == "nicks":
+            return nick
+        else:
+            raise ValueError("Unknown addressing mode %r" % self.addressing_mode)
+
+    def set(self, prefix, newId):
+        """Sets a user ID given the user's prefix."""
+        user = self._get_key(prefix)
+        self.db[user] = newId
+
+    def get(self, prefix):
+        """Sets a user ID given the user's prefix."""
+        user = self._get_key(prefix)
+
+        # Automatically returns None if entry does not exist
+        return self.db.get(user)
diff --git a/TVMaze/config.py b/TVMaze/config.py
new file mode 100644
index 0000000..191b1ea
--- /dev/null
+++ b/TVMaze/config.py
@@ -0,0 +1,39 @@
+###
+# Copyright (c) 2019, cottongin
+# All rights reserved.
+#
+#
+###
+
+from supybot import conf, registry
+try:
+    from supybot.i18n import PluginInternationalization
+    _ = PluginInternationalization('TVMaze')
+except:
+    # Placeholder that allows to run the plugin on a bot
+    # without the i18n module
+    _ = lambda x: x
+    
+from . import accountsdb
+
+
+def configure(advanced):
+    # This will be called by supybot to configure this module.  advanced is
+    # a bool that specifies whether the user identified themself as an advanced
+    # user or not.  You should effect your configuration by manipulating the
+    # registry as appropriate.
+    from supybot.questions import expect, anything, something, yn
+    conf.registerPlugin('TVMaze', True)
+
+
+TVMaze = conf.registerPlugin('TVMaze')
+# This is where your configuration variables (if any) should go.  For example:
+# conf.registerGlobalValue(TVMaze, 'someConfigVariableName',
+#     registry.Boolean(False, _("""Help for someConfigVariableName.""")))
+conf.registerGlobalValue(TVMaze, accountsdb.CONFIG_OPTION_NAME, accountsdb.CONFIG_OPTION)
+
+conf.registerChannelValue(TVMaze, 'showEpisodeTitle',
+    registry.Boolean(True, _("""Determines whether the episode title will be displayed in the schedule output.""")))
+
+
+# vim:set shiftwidth=4 tabstop=4 expandtab textwidth=79:
diff --git a/TVMaze/plugin.py b/TVMaze/plugin.py
new file mode 100644
index 0000000..2c4c7e9
--- /dev/null
+++ b/TVMaze/plugin.py
@@ -0,0 +1,417 @@
+# TVMaze v0.0.1
+###
+# Copyright (c) 2019, cottongin
+# All rights reserved.
+#
+# See LICENSE.txt
+###
+
+import requests
+import pendulum
+import urllib.parse
+
+from . import accountsdb
+
+from supybot import utils, plugins, ircutils, callbacks, world
+from supybot.commands import *
+try:
+    from supybot.i18n import PluginInternationalization
+    _ = PluginInternationalization('TVMaze')
+except ImportError:
+    # Placeholder that allows to run the plugin on a bot
+    # without the i18n module
+    _ = lambda x: x
+
+
+class TVMaze(callbacks.Plugin):
+    """Limnoria plugin to fetch TV show information and schedules from tvmaze.com API"""
+    threaded = True
+    
+    def __init__(self, irc):
+        super().__init__(irc)
+        self.db = accountsdb.AccountsDB("TVMaze", 'TVMaze.db', self.registryValue(accountsdb.CONFIG_OPTION_NAME))
+        world.flushers.append(self.db.flush)
+        
+    def die(self):
+        world.flushers.remove(self.db.flush)
+        self.db.flush()
+        super().die()
+        
+    #--------------------#
+    # Formatting helpers #
+    #--------------------#
+    
+    def _bold(self, string):
+        return ircutils.bold(string)
+    
+    def _ul(self, string):
+        return ircutils.underline(string)
+    
+    def _color(self, string, color):
+        return ircutils.mircColor(string, color)
+    
+    #--------------------#
+    # Internal functions #
+    #--------------------#
+    
+    def _get(self, mode, country='US', date=None, query=None, id_=None):
+        """wrapper for requests tailored to TVMaze API"""
+        
+        base_url = 'http://api.tvmaze.com'
+        
+        if mode == 'search':
+            if not query:
+                return
+            query = urllib.parse.quote_plus(query)
+            base_url += '/search/shows?q={}'.format(query)
+            try:
+                data = requests.get(base_url).json()
+            except:
+                data = None
+        elif mode == 'schedule':
+            if not date:
+                date = pendulum.now().format('YYYY-MM-DD')
+            base_url += '/schedule?country={}&date={}'.format(country, date)
+            try:
+                data = requests.get(base_url).json()
+            except:
+                data = None
+        elif mode == 'shows':
+            if not id_:
+                return
+            base_url += '/shows/{}?embed[]=previousepisode&embed[]=nextepisode'.format(id_)
+            try:
+                data = requests.get(base_url).json()
+            except:
+                data = None
+        else:
+            data = None
+        
+        return data
+        
+    #------------------#
+    # Public functions #
+    #------------------#
+    
+    @wrap([getopts({'country': 'somethingWithoutSpaces',
+                    'detail': '',
+                    'd': '',
+                    'search': '',
+                    'record': 'positiveInt'}), 'text'])
+    def tvshow(self, irc, msg, args, options, query):
+        """[--country  | --detail|--d] 
+        Fetches information about provided TV Show from TVMaze.com.
+        Optionally include --country to find shows with the same name from another country.
+        Optionally include --detail (or --d) to show additional details.
+        Ex: tvshow --country GB the office
+        """
+        # prefer manually passed options, then saved user options
+        # this merges the two possible dictionaries, prefering manually passed
+        # options if they already exist
+        user_options = self.db.get(msg.prefix) or dict()
+        options = {**user_options, **dict(options)}
+        
+        # filter out any manually passed options
+        country = options.get('country')
+        show_detail = options.get('d') or options.get('detail')
+        
+        # search for the queried TV show
+        show_search = self._get('search', query=query)
+        if not show_search:
+            irc.reply('Nothing found for your query: {}'.format(query))
+            return
+        
+        # if the user is using --search let's just output the results
+        if options.get('search'):
+            results = []
+            for idx, show in enumerate(show_search):
+                # try to pin the year of release to the show name
+                if show['show'].get('premiered'):
+                    premiered = show['show']['premiered'][:4]
+                else:
+                    premiered = "TBD"
+                name = "{} ({})".format(show['show']['name'], premiered)
+                results.append("{}. {}".format(
+                    idx+1,
+                    self._bold(name)
+                ))
+            irc.reply("Results: {}".format(" | ".join(results)))
+            return
+        
+        # pull a specific show from --search results
+        if options.get('record'):
+            if options.get('record') > len(show_search):
+                irc.reply('Invalid record!')
+                return
+            result_to_show = options.get('record') - 1
+        else:
+            result_to_show = 0
+        
+        # if we have a country, look for that first instead of the first result
+        if country:
+            show_id = None
+            for show in show_search:
+                if show['show'].get('network'):
+                    if show['show']['network']['country']['code'].upper() == country.upper():
+                        show_id = show['show']['id']
+                        break
+            # if we can't find it, default to the first result anyway
+            if not show_id:
+                show_id = show_search[result_to_show]['show']['id']
+        else:
+            show_id = show_search[result_to_show]['show']['id']
+        
+        # fetch the show information
+        show_info = self._get('shows', id_=show_id)
+        
+        # grab the included URLs and generate an imdb one
+        urls = []
+        urls.append(show_info['url'])
+        urls.append('https://imdb.com/title/{}/'.format(show_info['externals']['imdb']))
+        if show_info['officialSite']:
+            urls.append(show_info['officialSite'])
+        
+        # grab the genres
+        genres = '{}: {}'.format(
+            self._bold('Genre(s)'),
+            '/'.join(show_info['genres'])
+        )
+        
+        # show name
+        name = self._bold(show_info['name'])
+        
+        # show language
+        lang = "{}: {}".format(
+            self._bold('Language'),
+            show_info['language']
+        )
+        
+        # show status
+        status = show_info['status']
+        if status == 'Ended':
+            status = self._color(status, 'red')
+        elif status == 'Running':
+            status = self._color(status, 'green')
+            
+        # show duration
+        runtime = "{}: {}m".format(
+            self._bold('Duration'),
+            show_info['runtime']
+        )
+        
+        # show premiere date, stripped to year and added to name
+        if show_info.get('premiered'):
+            premiered = show_info['premiered'][:4]
+        else:
+            premiered = "TBD"
+        name = "{} ({})".format(name, premiered)
+        
+        # is the show on television or web (netflix, amazon, etc)
+        if show_info.get('network'):
+            # we use this if --detail/--d is asked for
+            network = show_info['network']['name']
+            schedule = "{}: {} at {} on {}".format(
+                self._bold('Schedule'),
+                ', '.join(show_info['schedule']['days']),
+                show_info['schedule']['time'],
+                network
+            )
+        elif show_info.get('webChannel'):
+            # we use this if --detail/--d is asked for
+            network = show_info['webChannel']['name']
+            schedule = "Watch on {}".format(
+                network
+            )
+            
+        # try to get previous and/or next episode details
+        if show_info['_embedded']:
+            # previous episode
+            if show_info['_embedded'].get('previousepisode'):
+                try:
+                    ep = "S{:02d}E{:02d}".format(
+                        show_info['_embedded']['previousepisode']['season'],
+                        show_info['_embedded']['previousepisode']['number']
+                    )
+                except:
+                    ep = "?"
+                ep = self._color(ep, 'orange')
+                previous = " | {}: {ep_name} [{ep}] ({ep_date})".format(
+                    self._bold('Prev'),
+                    ep_name=show_info['_embedded']['previousepisode']['name'],
+                    ep=ep,
+                    ep_date=show_info['_embedded']['previousepisode']['airdate']
+                )
+            else:
+                previous = ""
+            # next episode
+            if show_info['_embedded'].get('nextepisode'):
+                try:
+                    ep = "S{:02d}E{:02d}".format(
+                        show_info['_embedded']['nextepisode']['season'],
+                        show_info['_embedded']['nextepisode']['number']
+                    )
+                except:
+                    ep = "?"
+                ep = self._color(ep, 'orange')
+                next_ = " | {}: {ep_name} [{ep}] ({ep_date} {when})".format(
+                    self._bold('Next'),
+                    ep_name=show_info['_embedded']['nextepisode']['name'],
+                    ep=ep,
+                    ep_date=show_info['_embedded']['nextepisode']['airdate'],
+                    when=pendulum.parse(show_info['_embedded']['nextepisode']['airstamp']).diff_for_humans()
+                )
+            else:
+                next_ = ""
+        
+        # now finally put it all together and reply
+        reply = "{0} ({3}){1}{2} | {4}".format(
+            name,
+            next_,
+            previous,
+            status,
+            ' | '.join(urls)
+        )
+        irc.reply(reply)
+        
+        # add a second line for details if requested
+        if show_detail:
+            reply = "{} | {} | {} | {}".format(
+                schedule,
+                runtime,
+                lang,
+                genres
+            )
+            irc.reply(reply)
+        
+    
+    @wrap([getopts({'all': '', 
+                    'tz': 'somethingWithoutSpaces',
+                    'network': 'somethingWithoutSpaces',
+                    'country': 'somethingWithoutSpaces',
+                    'date': 'somethingWithoutSpaces',
+                    'showEpisodeTitle': '',
+                    'debug': ''})])
+    def schedule(self, irc, msg, args, options):
+        """[--all | --tz  | --network  | --country  | --date ]
+        Fetches upcoming TV schedule from TVMaze.com.
+        """
+        # prefer manually passed options, then saved user options
+        # this merges the two possible dictionaries, prefering manually passed
+        # options if they already exist
+        user_options = self.db.get(msg.prefix) or dict()
+        options = {**user_options, **dict(options)}
+        
+        # parse manually passed options, if any
+        tz = options.get('tz') or 'US/Eastern'
+        country = options.get('country')
+        date = options.get('date')
+        # TO-DO: add a --filter option(s)
+        if country:
+            country = country.upper()
+            # if user isn't asking for a specific timezone,
+            # default to some sane ones given the country
+            if not options.get('tz'):
+                if country == 'GB':
+                    tz = 'GMT'
+                elif country == 'AU':
+                    tz = 'Australia/Sydney'
+                else:
+                    tz = 'US/Eastern'
+        else:
+            country = 'US'
+            # we don't need to default tz here because it's already set
+        
+        # parse date input
+        if date:
+            date = pendulum.parse(date, strict=False).format('YYYY-MM-DD')
+        else:
+            date = pendulum.now(tz).format('YYYY-MM-DD')
+        
+        # fetch the schedule
+        schedule_data = self._get('schedule', country=country, date=date)
+        
+        if not schedule_data:
+            irc.reply('Something went wrong fetching TVMaze schedule data.')
+            return
+        
+        # parse schedule
+        shows = []
+        for show in schedule_data:
+            tmp = "{show_name} [{ep}] ({show_time})"
+            # by default we show the episode title, there is a channel config option to disable this
+            # and users can override with --showEpisodeTitle flag
+            show_title = options.get('showEpisodeTitle') or self.registryValue('showEpisodeTitle', msg.args[0])
+            if show_title:
+                name = "{1}: {0}".format(show['name'], show['show']['name'])
+            else:
+                name = "{0}".format(show['show']['name'])
+            # try to build some season/episode information
+            try:
+                ep_id = "S{:02d}E{:02d}".format(show['season'], show['number'])
+            except:
+                ep_id = '?'
+            time = pendulum.parse(show['airstamp']).in_tz(tz)
+            # put it all together
+            tmp = tmp.format(show_name=self._bold(name), 
+                             ep=self._color(ep_id, 'orange'), 
+                             show_time=time.format('h:mm A zz'))
+            # depending on any options, append to list
+            if options.get('all'):
+                shows.append(tmp)
+            elif options.get('network'):
+                if show['show'].get('network'):
+                    if show['show']['network']['name'].lower() == options.get('network').lower():
+                        shows.append(tmp)
+            else:
+                # for now, defaults to only upcoming 'Scripted' shows
+                if show['show']['type'] == 'Scripted' and pendulum.now(tz) <= time:
+                    shows.append(tmp)
+                    
+        # set a default message if no shows were found
+        if not shows:
+            shows.append('No upcoming shows found')
+        
+        # finally reply
+        reply = "{}: {}".format(self._ul("Today's Shows"), ", ".join(shows))
+        if options.get('debug'):
+            #irc.reply(repr(reply))
+            print(repr(reply))
+        irc.reply(reply)
+        
+        
+    @wrap([getopts({'country': 'somethingWithoutSpaces',
+                    'tz': 'somethingWithoutSpaces',
+                    'showEpisodeTitle': 'boolean',
+                    'detail': 'boolean',
+                    'd': 'boolean',
+                    'clear': ''})])
+    def settvmazeoptions(self, irc, msg, args, options):
+        """--country  | --tz  | --showEpisodeTitle (True/False) | --detail/--d (True/False)
+        Allows user to set options for easier use of TVMaze commands.
+        Use --clear to reset all options.
+        """
+        if not options:
+            irc.reply('You must give me some options!')
+            return
+        
+        # prefer manually passed options, then saved user options
+        # this merges the two possible dictionaries, prefering manually passed
+        # options if they already exist
+        user_options = self.db.get(msg.prefix) or dict()
+        options = {**user_options, **dict(options)}
+        
+        if options.get('clear'):
+            self.db.set(msg.prefix, {})
+            irc.replySuccess()
+            return
+        
+        self.db.set(msg.prefix, options)
+        irc.replySuccess()
+        
+        
+        
+
+Class = TVMaze
+
+
+# vim:set shiftwidth=4 softtabstop=4 expandtab textwidth=79:
diff --git a/TVMaze/requirements.txt b/TVMaze/requirements.txt
new file mode 100644
index 0000000..b47cb15
--- /dev/null
+++ b/TVMaze/requirements.txt
@@ -0,0 +1,2 @@
+requests
+pendulum
\ No newline at end of file
diff --git a/TVMaze/test.py b/TVMaze/test.py
new file mode 100644
index 0000000..4ae931e
--- /dev/null
+++ b/TVMaze/test.py
@@ -0,0 +1,15 @@
+###
+# Copyright (c) 2019, cottongin
+# All rights reserved.
+#
+#
+###
+
+from supybot.test import *
+
+
+class TVMazeTestCase(PluginTestCase):
+    plugins = ('TVMaze',)
+
+
+# vim:set shiftwidth=4 tabstop=4 expandtab textwidth=79: