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: