Skip to content

Commit f4a2a29

Browse files
authored
Add video methods (#8)
1 parent 77623d2 commit f4a2a29

18 files changed

Lines changed: 953 additions & 32 deletions

.editorconfig

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ indent_style = space
77
insert_final_newline = true
88
trim_trailing_whitespace = true
99
indent_size = 4
10+
max_line_length = 88
1011

1112
[*.md]
1213
trim_trailing_whitespace = false

pyproject.toml

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ classifiers = [
2121
"Topic :: Software Development :: Libraries :: Python Modules",
2222
]
2323
packages = [
24-
{ include = "python_youtube", from = "src" },
24+
{ include = "async_python_youtube", from = "src" },
2525
]
2626

2727
[tool.poetry.dependencies]
@@ -57,7 +57,7 @@ show_missing = true
5757

5858
[tool.coverage.run]
5959
plugins = ["covdefaults"]
60-
source = ["python_youtube"]
60+
source = ["async_python_youtube"]
6161

6262
[tool.mypy]
6363
# Specify the target platform details in config, so your developers are
@@ -120,6 +120,7 @@ disable = [
120120
"duplicate-code",
121121
"format",
122122
"unsubscriptable-object",
123+
"unnecessary-dunder-call",
123124
]
124125

125126
[tool.pylint.SIMILARITIES]
@@ -150,7 +151,7 @@ fixture-parentheses = false
150151
mark-parentheses = false
151152

152153
[tool.ruff.isort]
153-
known-first-party = ["python_youtube"]
154+
known-first-party = ["async_python_youtube"]
154155

155156
[tool.ruff.mccabe]
156157
max-complexity = 25

src/async_python_youtube/const.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
"""Models for YouTube API."""
2+
from enum import Enum
3+
4+
5+
class HttpStatusCode(int, Enum):
6+
"""Enum holding http status codes."""
7+
8+
NOT_FOUND = 404
9+
10+
11+
class VideoPart(str, Enum):
12+
"""Enum holding the part parameters for video requests."""
13+
14+
CONTENT_DETAILS = "contentDetails"
15+
FILE_DETAILS = "fileDetails"
16+
ID = "id"
17+
LIVE_STREAMING_DETAILS = "liveStreamingDetails"
18+
LOCALIZATIONS = "localizations"
19+
PLAYER = "player"
20+
PROCESSING_DETAILS = "processingDetails"
21+
RECORDING_DETAILS = "recordingDetails"
22+
SNIPPET = "snippet"
23+
STATISTICS = "statistics"
24+
STATUS = "status"
25+
SUGGESTIONS = "suggestions"
26+
TOPIC_DETAILS = "topicDetails"
27+
28+
29+
class LiveBroadcastContent(str, Enum):
30+
"""Enum holding the liveBroadcastContent values."""
31+
32+
NONE = "none"
33+
LIVE = "live"
34+
UPCOMING = "upcoming"
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
"""Asynchronous Python client for the YouTube API."""
2+
3+
4+
class YouTubeError(Exception):
5+
"""Generic exception."""
6+
7+
8+
class YouTubeConnectionError(YouTubeError):
9+
"""YouTube connection exception."""
10+
11+
12+
class YouTubeCoordinateError(YouTubeError):
13+
"""YouTube coordinate exception."""
14+
15+
16+
class YouTubeUnauthenticatedError(YouTubeError):
17+
"""YouTube unauthenticated exception."""
18+
19+
20+
class YouTubeNotFoundError(YouTubeError):
21+
"""YouTube not found exception."""

src/async_python_youtube/helper.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
"""Helper functions for the YouTube API."""
2+
from collections.abc import AsyncGenerator, Generator
3+
from typing import TypeVar
4+
5+
T = TypeVar("T")
6+
7+
8+
async def first(generator: AsyncGenerator[T, None]) -> T | None:
9+
"""Return the first value or None from the given AsyncGenerator."""
10+
try:
11+
return await generator.__anext__()
12+
except StopAsyncIteration:
13+
return None
14+
15+
16+
def chunk(source: list[T], chunk_size: int) -> Generator[list[T], None, None]:
17+
"""Divide the source list in chunks of given size."""
18+
for i in range(0, len(source), chunk_size):
19+
yield source[i : i + chunk_size]
20+
21+
22+
async def limit(
23+
generator: AsyncGenerator[T, None],
24+
total: int,
25+
) -> AsyncGenerator[T, None]:
26+
"""Limit the number of entries returned from the AsyncGenerator."""
27+
if total < 1:
28+
msg = "Limit has to be an int > 1"
29+
raise ValueError(msg)
30+
count = 0
31+
async for item in generator:
32+
count += 1
33+
if count > total:
34+
break
35+
yield item

src/async_python_youtube/models.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
"""Models for YouTube API."""
2+
from datetime import datetime
3+
from typing import TypeVar
4+
5+
from pydantic import BaseModel, Field
6+
7+
from async_python_youtube.const import LiveBroadcastContent
8+
9+
T = TypeVar("T")
10+
11+
12+
class YouTubeVideoThumbnail(BaseModel):
13+
"""Model representing a video thumbnail."""
14+
15+
url: str = Field(...)
16+
width: int = Field(...)
17+
height: int = Field(...)
18+
19+
20+
class YouTubeVideoThumbnails(BaseModel):
21+
"""Model representing video thumbnails."""
22+
23+
default: YouTubeVideoThumbnail = Field(...)
24+
medium: YouTubeVideoThumbnail = Field(...)
25+
high: YouTubeVideoThumbnail = Field(...)
26+
standard: YouTubeVideoThumbnail = Field(...)
27+
maxres: YouTubeVideoThumbnail | None = Field(None)
28+
29+
30+
class YouTubeVideoSnippet(BaseModel):
31+
"""Model representing video snippet."""
32+
33+
published_at: datetime = Field(..., alias="publishedAt")
34+
channel_id: str = Field(..., alias="channelId")
35+
title: str = Field(...)
36+
description: str = Field(...)
37+
thumbnails: YouTubeVideoThumbnails = Field(...)
38+
channel_title: str = Field(..., alias="channelTitle")
39+
tags: list[str] = Field(...)
40+
live_broadcast_content: LiveBroadcastContent = Field(
41+
...,
42+
alias="liveBroadcastContent",
43+
)
44+
default_language: str | None = Field(None, alias="defaultLanguage")
45+
default_audio_language: str | None = Field(None, alias="defaultAudioLanguage")
46+
47+
48+
class YouTubeVideo(BaseModel):
49+
"""Model representing a video."""
50+
51+
video_id: str = Field(..., alias="id")
52+
snippet: YouTubeVideoSnippet | None = None

src/async_python_youtube/py.typed

Whitespace-only changes.
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
"""The YouTube API."""
2+
3+
import asyncio
4+
from collections.abc import AsyncGenerator
5+
from dataclasses import dataclass
6+
from importlib import metadata
7+
from typing import Any, cast
8+
9+
import async_timeout
10+
from aiohttp import ClientResponseError, ClientSession
11+
from aiohttp.hdrs import METH_GET
12+
from yarl import URL
13+
14+
from async_python_youtube.const import HttpStatusCode
15+
from async_python_youtube.exceptions import (
16+
YouTubeConnectionError,
17+
YouTubeError,
18+
YouTubeNotFoundError,
19+
)
20+
from async_python_youtube.helper import chunk, first
21+
from async_python_youtube.models import YouTubeVideo
22+
23+
__all__ = [
24+
"YouTube",
25+
]
26+
27+
MAX_RESULTS_FOR_VIDEO = 50
28+
29+
30+
@dataclass
31+
class YouTube:
32+
"""YouTube API client."""
33+
34+
session: ClientSession | None = None
35+
request_timeout: int = 10
36+
api_host: str = "youtube.googleapis.com"
37+
_close_session: bool = False
38+
39+
async def _request(
40+
self,
41+
uri: str,
42+
*,
43+
data: dict[str, Any] | None = None,
44+
error_handler: dict[int, BaseException] | None = None,
45+
) -> dict[str, Any]:
46+
"""Handle a request to OpenSky.
47+
48+
A generic method for sending/handling HTTP requests done against
49+
OpenSky.
50+
51+
Args:
52+
----
53+
uri: the path to call.
54+
data: the query parameters to add.
55+
56+
Returns:
57+
-------
58+
A Python dictionary (JSON decoded) with the response from
59+
the API.
60+
61+
Raises:
62+
------
63+
OpenSkyConnectionError: An error occurred while communicating with
64+
the OpenSky API.
65+
OpenSkyrror: Received an unexpected response from the OpenSky API.
66+
"""
67+
version = metadata.version(__package__)
68+
url = URL.build(
69+
scheme="https",
70+
host=self.api_host,
71+
port=443,
72+
path="/youtube/v3/",
73+
).joinpath(uri)
74+
75+
headers = {
76+
"User-Agent": f"PythonOpenSky/{version}",
77+
"Accept": "application/json, text/plain, */*",
78+
}
79+
80+
if self.session is None:
81+
self.session = ClientSession()
82+
self._close_session = True
83+
84+
try:
85+
async with async_timeout.timeout(self.request_timeout):
86+
response = await self.session.request(
87+
METH_GET,
88+
url.with_query(data),
89+
headers=headers,
90+
)
91+
response.raise_for_status()
92+
except asyncio.TimeoutError as exception:
93+
msg = "Timeout occurred while connecting to the YouTube API"
94+
raise YouTubeConnectionError(msg) from exception
95+
except ClientResponseError as exception:
96+
if error_handler and exception.status in error_handler:
97+
raise error_handler[exception.status] from exception
98+
msg = "Error occurred while communicating with YouTube API"
99+
raise YouTubeConnectionError(msg) from exception
100+
101+
content_type = response.headers.get("Content-Type", "")
102+
103+
if "application/json" not in content_type:
104+
text = await response.text()
105+
msg = "Unexpected response from the YouTube API"
106+
raise YouTubeError(
107+
msg,
108+
{"Content-Type": content_type, "response": text},
109+
)
110+
111+
return cast(dict[str, Any], await response.json())
112+
113+
async def get_video(self, video_id: str) -> YouTubeVideo | None:
114+
"""Get a single video."""
115+
return await first(self.get_videos([video_id]))
116+
117+
async def get_videos(
118+
self,
119+
video_ids: list[str],
120+
) -> AsyncGenerator[YouTubeVideo, None]:
121+
"""Get a list of videos."""
122+
error_handler: dict[int, BaseException] = {
123+
HttpStatusCode.NOT_FOUND: YouTubeNotFoundError("Video not found"),
124+
}
125+
for video_chunk in chunk(video_ids, MAX_RESULTS_FOR_VIDEO):
126+
ids = ",".join(video_chunk)
127+
data = {
128+
"part": "snippet",
129+
"id": ids,
130+
"maxResults": MAX_RESULTS_FOR_VIDEO,
131+
}
132+
res = await self._request("videos", data=data, error_handler=error_handler)
133+
for item in res["items"]:
134+
yield YouTubeVideo.parse_obj(item)
135+
136+
async def close(self) -> None:
137+
"""Close open client session."""
138+
if self.session and self._close_session:
139+
await self.session.close()
140+
141+
async def __aenter__(self) -> Any:
142+
"""Async enter.
143+
144+
Returns
145+
-------
146+
The YouTube object.
147+
"""
148+
return self
149+
150+
async def __aexit__(self, *_exc_info: Any) -> None:
151+
"""Async exit.
152+
153+
Args:
154+
----
155+
_exc_info: Exec type.
156+
"""
157+
await self.close()

src/python_youtube/youtube.py

Lines changed: 0 additions & 20 deletions
This file was deleted.

0 commit comments

Comments
 (0)