From 69af75c810cebdbcc05460e8050a218fe519fb59 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 25 Jul 2023 18:36:33 +0200 Subject: [PATCH] Add support for video content details (#64) --- src/youtubeaio/const.py | 21 +++++++++++++++ src/youtubeaio/helper.py | 22 +++++++++++++++ src/youtubeaio/models.py | 42 +++++++++++++++++++++++++++-- tests/__snapshots__/test_video.ambr | 2 +- tests/test_helper.py | 32 +++++++++++++++++++++- tests/test_video.py | 13 +++++++-- 6 files changed, 126 insertions(+), 6 deletions(-) diff --git a/src/youtubeaio/const.py b/src/youtubeaio/const.py index 8f9f6d5..5b67aca 100644 --- a/src/youtubeaio/const.py +++ b/src/youtubeaio/const.py @@ -26,6 +26,27 @@ class VideoPart(str, Enum): TOPIC_DETAILS = "topicDetails" +class VideoDimension(str, Enum): + """Enum holding the possible video dimensions.""" + + D3 = "3d" + D2 = "2d" + + +class VideoDefinition(str, Enum): + """Enum holding the possible video definitions.""" + + HD = "hd" + SD = "sd" + + +class VideoProjection(str, Enum): + """Enum holding the possible video projections.""" + + THREE_SIXTY = "360" + RECTANGULAR = "rectangular" + + class LiveBroadcastContent(str, Enum): """Enum holding the liveBroadcastContent values.""" diff --git a/src/youtubeaio/helper.py b/src/youtubeaio/helper.py index f064d0e..e15dec0 100644 --- a/src/youtubeaio/helper.py +++ b/src/youtubeaio/helper.py @@ -1,6 +1,8 @@ """Helper functions for the YouTube API.""" +import re import urllib.parse from collections.abc import AsyncGenerator, Generator +from datetime import timedelta from enum import Enum from typing import Any, TypeVar @@ -106,3 +108,23 @@ async def limit( if count > total: break yield item + + +def get_duration(duration: str) -> timedelta: + """Return timedelta for ISO8601 duration string.""" + attributes = { + "S": 0, + "M": 0, + "H": 0, + "D": 0, + } + for match in re.compile(r"(\d+[DHMS])").finditer(duration): + part = match.group(1) + time_value = int(part[:-1]) + attributes[part[len(part) - 1]] = time_value + return timedelta( + days=attributes["D"], + hours=attributes["H"], + minutes=attributes["M"], + seconds=attributes["S"], + ) diff --git a/src/youtubeaio/models.py b/src/youtubeaio/models.py index b8fb2bd..ddecec7 100644 --- a/src/youtubeaio/models.py +++ b/src/youtubeaio/models.py @@ -1,10 +1,15 @@ """Models for YouTube API.""" -from datetime import datetime +from datetime import datetime, timedelta from typing import TypeVar from pydantic import BaseModel, Field -from youtubeaio.const import LiveBroadcastContent +from youtubeaio.const import ( + LiveBroadcastContent, + VideoDefinition, + VideoDimension, + VideoProjection, +) __all__ = [ "YouTubeThumbnail", @@ -18,6 +23,7 @@ "YouTubeChannel", ] +from youtubeaio.helper import get_duration from youtubeaio.types import PartMissingError T = TypeVar("T") @@ -66,11 +72,36 @@ class YouTubeVideoSnippet(BaseModel): default_audio_language: str | None = Field(None, alias="defaultAudioLanguage") +class YouTubeVideoContentDetails(BaseModel): + """Model representing video content details.""" + + raw_duration: str = Field(..., alias="duration") + dimension: VideoDimension = Field(...) + definition: VideoDefinition = Field(...) + raw_caption: str = Field(..., alias="caption") + licensed_content: bool = Field(..., alias="licensedContent") + projection: VideoProjection = Field(...) + + @property + def caption(self) -> bool: + """Return if video has caption.""" + return self.raw_caption == "true" + + @property + def duration(self) -> timedelta: + """Return length of the video.""" + return get_duration(self.raw_duration) + + class YouTubeVideo(BaseModel): """Model representing a video.""" video_id: str = Field(..., alias="id") nullable_snippet: YouTubeVideoSnippet | None = Field(None, alias="snippet") + nullable_content_details: YouTubeVideoContentDetails | None = Field( + None, + alias="contentDetails", + ) @property def snippet(self) -> YouTubeVideoSnippet: @@ -79,6 +110,13 @@ def snippet(self) -> YouTubeVideoSnippet: raise PartMissingError return self.nullable_snippet + @property + def content_details(self) -> YouTubeVideoContentDetails: + """Return content details.""" + if self.nullable_content_details is None: + raise PartMissingError + return self.nullable_content_details + class YouTubeChannelThumbnails(BaseModel): """Model representing channel thumbnails.""" diff --git a/tests/__snapshots__/test_video.ambr b/tests/__snapshots__/test_video.ambr index d25de05..7f6be61 100644 --- a/tests/__snapshots__/test_video.ambr +++ b/tests/__snapshots__/test_video.ambr @@ -1,4 +1,4 @@ # serializer version: 1 # name: test_fetch_video - YouTubeVideo(video_id='Ks-_Mh1QhMc', nullable_snippet=YouTubeVideoSnippet(published_at=datetime.datetime(2012, 10, 1, 15, 27, 35, tzinfo=TzInfo(UTC)), channel_id='UCAuUUnT6oDeKwE6v1NGQxug', title='Your body language may shape who you are | Amy Cuddy', description='Body language affects how others see us, but it may also change how we see ourselves. Social psychologist Amy Cuddy argues that "power posing" -- standing in a posture of confidence, even when we don\'t feel confident -- can boost feelings of confidence, and might have an impact on our chances for success. (Note: Some of the findings presented in this talk have been referenced in an ongoing debate among social scientists about robustness and reproducibility. Read Amy Cuddy\'s response here: http://ideas.ted.com/inside-the-debate-about-power-posing-a-q-a-with-amy-cuddy/)\n\nGet TED Talks recommended just for you! Learn more at https://www.ted.com/signup.\n\nThe TED Talks channel features the best talks and performances from the TED Conference, where the world\'s leading thinkers and doers give the talk of their lives in 18 minutes (or less). Look for talks on Technology, Entertainment and Design -- plus science, business, global issues, the arts and more.\n\nFollow TED on Twitter: http://www.twitter.com/TEDTalks\nLike TED on Facebook: https://www.facebook.com/TED\n\nSubscribe to our channel: https://www.youtube.com/TED', thumbnails=YouTubeVideoThumbnails(default=YouTubeThumbnail(url='https://i.ytimg.com/vi/Ks-_Mh1QhMc/default.jpg', width=120, height=90), medium=YouTubeThumbnail(url='https://i.ytimg.com/vi/Ks-_Mh1QhMc/mqdefault.jpg', width=320, height=180), high=YouTubeThumbnail(url='https://i.ytimg.com/vi/Ks-_Mh1QhMc/hqdefault.jpg', width=480, height=360), standard=YouTubeThumbnail(url='https://i.ytimg.com/vi/Ks-_Mh1QhMc/sddefault.jpg', width=640, height=480), maxres=YouTubeThumbnail(url='https://i.ytimg.com/vi/Ks-_Mh1QhMc/maxresdefault.jpg', width=1280, height=720)), channel_title='TED', tags=['Amy Cuddy', 'TED', 'TEDTalk', 'TEDTalks', 'TED Talk', 'TED Talks', 'TEDGlobal', 'brain', 'business', 'psychology', 'self', 'success'], live_broadcast_content=, default_language='en', default_audio_language='en')) + YouTubeVideo(video_id='Ks-_Mh1QhMc', nullable_snippet=YouTubeVideoSnippet(published_at=datetime.datetime(2012, 10, 1, 15, 27, 35, tzinfo=TzInfo(UTC)), channel_id='UCAuUUnT6oDeKwE6v1NGQxug', title='Your body language may shape who you are | Amy Cuddy', description='Body language affects how others see us, but it may also change how we see ourselves. Social psychologist Amy Cuddy argues that "power posing" -- standing in a posture of confidence, even when we don\'t feel confident -- can boost feelings of confidence, and might have an impact on our chances for success. (Note: Some of the findings presented in this talk have been referenced in an ongoing debate among social scientists about robustness and reproducibility. Read Amy Cuddy\'s response here: http://ideas.ted.com/inside-the-debate-about-power-posing-a-q-a-with-amy-cuddy/)\n\nGet TED Talks recommended just for you! Learn more at https://www.ted.com/signup.\n\nThe TED Talks channel features the best talks and performances from the TED Conference, where the world\'s leading thinkers and doers give the talk of their lives in 18 minutes (or less). Look for talks on Technology, Entertainment and Design -- plus science, business, global issues, the arts and more.\n\nFollow TED on Twitter: http://www.twitter.com/TEDTalks\nLike TED on Facebook: https://www.facebook.com/TED\n\nSubscribe to our channel: https://www.youtube.com/TED', thumbnails=YouTubeVideoThumbnails(default=YouTubeThumbnail(url='https://i.ytimg.com/vi/Ks-_Mh1QhMc/default.jpg', width=120, height=90), medium=YouTubeThumbnail(url='https://i.ytimg.com/vi/Ks-_Mh1QhMc/mqdefault.jpg', width=320, height=180), high=YouTubeThumbnail(url='https://i.ytimg.com/vi/Ks-_Mh1QhMc/hqdefault.jpg', width=480, height=360), standard=YouTubeThumbnail(url='https://i.ytimg.com/vi/Ks-_Mh1QhMc/sddefault.jpg', width=640, height=480), maxres=YouTubeThumbnail(url='https://i.ytimg.com/vi/Ks-_Mh1QhMc/maxresdefault.jpg', width=1280, height=720)), channel_title='TED', tags=['Amy Cuddy', 'TED', 'TEDTalk', 'TEDTalks', 'TED Talk', 'TED Talks', 'TEDGlobal', 'brain', 'business', 'psychology', 'self', 'success'], live_broadcast_content=, default_language='en', default_audio_language='en'), nullable_content_details=YouTubeVideoContentDetails(raw_duration='PT21M3S', dimension=, definition=, raw_caption='true', licensed_content=True, projection=)) # --- diff --git a/tests/test_helper.py b/tests/test_helper.py index 51abc8e..baa1001 100644 --- a/tests/test_helper.py +++ b/tests/test_helper.py @@ -1,10 +1,11 @@ """Tests for the helper module.""" from collections.abc import AsyncGenerator +from datetime import timedelta from typing import Any import pytest -from youtubeaio.helper import build_scope, build_url, chunk, first, limit +from youtubeaio.helper import build_scope, build_url, chunk, first, get_duration, limit from youtubeaio.types import AuthScope @@ -137,3 +138,32 @@ async def test_build_url( ) -> None: """Test build url.""" assert build_url("asd.com", params, remove_none, split_lists, enum_value) == result + + +@pytest.mark.parametrize( + ("duration", "result"), + [ + ( + "PT5S", + timedelta(seconds=5), + ), + ( + "PT10M5S", + timedelta(minutes=10, seconds=5), + ), + ( + "PT2H10M5S", + timedelta(hours=2, minutes=10, seconds=5), + ), + ( + "P4DT2H10M5S", + timedelta(days=4, hours=2, minutes=10, seconds=5), + ), + ], +) +def test_duration( + duration: str, + result: timedelta, +) -> None: + """Test duration,.""" + assert get_duration(duration) == result diff --git a/tests/test_video.py b/tests/test_video.py index df1343d..4ec5ed6 100644 --- a/tests/test_video.py +++ b/tests/test_video.py @@ -1,5 +1,6 @@ """Tests for the YouTube client.""" import json +from datetime import timedelta import aiohttp import pytest @@ -29,7 +30,9 @@ async def test_fetch_video( aresponses.Response( status=200, headers={"Content-Type": "application/json"}, - text=json.dumps(construct_fixture("video", ["snippet"], 1)), + text=json.dumps( + construct_fixture("video", ["snippet", "contentDetails"], 1), + ), ), ) async with aiohttp.ClientSession() as session, YouTube(session=session) as youtube: @@ -184,7 +187,9 @@ async def test_nullable_fields( aresponses.Response( status=200, headers={"Content-Type": "application/json"}, - text=json.dumps(construct_fixture("video", ["snippet"], 1)), + text=json.dumps( + construct_fixture("video", ["snippet", "contentDetails"], 1), + ), ), ) async with aiohttp.ClientSession() as session: @@ -192,6 +197,8 @@ async def test_nullable_fields( video = await youtube.get_video(video_id="V4DDt30Aat4") assert video assert video.snippet.channel_id == "UCAuUUnT6oDeKwE6v1NGQxug" + assert video.content_details.duration == timedelta(minutes=21, seconds=3) + assert video.content_details.caption is True async def test_nullable_fields_null( @@ -214,3 +221,5 @@ async def test_nullable_fields_null( assert video with pytest.raises(PartMissingError): assert video.snippet.thumbnails + with pytest.raises(PartMissingError): + assert video.content_details