breadtube-bot/breadtube_bot/youtube_manager.py
2025-10-04 16:51:08 +09:00

82 lines
3.6 KiB
Python

from __future__ import annotations
from dataclasses import dataclass
import http.client
import json
import time
from typing import TYPE_CHECKING
import urllib.error
import urllib.request
from .youtube_objects import ChannelResult, SearchResult
if TYPE_CHECKING:
import logging
from .objects import HTTPHeaders
class YoutubeManager:
DEFAULT_DAILY_REQUESTS = 10_000
SHORTS_CHECK_STATUS = 303
@dataclass
class RateLimit:
remaining: int
next_reset: float
def __init__(self, api_key: str, logger: logging.Logger):
self._api_key = api_key
self._logger = logger
self.rate_limit = self.RateLimit(remaining=self.DEFAULT_DAILY_REQUESTS, next_reset=time.time() + 24 * 3600)
def _request(self, url: str, request_timeout: float, expected_status: int = 200) -> tuple[HTTPHeaders, dict]:
if time.time() >= self.rate_limit.next_reset:
self.rate_limit.next_reset = time.time() + 24 * 3600
self.rate_limit.remaining = self.DEFAULT_DAILY_REQUESTS
elif self.rate_limit.remaining <= 0:
sleep_time = time.time() - self.rate_limit.next_reset
self._logger.debug('No more remaining in Youtube RateLimit : sleeping for %.03fs', sleep_time)
time.sleep(sleep_time)
self.rate_limit.remaining -= 1
request = urllib.request.Request(url)
request.add_header('Accept', 'application/json')
try:
with urllib.request.urlopen(request, timeout=request_timeout) as response:
if response.status != expected_status:
raise RuntimeError(
f'Unexpected YT status {response.status} (expected: {expected_status})'
f' -> {response.read().decode()}')
return dict(response.getheaders()), json.loads(response.read().decode())
except urllib.error.HTTPError as error:
raise RuntimeError(
f'HTTP error calling API ({url}): {error}:\n'
f'Headers:\n{error.headers}Body:\n{error.read()}') from error
except urllib.error.URLError as error:
raise RuntimeError(f'URL error calling YT API ({url}): {error}') from error
except TimeoutError as error:
raise RuntimeError(f'Timeout calling YT API ({url}): {error}') from error
def is_shorts(self, video_id: str, request_timeout: float) -> bool:
try:
connection = http.client.HTTPSConnection('www.youtube.com', timeout=request_timeout)
connection.request('GET', f'/shorts/{video_id}')
response = connection.getresponse()
return response.status != self.SHORTS_CHECK_STATUS
except Exception as error:
raise RuntimeError(f'Exception calling YouTube shorts ({video_id}): {error}') from error
def request_channel_info(self, channel_id: str, request_timeout: float) -> tuple[
HTTPHeaders, ChannelResult]:
url = ('https://www.googleapis.com/youtube/v3/channels?part=snippet'
f'&id={channel_id}&key={self._api_key}')
headers, info = self._request(url=url, request_timeout=request_timeout)
return headers, ChannelResult.from_dict(info)
def request_channel_videos(self, channel_id: str, max_results: int, request_timeout: float) -> tuple[
HTTPHeaders, SearchResult]:
url = (f'https://www.googleapis.com/youtube/v3/search?part=snippet&channelId={channel_id}'
f'&maxResults={max_results}&order=date&type=video&key={self._api_key}')
headers, info = self._request(url=url, request_timeout=request_timeout)
return headers, SearchResult.from_dict(info)