Add youtube endpoint

This commit is contained in:
Mattéo Delabre 2021-09-13 00:00:24 +02:00
parent 33d10c20a1
commit 527077fab4
Signed by: matteo
GPG Key ID: AE3FBD02DC583ABB
8 changed files with 260 additions and 40 deletions

View File

@ -3,12 +3,13 @@ import logging
import sys import sys
from os import environ from os import environ
from flask import Flask from flask import Flask
from . import twitch from . import twitch, youtube
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
blueprints = { blueprints = {
"twitch": twitch, "twitch": twitch,
"youtube": youtube,
} }

View File

@ -9,7 +9,7 @@ def create_blueprint(config):
twitch = Blueprint("twitch", __name__) twitch = Blueprint("twitch", __name__)
@twitch.route("/<string:login>", methods=["GET", "HEAD"]) @twitch.route("/<string:login>", methods=["GET", "HEAD"])
def vod(login: str): def get(login: str):
try: try:
return ( return (
construct_rss(client, login), construct_rss(client, login),

View File

@ -1,12 +1,8 @@
from datetime import datetime
from ..feedformatter import Feed from ..feedformatter import Feed
from ..util import parse_iso
from .twitch import APIClient from .twitch import APIClient
def parse_iso(iso: str) -> datetime:
return datetime.fromisoformat(iso.replace("Z", "+00:00"))
def construct_rss(client: APIClient, login: str) -> str: def construct_rss(client: APIClient, login: str) -> str:
""" """
Build a RSS stream for a Twitch user. Build a RSS stream for a Twitch user.
@ -28,9 +24,9 @@ def construct_rss(client: APIClient, login: str) -> str:
# Set the feed/channel level properties # Set the feed/channel level properties
feed.feed["title"] = user_info["display_name"] feed.feed["title"] = user_info["display_name"]
feed.feed["link"] = user_url feed.feed["link"] = user_url
feed.feed["author"] = "Twitch RSS Generated" feed.feed["author"] = "Feedleware"
feed.feed["description"] = user_info["description"] feed.feed["description"] = user_info["description"]
feed.feed["ttl"] = '10' feed.feed["ttl"] = "10"
if stream is not None: if stream is not None:
item = {} item = {}

View File

@ -8,6 +8,7 @@ import urllib
import urllib.request import urllib.request
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
from cachetools import cached, TTLCache from cachetools import cached, TTLCache
from ..util import send_with_retry
HTTPRequest = urllib.request.Request HTTPRequest = urllib.request.Request
@ -43,40 +44,17 @@ class APIClient:
""" """
self.client_id: str = client_id self.client_id: str = client_id
self.secret: str = secret self.secret: str = secret
self.retries: int = 3 self.retries = retries
self.oauth_token: str = "" self.oauth_token: str = ""
self.oauth_expire_epoch: int = 0 self.oauth_expire_epoch: int = 0
def _send_with_retry(self, request: HTTPRequest) -> HTTPResponse:
"""
Send an HTTP request and retry in case of failure.
The number of retries is configured by `self.retries`.
:param request: request to try sending
:returns: response sent by the server
:throws HTTPException: if the number of retries is exceeded
"""
retries = self.retries
last_err = HTTPException()
while retries:
try:
return urllib.request.urlopen(request, timeout=3)
except HTTPException as err:
logger.warning("HTTP error: %s", err)
retries -= 1
last_err = err
raise last_err
def authorize(self) -> bool: def authorize(self) -> bool:
""" """
Refresh the current OAuth app access token if needed. Refresh the current OAuth app access token if needed.
This uses the Twitch OAuth client credentials flow. See: This uses the Twitch OAuth client credentials flow. See
<https://dev.twitch.tv/docs/authentication/getting-tokens-oauth/#oauth-client-credentials-flow> <https://dev.twitch.tv/docs/authentication/getting-tokens-oauth/#oauth-client-credentials-flow>
for details.
:returns: true if the OAuth app access token was refreshed, :returns: true if the OAuth app access token was refreshed,
false if the existing one was still valid false if the existing one was still valid
@ -99,7 +77,7 @@ class APIClient:
method="POST", method="POST",
) )
http_response = self._send_with_retry(request) http_response = send_with_retry(request, self.retries)
response = json.loads(http_response.read().decode("utf-8")) response = json.loads(http_response.read().decode("utf-8"))
self.oauth_token = response["access_token"] self.oauth_token = response["access_token"]
@ -138,7 +116,7 @@ class APIClient:
method=method, method=method,
) )
http_response = self._send_with_retry(request) http_response = send_with_retry(request, self.retries)
if http_response.info().get("Content-Encoding") == "gzip": if http_response.info().get("Content-Encoding") == "gzip":
return json.loads(gzip.decompress(http_response.read())) return json.loads(gzip.decompress(http_response.read()))
@ -150,6 +128,8 @@ class APIClient:
""" """
Get information about a user. Get information about a user.
See <https://dev.twitch.tv/docs/api/reference#get-users> for details.
:param login: user login :param login: user login
:returns: user information :returns: user information
:throws HTTPException: if the query fails :throws HTTPException: if the query fails
@ -163,13 +143,10 @@ class APIClient:
method="GET", method="GET",
data={"login": login}, data={"login": login},
) )
assert type(response) == dict
if "data" not in response or not response["data"]: if "data" not in response or not response["data"]:
raise NoSuchUser(f"User '{login}' does not exist") raise NoSuchUser(f"User '{login}' does not exist")
assert type(response["data"]) == list
assert type(response["data"][0]) == dict
return response["data"][0] return response["data"][0]
@cached(cache=TTLCache(maxsize=1000, ttl=10 * 60)) @cached(cache=TTLCache(maxsize=1000, ttl=10 * 60))
@ -177,6 +154,8 @@ class APIClient:
""" """
Get the list of videos from a channel. Get the list of videos from a channel.
See <https://dev.twitch.tv/docs/api/reference#get-videos> for details.
:param channel_id: channel ID :param channel_id: channel ID
:returns: list of videos :returns: list of videos
:throws HTTPException: if the query fails :throws HTTPException: if the query fails
@ -198,6 +177,8 @@ class APIClient:
""" """
Get the information about the stream currently active on a channel. Get the information about the stream currently active on a channel.
See <https://dev.twitch.tv/docs/api/reference#get-streams> for details.
:param channel_id: channel ID :param channel_id: channel ID
:returns: stream information or None :returns: stream information or None
:throws HTTPException: if the query fails :throws HTTPException: if the query fails

33
feedleware/util.py Normal file
View File

@ -0,0 +1,33 @@
from datetime import datetime
import http
import urllib
import urllib.request
HTTPRequest = urllib.request.Request
HTTPResponse = http.client.HTTPResponse
HTTPException = http.client.HTTPException
def parse_iso(iso: str) -> datetime:
return datetime.fromisoformat(iso.replace("Z", "+00:00"))
def send_with_retry(request: HTTPRequest, retries: int) -> HTTPResponse:
"""
Send an HTTP request and retry in case of failure.
:param request: request to try sending
:param retries: number of retries
:returns: response sent by the server
:throws HTTPException: if the number of retries is exceeded
"""
last_err = HTTPException()
while retries:
try:
return urllib.request.urlopen(request, timeout=3)
except HTTPException as err:
retries -= 1
last_err = err
raise last_err

View File

@ -0,0 +1,21 @@
from flask import abort, Blueprint
from .youtube import APIClient, NoSuchChannel
from .feed import construct_rss
def create_blueprint(config):
"""Create a YouTube endpoint blueprint."""
client = APIClient(config["key"])
twitch = Blueprint("youtube", __name__)
@twitch.route("/<string:login>", methods=["GET", "HEAD"])
def vod(login: str):
try:
return (
construct_rss(client, login),
{"Content-Type": "application/rss+xml"},
)
except NoSuchChannel:
abort(404)
return twitch

View File

