Add youtube endpoint
This commit is contained in:
parent
33d10c20a1
commit
527077fab4
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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),
|
||||||
|
|
|
@ -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 = {}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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()
|
|
@ -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 doesn’t 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"
|
||||||
|
]
|
Loading…
Reference in New Issue