[V3 Streams] Add support for Youtube streams (#1385)

This commit is contained in:
palmtree5 2018-03-14 15:07:14 -08:00 committed by Will
parent 052af2f9bf
commit fe3d6f57af
3 changed files with 210 additions and 67 deletions

View File

@ -14,7 +14,11 @@ class APIError(StreamsError):
pass
class InvalidCredentials(StreamsError):
class InvalidTwitchCredentials(StreamsError):
pass
class InvalidYoutubeCredentials(StreamsError):
pass

View File

@ -1,18 +1,23 @@
import discord
from discord.ext import commands
from redbot.core import Config, checks, RedContext
from redbot.core.utils.chat_formatting import pagify, box
from redbot.core.utils.chat_formatting import pagify
from redbot.core.bot import Red
from .streamtypes import TwitchStream, HitboxStream, MixerStream, PicartoStream, TwitchCommunity
from .errors import (OfflineStream, StreamNotFound, APIError, InvalidCredentials,
CommunityNotFound, OfflineCommunity, StreamsError)
from redbot.core.i18n import CogI18n
from .streamtypes import TwitchStream, HitboxStream, MixerStream, PicartoStream, TwitchCommunity, YoutubeStream
from .errors import (OfflineStream, StreamNotFound, APIError, InvalidYoutubeCredentials,
CommunityNotFound, OfflineCommunity, StreamsError, InvalidTwitchCredentials)
from . import streamtypes as StreamClasses
from collections import defaultdict
import asyncio
import re
CHECK_DELAY = 60
_ = CogI18n("Streams", __file__)
class Streams:
global_defaults = {
@ -44,6 +49,14 @@ class Streams:
self.bot.loop.create_task(self._initialize_lists())
self.yt_cid_pattern = re.compile("^UC[-_A-Za-z0-9]{21}[AQgw]$")
def check_name_or_id(self, data: str):
matched = self.yt_cid_pattern.fullmatch(data)
if matched is None:
return True
return False
async def _initialize_lists(self):
self.streams = await self.load_streams()
self.communities = await self.load_communities()
@ -58,6 +71,19 @@ class Streams:
token=token)
await self.check_online(ctx, stream)
@commands.command()
async def youtube(self, ctx, channel_id_or_name: str):
"""
Checks if a Youtube channel is streaming
"""
apikey = await self.db.tokens.get_raw(YoutubeStream.__name__, default=None)
is_name = self.check_name_or_id(channel_id_or_name)
if is_name:
stream = YoutubeStream(name=channel_id_or_name, token=apikey)
else:
stream = YoutubeStream(id=channel_id_or_name, token=apikey)
await self.check_online(ctx, stream)
@commands.command()
async def hitbox(self, ctx, channel_name: str):
"""Checks if a Hitbox channel is streaming"""
@ -80,15 +106,18 @@ class Streams:
try:
embed = await stream.is_online()
except OfflineStream:
await ctx.send("The stream is offline.")
await ctx.send(_("The stream is offline."))
except StreamNotFound:
await ctx.send("The channel doesn't seem to exist.")
except InvalidCredentials:
await ctx.send("The twitch token is either invalid or has not been set. "
"See `{}streamset twitchtoken`.".format(ctx.prefix))
await ctx.send(_("The channel doesn't seem to exist."))
except InvalidTwitchCredentials:
await ctx.send(_("The twitch token is either invalid or has not been set. "
"See `{}`.").format("{}streamset twitchtoken".format(ctx.prefix)))
except InvalidYoutubeCredentials:
await ctx.send(_("The Youtube API key is either invalid or has not been set. "
"See {}.").format("`{}streamset youtubekey`".format(ctx.prefix)))
except APIError:
await ctx.send("Something went wrong whilst trying to contact the "
"stream service's API.")
await ctx.send(_("Something went wrong whilst trying to contact the "
"stream service's API."))
else:
await ctx.send(embed=embed)
@ -116,6 +145,11 @@ class Streams:
for the specified community."""
await self.community_alert(ctx, TwitchCommunity, community.lower())
@streamalert.command(name="youtube")
async def youtube_alert(self, ctx: RedContext, channel_name_or_id: str):
"""Sets a Youtube stream alert notification in the channel"""
await self.stream_alert(ctx, YoutubeStream, channel_name_or_id)
@streamalert.command(name="hitbox")
async def hitbox_alert(self, ctx, channel_name: str):
"""Sets a Hitbox stream alert notification in the channel"""
@ -157,8 +191,8 @@ class Streams:
self.streams = streams
await self.save_streams()
msg = "All {}'s stream alerts have been disabled." \
"".format("server" if _all else "channel")
msg = _("All {}'s stream alerts have been disabled."
"").format("server" if _all else "channel")
await ctx.send(msg)
@ -166,7 +200,7 @@ class Streams:
async def streamalert_list(self, ctx):
streams_list = defaultdict(list)
guild_channels_ids = [c.id for c in ctx.guild.channels]
msg = "Active stream alerts:\n\n"
msg = _("Active stream alerts:\n\n")
for stream in self.streams:
for channel_id in stream.channels:
@ -174,7 +208,7 @@ class Streams:
streams_list[channel_id].append(stream.name.lower())
if not streams_list:
await ctx.send("There are no active stream alerts in this server.")
await ctx.send(_("There are no active stream alerts in this server."))
return
for channel_id, streams in streams_list.items():
@ -185,24 +219,34 @@ class Streams:
await ctx.send(page)
async def stream_alert(self, ctx, _class, channel_name):
stream = self.get_stream(_class, channel_name.lower())
stream = self.get_stream(_class, channel_name)
if not stream:
token = await self.db.tokens.get_raw(_class.__name__, default=None)
stream = _class(name=channel_name,
token=token)
is_yt = _class.__name__ == "YoutubeStream"
if is_yt and not self.check_name_or_id(channel_name):
stream = _class(id=channel_name, token=token)
else:
stream = _class(name=channel_name,
token=token)
try:
exists = await self.check_exists(stream)
except InvalidCredentials:
await ctx.send("The twitch token is either invalid or has not been set. "
"See `{}streamset twitchtoken`.".format(ctx.prefix))
except InvalidTwitchCredentials:
await ctx.send(
_("The twitch token is either invalid or has not been set. "
"See {}.").format("`{}streamset twitchtoken`".format(ctx.prefix)))
return
except InvalidYoutubeCredentials:
await ctx.send(_("The Youtube API key is either invalid or has not been set. "
"See {}.").format("`{}streamset youtubekey`".format(ctx.prefix)))
return
except APIError:
await ctx.send("Something went wrong whilst trying to contact the "
"stream service's API.")
await ctx.send(
_("Something went wrong whilst trying to contact the "
"stream service's API."))
return
else:
if not exists:
await ctx.send("That channel doesn't seem to exist.")
await ctx.send(_("That channel doesn't seem to exist."))
return
await self.add_or_remove(ctx, stream)
@ -214,18 +258,18 @@ class Streams:
community = _class(name=community_name, token=token)
try:
await community.get_community_streams()
except InvalidCredentials:
except InvalidTwitchCredentials:
await ctx.send(
"The twitch token is either invalid or has not been set. "
"See `{}streamset twitchtoken`.".format(ctx.prefix))
_("The twitch token is either invalid or has not been set. "
"See {}.").format("`{}streamset twitchtoken`".format(ctx.prefix)))
return
except CommunityNotFound:
await ctx.send("That community doesn't seem to exist.")
await ctx.send(_("That community doesn't seem to exist."))
return
except APIError:
await ctx.send(
"Something went wrong whilst trying to contact the "
"stream service's API.")
_("Something went wrong whilst trying to contact the "
"stream service's API."))
return
except OfflineCommunity:
pass
@ -252,11 +296,25 @@ class Streams:
5. Paste the Client ID into this command. Done!
"""
tokens = await self.db.tokens()
tokens["TwitchStream"] = token
tokens["TwitchCommunity"] = token
await self.db.tokens.set(tokens)
await ctx.send("Twitch token set.")
await self.db.tokens.set_raw("TwitchStream", value=token)
await self.db.tokens.set_raw("TwitchCommunity", value=token)
await ctx.send(_("Twitch token set."))
@streamset.command()
@checks.is_owner()
async def youtubekey(self, ctx: RedContext, key: str):
"""Sets the API key for Youtube.
To get one, do the following:
1. Create a project (see https://support.google.com/googleapi/answer/6251787 for details)
2. Enable the Youtube Data API v3 (see https://support.google.com/googleapi/answer/6158841 for instructions)
3. Set up your API key (see https://support.google.com/googleapi/answer/6158862 for instructions)
4. Copy your API key and paste it into this command. Done!
"""
await self.db.tokens.set_raw("YoutubeStream", value=key)
await ctx.send(_("Youtube key set."))
@streamset.group()
@commands.guild_only()
@ -273,12 +331,12 @@ class Streams:
current_setting = await self.db.guild(guild).mention_everyone()
if current_setting:
await self.db.guild(guild).mention_everyone.set(False)
await ctx.send("@\u200beveryone will no longer be mentioned "
"for a stream alert.")
await ctx.send(_("{} will no longer be mentioned "
"for a stream alert.").format("@\u200beveryone"))
else:
await self.db.guild(guild).mention_everyone.set(True)
await ctx.send("When a stream configured for stream alerts "
"comes online, @\u200beveryone will be mentioned")
await ctx.send(_("When a stream configured for stream alerts "
"comes online, {} will be mentioned").format("@\u200beveryone"))
@mention.command(aliases=["here"])
@commands.guild_only()
@ -288,12 +346,12 @@ class Streams:
current_setting = await self.db.guild(guild).mention_here()
if current_setting:
await self.db.guild(guild).mention_here.set(False)
await ctx.send("@\u200bhere will no longer be mentioned "
"for a stream alert.")
await ctx.send(_("{} will no longer be mentioned "
"for a stream alert.").format("@\u200bhere"))
else:
await self.db.guild(guild).mention_here.set(True)
await ctx.send("When a stream configured for stream alerts "
"comes online, @\u200bhere will be mentioned")
await ctx.send(_("When a stream configured for stream alerts "
"comes online, {} will be mentioned").format("@\u200bhere"))
@mention.command()
@commands.guild_only()
@ -305,13 +363,13 @@ class Streams:
return
if current_setting:
await self.db.role(role).mention.set(False)
await ctx.send("@\u200b{} will no longer be mentioned "
"for a stream alert".format(role.name))
await ctx.send(_("{} will no longer be mentioned "
"for a stream alert").format("@\u200b{}".format(role.name)))
else:
await self.db.role(role).mention.set(True)
await ctx.send("When a stream configured for stream alerts "
"comes online, @\u200b{} will be mentioned"
"".format(role.name))
await ctx.send(_("When a stream configured for stream alerts "
"comes online, {} will be mentioned"
"").format("@\u200b{}".format(role.name)))
@streamset.command()
@commands.guild_only()
@ -329,14 +387,14 @@ class Streams:
stream.channels.append(ctx.channel.id)
if stream not in self.streams:
self.streams.append(stream)
await ctx.send("I'll send a notification in this channel when {} "
"is online.".format(stream.name))
await ctx.send(_("I'll send a notification in this channel when {} "
"is online.").format(stream.name))
else:
stream.channels.remove(ctx.channel.id)
if not stream.channels:
self.streams.remove(stream)
await ctx.send("I won't send notifications about {} in this "
"channel anymore.".format(stream.name))
await ctx.send(_("I won't send notifications about {} in this "
"channel anymore.").format(stream.name))
await self.save_streams()
@ -345,16 +403,16 @@ class Streams:
community.channels.append(ctx.channel.id)
if community not in self.communities:
self.communities.append(community)
await ctx.send("I'll send a notification in this channel when a "
"channel is streaming to the {} community"
"".format(community.name))
await ctx.send(_("I'll send a notification in this channel when a "
"channel is streaming to the {} community"
"").format(community.name))
else:
community.channels.remove(ctx.channel.id)
if not community.channels:
self.communities.remove(community)
await ctx.send("I won't send notifications about channels streaming "
"to the {} community in this channel anymore"
"".format(community.name))
await ctx.send(_("I won't send notifications about channels streaming "
"to the {} community in this channel anymore"
"").format(community.name))
await self.save_communities()
def get_stream(self, _class, name):
@ -365,6 +423,12 @@ class Streams:
# isinstance will always return False
# As a workaround, we'll compare the class' name instead.
# Good enough.
if _class.__name__ == "YoutubeStream" and stream.type == _class.__name__:
# Because name could be a username or a channel id
if self.check_name_or_id(name) and stream.name.lower() == name.lower():
return stream
elif not self.check_name_or_id(name) and stream.id == name:
return stream
if stream.type == _class.__name__ and stream.name.lower() == name.lower():
return stream
@ -446,7 +510,7 @@ class Streams:
try:
streams = community.get_community_streams()
except CommunityNotFound:
print("Community {} not found!".format(community.name))
print(_("Community {} not found!").format(community.name))
continue
except OfflineCommunity:
pass
@ -454,7 +518,7 @@ class Streams:
token = self.db.tokens().get(TwitchStream.__name__)
for channel in community.channels:
chn = self.bot.get_channel(channel)
await chn.send("Online streams for {}".format(community.name))
await chn.send(_("Online streams for {}").format(community.name))
for stream in streams:
stream_obj = TwitchStream(
token=token, name=stream["channel"]["name"],

View File

@ -1,4 +1,5 @@
from .errors import StreamNotFound, APIError, InvalidCredentials, OfflineStream, CommunityNotFound, OfflineCommunity
from .errors import StreamNotFound, APIError, OfflineStream, CommunityNotFound, OfflineCommunity, \
InvalidYoutubeCredentials, InvalidTwitchCredentials
from random import choice
from string import ascii_letters
import discord
@ -10,6 +11,11 @@ TWITCH_ID_ENDPOINT = TWITCH_BASE_URL + "/kraken/users?login="
TWITCH_STREAMS_ENDPOINT = TWITCH_BASE_URL + "/kraken/streams/"
TWITCH_COMMUNITIES_ENDPOINT = TWITCH_BASE_URL + "/kraken/communities"
YOUTUBE_BASE_URL = "https://www.googleapis.com/youtube/v3"
YOUTUBE_CHANNELS_ENDPOINT = YOUTUBE_BASE_URL + "/channels"
YOUTUBE_SEARCH_ENDPOINT = YOUTUBE_BASE_URL + "/search"
YOUTUBE_VIDEOS_ENDPOINT = YOUTUBE_BASE_URL + "/videos"
def rnd(url):
"""Appends a random parameter to the url to avoid Discord's caching"""
@ -38,7 +44,7 @@ class TwitchCommunity:
if r.status == 200:
return data["_id"]
elif r.status == 400:
raise InvalidCredentials()
raise InvalidTwitchCredentials()
elif r.status == 404:
raise CommunityNotFound()
else:
@ -67,7 +73,7 @@ class TwitchCommunity:
else:
return data["streams"]
elif r.status == 400:
raise InvalidCredentials()
raise InvalidTwitchCredentials()
elif r.status == 404:
raise CommunityNotFound()
else:
@ -86,7 +92,7 @@ class TwitchCommunity:
class Stream:
def __init__(self, **kwargs):
self.name = kwargs.pop("name")
self.name = kwargs.pop("name", None)
self.channels = kwargs.pop("channels", [])
#self.already_online = kwargs.pop("already_online", False)
self._messages_cache = []
@ -109,6 +115,75 @@ class Stream:
return "<{0.__class__.__name__}: {0.name}>".format(self)
class YoutubeStream(Stream):
def __init__(self, **kwargs):
self.id = kwargs.pop("id", None)
self._token = kwargs.pop("token", None)
super().__init__(**kwargs)
async def is_online(self):
if not self.id:
self.id = await self.fetch_id()
url = YOUTUBE_SEARCH_ENDPOINT
params = {
"key": self._token,
"part": "snippet",
"channelId": self.id,
"type": "video",
"eventType": "live"
}
async with aiohttp.ClientSession() as session:
async with session.get(url, params=params) as r:
data = await r.json()
if "items" in data and len(data["items"]) == 0:
raise OfflineStream()
elif "items" in data:
vid_id = data["items"][0]["id"]["videoId"]
params = {
"key": self._token,
"id": vid_id,
"part": "snippet"
}
async with aiohttp.ClientSession() as session:
async with session.get(YOUTUBE_VIDEOS_ENDPOINT, params=params) as r:
data = await r.json()
return self.make_embed(data)
def make_embed(self, data):
vid_data = data["items"][0]
video_url = "https://youtube.com/watch?v={}".format(vid_data["id"])
title = vid_data["snippet"]["title"]
thumbnail = vid_data["snippet"]["thumbnails"]["default"]["url"]
channel_title = data["snippet"]["channelTitle"]
embed = discord.Embed(title=title, url=video_url)
embed.set_author(name=channel_title)
embed.set_image(url=rnd(thumbnail))
embed.colour = 0x9255A5
return embed
async def fetch_id(self):
params = {
"key": self._token,
"forUsername": self.name,
"part": "id"
}
async with aiohttp.ClientSession() as session:
async with session.get(YOUTUBE_CHANNELS_ENDPOINT, params=params) as r:
data = await r.json()
if "error" in data and data["error"]["code"] == 400 and\
data["error"]["errors"][0]["reason"] == "keyInvalid":
raise InvalidYoutubeCredentials()
elif "items" in data and len(data["items"]) == 0:
raise StreamNotFound()
elif "items" in data:
return data["items"][0]["id"]
raise APIError()
def __repr__(self):
return "<{0.__class__.__name__}: {0.name} (ID: {0.id})>".format(self)
class TwitchStream(Stream):
def __init__(self, **kwargs):
self.id = kwargs.pop("id", None)
@ -137,7 +212,7 @@ class TwitchStream(Stream):
self.name = data["stream"]["channel"]["name"]
return self.make_embed(data)
elif r.status == 400:
raise InvalidCredentials()
raise InvalidTwitchCredentials()
elif r.status == 404:
raise StreamNotFound()
else:
@ -159,7 +234,7 @@ class TwitchStream(Stream):
raise StreamNotFound()
return data["users"][0]["_id"]
elif r.status == 400:
raise InvalidCredentials()
raise InvalidTwitchCredentials()
else:
raise APIError()