@ -0,0 +1,47 @@
from ..feedformatter import Feed
from ..util import parse_iso
from .youtube import APIClient
def construct_rss(client: APIClient, channel_id: str) -> str:
"""
Build a RSS stream for a YouTube channel.
:param client: YouTube API client
:param channel_id: channel ID
:returns: RSS stream
:raises HTTPException: if one of the requests fail
:raises NoSuchChannel: if the channel does not exist
"""
channel_info = client.channel(channel_id)
videos = client.playlist(channel_info["playlist"])
feed = Feed()
channel_url = f"https://www.youtube.com/channel/{channel_id}"
# Set the feed/channel level properties
feed.feed["title"] = channel_info["title"]
feed.feed["link"] = channel_url
feed.feed["author"] = "Feedleware"
feed.feed["description"] = channel_info["description"]
feed.feed["ttl"] = "30"
for video in videos:
item = {}
video_id = video["resourceId"]["videoId"]
link = f"https://www.youtube.com/watch?v={video_id}"
thumbnail = video["thumbnails"]["standard"]["url"]
item["guid"] = video["resourceId"]["videoId"]
item["title"] = video.get("title", "Untitled Video")
item["link"] = link
item["description"] = (
f'<a href="{link}"><img src="{thumbnail}" /></a><br><br>'
+ video["description"]
)
item["pubDate"] = parse_iso(video["publishedAt"]).timetuple()
feed.items.append(item)
return feed.format_rss2_string()

View File

@ -0,0 +1,141 @@
import gzip
import http
import json
import logging
import urllib
import urllib.request
from typing import Any, Iterable, Optional, Tuple
from cachetools import cached, TTLCache
from ..util import send_with_retry
HTTPRequest = urllib.request.Request
HTTPResponse = http.client.HTTPResponse
HTTPException = http.client.HTTPException
logger = logging.getLogger(__name__)
class NoSuchChannel(Exception):
"""Raised when an unknown channel is queried."""
class APIClient:
"""Client for the YouTube Data API."""
def __init__(self, key: str = "", retries: int = 3):
"""
Create a YouTube Data API client.
See <https://developers.google.com/youtube/v3/docs> for details.
:param key: YouTube API key
:param retries: number of times to retry each request in case of failure
"""
self.key = key
self.retries = retries
def _query(
self,
url: str,
method: str = "GET",
data: Iterable[Tuple[str, str]] = []
) -> Any:
"""
Low-level method to query the API.
:param url: URL to query
:param method: HTTP method to use
:param data: payload dictionary to send
:returns: JSON data
:throws HTTPException: if the query fails
"""
logger.debug("Querying %s %s %s", method, url, data)
headers = {
"Accept": "application/json",
"Accept-Encoding": "gzip",
}
payload = (
*data,
("key", self.key),
)
request = HTTPRequest(
url=f"{url}?{urllib.parse.urlencode(payload)}",
headers=headers,
method=method,
)
http_response = send_with_retry(request, self.retries)
if http_response.info().get("Content-Encoding") == "gzip":
return json.loads(gzip.decompress(http_response.read()))
else:
return json.loads(http_response.read())
@cached(cache=TTLCache(maxsize=1000, ttl=24 * 60 * 60))
def channel(self, channel_id: str) -> Any:
"""
Get information about a channel.
See <https://developers.google.com/youtube/v3/docs/channels>
for details.
:param channel_id: channel ID
:returns: channel information
:throws HTTPException: if the query fails
:throws NoSuchChannel: if the channel doesnt exist
"""
response = self._query(
url="https://youtube.googleapis.com/youtube/v3/channels",
method="GET",
data=(
("part", "id"),
("part", "snippet"),
("part", "contentDetails"),
("id", channel_id),
("maxResults", 1),
)
)
if response["pageInfo"]["totalResults"] == 0:
raise NoSuchChannel(f"Channel '{channel_id}' does not exist")
data = response["items"][0]
return {
"id": data["id"],
"playlist": data["contentDetails"]["relatedPlaylists"]["uploads"],
**response["items"][0]["snippet"],
}
@cached(cache=TTLCache(maxsize=1000, ttl=30 * 60))
def playlist(self, playlist_id: str) -> Any:
"""
Get the latest videos from a playlist.
See <https://developers.google.com/youtube/v3/docs/playlistItems>
for details.
:param playlist_id: channel ID
:returns: list of latest videos
:throws HTTPException: if the query fails
"""
response = self._query(
url="https://youtube.googleapis.com/youtube/v3/playlistItems",
method="GET",
data=(
("part", "snippet"),
("part", "status"),
("playlistId", playlist_id),
("maxResults", 50),
)
)
return [
item["snippet"]
for item in response["items"]
if item["status"]["privacyStatus"] == "public"
and item["snippet"]["resourceId"]["kind"] == "youtube#video"
]