### # Copyright (c) 2019, cottongin # Copyright (c) 2020, oddluck # 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. ### import requests import pendulum import urllib.parse import json 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) data = json.loads(data.content) 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) data = json.loads(data.content) 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) data = json.loads(data.content) 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: