[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 pass
class InvalidCredentials(StreamsError): class InvalidTwitchCredentials(StreamsError):
pass
class InvalidYoutubeCredentials(StreamsError):
pass pass

View File

@ -1,18 +1,23 @@
import discord import discord
from discord.ext import commands from discord.ext import commands
from redbot.core import Config, checks, RedContext 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 redbot.core.bot import Red
from .streamtypes import TwitchStream, HitboxStream, MixerStream, PicartoStream, TwitchCommunity from redbot.core.i18n import CogI18n
from .errors import (OfflineStream, StreamNotFound, APIError, InvalidCredentials, from .streamtypes import TwitchStream, HitboxStream, MixerStream, PicartoStream, TwitchCommunity, YoutubeStream
CommunityNotFound, OfflineCommunity, StreamsError) from .errors import (OfflineStream, StreamNotFound, APIError, InvalidYoutubeCredentials,
CommunityNotFound, OfflineCommunity, StreamsError, InvalidTwitchCredentials)
from . import streamtypes as StreamClasses from . import streamtypes as StreamClasses
from collections import defaultdict from collections import defaultdict
import asyncio import asyncio
import re
CHECK_DELAY = 60 CHECK_DELAY = 60
_ = CogI18n("Streams", __file__)
class Streams: class Streams:
global_defaults = { global_defaults = {
@ -44,6 +49,14 @@ class Streams:
self.bot.loop.create_task(self._initialize_lists()) 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): async def _initialize_lists(self):
self.streams = await self.load_streams() self.streams = await self.load_streams()
self.communities = await self.load_communities() self.communities = await self.load_communities()
@ -58,6 +71,19 @@ class Streams:
token=token) token=token)
await self.check_online(ctx, stream) 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() @commands.command()
async def hitbox(self, ctx, channel_name: str): async def hitbox(self, ctx, channel_name: str):
"""Checks if a Hitbox channel is streaming""" """Checks if a Hitbox channel is streaming"""
@ -80,15 +106,18 @@ class Streams:
try: try:
embed = await stream.is_online() embed = await stream.is_online()
except OfflineStream: except OfflineStream:
await ctx.send("The stream is offline.") await ctx.send(_("The stream is offline."))
except StreamNotFound: except StreamNotFound:
await ctx.send("The channel doesn't seem to exist.") await ctx.send(_("The channel doesn't seem to exist."))
except InvalidCredentials: except InvalidTwitchCredentials:
await ctx.send("The twitch token is either invalid or has not been set. " await ctx.send(_("The twitch token is either invalid or has not been set. "
"See `{}streamset twitchtoken`.".format(ctx.prefix)) "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: except APIError:
await ctx.send("Something went wrong whilst trying to contact the " await ctx.send(_("Something went wrong whilst trying to contact the "
"stream service's API.") "stream service's API."))
else: else:
await ctx.send(embed=embed) await ctx.send(embed=embed)
@ -116,6 +145,11 @@ class Streams:
for the specified community.""" for the specified community."""
await self.community_alert(ctx, TwitchCommunity, community.lower()) 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") @streamalert.command(name="hitbox")
async def hitbox_alert(self, ctx, channel_name: str): async def hitbox_alert(self, ctx, channel_name: str):
"""Sets a Hitbox stream alert notification in the channel""" """Sets a Hitbox stream alert notification in the channel"""
@ -157,8 +191,8 @@ class Streams:
self.streams = streams self.streams = streams
await self.save_streams() await self.save_streams()
msg = "All {}'s stream alerts have been disabled." \ msg = _("All {}'s stream alerts have been disabled."
"".format("server" if _all else "channel") "").format("server" if _all else "channel")
await ctx.send(msg) await ctx.send(msg)
@ -166,7 +200,7 @@ class Streams:
async def streamalert_list(self, ctx): async def streamalert_list(self, ctx):
streams_list = defaultdict(list) streams_list = defaultdict(list)
guild_channels_ids = [c.id for c in ctx.guild.channels] 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 stream in self.streams:
for channel_id in stream.channels: for channel_id in stream.channels:
@ -174,7 +208,7 @@ class Streams:
streams_list[channel_id].append(stream.name.lower()) streams_list[channel_id].append(stream.name.lower())
if not streams_list: 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 return
for channel_id, streams in streams_list.items(): for channel_id, streams in streams_list.items():
@ -185,24 +219,34 @@ class Streams:
await ctx.send(page) await ctx.send(page)
async def stream_alert(self, ctx, _class, channel_name): 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: if not stream:
token = await self.db.tokens.get_raw(_class.__name__, default=None) token = await self.db.tokens.get_raw(_class.__name__, default=None)
stream = _class(name=channel_name, is_yt = _class.__name__ == "YoutubeStream"
token=token) 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: try:
exists = await self.check_exists(stream) exists = await self.check_exists(stream)
except InvalidCredentials: except InvalidTwitchCredentials:
await ctx.send("The twitch token is either invalid or has not been set. " await ctx.send(
"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 InvalidYoutubeCredentials:
await ctx.send(_("The Youtube API key is either invalid or has not been set. "
"See {}.").format("`{}streamset youtubekey`".format(ctx.prefix)))
return return
except APIError: except APIError:
await ctx.send("Something went wrong whilst trying to contact the " await ctx.send(
"stream service's API.") _("Something went wrong whilst trying to contact the "
"stream service's API."))
return return
else: else:
if not exists: if not exists:
await ctx.send("That channel doesn't seem to exist.") await ctx.send(_("That channel doesn't seem to exist."))
return return
await self.add_or_remove(ctx, stream) await self.add_or_remove(ctx, stream)
@ -214,18 +258,18 @@ class Streams:
community = _class(name=community_name, token=token) community = _class(name=community_name, token=token)
try: try:
await community.get_community_streams() await community.get_community_streams()
except InvalidCredentials: except InvalidTwitchCredentials:
await ctx.send( await ctx.send(
"The twitch token is either invalid or has not been set. " _("The twitch token is either invalid or has not been set. "
"See `{}streamset twitchtoken`.".format(ctx.prefix)) "See {}.").format("`{}streamset twitchtoken`".format(ctx.prefix)))
return return
except CommunityNotFound: except CommunityNotFound:
await ctx.send("That community doesn't seem to exist.") await ctx.send(_("That community doesn't seem to exist."))
return return
except APIError: except APIError:
await ctx.send( await ctx.send(
"Something went wrong whilst trying to contact the " _("Something went wrong whilst trying to contact the "
"stream service's API.") "stream service's API."))
return return
except OfflineCommunity: except OfflineCommunity:
pass pass
@ -252,11 +296,25 @@ class Streams:
5. Paste the Client ID into this command. Done! 5. Paste the Client ID into this command. Done!
""" """
tokens = await self.db.tokens() await self.db.tokens.set_raw("TwitchStream", value=token)
tokens["TwitchStream"] = token await self.db.tokens.set_raw("TwitchCommunity", value=token)
tokens["TwitchCommunity"] = token await ctx.send(_("Twitch token set."))
await self.db.tokens.set(tokens)
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() @streamset.group()
@commands.guild_only() @commands.guild_only()
@ -273,12 +331,12 @@ class Streams:
current_setting = await self.db.guild(guild).mention_everyone() current_setting = await self.db.guild(guild).mention_everyone()
if current_setting: if current_setting:
await self.db.guild(guild).mention_everyone.set(False) await self.db.guild(guild).mention_everyone.set(False)
await ctx.send("@\u200beveryone will no longer be mentioned " await ctx.send(_("{} will no longer be mentioned "
"for a stream alert.") "for a stream alert.").format("@\u200beveryone"))
else: else:
await self.db.guild(guild).mention_everyone.set(True) await self.db.guild(guild).mention_everyone.set(True)
await ctx.send("When a stream configured for stream alerts " await ctx.send(_("When a stream configured for stream alerts "
"comes online, @\u200beveryone will be mentioned") "comes online, {} will be mentioned").format("@\u200beveryone"))
@mention.command(aliases=["here"]) @mention.command(aliases=["here"])
@commands.guild_only() @commands.guild_only()
@ -288,12 +346,12 @@ class Streams:
current_setting = await self.db.guild(guild).mention_here() current_setting = await self.db.guild(guild).mention_here()
if current_setting: if current_setting:
await self.db.guild(guild).mention_here.set(False) await self.db.guild(guild).mention_here.set(False)
await ctx.send("@\u200bhere will no longer be mentioned " await ctx.send(_("{} will no longer be mentioned "
"for a stream alert.") "for a stream alert.").format("@\u200bhere"))
else: else:
await self.db.guild(guild).mention_here.set(True) await self.db.guild(guild).mention_here.set(True)
await ctx.send("When a stream configured for stream alerts " await ctx.send(_("When a stream configured for stream alerts "
"comes online, @\u200bhere will be mentioned") "comes online, {} will be mentioned").format("@\u200bhere"))
@mention.command() @mention.command()
@commands.guild_only() @commands.guild_only()
@ -305,13 +363,13 @@ class Streams:
return return
if current_setting: if current_setting:
await self.db.role(role).mention.set(False) await self.db.role(role).mention.set(False)
await ctx.send("@\u200b{} will no longer be mentioned " await ctx.send(_("{} will no longer be mentioned "
"for a stream alert".format(role.name)) "for a stream alert").format("@\u200b{}".format(role.name)))
else: else:
await self.db.role(role).mention.set(True) await self.db.role(role).mention.set(True)
await ctx.send("When a stream configured for stream alerts " await ctx.send(_("When a stream configured for stream alerts "
"comes online, @\u200b{} will be mentioned" "comes online, {} will be mentioned"
"".format(role.name)) "").format("@\u200b{}".format(role.name)))
@streamset.command() @streamset.command()
@commands.guild_only() @commands.guild_only()
@ -329,14 +387,14 @@ class Streams:
stream.channels.append(ctx.channel.id) stream.channels.append(ctx.channel.id)
if stream not in self.streams: if stream not in self.streams:
self.streams.append(stream) self.streams.append(stream)
await ctx.send("I'll send a notification in this channel when {} " await ctx.send(_("I'll send a notification in this channel when {} "
"is online.".format(stream.name)) "is online.").format(stream.name))
else: else:
stream.channels.remove(ctx.channel.id) stream.channels.remove(ctx.channel.id)
if not stream.channels: if not stream.channels:
self.streams.remove(stream) self.streams.remove(stream)
await ctx.send("I won't send notifications about {} in this " await ctx.send(_("I won't send notifications about {} in this "
"channel anymore.".format(stream.name)) "channel anymore.").format(stream.name))
await self.save_streams() await self.save_streams()
@ -345,16 +403,16 @@ class Streams:
community.channels.append(ctx.channel.id) community.channels.append(ctx.channel.id)
if community not in self.communities: if community not in self.communities:
self.communities.append(community) self.communities.append(community)
await ctx.send("I'll send a notification in this channel when a " await ctx.send(_("I'll send a notification in this channel when a "
"channel is streaming to the {} community" "channel is streaming to the {} community"
"".format(community.name)) "").format(community.name))
else: else:
community.channels.remove(ctx.channel.id) community.channels.remove(ctx.channel.id)
if not community.channels: if not community.channels:
self.communities.remove(community) self.communities.remove(community)
await ctx.send("I won't send notifications about channels streaming " await ctx.send(_("I won't send notifications about channels streaming "
"to the {} community in this channel anymore" "to the {} community in this channel anymore"
"".format(community.name)) "").format(community.name))
await self.save_communities() await self.save_communities()
def get_stream(self, _class, name): def get_stream(self, _class, name):
@ -365,6 +423,12 @@ class Streams:
# isinstance will always return False # isinstance will always return False
# As a workaround, we'll compare the class' name instead. # As a workaround, we'll compare the class' name instead.
# Good enough. # 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(): if stream.type == _class.__name__ and stream.name.lower() == name.lower():
return stream return stream
@ -446,7 +510,7 @@ class Streams:
try: try:
streams = community.get_community_streams() streams = community.get_community_streams()
except CommunityNotFound: except CommunityNotFound:
print("Community {} not found!".format(community.name)) print(_("Community {} not found!").format(community.name))
continue continue
except OfflineCommunity: except OfflineCommunity:
pass pass
@ -454,7 +518,7 @@ class Streams:
token = self.db.tokens().get(TwitchStream.__name__) token = self.db.tokens().get(TwitchStream.__name__)
for channel in community.channels: for channel in community.channels:
chn = self.bot.get_channel(channel) 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: for stream in streams:
stream_obj = TwitchStream( stream_obj = TwitchStream(
token=token, name=stream["channel"]["name"], 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 random import choice
from string import ascii_letters from string import ascii_letters
import discord import discord
@ -10,6 +11,11 @@ TWITCH_ID_ENDPOINT = TWITCH_BASE_URL + "/kraken/users?login="
TWITCH_STREAMS_ENDPOINT = TWITCH_BASE_URL + "/kraken/streams/" TWITCH_STREAMS_ENDPOINT = TWITCH_BASE_URL + "/kraken/streams/"
TWITCH_COMMUNITIES_ENDPOINT = TWITCH_BASE_URL + "/kraken/communities" 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): def rnd(url):
"""Appends a random parameter to the url to avoid Discord's caching""" """Appends a random parameter to the url to avoid Discord's caching"""
@ -38,7 +44,7 @@ class TwitchCommunity:
if r.status == 200: if r.status == 200:
return data["_id"] return data["_id"]
elif r.status == 400: elif r.status == 400:
raise InvalidCredentials() raise InvalidTwitchCredentials()
elif r.status == 404: elif r.status == 404:
raise CommunityNotFound() raise CommunityNotFound()
else: else:
@ -67,7 +73,7 @@ class TwitchCommunity:
else: else:
return data["streams"] return data["streams"]
elif r.status == 400: elif r.status == 400:
raise InvalidCredentials() raise InvalidTwitchCredentials()
elif r.status == 404: elif r.status == 404:
raise CommunityNotFound() raise CommunityNotFound()
else: else:
@ -86,7 +92,7 @@ class TwitchCommunity:
class Stream: class Stream:
def __init__(self, **kwargs): def __init__(self, **kwargs):
self.name = kwargs.pop("name") self.name = kwargs.pop("name", None)
self.channels = kwargs.pop("channels", []) self.channels = kwargs.pop("channels", [])
#self.already_online = kwargs.pop("already_online", False) #self.already_online = kwargs.pop("already_online", False)
self._messages_cache = [] self._messages_cache = []
@ -109,6 +115,75 @@ class Stream:
return "<{0.__class__.__name__}: {0.name}>".format(self) 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): class TwitchStream(Stream):
def __init__(self, **kwargs): def __init__(self, **kwargs):
self.id = kwargs.pop("id", None) self.id = kwargs.pop("id", None)
@ -137,7 +212,7 @@ class TwitchStream(Stream):
self.name = data["stream"]["channel"]["name"] self.name = data["stream"]["channel"]["name"]
return self.make_embed(data) return self.make_embed(data)
elif r.status == 400: elif r.status == 400:
raise InvalidCredentials() raise InvalidTwitchCredentials()
elif r.status == 404: elif r.status == 404:
raise StreamNotFound() raise StreamNotFound()
else: else:
@ -159,7 +234,7 @@ class TwitchStream(Stream):
raise StreamNotFound() raise StreamNotFound()
return data["users"][0]["_id"] return data["users"][0]["_id"]
elif r.status == 400: elif r.status == 400:
raise InvalidCredentials() raise InvalidTwitchCredentials()
else: else:
raise APIError() raise APIError()