From d47da87d03636e4e7166045b4ad9d6426ba43a14 Mon Sep 17 00:00:00 2001 From: JoshPaulie Date: Sun, 30 Jan 2022 18:18:37 -0600 Subject: [PATCH 001/141] Readability changes --- examples/channel_videos.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/examples/channel_videos.py b/examples/channel_videos.py index a11aba5b..11dd5b12 100644 --- a/examples/channel_videos.py +++ b/examples/channel_videos.py @@ -13,19 +13,17 @@ def get_videos(channel_id): api = pyyoutube.Api(api_key=API_KEY) - channel_res = api.get_channel_info(channel_id=channel_id) + channel_info = api.get_channel_info(channel_id=channel_id) - playlist_id = channel_res.items[0].contentDetails.relatedPlaylists.uploads + playlist_id = channel_info.items[0].contentDetails.relatedPlaylists.uploads - playlist_item_res = api.get_playlist_items( - playlist_id=playlist_id, count=10, limit=6 - ) + uploads_playlist_items = api.get_playlist_items(playlist_id=playlist_id, count=10, limit=6) videos = [] - for item in playlist_item_res.items: + for item in uploads_playlist_items.items: video_id = item.contentDetails.videoId - video_res = api.get_video_by_id(video_id=video_id) - videos.extend(video_res.items) + video = api.get_video_by_id(video_id=video_id) + videos.extend(video.items) return videos From 8d31ff7112e23c073debc5c35853a7e88938d050 Mon Sep 17 00:00:00 2001 From: ikaroskun Date: Wed, 16 Mar 2022 11:28:58 +0800 Subject: [PATCH 002/141] refactor(format): :white_check_mark: format code --- examples/channel_videos.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/examples/channel_videos.py b/examples/channel_videos.py index 11dd5b12..2f4f158e 100644 --- a/examples/channel_videos.py +++ b/examples/channel_videos.py @@ -17,7 +17,9 @@ def get_videos(channel_id): playlist_id = channel_info.items[0].contentDetails.relatedPlaylists.uploads - uploads_playlist_items = api.get_playlist_items(playlist_id=playlist_id, count=10, limit=6) + uploads_playlist_items = api.get_playlist_items( + playlist_id=playlist_id, count=10, limit=6 + ) videos = [] for item in uploads_playlist_items.items: From 92100f1f07a05721e515e034ed8d5eb223f77e85 Mon Sep 17 00:00:00 2001 From: ikaroskun Date: Wed, 16 Mar 2022 15:31:26 +0800 Subject: [PATCH 003/141] chore(release): :construction_worker: update actions for release --- .github/hack/changelog.sh | 27 +++++++++++++++ .github/hack/version.sh | 16 +++++++++ .github/workflows/release.yaml | 62 +++++++++++----------------------- CHANGELOG.md | 12 ++++++- 4 files changed, 74 insertions(+), 43 deletions(-) create mode 100755 .github/hack/changelog.sh create mode 100755 .github/hack/version.sh diff --git a/.github/hack/changelog.sh b/.github/hack/changelog.sh new file mode 100755 index 00000000..a4500c49 --- /dev/null +++ b/.github/hack/changelog.sh @@ -0,0 +1,27 @@ +#!/bin/sh + +MARKER_PREFIX="## Version" +VERSION=$(echo "$1" | sed 's/^v//g') + +IFS='' +found=0 + +while read -r "line"; do + # If not found and matching heading + if [ $found -eq 0 ] && echo "$line" | grep -q "$MARKER_PREFIX $VERSION"; then + echo "$line" + found=1 + continue + fi + + # If needed version if found, and reaching next delimter - stop + if [ $found -eq 1 ] && echo "$line" | grep -q -E "$MARKER_PREFIX [[:digit:]]+\.[[:digit:]]+\.[[:digit:]]"; then + found=0 + break + fi + + # Keep printing out lines as no other version delimiter found + if [ $found -eq 1 ]; then + echo "$line" + fi +done < CHANGELOG.md \ No newline at end of file diff --git a/.github/hack/version.sh b/.github/hack/version.sh new file mode 100755 index 00000000..f58433a1 --- /dev/null +++ b/.github/hack/version.sh @@ -0,0 +1,16 @@ +#!/bin/sh + +LATEST_TAG_REV=$(git rev-list --tags --max-count=1) +LATEST_COMMIT_REV=$(git rev-list HEAD --max-count=1) + +if [ -n "$LATEST_TAG_REV" ]; then + LATEST_TAG=$(git describe --tags "$(git rev-list --tags --max-count=1)") +else + LATEST_TAG="v0.0.0" +fi + +if [ "$LATEST_TAG_REV" != "$LATEST_COMMIT_REV" ]; then + echo "$LATEST_TAG+$(git rev-list HEAD --max-count=1 --abbrev-commit)" +else + echo "$LATEST_TAG" +fi \ No newline at end of file diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 63001945..00a2f04b 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -1,47 +1,25 @@ -name: Release - +name: Publish Pypi on: push: tags: - - v* - + - 'v*.*.*' jobs: - release-pypi: - name: release-pypi - runs-on: ubuntu-18.04 - + build: + runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - uses: actions/setup-python@v2 - with: - python-version: 3.7 - architecture: 'x64' - - name: Cache venv - uses: actions/cache@v2 - with: - path: venv - key: ubuntu-18.04-poetryenv-${{ hashFiles('pyproject.toml') }} - - name: Install Dependencies - run: | - python3 -m venv venv - . venv/bin/activate - pip install -U pip - pip install poetry - poetry install - - name: Poetry Build - run: | - . venv/bin/activate - poetry build - - name: Publish - uses: softprops/action-gh-release@v1 - if: startsWith(github.ref, 'refs/tags/') - with: - files: 'python-youtube-*' - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Upload to Pypi - env: - PASSWORD: ${{ secrets.PYPI_TOKEN }} - run: | - . venv/bin/activate - poetry publish --username __token__ --password ${PASSWORD} + - uses: actions/checkout@v2 + - name: Build and publish to pypi + uses: JRubics/poetry-publish@v1.10 + with: + pypi_token: ${{ secrets.PYPI_TOKEN }} + + - name: Generate Changelog + run: | + VERSION=$(.github/hack/version.sh) + .github/hack/changelog.sh $VERSION > NEW-VERSION-CHANGELOG.md + - name: Publish + uses: softprops/action-gh-release@v1 + with: + body_path: NEW-VERSION-CHANGELOG.md + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f739663..c3b286de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,16 @@ # Changelog -## Version 0.8.1 +All notable changes to this project will be documented in this file. + +## Version 0.8.2 (2022-03-16) + +### What's New + +- Update OAuth's functions. +- Update for examples. + + +## Version 0.8.1 (2021-05-14) ### Deprecation From 23bf1319480858e4a1bcef36fedb853df11e7c74 Mon Sep 17 00:00:00 2001 From: ikaroskun Date: Wed, 16 Mar 2022 15:31:56 +0800 Subject: [PATCH 004/141] =?UTF-8?q?Bump=20version:=200.8.1=20=E2=86=92=200?= =?UTF-8?q?.8.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .bumpversion.cfg | 2 +- pyproject.toml | 2 +- pyyoutube/__version__.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index d7152a1b..e2e39622 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.8.1 +current_version = 0.8.2 commit = True tag = True diff --git a/pyproject.toml b/pyproject.toml index e89513cb..9bfc2e45 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "python-youtube" -version = "0.8.1" +version = "0.8.2" description = "A Python wrapper around for YouTube Data API." authors = ["ikaroskun "] license = "MIT" diff --git a/pyyoutube/__version__.py b/pyyoutube/__version__.py index 91cf9eac..d63d80a8 100644 --- a/pyyoutube/__version__.py +++ b/pyyoutube/__version__.py @@ -5,4 +5,4 @@ # 88 88 88 88 88 `8b d8' 88 V888 88 `8b d8' 88b d88 88 88b d88 88 8D 88. # 88 YP YP YP YP `Y88P' VP V8P YP `Y88P' ~Y8888P' YP ~Y8888P' Y8888P' Y88888P -__version__ = "0.8.1" +__version__ = "0.8.2" From 9629f11df9a4effc288a8d6a4d2dce80e04cad2d Mon Sep 17 00:00:00 2001 From: "dusking@gmail.com" Date: Sat, 17 Sep 2022 22:48:38 +0300 Subject: [PATCH 005/141] support video part of live streaming details --- pyyoutube/models/video.py | 17 +++++++++++++++++ pyyoutube/utils/constants.py | 1 + 2 files changed, 18 insertions(+) diff --git a/pyyoutube/models/video.py b/pyyoutube/models/video.py index ea9bf18a..7ecd73a9 100644 --- a/pyyoutube/models/video.py +++ b/pyyoutube/models/video.py @@ -220,6 +220,22 @@ class VideoStatus(BaseModel, DatetimeTimeMixin): selfDeclaredMadeForKids: Optional[bool] = field(default=None, repr=False) +@dataclass +class VideoLiveStreamingDetails(BaseModel, DatetimeTimeMixin): + """ + A class representing the video live streaming details. + + Refer: https://developers.google.com/youtube/v3/docs/videos#liveStreamingDetails + """ + + actualStartTime: Optional[str] = field(default=None, repr=False) + actualEndTime: Optional[str] = field(default=None, repr=False) + scheduledStartTime: Optional[str] = field(default=None, repr=False) + scheduledEndTime: Optional[str] = field(default=None, repr=False) + concurrentViewers: Optional[int] = field(default=None) + activeLiveChatId: Optional[str] = field(default=None, repr=False) + + @dataclass class Video(BaseResource): """ @@ -234,6 +250,7 @@ class Video(BaseResource): statistics: Optional[VideoStatistics] = field(default=None, repr=False) topicDetails: Optional[VideoTopicDetails] = field(default=None, repr=False) player: Optional[Player] = field(default=None, repr=False) + liveStreamingDetails: Optional[VideoLiveStreamingDetails] = field(default=None, repr=False) @dataclass diff --git a/pyyoutube/utils/constants.py b/pyyoutube/utils/constants.py index 2d542b20..a35e2dd2 100644 --- a/pyyoutube/utils/constants.py +++ b/pyyoutube/utils/constants.py @@ -40,6 +40,7 @@ "statistics", "status", "topicDetails", + "liveStreamingDetails", } COMMENT_THREAD_RESOURCE_PROPERTIES = {"id", "replies", "snippet"} From ae39df00328189d5ed9d7986c8846e81febc1eb4 Mon Sep 17 00:00:00 2001 From: ikaroskun Date: Mon, 26 Sep 2022 10:48:50 +0800 Subject: [PATCH 006/141] style(lint): :art: formatting code --- pyyoutube/models/video.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pyyoutube/models/video.py b/pyyoutube/models/video.py index 7ecd73a9..6c433b7c 100644 --- a/pyyoutube/models/video.py +++ b/pyyoutube/models/video.py @@ -250,7 +250,9 @@ class Video(BaseResource): statistics: Optional[VideoStatistics] = field(default=None, repr=False) topicDetails: Optional[VideoTopicDetails] = field(default=None, repr=False) player: Optional[Player] = field(default=None, repr=False) - liveStreamingDetails: Optional[VideoLiveStreamingDetails] = field(default=None, repr=False) + liveStreamingDetails: Optional[VideoLiveStreamingDetails] = field( + default=None, repr=False + ) @dataclass From 1bc08197ff05c5d35b0f4634f092bb8c31f6c229 Mon Sep 17 00:00:00 2001 From: ikaroskun Date: Mon, 17 Oct 2022 18:48:36 +0800 Subject: [PATCH 007/141] docs(changelog): :memo: update changelog --- CHANGELOG.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c3b286de..199572db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,14 +2,19 @@ All notable changes to this project will be documented in this file. +## Version 0.8.3 (2022-10-17) + +### What's New + +- Add parts for video, thanks for [@Omer](https://github.com/dusking) + ## Version 0.8.2 (2022-03-16) ### What's New -- Update OAuth's functions. +- Update OAuthorize functions. - Update for examples. - ## Version 0.8.1 (2021-05-14) ### Deprecation From 5456a20f007a0e95cec5c90fea4b5d155ff43dc3 Mon Sep 17 00:00:00 2001 From: ikaroskun Date: Mon, 17 Oct 2022 18:48:52 +0800 Subject: [PATCH 008/141] =?UTF-8?q?Bump=20version:=200.8.2=20=E2=86=92=200?= =?UTF-8?q?.8.3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .bumpversion.cfg | 2 +- pyproject.toml | 2 +- pyyoutube/__version__.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index e2e39622..90a98973 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.8.2 +current_version = 0.8.3 commit = True tag = True diff --git a/pyproject.toml b/pyproject.toml index 9bfc2e45..9e843070 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "python-youtube" -version = "0.8.2" +version = "0.8.3" description = "A Python wrapper around for YouTube Data API." authors = ["ikaroskun "] license = "MIT" diff --git a/pyyoutube/__version__.py b/pyyoutube/__version__.py index d63d80a8..722189c6 100644 --- a/pyyoutube/__version__.py +++ b/pyyoutube/__version__.py @@ -5,4 +5,4 @@ # 88 88 88 88 88 `8b d8' 88 V888 88 `8b d8' 88b d88 88 88b d88 88 8D 88. # 88 YP YP YP YP `Y88P' VP V8P YP `Y88P' ~Y8888P' YP ~Y8888P' Y8888P' Y88888P -__version__ = "0.8.2" +__version__ = "0.8.3" From c5c44cbd9245a700c514180853493a54551c7a14 Mon Sep 17 00:00:00 2001 From: ikaroskun Date: Tue, 18 Oct 2022 10:10:26 +0800 Subject: [PATCH 009/141] chore(codecov): :construction_worker: fix action --- .github/workflows/release.yaml | 2 +- .github/workflows/test.yaml | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 00a2f04b..c69115de 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -9,7 +9,7 @@ jobs: steps: - uses: actions/checkout@v2 - name: Build and publish to pypi - uses: JRubics/poetry-publish@v1.10 + uses: JRubics/poetry-publish@v1.13 with: pypi_token: ${{ secrets.PYPI_TOKEN }} diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index d843ae30..8f4fed35 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -12,6 +12,9 @@ jobs: strategy: matrix: python-version: [3.6, 3.7, 3.8, 3.9] + include: + - python-version: 3.8 + update-coverage: true steps: - uses: actions/checkout@v2 @@ -32,6 +35,7 @@ jobs: run: | poetry run pytest - name: Upload coverage to Codecov + if: ${{ matrix.update-coverage }} uses: codecov/codecov-action@v1 with: file: ./coverage.xml From 530363bfe4125039f5b9e320a26601bd53d6426b Mon Sep 17 00:00:00 2001 From: ikaroskun Date: Thu, 20 Oct 2022 17:34:09 +0800 Subject: [PATCH 010/141] feat(client): :construction: new structure for client impl --- pyyoutube/__init__.py | 1 + pyyoutube/client.py | 369 +++++++++++++++++++++++++++ pyyoutube/resources/__init__.py | 1 + pyyoutube/resources/base_resource.py | 22 ++ pyyoutube/resources/channel.py | 160 ++++++++++++ 5 files changed, 553 insertions(+) create mode 100644 pyyoutube/client.py create mode 100644 pyyoutube/resources/__init__.py create mode 100644 pyyoutube/resources/base_resource.py create mode 100644 pyyoutube/resources/channel.py diff --git a/pyyoutube/__init__.py b/pyyoutube/__init__.py index 79f28f75..45bb6182 100644 --- a/pyyoutube/__init__.py +++ b/pyyoutube/__init__.py @@ -1,4 +1,5 @@ from .api import Api # noqa +from .client import Client # noqa from .error import * # noqa from .models import * # noqa from .utils.constants import TOPICS # noqa diff --git a/pyyoutube/client.py b/pyyoutube/client.py new file mode 100644 index 00000000..e1823308 --- /dev/null +++ b/pyyoutube/client.py @@ -0,0 +1,369 @@ +""" + New Client for YouTube API +""" +import inspect +from typing import List, Optional, Tuple, Union + +import requests +from requests import Response +from requests.sessions import merge_setting +from requests.structures import CaseInsensitiveDict +from requests_oauthlib.oauth2_session import OAuth2Session + +import pyyoutube.resources as resources +from pyyoutube.error import ErrorCode, ErrorMessage, PyYouTubeException +from pyyoutube.models import ( + AccessToken, +) +from pyyoutube.resources.base_resource import Resource + + +def _is_resource_endpoint(obj): + return isinstance(obj, Resource) + + +class Client: + """Client for YouTube resource""" + + BASE_URL = "https://www.googleapis.com/youtube/v3/" + AUTHORIZATION_URL = "https://accounts.google.com/o/oauth2/v2/auth" + EXCHANGE_ACCESS_TOKEN_URL = "https://oauth2.googleapis.com/token" + + DEFAULT_REDIRECT_URI = "https://localhost/" + DEFAULT_SCOPE = [ + "https://www.googleapis.com/auth/youtube", + "https://www.googleapis.com/auth/userinfo.profile", + ] + DEFAULT_STATE = "Python-YouTube" + + channel = resources.Channel() + + def __new__(cls, *args, **kwargs): + self = super().__new__(cls) + sub_resources = inspect.getmembers(self, _is_resource_endpoint) + for name, resource in sub_resources: + resource_cls = type(resource) + resource = resource_cls(self) + setattr(self, name, resource) + + return self + + def __init__( + self, + client_id: Optional[str] = None, + client_secret: Optional[str] = None, + access_token: Optional[str] = None, + refresh_token: Optional[str] = None, + api_key: Optional[str] = None, + timeout: Optional[int] = None, + proxies: Optional[dict] = None, + headers: Optional[dict] = None, + ) -> None: + """Class initial + + Args: + client_id: + ID for your app. + client_secret: + Secret for your app. + access_token: + Access token for user authorized with your app. + refresh_token: + Refresh Token for user. + api_key: + API key for your app which generated from api console. + timeout: + Timeout for every request. + proxies: + Proxies for every request. + headers: + Headers for every request. + + Raises: + PyYouTubeException: Missing either credentials. + """ + self.client_id = client_id + self.client_secret = client_secret + self.access_token = access_token + self.refresh_token = refresh_token + self.api_key = api_key + self.timeout = timeout + self.proxies = proxies + self.headers = headers + + self.session = requests.Session() + self.merge_headers() + + # Auth settings + if not ( + (self.client_id and self.client_secret) or self.api_key or self.access_token + ): + raise PyYouTubeException( + ErrorMessage( + status_code=ErrorCode.MISSING_PARAMS, + message="Must specify either client key info or api key.", + ) + ) + + def merge_headers(self): + """Merge custom headers to session.""" + if self.headers: + self.session.headers = merge_setting( + request_setting=self.session.headers, + session_setting=self.headers, + dict_class=CaseInsensitiveDict, + ) + + @staticmethod + def parse_response(response: Response) -> dict: + """Response parser + + Args: + response: + Response from the Response. + + Returns: + Response dict data. + + Raises: + PyYouTubeException: If response has errors. + """ + data = response.json() + if "error" in data: + raise PyYouTubeException(response) + return data + + def request( + self, + path: str, + method: str = "GET", + params: Optional[dict] = None, + data: Optional[dict] = None, + enforce_auth: bool = True, + **kwargs, + ): + """Send request to YouTube. + + Args: + path: + Resource or url for YouTube data. such as channels,videos and so on. + method: + Method for the request. + params: + Object to send in the query string of the request. + data: + Object to send in the body of the request. + enforce_auth: + Whether to use user credentials. + kwargs: + Additional parameters for request. + + Returns: + Response for request. + + Raises: + PyYouTubeException: Missing credentials when need credentials. + Request http error. + """ + if not path.startswith("http"): + path = self.BASE_URL + path + + # Access Token first + key, access_token = None, None + if self.access_token is not None: + key, access_token = "access_token", self.access_token + elif self.api_key is not None: + key, access_token = "key", self.api_key + + if access_token is None and enforce_auth: + raise PyYouTubeException( + ErrorMessage( + status_code=ErrorCode.MISSING_PARAMS, + message="You must provide your credentials.", + ) + ) + + if enforce_auth: + if method == "GET": + if not params: + params = {key: access_token} + elif key not in params: + params[key] = access_token + elif method == "POST": + if not data: + data = {key: access_token} + elif key not in data: + data[key] = access_token + + try: + response = self.session.request( + method=method, + url=path, + params=params, + data=data, + proxies=self.proxies, + timeout=self.timeout, + **kwargs, + ) + except requests.HTTPError as e: + raise PyYouTubeException( + ErrorMessage(status_code=ErrorCode.HTTP_ERROR, message=e.args[0]) + ) + else: + return response + + def _get_oauth_session( + self, + redirect_uri: Optional[str] = None, + scope: Optional[List[str]] = None, + state: Optional[str] = None, + **kwargs, + ) -> OAuth2Session: + """Build request session for authorization + + Args: + redirect_uri: + Determines how Google's authorization server sends a response to your app. + If not provide will use default https://localhost/ + scope: + Permission scope for authorization. + see more: https://developers.google.com/youtube/v3/guides/auth/client-side-web-apps#identify-access-scopes + state: + State sting for authorization. + **kwargs: + Additional parameters for session. + + Returns: + OAuth2.0 Session + """ + redirect_uri = ( + redirect_uri if redirect_uri is not None else self.DEFAULT_REDIRECT_URI + ) + scope = scope if scope is not None else self.DEFAULT_SCOPE + state = state if state is not None else self.DEFAULT_STATE + + return OAuth2Session( + client_id=self.client_id, + scope=scope, + redirect_uri=redirect_uri, + state=state, + **kwargs, + ) + + def get_authorize_url( + self, + redirect_uri: Optional[str] = None, + scope: Optional[List[str]] = None, + access_type: str = "offline", + state: Optional[str] = None, + include_granted_scopes: Optional[bool] = None, + login_hint: Optional[str] = None, + prompt: Optional[str] = None, + **kwargs, + ) -> Tuple[str, str]: + """Get authorize url for user. + + Args: + redirect_uri: + Determines how Google's authorization server sends a response to your app. + If not provide will use default https://localhost/ + scope: + The scope you want user to grant permission. + access_type: + Indicates whether your application can refresh access tokens when the user + is not present at the browser. + Valid parameter are `online` and `offline`. + state: + State string between your authorization request and the authorization server's response. + include_granted_scopes: + Enables applications to use incremental authorization to request + access to additional scopes in context. + Set true to enable. + login_hint: + Set the parameter value to an email address or sub identifier, which is + equivalent to the user's Google ID. + prompt: + A space-delimited, case-sensitive list of prompts to present the user. + Possible values are: + - none: + Do not display any authentication or consent screens. + Must not be specified with other values. + - consent: + Prompt the user for consent. + - select_account: + Prompt the user to select an account. + **kwargs: + Additional parameters for authorize session. + + Returns: + A tuple of (url, state) + + url: Authorize url for user. + state: State string for authorization. + + References: + https://developers.google.com/youtube/v3/guides/auth/server-side-web-apps + """ + session = self._get_oauth_session( + redirect_uri=redirect_uri, + scope=scope, + state=state, + **kwargs, + ) + authorize_url, state = session.authorization_url( + url=self.AUTHORIZATION_URL, + access_type=access_type, + include_granted_scopes=include_granted_scopes, + login_hint=login_hint, + prompt=prompt, + ) + return authorize_url, state + + def generate_access_token( + self, + authorization_response: Optional[str] = None, + code: Optional[str] = None, + redirect_uri: Optional[str] = None, + scope: Optional[List[str]] = None, + state: Optional[str] = None, + return_json: bool = False, + **kwargs, + ) -> Union[dict, AccessToken]: + """Exchange the authorization code or authorization response for an access token. + + Args: + authorization_response: + Response url for YouTune redirected to. + code: + Authorization code from authorization_response. + redirect_uri: + Determines how Google's authorization server sends a response to your app. + If not provide will use default https://localhost/ + scope: + The scope you want user to grant permission. + state: + State string between your authorization request and the authorization server's response. + return_json: + Type for returned data. If you set True JSON data will be returned. + **kwargs: + Additional parameters for authorize session. + + Returns: + Access token data. + """ + session = self._get_oauth_session( + redirect_uri=redirect_uri, + scope=scope, + state=state, + **kwargs, + ) + token = session.fetch_token( + token_url=self.EXCHANGE_ACCESS_TOKEN_URL, + client_secret=self.client_secret, + authorization_response=authorization_response, + code=code, + proxies=self.proxies, + ) + self.access_token = token["access_token"] + self.refresh_token = token.get("refresh_token") + return token if return_json else AccessToken.from_dict(token) diff --git a/pyyoutube/resources/__init__.py b/pyyoutube/resources/__init__.py new file mode 100644 index 00000000..8ca2339e --- /dev/null +++ b/pyyoutube/resources/__init__.py @@ -0,0 +1 @@ +from .channel import Channel # noqa diff --git a/pyyoutube/resources/base_resource.py b/pyyoutube/resources/base_resource.py new file mode 100644 index 00000000..986364ea --- /dev/null +++ b/pyyoutube/resources/base_resource.py @@ -0,0 +1,22 @@ +""" + Base resource class. +""" +from typing import Optional, TYPE_CHECKING + +if TYPE_CHECKING: + from pyyoutube import Client + + +class Resource: + """Resource base class""" + + def __init__(self, client: Optional["Client"] = None): + self._client = client + + @property + def access_token(self): + return self._client.access_token + + @property + def api_key(self): + return self._client.api_key diff --git a/pyyoutube/resources/channel.py b/pyyoutube/resources/channel.py new file mode 100644 index 00000000..4d8b19d3 --- /dev/null +++ b/pyyoutube/resources/channel.py @@ -0,0 +1,160 @@ +""" + Channel resource implementation. +""" +from typing import Optional, Union + +from pyyoutube.error import PyYouTubeException, ErrorMessage, ErrorCode +from pyyoutube.resources.base_resource import Resource +from pyyoutube.models import ChannelListResponse +from pyyoutube.utils.params_checker import enf_parts + + +class Channel(Resource): + """A channel resource contains information about a YouTube channel. + + References: https://developers.google.com/youtube/v3/docs/channels + """ + + def list( + self, + parts: Optional[Union[str, list, tuple, set]] = None, + for_username: Optional[str] = None, + id: Optional[str] = None, + managed_by_me: Optional[bool] = None, + mine: Optional[bool] = None, + hl: Optional[str] = None, + max_results: Optional[int] = None, + on_behalf_of_content_owner: Optional[str] = None, + page_token: Optional[str] = None, + return_json: bool = False, + **kwargs: Optional[dict], + ) -> Union[dict, ChannelListResponse]: + """Returns a collection of zero or more channel resources that match the request criteria. + + Args: + parts: + Comma-separated list of one or more channel resource properties. + for_username: + The parameter specifies a YouTube username, thereby requesting + the channel associated with that username. + id: + The parameter specifies a comma-separated list of the YouTube channel ID(s) + for the resource(s) that are being retrieved. + managed_by_me: + Set this parameter's value to true to instruct the API to only return channels + managed by the content owner that the onBehalfOfContentOwner parameter specifies. + The user must be authenticated as a CMS account linked to the specified content + owner and onBehalfOfContentOwner must be provided. + mine: + Set this parameter's value to true to instruct the API to only return channels + owned by the authenticated user. + hl: + The hl parameter instructs the API to retrieve localized resource metadata for + a specific application language that the YouTube website supports. + The parameter value must be a language code included in the list returned by the + i18nLanguages.list method. + max_results: + The parameter specifies the maximum number of items that should be returned + the result set. + Acceptable values are 0 to 50, inclusive. The default value is 5. + on_behalf_of_content_owner: + The onBehalfOfContentOwner parameter indicates that the request's authorization + credentials identify a YouTube CMS user who is acting on behalf of the content + owner specified in the parameter value. This parameter is intended for YouTube + content partners that own and manage many different YouTube channels. It allows + content owners to authenticate once and get access to all their video and channel + data, without having to provide authentication credentials for each individual channel. + The CMS account that the user authenticates with must be linked to the specified YouTube content owner. + page_token: + The parameter identifies a specific page in the result set that should be returned. + return_json: + Type for returned data. If you set True JSON data will be returned. + kwargs: + Additional parameters for system parameters. + Refer: https://cloud.google.com/apis/docs/system-parameters. + + Returns: + Channel data + Raises: + PyYouTubeException: Missing filter parameter. + Request not success. + """ + + params = { + "part": enf_parts(resource="channels", value=parts), + "hl": hl, + "maxResults": max_results, + "onBehalfOfContentOwner": on_behalf_of_content_owner, + "pageToken": page_token, + **kwargs, + } + if for_username is not None: + params["forUsername"] = for_username + elif id is not None: + params["id"] = id + elif managed_by_me is not None: + params["managedByMe"] = managed_by_me + elif mine is not None: + params["mine"] = mine + else: + raise PyYouTubeException( + ErrorMessage( + status_code=ErrorCode.MISSING_PARAMS, + message=f"Specify at least one of for_username, id, managedByMe or mine", + ) + ) + + response = self._client.request(path="channels", params=params) + data = self._client.parse_response(response=response) + return data if return_json else ChannelListResponse.from_dict(data) + + def update( + self, + part: str, + body: Optional[dict], + on_behalf_of_content_owner: Optional[str] = None, + return_json: bool = False, + **kwargs, + ) -> Union[dict, ChannelListResponse]: + """Updates a channel's metadata. + + Note that this method currently only supports updates to the channel resource's brandingSettings, + invideoPromotion, and localizations objects and their child properties. + + Args: + part: + The part parameter serves two purposes in this operation. It identifies the properties + that the write operation will set as well as the properties that the API response will include. + body: + Provide a channel resource in the request body. + on_behalf_of_content_owner: + The onBehalfOfContentOwner parameter indicates that the request's authorization + credentials identify a YouTube CMS user who is acting on behalf of the content + owner specified in the parameter value. This parameter is intended for YouTube + content partners that own and manage many different YouTube channels. It allows + content owners to authenticate once and get access to all their video and channel + data, without having to provide authentication credentials for each individual channel. + The CMS account that the user authenticates with must be linked to the specified YouTube content owner. + return_json: + Type for returned data. If you set True JSON data will be returned. + **kwargs: + Additional parameters for system parameters. + Refer: https://cloud.google.com/apis/docs/system-parameters. + + Returns: + Channel updated data. + """ + + params = { + "part": part, + "onBehalfOfContentOwner": on_behalf_of_content_owner, + **kwargs, + } + response = self._client.request( + method="PUT", + path="channels", + params=params, + data=body, + ) + data = self._client.parse_response(response=response) + return data if return_json else ChannelListResponse.from_dict(data) From 34b832a7ac3999baf66c4db16d68932a4b6d99f6 Mon Sep 17 00:00:00 2001 From: ikaroskun Date: Thu, 20 Oct 2022 18:28:54 +0800 Subject: [PATCH 011/141] fix(request): :bug: make credentials work well --- pyyoutube/client.py | 53 +++++++++++++++++++++++---------------------- 1 file changed, 27 insertions(+), 26 deletions(-) diff --git a/pyyoutube/client.py b/pyyoutube/client.py index e1823308..1cc3d3ee 100644 --- a/pyyoutube/client.py +++ b/pyyoutube/client.py @@ -168,39 +168,25 @@ def request( if not path.startswith("http"): path = self.BASE_URL + path - # Access Token first - key, access_token = None, None - if self.access_token is not None: - key, access_token = "access_token", self.access_token - elif self.api_key is not None: - key, access_token = "key", self.api_key - - if access_token is None and enforce_auth: - raise PyYouTubeException( - ErrorMessage( - status_code=ErrorCode.MISSING_PARAMS, - message="You must provide your credentials.", - ) - ) - + # Add credentials to request if enforce_auth: - if method == "GET": - if not params: - params = {key: access_token} - elif key not in params: - params[key] = access_token - elif method == "POST": - if not data: - data = {key: access_token} - elif key not in data: - data[key] = access_token + if self.api_key is None and self.access_token is None: + raise PyYouTubeException( + ErrorMessage( + status_code=ErrorCode.MISSING_PARAMS, + message="You must provide your credentials.", + ) + ) + else: + self.add_token_to_headers() + params = self.add_api_key_to_params(params=params) try: response = self.session.request( method=method, url=path, params=params, - data=data, + json=data, proxies=self.proxies, timeout=self.timeout, **kwargs, @@ -212,6 +198,21 @@ def request( else: return response + def add_token_to_headers(self): + if self.access_token: + self.session.headers.update( + {"Authorization": f"Bearer {self.access_token}"} + ) + + def add_api_key_to_params(self, params: Optional[dict] = None): + if not self.api_key: + return params + if params is None: + params = {"key": self.api_key} + else: + params["key"] = self.api_key + return params + def _get_oauth_session( self, redirect_uri: Optional[str] = None, From 12b910ac3e7f76819d6922b78eb18fee1fdf66f6 Mon Sep 17 00:00:00 2001 From: ikaroskun Date: Thu, 20 Oct 2022 18:51:23 +0800 Subject: [PATCH 012/141] feat(token): :sparkles: add api for refresh access token --- pyyoutube/client.py | 37 +++++++++++++++++++++++++++++++++- pyyoutube/resources/channel.py | 2 +- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/pyyoutube/client.py b/pyyoutube/client.py index 1cc3d3ee..328278b6 100644 --- a/pyyoutube/client.py +++ b/pyyoutube/client.py @@ -139,6 +139,7 @@ def request( method: str = "GET", params: Optional[dict] = None, data: Optional[dict] = None, + json: Optional[dict] = None, enforce_auth: bool = True, **kwargs, ): @@ -153,6 +154,8 @@ def request( Object to send in the query string of the request. data: Object to send in the body of the request. + json: + Object json to send in the body of the request. enforce_auth: Whether to use user credentials. kwargs: @@ -228,7 +231,7 @@ def _get_oauth_session( If not provide will use default https://localhost/ scope: Permission scope for authorization. - see more: https://developers.google.com/youtube/v3/guides/auth/client-side-web-apps#identify-access-scopes + see more: https://developers.google.com/identity/protocols/oauth2/scopes#youtube state: State sting for authorization. **kwargs: @@ -368,3 +371,35 @@ def generate_access_token( self.access_token = token["access_token"] self.refresh_token = token.get("refresh_token") return token if return_json else AccessToken.from_dict(token) + + def refresh_access_token( + self, refresh_token: str, return_json: bool = False, **kwargs + ) -> Union[dict, AccessToken]: + """Refresh new access token. + + Args: + refresh_token: + The refresh token returned from the authorization code exchange. + return_json: + Type for returned data. If you set True JSON data will be returned. + **kwargs: + Additional parameters for request. + + Returns: + Access token data. + """ + response = self.request( + method="POST", + path=self.EXCHANGE_ACCESS_TOKEN_URL, + data={ + "client_id": self.client_id, + "client_secret": self.client_secret, + "refresh_token": refresh_token, + "grant_type": "refresh_token", + }, + enforce_auth=False, + proxies=self.proxies, + **kwargs, + ) + data = self.parse_response(response) + return data if return_json else AccessToken.from_dict(data) diff --git a/pyyoutube/resources/channel.py b/pyyoutube/resources/channel.py index 4d8b19d3..5740cc80 100644 --- a/pyyoutube/resources/channel.py +++ b/pyyoutube/resources/channel.py @@ -154,7 +154,7 @@ def update( method="PUT", path="channels", params=params, - data=body, + json=body, ) data = self._client.parse_response(response=response) return data if return_json else ChannelListResponse.from_dict(data) From d5935c7870da57b1ddedcde50d9583a7f60dbbb4 Mon Sep 17 00:00:00 2001 From: ikaroskun Date: Thu, 20 Oct 2022 19:03:53 +0800 Subject: [PATCH 013/141] feat(token): :sparkles: Add api for revoke token --- pyyoutube/client.py | 35 ++++++++++++++++++++++++++++++++++- pyyoutube/error.py | 9 +++++++-- 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/pyyoutube/client.py b/pyyoutube/client.py index 328278b6..8a93127a 100644 --- a/pyyoutube/client.py +++ b/pyyoutube/client.py @@ -28,6 +28,7 @@ class Client: BASE_URL = "https://www.googleapis.com/youtube/v3/" AUTHORIZATION_URL = "https://accounts.google.com/o/oauth2/v2/auth" EXCHANGE_ACCESS_TOKEN_URL = "https://oauth2.googleapis.com/token" + REVOKE_TOKEN_URL = "https://oauth2.googleapis.com/revoke" DEFAULT_REDIRECT_URI = "https://localhost/" DEFAULT_SCOPE = [ @@ -189,7 +190,8 @@ def request( method=method, url=path, params=params, - json=data, + data=data, + json=json, proxies=self.proxies, timeout=self.timeout, **kwargs, @@ -403,3 +405,34 @@ def refresh_access_token( ) data = self.parse_response(response) return data if return_json else AccessToken.from_dict(data) + + def revoke_access_token( + self, + token: str, + ) -> bool: + """Revoke token. + + Notes: + If the token is an access token which has a corresponding refresh token, + the refresh token will also be revoked. + + Args: + token: + Can be an access token or a refresh token. + + Returns: + Revoked status + + Raises: + PyYouTubeException: When occur errors. + """ + response = self.request( + method="POST", + path=self.REVOKE_TOKEN_URL, + params={"token": token}, + enforce_auth=False, + proxies=self.proxies, + ) + if response.ok: + return True + self.parse_response(response) diff --git a/pyyoutube/error.py b/pyyoutube/error.py index c6deecb4..99db75b3 100644 --- a/pyyoutube/error.py +++ b/pyyoutube/error.py @@ -51,8 +51,13 @@ def error_handler(self): elif isinstance(self.response, Response): res_data = self.response.json() if "error" in res_data: - self.status_code = res_data["error"]["code"] - self.message = res_data["error"]["message"] + error = res_data["error"] + if isinstance(error, dict): + self.status_code = res_data["error"]["code"] + self.message = res_data["error"]["message"] + else: + self.status_code = self.response.status_code + self.message = error self.error_type = "YouTubeException" def __repr__(self): From c56d66518d52e6ae622c15f813d6ea5f008f752e Mon Sep 17 00:00:00 2001 From: ikaroskun Date: Mon, 24 Oct 2022 10:59:28 +0800 Subject: [PATCH 014/141] feat(update): :sparkles: Add support to update channel with dataclass parameters --- pyyoutube/client.py | 7 ++++++- pyyoutube/models/base.py | 7 ++++++- pyyoutube/resources/__init__.py | 2 +- pyyoutube/resources/channel.py | 8 ++++---- 4 files changed, 17 insertions(+), 7 deletions(-) diff --git a/pyyoutube/client.py b/pyyoutube/client.py index 8a93127a..bed9cd02 100644 --- a/pyyoutube/client.py +++ b/pyyoutube/client.py @@ -11,6 +11,7 @@ from requests_oauthlib.oauth2_session import OAuth2Session import pyyoutube.resources as resources +from pyyoutube.models.base import BaseModel from pyyoutube.error import ErrorCode, ErrorMessage, PyYouTubeException from pyyoutube.models import ( AccessToken, @@ -37,7 +38,7 @@ class Client: ] DEFAULT_STATE = "Python-YouTube" - channel = resources.Channel() + channel = resources.ChannelResource() def __new__(cls, *args, **kwargs): self = super().__new__(cls) @@ -185,6 +186,10 @@ def request( self.add_token_to_headers() params = self.add_api_key_to_params(params=params) + # If json is dataclass convert to dict + if isinstance(json, BaseModel): + json = json.to_dict_ignore_none() + try: response = self.session.request( method=method, diff --git a/pyyoutube/models/base.py b/pyyoutube/models/base.py index e54372d6..e62c2577 100644 --- a/pyyoutube/models/base.py +++ b/pyyoutube/models/base.py @@ -1,4 +1,4 @@ -from dataclasses import dataclass +from dataclasses import dataclass, asdict from typing import Type, TypeVar from dataclasses_json import DataClassJsonMixin @@ -16,3 +16,8 @@ def from_dict(cls: Type[A], kvs: Json, *, infer_missing=False) -> A: # save original data for lookup cls._json = kvs return _decode_dataclass(cls, kvs, infer_missing) + + def to_dict_ignore_none(self): + return asdict( + obj=self, dict_factory=lambda x: {k: v for (k, v) in x if v is not None} + ) diff --git a/pyyoutube/resources/__init__.py b/pyyoutube/resources/__init__.py index 8ca2339e..10c7cda1 100644 --- a/pyyoutube/resources/__init__.py +++ b/pyyoutube/resources/__init__.py @@ -1 +1 @@ -from .channel import Channel # noqa +from .channel import ChannelResource # noqa diff --git a/pyyoutube/resources/channel.py b/pyyoutube/resources/channel.py index 5740cc80..d9838407 100644 --- a/pyyoutube/resources/channel.py +++ b/pyyoutube/resources/channel.py @@ -5,11 +5,11 @@ from pyyoutube.error import PyYouTubeException, ErrorMessage, ErrorCode from pyyoutube.resources.base_resource import Resource -from pyyoutube.models import ChannelListResponse +from pyyoutube.models import Channel, ChannelListResponse from pyyoutube.utils.params_checker import enf_parts -class Channel(Resource): +class ChannelResource(Resource): """A channel resource contains information about a YouTube channel. References: https://developers.google.com/youtube/v3/docs/channels @@ -111,7 +111,7 @@ def list( def update( self, part: str, - body: Optional[dict], + body: Union[dict, Channel], on_behalf_of_content_owner: Optional[str] = None, return_json: bool = False, **kwargs, @@ -126,7 +126,7 @@ def update( The part parameter serves two purposes in this operation. It identifies the properties that the write operation will set as well as the properties that the API response will include. body: - Provide a channel resource in the request body. + Provide channel data in the request body. You can give dataclass or just a dict with data. on_behalf_of_content_owner: The onBehalfOfContentOwner parameter indicates that the request's authorization credentials identify a YouTube CMS user who is acting on behalf of the content From c0c09b8e75b6772c884094a5a39a1379e01f9573 Mon Sep 17 00:00:00 2001 From: ikaroskun Date: Mon, 31 Oct 2022 19:18:12 +0800 Subject: [PATCH 015/141] feat(activities): :sparkles: add activities resource api --- pyyoutube/client.py | 3 +- pyyoutube/models/__init__.py | 95 +--------- pyyoutube/models/activity.py | 38 ++-- pyyoutube/models/channel.py | 162 +++++++++--------- pyyoutube/resources/__init__.py | 3 +- pyyoutube/resources/activities.py | 89 ++++++++++ .../resources/{channel.py => channels.py} | 2 +- pyyoutube/utils/constants.py | 14 +- 8 files changed, 203 insertions(+), 203 deletions(-) create mode 100644 pyyoutube/resources/activities.py rename pyyoutube/resources/{channel.py => channels.py} (99%) diff --git a/pyyoutube/client.py b/pyyoutube/client.py index bed9cd02..ecde1a60 100644 --- a/pyyoutube/client.py +++ b/pyyoutube/client.py @@ -38,7 +38,8 @@ class Client: ] DEFAULT_STATE = "Python-YouTube" - channel = resources.ChannelResource() + activities = resources.ActivitiesResource() + channels = resources.ChannelsResource() def __new__(cls, *args, **kwargs): self = super().__new__(cls) diff --git a/pyyoutube/models/__init__.py b/pyyoutube/models/__init__.py index de757642..b6535aff 100644 --- a/pyyoutube/models/__init__.py +++ b/pyyoutube/models/__init__.py @@ -1,25 +1,11 @@ -from .activity import ( - Activity, - ActivityContentDetails, - ActivityListResponse, - ActivitySnippet, -) +from .activity import * # noqa from .auth import AccessToken, UserProfile from .caption import Caption, CaptionListResponse, CaptionSnippet from .category import ( VideoCategory, VideoCategoryListResponse, ) -from .channel import ( - Channel, - ChannelBrandingSetting, - ChannelContentDetails, - ChannelListResponse, - ChannelSnippet, - ChannelStatistics, - ChannelStatus, - ChannelTopicDetails, -) +from .channel import * # noqa from .channel_section import ( ChannelSection, ChannelSectionContentDetails, @@ -84,80 +70,3 @@ ) from .member import Member, MemberListResponse from .memberships_level import MembershipsLevel, MembershipsLevelListResponse - -__all__ = [ - "AccessToken", - "Activity", - "ActivityContentDetails", - "ActivityListResponse", - "ActivitySnippet", - "Caption", - "CaptionListResponse", - "CaptionSnippet", - "Channel", - "ChannelBrandingSetting", - "ChannelContentDetails", - "ChannelListResponse", - "ChannelSnippet", - "ChannelStatistics", - "ChannelStatus", - "ChannelTopicDetails", - "ChannelSection", - "ChannelSectionContentDetails", - "ChannelSectionSnippet", - "ChannelSectionResponse", - "Comment", - "CommentListResponse", - "CommentSnippet", - "CommentThread", - "CommentThreadListResponse", - "CommentThreadReplies", - "CommentThreadSnippet", - "Playlist", - "PlaylistContentDetails", - "PlaylistItem", - "PlaylistItemContentDetails", - "PlaylistItemListResponse", - "PlaylistItemSnippet", - "PlaylistItemStatus", - "PlaylistItemListResponse", - "Comment", - "CommentSnippet", - "CommentListResponse", - "CommentThread", - "CommentThreadSnippet", - "CommentThreadReplies", - "CommentThreadListResponse", - "SearchResult", - "SearchListResponse", - "SearchResultSnippet", - "SearchResultId", - "PlaylistListResponse", - "PlaylistSnippet", - "PlaylistStatus", - "Subscription", - "SubscriptionContentDetails", - "SubscriptionListResponse", - "SubscriptionSnippet", - "SubscriptionSubscriberSnippet", - "UserProfile", - "Video", - "VideoCategory", - "VideoCategoryListResponse", - "VideoContentDetails", - "VideoListResponse", - "VideoSnippet", - "VideoStatistics", - "VideoStatus", - "VideoTopicDetails", - "I18nLanguage", - "I18nLanguageListResponse", - "I18nRegion", - "I18nRegionListResponse", - "VideoAbuseReportReason", - "VideoAbuseReportReasonListResponse", - "Member", - "MemberListResponse", - "MembershipsLevel", - "MembershipsLevelListResponse", -] diff --git a/pyyoutube/models/activity.py b/pyyoutube/models/activity.py index 8d6c2262..4dcdbfc2 100644 --- a/pyyoutube/models/activity.py +++ b/pyyoutube/models/activity.py @@ -10,24 +10,6 @@ from .mixins import DatetimeTimeMixin -@dataclass -class ActivitySnippet(BaseModel, DatetimeTimeMixin): - """ - A class representing the activity snippet resource info. - - Refer: https://developers.google.com/youtube/v3/docs/activities#snippet - """ - - publishedAt: Optional[str] = field(default=None, repr=False) - channelId: Optional[str] = field(default=None, repr=False) - title: Optional[str] = field(default=None) - description: Optional[str] = field(default=None) - thumbnails: Optional[Thumbnails] = field(default=None, repr=False) - channelTitle: Optional[str] = field(default=None, repr=False) - type: Optional[str] = field(default=None, repr=False) - groupId: Optional[str] = field(default=None, repr=False) - - @dataclass class ActivityContentDetailsUpload(BaseModel): """ @@ -128,8 +110,8 @@ class ActivityContentDetailsSocial(BaseModel): Refer: https://developers.google.com/youtube/v3/docs/activities#contentDetails.social """ - resourceId: Optional[ResourceId] = field(default=None) type: Optional[str] = field(default=None) + resourceId: Optional[ResourceId] = field(default=None) author: Optional[str] = field(default=None) referenceUrl: Optional[str] = field(default=None) imageUrl: Optional[str] = field(default=None) @@ -146,6 +128,24 @@ class ActivityContentDetailsChannelItem(BaseModel): resourceId: Optional[ResourceId] = field(default=None) +@dataclass +class ActivitySnippet(BaseModel, DatetimeTimeMixin): + """ + A class representing the activity snippet resource info. + + Refer: https://developers.google.com/youtube/v3/docs/activities#snippet + """ + + publishedAt: Optional[str] = field(default=None, repr=False) + channelId: Optional[str] = field(default=None, repr=False) + title: Optional[str] = field(default=None) + description: Optional[str] = field(default=None) + thumbnails: Optional[Thumbnails] = field(default=None, repr=False) + channelTitle: Optional[str] = field(default=None, repr=False) + type: Optional[str] = field(default=None, repr=False) + groupId: Optional[str] = field(default=None, repr=False) + + @dataclass class ActivityContentDetails(BaseModel): """ diff --git a/pyyoutube/models/channel.py b/pyyoutube/models/channel.py index 4f8ce4a4..2b4a7d6e 100644 --- a/pyyoutube/models/channel.py +++ b/pyyoutube/models/channel.py @@ -1,20 +1,40 @@ """ These are channel related models. + + References: https://developers.google.com/youtube/v3/docs/channels#properties """ from dataclasses import dataclass, field from typing import List, Optional from .base import BaseModel -from .common import BaseResource, BaseTopicDetails, Thumbnails, BaseApiResponse +from .common import ( + BaseResource, + BaseTopicDetails, + Thumbnails, + BaseApiResponse, + Localized, +) from .mixins import DatetimeTimeMixin @dataclass -class ChannelBrandingChannel(BaseModel): +class RelatedPlaylists(BaseModel): + """ + A class representing the channel's related playlists info + + References: https://developers.google.com/youtube/v3/docs/channels#contentDetails.relatedPlaylists + """ + + likes: Optional[str] = field(default=None, repr=False) + uploads: Optional[str] = field(default=None) + + +@dataclass +class ChannelBrandingSettingChannel(BaseModel): """ A class representing the channel branding setting's channel info. - Refer: https://developers.google.com/youtube/v3/docs/channels#brandingSettings.channel + References: https://developers.google.com/youtube/v3/docs/channels#brandingSettings.channel """ title: Optional[str] = field(default=None) @@ -28,76 +48,57 @@ class ChannelBrandingChannel(BaseModel): @dataclass -class ChannelBrandingImage(BaseModel): +class ChannelBrandingSettingImage(BaseModel): """ A class representing the channel branding setting's image info. - Refer: https://developers.google.com/youtube/v3/docs/channels#brandingSettings.image + References: https://developers.google.com/youtube/v3/docs/channels#brandingSettings.image """ - bannerImageUrl: Optional[str] = field(default=None) - bannerMobileImageUrl: Optional[str] = field(default=None, repr=False) - watchIconImageUrl: Optional[str] = field(default=None, repr=False) - trackingImageUrl: Optional[str] = field(default=None, repr=False) - bannerTabletLowImageUrl: Optional[str] = field(default=None, repr=False) - bannerTabletImageUrl: Optional[str] = field(default=None, repr=None) - bannerTabletHdImageUrl: Optional[str] = field(default=None, repr=None) - bannerTabletExtraHdImageUrl: Optional[str] = field(default=None, repr=None) - bannerMobileLowImageUrl: Optional[str] = field(default=None, repr=None) - bannerMobileMediumHdImageUrl: Optional[str] = field(default=None, repr=None) - bannerMobileHdImageUrl: Optional[str] = field(default=None, repr=None) - bannerMobileExtraHdImageUrl: Optional[str] = field(default=None, repr=None) - bannerTvImageUrl: Optional[str] = field(default=None, repr=None) - bannerTvLowImageUrl: Optional[str] = field(default=None, repr=None) - bannerTvMediumImageUrl: Optional[str] = field(default=None, repr=None) - bannerTvHighImageUrl: Optional[str] = field(default=None, repr=None) - bannerExternalUrl: Optional[str] = field(default=None, repr=None) + bannerExternalUrl: Optional[str] = field(default=None, repr=False) @dataclass -class ChannelBrandingHint(BaseModel): - """ - A class representing the channel branding setting's hint info. - - Refer: https://developers.google.com/youtube/v3/docs/channels#brandingSettings.hints - """ - - property: Optional[str] = field(default=None) - value: Optional[str] = field(default=None) - - -@dataclass -class ChannelBrandingSetting(BaseModel): +class ChannelSnippet(BaseModel, DatetimeTimeMixin): """ - A class representing the channel branding settings info. + A class representing the channel snippet info. - Refer: https://developers.google.com/youtube/v3/docs/channels#brandingSettings + References: https://developers.google.com/youtube/v3/docs/channels#snippet """ - channel: Optional[ChannelBrandingChannel] = field(default=None) + title: Optional[str] = field(default=None) + description: Optional[str] = field(default=None) + customUrl: Optional[str] = field(default=None, repr=False) + publishedAt: Optional[str] = field(default=None, repr=False) + thumbnails: Optional[Thumbnails] = field(default=None, repr=False) + defaultLanguage: Optional[str] = field(default=None, repr=False) + localized: Optional[Localized] = field(default=None, repr=False) + country: Optional[str] = field(default=None, repr=False) @dataclass -class RelatedPlaylists(BaseModel): +class ChannelContentDetails(BaseModel): """ - A class representing the channel's related playlist info + A class representing the channel's content info. - Refer: https://developers.google.com/youtube/v3/docs/channels#contentDetails.relatedPlaylists + References: https://developers.google.com/youtube/v3/docs/channels#contentDetails """ - likes: Optional[str] = field(default=None, repr=False) - uploads: Optional[str] = field(default=None) + relatedPlaylists: Optional[RelatedPlaylists] = field(default=None) @dataclass -class ChannelContentDetails(BaseModel): +class ChannelStatistics(BaseModel): """ - A class representing the channel's content info. + A class representing the Channel's statistics info. - Refer: https://developers.google.com/youtube/v3/docs/channels#contentDetails + References: https://developers.google.com/youtube/v3/docs/channels#statistics """ - relatedPlaylists: Optional[RelatedPlaylists] = field(default=None) + viewCount: Optional[int] = field(default=None) + subscriberCount: Optional[int] = field(default=None) + hiddenSubscriberCount: Optional[bool] = field(default=None, repr=False) + videoCount: Optional[int] = field(default=None, repr=False) @dataclass @@ -105,7 +106,7 @@ class ChannelTopicDetails(BaseTopicDetails): """ A class representing the channel's topic detail info. - Refer: https://developers.google.com/youtube/v3/docs/channels#topicDetails + References: https://developers.google.com/youtube/v3/docs/channels#topicDetails """ # Important: @@ -116,60 +117,54 @@ class ChannelTopicDetails(BaseTopicDetails): @dataclass -class Localized(BaseModel): +class ChannelStatus(BaseModel): """ - A class representing the channel snippet localized info. + A class representing the channel's status info. - Refer: https://developers.google.com/youtube/v3/docs/channels#snippet.localized + References: https://developers.google.com/youtube/v3/docs/channels#status """ - title: Optional[str] = field(default=None) - description: Optional[str] = field(default=None, repr=False) + privacyStatus: Optional[str] = field(default=None) + isLinked: Optional[bool] = field(default=None, repr=False) + longUploadsStatus: Optional[str] = field(default=None, repr=False) + madeForKids: Optional[bool] = field(default=None, repr=False) + selfDeclaredMadeForKids: Optional[bool] = field(default=None, repr=False) @dataclass -class ChannelSnippet(BaseModel, DatetimeTimeMixin): +class ChannelBrandingSetting(BaseModel): """ - A class representing the channel snippet info. + A class representing the channel branding settings info. - Refer: https://developers.google.com/youtube/v3/docs/channels#snippet + References: https://developers.google.com/youtube/v3/docs/channels#brandingSettings """ - title: Optional[str] = field(default=None) - description: Optional[str] = field(default=None) - customUrl: Optional[str] = field(default=None, repr=False) - publishedAt: Optional[str] = field(default=None, repr=False) - thumbnails: Optional[Thumbnails] = field(default=None, repr=False) - defaultLanguage: Optional[str] = field(default=None, repr=False) - localized: Optional[Localized] = field(default=None, repr=False) - country: Optional[str] = field(default=None, repr=False) + channel: Optional[ChannelBrandingSettingChannel] = field(default=None) + image: Optional[ChannelBrandingSettingImage] = field(default=None) @dataclass -class ChannelStatistics(BaseModel): - """ - A class representing the Channel's statistics info. +class ChannelAuditDetails(BaseModel): + """A class representing the channel audit details info. - Refer: https://developers.google.com/youtube/v3/docs/channels#statistics + References: https://developers.google.com/youtube/v3/docs/channels#auditDetails """ - viewCount: Optional[int] = field(default=None) - subscriberCount: Optional[int] = field(default=None) - hiddenSubscriberCount: Optional[bool] = field(default=None, repr=False) - videoCount: Optional[int] = field(default=None, repr=False) + overallGoodStanding: Optional[bool] = field(default=None) + communityGuidelinesGoodStanding: Optional[bool] = field(default=None, repr=True) + copyrightStrikesGoodStanding: Optional[bool] = field(default=None, repr=True) + contentIdClaimsGoodStanding: Optional[bool] = field(default=None, repr=True) @dataclass -class ChannelStatus(BaseModel): - """ - A class representing the channel's status info. +class ChannelContentOwnerDetails(BaseModel): + """A class representing the channel data relevant for YouTube Partners. - Refer: https://developers.google.com/youtube/v3/docs/channels#status + References: https://developers.google.com/youtube/v3/docs/channels#contentOwnerDetails """ - privacyStatus: Optional[str] = field(default=None) - isLinked: Optional[bool] = field(default=None, repr=False) - longUploadsStatus: Optional[str] = field(default=None, repr=False) + contentOwner: Optional[str] = field(default=None) + timeLinked: Optional[str] = field(default=None) @dataclass @@ -177,7 +172,7 @@ class Channel(BaseResource): """ A class representing the channel's info. - Refer: https://developers.google.com/youtube/v3/docs/channels + References: https://developers.google.com/youtube/v3/docs/channels """ snippet: Optional[ChannelSnippet] = field(default=None, repr=False) @@ -186,6 +181,11 @@ class Channel(BaseResource): topicDetails: Optional[ChannelTopicDetails] = field(default=None, repr=False) status: Optional[ChannelStatus] = field(default=None, repr=False) brandingSettings: Optional[ChannelBrandingSetting] = field(default=None, repr=False) + auditDetails: Optional[ChannelAuditDetails] = field(default=None, repr=False) + contentOwnerDetails: Optional[ChannelContentOwnerDetails] = field( + default=None, repr=False + ) + localizations: Optional[dict] = field(default=None, repr=False) @dataclass @@ -193,7 +193,7 @@ class ChannelListResponse(BaseApiResponse): """ A class representing the channel's retrieve response info. - Refer: https://developers.google.com/youtube/v3/docs/channels/list#response_1 + References: https://developers.google.com/youtube/v3/docs/channels/list#response """ items: Optional[List[Channel]] = field(default=None, repr=False) diff --git a/pyyoutube/resources/__init__.py b/pyyoutube/resources/__init__.py index 10c7cda1..270a5f0e 100644 --- a/pyyoutube/resources/__init__.py +++ b/pyyoutube/resources/__init__.py @@ -1 +1,2 @@ -from .channel import ChannelResource # noqa +from .activities import ActivitiesResource # noqa +from .channels import ChannelsResource # noqa diff --git a/pyyoutube/resources/activities.py b/pyyoutube/resources/activities.py new file mode 100644 index 00000000..9885957b --- /dev/null +++ b/pyyoutube/resources/activities.py @@ -0,0 +1,89 @@ +""" + Activities resource implementation +""" +from typing import Optional, Union + +from pyyoutube.error import PyYouTubeException, ErrorMessage, ErrorCode +from pyyoutube.resources.base_resource import Resource +from pyyoutube.models import ActivityListResponse +from pyyoutube.utils.params_checker import enf_parts + + +class ActivitiesResource(Resource): + """An activity resource contains information about an action that a particular channel, + or user, has taken on YouTube. + + References: https://developers.google.com/youtube/v3/docs/activities + """ + + def list( + self, + parts: Optional[Union[str, list, tuple, set]] = None, + channel_id: Optional[str] = None, + mine: Optional[bool] = None, + max_results: Optional[int] = None, + page_token: Optional[str] = None, + published_after: Optional[str] = None, + published_before: Optional[str] = None, + region_code: Optional[str] = None, + return_json: bool = False, + **kwargs, + ) -> Union[dict, ActivityListResponse]: + """Returns a list of channel activity events that match the request criteria. + + Args: + parts: + Comma-separated list of one or more activity resource properties. + channel_id: + The channelId parameter specifies a unique YouTube channel ID. + mine: + This parameter can only be used in a properly authorized request. Set this parameter's value + to true to retrieve a feed of the authenticated user's activities. + max_results: + The parameter specifies the maximum number of items that should be returned + the result set. + Acceptable values are 0 to 50, inclusive. The default value is 5. + page_token: + The parameter identifies a specific page in the result set that should be returned. + published_after: + The parameter specifies the earliest date and time that an activity could + have occurred for that activity to be included in the API response. + published_before: + The parameter specifies the date and time before which an activity must + have occurred for that activity to be included in the API response. + region_code: + The parameter instructs the API to return results for the specified country. + return_json: + Type for returned data. If you set True JSON data will be returned. + kwargs: + Additional parameters for system parameters. + Refer: https://cloud.google.com/apis/docs/system-parameters. + + Returns: + Activities data + + """ + params = { + "part": enf_parts(resource="activities", value=parts), + "maxResults": max_results, + "pageToken": page_token, + "publishedAfter": published_after, + "publishedBefore": published_before, + "regionCode": region_code, + **kwargs, + } + if channel_id is not None: + params["channelId"] = channel_id + elif mine is not None: + params["mine"] = mine + else: + raise PyYouTubeException( + ErrorMessage( + status_code=ErrorCode.MISSING_PARAMS, + message=f"Specify at least one of for_username, id, managedByMe or mine", + ) + ) + + response = self._client.request(path="activities", params=params) + data = self._client.parse_response(response=response) + return data if return_json else ActivityListResponse.from_dict(data) diff --git a/pyyoutube/resources/channel.py b/pyyoutube/resources/channels.py similarity index 99% rename from pyyoutube/resources/channel.py rename to pyyoutube/resources/channels.py index d9838407..b590e4e4 100644 --- a/pyyoutube/resources/channel.py +++ b/pyyoutube/resources/channels.py @@ -9,7 +9,7 @@ from pyyoutube.utils.params_checker import enf_parts -class ChannelResource(Resource): +class ChannelsResource(Resource): """A channel resource contains information about a YouTube channel. References: https://developers.google.com/youtube/v3/docs/channels diff --git a/pyyoutube/utils/constants.py b/pyyoutube/utils/constants.py index a35e2dd2..c557124e 100644 --- a/pyyoutube/utils/constants.py +++ b/pyyoutube/utils/constants.py @@ -2,6 +2,12 @@ some constants for YouTube """ +ACTIVITIES_RESOURCE_PROPERTIES = { + "id", + "snippet", + "contentDetails", +} + CHANNEL_RESOURCE_PROPERTIES = { "id", "brandingSettings", @@ -60,12 +66,6 @@ "subscriberSnippet", } -ACTIVITIES_RESOURCE_PROPERTIES = { - "id", - "snippet", - "contentDetails", -} - CAPTIONS_RESOURCE_PROPERTIES = { "id", "snippet", @@ -97,6 +97,7 @@ } RESOURCE_PARTS_MAPPING = { + "activities": ACTIVITIES_RESOURCE_PROPERTIES, "channels": CHANNEL_RESOURCE_PROPERTIES, "channelSections": CHANNEL_SECTIONS_PROPERTIES, "playlists": PLAYLIST_RESOURCE_PROPERTIES, @@ -108,7 +109,6 @@ "guideCategories": GUIDE_CATEGORY_RESOURCE_PROPERTIES, "search": SEARCH_RESOURCE_PROPERTIES, "subscriptions": SUBSCRIPTION_RESOURCE_PROPERTIES, - "activities": ACTIVITIES_RESOURCE_PROPERTIES, "captions": CAPTIONS_RESOURCE_PROPERTIES, "i18nRegions": I18N_REGION_PROPERTIES, "i18nLanguages": I18N_LANGUAGE_PROPERTIES, From ddbd026584606ff813633588676e10b45e6296c3 Mon Sep 17 00:00:00 2001 From: ikaroskun Date: Mon, 31 Oct 2022 21:30:20 +0800 Subject: [PATCH 016/141] WIP(captions): :construction: working with captions api --- pyyoutube/models/__init__.py | 2 +- pyyoutube/resources/captions.py | 59 +++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) create mode 100644 pyyoutube/resources/captions.py diff --git a/pyyoutube/models/__init__.py b/pyyoutube/models/__init__.py index b6535aff..e03d836c 100644 --- a/pyyoutube/models/__init__.py +++ b/pyyoutube/models/__init__.py @@ -1,6 +1,6 @@ from .activity import * # noqa from .auth import AccessToken, UserProfile -from .caption import Caption, CaptionListResponse, CaptionSnippet +from .caption import * # noqa from .category import ( VideoCategory, VideoCategoryListResponse, diff --git a/pyyoutube/resources/captions.py b/pyyoutube/resources/captions.py new file mode 100644 index 00000000..b0266f73 --- /dev/null +++ b/pyyoutube/resources/captions.py @@ -0,0 +1,59 @@ +""" + Captions resource implementation +""" +from typing import Optional, Union + +from pyyoutube.resources.base_resource import Resource +from pyyoutube.models import CaptionListResponse +from pyyoutube.utils.params_checker import enf_comma_separated, enf_parts + + +class CaptionsResource(Resource): + """A caption resource represents a YouTube caption track + + References: https://developers.google.com/youtube/v3/docs/captions + """ + + def list( + self, + parts: Optional[Union[str, list, tuple, set]] = None, + video_id: Optional[str] = None, + id: Optional[Union[str, list, tuple, set]] = None, + on_behalf_of_content_owner: Optional[str] = None, + return_json: bool = False, + **kwargs: Optional[dict], + ) -> Union[dict, CaptionListResponse]: + """Returns a list of caption tracks that are associated with a specified video. + + Args: + parts: + Comma-separated list of one or more caption resource properties. + video_id: + The parameter specifies the YouTube video ID of the video for which the API + should return caption tracks. + id: + The id parameter specifies a comma-separated list of IDs that identify the + caption resources that should be retrieved. + on_behalf_of_content_owner: + This parameter can only be used in a properly authorized request. + Note: This parameter is intended exclusively for YouTube content partners. + return_json: + Type for returned data. If you set True JSON data will be returned. + kwargs: + Additional parameters for system parameters. + Refer: https://cloud.google.com/apis/docs/system-parameters. + + Returns: + Caption data + """ + + params = { + "part": enf_parts(resource="", value=parts), + "videoId": video_id, + "id": enf_comma_separated(field="id", value=id), + "onBehalfOfContentOwner": on_behalf_of_content_owner, + **kwargs, + } + response = self._client.request(path="captions", params=params) + data = self._client.parse_response(response=response) + return data if return_json else CaptionListResponse.from_dict(data) From f1bc93e733ea79ce22bd94a213c3e14a917ca01f Mon Sep 17 00:00:00 2001 From: ikaros Date: Mon, 31 Oct 2022 23:06:12 +0800 Subject: [PATCH 017/141] WIP(caption): :construction: how to upload file? --- pyyoutube/resources/captions.py | 52 ++++++++++++++++++++++++++++++++- 1 file changed, 51 insertions(+), 1 deletion(-) diff --git a/pyyoutube/resources/captions.py b/pyyoutube/resources/captions.py index b0266f73..f0d7c22a 100644 --- a/pyyoutube/resources/captions.py +++ b/pyyoutube/resources/captions.py @@ -4,7 +4,7 @@ from typing import Optional, Union from pyyoutube.resources.base_resource import Resource -from pyyoutube.models import CaptionListResponse +from pyyoutube.models import Caption, CaptionListResponse from pyyoutube.utils.params_checker import enf_comma_separated, enf_parts @@ -57,3 +57,53 @@ def list( response = self._client.request(path="captions", params=params) data = self._client.parse_response(response=response) return data if return_json else CaptionListResponse.from_dict(data) + + def insert( + self, + part: str, + body: Union[dict, Caption], + on_behalf_of_content_owner: Optional[str] = None, + sync: Optional[bool] = None, + return_json: bool = False, + **kwargs, + ) -> Union[dict, CaptionListResponse]: + """Uploads a caption track. + + Args: + part: + The part parameter specifies the caption resource parts that + the API response will include. Set the parameter value to snippet. + body: + Provide caption data in the request body. You can give dataclass or just a dict with data. + on_behalf_of_content_owner: + This parameter can only be used in a properly authorized request. + Note: This parameter is intended exclusively for YouTube content partners. + sync: + The sync parameter indicates whether YouTube should automatically synchronize the caption + file with the audio track of the video. + If you set the value to true, YouTube will disregard any time codes that are in the uploaded + caption file and generate new time codes for the captions. + return_json: + Type for returned data. If you set True JSON data will be returned. + **kwargs: + Additional parameters for system parameters. + Refer: https://cloud.google.com/apis/docs/system-parameters. + + Returns: + Caption data. + """ + + params = { + "part": part, + "onBehalfOfContentOwner": on_behalf_of_content_owner, + "sync": sync, + **kwargs, + } + response = self._client.request( + method="POST", + path="captions", + params=params, + json=body, + ) + data = self._client.parse_response(response=response) + return data if return_json else CaptionListResponse.from_dict(data) From 3b26495e38e1fe45ab0d056fc56a107a295bb18e Mon Sep 17 00:00:00 2001 From: ikaroskun Date: Thu, 3 Nov 2022 18:18:00 +0800 Subject: [PATCH 018/141] feat(caption): :sparkles: Add download and delete caption api --- pyyoutube/resources/captions.py | 83 +++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/pyyoutube/resources/captions.py b/pyyoutube/resources/captions.py index f0d7c22a..6d2fa010 100644 --- a/pyyoutube/resources/captions.py +++ b/pyyoutube/resources/captions.py @@ -3,6 +3,8 @@ """ from typing import Optional, Union +from requests import Response + from pyyoutube.resources.base_resource import Resource from pyyoutube.models import Caption, CaptionListResponse from pyyoutube.utils.params_checker import enf_comma_separated, enf_parts @@ -107,3 +109,84 @@ def insert( ) data = self._client.parse_response(response=response) return data if return_json else CaptionListResponse.from_dict(data) + + def download( + self, + caption_id: str, + on_behalf_of_content_owner: Optional[str] = None, + tfmt: Optional[str] = None, + tlang: Optional[str] = None, + **kwargs, + ) -> Response: + """Downloads a caption track. + + Args: + caption_id: + ID for the caption track that is being deleted. + on_behalf_of_content_owner: + This parameter can only be used in a properly authorized request. + Note: This parameter is intended exclusively for YouTube content partners. + tfmt: + Specifies that the caption track should be returned in a specific format. + Supported values are: + sbv – SubViewer subtitle + scc – Scenarist Closed Caption format + srt – SubRip subtitle + ttml – Timed Text Markup Language caption + vtt – Web Video Text Tracks caption + tlang: + Specifies that the API response should return a translation of the specified caption track. + **kwargs: + Additional parameters for system parameters. + Refer: https://cloud.google.com/apis/docs/system-parameters. + + Returns: + Response form YouTube. + """ + params = { + "id": caption_id, + "onBehalfOfContentOwner": on_behalf_of_content_owner, + "tfmt": tfmt, + "tlang": tlang, + **kwargs, + } + response = self._client.request( + path=f"captions/{caption_id}", + params=params, + ) + return response + + def delete( + self, + caption_id: str, + on_behalf_of_content_owner: Optional[str] = None, + **kwargs, + ) -> bool: + """Deletes a specified caption track. + + Args: + caption_id: + ID for the caption track that is being deleted. + on_behalf_of_content_owner: + This parameter can only be used in a properly authorized request. + Note: This parameter is intended exclusively for YouTube content partners. + **kwargs: + Additional parameters for system parameters. + Refer: https://cloud.google.com/apis/docs/system-parameters. + + Returns: + Delete status + + Raises: + PyYouTubeException: Request not success. + """ + params = { + "id": caption_id, + "onBehalfOfContentOwner": on_behalf_of_content_owner, + **kwargs, + } + + response = self._client.request(path="captions", params=params) + if response.ok: + return True + self._client.parse_response(response=response) From e0c872b5c3537c73d5c6e662616e49fbe06112a9 Mon Sep 17 00:00:00 2001 From: ikaroskun Date: Thu, 3 Nov 2022 19:21:38 +0800 Subject: [PATCH 019/141] WIP(caption): :construction: work on upload file --- pyyoutube/resources/captions.py | 59 ++++++++++++++++++++++++++++++--- 1 file changed, 54 insertions(+), 5 deletions(-) diff --git a/pyyoutube/resources/captions.py b/pyyoutube/resources/captions.py index 6d2fa010..766037bb 100644 --- a/pyyoutube/resources/captions.py +++ b/pyyoutube/resources/captions.py @@ -20,7 +20,7 @@ def list( self, parts: Optional[Union[str, list, tuple, set]] = None, video_id: Optional[str] = None, - id: Optional[Union[str, list, tuple, set]] = None, + caption_id: Optional[Union[str, list, tuple, set]] = None, on_behalf_of_content_owner: Optional[str] = None, return_json: bool = False, **kwargs: Optional[dict], @@ -33,7 +33,7 @@ def list( video_id: The parameter specifies the YouTube video ID of the video for which the API should return caption tracks. - id: + caption_id: The id parameter specifies a comma-separated list of IDs that identify the caption resources that should be retrieved. on_behalf_of_content_owner: @@ -50,9 +50,9 @@ def list( """ params = { - "part": enf_parts(resource="", value=parts), + "part": enf_parts(resource="captions", value=parts), "videoId": video_id, - "id": enf_comma_separated(field="id", value=id), + "id": enf_comma_separated(field="caption_id", value=caption_id), "onBehalfOfContentOwner": on_behalf_of_content_owner, **kwargs, } @@ -103,7 +103,56 @@ def insert( } response = self._client.request( method="POST", - path="captions", + path="", + params=params, + json=body, + ) + data = self._client.parse_response(response=response) + return data if return_json else CaptionListResponse.from_dict(data) + + def update( + self, + part: str, + body: Union[dict, Caption], + on_behalf_of_content_owner: Optional[str] = None, + sync: Optional[bool] = None, + return_json: bool = False, + **kwargs, + ) -> Union[dict, CaptionListResponse]: + """Updates a caption track. + + Args: + part: + The part parameter specifies the caption resource parts that + the API response will include. Set the parameter value to snippet. + body: + Provide caption data in the request body. You can give dataclass or just a dict with data. + on_behalf_of_content_owner: + This parameter can only be used in a properly authorized request. + Note: This parameter is intended exclusively for YouTube content partners. + sync: + The sync parameter indicates whether YouTube should automatically synchronize the caption + file with the audio track of the video. + If you set the value to true, YouTube will disregard any time codes that are in the uploaded + caption file and generate new time codes for the captions. + return_json: + Type for returned data. If you set True JSON data will be returned. + **kwargs: + Additional parameters for system parameters. + Refer: https://cloud.google.com/apis/docs/system-parameters. + Returns: + Caption data. + + """ + params = { + "part": part, + "onBehalfOfContentOwner": on_behalf_of_content_owner, + "sync": sync, + **kwargs, + } + response = self._client.request( + method="PUT", + path="", params=params, json=body, ) From 190558ceadb1ec1ce862c4f163b3840d7a471f5f Mon Sep 17 00:00:00 2001 From: ikaroskun Date: Tue, 8 Nov 2022 15:51:27 +0800 Subject: [PATCH 020/141] WIP(channelbanner): :construction: update channel banner api --- pyyoutube/models/__init__.py | 1 + pyyoutube/models/channel_banner.py | 23 +++++++++++++ pyyoutube/resources/captions.py | 2 ++ pyyoutube/resources/channel_banners.py | 47 ++++++++++++++++++++++++++ 4 files changed, 73 insertions(+) create mode 100644 pyyoutube/models/channel_banner.py create mode 100644 pyyoutube/resources/channel_banners.py diff --git a/pyyoutube/models/__init__.py b/pyyoutube/models/__init__.py index e03d836c..7b5439ba 100644 --- a/pyyoutube/models/__init__.py +++ b/pyyoutube/models/__init__.py @@ -6,6 +6,7 @@ VideoCategoryListResponse, ) from .channel import * # noqa +from .channel_banner import * # noqa from .channel_section import ( ChannelSection, ChannelSectionContentDetails, diff --git a/pyyoutube/models/channel_banner.py b/pyyoutube/models/channel_banner.py new file mode 100644 index 00000000..ba76da0b --- /dev/null +++ b/pyyoutube/models/channel_banner.py @@ -0,0 +1,23 @@ +""" + There are channel banner related models + + References: https://developers.google.com/youtube/v3/docs/channelBanners#properties +""" + +from dataclasses import dataclass, field +from typing import List, Optional + +from .base import BaseModel + + +@dataclass +class ChannelBanner(BaseModel): + """ + A class representing the channel banner's info. + + References: https://developers.google.com/youtube/v3/docs/channelBanners#resource + """ + + kind: Optional[str] = field(default=None) + etag: Optional[str] = field(default=None, repr=False) + url: Optional[str] = field(default=None) diff --git a/pyyoutube/resources/captions.py b/pyyoutube/resources/captions.py index 766037bb..694013ff 100644 --- a/pyyoutube/resources/captions.py +++ b/pyyoutube/resources/captions.py @@ -60,6 +60,7 @@ def list( data = self._client.parse_response(response=response) return data if return_json else CaptionListResponse.from_dict(data) + # TODO upload file def insert( self, part: str, @@ -110,6 +111,7 @@ def insert( data = self._client.parse_response(response=response) return data if return_json else CaptionListResponse.from_dict(data) + # TODO upload file def update( self, part: str, diff --git a/pyyoutube/resources/channel_banners.py b/pyyoutube/resources/channel_banners.py new file mode 100644 index 00000000..7d0a0afa --- /dev/null +++ b/pyyoutube/resources/channel_banners.py @@ -0,0 +1,47 @@ +""" + Channel banners resource implementation. +""" +from typing import Optional, Union + +from pyyoutube.resources.base_resource import Resource +from pyyoutube.models import ChannelBanner + + +class ChannelBannersResource(Resource): + """A channelBanner resource contains the URL that you would use to set a newly uploaded image as + the banner image for a channel. + + References: https://developers.google.com/youtube/v3/docs/channelBanners + """ + + # TODO upload file + def insert( + self, + on_behalf_of_content_owner: Optional[str] = None, + return_json: bool = False, + **kwargs: Optional[dict], + ) -> Union[dict, ChannelBanner]: + """Uploads a channel banner image to YouTube. + + Args: + on_behalf_of_content_owner: + The onBehalfOfContentOwner parameter indicates that the request's authorization + credentials identify a YouTube CMS user who is acting on behalf of the content + owner specified in the parameter value. This parameter is intended for YouTube + content partners that own and manage many different YouTube channels. It allows + content owners to authenticate once and get access to all their video and channel + data, without having to provide authentication credentials for each individual channel. + The CMS account that the user authenticates with must be linked to the specified YouTube content owner. + return_json: + Type for returned data. If you set True JSON data will be returned. + **kwargs: + Additional parameters for system parameters. + Refer: https://cloud.google.com/apis/docs/system-parameters. + + Returns: + Channel banner data. + """ + params = {"onBehalfOfContentOwner": on_behalf_of_content_owner, **kwargs} + response = self._client.request(path="channelBanners", params=params) + data = self._client.parse_response(response=response) + return data if return_json else ChannelBanner.from_dict(data) From 0c8cfba676aee659ffb683e5a44e37aa697d4797 Mon Sep 17 00:00:00 2001 From: ikaroskun Date: Tue, 8 Nov 2022 16:48:14 +0800 Subject: [PATCH 021/141] feat(channelsection): :sparkles: Add api for channel section --- pyyoutube/models/__init__.py | 7 +- pyyoutube/models/channel_section.py | 14 +- pyyoutube/resources/channel_sections.py | 240 ++++++++++++++++++++++++ pyyoutube/resources/channels.py | 2 +- pyyoutube/utils/constants.py | 2 - 5 files changed, 251 insertions(+), 14 deletions(-) create mode 100644 pyyoutube/resources/channel_sections.py diff --git a/pyyoutube/models/__init__.py b/pyyoutube/models/__init__.py index 7b5439ba..85961d88 100644 --- a/pyyoutube/models/__init__.py +++ b/pyyoutube/models/__init__.py @@ -7,12 +7,7 @@ ) from .channel import * # noqa from .channel_banner import * # noqa -from .channel_section import ( - ChannelSection, - ChannelSectionContentDetails, - ChannelSectionSnippet, - ChannelSectionResponse, -) +from .channel_section import * # noqa from .comment import ( Comment, CommentListResponse, diff --git a/pyyoutube/models/channel_section.py b/pyyoutube/models/channel_section.py index 61ea09d8..cfbbbb41 100644 --- a/pyyoutube/models/channel_section.py +++ b/pyyoutube/models/channel_section.py @@ -2,12 +2,11 @@ Those are models related to channel sections. """ -from dataclasses import dataclass, field, make_dataclass -from dataclasses_json import config -from typing import List, Optional, Any +from dataclasses import dataclass, field +from typing import List, Optional from .base import BaseModel -from .common import Localized, BaseResource, BaseApiResponse +from .common import BaseResource, BaseApiResponse @dataclass @@ -41,7 +40,7 @@ class ChannelSection(BaseResource): """ A class representing the channel section info. - Refer: https://developers.google.com/youtube/v3/docs/channelSections + Refer: https://developers.google.com/youtube/v3/docs/channelSections#properties """ snippet: Optional[ChannelSectionSnippet] = field(default=None, repr=False) @@ -59,3 +58,8 @@ class ChannelSectionResponse(BaseApiResponse): """ items: Optional[List[ChannelSection]] = field(default=None, repr=False) + + +@dataclass +class ChannelSectionListResponse(ChannelSectionResponse): + ... diff --git a/pyyoutube/resources/channel_sections.py b/pyyoutube/resources/channel_sections.py new file mode 100644 index 00000000..2dc131f4 --- /dev/null +++ b/pyyoutube/resources/channel_sections.py @@ -0,0 +1,240 @@ +""" + Channel Section resource implementation. +""" + +from typing import Optional, Union + +from pyyoutube import PyYouTubeException, ErrorMessage, ErrorCode +from pyyoutube.resources.base_resource import Resource +from pyyoutube.models import ChannelSection, ChannelSectionListResponse +from pyyoutube.utils.params_checker import enf_comma_separated, enf_parts + + +class ChannelSectionsResource(Resource): + """A channelSection resource contains information about a set of videos that a channel has chosen to feature. + + References: https://developers.google.com/youtube/v3/docs/channelSections + """ + + def list( + self, + parts: Optional[Union[str, list, tuple, set]] = None, + channel_id: Optional[str] = None, + section_id: Optional[Union[str, list, tuple, set]] = None, + mine: Optional[bool] = None, + hl: Optional[str] = None, + on_behalf_of_content_owner: Optional[str] = None, + return_json: bool = False, + **kwargs: Optional[dict], + ) -> Union[dict, ChannelSectionListResponse]: + """Returns a list of channelSection resources that match the API request criteria. + + Args: + parts: + Comma-separated list of one or more channel resource properties. + channel_id: + ID for the channel which you want to retrieve sections. + section_id: + Specifies a comma-separated list of IDs that uniquely identify the channelSection + resources that are being retrieved. + mine: + Set this parameter's value to true to retrieve a feed of the channel sections + associated with the authenticated user's YouTube channel. + hl: + The hl parameter provided support for retrieving localized metadata for a channel section. + on_behalf_of_content_owner: + The onBehalfOfContentOwner parameter indicates that the request's authorization + credentials identify a YouTube CMS user who is acting on behalf of the content + owner specified in the parameter value. This parameter is intended for YouTube + content partners that own and manage many different YouTube channels. It allows + content owners to authenticate once and get access to all their video and channel + data, without having to provide authentication credentials for each individual channel. + The CMS account that the user authenticates with must be linked to the specified YouTube content owner. + return_json: + Type for returned data. If you set True JSON data will be returned. + **kwargs: + Additional parameters for system parameters. + Refer: https://cloud.google.com/apis/docs/system-parameters. + + Returns: + Channel section data. + + Raises: + PyYouTubeException: Missing filter parameter. + + """ + params = { + "part": enf_parts(resource="channelSections", value=parts), + "hl": hl, + "onBehalfOfContentOwner": on_behalf_of_content_owner, + **kwargs, + } + if channel_id is not None: + params["channelId"] = channel_id + elif section_id is not None: + params["id"] = enf_comma_separated(field="section_id", value=section_id) + elif mine is not None: + params["mine"] = mine + else: + raise PyYouTubeException( + ErrorMessage( + status_code=ErrorCode.MISSING_PARAMS, + message=f"Specify at least one of for_username, id, managedByMe or mine", + ) + ) + response = self._client.request(path="channelSections", params=params) + data = self._client.parse_response(response=response) + return data if return_json else ChannelSectionListResponse.from_dict(data) + + def insert( + self, + part: str, + body: Union[dict, ChannelSection], + on_behalf_of_content_owner: Optional[str] = None, + on_behalf_of_content_owner_channel: Optional[str] = None, + return_json: bool = False, + **kwargs, + ) -> Union[dict, ChannelSection]: + """Adds a channel section to the authenticated user's channel. + A channel can create a maximum of 10 shelves. + + Args: + part: + The part parameter serves two purposes in this operation. It identifies the properties + that the write operation will set as well as the properties that the API response will include. + Accept values: + - id + - contentDetails + - snippet + body: + Provide a channelSection resource in the request body. You can give dataclass or just a dict with data. + on_behalf_of_content_owner: + The onBehalfOfContentOwner parameter indicates that the request's authorization + credentials identify a YouTube CMS user who is acting on behalf of the content + owner specified in the parameter value. This parameter is intended for YouTube + content partners that own and manage many different YouTube channels. It allows + content owners to authenticate once and get access to all their video and channel + data, without having to provide authentication credentials for each individual channel. + The CMS account that the user authenticates with must be linked to the specified YouTube content owner. + on_behalf_of_content_owner_channel: + The onBehalfOfContentOwnerChannel parameter specifies the YouTube channel ID of the + channel to which a video is being added. This parameter is required when a request + specifies a value for the onBehalfOfContentOwner parameter, and it can only be used + in conjunction with that parameter. In addition, the request must be authorized + using a CMS account that is linked to the content owner that the onBehalfOfContentOwner + parameter specifies. Finally, the channel that the onBehalfOfContentOwnerChannel parameter + value specifies must be linked to the content owner that the onBehalfOfContentOwner parameter specifies. + return_json: + Type for returned data. If you set True JSON data will be returned. + **kwargs: + Additional parameters for system parameters. + Refer: https://cloud.google.com/apis/docs/system-parameters. + + Returns: + Channel section data. + """ + params = { + "part": part, + "onBehalfOfContentOwner": on_behalf_of_content_owner, + "onBehalfOfContentOwnerChannel": on_behalf_of_content_owner_channel, + **kwargs, + } + response = self._client.request( + method="POST", + path="channelSections", + params=params, + json=body, + ) + data = self._client.parse_response(response=response) + return data if return_json else ChannelSection.from_dict(data) + + def update( + self, + part: str, + body: Union[dict, ChannelSection], + on_behalf_of_content_owner: Optional[str] = None, + return_json: bool = False, + **kwargs, + ) -> Union[dict, ChannelSection]: + """Updates a channel section. + + Args: + part: + The part parameter serves two purposes in this operation. It identifies the properties + that the write operation will set as well as the properties that the API response will include. + Accept values: + - id + - contentDetails + - snippet + body: + Provide a channelSection resource in the request body. You can give dataclass or just a dict with data. + on_behalf_of_content_owner: + The onBehalfOfContentOwner parameter indicates that the request's authorization + credentials identify a YouTube CMS user who is acting on behalf of the content + owner specified in the parameter value. This parameter is intended for YouTube + content partners that own and manage many different YouTube channels. It allows + content owners to authenticate once and get access to all their video and channel + data, without having to provide authentication credentials for each individual channel. + The CMS account that the user authenticates with must be linked to the specified YouTube content owner. + return_json: + Type for returned data. If you set True JSON data will be returned. + **kwargs: + Additional parameters for system parameters. + Refer: https://cloud.google.com/apis/docs/system-parameters. + + Returns: + Channel section data. + """ + params = { + "part": part, + "onBehalfOfContentOwner": on_behalf_of_content_owner, + **kwargs, + } + response = self._client.request( + method="PUT", + path="channelSections", + params=params, + json=body, + ) + data = self._client.parse_response(response=response) + return data if return_json else ChannelSection.from_dict(data) + + def delete( + self, + section_id: str, + on_behalf_of_content_owner: Optional[str] = None, + **kwargs, + ) -> bool: + """Deletes a channel section. + + Args: + section_id: + ID for the target channel section. + on_behalf_of_content_owner: + The onBehalfOfContentOwner parameter indicates that the request's authorization + credentials identify a YouTube CMS user who is acting on behalf of the content + owner specified in the parameter value. This parameter is intended for YouTube + content partners that own and manage many different YouTube channels. It allows + content owners to authenticate once and get access to all their video and channel + data, without having to provide authentication credentials for each individual channel. + The CMS account that the user authenticates with must be linked to the specified YouTube content owner. + **kwargs: + Additional parameters for system parameters. + Refer: https://cloud.google.com/apis/docs/system-parameters. + + Returns: + Channel section delete status + """ + params = { + "id": section_id, + "onBehalfOfContentOwner": on_behalf_of_content_owner, + **kwargs, + } + response = self._client.request( + method="DELETE", + path="channelSections", + params=params, + ) + if response.ok: + return True + self._client.parse_response(response=response) diff --git a/pyyoutube/resources/channels.py b/pyyoutube/resources/channels.py index b590e4e4..ee477426 100644 --- a/pyyoutube/resources/channels.py +++ b/pyyoutube/resources/channels.py @@ -38,7 +38,7 @@ def list( The parameter specifies a YouTube username, thereby requesting the channel associated with that username. id: - The parameter specifies a comma-separated list of the YouTube channel ID(s) + The parameter specifies a comma-separated list of the YouTube channel ID(s) for the resource(s) that are being retrieved. managed_by_me: Set this parameter's value to true to instruct the API to only return channels diff --git a/pyyoutube/utils/constants.py b/pyyoutube/utils/constants.py index c557124e..c07627ee 100644 --- a/pyyoutube/utils/constants.py +++ b/pyyoutube/utils/constants.py @@ -22,9 +22,7 @@ CHANNEL_SECTIONS_PROPERTIES = { "id", "contentDetails", - "localizations", "snippet", - "targeting", } PLAYLIST_RESOURCE_PROPERTIES = { From ad05a3e0d137db3f1f02f9eb8f0f7507fa167ecd Mon Sep 17 00:00:00 2001 From: ikaroskun Date: Tue, 8 Nov 2022 18:41:44 +0800 Subject: [PATCH 022/141] feat(comment): :sparkles: Add api for comments --- pyyoutube/models/__init__.py | 10 +- pyyoutube/resources/comments.py | 258 ++++++++++++++++++++++++++++++++ 2 files changed, 259 insertions(+), 9 deletions(-) create mode 100644 pyyoutube/resources/comments.py diff --git a/pyyoutube/models/__init__.py b/pyyoutube/models/__init__.py index 85961d88..96b3bade 100644 --- a/pyyoutube/models/__init__.py +++ b/pyyoutube/models/__init__.py @@ -8,15 +8,7 @@ from .channel import * # noqa from .channel_banner import * # noqa from .channel_section import * # noqa -from .comment import ( - Comment, - CommentListResponse, - CommentSnippet, - CommentThread, - CommentThreadListResponse, - CommentThreadReplies, - CommentThreadSnippet, -) +from .comment import * # noqa from .playlist import ( Playlist, PlaylistContentDetails, diff --git a/pyyoutube/resources/comments.py b/pyyoutube/resources/comments.py new file mode 100644 index 00000000..421734ab --- /dev/null +++ b/pyyoutube/resources/comments.py @@ -0,0 +1,258 @@ +""" + Comment resource implementation. +""" + +from typing import Optional, Union + +from pyyoutube import PyYouTubeException, ErrorMessage, ErrorCode +from pyyoutube.resources.base_resource import Resource +from pyyoutube.models import Comment, CommentListResponse +from pyyoutube.utils.params_checker import enf_comma_separated, enf_parts + + +class CommentsResource(Resource): + """A comment resource contains information about a single YouTube comment. + + References: https://developers.google.com/youtube/v3/docs/comments + """ + + def list( + self, + parts: Optional[Union[str, list, tuple, set]] = None, + comment_id: Optional[Union[str, list, tuple, set]] = None, + parent_id: Optional[str] = None, + max_results: Optional[int] = None, + text_format: Optional[str] = None, + page_token: Optional[str] = None, + return_json: bool = False, + **kwargs: Optional[dict], + ) -> Union[dict, CommentListResponse]: + """Returns a list of comments that match the API request parameters. + + Args: + parts: + Comma-separated list of one or more channel resource properties. + comment_id: + Specifies a comma-separated list of comment IDs for the resources that are being retrieved. + parent_id: + Specifies the ID of the comment for which replies should be retrieved. + max_results: + The parameter specifies the maximum number of items that should be returned + the result set. + This parameter is not supported for use in conjunction with the comment_id parameter. + Acceptable values are 1 to 100, inclusive. The default value is 20. + text_format: + Whether the API should return comments formatted as HTML or as plain text. + The default value is html. + Acceptable values are: + - html: Returns the comments in HTML format. + - plainText: Returns the comments in plain text format. + page_token: + The parameter identifies a specific page in the result set that should be returned. + return_json: + Type for returned data. If you set True JSON data will be returned. + kwargs: + Additional parameters for system parameters. + Refer: https://cloud.google.com/apis/docs/system-parameters. + + Returns: + Comments data + Raises: + PyYouTubeException: Missing filter parameter. + """ + + params = { + "part": enf_parts(resource="comments", value=parts), + "maxResults": max_results, + "textFormat": text_format, + "pageToken": page_token, + **kwargs, + } + if comment_id is not None: + params["id"] = enf_comma_separated(field="comment_id", value=comment_id) + elif parent_id is not None: + params["parentId"] = parent_id + else: + raise PyYouTubeException( + ErrorMessage( + status_code=ErrorCode.MISSING_PARAMS, + message=f"Specify at least one of for_username, id, managedByMe or mine", + ) + ) + + response = self._client.request(path="comments", params=params) + data = self._client.parse_response(response=response) + return data if return_json else CommentListResponse.from_dict(data) + + def insert( + self, + part: str, + body: Union[dict, Comment], + return_json: bool = False, + **kwargs, + ) -> Union[dict, Comment]: + """Creates a reply to an existing comment. + + Notes: + To create a top-level comment, use the commentThreads.insert method. + + Args: + part: + The part parameter serves two purposes in this operation. It identifies the properties + that the write operation will set as well as the properties that the API response will include. + body: + Provide a comment resource in the request body. You can give dataclass or just a dict with data. + return_json: + Type for returned data. If you set True JSON data will be returned. + **kwargs: + Additional parameters for system parameters. + Refer: https://cloud.google.com/apis/docs/system-parameters. + + Returns: + Comment data. + """ + + params = {"part": part, **kwargs} + response = self._client.request( + method="POST", + path="comments", + params=params, + json=body, + ) + data = self._client.parse_response(response=response) + return data if return_json else Comment.from_dict(data) + + def update( + self, + part: str, + body: Union[dict, Comment], + return_json: bool = False, + **kwargs, + ) -> Union[dict, Comment]: + """Modifies a comment. + + Args: + part: + The part parameter serves two purposes in this operation. It identifies the properties + that the write operation will set as well as the properties that the API response will include. + body: + Provide a comment resource in the request body. You can give dataclass or just a dict with data. + return_json: + Type for returned data. If you set True JSON data will be returned. + **kwargs: + Additional parameters for system parameters. + Refer: https://cloud.google.com/apis/docs/system-parameters. + + Returns: + Comment updated data. + + """ + params = {"part": part, **kwargs} + response = self._client.request( + method="PUT", + path="comments", + params=params, + json=body, + ) + data = self._client.parse_response(response=response) + return data if return_json else Comment.from_dict(data) + + def mark_as_spam( + self, + comment_id: str, + **kwargs, + ) -> bool: + """Expresses the caller's opinion that one or more comments should be flagged as spam. + + Args: + comment_id: + ID for the target comment. + **kwargs: + Additional parameters for system parameters. + Refer: https://cloud.google.com/apis/docs/system-parameters. + + Returns: + Mark as spam status. + + """ + params = {"id": comment_id, **kwargs} + response = self._client.request( + method="POST", + path="comments/markAsSpam", + params=params, + ) + if response.ok: + return True + self._client.parse_response(response=response) + + def set_moderation_status( + self, + comment_id: str, + moderation_status: str, + ban_author: Optional[bool] = None, + **kwargs, + ) -> bool: + """Sets the moderation status of one or more comments. + + Args: + comment_id: + ID for the target comment. + moderation_status: + Identifies the new moderation status of the specified comments. + Acceptable values: + - heldForReview: Marks a comment as awaiting review by a moderator. + - published: Clears a comment for public display. + - rejected: Rejects a comment as being unfit for display. + This action also effectively hides all replies to the rejected comment. + ban_author: + Set the parameter value to true to ban the author. + This parameter is only valid if the moderationStatus parameter is also set to rejected. + **kwargs: + Additional parameters for system parameters. + Refer: https://cloud.google.com/apis/docs/system-parameters. + + Returns: + Moderation status set status. + """ + params = { + "id": comment_id, + "moderationStatus": moderation_status, + "banAuthor": ban_author, + **kwargs, + } + response = self._client.request( + method="POST", + path="comments/setModerationStatus", + params=params, + ) + if response.ok: + return True + self._client.parse_response(response=response) + + def delete( + self, + comment_id: str, + **kwargs, + ) -> bool: + """Deletes a comment. + + Args: + comment_id: + ID for the target comment. + **kwargs: + Additional parameters for system parameters. + Refer: https://cloud.google.com/apis/docs/system-parameters. + + Returns: + Comment delete status. + + """ + params = {"id": comment_id, **kwargs} + response = self._client.request( + method="DELETE", + path="comments", + params=params, + ) + if response.ok: + return True + self._client.parse_response(response=response) From 3abf9a425d555ac5ee55966cd765b8cef0f8385c Mon Sep 17 00:00:00 2001 From: ikaroskun Date: Wed, 9 Nov 2022 12:21:18 +0800 Subject: [PATCH 023/141] feat(commentthreads): :sparkles: Add api for commentThreads --- pyyoutube/client.py | 2 + pyyoutube/models/__init__.py | 1 + pyyoutube/models/comment.py | 52 +-------- pyyoutube/models/comment_thread.py | 58 +++++++++ pyyoutube/resources/__init__.py | 2 + pyyoutube/resources/channels.py | 2 +- pyyoutube/resources/comment_threads.py | 155 +++++++++++++++++++++++++ pyyoutube/resources/comments.py | 2 +- 8 files changed, 221 insertions(+), 53 deletions(-) create mode 100644 pyyoutube/models/comment_thread.py create mode 100644 pyyoutube/resources/comment_threads.py diff --git a/pyyoutube/client.py b/pyyoutube/client.py index ecde1a60..d94f70f0 100644 --- a/pyyoutube/client.py +++ b/pyyoutube/client.py @@ -40,6 +40,8 @@ class Client: activities = resources.ActivitiesResource() channels = resources.ChannelsResource() + comments = resources.CommentsResource() + commentThreads = resources.CommentThreadsResource() def __new__(cls, *args, **kwargs): self = super().__new__(cls) diff --git a/pyyoutube/models/__init__.py b/pyyoutube/models/__init__.py index 96b3bade..15a3d29c 100644 --- a/pyyoutube/models/__init__.py +++ b/pyyoutube/models/__init__.py @@ -9,6 +9,7 @@ from .channel_banner import * # noqa from .channel_section import * # noqa from .comment import * # noqa +from .comment_thread import * # noqa from .playlist import ( Playlist, PlaylistContentDetails, diff --git a/pyyoutube/models/comment.py b/pyyoutube/models/comment.py index a6f3b497..1c1ad8f0 100644 --- a/pyyoutube/models/comment.py +++ b/pyyoutube/models/comment.py @@ -1,5 +1,5 @@ """ - These are comment and comment threads related models. + These are comment related models. """ from dataclasses import dataclass, field @@ -68,53 +68,3 @@ class CommentListResponse(BaseApiResponse): """ items: Optional[List[Comment]] = field(default=None, repr=False) - - -@dataclass -class CommentThreadSnippet(BaseModel): - """ - A class representing comment tread snippet info. - - Refer: https://developers.google.com/youtube/v3/docs/commentThreads#snippet - """ - - channelId: Optional[str] = field(default=None) - videoId: Optional[str] = field(default=None) - topLevelComment: Optional[Comment] = field(default=None, repr=False) - canReply: Optional[bool] = field(default=None, repr=False) - totalReplyCount: Optional[int] = field(default=None, repr=False) - isPublic: Optional[bool] = field(default=None, repr=False) - - -@dataclass -class CommentThreadReplies(BaseModel): - """ - A class representing comment tread replies info. - - Refer: https://developers.google.com/youtube/v3/docs/commentThreads#replies - """ - - comments: Optional[List[Comment]] = field(default=None, repr=False) - - -@dataclass -class CommentThread(BaseResource): - """ - A class representing comment thread info. - - Refer: https://developers.google.com/youtube/v3/docs/commentThreads - """ - - snippet: Optional[CommentThreadSnippet] = field(default=None, repr=False) - replies: Optional[CommentThreadReplies] = field(default=None, repr=False) - - -@dataclass -class CommentThreadListResponse(BaseApiResponse): - """ - A class representing the comment thread's retrieve response info. - - Refer: https://developers.google.com/youtube/v3/docs/commentThreads/list#response_1 - """ - - items: Optional[List[CommentThread]] = field(default=None, repr=False) diff --git a/pyyoutube/models/comment_thread.py b/pyyoutube/models/comment_thread.py new file mode 100644 index 00000000..baab3f28 --- /dev/null +++ b/pyyoutube/models/comment_thread.py @@ -0,0 +1,58 @@ +""" + These are comment threads related models. +""" +from dataclasses import dataclass, field +from typing import Optional, List + +from .base import BaseModel +from .common import BaseResource, BaseApiResponse +from .comment import Comment + + +@dataclass +class CommentThreadSnippet(BaseModel): + """A class representing comment tread snippet info. + + References: https://developers.google.com/youtube/v3/docs/commentThreads#snippet + """ + + channelId: Optional[str] = field(default=None) + videoId: Optional[str] = field(default=None) + topLevelComment: Optional[Comment] = field(default=None, repr=False) + canReply: Optional[bool] = field(default=None, repr=False) + totalReplyCount: Optional[int] = field(default=None, repr=False) + isPublic: Optional[bool] = field(default=None, repr=False) + + +@dataclass +class CommentThreadReplies(BaseModel): + """ + A class representing comment tread replies info. + + Refer: https://developers.google.com/youtube/v3/docs/commentThreads#replies + """ + + comments: Optional[List[Comment]] = field(default=None, repr=False) + + +@dataclass +class CommentThread(BaseResource): + """ + A class representing comment thread info. + + Refer: https://developers.google.com/youtube/v3/docs/commentThreads + """ + + snippet: Optional[CommentThreadSnippet] = field(default=None, repr=False) + replies: Optional[CommentThreadReplies] = field(default=None, repr=False) + + +@dataclass +class CommentThreadListResponse(BaseApiResponse): + """ + A class representing the comment thread's retrieve response info. + + Refer: https://developers.google.com/youtube/v3/docs/commentThreads/list#response_1 + """ + + items: Optional[List[CommentThread]] = field(default=None, repr=False) diff --git a/pyyoutube/resources/__init__.py b/pyyoutube/resources/__init__.py index 270a5f0e..75d344a4 100644 --- a/pyyoutube/resources/__init__.py +++ b/pyyoutube/resources/__init__.py @@ -1,2 +1,4 @@ from .activities import ActivitiesResource # noqa from .channels import ChannelsResource # noqa +from .comments import CommentsResource # noqa +from .comment_threads import CommentThreadsResource # noqa diff --git a/pyyoutube/resources/channels.py b/pyyoutube/resources/channels.py index ee477426..6398eaa4 100644 --- a/pyyoutube/resources/channels.py +++ b/pyyoutube/resources/channels.py @@ -69,7 +69,7 @@ def list( The parameter identifies a specific page in the result set that should be returned. return_json: Type for returned data. If you set True JSON data will be returned. - kwargs: + **kwargs: Additional parameters for system parameters. Refer: https://cloud.google.com/apis/docs/system-parameters. diff --git a/pyyoutube/resources/comment_threads.py b/pyyoutube/resources/comment_threads.py new file mode 100644 index 00000000..9edf96a3 --- /dev/null +++ b/pyyoutube/resources/comment_threads.py @@ -0,0 +1,155 @@ +""" + Comment threads resource implementation. +""" +from typing import Optional, Union + +from pyyoutube.error import PyYouTubeException, ErrorMessage, ErrorCode +from pyyoutube.resources.base_resource import Resource +from pyyoutube.models import CommentThread, CommentThreadListResponse +from pyyoutube.utils.params_checker import enf_parts + + +class CommentThreadsResource(Resource): + """A commentThread resource contains information about a YouTube comment thread, which comprises a + top-level comment and replies, if any exist, to that comment + + References: https://developers.google.com/youtube/v3/docs/commentThreads + """ + + def list( + self, + parts: Optional[Union[str, list, tuple, set]] = None, + all_threads_related_to_channel_id: Optional[str] = None, + channel_id: Optional[str] = None, + thread_id: Optional[Union[str, list, tuple, set]] = None, + video_id: Optional[str] = None, + max_results: Optional[int] = None, + moderation_status: Optional[str] = None, + order: Optional[str] = None, + page_token: Optional[str] = None, + search_terms: Optional[str] = None, + text_format: Optional[str] = None, + return_json: bool = False, + **kwargs: Optional[dict], + ) -> Union[dict, CommentThreadListResponse]: + """Returns a list of comment threads that match the API request parameters. + + Args: + parts: + Comma-separated list of one or more channel resource properties. + all_threads_related_to_channel_id: + Instructs the API to return all comment threads associated with the specified channel. + channel_id: + Instructs the API to return comment threads containing comments about the specified channel + thread_id: + Specifies a comma-separated list of comment thread IDs for the resources that should be retrieved. + video_id: + Instructs the API to return comment threads associated with the specified video ID. + max_results: + The parameter specifies the maximum number of items that should be returned + the result set. + Acceptable values are 1 to 100, inclusive. The default value is 20. + moderation_status: + Set this parameter to limit the returned comment threads to a particular moderation state. + The default value is published. + Note: This parameter is not supported for use in conjunction with the id parameter. + order: + Specifies the order in which the API response should list comment threads. + Valid values are: + - time: Comment threads are ordered by time. This is the default behavior. + - relevance: Comment threads are ordered by relevance. + Notes: This parameter is not supported for use in conjunction with the `id` parameter. + page_token: + Identifies a specific page in the result set that should be returned. + Notes: This parameter is not supported for use in conjunction with the `id` parameter. + search_terms: + Instructs the API to limit the API response to only contain comments that contain + the specified search terms. + Notes: This parameter is not supported for use in conjunction with the `id` parameter. + text_format: + Set this parameter's value to html or plainText to instruct the API to return the comments + left by users in html formatted or in plain text. The default value is html. + Acceptable values are: + – html: Returns the comments in HTML format. This is the default value. + – plainText: Returns the comments in plain text format. + Notes: This parameter is not supported for use in conjunction with the `id` parameter. + return_json: + Type for returned data. If you set True JSON data will be returned. + **kwargs: + Additional parameters for system parameters. + Refer: https://cloud.google.com/apis/docs/system-parameters. + + Returns: + Comment threads data. + Raises: + PyYouTubeException: Missing filter parameter. + """ + + params = { + "part": enf_parts(resource="commentThreads", value=parts), + "maxResults": max_results, + "moderationStatus": moderation_status, + "order": order, + "pageToken": page_token, + "searchTerms": search_terms, + "textFormat": text_format, + **kwargs, + } + if all_threads_related_to_channel_id is not None: + params["allThreadsRelatedToChannelId"] = all_threads_related_to_channel_id + elif channel_id: + params["channelId"] = channel_id + elif thread_id: + params["id"] = thread_id + elif video_id: + params["videoId"] = video_id + else: + raise PyYouTubeException( + ErrorMessage( + status_code=ErrorCode.MISSING_PARAMS, + message="Specify at least one of all_threads_related_to_channel_id, channel_id, thread_id or video_id", + ) + ) + response = self._client.request(path="commentThreads", params=params) + data = self._client.parse_response(response=response) + return data if return_json else CommentThreadListResponse.from_dict(data) + + def insert( + self, + body: Union[dict, CommentThread], + parts: Optional[Union[str, list, tuple, set]] = None, + return_json: bool = False, + **kwargs, + ) -> Union[dict, CommentThread]: + """Creates a new top-level comment. + + Notes: To add a reply to an existing comment, use the comments.insert method instead. + + Args: + body: + Provide a commentThread resource in the request body. You can give dataclass or just a dict with data. + parts: + Comma-separated list of one or more channel thread resource properties. + return_json: + Type for returned data. If you set True JSON data will be returned. + **kwargs: + Additional parameters for system parameters. + Refer: https://cloud.google.com/apis/docs/system-parameters. + + Returns: + Channel thread data. + + """ + params = { + "part": enf_parts(resource="commentThreads", value=parts), + **kwargs, + } + + response = self._client.request( + method="POST", + path="commentThreads", + params=params, + json=body, + ) + data = self._client.parse_response(response=response) + return data if return_json else CommentThread.from_dict(data) diff --git a/pyyoutube/resources/comments.py b/pyyoutube/resources/comments.py index 421734ab..602f3667 100644 --- a/pyyoutube/resources/comments.py +++ b/pyyoutube/resources/comments.py @@ -4,7 +4,7 @@ from typing import Optional, Union -from pyyoutube import PyYouTubeException, ErrorMessage, ErrorCode +from pyyoutube.error import PyYouTubeException, ErrorMessage, ErrorCode from pyyoutube.resources.base_resource import Resource from pyyoutube.models import Comment, CommentListResponse from pyyoutube.utils.params_checker import enf_comma_separated, enf_parts From c306113061e2ac879e1e42ef8e40716b3f2ce001 Mon Sep 17 00:00:00 2001 From: ikaroskun Date: Wed, 9 Nov 2022 15:07:27 +0800 Subject: [PATCH 024/141] feat(comments): :art: make comments api work well --- pyyoutube/resources/comment_threads.py | 4 +- pyyoutube/resources/comments.py | 20 +++--- pyyoutube/utils/constants.py | 92 +++++++++++++------------- 3 files changed, 57 insertions(+), 59 deletions(-) diff --git a/pyyoutube/resources/comment_threads.py b/pyyoutube/resources/comment_threads.py index 9edf96a3..05803d97 100644 --- a/pyyoutube/resources/comment_threads.py +++ b/pyyoutube/resources/comment_threads.py @@ -36,7 +36,7 @@ def list( Args: parts: - Comma-separated list of one or more channel resource properties. + Comma-separated list of one or more comment thread resource properties. all_threads_related_to_channel_id: Instructs the API to return all comment threads associated with the specified channel. channel_id: @@ -129,7 +129,7 @@ def insert( body: Provide a commentThread resource in the request body. You can give dataclass or just a dict with data. parts: - Comma-separated list of one or more channel thread resource properties. + Comma-separated list of one or more comment thread resource properties. return_json: Type for returned data. If you set True JSON data will be returned. **kwargs: diff --git a/pyyoutube/resources/comments.py b/pyyoutube/resources/comments.py index 602f3667..492aab81 100644 --- a/pyyoutube/resources/comments.py +++ b/pyyoutube/resources/comments.py @@ -31,7 +31,7 @@ def list( Args: parts: - Comma-separated list of one or more channel resource properties. + Comma-separated list of one or more comment resource properties. comment_id: Specifies a comma-separated list of comment IDs for the resources that are being retrieved. parent_id: @@ -86,8 +86,8 @@ def list( def insert( self, - part: str, body: Union[dict, Comment], + parts: Optional[Union[str, list, tuple, set]] = None, return_json: bool = False, **kwargs, ) -> Union[dict, Comment]: @@ -97,11 +97,10 @@ def insert( To create a top-level comment, use the commentThreads.insert method. Args: - part: - The part parameter serves two purposes in this operation. It identifies the properties - that the write operation will set as well as the properties that the API response will include. body: Provide a comment resource in the request body. You can give dataclass or just a dict with data. + parts: + Comma-separated list of one or more comment resource properties. return_json: Type for returned data. If you set True JSON data will be returned. **kwargs: @@ -112,7 +111,7 @@ def insert( Comment data. """ - params = {"part": part, **kwargs} + params = {"part": enf_parts(resource="comments", value=parts), **kwargs} response = self._client.request( method="POST", path="comments", @@ -124,19 +123,18 @@ def insert( def update( self, - part: str, body: Union[dict, Comment], + parts: Optional[Union[str, list, tuple, set]] = None, return_json: bool = False, **kwargs, ) -> Union[dict, Comment]: """Modifies a comment. Args: - part: - The part parameter serves two purposes in this operation. It identifies the properties - that the write operation will set as well as the properties that the API response will include. body: Provide a comment resource in the request body. You can give dataclass or just a dict with data. + parts: + Comma-separated list of one or more comment resource properties. return_json: Type for returned data. If you set True JSON data will be returned. **kwargs: @@ -147,7 +145,7 @@ def update( Comment updated data. """ - params = {"part": part, **kwargs} + params = {"part": enf_parts(resource="comments", value=parts), **kwargs} response = self._client.request( method="PUT", path="comments", diff --git a/pyyoutube/utils/constants.py b/pyyoutube/utils/constants.py index c07627ee..4697751b 100644 --- a/pyyoutube/utils/constants.py +++ b/pyyoutube/utils/constants.py @@ -8,6 +8,11 @@ "contentDetails", } +CAPTIONS_RESOURCE_PROPERTIES = { + "id", + "snippet", +} + CHANNEL_RESOURCE_PROPERTIES = { "id", "brandingSettings", @@ -25,94 +30,89 @@ "snippet", } -PLAYLIST_RESOURCE_PROPERTIES = { - "id", - "contentDetails", - "localizations", - "player", - "snippet", - "status", -} +COMMENT_RESOURCE_PROPERTIES = {"id", "snippet"} -PLAYLIST_ITEM_RESOURCE_PROPERTIES = {"id", "contentDetails", "snippet", "status"} +COMMENT_THREAD_RESOURCE_PROPERTIES = {"id", "replies", "snippet"} -VIDEO_RESOURCE_PROPERTIES = { +I18N_LANGUAGE_PROPERTIES = { "id", - "contentDetails", - "player", "snippet", - "statistics", - "status", - "topicDetails", - "liveStreamingDetails", } -COMMENT_THREAD_RESOURCE_PROPERTIES = {"id", "replies", "snippet"} - -COMMENT_RESOURCE_PROPERTIES = {"id", "snippet"} - -VIDEO_CATEGORY_RESOURCE_PROPERTIES = {"id", "snippet"} - -GUIDE_CATEGORY_RESOURCE_PROPERTIES = {"id", "snippet"} - -SEARCH_RESOURCE_PROPERTIES = {"id", "snippet"} - -SUBSCRIPTION_RESOURCE_PROPERTIES = { +I18N_REGION_PROPERTIES = { "id", "snippet", - "contentDetails", - "subscriberSnippet", } -CAPTIONS_RESOURCE_PROPERTIES = { +MEMBER_PROPERTIES = { "id", "snippet", } -I18N_REGION_PROPERTIES = { +MEMBERSHIP_LEVEL_PROPERTIES = { "id", "snippet", } -I18N_LANGUAGE_PROPERTIES = { +PLAYLIST_ITEM_RESOURCE_PROPERTIES = {"id", "contentDetails", "snippet", "status"} + +PLAYLIST_RESOURCE_PROPERTIES = { "id", + "contentDetails", + "localizations", + "player", "snippet", + "status", } -VIDEO_ABUSE_REPORT_REASON_PROPERTIES = { +SEARCH_RESOURCE_PROPERTIES = {"id", "snippet"} + +SUBSCRIPTION_RESOURCE_PROPERTIES = { "id", "snippet", + "contentDetails", + "subscriberSnippet", } -MEMBER_PROPERTIES = { +VIDEO_ABUSE_REPORT_REASON_PROPERTIES = { "id", "snippet", } -MEMBERSHIP_LEVEL_PROPERTIES = { +VIDEO_CATEGORY_RESOURCE_PROPERTIES = {"id", "snippet"} + +VIDEO_RESOURCE_PROPERTIES = { "id", + "contentDetails", + "player", "snippet", + "statistics", + "status", + "topicDetails", + "liveStreamingDetails", } +GUIDE_CATEGORY_RESOURCE_PROPERTIES = {"id", "snippet"} + RESOURCE_PARTS_MAPPING = { "activities": ACTIVITIES_RESOURCE_PROPERTIES, + "captions": CAPTIONS_RESOURCE_PROPERTIES, "channels": CHANNEL_RESOURCE_PROPERTIES, "channelSections": CHANNEL_SECTIONS_PROPERTIES, - "playlists": PLAYLIST_RESOURCE_PROPERTIES, - "playlistItems": PLAYLIST_ITEM_RESOURCE_PROPERTIES, - "videos": VIDEO_RESOURCE_PROPERTIES, - "commentThreads": COMMENT_THREAD_RESOURCE_PROPERTIES, "comments": COMMENT_RESOURCE_PROPERTIES, - "videoCategories": VIDEO_CATEGORY_RESOURCE_PROPERTIES, - "guideCategories": GUIDE_CATEGORY_RESOURCE_PROPERTIES, - "search": SEARCH_RESOURCE_PROPERTIES, - "subscriptions": SUBSCRIPTION_RESOURCE_PROPERTIES, - "captions": CAPTIONS_RESOURCE_PROPERTIES, - "i18nRegions": I18N_REGION_PROPERTIES, + "commentThreads": COMMENT_THREAD_RESOURCE_PROPERTIES, "i18nLanguages": I18N_LANGUAGE_PROPERTIES, - "videoAbuseReportReasons": VIDEO_ABUSE_REPORT_REASON_PROPERTIES, + "i18nRegions": I18N_REGION_PROPERTIES, "members": MEMBER_PROPERTIES, "membershipsLevels": MEMBERSHIP_LEVEL_PROPERTIES, + "playlistItems": PLAYLIST_ITEM_RESOURCE_PROPERTIES, + "playlists": PLAYLIST_RESOURCE_PROPERTIES, + "search": SEARCH_RESOURCE_PROPERTIES, + "subscriptions": SUBSCRIPTION_RESOURCE_PROPERTIES, + "videoAbuseReportReasons": VIDEO_ABUSE_REPORT_REASON_PROPERTIES, + "videoCategories": VIDEO_CATEGORY_RESOURCE_PROPERTIES, + "videos": VIDEO_RESOURCE_PROPERTIES, + "guideCategories": GUIDE_CATEGORY_RESOURCE_PROPERTIES, } TOPICS = { From 7fc48706bd518076208e9f03408e5054caf652c7 Mon Sep 17 00:00:00 2001 From: ikaroskun Date: Wed, 9 Nov 2022 18:41:25 +0800 Subject: [PATCH 025/141] feat(i18n): :sparkles: add api for i18n languages --- pyyoutube/client.py | 1 + pyyoutube/models/__init__.py | 7 +--- pyyoutube/resources/__init__.py | 1 + pyyoutube/resources/i18n_languages.py | 51 +++++++++++++++++++++++++++ pyyoutube/utils/constants.py | 5 +-- 5 files changed, 55 insertions(+), 10 deletions(-) create mode 100644 pyyoutube/resources/i18n_languages.py diff --git a/pyyoutube/client.py b/pyyoutube/client.py index d94f70f0..fd145584 100644 --- a/pyyoutube/client.py +++ b/pyyoutube/client.py @@ -42,6 +42,7 @@ class Client: channels = resources.ChannelsResource() comments = resources.CommentsResource() commentThreads = resources.CommentThreadsResource() + i18nLanguages = resources.I18nLanguagesResource() def __new__(cls, *args, **kwargs): self = super().__new__(cls) diff --git a/pyyoutube/models/__init__.py b/pyyoutube/models/__init__.py index 15a3d29c..6c93520a 100644 --- a/pyyoutube/models/__init__.py +++ b/pyyoutube/models/__init__.py @@ -10,6 +10,7 @@ from .channel_section import * # noqa from .comment import * # noqa from .comment_thread import * # noqa +from .i18n import * # noqa from .playlist import ( Playlist, PlaylistContentDetails, @@ -40,12 +41,6 @@ VideoStatus, VideoTopicDetails, ) -from .i18n import ( - I18nRegion, - I18nRegionListResponse, - I18nLanguage, - I18nLanguageListResponse, -) from .video_abuse_report_reason import ( VideoAbuseReportReason, diff --git a/pyyoutube/resources/__init__.py b/pyyoutube/resources/__init__.py index 75d344a4..b9af99a1 100644 --- a/pyyoutube/resources/__init__.py +++ b/pyyoutube/resources/__init__.py @@ -2,3 +2,4 @@ from .channels import ChannelsResource # noqa from .comments import CommentsResource # noqa from .comment_threads import CommentThreadsResource # noqa +from .i18n_languages import I18nLanguagesResource # noqa diff --git a/pyyoutube/resources/i18n_languages.py b/pyyoutube/resources/i18n_languages.py new file mode 100644 index 00000000..f09da4ef --- /dev/null +++ b/pyyoutube/resources/i18n_languages.py @@ -0,0 +1,51 @@ +""" + i18n language resource implementation. +""" + +from typing import Optional, Union + +from pyyoutube.resources.base_resource import Resource +from pyyoutube.models import I18nLanguageListResponse +from pyyoutube.utils.params_checker import enf_parts + + +class I18nLanguagesResource(Resource): + """An i18nLanguage resource identifies an application language that the YouTube website supports. + The application language can also be referred to as a UI language + + References: https://developers.google.com/youtube/v3/docs/i18nLanguages + """ + + def list( + self, + parts: Optional[Union[str, list, tuple, set]] = None, + hl: Optional[str] = None, + return_json: bool = False, + **kwargs: Optional[dict], + ) -> Union[dict, I18nLanguageListResponse]: + """Returns a list of application languages that the YouTube website supports. + + Args: + parts: + Comma-separated list of one or more channel resource properties. + Accepted values: snippet. + hl: + Specifies the language that should be used for text values in the API response. + The default value is en_US. + return_json: + Type for returned data. If you set True JSON data will be returned. + **kwargs: + Additional parameters for system parameters. + Refer: https://cloud.google.com/apis/docs/system-parameters. + + Returns: + i18n language data + """ + params = { + "part": enf_parts(resource="i18nLanguages", value=parts), + "hl": hl, + **kwargs, + } + response = self._client.request(path="i18nLanguages", params=params) + data = self._client.parse_response(response=response) + return data if return_json else I18nLanguageListResponse.from_dict(data) diff --git a/pyyoutube/utils/constants.py b/pyyoutube/utils/constants.py index 4697751b..9f053fc8 100644 --- a/pyyoutube/utils/constants.py +++ b/pyyoutube/utils/constants.py @@ -34,10 +34,7 @@ COMMENT_THREAD_RESOURCE_PROPERTIES = {"id", "replies", "snippet"} -I18N_LANGUAGE_PROPERTIES = { - "id", - "snippet", -} +I18N_LANGUAGE_PROPERTIES = {"snippet"} I18N_REGION_PROPERTIES = { "id", From 64cd08b7a2501f7f8c0aacbbdcd1375105057e57 Mon Sep 17 00:00:00 2001 From: ikaroskun Date: Wed, 9 Nov 2022 18:49:19 +0800 Subject: [PATCH 026/141] feat(i18n regions): :sparkles: add api for i18n regions --- pyyoutube/client.py | 1 + pyyoutube/resources/__init__.py | 1 + pyyoutube/resources/i18n_languages.py | 2 +- pyyoutube/resources/i18n_regions.py | 51 +++++++++++++++++++++++++++ pyyoutube/utils/constants.py | 5 +-- 5 files changed, 55 insertions(+), 5 deletions(-) create mode 100644 pyyoutube/resources/i18n_regions.py diff --git a/pyyoutube/client.py b/pyyoutube/client.py index fd145584..9e8a4ac3 100644 --- a/pyyoutube/client.py +++ b/pyyoutube/client.py @@ -43,6 +43,7 @@ class Client: comments = resources.CommentsResource() commentThreads = resources.CommentThreadsResource() i18nLanguages = resources.I18nLanguagesResource() + i18nRegions = resources.I18nRegionsResource() def __new__(cls, *args, **kwargs): self = super().__new__(cls) diff --git a/pyyoutube/resources/__init__.py b/pyyoutube/resources/__init__.py index b9af99a1..06e679cc 100644 --- a/pyyoutube/resources/__init__.py +++ b/pyyoutube/resources/__init__.py @@ -3,3 +3,4 @@ from .comments import CommentsResource # noqa from .comment_threads import CommentThreadsResource # noqa from .i18n_languages import I18nLanguagesResource # noqa +from .i18n_regions import I18nRegionsResource # noqa diff --git a/pyyoutube/resources/i18n_languages.py b/pyyoutube/resources/i18n_languages.py index f09da4ef..902e6aa3 100644 --- a/pyyoutube/resources/i18n_languages.py +++ b/pyyoutube/resources/i18n_languages.py @@ -27,7 +27,7 @@ def list( Args: parts: - Comma-separated list of one or more channel resource properties. + Comma-separated list of one or more i18n languages resource properties. Accepted values: snippet. hl: Specifies the language that should be used for text values in the API response. diff --git a/pyyoutube/resources/i18n_regions.py b/pyyoutube/resources/i18n_regions.py new file mode 100644 index 00000000..a927224a --- /dev/null +++ b/pyyoutube/resources/i18n_regions.py @@ -0,0 +1,51 @@ +""" + i18n regions resource implementation. +""" + +from typing import Optional, Union + +from pyyoutube.resources.base_resource import Resource +from pyyoutube.models import I18nRegionListResponse +from pyyoutube.utils.params_checker import enf_parts + + +class I18nRegionsResource(Resource): + """An i18nRegion resource identifies a geographic area that a YouTube user can select as + the preferred content region. + + References: https://developers.google.com/youtube/v3/docs/i18nRegions + """ + + def list( + self, + parts: Optional[Union[str, list, tuple, set]] = None, + hl: Optional[str] = None, + return_json: bool = False, + **kwargs: Optional[dict], + ) -> Union[dict, I18nRegionListResponse]: + """Returns a list of content regions that the YouTube website supports. + + Args: + parts: + Comma-separated list of one or more i18n regions resource properties. + Accepted values: snippet. + hl: + Specifies the language that should be used for text values in the API response. + The default value is en_US. + return_json: + Type for returned data. If you set True JSON data will be returned. + **kwargs: + Additional parameters for system parameters. + Refer: https://cloud.google.com/apis/docs/system-parameters. + + Returns: + i18n regions data. + """ + params = { + "part": enf_parts(resource="i18nRegions", value=parts), + "hl": hl, + **kwargs, + } + response = self._client.request(path="i18nRegions", params=params) + data = self._client.parse_response(response=response) + return data if return_json else I18nRegionListResponse.from_dict(data) diff --git a/pyyoutube/utils/constants.py b/pyyoutube/utils/constants.py index 9f053fc8..c02d5e72 100644 --- a/pyyoutube/utils/constants.py +++ b/pyyoutube/utils/constants.py @@ -36,10 +36,7 @@ I18N_LANGUAGE_PROPERTIES = {"snippet"} -I18N_REGION_PROPERTIES = { - "id", - "snippet", -} +I18N_REGION_PROPERTIES = {"snippet"} MEMBER_PROPERTIES = { "id", From edca071a80e8c4f1ed0e69508804ec223889b35a Mon Sep 17 00:00:00 2001 From: ikaroskun Date: Wed, 9 Nov 2022 19:03:50 +0800 Subject: [PATCH 027/141] fix(request): :bug: remove error params --- pyyoutube/client.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/pyyoutube/client.py b/pyyoutube/client.py index 9e8a4ac3..5c381de4 100644 --- a/pyyoutube/client.py +++ b/pyyoutube/client.py @@ -410,7 +410,6 @@ def refresh_access_token( "grant_type": "refresh_token", }, enforce_auth=False, - proxies=self.proxies, **kwargs, ) data = self.parse_response(response) @@ -441,7 +440,6 @@ def revoke_access_token( path=self.REVOKE_TOKEN_URL, params={"token": token}, enforce_auth=False, - proxies=self.proxies, ) if response.ok: return True From efe51686708ce29a8e5189aacd398df60d9026c2 Mon Sep 17 00:00:00 2001 From: ikaroskun Date: Wed, 9 Nov 2022 19:06:21 +0800 Subject: [PATCH 028/141] feat(resource): :sparkles: add success resource --- pyyoutube/client.py | 1 + pyyoutube/resources/__init__.py | 1 + 2 files changed, 2 insertions(+) diff --git a/pyyoutube/client.py b/pyyoutube/client.py index 5c381de4..03b6c708 100644 --- a/pyyoutube/client.py +++ b/pyyoutube/client.py @@ -40,6 +40,7 @@ class Client: activities = resources.ActivitiesResource() channels = resources.ChannelsResource() + channelSections = resources.ChannelSectionsResource() comments = resources.CommentsResource() commentThreads = resources.CommentThreadsResource() i18nLanguages = resources.I18nLanguagesResource() diff --git a/pyyoutube/resources/__init__.py b/pyyoutube/resources/__init__.py index 06e679cc..e76d8a70 100644 --- a/pyyoutube/resources/__init__.py +++ b/pyyoutube/resources/__init__.py @@ -1,5 +1,6 @@ from .activities import ActivitiesResource # noqa from .channels import ChannelsResource # noqa +from .channel_sections import ChannelSectionsResource # noqa from .comments import CommentsResource # noqa from .comment_threads import CommentThreadsResource # noqa from .i18n_languages import I18nLanguagesResource # noqa From 29dbc450aa9983f740d15e9f1762fd658a4f7224 Mon Sep 17 00:00:00 2001 From: ikaroskun Date: Wed, 9 Nov 2022 19:29:27 +0800 Subject: [PATCH 029/141] fix(import): :bug: fix import --- pyyoutube/resources/channel_sections.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyyoutube/resources/channel_sections.py b/pyyoutube/resources/channel_sections.py index 2dc131f4..631290a4 100644 --- a/pyyoutube/resources/channel_sections.py +++ b/pyyoutube/resources/channel_sections.py @@ -4,7 +4,7 @@ from typing import Optional, Union -from pyyoutube import PyYouTubeException, ErrorMessage, ErrorCode +from pyyoutube.error import PyYouTubeException, ErrorMessage, ErrorCode from pyyoutube.resources.base_resource import Resource from pyyoutube.models import ChannelSection, ChannelSectionListResponse from pyyoutube.utils.params_checker import enf_comma_separated, enf_parts From 6e9a74abba3eedcd4811fb479b5de495418b5889 Mon Sep 17 00:00:00 2001 From: ikaroskun Date: Wed, 9 Nov 2022 19:32:06 +0800 Subject: [PATCH 030/141] fix(tests): :white_check_mark: make tests well --- tests/apis/test_i18ns.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/apis/test_i18ns.py b/tests/apis/test_i18ns.py index 86ddcab6..0c63f898 100644 --- a/tests/apis/test_i18ns.py +++ b/tests/apis/test_i18ns.py @@ -22,7 +22,7 @@ def testGetI18nRegions(self) -> None: with responses.RequestsMock() as m: m.add("GET", self.REGION_URL, json=self.REGIONS_RES) - regions = self.api.get_i18n_regions(parts=["id", "snippet"]) + regions = self.api.get_i18n_regions(parts=["snippet"]) self.assertEqual(regions.kind, "youtube#i18nRegionListResponse") self.assertEqual(len(regions.items), 4) self.assertEqual(regions.items[0].id, "VE") @@ -34,7 +34,7 @@ def testGetI18nLanguages(self) -> None: with responses.RequestsMock() as m: m.add("GET", self.LANGUAGE_URL, json=self.LANGUAGE_RES) - languages = self.api.get_i18n_languages(parts=["id", "snippet"]) + languages = self.api.get_i18n_languages(parts=["snippet"]) self.assertEqual(len(languages.items), 5) self.assertEqual(languages.items[0].id, "zh-CN") From a85ae882c5929a2650bc872106befa66e612b9c5 Mon Sep 17 00:00:00 2001 From: ikaros Date: Wed, 9 Nov 2022 22:24:16 +0800 Subject: [PATCH 031/141] test(tests): :white_check_mark: add tests basic --- tests/clients/__init__.py | 0 tests/clients/base.py | 16 ++++++++++++++++ tests/clients/test_channels.py | 35 ++++++++++++++++++++++++++++++++++ tests/conftest.py | 27 ++++++++++++++++++++++++++ 4 files changed, 78 insertions(+) create mode 100644 tests/clients/__init__.py create mode 100644 tests/clients/base.py create mode 100644 tests/clients/test_channels.py create mode 100644 tests/conftest.py diff --git a/tests/clients/__init__.py b/tests/clients/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/clients/base.py b/tests/clients/base.py new file mode 100644 index 00000000..37998fea --- /dev/null +++ b/tests/clients/base.py @@ -0,0 +1,16 @@ +""" + Base class +""" + + +class BaseTestCase: + BASE_PATH = "testdata/apidata" + BASE_URL = "https://www.googleapis.com/youtube/v3" + RESOURCE = "CHANNELS" + + @property + def url(self): + return f"{self.BASE_URL}/{self.RESOURCE}" + + def load_json(self, filename, helpers): + return helpers.load_json(f"{self.BASE_PATH}/{filename}") diff --git a/tests/clients/test_channels.py b/tests/clients/test_channels.py new file mode 100644 index 00000000..53a3ec8b --- /dev/null +++ b/tests/clients/test_channels.py @@ -0,0 +1,35 @@ +import responses + +from .base import BaseTestCase + + +class TestChannelsResource(BaseTestCase): + RESOURCE = "channels" + channel_id = "UC_x5XG1OV2P6uZZ5FSM9Ttw" + + def test_list(self, helpers, authed_cli): + with responses.RequestsMock() as m: + m.add( + method="GET", + url=self.url, + json=self.load_json("channel_info_single.json", helpers), + ) + + res = authed_cli.channels.list( + parts="id,snippet,statistics", + id=self.channel_id, + ) + assert res.items[0].id == self.channel_id + + with responses.RequestsMock() as m: + m.add( + method="GET", + url=self.url, + json=self.load_json("channel_info_multi.json", helpers), + ) + + res = authed_cli.channels.list( + parts=["id", "snippet", "statistics"], + id="UC_x5XG1OV2P6uZZ5FSM9Ttw,UCK8sQmJBp8GCxrOtXWBpyEA", + ) + assert len(res.items) == 2 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..50fca069 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,27 @@ +import json + +import pytest + +from pyyoutube import Client + + +class Helpers: + @staticmethod + def load_json(filename): + with open(filename, "rb") as f: + return json.loads(f.read().decode("utf-8")) + + @staticmethod + def load_file_binary(filename): + with open(filename, "rb") as f: + return f.read() + + +@pytest.fixture +def helpers(): + return Helpers() + + +@pytest.fixture(scope="class") +def authed_cli(): + return Client(access_token="access token") From 165dcb4c0af172cf2769f878560c0ffb355ce1a8 Mon Sep 17 00:00:00 2001 From: ikaroskun Date: Thu, 10 Nov 2022 11:27:46 +0800 Subject: [PATCH 032/141] test(channel): :white_check_mark: make channels tests --- pyyoutube/resources/channels.py | 4 +- testdata/apidata/channels/info.json | 1 + testdata/apidata/channels/info_multiple.json | 1 + testdata/apidata/channels/update_resp.json | 1 + tests/clients/test_channels.py | 59 ++++++++++++++++++-- tests/conftest.py | 5 ++ 6 files changed, 63 insertions(+), 8 deletions(-) create mode 100644 testdata/apidata/channels/info.json create mode 100644 testdata/apidata/channels/info_multiple.json create mode 100644 testdata/apidata/channels/update_resp.json diff --git a/pyyoutube/resources/channels.py b/pyyoutube/resources/channels.py index 6398eaa4..254f5b2f 100644 --- a/pyyoutube/resources/channels.py +++ b/pyyoutube/resources/channels.py @@ -115,7 +115,7 @@ def update( on_behalf_of_content_owner: Optional[str] = None, return_json: bool = False, **kwargs, - ) -> Union[dict, ChannelListResponse]: + ) -> Union[dict, Channel]: """Updates a channel's metadata. Note that this method currently only supports updates to the channel resource's brandingSettings, @@ -157,4 +157,4 @@ def update( json=body, ) data = self._client.parse_response(response=response) - return data if return_json else ChannelListResponse.from_dict(data) + return data if return_json else Channel.from_dict(data) diff --git a/testdata/apidata/channels/info.json b/testdata/apidata/channels/info.json new file mode 100644 index 00000000..9fd7c0fa --- /dev/null +++ b/testdata/apidata/channels/info.json @@ -0,0 +1 @@ +{"kind":"youtube#channelListResponse","etag":"DovVRc4nTNzGShQkXoC7R2ab3JQ","pageInfo":{"totalResults":1,"resultsPerPage":5},"items":[{"kind":"youtube#channel","etag":"Cxi25U626ZmPs7h8MsS4D8GzfV8","id":"UC_x5XG1OV2P6uZZ5FSM9Ttw","snippet":{"title":"Google Developers","description":"The Google Developers channel features talks from events, educational series, best practices, tips, and the latest updates across our products and platforms.\n\nSubscribe to Google Developers → https://goo.gle/developers\n","customUrl":"@googledevelopers","publishedAt":"2007-08-23T00:34:43Z","thumbnails":{"default":{"url":"https://yt3.ggpht.com/ytc/AMLnZu-oDvWEJ-WfN9bgxQB2YAlnjC2uqN_c7JQZvX9Ikfg=s88-c-k-c0x00ffffff-no-rj","width":88,"height":88},"medium":{"url":"https://yt3.ggpht.com/ytc/AMLnZu-oDvWEJ-WfN9bgxQB2YAlnjC2uqN_c7JQZvX9Ikfg=s240-c-k-c0x00ffffff-no-rj","width":240,"height":240},"high":{"url":"https://yt3.ggpht.com/ytc/AMLnZu-oDvWEJ-WfN9bgxQB2YAlnjC2uqN_c7JQZvX9Ikfg=s800-c-k-c0x00ffffff-no-rj","width":800,"height":800}},"localized":{"title":"Google Developers","description":"The Google Developers channel features talks from events, educational series, best practices, tips, and the latest updates across our products and platforms.\n\nSubscribe to Google Developers → https://goo.gle/developers\n"},"country":"US"}}]} \ No newline at end of file diff --git a/testdata/apidata/channels/info_multiple.json b/testdata/apidata/channels/info_multiple.json new file mode 100644 index 00000000..d113a476 --- /dev/null +++ b/testdata/apidata/channels/info_multiple.json @@ -0,0 +1 @@ +{"kind":"youtube#channelListResponse","etag":"doLptdWt69-xv1D0XqhnNqKHg9o","pageInfo":{"totalResults":2,"resultsPerPage":5},"items":[{"kind":"youtube#channel","etag":"BSP3hQtvSS6Eo9sg31jocVuV4mg","id":"UCK8sQmJBp8GCxrOtXWBpyEA","snippet":{"title":"Google","description":"Experience the world of Google on our official YouTube channel. Watch videos about our products, technology, company happenings and more. Subscribe to get updates from all your favorite Google products and teams.","customUrl":"@google","publishedAt":"2005-09-18T22:37:10Z","thumbnails":{"default":{"url":"https://yt3.ggpht.com/ytc/AMLnZu_31rROBnB8bq9EJfk82OnclHISQ3Hrx6i1oWLai5o=s88-c-k-c0x00ffffff-no-rj","width":88,"height":88},"medium":{"url":"https://yt3.ggpht.com/ytc/AMLnZu_31rROBnB8bq9EJfk82OnclHISQ3Hrx6i1oWLai5o=s240-c-k-c0x00ffffff-no-rj","width":240,"height":240},"high":{"url":"https://yt3.ggpht.com/ytc/AMLnZu_31rROBnB8bq9EJfk82OnclHISQ3Hrx6i1oWLai5o=s800-c-k-c0x00ffffff-no-rj","width":800,"height":800}},"localized":{"title":"Google","description":"Experience the world of Google on our official YouTube channel. Watch videos about our products, technology, company happenings and more. Subscribe to get updates from all your favorite Google products and teams."}},"contentDetails":{"relatedPlaylists":{"likes":"","uploads":"UUK8sQmJBp8GCxrOtXWBpyEA"}},"statistics":{"viewCount":"3331930783","subscriberCount":"10700000","hiddenSubscriberCount":false,"videoCount":"2678"},"brandingSettings":{"channel":{"title":"Google","description":"Experience the world of Google on our official YouTube channel. Watch videos about our products, technology, company happenings and more. Subscribe to get updates from all your favorite Google products and teams.","keywords":"Google Technology Science Android \"Google app\" \"Google drive\" Gmail \"Google Maps\" Nexus \"Google Doodles\" \"Google Zeitgeist\"","trackingAnalyticsAccountId":"UA-7001471-1","unsubscribedTrailer":"hl4N6Yo6qWc"},"image":{"bannerExternalUrl":"https://yt3.ggpht.com/C7C_rceG0_dgSK1uRXoM6s1wCiOwDpsc_bJLELECJ7dVrNZNMhub9la_nhAL6aKpkdR0Z91d"}}},{"kind":"youtube#channel","etag":"-CUA2eUMiVEMMF7ru5xl_INNyfw","id":"UC_x5XG1OV2P6uZZ5FSM9Ttw","snippet":{"title":"Google Developers","description":"The Google Developers channel features talks from events, educational series, best practices, tips, and the latest updates across our products and platforms.\n\nSubscribe to Google Developers → https://goo.gle/developers\n","customUrl":"@googledevelopers","publishedAt":"2007-08-23T00:34:43Z","thumbnails":{"default":{"url":"https://yt3.ggpht.com/ytc/AMLnZu-oDvWEJ-WfN9bgxQB2YAlnjC2uqN_c7JQZvX9Ikfg=s88-c-k-c0x00ffffff-no-rj","width":88,"height":88},"medium":{"url":"https://yt3.ggpht.com/ytc/AMLnZu-oDvWEJ-WfN9bgxQB2YAlnjC2uqN_c7JQZvX9Ikfg=s240-c-k-c0x00ffffff-no-rj","width":240,"height":240},"high":{"url":"https://yt3.ggpht.com/ytc/AMLnZu-oDvWEJ-WfN9bgxQB2YAlnjC2uqN_c7JQZvX9Ikfg=s800-c-k-c0x00ffffff-no-rj","width":800,"height":800}},"localized":{"title":"Google Developers","description":"The Google Developers channel features talks from events, educational series, best practices, tips, and the latest updates across our products and platforms.\n\nSubscribe to Google Developers → https://goo.gle/developers\n"},"country":"US"},"contentDetails":{"relatedPlaylists":{"likes":"","uploads":"UU_x5XG1OV2P6uZZ5FSM9Ttw"}},"statistics":{"viewCount":"208790084","subscriberCount":"2260000","hiddenSubscriberCount":false,"videoCount":"5652"},"brandingSettings":{"channel":{"title":"Google Developers","description":"The Google Developers channel features talks from events, educational series, best practices, tips, and the latest updates across our products and platforms.\n\nSubscribe to Google Developers → https://goo.gle/developers\n","keywords":"\"google developers\" developers \"Google developers videos\" \"google developer tutorials\" \"developer tutorials\" \"developer news\" android firebase tensorflow chrome web flutter \"google developer experts\" \"google launchpad\" \"developer updates\" google \"google design\"","trackingAnalyticsAccountId":"YT-9170156-1","unsubscribedTrailer":"CMN0rd1-uOM","country":"US"},"image":{"bannerExternalUrl":"https://yt3.ggpht.com/LMkDZSq0icg6yqyItLxe2c9tb_KjjI6jsrWE019X4L5TULPPLXJy6rtx7-nN7TB5EiHzoB0R5g"}}}]} \ No newline at end of file diff --git a/testdata/apidata/channels/update_resp.json b/testdata/apidata/channels/update_resp.json new file mode 100644 index 00000000..a9b848c8 --- /dev/null +++ b/testdata/apidata/channels/update_resp.json @@ -0,0 +1 @@ +{"kind":"youtube#channel","etag":"qlk0Tup07Hsl_Dz8nMefxFRUiEU","id":"UCa-vrCLQHviTOVnEKDOdetQ","brandingSettings":{"channel":{"title":"ikaros data","description":"This is a test channel.","keywords":"life 学习 测试","defaultLanguage":"en","country":"CN"},"image":{"bannerExternalUrl":"https://yt3.ggpht.com/t_A-_WuHfqjHqNp8Zbi1Xwed864ix3fD7zWGpkC3huniGjSHe4GEDFPg-dmc0LGpWvrtQZgPBg"}}} \ No newline at end of file diff --git a/tests/clients/test_channels.py b/tests/clients/test_channels.py index 53a3ec8b..18eedd24 100644 --- a/tests/clients/test_channels.py +++ b/tests/clients/test_channels.py @@ -1,35 +1,82 @@ +import pytest import responses from .base import BaseTestCase +from pyyoutube.error import PyYouTubeException +import pyyoutube.models as mds class TestChannelsResource(BaseTestCase): RESOURCE = "channels" channel_id = "UC_x5XG1OV2P6uZZ5FSM9Ttw" - def test_list(self, helpers, authed_cli): + def test_list(self, helpers, authed_cli, key_cli): + with pytest.raises(PyYouTubeException): + key_cli.channels.list() + with responses.RequestsMock() as m: m.add( method="GET", url=self.url, - json=self.load_json("channel_info_single.json", helpers), + json=self.load_json("channels/info.json", helpers), ) - res = authed_cli.channels.list( - parts="id,snippet,statistics", + res = key_cli.channels.list( + parts="id,snippet", id=self.channel_id, ) assert res.items[0].id == self.channel_id + res = key_cli.channels.list( + parts=["id", "snippet"], for_username="googledevelopers" + ) + assert res.items[0].snippet.title == "Google Developers" + + res = authed_cli.channels.list( + parts=("id", "snippet"), + managed_by_me=True, + ) + assert res.items[0].snippet.title == "Google Developers" + + res = authed_cli.channels.list( + parts={"id", "snippet"}, + mine=True, + ) + assert res.items[0].snippet.title == "Google Developers" + with responses.RequestsMock() as m: m.add( method="GET", url=self.url, - json=self.load_json("channel_info_multi.json", helpers), + json=self.load_json("channels/info_multiple.json", helpers), ) res = authed_cli.channels.list( - parts=["id", "snippet", "statistics"], + parts="id,snippet,statistics,contentDetails,brandingSettings", id="UC_x5XG1OV2P6uZZ5FSM9Ttw,UCK8sQmJBp8GCxrOtXWBpyEA", ) assert len(res.items) == 2 + + def test_update(self, helpers, authed_cli): + with responses.RequestsMock() as m: + m.add( + method="PUT", + url=self.url, + json=self.load_json("channels/update_resp.json", helpers), + ) + + updated_channel = authed_cli.channels.update( + part="brandingSettings", + body=mds.Channel( + brandingSettings=mds.ChannelBrandingSetting( + channel=mds.ChannelBrandingSettingChannel( + title="ikaros data", + description="This is a test channel.", + keywords="life 学习 测试", + country="CN", + defaultLanguage="en", + ) + ) + ), + ) + assert updated_channel.brandingSettings.channel.defaultLanguage == "en" diff --git a/tests/conftest.py b/tests/conftest.py index 50fca069..31448974 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -25,3 +25,8 @@ def helpers(): @pytest.fixture(scope="class") def authed_cli(): return Client(access_token="access token") + + +@pytest.fixture(scope="class") +def key_cli(): + return Client(api_key="api key") From 85c3f6a378fb035a5482ee83e046598409a3e384 Mon Sep 17 00:00:00 2001 From: ikaroskun Date: Fri, 11 Nov 2022 16:42:04 +0800 Subject: [PATCH 033/141] test(client): :white_check_mark: update tests for client --- testdata/error_response_simple.json | 3 ++ tests/clients/test_client.py | 83 +++++++++++++++++++++++++++++ tests/test_error_handling.py | 11 ++++ 3 files changed, 97 insertions(+) create mode 100644 testdata/error_response_simple.json create mode 100644 tests/clients/test_client.py diff --git a/testdata/error_response_simple.json b/testdata/error_response_simple.json new file mode 100644 index 00000000..1f8809a1 --- /dev/null +++ b/testdata/error_response_simple.json @@ -0,0 +1,3 @@ +{ + "error": "error message" +} \ No newline at end of file diff --git a/tests/clients/test_client.py b/tests/clients/test_client.py new file mode 100644 index 00000000..d6a25553 --- /dev/null +++ b/tests/clients/test_client.py @@ -0,0 +1,83 @@ +""" + Tests for client. +""" +import pytest + +import responses +from requests import Response, HTTPError + +from .base import BaseTestCase +from pyyoutube import Client, PyYouTubeException + + +class TestClient(BaseTestCase): + BASE_PATH = "testdata" + RESOURCE = "channels" + + def test_initial(self): + with pytest.raises(PyYouTubeException): + Client() + + cli = Client(api_key="key", headers={"HA": "P"}) + assert cli.session.headers["HA"] == "P" + + def test_request(self, key_cli): + with pytest.raises(PyYouTubeException): + cli = Client(client_id="id", client_secret="secret") + cli.request(path="path", enforce_auth=True) + + with responses.RequestsMock() as m: + m.add(method="GET", url="https://example.com", body="") + key_cli.request(path="https://example.com") + + with pytest.raises(PyYouTubeException): + with responses.RequestsMock() as m: + m.add(method="GET", url=self.url, body=HTTPError("Exception")) + key_cli.channels.list(id="xxxxx") + + def test_parse_response(self, key_cli, helpers): + with pytest.raises(PyYouTubeException): + with responses.RequestsMock() as m: + m.add( + method="GET", + url=self.url, + json=self.load_json("error_response.json", helpers), + status=400, + ) + key_cli.channels.list(id="xxxx") + + def test_oauth(self, helpers): + cli = Client(client_id="id", client_secret="secret") + url, state = cli.get_authorize_url() + assert state == "Python-YouTube" + + # test oauth flow + with responses.RequestsMock() as m: + m.add( + method="POST", + url=cli.EXCHANGE_ACCESS_TOKEN_URL, + json=self.load_json("apidata/access_token.json", helpers), + ) + token = cli.generate_access_token(code="code") + assert token.access_token == "access_token" + + refresh_token = cli.refresh_access_token(refresh_token="token") + assert refresh_token.access_token == "access_token" + + # test revoke access token + with responses.RequestsMock() as m: + m.add( + method="POST", + url=cli.REVOKE_TOKEN_URL, + ) + assert cli.revoke_access_token(token="token") + + with pytest.raises(PyYouTubeException): + with responses.RequestsMock() as m: + m.add( + method="POST", + url=cli.REVOKE_TOKEN_URL, + json={"error": {"code": 400, "message": "error"}}, + status=400, + ) + cli.revoke_access_token(token="token") diff --git a/tests/test_error_handling.py b/tests/test_error_handling.py index 7d222e76..c761df13 100644 --- a/tests/test_error_handling.py +++ b/tests/test_error_handling.py @@ -10,6 +10,9 @@ class ErrorTest(unittest.TestCase): with open(BASE_PATH + "error_response.json", "rb") as f: ERROR_DATA = f.read() + with open(BASE_PATH + "error_response_simple.json", "rb") as f: + ERROR_DATA_SIMPLE = f.read() + def testResponseError(self) -> None: response = Response() response.status_code = 400 @@ -24,6 +27,14 @@ def testResponseError(self) -> None: self.assertEqual(repr(ex), error_msg) self.assertTrue(str(ex), error_msg) + def testResponseErrorSimple(self) -> None: + response = Response() + response.status_code = 400 + response._content = self.ERROR_DATA_SIMPLE + + ex = PyYouTubeException(response=response) + self.assertEqual(ex.status_code, 400) + def testErrorMessage(self): response = ErrorMessage(status_code=ErrorCode.HTTP_ERROR, message="error") From c16e3f267d3f0702565ce7bc26c8682c59cfc70a Mon Sep 17 00:00:00 2001 From: ikaroskun Date: Fri, 11 Nov 2022 17:17:55 +0800 Subject: [PATCH 034/141] test(activities): :white_check_mark: update tests for activities --- pyyoutube/resources/base_resource.py | 2 +- tests/clients/test_activities.py | 34 ++++++++++++++++++++++++++++ tests/clients/test_channels.py | 1 + 3 files changed, 36 insertions(+), 1 deletion(-) create mode 100644 tests/clients/test_activities.py diff --git a/pyyoutube/resources/base_resource.py b/pyyoutube/resources/base_resource.py index 986364ea..8359688b 100644 --- a/pyyoutube/resources/base_resource.py +++ b/pyyoutube/resources/base_resource.py @@ -4,7 +4,7 @@ from typing import Optional, TYPE_CHECKING if TYPE_CHECKING: - from pyyoutube import Client + from pyyoutube import Client # pragma: no cover class Resource: diff --git a/tests/clients/test_activities.py b/tests/clients/test_activities.py new file mode 100644 index 00000000..f9cacabe --- /dev/null +++ b/tests/clients/test_activities.py @@ -0,0 +1,34 @@ +import pytest +import responses + +from .base import BaseTestCase +from pyyoutube.error import PyYouTubeException + + +class TestActivitiesResource(BaseTestCase): + RESOURCE = "activities" + + def test_list(self, helpers, authed_cli): + with pytest.raises(PyYouTubeException): + authed_cli.activities.list() + + with responses.RequestsMock() as m: + m.add( + method="GET", + url=self.url, + json=self.load_json( + "activities/activities_by_channel_p1.json", helpers + ), + ) + res = authed_cli.activities.list( + parts=["id", "snippet"], + channel_id="UC_x5XG1OV2P6uZZ5FSM9Ttw", + max_results=10, + ) + assert len(res.items) == 10 + assert authed_cli.activities.access_token == "access token" + + res = authed_cli.activities.list( + parts=["id", "snippet"], mine=True, max_results=10 + ) + assert res.items[0].snippet.type == "upload" diff --git a/tests/clients/test_channels.py b/tests/clients/test_channels.py index 18eedd24..4fc0680d 100644 --- a/tests/clients/test_channels.py +++ b/tests/clients/test_channels.py @@ -26,6 +26,7 @@ def test_list(self, helpers, authed_cli, key_cli): id=self.channel_id, ) assert res.items[0].id == self.channel_id + assert key_cli.channels.api_key == "api key" res = key_cli.channels.list( parts=["id", "snippet"], for_username="googledevelopers" From 6b9dab27c8abc9569b8a05d40f78edfdbef73514 Mon Sep 17 00:00:00 2001 From: ikaroskun Date: Fri, 11 Nov 2022 18:58:01 +0800 Subject: [PATCH 035/141] test(channel section): :white_check_mark: update tests for channel sections --- pyyoutube/resources/channel_sections.py | 12 +- .../apidata/channel_sections/insert_resp.json | 15 +++ testdata/apidata/error_permission_resp.json | 14 +++ tests/clients/test_channel_sections.py | 111 ++++++++++++++++++ 4 files changed, 146 insertions(+), 6 deletions(-) create mode 100644 testdata/apidata/channel_sections/insert_resp.json create mode 100644 testdata/apidata/error_permission_resp.json create mode 100644 tests/clients/test_channel_sections.py diff --git a/pyyoutube/resources/channel_sections.py b/pyyoutube/resources/channel_sections.py index 631290a4..4db55bd2 100644 --- a/pyyoutube/resources/channel_sections.py +++ b/pyyoutube/resources/channel_sections.py @@ -88,8 +88,8 @@ def list( def insert( self, - part: str, body: Union[dict, ChannelSection], + parts: Optional[Union[str, list, tuple, set]] = None, on_behalf_of_content_owner: Optional[str] = None, on_behalf_of_content_owner_channel: Optional[str] = None, return_json: bool = False, @@ -99,7 +99,7 @@ def insert( A channel can create a maximum of 10 shelves. Args: - part: + parts: The part parameter serves two purposes in this operation. It identifies the properties that the write operation will set as well as the properties that the API response will include. Accept values: @@ -134,7 +134,7 @@ def insert( Channel section data. """ params = { - "part": part, + "part": enf_parts(resource="channelSections", value=parts), "onBehalfOfContentOwner": on_behalf_of_content_owner, "onBehalfOfContentOwnerChannel": on_behalf_of_content_owner_channel, **kwargs, @@ -150,8 +150,8 @@ def insert( def update( self, - part: str, body: Union[dict, ChannelSection], + parts: Optional[Union[str, list, tuple, set]] = None, on_behalf_of_content_owner: Optional[str] = None, return_json: bool = False, **kwargs, @@ -159,7 +159,7 @@ def update( """Updates a channel section. Args: - part: + parts: The part parameter serves two purposes in this operation. It identifies the properties that the write operation will set as well as the properties that the API response will include. Accept values: @@ -186,7 +186,7 @@ def update( Channel section data. """ params = { - "part": part, + "part": enf_parts(resource="channelSections", value=parts), "onBehalfOfContentOwner": on_behalf_of_content_owner, **kwargs, } diff --git a/testdata/apidata/channel_sections/insert_resp.json b/testdata/apidata/channel_sections/insert_resp.json new file mode 100644 index 00000000..3240e82b --- /dev/null +++ b/testdata/apidata/channel_sections/insert_resp.json @@ -0,0 +1,15 @@ +{ + "kind": "youtube#channelSection", + "etag": "VNVb0NhdJ8VHoZaVCqGVqfaRrVU", + "id": "UCa-vrCLQHviTOVnEKDOdetQ.Zx4DA4xg9IM", + "snippet": { + "type": "multipleplaylists", + "channelId": "UCa-vrCLQHviTOVnEKDOdetQ", + "position": 4 + }, + "contentDetails": { + "playlists": [ + "PLBaidt0ilCMbUdj0EppB710c_X5OuCP2g" + ] + } +} \ No newline at end of file diff --git a/testdata/apidata/error_permission_resp.json b/testdata/apidata/error_permission_resp.json new file mode 100644 index 00000000..4d643098 --- /dev/null +++ b/testdata/apidata/error_permission_resp.json @@ -0,0 +1,14 @@ +{ + "error": { + "code": 403, + "message": "The caller does not have permission", + "errors": [ + { + "message": "Permission denied.", + "domain": "youtube.CoreErrorDomain", + "reason": "SERVICE_UNAVAILABLE" + } + ], + "status": "PERMISSION_DENIED" + } +} \ No newline at end of file diff --git a/tests/clients/test_channel_sections.py b/tests/clients/test_channel_sections.py new file mode 100644 index 00000000..7ff6efb3 --- /dev/null +++ b/tests/clients/test_channel_sections.py @@ -0,0 +1,111 @@ +import pytest +import responses + +import pyyoutube.models as mds +from .base import BaseTestCase +from pyyoutube.error import PyYouTubeException + + +class TestChannelBannersResource(BaseTestCase): + RESOURCE = "channelSections" + + def test_list(self, helpers, authed_cli, key_cli): + with pytest.raises(PyYouTubeException): + key_cli.channelSections.list() + + with responses.RequestsMock() as m: + m.add( + method="GET", + url=self.url, + json=self.load_json( + "channel_sections/channel_sections_by_channel.json", helpers + ), + ) + + res = key_cli.channelSections.list( + parts=["id", "snippet"], + channel_id="UCa-vrCLQHviTOVnEKDOdetQ", + ) + assert res.items[0].snippet.type == "recentUploads" + + res = authed_cli.channelSections.list( + mine=True, + parts=["id", "snippet"], + ) + assert res.items[0].snippet.channelId == "UCa-vrCLQHviTOVnEKDOdetQ" + + with responses.RequestsMock() as m: + m.add( + method="GET", + url=self.url, + json=self.load_json( + "channel_sections/channel_sections_by_id.json", helpers + ), + ) + res = key_cli.channelSections.list( + parts=["id", "snippet"], + section_id="UCa-vrCLQHviTOVnEKDOdetQ.nGzAI5pLbMY", + ) + assert res.items[0].snippet.type == "multiplePlaylists" + + def test_insert(self, helpers, authed_cli): + with responses.RequestsMock() as m: + m.add( + method="POST", + url=self.url, + json=self.load_json("channel_sections/insert_resp.json", helpers), + ) + section = authed_cli.channelSections.insert( + parts="id,snippet,contentDetails", + body=mds.ChannelSection( + snippet=mds.ChannelSectionSnippet( + type="multiplePlaylists", + position=4, + ), + contentDetails=mds.ChannelSectionContentDetails( + playlists=["PLBaidt0ilCMbUdj0EppB710c_X5OuCP2g"] + ), + ), + ) + assert section.id == "UCa-vrCLQHviTOVnEKDOdetQ.Zx4DA4xg9IM" + + def test_update(self, helpers, authed_cli): + with responses.RequestsMock() as m: + m.add( + method="PUT", + url=self.url, + json=self.load_json("channel_sections/insert_resp.json", helpers), + ) + section = authed_cli.channelSections.update( + parts="id,snippet,contentDetails", + body=mds.ChannelSection( + id="UCa-vrCLQHviTOVnEKDOdetQ.Zx4DA4xg9IM", + snippet=mds.ChannelSectionSnippet( + type="multiplePlaylists", + position=4, + ), + ), + ) + assert section.id == "UCa-vrCLQHviTOVnEKDOdetQ.Zx4DA4xg9IM" + + def test_delete(self, helpers, authed_cli): + with responses.RequestsMock() as m: + m.add( + method="DELETE", + url=self.url, + ) + assert authed_cli.channelSections.delete( + section_id="UCa-vrCLQHviTOVnEKDOdetQ.Zx4DA4xg9IM" + ) + + with pytest.raises(PyYouTubeException): + with responses.RequestsMock() as m: + m.add( + method="DELETE", + url=self.url, + json=self.load_json("error_permission_resp.json", helpers), + status=403, + ) + authed_cli.channelSections.delete( + section_id="UCa-vrCLQHviTOVnEKDOdetQ.Zx4DA4xg9IM" + ) From f56469a748c2f615b8a27aa4278a38f794627beb Mon Sep 17 00:00:00 2001 From: ikaroskun Date: Mon, 14 Nov 2022 18:57:28 +0800 Subject: [PATCH 036/141] test(comments): :white_check_mark: update tests for comments --- .../apidata/comments/insert_response.json | 21 +++ tests/clients/test_comments.py | 137 ++++++++++++++++++ 2 files changed, 158 insertions(+) create mode 100644 testdata/apidata/comments/insert_response.json create mode 100644 tests/clients/test_comments.py diff --git a/testdata/apidata/comments/insert_response.json b/testdata/apidata/comments/insert_response.json new file mode 100644 index 00000000..d6f12542 --- /dev/null +++ b/testdata/apidata/comments/insert_response.json @@ -0,0 +1,21 @@ +{ + "kind": "youtube#comment", + "etag": "lTl2Wjqipb6KqrmPU04DLigrzrg", + "id": "Ugy_CAftKrIUCyPr9GR4AaABAg.9iPXEClD9lW9iPXLqKy_Pt", + "snippet": { + "textDisplay": "wow", + "textOriginal": "wow", + "parentId": "Ugy_CAftKrIUCyPr9GR4AaABAg", + "authorDisplayName": "ikaros data", + "authorProfileImageUrl": "https://yt3.ggpht.com/ytc/AMLnZu-fT-p35OYe0OaSzOmrQY8KEjXVHMgPxyj-hV2r=s48-c-k-c0x00ffffff-no-rj", + "authorChannelUrl": "http://www.youtube.com/channel/UCa-vrCLQHviTOVnEKDOdetQ", + "authorChannelId": { + "value": "UCa-vrCLQHviTOVnEKDOdetQ" + }, + "canRate": true, + "viewerRating": "none", + "likeCount": 0, + "publishedAt": "2022-11-14T10:23:02Z", + "updatedAt": "2022-11-14T10:23:02Z" + } +} diff --git a/tests/clients/test_comments.py b/tests/clients/test_comments.py new file mode 100644 index 00000000..387c8f4f --- /dev/null +++ b/tests/clients/test_comments.py @@ -0,0 +1,137 @@ +import pytest +import responses + +import pyyoutube.models as mds +from .base import BaseTestCase +from pyyoutube.error import PyYouTubeException + + +class TestCommentsResource(BaseTestCase): + RESOURCE = "comments" + + def test_list(self, helpers, key_cli): + with pytest.raises(PyYouTubeException): + key_cli.comments.list() + + with responses.RequestsMock() as m: + m.add( + method="GET", + url=self.url, + json=self.load_json( + "comments/comments_by_parent_paged_1.json", helpers + ), + ) + res = key_cli.comments.list( + parts=["id", "snippet"], + parent_id="Ugw5zYU6n9pmIgAZWvN4AaABAg", + ) + assert ( + res.items[0].id == "Ugw5zYU6n9pmIgAZWvN4AaABAg.91zT3cYb5B291za6voUoRh" + ) + assert res.items[0].snippet.parentId == "Ugw5zYU6n9pmIgAZWvN4AaABAg" + + res = key_cli.comments.list( + parts=["id", "snippet"], + comment_id="UgyUBI0HsgL9emxcZpR4AaABAg,Ugzi3lkqDPfIOirGFLh4AaABAg", + ) + assert len(res.items) == 2 + + def test_insert(self, helpers, authed_cli): + with responses.RequestsMock() as m: + m.add( + method="POST", + url=self.url, + json=self.load_json("comments/insert_response.json", helpers), + ) + + comment = authed_cli.comments.insert( + body=mds.Comment( + snippet=mds.CommentSnippet( + parentId="Ugy_CAftKrIUCyPr9GR4AaABAg", + textOriginal="wow", + ) + ), + parts=["id", "snippet"], + ) + assert comment.snippet.parentId == "Ugy_CAftKrIUCyPr9GR4AaABAg" + + def test_update(self, helpers, authed_cli): + with responses.RequestsMock() as m: + m.add( + method="PUT", + url=self.url, + json=self.load_json("comments/insert_response.json", helpers), + ) + comment = authed_cli.comments.update( + body=mds.Comment( + id="Ugy_CAftKrIUCyPr9GR4AaABAg.9iPXEClD9lW9iPXLqKy_Pt", + snippet=mds.CommentSnippet( + textOriginal="wow", + ), + ), + parts=["id", "snippet"], + ) + assert comment.snippet.parentId == "Ugy_CAftKrIUCyPr9GR4AaABAg" + + def test_mark_as_spam(self, helpers, authed_cli): + with responses.RequestsMock() as m: + m.add(method="POST", url=f"{self.url}/markAsSpam", status=204) + + assert authed_cli.comments.mark_as_spam( + comment_id="Ugy_CAftKrIUCyPr9GR4AaABAg.9iPXEClD9lW9iPXLqKy_Pt", + ) + + with pytest.raises(PyYouTubeException): + with responses.RequestsMock() as m: + m.add( + method="POST", + url=f"{self.url}/markAsSpam", + json=self.load_json("error_permission_resp.json", helpers), + status=403, + ) + authed_cli.comments.mark_as_spam( + comment_id="Ugy_CAftKrIUCyPr9GR4AaABAg.9iPXEClD9lW9iPXLqKy_Pt", + ) + + def test_set_moderation_status(self, helpers, authed_cli): + with responses.RequestsMock() as m: + m.add(method="POST", url=f"{self.url}/setModerationStatus", status=204) + + assert authed_cli.comments.set_moderation_status( + comment_id="Ugy_CAftKrIUCyPr9GR4AaABAg.9iPXEClD9lW9iPXLqKy_Pt", + moderation_status="rejected", + ) + + with pytest.raises(PyYouTubeException): + with responses.RequestsMock() as m: + m.add( + method="POST", + url=f"{self.url}/setModerationStatus", + json=self.load_json("error_permission_resp.json", helpers), + status=403, + ) + authed_cli.comments.set_moderation_status( + comment_id="Ugy_CAftKrIUCyPr9GR4AaABAg.9iPXEClD9lW9iPXLqKy_Pt", + moderation_status="published", + ban_author=True, + ) + + def test_delete(self, helpers, authed_cli): + with responses.RequestsMock() as m: + m.add(method="DELETE", url=f"{self.url}", status=204) + + assert authed_cli.comments.delete( + comment_id="Ugy_CAftKrIUCyPr9GR4AaABAg.9iPXEClD9lW9iPXLqKy_Pt", + ) + + with pytest.raises(PyYouTubeException): + with responses.RequestsMock() as m: + m.add( + method="DELETE", + url=f"{self.url}", + json=self.load_json("error_permission_resp.json", helpers), + status=403, + ) + authed_cli.comments.delete( + comment_id="Ugy_CAftKrIUCyPr9GR4AaABAg.9iPXEClD9lW9iPXLqKy_Pt", + ) From 8fbca92854c987bcf82ae3e67c6561e1e17b7374 Mon Sep 17 00:00:00 2001 From: ikaroskun Date: Tue, 15 Nov 2022 14:25:32 +0800 Subject: [PATCH 037/141] test(comments): :white_check_mark: update tests for comments and so on --- .../comment_threads/insert_response.json | 34 +++++++++ tests/clients/test_comment_threads.py | 70 +++++++++++++++++++ tests/clients/test_i18n.py | 35 ++++++++++ 3 files changed, 139 insertions(+) create mode 100644 testdata/apidata/comment_threads/insert_response.json create mode 100644 tests/clients/test_comment_threads.py create mode 100644 tests/clients/test_i18n.py diff --git a/testdata/apidata/comment_threads/insert_response.json b/testdata/apidata/comment_threads/insert_response.json new file mode 100644 index 00000000..80930cee --- /dev/null +++ b/testdata/apidata/comment_threads/insert_response.json @@ -0,0 +1,34 @@ +{ + "kind": "youtube#commentThread", + "etag": "AMgl2io48I4z6Ulu9kv4C43sVvk", + "id": "Ugx_5P8rmn4vKbN6wwt4AaABAg", + "snippet": { + "channelId": "UCa-vrCLQHviTOVnEKDOdetQ", + "videoId": "JE8xdDp5B8Q", + "topLevelComment": { + "kind": "youtube#comment", + "etag": "I_E2on6NOdGkpW0WodB74OVCU_E", + "id": "Ugx_5P8rmn4vKbN6wwt4AaABAg", + "snippet": { + "channelId": "UCa-vrCLQHviTOVnEKDOdetQ", + "videoId": "JE8xdDp5B8Q", + "textDisplay": "Sun from the api", + "textOriginal": "Sun from the api", + "authorDisplayName": "ikaros data", + "authorProfileImageUrl": "https://yt3.ggpht.com/ytc/AMLnZu-fT-p35OYe0OaSzOmrQY8KEjXVHMgPxyj-hV2r=s48-c-k-c0x00ffffff-no-rj", + "authorChannelUrl": "http://www.youtube.com/channel/UCa-vrCLQHviTOVnEKDOdetQ", + "authorChannelId": { + "value": "UCa-vrCLQHviTOVnEKDOdetQ" + }, + "canRate": true, + "viewerRating": "none", + "likeCount": 0, + "publishedAt": "2022-11-15T02:20:01Z", + "updatedAt": "2022-11-15T02:20:01Z" + } + }, + "canReply": true, + "totalReplyCount": 0, + "isPublic": true + } +} diff --git a/tests/clients/test_comment_threads.py b/tests/clients/test_comment_threads.py new file mode 100644 index 00000000..a85b52da --- /dev/null +++ b/tests/clients/test_comment_threads.py @@ -0,0 +1,70 @@ +import pytest +import responses + +import pyyoutube.models as mds +from .base import BaseTestCase +from pyyoutube.error import PyYouTubeException + + +class TestCommentThreadsResource(BaseTestCase): + RESOURCE = "commentThreads" + + def test_list(self, helpers, key_cli): + with pytest.raises(PyYouTubeException): + key_cli.commentThreads.list() + + with responses.RequestsMock() as m: + m.add( + method="GET", + url=self.url, + json=self.load_json( + "comment_threads/comment_threads_by_video_paged_1.json", helpers + ), + ) + + res = key_cli.commentThreads.list( + parts=["id", "snippet"], + all_threads_related_to_channel_id="UC_x5XG1OV2P6uZZ5FSM9Ttw", + ) + assert res.items[0].snippet.totalReplyCount == 0 + + res = key_cli.commentThreads.list( + parts=["id", "snippet"], + channel_id="UC_x5XG1OV2P6uZZ5FSM9Ttw", + ) + assert res.items[0].snippet.totalReplyCount == 0 + + res = key_cli.commentThreads.list( + parts=["id", "snippet"], + video_id="F1UP7wRCPH8", + ) + assert res.items[0].snippet.videoId == "F1UP7wRCPH8" + + res = key_cli.commentThreads.list( + parts=["id", "snippet"], + thread_id="UgyZ1jqkHKYvi1-ruOZ4AaABAg,Ugy4OzAuz5uJuFt3FH54AaABAg", + ) + assert res.items[0].id == "UgyZ1jqkHKYvi1-ruOZ4AaABAg" + + def test_insert(self, helpers, authed_cli): + with responses.RequestsMock() as m: + m.add( + method="POST", + url=self.url, + json=self.load_json("comment_threads/insert_response.json", helpers), + ) + + thread = authed_cli.commentThreads.insert( + body=mds.CommentThread( + snippet=mds.CommentThreadSnippet( + videoId="JE8xdDp5B8Q", + topLevelComment=mds.Comment( + snippet=mds.CommentSnippet( + textOriginal="Sun from the api", + ) + ), + ) + ), + parts=["id", "snippet"], + ) + assert thread.snippet.videoId == "JE8xdDp5B8Q" diff --git a/tests/clients/test_i18n.py b/tests/clients/test_i18n.py new file mode 100644 index 00000000..c0f85357 --- /dev/null +++ b/tests/clients/test_i18n.py @@ -0,0 +1,35 @@ +import responses + +from .base import BaseTestCase + + +class TestI18nLanguagesResource(BaseTestCase): + RESOURCE = "i18nLanguages" + + def test_list(self, helpers, key_cli): + with responses.RequestsMock() as m: + m.add( + method="GET", + url=self.url, + json=self.load_json("i18ns/language_res.json", helpers), + ) + res = key_cli.i18nLanguages.list( + parts=["snippet"], + ) + assert res.items[0].snippet.name == "Chinese" + + +class TestI18nRegionsResource(BaseTestCase): + RESOURCE = "i18nRegions" + + def test_list(self, helpers, key_cli): + with responses.RequestsMock() as m: + m.add( + method="GET", + url=self.url, + json=self.load_json("i18ns/regions_res.json", helpers), + ) + res = key_cli.i18nRegions.list( + parts=["snippet"], + ) + assert res.items[0].snippet.name == "Venezuela" From 77627f8a302396d0bb88483d4dc4f436a7d4e2c2 Mon Sep 17 00:00:00 2001 From: ikaroskun Date: Tue, 15 Nov 2022 17:08:28 +0800 Subject: [PATCH 038/141] feat(members): :sparkles: add api for members --- pyyoutube/client.py | 1 + pyyoutube/models/__init__.py | 2 +- pyyoutube/resources/__init__.py | 1 + pyyoutube/resources/members.py | 75 +++++++++++++++++++++++++++++++++ 4 files changed, 78 insertions(+), 1 deletion(-) create mode 100644 pyyoutube/resources/members.py diff --git a/pyyoutube/client.py b/pyyoutube/client.py index 03b6c708..4d8e7245 100644 --- a/pyyoutube/client.py +++ b/pyyoutube/client.py @@ -45,6 +45,7 @@ class Client: commentThreads = resources.CommentThreadsResource() i18nLanguages = resources.I18nLanguagesResource() i18nRegions = resources.I18nRegionsResource() + members = resources.MembersResource() def __new__(cls, *args, **kwargs): self = super().__new__(cls) diff --git a/pyyoutube/models/__init__.py b/pyyoutube/models/__init__.py index 6c93520a..f724eead 100644 --- a/pyyoutube/models/__init__.py +++ b/pyyoutube/models/__init__.py @@ -11,6 +11,7 @@ from .comment import * # noqa from .comment_thread import * # noqa from .i18n import * # noqa +from .member import * # noqa from .playlist import ( Playlist, PlaylistContentDetails, @@ -52,5 +53,4 @@ SearchResult, SearchListResponse, ) -from .member import Member, MemberListResponse from .memberships_level import MembershipsLevel, MembershipsLevelListResponse diff --git a/pyyoutube/resources/__init__.py b/pyyoutube/resources/__init__.py index e76d8a70..4c4a208f 100644 --- a/pyyoutube/resources/__init__.py +++ b/pyyoutube/resources/__init__.py @@ -5,3 +5,4 @@ from .comment_threads import CommentThreadsResource # noqa from .i18n_languages import I18nLanguagesResource # noqa from .i18n_regions import I18nRegionsResource # noqa +from .members import MembersResource # noqa diff --git a/pyyoutube/resources/members.py b/pyyoutube/resources/members.py new file mode 100644 index 00000000..313e1db3 --- /dev/null +++ b/pyyoutube/resources/members.py @@ -0,0 +1,75 @@ +""" + Members resource implementation. +""" + +from typing import Optional, Union + +from pyyoutube.resources.base_resource import Resource +from pyyoutube.models import MemberListResponse +from pyyoutube.utils.params_checker import enf_parts, enf_comma_separated + + +class MembersResource(Resource): + """A member resource represents a channel member for a YouTube channel. + + References: https://developers.google.com/youtube/v3/docs/members + """ + + def list( + self, + parts: Optional[Union[str, list, tuple, set]] = None, + mode: Optional[str] = None, + max_results: Optional[int] = None, + page_token: Optional[str] = None, + has_access_to_level: Optional[str] = None, + filter_by_member_channel_id: Optional[Union[str, list, tuple, set]] = None, + return_json: bool = False, + **kwargs: Optional[dict], + ) -> Union[dict, MemberListResponse]: + """Lists members (formerly known as "sponsors") for a channel. + + Args: + parts: + Comma-separated list of one or more channel resource properties. + Accepted values: snippet + mode: + Indicates which members will be included in the API response. + Accepted values: + - all_current: List current members, from newest to oldest. + - updates: List only members that joined or upgraded since the previous API call. + max_results: + The parameter specifies the maximum number of items that should be returned + the result set. + Acceptable values are 0 to 1000, inclusive. The default value is 5. + page_token: + The parameter identifies a specific page in the result set that should be returned. + has_access_to_level: + A level ID that specifies the minimum level that members in the result set should have. + filter_by_member_channel_id: + specifies a comma-separated list of channel IDs that can be used to check the membership + status of specific users. + Maximum of 100 channels can be specified per call. + return_json: + Type for returned data. If you set True JSON data will be returned. + **kwargs: + Additional parameters for system parameters. + Refer: https://cloud.google.com/apis/docs/system-parameters. + + Returns: + Members data. + """ + + params = { + "part": enf_parts(resource="members", value=parts), + "mode": mode, + "maxResults": max_results, + "pageToken": page_token, + "hasAccessToLevel": has_access_to_level, + "filterByMemberChannelId": enf_comma_separated( + field="filter_by_member_channel_id", value=filter_by_member_channel_id + ), + **kwargs, + } + response = self._client.request(path="members", params=params) + data = self._client.parse_response(response=response) + return data if return_json else MemberListResponse.from_dict(data) From ccc2b9fd9ef284e4abbc3025e207d3dea1aa7e54 Mon Sep 17 00:00:00 2001 From: ikaroskun Date: Tue, 15 Nov 2022 17:16:41 +0800 Subject: [PATCH 039/141] test(members): :white_check_mark: update tests for members --- pyyoutube/utils/constants.py | 1 - tests/apis/test_members.py | 2 +- tests/clients/test_members.py | 22 ++++++++++++++++++++++ 3 files changed, 23 insertions(+), 2 deletions(-) create mode 100644 tests/clients/test_members.py diff --git a/pyyoutube/utils/constants.py b/pyyoutube/utils/constants.py index c02d5e72..e861d90f 100644 --- a/pyyoutube/utils/constants.py +++ b/pyyoutube/utils/constants.py @@ -39,7 +39,6 @@ I18N_REGION_PROPERTIES = {"snippet"} MEMBER_PROPERTIES = { - "id", "snippet", } diff --git a/tests/apis/test_members.py b/tests/apis/test_members.py index 5c7d9d6e..9ddf497a 100644 --- a/tests/apis/test_members.py +++ b/tests/apis/test_members.py @@ -22,7 +22,7 @@ def testGetMembers(self) -> None: with responses.RequestsMock() as m: m.add("GET", self.MEMBERS_URL, json=self.MEMBERS_RES) - members = self.api.get_members(parts=["id", "snippet"]) + members = self.api.get_members(parts=["snippet"]) self.assertEqual(members.kind, "youtube#memberListResponse") self.assertEqual(len(members.items), 2) diff --git a/tests/clients/test_members.py b/tests/clients/test_members.py new file mode 100644 index 00000000..d72568e4 --- /dev/null +++ b/tests/clients/test_members.py @@ -0,0 +1,22 @@ +import responses + +from .base import BaseTestCase + + +class TestMembersResource(BaseTestCase): + RESOURCE = "members" + + def test_list(self, helpers, authed_cli): + with responses.RequestsMock() as m: + m.add( + method="GET", + url=self.url, + json=self.load_json("members/members_data.json", helpers), + ) + + res = authed_cli.members.list( + parts=["snippet"], + mode="all_current", + max_results=5, + ) + assert len(res.items) == 2 From 42210bb22eeb0865438c3974524bbc35a67c21e3 Mon Sep 17 00:00:00 2001 From: ikaroskun Date: Tue, 15 Nov 2022 17:24:21 +0800 Subject: [PATCH 040/141] feat(membershiplevel): :sparkles: Add api for membership levels --- pyyoutube/client.py | 1 + pyyoutube/models/__init__.py | 2 +- pyyoutube/resources/__init__.py | 1 + pyyoutube/resources/membership_levels.py | 45 ++++++++++++++++++++++++ 4 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 pyyoutube/resources/membership_levels.py diff --git a/pyyoutube/client.py b/pyyoutube/client.py index 4d8e7245..e55f05cf 100644 --- a/pyyoutube/client.py +++ b/pyyoutube/client.py @@ -46,6 +46,7 @@ class Client: i18nLanguages = resources.I18nLanguagesResource() i18nRegions = resources.I18nRegionsResource() members = resources.MembersResource() + membershipsLevels = resources.MembershipLevelsResource() def __new__(cls, *args, **kwargs): self = super().__new__(cls) diff --git a/pyyoutube/models/__init__.py b/pyyoutube/models/__init__.py index f724eead..24cddd7e 100644 --- a/pyyoutube/models/__init__.py +++ b/pyyoutube/models/__init__.py @@ -12,6 +12,7 @@ from .comment_thread import * # noqa from .i18n import * # noqa from .member import * # noqa +from .memberships_level import * # noqa from .playlist import ( Playlist, PlaylistContentDetails, @@ -53,4 +54,3 @@ SearchResult, SearchListResponse, ) -from .memberships_level import MembershipsLevel, MembershipsLevelListResponse diff --git a/pyyoutube/resources/__init__.py b/pyyoutube/resources/__init__.py index 4c4a208f..e40941ec 100644 --- a/pyyoutube/resources/__init__.py +++ b/pyyoutube/resources/__init__.py @@ -6,3 +6,4 @@ from .i18n_languages import I18nLanguagesResource # noqa from .i18n_regions import I18nRegionsResource # noqa from .members import MembersResource # noqa +from .membership_levels import MembershipLevelsResource # noqa diff --git a/pyyoutube/resources/membership_levels.py b/pyyoutube/resources/membership_levels.py new file mode 100644 index 00000000..6375afa5 --- /dev/null +++ b/pyyoutube/resources/membership_levels.py @@ -0,0 +1,45 @@ +""" + Membership levels resource implementation. +""" +from typing import Optional, Union + +from pyyoutube.models import MembershipsLevelListResponse +from pyyoutube.resources.base_resource import Resource +from pyyoutube.utils.params_checker import enf_parts + + +class MembershipLevelsResource(Resource): + """A membershipsLevel resource identifies a pricing level managed by the creator that authorized the API request. + + References: https://developers.google.com/youtube/v3/docs/membershipsLevels + """ + + def list( + self, + parts: Optional[Union[str, list, tuple, set]] = None, + return_json: bool = False, + **kwargs: Optional[dict], + ) -> Union[dict, MembershipsLevelListResponse]: + """Lists membership levels for the channel that authorized the request. + + Args: + parts: + Comma-separated list of one or more channel resource properties. + Accepted values: id,snippet + return_json: + Type for returned data. If you set True JSON data will be returned. + **kwargs: + Additional parameters for system parameters. + Refer: https://cloud.google.com/apis/docs/system-parameters. + + Returns: + Membership levels data. + + """ + params = { + "part": enf_parts(resource="membershipsLevels", value=parts), + **kwargs, + } + response = self._client.request(path="channels", params=params) + data = self._client.parse_response(response=response) + return data if return_json else MembershipsLevelListResponse.from_dict(data) From 5adb297516eb7d0a27bcf251d925a303f7260ead Mon Sep 17 00:00:00 2001 From: ikaroskun Date: Tue, 15 Nov 2022 18:16:16 +0800 Subject: [PATCH 041/141] test(membershiplevels): :white_check_mark: update tests for membership levels --- pyyoutube/resources/membership_levels.py | 2 +- tests/clients/test_membership_levels.py | 20 ++++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) create mode 100644 tests/clients/test_membership_levels.py diff --git a/pyyoutube/resources/membership_levels.py b/pyyoutube/resources/membership_levels.py index 6375afa5..4bbc790d 100644 --- a/pyyoutube/resources/membership_levels.py +++ b/pyyoutube/resources/membership_levels.py @@ -40,6 +40,6 @@ def list( "part": enf_parts(resource="membershipsLevels", value=parts), **kwargs, } - response = self._client.request(path="channels", params=params) + response = self._client.request(path="membershipsLevels", params=params) data = self._client.parse_response(response=response) return data if return_json else MembershipsLevelListResponse.from_dict(data) diff --git a/tests/clients/test_membership_levels.py b/tests/clients/test_membership_levels.py new file mode 100644 index 00000000..10b31e1f --- /dev/null +++ b/tests/clients/test_membership_levels.py @@ -0,0 +1,20 @@ +import responses + +from .base import BaseTestCase + + +class TestMembershipLevelsResource(BaseTestCase): + RESOURCE = "membershipsLevels" + + def test_list(self, helpers, authed_cli): + with responses.RequestsMock() as m: + m.add( + method="GET", + url=self.url, + json=self.load_json("members/membership_levels.json", helpers), + ) + + res = authed_cli.membershipsLevels.list( + parts=["id", "snippet"], + ) + assert len(res.items) == 2 From 45c867660352af3152d7eda00882465841417876 Mon Sep 17 00:00:00 2001 From: ikaros Date: Tue, 15 Nov 2022 21:17:45 +0800 Subject: [PATCH 042/141] feat(playlist items): :sparkles: add api for playlist items resource --- pyyoutube/client.py | 1 + pyyoutube/models/__init__.py | 8 +- pyyoutube/models/playlist_item.py | 2 + pyyoutube/resources/__init__.py | 1 + pyyoutube/resources/channels.py | 2 +- pyyoutube/resources/playlist_items.py | 231 ++++++++++++++++++++++++++ 6 files changed, 237 insertions(+), 8 deletions(-) create mode 100644 pyyoutube/resources/playlist_items.py diff --git a/pyyoutube/client.py b/pyyoutube/client.py index e55f05cf..4cecff4f 100644 --- a/pyyoutube/client.py +++ b/pyyoutube/client.py @@ -47,6 +47,7 @@ class Client: i18nRegions = resources.I18nRegionsResource() members = resources.MembersResource() membershipsLevels = resources.MembershipLevelsResource() + playlistItems = resources.PlaylistItemsResource() def __new__(cls, *args, **kwargs): self = super().__new__(cls) diff --git a/pyyoutube/models/__init__.py b/pyyoutube/models/__init__.py index 24cddd7e..680a2d3f 100644 --- a/pyyoutube/models/__init__.py +++ b/pyyoutube/models/__init__.py @@ -13,6 +13,7 @@ from .i18n import * # noqa from .member import * # noqa from .memberships_level import * # noqa +from .playlist_item import * # noqa from .playlist import ( Playlist, PlaylistContentDetails, @@ -20,13 +21,6 @@ PlaylistSnippet, PlaylistStatus, ) -from .playlist_item import ( - PlaylistItem, - PlaylistItemContentDetails, - PlaylistItemListResponse, - PlaylistItemSnippet, - PlaylistItemStatus, -) from .subscription import ( Subscription, SubscriptionContentDetails, diff --git a/pyyoutube/models/playlist_item.py b/pyyoutube/models/playlist_item.py index 60a43b0a..66d1c03b 100644 --- a/pyyoutube/models/playlist_item.py +++ b/pyyoutube/models/playlist_item.py @@ -21,6 +21,8 @@ class PlaylistItemContentDetails(BaseModel, DatetimeTimeMixin): videoId: Optional[str] = field(default=None) note: Optional[str] = field(default=None, repr=False) videoPublishedAt: Optional[str] = field(default=None) + startAt: Optional[str] = field(default=None, repr=False) + endAt: Optional[str] = field(default=None, repr=False) @dataclass diff --git a/pyyoutube/resources/__init__.py b/pyyoutube/resources/__init__.py index e40941ec..6123ae81 100644 --- a/pyyoutube/resources/__init__.py +++ b/pyyoutube/resources/__init__.py @@ -7,3 +7,4 @@ from .i18n_regions import I18nRegionsResource # noqa from .members import MembersResource # noqa from .membership_levels import MembershipLevelsResource # noqa +from .playlist_items import PlaylistItemsResource # noqa diff --git a/pyyoutube/resources/channels.py b/pyyoutube/resources/channels.py index 254f5b2f..35766499 100644 --- a/pyyoutube/resources/channels.py +++ b/pyyoutube/resources/channels.py @@ -61,7 +61,7 @@ def list( The onBehalfOfContentOwner parameter indicates that the request's authorization credentials identify a YouTube CMS user who is acting on behalf of the content owner specified in the parameter value. This parameter is intended for YouTube - content partners that own and manage many different YouTube channels. It allows + content partners that own and manage many difference YouTube channels. It allows content owners to authenticate once and get access to all their video and channel data, without having to provide authentication credentials for each individual channel. The CMS account that the user authenticates with must be linked to the specified YouTube content owner. diff --git a/pyyoutube/resources/playlist_items.py b/pyyoutube/resources/playlist_items.py new file mode 100644 index 00000000..7e933a2d --- /dev/null +++ b/pyyoutube/resources/playlist_items.py @@ -0,0 +1,231 @@ +""" + Playlist items resource implementation. +""" + +from typing import Optional, Union + +from pyyoutube.error import PyYouTubeException, ErrorCode, ErrorMessage +from pyyoutube.resources.base_resource import Resource +from pyyoutube.models import PlaylistItem, PlaylistItemListResponse +from pyyoutube.utils.params_checker import enf_parts, enf_comma_separated + + +class PlaylistItemsResource(Resource): + """A playlistItem resource identifies another resource, such as a video, that is included + in a playlist. In addition, the playlistItem resource contains details about the included + resource that pertain specifically to how that resource is used in that playlist. + + References: https://developers.google.com/youtube/v3/docs/playlistItems + """ + + def list( + self, + parts: Optional[Union[str, list, tuple, set]] = None, + playlist_item_id: Optional[Union[str, list, tuple, set]] = None, + playlist_id: Optional[str] = None, + max_results: Optional[int] = None, + on_behalf_of_content_owner: Optional[str] = None, + page_token: Optional[str] = None, + video_id: Optional[str] = None, + return_json: bool = False, + **kwargs: Optional[dict], + ) -> Union[dict, PlaylistItemListResponse]: + """Returns a collection of playlist items that match the API request parameters. + + Args: + parts: + Comma-separated list of one or more channel resource properties. + Accepted values: id,contentDetails,snippet,snippet + playlist_item_id: + Specifies a comma-separated list of one or more unique playlist item IDs. + playlist_id: + Specifies the unique ID of the playlist for which you want to retrieve playlist items. + max_results: + The parameter specifies the maximum number of items that should be returned + the result set. + Acceptable values are 0 to 50, inclusive. The default value is 5. + on_behalf_of_content_owner: + The onBehalfOfContentOwner parameter indicates that the request's authorization + credentials identify a YouTube CMS user who is acting on behalf of the content + owner specified in the parameter value. This parameter is intended for YouTube + content partners that own and manage many difference YouTube channels. It allows + content owners to authenticate once and get access to all their video and channel + data, without having to provide authentication credentials for each individual channel. + The CMS account that the user authenticates with must be linked to the specified YouTube content owner. + page_token: + The parameter identifies a specific page in the result set that should be returned. + video_id: + Specifies that the request should return only the playlist items that contain the specified video. + return_json: + Type for returned data. If you set True JSON data will be returned. + **kwargs: + Additional parameters for system parameters. + Refer: https://cloud.google.com/apis/docs/system-parameters. + + Returns: + Playlist items data. + + Raises: + PyYouTubeException: Missing filter parameter. + """ + + params = { + "part": enf_parts(resource="playlistItems", value=parts), + "maxResults": max_results, + "onBehalfOfContentOwner": on_behalf_of_content_owner, + "videoId": video_id, + "pageToken": page_token, + **kwargs, + } + if playlist_item_id is not None: + params["id"] = enf_comma_separated( + field="playlist_item_id", value=playlist_item_id + ) + elif playlist_id is not None: + params["playlistId"] = playlist_id + else: + raise PyYouTubeException( + ErrorMessage( + status_code=ErrorCode.MISSING_PARAMS, + message=f"Specify at least one of playlist_item_id or playlist_id", + ) + ) + + response = self._client.request(path="playlistItems", params=params) + data = self._client.parse_response(response=response) + return data if return_json else PlaylistItemListResponse.from_dict(data) + + def insert( + self, + body: Optional[dict, PlaylistItem], + parts: Optional[Union[str, list, tuple, set]] = None, + on_behalf_of_content_owner: Optional[str] = None, + return_json: bool = False, + **kwargs: Optional[dict], + ) -> Union[dict, PlaylistItem]: + """Adds a resource to a playlist. + + Args: + body: + Provide playlist item data in the request body. You can give dataclass or just a dict with data. + parts: + Comma-separated list of one or more channel resource properties. + Accepted values: id,contentDetails,snippet,snippet + on_behalf_of_content_owner: + The onBehalfOfContentOwner parameter indicates that the request's authorization + credentials identify a YouTube CMS user who is acting on behalf of the content + owner specified in the parameter value. This parameter is intended for YouTube + content partners that own and manage many difference YouTube channels. It allows + content owners to authenticate once and get access to all their video and channel + data, without having to provide authentication credentials for each individual channel. + The CMS account that the user authenticates with must be linked to the specified YouTube content owner. + return_json: + Type for returned data. If you set True JSON data will be returned. + **kwargs: + Additional parameters for system parameters. + Refer: https://cloud.google.com/apis/docs/system-parameters. + + Returns: + Playlist item data. + """ + params = { + "part": enf_parts(resource="playlistItems", value=parts), + "onBehalfOfContentOwner": on_behalf_of_content_owner, + **kwargs, + } + response = self._client.request( + method="POST", + path="playlistItems", + params=params, + json=body, + ) + data = self._client.parse_response(response=response) + return data if return_json else PlaylistItem.from_dict(data) + + def update( + self, + body: Optional[dict, PlaylistItem], + parts: Optional[Union[str, list, tuple, set]] = None, + on_behalf_of_content_owner: Optional[str] = None, + return_json: bool = False, + **kwargs: Optional[dict], + ) -> Union[dict, PlaylistItem]: + """Modifies a playlist item. For example, you could update the item's position in the playlist. + + Args: + body: + Provide playlist item data in the request body. You can give dataclass or just a dict with data. + parts: + Comma-separated list of one or more channel resource properties. + Accepted values: id,contentDetails,snippet,snippet + on_behalf_of_content_owner: + The onBehalfOfContentOwner parameter indicates that the request's authorization + credentials identify a YouTube CMS user who is acting on behalf of the content + owner specified in the parameter value. This parameter is intended for YouTube + content partners that own and manage many difference YouTube channels. It allows + content owners to authenticate once and get access to all their video and channel + data, without having to provide authentication credentials for each individual channel. + The CMS account that the user authenticates with must be linked to the specified YouTube content owner. + return_json: + Type for returned data. If you set True JSON data will be returned. + **kwargs: + Additional parameters for system parameters. + Refer: https://cloud.google.com/apis/docs/system-parameters. + + Returns: + Playlist item update data. + """ + params = { + "part": enf_parts(resource="playlistItems", value=parts), + "onBehalfOfContentOwner": on_behalf_of_content_owner, + **kwargs, + } + response = self._client.request( + method="PUT", + path="playlistItems", + params=params, + json=body, + ) + data = self._client.parse_response(response=response) + return data if return_json else PlaylistItem.from_dict(data) + + def delete( + self, + playlist_item_id: str, + on_behalf_of_content_owner: Optional[str] = None, + **kwargs: Optional[dict], + ) -> bool: + """Deletes a playlist item. + + Args: + playlist_item_id: + Specifies the YouTube playlist item ID for the playlist item that is being deleted. + on_behalf_of_content_owner: + The onBehalfOfContentOwner parameter indicates that the request's authorization + credentials identify a YouTube CMS user who is acting on behalf of the content + owner specified in the parameter value. This parameter is intended for YouTube + content partners that own and manage many difference YouTube channels. It allows + content owners to authenticate once and get access to all their video and channel + data, without having to provide authentication credentials for each individual channel. + The CMS account that the user authenticates with must be linked to the specified YouTube content owner. + **kwargs: + Additional parameters for system parameters. + Refer: https://cloud.google.com/apis/docs/system-parameters. + + Returns: + Playlist item delete status. + + """ + params = { + "id": playlist_item_id, + "onBehalfOfContentOwner": on_behalf_of_content_owner, + **kwargs, + } + response = self._client.request( + method="DELETE", + path="playlistItems", + params=params, + ) + if response.ok: + return True + self._client.parse_response(response=response) From 847e9c7978f97832f3e4165cf481ef29a328b881 Mon Sep 17 00:00:00 2001 From: ikaros Date: Tue, 15 Nov 2022 21:44:24 +0800 Subject: [PATCH 043/141] test(playlist items): :white_check_mark: update tests for playlist items resource --- pyyoutube/resources/playlist_items.py | 4 +- .../playlist_items/insert_response.json | 47 ++++++++ tests/clients/test_playlist_items.py | 100 ++++++++++++++++++ 3 files changed, 149 insertions(+), 2 deletions(-) create mode 100644 testdata/apidata/playlist_items/insert_response.json create mode 100644 tests/clients/test_playlist_items.py diff --git a/pyyoutube/resources/playlist_items.py b/pyyoutube/resources/playlist_items.py index 7e933a2d..cb86c2ba 100644 --- a/pyyoutube/resources/playlist_items.py +++ b/pyyoutube/resources/playlist_items.py @@ -97,7 +97,7 @@ def list( def insert( self, - body: Optional[dict, PlaylistItem], + body: Union[dict, PlaylistItem], parts: Optional[Union[str, list, tuple, set]] = None, on_behalf_of_content_owner: Optional[str] = None, return_json: bool = False, @@ -144,7 +144,7 @@ def insert( def update( self, - body: Optional[dict, PlaylistItem], + body: Union[dict, PlaylistItem], parts: Optional[Union[str, list, tuple, set]] = None, on_behalf_of_content_owner: Optional[str] = None, return_json: bool = False, diff --git a/testdata/apidata/playlist_items/insert_response.json b/testdata/apidata/playlist_items/insert_response.json new file mode 100644 index 00000000..af9f42b2 --- /dev/null +++ b/testdata/apidata/playlist_items/insert_response.json @@ -0,0 +1,47 @@ +{ + "kind": "youtube#playlistItem", + "etag": "4Bl2u6s8N1Jkkz1AHN4E-tw4OQQ", + "id": "UExCYWlkdDBpbENNYW5HRElLcjhVVkJGWndOX1V2TUt2Uy4wMTcyMDhGQUE4NTIzM0Y5", + "snippet": { + "publishedAt": "2022-11-15T13:38:09Z", + "channelId": "UCa-vrCLQHviTOVnEKDOdetQ", + "title": "Lecture 6: Version Control (git) (2020)", + "description": "You can find the lecture notes and exercises for this lecture at https://missing.csail.mit.edu/2020/version-control/\n\nHelp us caption & translate this video!\n\nhttps://amara.org/v/C1Ef9/", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/2sjqTHE0zok/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/2sjqTHE0zok/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/2sjqTHE0zok/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/2sjqTHE0zok/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/2sjqTHE0zok/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "ikaros data", + "playlistId": "PLBaidt0ilCManGDIKr8UVBFZwN_UvMKvS", + "position": 0, + "resourceId": { + "kind": "youtube#video", + "videoId": "2sjqTHE0zok" + }, + "videoOwnerChannelTitle": "Missing Semester", + "videoOwnerChannelId": "UCuXy5tCgEninup9cGplbiFw" + } +} diff --git a/tests/clients/test_playlist_items.py b/tests/clients/test_playlist_items.py new file mode 100644 index 00000000..c8444345 --- /dev/null +++ b/tests/clients/test_playlist_items.py @@ -0,0 +1,100 @@ +import pytest +import responses + +import pyyoutube.models as mds +from .base import BaseTestCase +from pyyoutube.error import PyYouTubeException + + +class TestPlaylistItemsResource(BaseTestCase): + RESOURCE = "playlistItems" + + def test_list(self, helpers, key_cli): + with pytest.raises(PyYouTubeException): + key_cli.playlistItems.list() + + with responses.RequestsMock() as m: + m.add( + method="GET", + url=self.url, + json=self.load_json( + "playlist_items/playlist_items_paged_1.json", helpers + ), + ) + + res = key_cli.playlistItems.list( + parts=["id", "snippet"], + playlist_id="PLOU2XLYxmsIKpaV8h0AGE05so0fAwwfTw", + max_results=10, + ) + assert len(res.items) == 10 + + res = key_cli.playlistItems.list( + playlist_item_id="UExPVTJYTFl4bXNJS3BhVjhoMEFHRTA1c28wZkF3d2ZUdy41NkI0NEY2RDEwNTU3Q0M2", + parts=["id", "snippet"], + ) + assert ( + res.items[0].id + == "UExPVTJYTFl4bXNJS3BhVjhoMEFHRTA1c28wZkF3d2ZUdy41NkI0NEY2RDEwNTU3Q0M2" + ) + + def test_insert(self, helpers, authed_cli): + with responses.RequestsMock() as m: + m.add( + method="POST", + url=self.url, + json=self.load_json("playlist_items/insert_response.json", helpers), + ) + + item = authed_cli.playlistItems.insert( + body=mds.PlaylistItem( + snippet=mds.PlaylistItemSnippet( + playlistId="PLBaidt0ilCManGDIKr8UVBFZwN_UvMKvS", + position=0, + resourceId=mds.ResourceId( + kind="youtube#video", videoId="2sjqTHE0zok" + ), + ) + ), + parts=["id", "snippet"], + ) + assert item.snippet.playlistId == "PLBaidt0ilCManGDIKr8UVBFZwN_UvMKvS" + + def test_update(self, helpers, authed_cli): + with responses.RequestsMock() as m: + m.add( + method="PUT", + url=self.url, + json=self.load_json("playlist_items/insert_response.json", helpers), + ) + + item = authed_cli.playlistItems.update( + body=mds.PlaylistItem( + snippet=mds.PlaylistItemSnippet( + playlistId="PLBaidt0ilCManGDIKr8UVBFZwN_UvMKvS", + position=1, + resourceId=mds.ResourceId( + kind="youtube#video", videoId="2sjqTHE0zok" + ), + ) + ), + parts=["id", "snippet"], + ) + assert item.snippet.playlistId == "PLBaidt0ilCManGDIKr8UVBFZwN_UvMKvS" + + def test_delete(self, helpers, authed_cli): + with responses.RequestsMock() as m: + m.add(method="DELETE", url=self.url, status=204) + assert authed_cli.playlistItems.delete( + playlist_item_id="PLBaidt0ilCManGDIKr8UVBFZwN_UvMKvSxxxxx" + ) + + with pytest.raises(PyYouTubeException): + with responses.RequestsMock() as m: + m.add( + method="DELETE", + url=self.url, + json=self.load_json("error_permission_resp.json", helpers), + status=403, + ) + authed_cli.playlistItems.delete(playlist_item_id="xxxxxx") From 9e96963284f6a83962d87d63de90181daad71728 Mon Sep 17 00:00:00 2001 From: ikaros Date: Tue, 15 Nov 2022 21:48:33 +0800 Subject: [PATCH 044/141] docs(channels): :memo: update docs for channel --- pyyoutube/resources/channels.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pyyoutube/resources/channels.py b/pyyoutube/resources/channels.py index 35766499..b8b83a70 100644 --- a/pyyoutube/resources/channels.py +++ b/pyyoutube/resources/channels.py @@ -34,6 +34,8 @@ def list( Args: parts: Comma-separated list of one or more channel resource properties. + Accepted values: id,auditDetails,brandingSettings,contentDetails,contentOwnerDetails, + localizations,snippet,statistics,status,topicDetails for_username: The parameter specifies a YouTube username, thereby requesting the channel associated with that username. From eebca199d7f023d28fcf2ba70bfad11da9a75846 Mon Sep 17 00:00:00 2001 From: ikaroskun Date: Wed, 16 Nov 2022 11:28:53 +0800 Subject: [PATCH 045/141] feat(playlist): :sparkles: add api for playlist resource --- pyyoutube/models/__init__.py | 8 +- pyyoutube/resources/playlists.py | 250 +++++++++++++++++++++++++++++++ pyyoutube/utils/constants.py | 31 +--- 3 files changed, 257 insertions(+), 32 deletions(-) create mode 100644 pyyoutube/resources/playlists.py diff --git a/pyyoutube/models/__init__.py b/pyyoutube/models/__init__.py index 680a2d3f..b3172187 100644 --- a/pyyoutube/models/__init__.py +++ b/pyyoutube/models/__init__.py @@ -14,13 +14,7 @@ from .member import * # noqa from .memberships_level import * # noqa from .playlist_item import * # noqa -from .playlist import ( - Playlist, - PlaylistContentDetails, - PlaylistListResponse, - PlaylistSnippet, - PlaylistStatus, -) +from .playlist import * # noqa from .subscription import ( Subscription, SubscriptionContentDetails, diff --git a/pyyoutube/resources/playlists.py b/pyyoutube/resources/playlists.py new file mode 100644 index 00000000..0ec5c034 --- /dev/null +++ b/pyyoutube/resources/playlists.py @@ -0,0 +1,250 @@ +""" + Playlist resource implementation. +""" + +from typing import Optional, Union + +from pyyoutube.error import PyYouTubeException, ErrorCode, ErrorMessage +from pyyoutube.resources.base_resource import Resource +from pyyoutube.models import Playlist, PlaylistListResponse +from pyyoutube.utils.params_checker import enf_comma_separated, enf_parts + + +class PlaylistsResource(Resource): + """A playlist resource represents a YouTube playlist. + + References: https://developers.google.com/youtube/v3/docs/playlists + """ + + def list( + self, + parts: Optional[Union[str, list, tuple, set]] = None, + channel_id: Optional[str] = None, + playlist_id: Optional[Union[str, list, tuple, set]] = None, + mine: Optional[bool] = None, + hl: Optional[str] = None, + max_results: Optional[int] = None, + on_behalf_of_content_owner: Optional[str] = None, + on_behalf_of_content_owner_channel: Optional[str] = None, + page_token: Optional[str] = None, + return_json: bool = False, + **kwargs: Optional[dict], + ) -> Union[dict, PlaylistListResponse]: + """Returns a collection of playlists that match the API request parameters. + + Args: + parts: + Comma-separated list of one or more channel resource properties. + Accepted values: id,contentDetails,localizations,player,snippet,status + channel_id: + Indicates that the API should only return the specified channel's playlists. + playlist_id: + Specifies a comma-separated list of the YouTube playlist ID(s) for the resource(s) + that are being retrieved. + mine: + Set this parameter's value to true to instruct the API to only return playlists + owned by the authenticated user. + hl: + The hl parameter instructs the API to retrieve localized resource metadata for + a specific application language that the YouTube website supports. + The parameter value must be a language code included in the list returned by the + i18nLanguages.list method. + max_results: + The parameter specifies the maximum number of items that should be returned + the result set. + Acceptable values are 0 to 50, inclusive. The default value is 5. + on_behalf_of_content_owner: + The onBehalfOfContentOwner parameter indicates that the request's authorization + credentials identify a YouTube CMS user who is acting on behalf of the content + owner specified in the parameter value. This parameter is intended for YouTube + content partners that own and manage many difference YouTube channels. It allows + content owners to authenticate once and get access to all their video and channel + data, without having to provide authentication credentials for each individual channel. + The CMS account that the user authenticates with must be linked to the specified YouTube content owner. + on_behalf_of_content_owner_channel: + The onBehalfOfContentOwnerChannel parameter specifies the YouTube channel ID of the channel + to which a video is being added. This parameter is required when a request specifies a value + for the onBehalfOfContentOwner parameter, and it can only be used in conjunction with that + parameter. In addition, the request must be authorized using a CMS account that is linked to + the content owner that the onBehalfOfContentOwner parameter specifies. Finally, the channel + that the onBehalfOfContentOwnerChannel parameter value specifies must be linked to the content + owner that the onBehalfOfContentOwner parameter specifies. + page_token: + The parameter identifies a specific page in the result set that should be returned. + return_json: + Type for returned data. If you set True JSON data will be returned. + **kwargs: + Additional parameters for system parameters. + Refer: https://cloud.google.com/apis/docs/system-parameters. + + Returns: + Playlist data. + Raises: + PyYouTubeException: Missing filter parameter. + """ + + params = { + "part": enf_parts(resource="playlists", value=parts), + "hl": hl, + "maxResults": max_results, + "onBehalfOfContentOwner": on_behalf_of_content_owner, + "onBehalfOfContentOwnerChannel": on_behalf_of_content_owner_channel, + "pageToken": page_token, + **kwargs, + } + if channel_id is not None: + params["channelId"] = channel_id + elif playlist_id is not None: + params["id"] = enf_comma_separated(field="playlist_id", value=playlist_id) + elif mine is not None: + params["mine"] = mine + else: + raise PyYouTubeException( + ErrorMessage( + status_code=ErrorCode.MISSING_PARAMS, + message=f"Specify at least one of channel_id, playlist_id or mine", + ) + ) + response = self._client.request(path="playlists", params=params) + data = self._client.parse_response(response=response) + return data if return_json else PlaylistListResponse.from_dict(data) + + def insert( + self, + body: Union[dict, Playlist], + parts: Optional[Union[str, list, tuple, set]] = None, + on_behalf_of_content_owner: Optional[str] = None, + on_behalf_of_content_owner_channel: Optional[str] = None, + return_json: bool = False, + **kwargs: Optional[dict], + ) -> Union[dict, Playlist]: + """Creates a playlist. + + Args: + body: + Provide playlist data in the request body. You can give dataclass or just a dict with data. + parts: + The part parameter serves two purposes in this operation. It identifies the properties + that the write operation will set as well as the properties that the API response will include. + on_behalf_of_content_owner: + The onBehalfOfContentOwner parameter indicates that the request's authorization + credentials identify a YouTube CMS user who is acting on behalf of the content + owner specified in the parameter value. This parameter is intended for YouTube + content partners that own and manage many difference YouTube channels. It allows + content owners to authenticate once and get access to all their video and channel + data, without having to provide authentication credentials for each individual channel. + The CMS account that the user authenticates with must be linked to the specified YouTube content owner. + on_behalf_of_content_owner_channel: + The onBehalfOfContentOwnerChannel parameter specifies the YouTube channel ID of the channel + to which a video is being added. This parameter is required when a request specifies a value + for the onBehalfOfContentOwner parameter, and it can only be used in conjunction with that + parameter. In addition, the request must be authorized using a CMS account that is linked to + the content owner that the onBehalfOfContentOwner parameter specifies. Finally, the channel + that the onBehalfOfContentOwnerChannel parameter value specifies must be linked to the content + owner that the onBehalfOfContentOwner parameter specifies. + return_json: + Type for returned data. If you set True JSON data will be returned. + **kwargs: + Additional parameters for system parameters. + Refer: https://cloud.google.com/apis/docs/system-parameters. + Returns: + playlist data. + """ + + params = { + "part": enf_parts(resource="playlists", value=parts), + "onBehalfOfContentOwner": on_behalf_of_content_owner, + "onBehalfOfContentOwnerChannel": on_behalf_of_content_owner_channel, + **kwargs, + } + response = self._client.request( + method="POST", path="playlists", params=params, json=body + ) + data = self._client.parse_response(response=response) + return data if return_json else Playlist.from_dict(data) + + def update( + self, + body: Union[dict, Playlist], + parts: Optional[Union[str, list, tuple, set]] = None, + on_behalf_of_content_owner: Optional[str] = None, + return_json: bool = False, + **kwargs: Optional[dict], + ) -> Union[dict, Playlist]: + """Modifies a playlist. + + Args: + body: + Provide playlist data in the request body. You can give dataclass or just a dict with data. + parts: + The part parameter serves two purposes in this operation. It identifies the properties + that the write operation will set as well as the properties that the API response will include. + on_behalf_of_content_owner: + The onBehalfOfContentOwner parameter indicates that the request's authorization + credentials identify a YouTube CMS user who is acting on behalf of the content + owner specified in the parameter value. This parameter is intended for YouTube + content partners that own and manage many difference YouTube channels. It allows + content owners to authenticate once and get access to all their video and channel + data, without having to provide authentication credentials for each individual channel. + The CMS account that the user authenticates with must be linked to the specified YouTube content owner. + return_json: + Type for returned data. If you set True JSON data will be returned. + **kwargs: + Additional parameters for system parameters. + Refer: https://cloud.google.com/apis/docs/system-parameters. + + Returns: + Playlist updated data. + + """ + params = { + "part": enf_parts(resource="playlists", value=parts), + "onBehalfOfContentOwner": on_behalf_of_content_owner, + **kwargs, + } + response = self._client.request( + method="PUT", path="playlists", params=params, json=body + ) + data = self._client.parse_response(response=response) + return data if return_json else Playlist.from_dict(data) + + def delete( + self, + playlist_id: str, + on_behalf_of_content_owner: Optional[str] = None, + **kwargs: Optional[dict], + ) -> bool: + """Deletes a playlist. + + Args: + playlist_id: + Specifies the YouTube playlist ID for the playlist that is being deleted. + on_behalf_of_content_owner: + The onBehalfOfContentOwner parameter indicates that the request's authorization + credentials identify a YouTube CMS user who is acting on behalf of the content + owner specified in the parameter value. This parameter is intended for YouTube + content partners that own and manage many difference YouTube channels. It allows + content owners to authenticate once and get access to all their video and channel + data, without having to provide authentication credentials for each individual channel. + The CMS account that the user authenticates with must be linked to the specified YouTube content owner. + **kwargs: + Additional parameters for system parameters. + Refer: https://cloud.google.com/apis/docs/system-parameters. + + Returns: + playlist delete status + + """ + params = { + "id": playlist_id, + "onBehalfOfContentOwner": on_behalf_of_content_owner, + **kwargs, + } + response = self._client.request( + method="DELETE", + path="playlists", + params=params, + ) + if response.ok: + return True + self._client.parse_response(response=response) diff --git a/pyyoutube/utils/constants.py b/pyyoutube/utils/constants.py index e861d90f..dd7a3ba1 100644 --- a/pyyoutube/utils/constants.py +++ b/pyyoutube/utils/constants.py @@ -2,16 +2,9 @@ some constants for YouTube """ -ACTIVITIES_RESOURCE_PROPERTIES = { - "id", - "snippet", - "contentDetails", -} +ACTIVITIES_RESOURCE_PROPERTIES = {"id", "snippet", "contentDetails"} -CAPTIONS_RESOURCE_PROPERTIES = { - "id", - "snippet", -} +CAPTIONS_RESOURCE_PROPERTIES = {"id", "snippet"} CHANNEL_RESOURCE_PROPERTIES = { "id", @@ -24,11 +17,7 @@ "topicDetails", } -CHANNEL_SECTIONS_PROPERTIES = { - "id", - "contentDetails", - "snippet", -} +CHANNEL_SECTIONS_PROPERTIES = {"id", "contentDetails", "snippet"} COMMENT_RESOURCE_PROPERTIES = {"id", "snippet"} @@ -38,14 +27,9 @@ I18N_REGION_PROPERTIES = {"snippet"} -MEMBER_PROPERTIES = { - "snippet", -} +MEMBER_PROPERTIES = {"snippet"} -MEMBERSHIP_LEVEL_PROPERTIES = { - "id", - "snippet", -} +MEMBERSHIP_LEVEL_PROPERTIES = {"id", "snippet"} PLAYLIST_ITEM_RESOURCE_PROPERTIES = {"id", "contentDetails", "snippet", "status"} @@ -67,10 +51,7 @@ "subscriberSnippet", } -VIDEO_ABUSE_REPORT_REASON_PROPERTIES = { - "id", - "snippet", -} +VIDEO_ABUSE_REPORT_REASON_PROPERTIES = {"id", "snippet"} VIDEO_CATEGORY_RESOURCE_PROPERTIES = {"id", "snippet"} From 5a7b5b75707388dcac88161c3fd51391dc1dddc2 Mon Sep 17 00:00:00 2001 From: ikaroskun Date: Wed, 16 Nov 2022 12:19:00 +0800 Subject: [PATCH 046/141] test(playlists): :white_check_mark: update tests for playlists resource --- pyyoutube/client.py | 1 + pyyoutube/resources/__init__.py | 1 + .../apidata/playlists/insert_response.json | 33 +++++++ tests/clients/test_playlists.py | 98 +++++++++++++++++++ 4 files changed, 133 insertions(+) create mode 100644 testdata/apidata/playlists/insert_response.json create mode 100644 tests/clients/test_playlists.py diff --git a/pyyoutube/client.py b/pyyoutube/client.py index 4cecff4f..85ee17dc 100644 --- a/pyyoutube/client.py +++ b/pyyoutube/client.py @@ -48,6 +48,7 @@ class Client: members = resources.MembersResource() membershipsLevels = resources.MembershipLevelsResource() playlistItems = resources.PlaylistItemsResource() + playlists = resources.PlaylistsResource() def __new__(cls, *args, **kwargs): self = super().__new__(cls) diff --git a/pyyoutube/resources/__init__.py b/pyyoutube/resources/__init__.py index 6123ae81..aeeb6943 100644 --- a/pyyoutube/resources/__init__.py +++ b/pyyoutube/resources/__init__.py @@ -8,3 +8,4 @@ from .members import MembersResource # noqa from .membership_levels import MembershipLevelsResource # noqa from .playlist_items import PlaylistItemsResource # noqa +from .playlists import PlaylistsResource # noqa diff --git a/testdata/apidata/playlists/insert_response.json b/testdata/apidata/playlists/insert_response.json new file mode 100644 index 00000000..707013e5 --- /dev/null +++ b/testdata/apidata/playlists/insert_response.json @@ -0,0 +1,33 @@ +{ + "kind": "youtube#playlist", + "etag": "Gw0SW_V3Hy1XNqjAJB1v1Q0ZmB4", + "id": "PLBaidt0ilCMZN8XPVB5iXY6FlSYGeyn2n", + "snippet": { + "publishedAt": "2022-11-16T04:12:59Z", + "channelId": "UCa-vrCLQHviTOVnEKDOdetQ", + "title": "Test playlist", + "description": "", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/img/no_thumbnail.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/img/no_thumbnail.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/img/no_thumbnail.jpg", + "width": 480, + "height": 360 + } + }, + "channelTitle": "ikaros data", + "localized": { + "title": "Test playlist", + "description": "" + } + } +} diff --git a/tests/clients/test_playlists.py b/tests/clients/test_playlists.py new file mode 100644 index 00000000..c0fe2bde --- /dev/null +++ b/tests/clients/test_playlists.py @@ -0,0 +1,98 @@ +import pytest +import responses + +import pyyoutube.models as mds +from .base import BaseTestCase +from pyyoutube.error import PyYouTubeException + + +class TestPlaylistsResource(BaseTestCase): + RESOURCE = "playlists" + + def test_list(self, helpers, authed_cli, key_cli): + with pytest.raises(PyYouTubeException): + authed_cli.playlists.list() + + with responses.RequestsMock() as m: + m.add( + method="GET", + url=self.url, + json=self.load_json("playlists/playlists_paged_1.json", helpers), + ) + + res = key_cli.playlists.list( + parts=["id", "snippet"], + channel_id="UC_x5XG1OV2P6uZZ5FSM9Ttw", + max_results=10, + ) + assert len(res.items) == 10 + + res = key_cli.playlists.list( + parts=["id", "snippet"], + playlist_id=[ + "PLOU2XLYxmsIKpaV8h0AGE05so0fAwwfTw", + "PLOU2XLYxmsIJO83u2UmyC8ud41AvUnhgj", + ], + ) + assert res.items[0].id == "PLOU2XLYxmsIKpaV8h0AGE05so0fAwwfTw" + + res = authed_cli.playlists.list( + parts=["id", "snippet"], mine=True, max_results=10 + ) + assert len(res.items) == 10 + + def test_insert(self, helpers, authed_cli): + with responses.RequestsMock() as m: + m.add( + method="POST", + url=self.url, + json=self.load_json("playlists/insert_response.json", helpers), + ) + + playlist = authed_cli.playlists.insert( + body=mds.Playlist( + snippet=mds.PlaylistSnippet( + title="Test playlist", + ) + ), + ) + + assert playlist.id == "PLBaidt0ilCMZN8XPVB5iXY6FlSYGeyn2n" + + def test_update(self, helpers, authed_cli): + with responses.RequestsMock() as m: + m.add( + method="PUT", + url=self.url, + json=self.load_json("playlists/insert_response.json", helpers), + ) + + playlist = authed_cli.playlists.update( + body=mds.Playlist( + snippet=mds.PlaylistSnippet( + title="Test playlist", + defaultLanguage="", + ) + ) + ) + assert playlist.snippet.description == "" + + def test_delete(self, helpers, authed_cli): + with responses.RequestsMock() as m: + m.add(method="DELETE", url=self.url, status=204) + + assert authed_cli.playlists.delete( + playlist_id="PLBaidt0ilCMZN8XPVB5iXY6FlSYGeyn2n" + ) + + with pytest.raises(PyYouTubeException): + with responses.RequestsMock() as m: + m.add( + method="DELETE", + url=self.url, + json=self.load_json("error_permission_resp.json", helpers), + status=403, + ) + authed_cli.playlists.delete( + playlist_id="PLBaidt0ilCMZN8XPVB5iXY6FlSYGeyn2n" + ) From 5e720d97852fbd8c6fe1be0228e7c73d62af38ce Mon Sep 17 00:00:00 2001 From: ikaroskun Date: Wed, 16 Nov 2022 15:39:35 +0800 Subject: [PATCH 047/141] feat(search): :sparkles: add api for search --- pyyoutube/models/__init__.py | 7 +- pyyoutube/resources/search.py | 248 ++++++++++++++++++++++++++++++++++ pyyoutube/utils/constants.py | 2 +- tests/apis/test_search.py | 2 +- 4 files changed, 251 insertions(+), 8 deletions(-) create mode 100644 pyyoutube/resources/search.py diff --git a/pyyoutube/models/__init__.py b/pyyoutube/models/__init__.py index b3172187..24750535 100644 --- a/pyyoutube/models/__init__.py +++ b/pyyoutube/models/__init__.py @@ -15,6 +15,7 @@ from .memberships_level import * # noqa from .playlist_item import * # noqa from .playlist import * # noqa +from .search_result import * # noqa from .subscription import ( Subscription, SubscriptionContentDetails, @@ -36,9 +37,3 @@ VideoAbuseReportReason, VideoAbuseReportReasonListResponse, ) -from .search_result import ( - SearchResultId, - SearchResultSnippet, - SearchResult, - SearchListResponse, -) diff --git a/pyyoutube/resources/search.py b/pyyoutube/resources/search.py new file mode 100644 index 00000000..dfc13dae --- /dev/null +++ b/pyyoutube/resources/search.py @@ -0,0 +1,248 @@ +""" + Search resource implementation. +""" +from typing import Optional, Union + +from pyyoutube.resources.base_resource import Resource +from pyyoutube.models import SearchListResponse +from pyyoutube.utils.params_checker import enf_parts + + +class SearchResource(Resource): + """A search result contains information about a YouTube video, channel, or playlist + that matches the search parameters specified in an API request + + References: https://developers.google.com/youtube/v3/docs/search + """ + + def list( + self, + parts: Optional[Union[str, list, tuple, set]] = None, + for_content_owner: Optional[bool] = None, + for_developer: Optional[bool] = None, + for_mine: Optional[bool] = None, + related_to_video_id: Optional[str] = None, + channel_id: Optional[str] = None, + channel_type: Optional[str] = None, + event_type: Optional[str] = None, + location: Optional[str] = None, + location_radius: Optional[str] = None, + max_results: Optional[int] = None, + on_behalf_of_content_owner: Optional[str] = None, + order: Optional[str] = None, + page_token: Optional[str] = None, + published_after: Optional[str] = None, + published_before: Optional[str] = None, + q: Optional[str] = None, + region_code: Optional[str] = None, + relevance_language: Optional[str] = None, + safe_search: Optional[str] = None, + topic_id: Optional[str] = None, + type: Optional[Union[str, list, tuple, set]] = None, + video_caption: Optional[str] = None, + video_category_id: Optional[str] = None, + video_definition: Optional[str] = None, + video_dimension: Optional[str] = None, + video_duration: Optional[str] = None, + video_embeddable: Optional[str] = None, + video_license: Optional[str] = None, + video_syndicated: Optional[str] = None, + video_type: Optional[str] = None, + return_json: bool = False, + **kwargs: Optional[dict], + ) -> Union[dict, SearchListResponse]: + """Returns a collection of search results that match the query parameters specified in the API request. + + Notes: + Search API is very complex. If you want to search, You may need to read the parameter description + with the docs: https://developers.google.com/youtube/v3/docs/search/list#parameters + + Args: + parts: + Comma-separated list of one or more channel resource properties. + Accepted values: snippet + for_content_owner: + Parameter restricts the search to only retrieve videos owned by the content owner + identified by the onBehalfOfContentOwner parameter. + for_developer: + Parameter restricts the search to only retrieve videos uploaded via the developer's + application or website. + for_mine: + Parameter restricts the search to only retrieve videos owned by the authenticated user. + related_to_video_id: + Parameter retrieves a list of videos that are related to the video that the parameter value identifies. + channel_id: + Indicates that the API response should only contain resources created by the channel. + channel_type: + Parameter lets you restrict a search to a particular type of channel. + Acceptable values are: + - any: Return all channels. + - show: Only retrieve shows. + event_type: + Parameter restricts a search to broadcast events. + Acceptable values are: + - completed: Only include completed broadcasts. + - live: Only include active broadcasts. + - upcoming: Only include upcoming broadcasts. + location: + Parameter value identifies the point at the center of the area. + location_radius: + Specifies the maximum distance that the location associated with a video can be from + that point for the video to still be included in the search results. + max_results: + The parameter specifies the maximum number of items that should be returned + the result set. + Acceptable values are 0 to 50, inclusive. The default value is 5. + on_behalf_of_content_owner: + The onBehalfOfContentOwner parameter indicates that the request's authorization + credentials identify a YouTube CMS user who is acting on behalf of the content + owner specified in the parameter value. This parameter is intended for YouTube + content partners that own and manage many difference YouTube channels. It allows + content owners to authenticate once and get access to all their video and channel + data, without having to provide authentication credentials for each individual channel. + The CMS account that the user authenticates with must be linked to the specified YouTube content owner. + order: + Specifies the method that will be used to order resources in the API response. + The default value is relevance. + Acceptable values are: + - date: Resources are sorted in reverse chronological order based on the date they were created. + - rating: Resources are sorted from highest to lowest rating. + - relevance: Resources are sorted based on their relevance to the search query. + - title: Resources are sorted alphabetically by title. + - videoCount: Channels are sorted in descending order of their number of uploaded videos. + - viewCount: Resources are sorted from highest to lowest number of views. + For live broadcasts, videos are sorted by number of concurrent viewers while the broadcasts + are ongoing. + page_token: + The parameter identifies a specific page in the result set that should be returned. + published_after: + Indicates that the API response should only contain resources created at or after the specified time. + published_before: + Indicates that the API response should only contain resources created before or at the specified time. + q: + Specifies the query term to search for. + region_code: + Instructs the API to return search results for videos that can be viewed in the specified country. + relevance_language: + Instructs the API to return search results that are most relevant to the specified language. + safe_search: + Indicates whether the search results should include restricted content as well as standard content. + Acceptable values are: + - moderate: YouTube will filter some content from search results and, at the least, + will filter content that is restricted in your locale. Based on their content, search + results could be removed from search results or demoted in search results. + This is the default parameter value. + - none: YouTube will not filter the search result set. + - strict: YouTube will try to exclude all restricted content from the search result set. + Based on their content, search results could be removed from search results or + demoted in search results. + topic_id: + Indicates that the API response should only contain resources associated with the specified topic. + type: + Parameter restricts a search query to only retrieve a particular type of resource. + The value is a comma-separated list of resource types. + Acceptable values are: channel,playlist,video + video_caption: + Indicates whether the API should filter video search results based on whether they have captions. + Acceptable values are: + - any: Do not filter results based on caption availability. + - closedCaption: Only include videos that have captions. + - none: Only include videos that do not have captions. + video_category_id: + Parameter filters video search results based on their category. + video_definition: + Parameter lets you restrict a search to only include either high definition (HD) or + standard definition (SD) videos. + Acceptable values are: + - any: Return all videos, regardless of their resolution. + - high: Only retrieve HD videos. + - standard: Only retrieve videos in standard definition. + video_dimension: + Parameter lets you restrict a search to only retrieve 2D or 3D videos. + Acceptable values are: + - 2d: Restrict search results to exclude 3D videos. + - 3d: Restrict search results to only include 3D videos. + - any: Include both 3D and non-3D videos in returned results. This is the default value. + video_duration: + Parameter filters video search results based on their duration. + Acceptable values are: + - any: Do not filter video search results based on their duration. This is the default value. + - long: Only include videos longer than 20 minutes. + - medium: Only include videos that are between four and 20 minutes long (inclusive). + - short: Only include videos that are less than four minutes long. + video_embeddable: + Parameter lets you to restrict a search to only videos that can be embedded into a webpage. + Acceptable values are: + - any: Return all videos, embeddable or not. + - true: Only retrieve embeddable videos. + video_license: + Parameter filters search results to only include videos with a particular license. + Acceptable values are: + - any – Return all videos, regardless of which license they have, that match the query parameters. + - creativeCommon – Only return videos that have a Creative Commons license. + Users can reuse videos with this license in other videos that they create. Learn more. + - youtube – Only return videos that have the standard YouTube license. + video_syndicated: + Parameter lets you to restrict a search to only videos that can be played outside youtube.com. + Acceptable values are: + - any: Return all videos, syndicated or not. + - true: Only retrieve syndicated videos. + video_type: + Parameter lets you restrict a search to a particular type of videos. + Acceptable values are: + - any: Return all videos. + - episode: Only retrieve episodes of shows. + - movie: Only retrieve movies. + return_json: + Type for returned data. If you set True JSON data will be returned. + **kwargs: + Additional parameters for system parameters. + Refer: https://cloud.google.com/apis/docs/system-parameters. + Returns: + Search result data + + """ + + params = { + "part": enf_parts(resource="search", value=parts), + "channelId": channel_id, + "channelType": channel_type, + "eventType": event_type, + "location": location, + "locationRadius": location_radius, + "maxResults": max_results, + "onBehalfOfContentOwner": on_behalf_of_content_owner, + "order": order, + "pageToken": page_token, + "publishedAfter": published_after, + "publishedBefore": published_before, + "q": q, + "regionCode": region_code, + "relevanceLanguage": relevance_language, + "safeSearch": safe_search, + "topicId": topic_id, + "type": type, + "videoCaption": video_caption, + "videoCategoryId": video_category_id, + "videoDefinition": video_definition, + "videoDimension": video_dimension, + "videoDuration": video_duration, + "videoEmbeddable": video_embeddable, + "videoLicense": video_license, + "videoSyndicated": video_syndicated, + "videoType": video_type, + **kwargs, + } + + if for_content_owner is not None: + params["forContentOwner"] = for_content_owner + elif for_developer is not None: + params["forDeveloper"] = for_developer + elif for_mine is not None: + params["forMine"] = for_mine + elif related_to_video_id is not None: + params["relatedToVideoId"] = related_to_video_id + + response = self._client.request(path="search", params=params) + data = self._client.parse_response(response=response) + return data if return_json else SearchListResponse.from_dict(data) diff --git a/pyyoutube/utils/constants.py b/pyyoutube/utils/constants.py index dd7a3ba1..7dabb603 100644 --- a/pyyoutube/utils/constants.py +++ b/pyyoutube/utils/constants.py @@ -42,7 +42,7 @@ "status", } -SEARCH_RESOURCE_PROPERTIES = {"id", "snippet"} +SEARCH_RESOURCE_PROPERTIES = {"snippet"} SUBSCRIPTION_RESOURCE_PROPERTIES = { "id", diff --git a/tests/apis/test_search.py b/tests/apis/test_search.py index c822b0c5..51a2a0d5 100644 --- a/tests/apis/test_search.py +++ b/tests/apis/test_search.py @@ -112,7 +112,7 @@ def testSearchByKeywords(self) -> None: res = self.api.search_by_keywords( q="surfing", - parts=["id", "snippet"], + parts=["snippet"], count=25, ) self.assertEqual(res.pageInfo.resultsPerPage, 25) From fd54c4ce9f03c93cb2f7f49658f5948b2fc3881c Mon Sep 17 00:00:00 2001 From: ikaroskun Date: Wed, 16 Nov 2022 15:52:06 +0800 Subject: [PATCH 048/141] test(search): :white_check_mark: update tests for search resource --- pyyoutube/client.py | 1 + pyyoutube/resources/__init__.py | 1 + tests/clients/test_search.py | 64 +++++++++++++++++++++++++++++++++ 3 files changed, 66 insertions(+) create mode 100644 tests/clients/test_search.py diff --git a/pyyoutube/client.py b/pyyoutube/client.py index 85ee17dc..1cf4f27c 100644 --- a/pyyoutube/client.py +++ b/pyyoutube/client.py @@ -49,6 +49,7 @@ class Client: membershipsLevels = resources.MembershipLevelsResource() playlistItems = resources.PlaylistItemsResource() playlists = resources.PlaylistsResource() + search = resources.SearchResource() def __new__(cls, *args, **kwargs): self = super().__new__(cls) diff --git a/pyyoutube/resources/__init__.py b/pyyoutube/resources/__init__.py index aeeb6943..517b262e 100644 --- a/pyyoutube/resources/__init__.py +++ b/pyyoutube/resources/__init__.py @@ -9,3 +9,4 @@ from .membership_levels import MembershipLevelsResource # noqa from .playlist_items import PlaylistItemsResource # noqa from .playlists import PlaylistsResource # noqa +from .search import SearchResource # noqa diff --git a/tests/clients/test_search.py b/tests/clients/test_search.py new file mode 100644 index 00000000..b909d9c8 --- /dev/null +++ b/tests/clients/test_search.py @@ -0,0 +1,64 @@ +import responses + +from .base import BaseTestCase + + +class TestSearchResource(BaseTestCase): + RESOURCE = "search" + + def test_list(self, helpers, authed_cli, key_cli): + with responses.RequestsMock() as m: + m.add( + method="GET", + url=self.url, + json=self.load_json("search/search_by_developer.json", helpers), + ) + + res = authed_cli.search.list( + parts=["snippet"], + for_content_owner=True, + ) + assert res.items[0].id.videoId == "WuyFniRMrxY" + + res = authed_cli.search.list( + for_developer=True, + max_results=5, + ) + assert len(res.items) == 5 + + with responses.RequestsMock() as m: + m.add( + method="GET", + url=self.url, + json=self.load_json("search/search_by_mine.json", helpers), + ) + res = authed_cli.search.list(for_mine=True, max_results=5) + assert res.items[0].snippet.channelId == "UCa-vrCLQHviTOVnEKDOdetQ" + + with responses.RequestsMock() as m: + m.add( + method="GET", + url=self.url, + json=self.load_json("search/search_by_related_video.json", helpers), + ) + res = authed_cli.search.list( + related_to_video_id="Ks-_Mh1QhMc", + region_code="US", + relevance_language="en", + safe_search="moderate", + max_results=5, + ) + assert res.items[0].id.videoId == "eIho2S0ZahI" + + with responses.RequestsMock() as m: + m.add( + method="GET", + url=self.url, + json=self.load_json("search/search_by_keywords_p1.json", helpers), + ) + res = key_cli.search.list( + q="surfing", + parts=["snippet"], + count=25, + ) + assert len(res.items) == 25 From 7ed64ad54fe4d9c68c2e06065fd02fb665b4fe79 Mon Sep 17 00:00:00 2001 From: ikaroskun Date: Wed, 16 Nov 2022 17:18:15 +0800 Subject: [PATCH 049/141] docs(client): :memo: introduce new stucture for client --- README.rst | 4 +++ docs/docs/images/structure-uml.png | Bin 0 -> 19712 bytes docs/docs/introduce-new-structure.md | 36 +++++++++++++++++++++++++++ docs/mkdocs.yml | 1 + pyyoutube/resources/channels.py | 10 ++++---- tests/clients/test_channels.py | 4 +-- 6 files changed, 48 insertions(+), 7 deletions(-) create mode 100644 docs/docs/images/structure-uml.png create mode 100644 docs/docs/introduce-new-structure.md diff --git a/README.rst b/README.rst index e6a57d82..c1bdc8b0 100644 --- a/README.rst +++ b/README.rst @@ -29,6 +29,10 @@ Introduction Library provides an easy way to use YouTube Data API V3. +.. tip:: + + Recently, we are working on the new structure for the library. `Read docs `_ to get more detail. + ============= Documentation ============= diff --git a/docs/docs/images/structure-uml.png b/docs/docs/images/structure-uml.png new file mode 100644 index 0000000000000000000000000000000000000000..a48152c24d28996113050ccde25318eccaee8934 GIT binary patch literal 19712 zcmd?QcT|&IurC}sN)r(fDNz(bLAo>{fOJ86??@8?Y0`P90@9^-LhnV8UV?!1-lca4 zp+iCm?Thbw&iT&z)?MGa_x^X+O%@w6vuEbFXMTIn^CTfeSy6`M?xVXP5Qs!hR#FuN z!bO8X*lZ<>uz@>FI%pigt8#NJvQJ=H}ksUJD5c4GavJ znws)q)ZBbiQ&WY7h2Ot_cXf3|-`*M;8MU;)j*pN1{r#1dmF48*N=iyjPfzvq^lop@ zmzI{ke*IcoTf4ft%Kcj5_G-n+$?5m+-*ItqiHV7Jc6PF_xoc`_gfWhJB~?{bH8eCL zA|klCg|)P_s;a8qzJ2TA;gMm?e|x>n!>{>TIsEqer0?SP7z0)C^X<+4>Fw>t^{rCa zpdlak&gCuNhb(h`?%UIri_QRE*+6>%-X?dkz;1MaEN^3DQ@({jf64XN*6M7EL8&!AkGRJzdd8;gumFB@*5rOU zmwb4AyH{p?yy=TR9Tus^x87eB6d2;DEL^#M72b20 zTh=P1=DCHsfvsGGSSi$v?*>&ZoUIQuRF%aQ{M_wLOLkB$XdcPP%t7`?G)7srA+8JB zSAnt_?~Ka++PvaqFL{f=g39Wt_2bOJV_n}Lh&uPwFAfDW=Zob|d7-vaD!R3PnxaCl z!}B_1EMqN{M50<(gA($4QyohHje)6+>Wb3(u6gIoZcSwg&pLQ;V`qDL?8oFj)NGOJ z=cok0WW3yazBy%XZuYB_nSrTClD}gCG*L@aJv$*xLrl?DU+QaIf~Rlp`ucj!OdB-1 zz}_?{CML!SqVE9$$u-GIimSWL?zZ`1_6Nu}^SX$-2>b7!|9cPP(`B)LEPHNi$kj3Y z{@;8AiZsbsJtkb^yt$#AA*<^3@9&~~a^GLu6IY%#WO7mR@WE7-?dKm_Jy^Ic-Jeq5 zO4*ch$1*0r&se&HznNa(M({s|e`8gJ`?;zQv>RaI&MpvE;7Wuu;u>4I%v=D7EnUZR z1f`12QES-Wn{P33b(C{&m#u$UE-x2F6+_8Nq{VD|pT=0TQ?;M-oaNni7dH0;;R!GG zmF^;57GlUy?wW4~*+vd<$e*YhkSIO&`|YUQqiUH2ys7?8Q#l?S>9L7r^Cv|=l?QSK zcm#?wa$`uyY@Ti4b42GFf1rpTOKj7j8ry3l*q-}!lV9;?!Nd?UHtf2SS{N4;6d;54 z$biM?cGYfPl$BQ)?oD@;u^*JVs#;kIxGn82nA=d8Jp6TZ_5w`m$dvUl6ARxcg)i%# z@q-u2{(25)*&RX5xUQ~b`+R9DL-x#VOdstD#oJ{v&bCpR3rslyY#XOn6o2|!=)qLp z^Q;64OFoK5CZL{Quh5@JK@V5nhe6EGvpZIma@uILTjg#Z})b$CUtpj!)P!uZ!xTbnf0^-A@kev2P!1HGRDSjHh_iC8MNY-c2J`7}IPC;$ zl)5S|yY}Tsk4C1Wv-~e#E;0snBOSP%#8bQ zLfP$K(`5oRn>>tdnIvThkci?1)ZnkEaXpaYx710{nSjynA-vJ8&*nKw8&SAd_s7?N zQ}rG2EK;br%qJ61#Z&*b!(@6u82O?g=E$Z;-tC>f`(^MHJ3S!#1o?X;_{Tz9Gxq^801F5W=H_(CB@4O zGC7bMnE0Lv7eHr0g{6V0Jttd~zt7@aj&m(>V0rInvQZO9h+bYP zTv5`H1MeL^H>|R`;#38frtjqJt$#~`uqO$^J7VwWreOhJ&OrN@prgyz==lpVs%hWt zer0rFr5!_-pAZT!3j?@ zCPV;4&_~x=0emxGOZGmn?~ED!k%&C8$0RGUPLMkT@=mAKFwsin`EG;L)~QF=md7R~ z_vN8r!@5w}Y5UkTwfi%xj%Eo)QJ4&ER2KtJ0GAYHOjA(XZNP`LV0!%(|{<28U5|H1MBEs!4-Mvq>nXrOg`RsVTizCB2Yh>EBq zTa>U1>7cNrbh`hBuS+0u?fgJ%wNK=k%t-ycF;N6Noj=vIYNb|)M$S~o#5X?ah~N3r zdm{cBf%45&=oOgE1b65{rs`2jcMcgzUS5h zig+QK1P4v>xi1rKKCsEjm-w;0l}P%@B2x)Fq2HjYZ~SY5Ooo$+>r)u`dM-&yL1)Oh|BVl0c9Vv1EYfziOOOoK*{SD&Tp(PNgti!jpC<(vPAdFk`+CGu zU}TE`9Y=eSAyi)#L4& zxm6b_a-}j!fme-ODD;!cBw zO1Se!1!&1Tew>ZzKNOX^Xf`>`m0X2?;Ywz55KQDNkiJ+F%~RT6PM~|v=47i=XWw&D zq4AppMqhBIYsj91$`H1iMUM<^DkSq;d}PE5A8lO1;UoZG`ClXXTclLEyT_Bf(-|I8l!n7#p~~0|fey9B2_^ zw&a=5NH8%T`W_qyz@ouy!2_R64r=c2*ci#BD7=G@pjPl6e4Gd~^B?>5zxJ6NARK@Y z=?4S-^+15e=KNRr>;J3FvdKIcnd%=v`a#qP&?Bt{!-h)RJqlnrk8>uOA7q5u*AIeZ zMe7>9F*Qv_kn_$%9$!wduW+nM_@LIKHdMl2CxU^FFAhR{44WghMUr>rkabc$bUIy`!BB&{LzVrX*K z2~ukDAiYn+GSIiOX)J+3Xg>VHeI)$y#tqf{>XJ<{L``du{vTvCId&UcQed(%@$sQo z{acaBx?`d2jJ?rrE~gFTZBwPl_(uh7at7Bz&(jR6h$H3X^j@Cs2Dir+7`_gP&ThOFAD}vp5j?>^=?Sg#<@9$$5BiUc~n$rQcOoUPN5mWNLUM>T)?j#f1 zOe?#YV6T9#^e!56m}24`C%igCKLgU~Utd}_I#3TMd8v$1^N-abb|xdtUw*js4(4G% zV$Q1oKgjwk+U*_Hr{!nE(=nwd8>*kFY@J5%Ceb6Ys+1ux(ui%%%;%nOtcO`fm~jAn z%*NL{9hE73F)8w)fZs3UeP~&8>S%{{wvs)y610S#)x~X2r^B+xNOb%@*J`RhdzUP_ zku}zKVE131RowD-i;JNDglJC(R?BRScjn9Fc}%TKJ(=#N4kvG-*`wodB&ii4Y` z)*i*5_4&+$`Bh7CICA6vq_7*CC3r{1RbyW!HT>JhNC2VjQA@$9Sf}#R=6>#*;UK4L z>DkRpW-_`oW+GXF~H-iy$QJdS(L+rJRR>DPDN!KcJUIJ|0ke^ejQ zEZwGxl^&X2&Thp(nsY0@N7bDbEQ$7XO)d-@rBWJ_U!Fod$9KHuyd7H%z_k_ovork> z9HN`8t$m-L0&gOaD`n5?O2;kljn$3If@Q(mJomP7wx0s{jKE_ccUUZ!%q|{gqrSbr zR4Rj35J&%twE0Jx8-=9#hV(;fJO<=74DKE`snp?(4q2u zaS%bZ2wQPO1C2yh-0#tdNH zdOCJ=6N?f1cYdf&;)s(MA=LX!w^{Gn&tZQPQ9&u;UZh+1?ni2q(n}e3Ph*?04W^0k zHudBPrW6;eRf&czUa?$%oerGJ0a?2gvh=KcApdc)%dD6fdWQw9jMw5`GAhH~?cmlE zDgauBo{m?mhlzs@N%MNN?XBUZmJm#L7>A{5qt^j_}sEtSn{h<53- zaT-r=HPA0yo~1X4o}Zr)-53l>id~mgd=I;5h) zw>DxN5WeiAZY#aogfNz*J0SIRXBr*)3u_m3@LiC)0Wa{WFRVj~j6SYj>9TJbd61;mj z)nh2X{5F@&mni!G75wKrMtQmB4m-KJYS;5k-?4qiz8pI-MxUS9ZMp1MY%5y}EjVvC z{}pc)O$l*+d;_#RHC-!q_#jva%3OD=g`&Fhi*53i+}2$W?N5BMeeWYjXsuOU#+hZo z$`6WJmA_S>^W$MKlIh730vX2@FSHkhsHNf8IP>#;n5P=+E%&u0+HkAAW8)g-q%@2D zbM93A*PT0f56pf_`zfG>6I&$0yxkvqTIbnW&6YSX}dFj#H?h`jCPz}lXVnlfFU zQT)5`3Hu>wyzda&H2J0W_Qdx~GGBzBZI@i~LHnDJrVV3znSDOhqJi4Yy~B0 zcns`|1yamdE&$o~d}~|_e=Z+&;)TWzaP)$Q;xYk^Ps-Hg)w@e38cz44ES8mOQ;^%2 zjx4c|9X%r&x*uUk6Orw?Y-Mc!?zmXUUu^G}l;dCMTb{j;GqUaO>)bH=Lp@p4vYM#>fp2@KK^#py7R)G)o=Sz) z%Pn!qEg?1%Bw}D)dNGjxC^_pv@@D}4wnX$};FcMHecZScFN4;MhCCn|J4;Nzl&a+R zZ5Ox#qyk1Yv=w+B8Th`rN8E_jWG<%u;FbTa7@I|te;bp+{V;zb`@T}+&jQ{m z&g`MgtryfwVRB28p3{q>aWLC4nY-IfNyt}Z`*hxVeBKPUd3HZVuBsmcyRvBZrRyc1 zX_SaQopxRZjXjnK9wQjDB;F2V1ULhbc)1*+ zxn#M+567sb(8l=NM6yQSm=+1;%H;?}wh@m}e}`Rt$JEDE*66`@08K8(W8mTmrfYV6 zhp~(i@W>j8p^qidV(9QEfJ!cz#dnx5aw8biGHE2x*|Ok)#{kxLKN+Uk@&qBJq9IA* z|6>3ZvU{6d8r#54(U_CnPDtW!*k!NHIdn3}8?D>*GX|HI8dboQqSd?yonOIgQzEqDSrzWA{01# zINrE?Pf;euQ$`xFR0mMug^j6X)jrlHH;5o|Cfe_25+Il4#ksicm#?=>ki(~$e|CT) zAZUKtB2okIM*31q@U#Arb}}@kkf3Q3;5{t+>1$QEOlv=HgZ@1D zehH^4f4I_ryU7BrU}TtI@e9dhidKMbmqc#!0Qnp=<66e(7%m`x7^%^XOoognhHtZS z0=-F)aZ7aZKQraKkd#Rn{s>N>r;k9m~e z!Texh;}WuqOcLwu=1?a2|?-YA8xy6x* za=*g`Ze`qX0F}Bm%Z1cs&wTx}Q!z`Fk~A*R3I;CuCOq!Fu{tK}j-Tu#5OgXsig*rT z$&l9>zP}@TbaSZHaw?i-cUVMi7xR2D4fPe>AMJs&-F(s0GLHyeYZb$E?~^jmC&GPt z)S0^e2T4`ZR=zrjOOt^e8fEE`v!>C+>r>fG!UXgnd}lMj{>%An)X{AKWM`Qw!$uTE zU^-DmVI-^5O82$H@Q22={S`I*a`y*4wa-03svk!^)R6u2EWTJPrbKFU ziOT3kacSNY4@O4#Wefbol?W6LbKz@SeqS+k{TVrGTvm?zi(&Os?=1mfYK@J%RYoj@ zTCg{|uKd-x7YS(+!@kY;tABS{2#&nP1q7X>%HDiUD!!J0UykBi8QR4G7<7in*BsbC zOsT<~Xz4-OI^g*<%|fDi@_p~6m(t;L1cIaxhQPCn7=M%90s%o-^ zOeGBtGR`oMh?F?P)Np%-e?%>CQ5o$>GfRdq|@D?oowj`iGNOWf9ScU+=yqYO?vMDaE81X`f_WQj`6H zJd+N8h*Rp!^AfyRgcqhlYV1CL`XmnZpCsIB& zTzop+6OE4g3|e}w=wKa%F8x_ITA)6DAI>Qa9k13r<2KJ`;w|47b;{Cen9MRVj?zosbG6KiRYc;{xnKcdGS3lJS=G2)j z$#8v_O&7-Dg6P$hJLnDlBp2(?hsi=%<|^T)9SzJ-RNvJ+EM~!KzQQXbRRzgaR6-Zm!<=CxC`)-Fr3arYWW69<%0M*ae1>ff@ z7(`ZcR%H(R;$>eP!~A$FWW(TRBqxsOa@^fn#gmjQ3=wzQ;A@U}6f7hBY`cMJBE70E z10_lMRP&PgZ{#kdK6;wLly`U=}5x$;SH} zMz&1&2g!u?Clkx5@a7xw)IBe9Vo}N*{#B@$Z7^aioiK`r_u`MO3`;`I1bdowV5`5)`MWA6!1rn$Os-~ z#>gfYj7~QbzWfEJ$bZ%N?bpTuKIcx1I8=>_8J(?*mi3Z`a$%OayY<;H0M@^aHEKDq zL-*1j*81o^fGib-ih=c)#u1TG_aR5^HOXq0w?otKln5eROkxi$&$KtCF+-(YrpAY?mi~oS|+O z|MB=muVx8NLz;v;Hjn~F;U`NNyn_-;iO}D9B=~HyK}E~U7dq5pfVGRy_L>d+uxowe0N!9e{b8cJbl3%e(7)z{nHBW2Cx3sQNc= z^^^>#mB@V*V9${o{nhGP&8oam`G*fh z0HlD;tAlo}R26+>_rnE}=JCYJFq&#qDP@`j;6z`~rIMZq1&3xHa0H8knaI&{NR#(( z1N$Bd=SGAC;BiG&g&6M6uBaq8+S~wjS{l!ekiP6t6^NXRnT+f79;K5KW zld@1`ez1BTvL;Sit1+v>S<5?8M~=fnFphsj>wf0>eF)*tW?cy2SAD2moX;eScqGvO zgtRCXPA_!duQ*HylEl!SN8|hMNend+7b5I~0ROR$XhOh;Kh10JLevE=pWvS}(ynL? zI*1$P&)z{^ye={fE&pjbS`hG#T~j8B$|1^0>itL*Hozud-Ay+szbsbm^K_7P`~$in zRV%yV6OW0ZpgJz#BWO0+O=FCVCSi&G=<)hfM;L*YIQ6saB5^mIo$WE2cOCp*Y&2uc83^5Hc>XF~$u660;|{V07n_7Y zl}XJoboG-uqjF~MViVZWD)}BR!XU}T(S_t6A8a#7vsIuTw?zLXu*coDV6R_^pE%#D zR@zt75dx*mY7wI{7t1{}u|g79T1c8}w~B*h+#i>es*IFF-U7T({q!l5FUTYGmW@{y zrYO@ZK;zT1yU9M+>b`+F zm;tjb{kN%ivpK!y*go`7PPkCtbD(f0$0d5LtL$ZSw=0lc@M25dka+piEQ5@dkw`-WEDigrwRGOtM453Daj=uYtS`fE?`zY}8 zg^)&CVPUCQ)gzfhydpX@)lOL)#Dzvp$6;AY4{TZ1MwMo3?8`V3-04_;Ul+>u4btKL z2#8BkJC844cF<0fqYK&2@r9LLz&q;v4lK&hlS5MsZa4xPaEU4{ZN;hnqJlDFmBGDA#H89A&n$vVHMJG;Bo z!t!P$q2$$^A3w~fhjLH=HdI&s*6M}dFRi5xtMq=?x3~4fdHl7MHgr?u*+U@b2{B~Q z?pJSQ^&CSWv*VWRhUJ8kV1)h|cXBO9DiiugBYfhXb-|-QaO3==(ZW93vkZ87N#36v zDea$Hd2r^_gSPkDWh0VHaVzy9kT}|Fwdt3aKBfK8K2oInaU%$+~+(ClznP5{xHtG*>jr36ifI=X;aB)cMX5P=8~j~k%qyMP=>pL@N41Wq@Eg2w9B%?#)PWQ&VNR)rr-idGKF@F_=$ z?612pB0vf*kH|l0c{ME<2V-L1K}^dmxvGtVfzPD;GMb+wx%p+(soyv&V7`uuBi_@x z^Ahe2^(CArfS?9dpy&ze99S^=!~Y2{BhxQE%eW$O1a91OGq%~1DJb$bSlIZdYE4fm zq~;i-Jn7h?~MssoK6Sx``QXGub!Cd=+ zkuQkQ#NyDZOfq!laO2-mNoT~85FLjHU>r>SGh*x|{<;c)z<;v;9WnMa{t$DFwK&xI zpAmD5aPO}xjDymDMvQ&XUswNsQ1YT798n7Y*q4vVfSCyYnt%B(L)~pweX$_Tw|}FQ zKXiKdVIb?jW6=DABuNZ^Alo0M*<184@F(M2;E4I6P`fiec|{ELM0zwI+j9x|pJGd) zo6V1s4ZW6!r5yy}0kF~$nUK3s*fJB(Y@_M8++P_4KCVRtr-0{7VB&7*Z*eOu9S53y zlndInMhmEN2E!8E%NXyA@=To7zs>!!t7AI@uX0`eJd#L?ri(aVT@kFdA9BX;^{S>q<`nMW^#ys9&tVB`RY!Ad_9@CK$(b1P5{JQisy#E zd*^m+i(mAr_H?SGr}7i|3tJ=%27ciB?~K6a>QA9CGHYzoP^e3hb( zi6Ib@itb<3>69gxR6$Zs^?=&U&wr)k`8PGm|5$EYV5)C=gX>zi%rO6|p1>dX&b?>P zzAI~t|FX^`NY5{J+X|*}IW4HWwAtH3xXJ-pDqx17u*t-dxj4m-5BF1=T*qv~qV1^* zgYP27*%+8m+qgv>l8BDfO@}EhhztiWah=Mnl(IB{{+*KxNq>xTuYm9pyZ(;p z*xpQt$XPvoVLnlyB>WCBen%C0hKTL3k8h2fRDtpx6_z9Vy;loo`ai0I8F>*-U`%M{ z;FXs?^uhYTW@k=gafM0e)ccjzfQd8y$t?&mb;&S8kUWCt6~m+k5D_$FKq%T?UmyRU zTBB}g@JbANAXLU#1FU`T0S^xWCZf(#qDlQrsN}mMr6@ObJrq_^)S)bJ%Bdf(l>NPA zxYL^4u#Qg)~qsf7pj!zV2!?xr(Ln7ELIvWE!;D}1(mZWejo8DOPh7Q_Q z`gd6_Oc^p*dD(<;BJL=geR92PB_l5Kwj6S@sxmm~dU0F>j|!?nT&Ps_4f2a#H{y+> zFN?Rx5%NaXqh&A8!oTUs0SqD?VUxkZE1Sh?(HHoB9xU;wZSts36X(}J$@+NJNHF7P`UQRKXtH&GLohQh^u=(LbxHGq4TRWn}~C#{sPRN&c9 zN*)c z5GLcS4Aq+oo(zuBU~gbFcntbSL-IG9k8)wdy`OU4I?u(R301)$lEPaCiy|_n5>(&i z-B8z=xlVL0MB<-#Q4<9ZvwTuaU6oOSF_RLnI~4btJdeA=ub>l_vh(i{Pf^k62OT+< zZVQvau^N}Xky89^n$Q;xtbdUXhPjt<6=D}1XpQIsZLu1MXNED^Mr?$}iWo~ZS)g-UnzKkO2#T0##4oU z_>O*?4@+=;4JLbwP@__XT2iiS9>C|!ztUZYe~X-Ru9{MO<5s~XE6w)at)j9F<`;05 zZxC(7XI{n;x8~N4|1aygjyB|J&MhF_cKx6Xx4D9*A|=q zAlGz&-<1Hg-XxO7RcqU#Y*f`z^6>U{hoVy&=lMe-x?6<9mHFkuMH~Iv#8w4G!{aNT zK*9fT<%y8L^%$*TiQ=ndG&rZs_}pUyA?5bsEr)%eRNULS`4}6e6h6T3rZ`GRwlBaqM7^k4iJK@O6)k@3 z+KXPNh1{z1J zK2XJkG*a|E>%gcu;5h}wYk7hSg!#`)0r6tLCKD}_DsB>aYz%!8ZXA!@*60xZuft1} zjvaaGO$*WOt56}%bNfd({n(V7%je@i+UZCpwm3S6budoclTj$=!eLmwB^&!j3CS@l zW!M8*O?De|W>1%UhNv0voyaVNsi`MuowYLuOFb!IO{wFqlPtjg9sOX9c`}jpNB!DU z?p$3k41HhGk|~LHj=V*%N9K+L*y1^FkDvZ1 z0OLxSq6Ak}3eXTdyt2DF?R0cLk2I6kjtW&_ar+cWTAfkPMAs*wB-AvBLG2-uf++-T zIL~ouvmkR17m-GsHGg;D#Fh$P!-UqK*zp~Hb6IL_6w^a@)~<2hZTEhwQ&qO^T{h{hNlzQ20-@_r|A7K7tclPdlw=;S7D+ipdZ9mRe1eT*NrPXo6uZ3lqV2 zoy@MREUbTvY8s6ZRC%Ng_N@J}|3tFSXlN&E^vDvZ&(`OV1q>rO9&-6Dc@0qVe_rMb zo_y0@fQp!O?9JU?Wp!NBO`~)#m2@zjS*pqeGk|I0Zp8r?6UE0j&rsKLcnx9b&@XrB z_U|{eF4(_J#~higeJKBltz*33UY*>@}*~Ps$pfK^>i@o zcS%x(peDGSWt!;U^ZCe)w-EhbL@EBubv)6ciX77pt18xQuSBD+ov6vJ(*K$-=U@15 zfa>-%pYNz%+qa~s*Tp)nphf#nC~{8JE@;6q&?$9j{} zQ%+A(!qv@kh^!=vKg|&p!j$P;Xdwm&ud{{w6Z8XHr&ZcMkN?~@DRR~JEw>WLe2 z(CSs&&EE8P;2`9`I~#<@uKlTE|JYG<6~NzpRGqlEn27i(R6yIn(M77SQa<-pTXhLa z5dtluO2V&Gv#=LKILWZ0fQWhdzS@?$pD;+>cuzOaNRZkkU+|ycM?Gj`FTO5Eq@#MA zHGa{-uw45?B7&ew0Y=joSmFpG|4qc2r(A!nwM}P4s;GUc>2KUOZ>4AzH+EjkUF=-> z+0p2`NM&$PZZuvu$RjRU&CJ+7eBlhOFQPFW#(e+9NyZfgh!-k}Mnt1;$nVfFJLJa| zDLk<_?g_6fqDN7VF#qUlZB zK9x=9{x4E>;t7H?!^p;3A&*jr+MA{RBZjUw_Vs6``$CpemSE+jgUrw?f)L9aeKBAO zg!oBTcu;qbsp_ixTk_OZr7s$WH}TIb6YuI>2ktAL-1kXqVc<~X;sdBn;I$NLTO*IE zoIVik%jBgD)-~}{Tk^q~AZTxAK+P=O4M4+4CkRSqO32#i3J(o z@?&*oXMfcQ4}o)IT0GoDxrq#~3E`_QG<#_4Wv{6?>-qC2vFwL8j*FSD>S36sb}N@) zm+k$jqr{2C(mWO6yma6bbi4KL1cBGqrCy3i@m?b8RUX&mtiVso1dChlqKsC7DVsnh zJ;=Mn&e|ClBOr6q^7a`e0!LIDG?BP*(M&YDUmo8nlv?)VK4s+Yu8XVf00PgQbwDt^ z^1AIjSf;2d(i~SCxcw!7g)x%6H^ID9k@MbB$Ac(`G9lkUd{#)yk^OeVMz?tO#Y_6S z+PyUn<)vvq%py5a?zQbOb?D7tZmU&E_jNO+bY=Dx{U45kyNBu=vPyb&(&tfy!=Bmi z0*C2k61^}DyZp^29nXXekD5~qg2D2r8=3`KX4MB4%Q&NSz`zP`h>AOG=5D_`i)WDD z7E_+F9DqEBsN6JX&%U*!_O`R4Vs){c3WQtd!gK)Bkw%CXU}Mquya*aebYA69NQQg2 z9CD!fPz&6j%!1l5E#oY%wG>R{TVas^-0@jaO8@ll_y&@Xp6z2s&^zR zrkcZNhW~on70~MzB`FsI;W7US@lHz@10L&NX&Es94l>T1sz!x@_jpgNR}M9`BUCR? zycM4u@*&*olJfktSD3!3q%MIBr5stw+U%6y%+uoHy=p<955yHkfi0Pp!GzSelUrZ1 z{+x$$$4oHuGi3HhUF~SIDwI@2kcLJ?WlePJnnZ+-Jo(@WHlb-X0v0JWVsulB4wgh! z5!TvAfT~yWJNxhd&EX;VpA>DgBA4Na8XcZk^xKeNgOF=Z(*z-h2utBSCKOYs6U4d>YI35Z;_XfZ8ADyF99G3l3*{)~u;6YgHGidBHas9=+IeMOJ zCDlpJ=cO=w>&4+6=?aw?Yjw#8WIg&1CUr{mmJ8iXS&+lCi;%%rxsW5H|C}+9%0Kw|^F|O4_8rUnSTy zg?KG+!cO!b^xPGYmm@{_{lJ_v;+&HOFs0uYt4$+6s!LO{p38|zw3vxd=k9c{6I~3% zDXDG?=-tj!GN!~i!a&QOSI(#+jl8Kv4z%mUne;oF_r4Xe27NZzz6VEVlV`VwO%u#9 z?`Y_$`HYw~)L)DffnWp^XC(f`uol(6+!qwTyrDdzlZ)R}`_?fgGu2YF8`r*;0cmV~yPE%nsC589g8-Fh&oP;`)l9cXAucoDcoshYc z5k#{Qm+hgpp!H5z84OzHXX=py;=@$Ex$8bJ@AfCn%_qx6WZz&oYzHI`@Q@LbD#dEO z$}sYLkLn~w6&>u(jwz)5jQxn(I1syi?aXxi#FqVnxR4BV*nh{7ZzoKCZbXkdZ}3-x zoGh!6uw0`;F#{>y8xJCnff&BEu0em(++JEux|!sbj(*Kak~gx)JomToX5>Cj@h8%~ zOY|h^i^USfLEwO{GH?ZpDujwE7}bnYoRUp5fRrBYInqiJn4R>z&idY_Aj zotO4Qxod{bQ$n0v?yDLZVxTN{ln$!_icteR{Nwpw)PSOcxI0Bw3;}t)FLES4u!$o~ z1z3lPpI9CLc5fqN_QRyrPuVD=Eo)_Ba1)W!eHrlf+WpJCY;07a6b(JeZTDCIloVHX&m@aW;v53w&5AWIHS zUh>~PZ`)FLjPrRo?0_Q3mRT8_GepBtJ0&eV-+B|+idaQBA%>b_ti}=^mJua~oJ9Qt z2(r|%bIljL|Gn<&EzqcK$c5UA+UFf#cV{3`F`{?xw*a0=zA#VFFb#|rxOKZffLR0M z;~gS(FR%9Jl;B%$yitYNGg^vU4}E4V%tu%C9rw2~n2+%PCUyZ2C3nC6*%2un)43RD zm>q@%w=6!2Krg<0Vn};fC)c{?u>^+Bb|1pfY2^$034|QFQjTQFAXJO#fv{=nw>C~2 z993L8S(e=^tPO&%9dPbUf0@#9jMIy#SjN*IrwWRy7}wp-+~hazT$Eb2ze3mEetGt zrq{(BC=d$ESUD3?Nao8+I6;8I`i_55f#7Lj@d&!Un->Lkr~+f!1W2_B&6Zxna>`om za?She{@;ZHs6g9u{nLsPyYr(G#V(dn7Vlo@ONZ%39*l3R_$77lhArkKtf`$Ovi!QO z6fbK9{zq|74IF^Ur;l?hEN3Pu)JploQGMCK`^4Y=tnF4aCI(F1mGhU$2UKh#vypEM zeSFNje#gF$ibEXKARUAVuP{!i?z1BFQY0~ zXgt4`NT@6&k0y}Vk?v+XUL!Q#<#vU19%v+3@~=luPHie?u+v)1yfe2H+}p#f+1SQ^ zJ};SR@2A7i3$los=G|5Ou%&0?eD!ABCvQ^`ujpPyc7Y8=q`bmKho6PjT5jsElWH82 zT8et6PSmkzroH^ar>o?>W4abF)4azwbLF7dRZO{zZ5z`(JzcHfd4WG3jjENY{o%s^gN_{cou3J9lyrkxLHSlnS1 zbu^*umN;p#o8W&rT^SxFeG;oSG$hQ>-}FvOgr+QAfe)ILJy^!Jp%conU<%bC05dwYV74~+VC&dQVY*RUu$Dky)hUw_j6kRbGoogkGUi2(m9b_f zS`BL?Xm&zghngjB>ve2S5)~GbdykRu*aFUqw< z=NAt%FrjPNf90jlDvv2|b3F>IcBXsf`kWJ0tC2{v!RYYxU0U|$300VjDiFvpf-d1r z&r{lLL$qM-!-O6bMJerb^k>yOwt7t7fB)2!rCz^Kv0=KPI10UD2nKa+c_QX0X=q+J zx^)fJ&1c=mqnAxosji#j=@KmSiB*B;GQ*2OUl zMjswkCE9prXeXGeQ8Y@upD|QR5%p-9kx>bP5sInLG_88KsUb`d?=l*`%0oN#8gFSl z#-nMydsEegJBVn%8=ua9v(~r1zwTZ8o^$p-d++l*zq{8ur|L^=2x28)g?wSw8~m+Y zYe({14EY0qPXdKd4W^>I#rPSH0BcX&b=FyuLc>8Q7mZLnPfEo3YYi`gkKw)ADtiVd zat5%9I~BocJ7jGGNi+u3Ws9qEt}5WV?8N8Syj!G@8=Nyf_%RH3EeSkznD!g9&W1c#uN42KQTlAEzYHJYx9qpcA-+eX;559nB`st zXVHu=D3uE`99*2ZO;>fR@ zO@<9x3Y>u)H&6JCqpRN#qm6EQR?iMnlFFrxx#$8}B*jbeeZ9pocn&5UxbS4ji5_pe zrz>4)3!7D3Bba1jZhW`dDY0N~q{!hEb@C#kjPER>g_G6ulfKx3MkYFi)pOhoiAY^8iryMN5`9P6IZHZVWbI1;=x2^Kck(@22^YnR8HlOx|FBH zAMgGmFewF-dG|!4YCD2&+UwP7r95sBNkaeEfu%2B>t2d(z@DWt^^KjGZq|8*gMC7O zVx$7A#5kFj=Ty?sYxs@%3(Lq=`?FgdSU-WBJ~h-3$K%U=zc@Q76Cm>GC~d6vzGI*I zFPoMItDtOU;q0_AI*>0Bz)wh(iLyF{7lWY78!pFZ)bWNRU1@!fll4jMbCtRW!mMru z_MYL%Zf2?I+xO(L>Mi~iPA>TT(lJy$FXCF+7A~>L6ge4H6~wPl1d~s<{?_=`osgH4 zbMkj^eX(rzMx2M0<+Q}j5lwIxWfN?kFp=^+e8$MeKW_HK+%<45~95hYrSBEsffG>`*8+rQD5XgF8;yqY8ctu@E=^F+}$AYXMD z_)O#A8VTrYf43O3c!9&ew@dciYu;YP)}wD6ug%}h?qo3Sq2KVQrNV>YiXS0E^)t~D zT0@M|GBC&X-X%aNU(2l}R>uh@u=O%w4?`Aj3BXYG@zXwVFxKgm<4)-V(|yz*WUkm8 z6^)=cj~ozGCT4|{qxt~-S(l;W!6V>4@Y`A#xu~y`Cy};$);z0uG!VW6T_Hhy^GF6H zBabX6^d|*7vjkc|ttqfO9r*a>gI#Pi|^Nk#7km$HU_T zGgtgm8r6GesS(Ed#j3(WOuQk^$|o=;Hj&sWD`)Emb+t#qDMDgIDz~{bEkU zi~KgPKUTf69y$h1dzGL}fR6fDa7q5or*w4=aaxm?Dd*~=UExbP+8JZnjEW6h@`Tgnu=}$tns{tS*t`xInx5oy1rE~dLMX2up$Lp@ zhz~t2m8@o0^XUq&@6hW8I)x2xy4~0O4yo4EdN?-p$LO=z#%tHFV->-RleeHWfjG(g zDRm1LKt*`J_3)@C1(@4a8AfJcxoKX_l=_FIwfA)Emt3NbrZg89+PSi@bDNEr^2g0A z4q9F+DBk^ysiyOLz02hsc_Vp?Xt_A#b7h%AO_-zRTgb_M7DO_p$IK7Mu7)pEq}?aKoBvi8dr=XKy&c zb##avX;~-AhP4d`@xO|O6FTaKL)1o;OV2%02>5El{lImp@F5MY&u_5+v*61&|KcKN zaaSg&@A~D2YZ#ek<|X|!;n(eIS=zy`O~f90d!z>zOBtKgyh}UbvXCB!MKp(X!%5vF z0wdtl*|2|jzDhr5CcpP>Ed?w2e_nYc>K1YMkfdlF$?YqLaZ>-FZD0J Date: Wed, 16 Nov 2022 17:40:58 +0800 Subject: [PATCH 050/141] test(client): :white_check_mark: make tests working --- tests/clients/test_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/clients/test_client.py b/tests/clients/test_client.py index d6a25553..feb6c0f6 100644 --- a/tests/clients/test_client.py +++ b/tests/clients/test_client.py @@ -33,7 +33,7 @@ def test_request(self, key_cli): with pytest.raises(PyYouTubeException): with responses.RequestsMock() as m: m.add(method="GET", url=self.url, body=HTTPError("Exception")) - key_cli.channels.list(id="xxxxx") + key_cli.channels.list(channel_id="xxxxx") def test_parse_response(self, key_cli, helpers): with pytest.raises(PyYouTubeException): From 274752f1ecb97bf597b4f7d872c12c216b2cfcc2 Mon Sep 17 00:00:00 2001 From: klein Date: Wed, 16 Nov 2022 17:50:53 +0800 Subject: [PATCH 051/141] Update README.rst --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index c1bdc8b0..b7d89807 100644 --- a/README.rst +++ b/README.rst @@ -29,7 +29,7 @@ Introduction Library provides an easy way to use YouTube Data API V3. -.. tip:: +.. Recently, we are working on the new structure for the library. `Read docs `_ to get more detail. From 02e1e4e5148fd7b773aabbca16bea90b6e2609d8 Mon Sep 17 00:00:00 2001 From: ikaroskun Date: Wed, 16 Nov 2022 18:18:57 +0800 Subject: [PATCH 052/141] feat(subscriptions): :sparkles: add api for subscriptions resource --- pyyoutube/client.py | 1 + pyyoutube/models/__init__.py | 8 +- pyyoutube/resources/__init__.py | 1 + pyyoutube/resources/channels.py | 2 +- pyyoutube/resources/subscriptions.py | 200 +++++++++++++++++++++++++++ 5 files changed, 204 insertions(+), 8 deletions(-) create mode 100644 pyyoutube/resources/subscriptions.py diff --git a/pyyoutube/client.py b/pyyoutube/client.py index 1cf4f27c..4e54508e 100644 --- a/pyyoutube/client.py +++ b/pyyoutube/client.py @@ -50,6 +50,7 @@ class Client: playlistItems = resources.PlaylistItemsResource() playlists = resources.PlaylistsResource() search = resources.SearchResource() + subscriptions = resources.SubscriptionsResource() def __new__(cls, *args, **kwargs): self = super().__new__(cls) diff --git a/pyyoutube/models/__init__.py b/pyyoutube/models/__init__.py index 24750535..24cfa76b 100644 --- a/pyyoutube/models/__init__.py +++ b/pyyoutube/models/__init__.py @@ -16,13 +16,7 @@ from .playlist_item import * # noqa from .playlist import * # noqa from .search_result import * # noqa -from .subscription import ( - Subscription, - SubscriptionContentDetails, - SubscriptionListResponse, - SubscriptionSnippet, - SubscriptionSubscriberSnippet, -) +from .subscription import * # noqa from .video import ( Video, VideoContentDetails, diff --git a/pyyoutube/resources/__init__.py b/pyyoutube/resources/__init__.py index 517b262e..2e1a7980 100644 --- a/pyyoutube/resources/__init__.py +++ b/pyyoutube/resources/__init__.py @@ -10,3 +10,4 @@ from .playlist_items import PlaylistItemsResource # noqa from .playlists import PlaylistsResource # noqa from .search import SearchResource # noqa +from .subscriptions import SubscriptionsResource # noqa diff --git a/pyyoutube/resources/channels.py b/pyyoutube/resources/channels.py index 46d16929..2fe11912 100644 --- a/pyyoutube/resources/channels.py +++ b/pyyoutube/resources/channels.py @@ -102,7 +102,7 @@ def list( raise PyYouTubeException( ErrorMessage( status_code=ErrorCode.MISSING_PARAMS, - message=f"Specify at least one of for_username, id, managedByMe or mine", + message=f"Specify at least one of for_username,channel_id,managedByMe or mine", ) ) diff --git a/pyyoutube/resources/subscriptions.py b/pyyoutube/resources/subscriptions.py new file mode 100644 index 00000000..e4e1f788 --- /dev/null +++ b/pyyoutube/resources/subscriptions.py @@ -0,0 +1,200 @@ +""" + Subscription resource implementation. +""" + +from typing import Optional, Union + +from pyyoutube.error import PyYouTubeException, ErrorCode, ErrorMessage +from pyyoutube.resources.base_resource import Resource +from pyyoutube.models import Subscription, SubscriptionListResponse +from pyyoutube.utils.params_checker import enf_parts, enf_comma_separated + + +class SubscriptionsResource(Resource): + """A subscription resource contains information about a YouTube user subscription. + + References: https://developers.google.com/youtube/v3/docs/subscriptions + """ + + def list( + self, + parts: Optional[Union[str, list, tuple, set]] = None, + channel_id: Optional[str] = None, + subscription_id: Optional[Union[str, list, tuple, set]] = None, + mine: Optional[bool] = None, + my_recent_subscribers: Optional[bool] = None, + my_subscribers: Optional[bool] = None, + for_channel_id: Optional[Union[str, list, tuple, set]] = None, + max_results: Optional[int] = None, + on_behalf_of_content_owner: Optional[str] = None, + on_behalf_of_content_owner_channel: Optional[str] = None, + order: Optional[str] = None, + page_token: Optional[str] = None, + return_json: bool = False, + **kwargs: Optional[dict], + ) -> Union[dict, SubscriptionListResponse]: + """Returns subscription resources that match the API request criteria. + + Args: + parts: + Comma-separated list of one or more channel resource properties. + Accepted values: id,contentDetails,snippet,subscriberSnippet + channel_id: + Specifies a YouTube channel ID. The API will only return that channel's subscriptions. + subscription_id: + Specifies a comma-separated list of the YouTube subscription ID(s) for the resource(s) + that are being retrieved. + mine: + Set this parameter's value to true to retrieve a feed of the authenticated user's subscriptions. + my_recent_subscribers: + Set this parameter's value to true to retrieve a feed of the subscribers of the authenticated user + in reverse chronological order (the newest first). + my_subscribers: + Set this parameter's value to true to retrieve a feed of the subscribers of the authenticated user + in no particular order. + for_channel_id: + Specifies a comma-separated list of channel IDs. + The API response will then only contain subscriptions matching those channels. + max_results: + The parameter specifies the maximum number of items that should be returned + the result set. + Acceptable values are 0 to 50, inclusive. The default value is 5. + on_behalf_of_content_owner: + The onBehalfOfContentOwner parameter indicates that the request's authorization + credentials identify a YouTube CMS user who is acting on behalf of the content + owner specified in the parameter value. This parameter is intended for YouTube + content partners that own and manage many difference YouTube channels. It allows + content owners to authenticate once and get access to all their video and channel + data, without having to provide authentication credentials for each individual channel. + The CMS account that the user authenticates with must be linked to the specified YouTube content owner. + on_behalf_of_content_owner_channel: + The onBehalfOfContentOwnerChannel parameter specifies the YouTube channel ID of + the channel to which a video is being added. This parameter is required when a request + specifies a value for the onBehalfOfContentOwner parameter, and it can only be used in + conjunction with that parameter. In addition, the request must be authorized using a + CMS account that is linked to the content owner that the onBehalfOfContentOwner parameter + specifies. Finally, the channel that the onBehalfOfContentOwnerChannel parameter value + specifies must be linked to the content owner that the onBehalfOfContentOwner parameter specifies. + order: + Specifies the method that will be used to sort resources in the API response. + Acceptable values are: + - alphabetical: Sort alphabetically. + - relevance: Sort by relevance. Default. + - unread: Sort by order of activity. + page_token: + The parameter identifies a specific page in the result set that should be returned. + return_json: + Type for returned data. If you set True JSON data will be returned. + **kwargs: + Additional parameters for system parameters. + Refer: https://cloud.google.com/apis/docs/system-parameters. + + Returns: + Subscriptions data. + Raises: + PyYouTubeException: Missing filter parameter. + """ + + params = { + "part": enf_parts(resource="subscriptions", value=parts), + "forChannelId": enf_comma_separated( + field="for_channel_id", value=for_channel_id + ), + "maxResults": max_results, + "onBehalfOfContentOwner": on_behalf_of_content_owner, + "onBehalfOfContentOwnerChannel": on_behalf_of_content_owner_channel, + "order": order, + "pageToken": page_token, + **kwargs, + } + + if channel_id is not None: + params["channelId"] = channel_id + elif subscription_id is not None: + params["id"] = enf_comma_separated( + field="subscription_id", value=subscription_id + ) + elif mine is not None: + params["mine"] = mine + elif my_recent_subscribers is not None: + params["myRecentSubscribers"] = my_recent_subscribers + elif my_subscribers is not None: + params["mySubscribers"] = my_subscribers + else: + raise PyYouTubeException( + ErrorMessage( + status_code=ErrorCode.MISSING_PARAMS, + message=f"Specify at least one of channel_id,subscription_id,mine,my_recent_subscribers or mySubscribers", + ) + ) + + response = self._client.request(path="subscriptions", params=params) + data = self._client.parse_response(response=response) + return data if return_json else SubscriptionListResponse.from_dict(data) + + def insert( + self, + body: Union[dict, Subscription], + parts: Optional[Union[str, list, tuple, set]] = None, + return_json: bool = False, + **kwargs: Optional[dict], + ) -> Union[dict, Subscription]: + """Adds a subscription for the authenticated user's channel. + + Args: + body: + Provide subscription data in the request body. You can give dataclass or just a dict with data. + parts: + The part parameter serves two purposes in this operation. It identifies the properties + that the write operation will set as well as the properties that the API response will include. + Accepted values: id,contentDetails,snippet,subscriberSnippet + return_json: + Type for returned data. If you set True JSON data will be returned. + **kwargs: + Additional parameters for system parameters. + Refer: https://cloud.google.com/apis/docs/system-parameters. + + Returns: + Subscription data + + """ + params = { + "part": enf_parts(resource="subscriptions", value=parts), + **kwargs, + } + response = self._client.request( + method="POST", + path="subscriptions", + params=params, + json=body, + ) + data = self._client.parse_response(response=response) + return data if return_json else Subscription.from_dict(data) + + def delete( + self, + subscription_id: str, + **kwargs: Optional[dict], + ) -> bool: + """Deletes a subscription. + + Args: + subscription_id: + Specifies the YouTube subscription ID for the resource that is being deleted. + **kwargs: + Additional parameters for system parameters. + Refer: https://cloud.google.com/apis/docs/system-parameters. + + Returns: + Subscription delete status. + """ + params = { + "id": subscription_id, + **kwargs, + } + response = self._client.request( + method="DELETE", path="subscriptions", params=params + ) + if response.ok: + return True + self._client.parse_response(response=response) From 7e33f5cc2ed9978851435934172d2976b11667a0 Mon Sep 17 00:00:00 2001 From: ikaroskun Date: Wed, 16 Nov 2022 19:15:44 +0800 Subject: [PATCH 053/141] test(subscriptions): :white_check_mark: update tests for subscriptions resource --- .../subscriptions/insert_response.json | 47 +++++++++ tests/clients/test_subscriptions.py | 98 +++++++++++++++++++ 2 files changed, 145 insertions(+) create mode 100644 testdata/apidata/subscriptions/insert_response.json create mode 100644 tests/clients/test_subscriptions.py diff --git a/testdata/apidata/subscriptions/insert_response.json b/testdata/apidata/subscriptions/insert_response.json new file mode 100644 index 00000000..56592f87 --- /dev/null +++ b/testdata/apidata/subscriptions/insert_response.json @@ -0,0 +1,47 @@ +{ + "kind": "youtube#subscription", + "etag": "BBbHqFIch0N1EhR1bwn0s3MofFg", + "id": "POsnRIYsMcp1Cghr_Fsh-6uFZRcIHmTKzzByiv9ZAro", + "snippet": { + "publishedAt": "2022-11-16T11:02:09.19802Z", + "title": "iQIYI 综艺精选", + "description": "www.iq.com\n\niQIYI is an innovative market-leading online entertainment service and one of the largest internet companies in terms of user base in China. Over 1500 hit films and 180 TV shows are available FOR FREE on our global platform with multilingual subtitles in Mandarin, English, Malay, Indonesian, Thai and Vietnamese. \nWebsite: http://bit.ly/iqjxweb\n\nClick the link below to download iQIYI App and explore thousands of highly popular original and professionally-produced content.\nApp: http://bit.ly/iqjxapp\n\nFollow us on Facebook and know everything about your favorite shows!\nFacebook: https://bit.ly/iqiyifb\nInstagram: https://bit.ly/iqiyiins\nTwitter: https://bit.ly/iqiyitw", + "resourceId": { + "kind": "youtube#channel", + "channelId": "UCQ6ptCagG3W0Bf4lexvnBEg" + }, + "channelId": "UCa-vrCLQHviTOVnEKDOdetQ", + "thumbnails": { + "default": { + "url": "https://yt3.ggpht.com/kszyCtmb0Bsjo-Pwp6lhZnAA6wMP8gzqNU9qZOj-b9p2GvwF4yygtPBzuRpmGUPtymByYol9Oj8=s88-c-k-c0x00ffffff-no-rj" + }, + "medium": { + "url": "https://yt3.ggpht.com/kszyCtmb0Bsjo-Pwp6lhZnAA6wMP8gzqNU9qZOj-b9p2GvwF4yygtPBzuRpmGUPtymByYol9Oj8=s240-c-k-c0x00ffffff-no-rj" + }, + "high": { + "url": "https://yt3.ggpht.com/kszyCtmb0Bsjo-Pwp6lhZnAA6wMP8gzqNU9qZOj-b9p2GvwF4yygtPBzuRpmGUPtymByYol9Oj8=s800-c-k-c0x00ffffff-no-rj" + } + } + }, + "contentDetails": { + "totalItemCount": 6986, + "newItemCount": 0, + "activityType": "all" + }, + "subscriberSnippet": { + "title": "ikaros data", + "description": "This is a test channel.", + "channelId": "UCa-vrCLQHviTOVnEKDOdetQ", + "thumbnails": { + "default": { + "url": "https://yt3.ggpht.com/ytc/AMLnZu-fT-p35OYe0OaSzOmrQY8KEjXVHMgPxyj-hV2r=s88-c-k-c0x00ffffff-no-rj" + }, + "medium": { + "url": "https://yt3.ggpht.com/ytc/AMLnZu-fT-p35OYe0OaSzOmrQY8KEjXVHMgPxyj-hV2r=s240-c-k-c0x00ffffff-no-rj" + }, + "high": { + "url": "https://yt3.ggpht.com/ytc/AMLnZu-fT-p35OYe0OaSzOmrQY8KEjXVHMgPxyj-hV2r=s800-c-k-c0x00ffffff-no-rj" + } + } + } +} diff --git a/tests/clients/test_subscriptions.py b/tests/clients/test_subscriptions.py new file mode 100644 index 00000000..715bcec0 --- /dev/null +++ b/tests/clients/test_subscriptions.py @@ -0,0 +1,98 @@ +import pytest +import responses + +import pyyoutube.models as mds +from .base import BaseTestCase +from pyyoutube.error import PyYouTubeException + + +class TestSubscriptionsResource(BaseTestCase): + RESOURCE = "subscriptions" + + def test_list(self, helpers, key_cli, authed_cli): + with pytest.raises(PyYouTubeException): + key_cli.subscriptions.list() + + with responses.RequestsMock() as m: + m.add( + method="GET", + url=self.url, + json=self.load_json( + "subscriptions/subscriptions_by_mine_p1.json", helpers + ), + ) + + res = key_cli.subscriptions.list( + parts=["id", "snippet"], + channel_id="UCa-vrCLQHviTOVnEKDOdetQ", + max_results=10, + ) + assert res.items[0].id == "zqShTXi-2-Tx7TtwQqhCBzrqBvZj94YvFZOGA9x6NuY" + + res = authed_cli.subscriptions.list(mine=True, max_results=10) + assert res.items[0].snippet.channelId == "UCNvMBmCASzTNNX8lW3JRMbw" + + res = authed_cli.subscriptions.list( + my_recent_subscribers=True, max_results=10 + ) + assert res.items[0].snippet.channelId == "UCNvMBmCASzTNNX8lW3JRMbw" + + res = authed_cli.subscriptions.list(my_subscribers=True, max_results=10) + assert res.items[0].snippet.channelId == "UCNvMBmCASzTNNX8lW3JRMbw" + + with responses.RequestsMock() as m: + m.add( + method="GET", + url=self.url, + json=self.load_json("subscriptions/subscriptions_by_id.json", helpers), + ) + res = key_cli.subscriptions.list( + parts=["id", "snippet"], + subscription_id=[ + "zqShTXi-2-Tx7TtwQqhCBwViE_j9IEgnmRmPnqJljxo", + "zqShTXi-2-Rya5uUxEp3ZsPI3fZrFQnSXNQCwvHBGGo", + ], + ) + assert res.items[0].id == "zqShTXi-2-Tx7TtwQqhCBwViE_j9IEgnmRmPnqJljxo" + + def test_inset(self, helpers, authed_cli): + with responses.RequestsMock() as m: + m.add( + method="POST", + url=self.url, + json=self.load_json("subscriptions/insert_response.json", helpers), + ) + subscription = authed_cli.subscriptions.insert( + body=mds.Subscription( + snippet=mds.SubscriptionSnippet( + resourceId=mds.ResourceId( + kind="youtube#channel", + channelId="UCQ6ptCagG3W0Bf4lexvnBEg", + ) + ) + ) + ) + assert subscription.id == "POsnRIYsMcp1Cghr_Fsh-6uFZRcIHmTKzzByiv9ZAro" + + def test_delete(self, helpers, authed_cli): + with responses.RequestsMock() as m: + m.add( + method="DELETE", + url=self.url, + status=204, + ) + assert authed_cli.subscriptions.delete( + subscription_id="POsnRIYsMcp1Cghr_Fsh-6uFZRcIHmTKzzByiv9ZAro" + ) + + with pytest.raises(PyYouTubeException): + with responses.RequestsMock() as m: + m.add( + method="DELETE", + url=self.url, + json=self.load_json("error_permission_resp.json", helpers), + status=403, + ) + authed_cli.subscriptions.delete( + subscription_id="POsnRIYsMcp1Cghr_Fsh-6uFZRcIHmTKzzByiv9ZAro" + ) From 450fd8d87fc2fa4b302116ccf6cc63bb53ef9031 Mon Sep 17 00:00:00 2001 From: klein Date: Wed, 16 Nov 2022 19:22:44 +0800 Subject: [PATCH 054/141] Update README.rst fix invalid link --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index b7d89807..1401df02 100644 --- a/README.rst +++ b/README.rst @@ -31,7 +31,7 @@ Library provides an easy way to use YouTube Data API V3. .. - Recently, we are working on the new structure for the library. `Read docs `_ to get more detail. + Recently, we are working on the new structure for the library. `Read docs `_ to get more detail. ============= Documentation From ee45fe9f920372e1e8cf7d61546841269ac19ac6 Mon Sep 17 00:00:00 2001 From: ikaroskun Date: Mon, 21 Nov 2022 16:50:35 +0800 Subject: [PATCH 055/141] feat(videoAbuseReportReasons): :sparkles: add api for videoAbuseReportReasons resource --- pyyoutube/client.py | 1 + pyyoutube/resources/__init__.py | 1 + .../resources/video_abuse_report_reasons.py | 52 +++++++++++++++++++ 3 files changed, 54 insertions(+) create mode 100644 pyyoutube/resources/video_abuse_report_reasons.py diff --git a/pyyoutube/client.py b/pyyoutube/client.py index 4e54508e..41c76b34 100644 --- a/pyyoutube/client.py +++ b/pyyoutube/client.py @@ -51,6 +51,7 @@ class Client: playlists = resources.PlaylistsResource() search = resources.SearchResource() subscriptions = resources.SubscriptionsResource() + videoAbuseReportReasons = resources.VideoAbuseReportReasonsResource() def __new__(cls, *args, **kwargs): self = super().__new__(cls) diff --git a/pyyoutube/resources/__init__.py b/pyyoutube/resources/__init__.py index 2e1a7980..a03fe5c5 100644 --- a/pyyoutube/resources/__init__.py +++ b/pyyoutube/resources/__init__.py @@ -11,3 +11,4 @@ from .playlists import PlaylistsResource # noqa from .search import SearchResource # noqa from .subscriptions import SubscriptionsResource # noqa +from .video_abuse_report_reasons import VideoAbuseReportReasonsResource # noqa diff --git a/pyyoutube/resources/video_abuse_report_reasons.py b/pyyoutube/resources/video_abuse_report_reasons.py new file mode 100644 index 00000000..1ab85014 --- /dev/null +++ b/pyyoutube/resources/video_abuse_report_reasons.py @@ -0,0 +1,52 @@ +""" + Video abuse report reasons resource implementation. +""" +from typing import Optional, Union + +from pyyoutube.resources.base_resource import Resource +from pyyoutube.models import VideoAbuseReportReasonListResponse +from pyyoutube.utils.params_checker import enf_parts + + +class VideoAbuseReportReasonsResource(Resource): + """A videoAbuseReportReason resource contains information about a reason that a video would be flagged + for containing abusive content. + + References: https://developers.google.com/youtube/v3/docs/videoAbuseReportReasons + """ + + def list( + self, + parts: Optional[Union[str, list, tuple, set]] = None, + hl: Optional[str] = None, + return_json: bool = False, + **kwargs: Optional[dict], + ) -> Union[dict, VideoAbuseReportReasonListResponse]: + """Retrieve a list of reasons that can be used to report abusive videos. + + Args: + parts: + Comma-separated list of one or more channel resource properties. + Accepted values: id,snippet + hl: + Specifies the language that should be used for text values in the API response. + The default value is en_US. + return_json: + Type for returned data. If you set True JSON data will be returned. + **kwargs: + Additional parameters for system parameters. + Refer: https://cloud.google.com/apis/docs/system-parameters. + + Returns: + reasons data. + """ + params = { + "part": enf_parts(resource="videoAbuseReportReasons", value=parts), + "hl": hl, + **kwargs, + } + response = self._client.request(path="videoAbuseReportReasons", params=params) + data = self._client.parse_response(response=response) + return ( + data if return_json else VideoAbuseReportReasonListResponse.from_dict(data) + ) From 3fc14584db10aaab69b8e5168d1c4812860f5f7d Mon Sep 17 00:00:00 2001 From: ikaroskun Date: Mon, 21 Nov 2022 16:57:48 +0800 Subject: [PATCH 056/141] test(videoAbuseReportReasons): :white_check_mark: update tests for videoAbuseReportReasons --- .../test_video_abuse_report_reasons.py | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 tests/clients/test_video_abuse_report_reasons.py diff --git a/tests/clients/test_video_abuse_report_reasons.py b/tests/clients/test_video_abuse_report_reasons.py new file mode 100644 index 00000000..6758ffd2 --- /dev/null +++ b/tests/clients/test_video_abuse_report_reasons.py @@ -0,0 +1,20 @@ +import responses + +from .base import BaseTestCase + + +class TestVideoAbuseReportReasonsResource(BaseTestCase): + RESOURCE = "videoAbuseReportReasons" + + def test_list(self, helpers, authed_cli): + with responses.RequestsMock() as m: + m.add( + method="GET", + url=self.url, + json=self.load_json("abuse_reasons/abuse_reason.json", helpers) + ) + + res = authed_cli.videoAbuseReportReasons.list( + parts=["id", "snippet"], + ) + assert res.items[0].id == "N" From a6742fd8fef9f505a88a8c7ffa6ac1ad6f6e9368 Mon Sep 17 00:00:00 2001 From: ikaroskun Date: Mon, 21 Nov 2022 17:00:00 +0800 Subject: [PATCH 057/141] style(format): :art: format code --- tests/clients/test_video_abuse_report_reasons.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/clients/test_video_abuse_report_reasons.py b/tests/clients/test_video_abuse_report_reasons.py index 6758ffd2..dfe89f3d 100644 --- a/tests/clients/test_video_abuse_report_reasons.py +++ b/tests/clients/test_video_abuse_report_reasons.py @@ -11,7 +11,7 @@ def test_list(self, helpers, authed_cli): m.add( method="GET", url=self.url, - json=self.load_json("abuse_reasons/abuse_reason.json", helpers) + json=self.load_json("abuse_reasons/abuse_reason.json", helpers), ) res = authed_cli.videoAbuseReportReasons.list( From 85ab482cce5baad42c8b24d5deb83d23be3f8152 Mon Sep 17 00:00:00 2001 From: ikaroskun Date: Mon, 21 Nov 2022 17:40:03 +0800 Subject: [PATCH 058/141] feat(videoCategories): :sparkles: add api for videoCategories resource --- pyyoutube/models/__init__.py | 11 +--- pyyoutube/resources/video_categories.py | 72 +++++++++++++++++++++++++ pyyoutube/utils/constants.py | 2 +- 3 files changed, 75 insertions(+), 10 deletions(-) create mode 100644 pyyoutube/resources/video_categories.py diff --git a/pyyoutube/models/__init__.py b/pyyoutube/models/__init__.py index 24cfa76b..35f41a85 100644 --- a/pyyoutube/models/__init__.py +++ b/pyyoutube/models/__init__.py @@ -1,10 +1,7 @@ from .activity import * # noqa from .auth import AccessToken, UserProfile from .caption import * # noqa -from .category import ( - VideoCategory, - VideoCategoryListResponse, -) +from .category import * # noqa from .channel import * # noqa from .channel_banner import * # noqa from .channel_section import * # noqa @@ -17,6 +14,7 @@ from .playlist import * # noqa from .search_result import * # noqa from .subscription import * # noqa +from .video_abuse_report_reason import * # noqa from .video import ( Video, VideoContentDetails, @@ -26,8 +24,3 @@ VideoStatus, VideoTopicDetails, ) - -from .video_abuse_report_reason import ( - VideoAbuseReportReason, - VideoAbuseReportReasonListResponse, -) diff --git a/pyyoutube/resources/video_categories.py b/pyyoutube/resources/video_categories.py new file mode 100644 index 00000000..8493f1e5 --- /dev/null +++ b/pyyoutube/resources/video_categories.py @@ -0,0 +1,72 @@ +""" + Video categories resource implementation. +""" + +from typing import Optional, Union + +from pyyoutube.error import PyYouTubeException, ErrorMessage, ErrorCode +from pyyoutube.resources.base_resource import Resource +from pyyoutube.models import VideoCategoryListResponse +from pyyoutube.utils.params_checker import enf_comma_separated, enf_parts + + +class VideoCategoriesResource(Resource): + """A videoCategory resource identifies a category that has been or could be associated with uploaded videos. + + References: https://developers.google.com/youtube/v3/docs/videoCategories + """ + + def list( + self, + parts: Optional[Union[str, list, tuple, set]] = None, + category_id: Optional[Union[str, list, tuple, set]] = None, + region_code: Optional[str] = None, + hl: Optional[str] = None, + return_json: bool = False, + **kwargs: Optional[dict], + ) -> Union[dict, VideoCategoryListResponse]: + """Returns a list of categories that can be associated with YouTube videos. + + Args: + parts: + Comma-separated list of one or more video category resource properties. + Accepted values: snippet + category_id: + Specifies a comma-separated list of video category IDs for the resources that you are retrieving. + region_code: + Instructs the API to return the list of video categories available in the specified country. + The parameter value is an ISO 3166-1 alpha-2 country code. + hl: + Specifies the language that should be used for text values in the API response. + The default value is en_US. + return_json: + Type for returned data. If you set True JSON data will be returned. + **kwargs: + Additional parameters for system parameters. + Refer: https://cloud.google.com/apis/docs/system-parameters. + + Returns: + Video category data. + Raises: + PyYouTubeException: Missing filter parameter. + """ + params = { + "part": enf_parts(resource="videoCategories", value=parts), + "hl": hl, + **kwargs, + } + + if category_id is not None: + params["id"] = enf_comma_separated(field="category_id", value=category_id) + elif region_code is not None: + params["regionCode"] = region_code + else: + raise PyYouTubeException( + ErrorMessage( + status_code=ErrorCode.MISSING_PARAMS, + message=f"Specify at least one of category_id or region_code", + ) + ) + response = self._client.request(path="videoCategories", params=params) + data = self._client.parse_response(response=response) + return data if return_json else VideoCategoryListResponse.from_dict(data) diff --git a/pyyoutube/utils/constants.py b/pyyoutube/utils/constants.py index 7dabb603..e91532f4 100644 --- a/pyyoutube/utils/constants.py +++ b/pyyoutube/utils/constants.py @@ -53,7 +53,7 @@ VIDEO_ABUSE_REPORT_REASON_PROPERTIES = {"id", "snippet"} -VIDEO_CATEGORY_RESOURCE_PROPERTIES = {"id", "snippet"} +VIDEO_CATEGORY_RESOURCE_PROPERTIES = {"snippet"} VIDEO_RESOURCE_PROPERTIES = { "id", From 21e1287dafc0f0c82a3af27ae6817c587757c51c Mon Sep 17 00:00:00 2001 From: ikaroskun Date: Mon, 21 Nov 2022 21:03:36 +0800 Subject: [PATCH 059/141] test(videoCategories): :white_check_mark: update tests for videoCategories --- pyyoutube/client.py | 1 + pyyoutube/resources/__init__.py | 1 + tests/apis/test_categories.py | 6 ++-- tests/clients/test_video_categories.py | 39 ++++++++++++++++++++++++++ 4 files changed, 44 insertions(+), 3 deletions(-) create mode 100644 tests/clients/test_video_categories.py diff --git a/pyyoutube/client.py b/pyyoutube/client.py index 41c76b34..ef1a6706 100644 --- a/pyyoutube/client.py +++ b/pyyoutube/client.py @@ -52,6 +52,7 @@ class Client: search = resources.SearchResource() subscriptions = resources.SubscriptionsResource() videoAbuseReportReasons = resources.VideoAbuseReportReasonsResource() + videoCategories = resources.VideoCategoriesResource() def __new__(cls, *args, **kwargs): self = super().__new__(cls) diff --git a/pyyoutube/resources/__init__.py b/pyyoutube/resources/__init__.py index a03fe5c5..1d629c0d 100644 --- a/pyyoutube/resources/__init__.py +++ b/pyyoutube/resources/__init__.py @@ -12,3 +12,4 @@ from .search import SearchResource # noqa from .subscriptions import SubscriptionsResource # noqa from .video_abuse_report_reasons import VideoAbuseReportReasonsResource # noqa +from .video_categories import VideoCategoriesResource # noqa diff --git a/tests/apis/test_categories.py b/tests/apis/test_categories.py index e883145d..47993622 100644 --- a/tests/apis/test_categories.py +++ b/tests/apis/test_categories.py @@ -35,7 +35,7 @@ def testGetVideoCategories(self) -> None: res_by_single = self.api.get_video_categories( category_id="17", - parts=["id", "snippet"], + parts=["snippet"], return_json=True, ) self.assertEqual(res_by_single["kind"], "youtube#videoCategoryListResponse") @@ -44,14 +44,14 @@ def testGetVideoCategories(self) -> None: res_by_multi = self.api.get_video_categories( category_id=["17", "18"], - parts="id,snippet", + parts="snippet", ) self.assertEqual(len(res_by_multi.items), 2) self.assertEqual(res_by_multi.items[1].id, "18") res_by_region = self.api.get_video_categories( region_code="US", - parts="id,snippet", + parts="snippet", ) self.assertEqual(len(res_by_region.items), 32) self.assertEqual(res_by_region.items[0].id, "1") diff --git a/tests/clients/test_video_categories.py b/tests/clients/test_video_categories.py new file mode 100644 index 00000000..220447a4 --- /dev/null +++ b/tests/clients/test_video_categories.py @@ -0,0 +1,39 @@ +import pytest +import responses + +from .base import BaseTestCase +from pyyoutube.error import PyYouTubeException + + +class TestVideoCategoriesResource(BaseTestCase): + RESOURCE = "videoCategories" + + def test_list(self, helpers, key_cli): + with pytest.raises(PyYouTubeException): + key_cli.videoCategories.list() + + with responses.RequestsMock() as m: + m.add( + method="GET", + url=self.url, + json=self.load_json( + "categories/video_category_by_region.json", helpers + ), + ) + res = key_cli.videoCategories.list( + parts=["snippet"], + region_code="US", + ) + assert res.items[0].snippet.title == "Film & Animation" + + with responses.RequestsMock() as m: + m.add( + method="GET", + url=self.url, + json=self.load_json("categories/video_category_multi.json", helpers), + ) + res = key_cli.videoCategories.list( + parts=["snippet"], + category_id=["17", "18"], + ) + assert len(res.items) == 2 From 0bb825ddc4a5b4e6443d50204172504464acefd9 Mon Sep 17 00:00:00 2001 From: ikaros Date: Mon, 21 Nov 2022 22:51:28 +0800 Subject: [PATCH 060/141] WIP(videos): :construction: add api for videos resource --- pyyoutube/resources/videos.py | 166 ++++++++++++++++++++++++++++++++++ 1 file changed, 166 insertions(+) create mode 100644 pyyoutube/resources/videos.py diff --git a/pyyoutube/resources/videos.py b/pyyoutube/resources/videos.py new file mode 100644 index 00000000..cf92fefe --- /dev/null +++ b/pyyoutube/resources/videos.py @@ -0,0 +1,166 @@ +""" + Videos resource implementation. +""" + +from typing import Optional, Union + +from pyyoutube.error import PyYouTubeException, ErrorCode, ErrorMessage +from pyyoutube.resources.base_resource import Resource +from pyyoutube.models import Video, VideoListResponse +from pyyoutube.utils.params_checker import enf_comma_separated, enf_parts + + +class VideosResource(Resource): + """A video resource represents a YouTube video. + + References: https://developers.google.com/youtube/v3/docs/videos + """ + + def list( + self, + parts: Optional[Union[str, list, tuple, set]] = None, + chart: Optional[str] = None, + video_id: Optional[Union[str, list, tuple, set]] = None, + my_rating: Optional[str] = None, + hl: Optional[str] = None, + max_height: Optional[int] = None, + max_results: Optional[int] = None, + max_width: Optional[int] = None, + on_behalf_of_content_owner: Optional[str] = None, + page_token: Optional[str] = None, + region_code: Optional[str] = None, + video_category_id: Optional[str] = None, + return_json: bool = False, + **kwargs: Optional[dict], + ) -> Union[dict, VideoListResponse]: + """Returns a list of videos that match the API request parameters. + + Args: + parts: + Comma-separated list of one or more channel resource properties. + Accepted values: id,contentDetails,fileDetails,liveStreamingDetails, + localizations,player,processingDetails,recordingDetails,snippet,statistics, + status,suggestions,topicDetails + chart: + Identifies the chart that you want to retrieve. + Acceptable values are: + - mostPopular: Return the most popular videos for the specified content region and video category. + video_id: + Specifies a comma-separated list of the YouTube video ID(s) for the resource(s) that are being retrieved. + my_rating: + Set this parameter's value to like or dislike to instruct the API to only return videos liked + or disliked by the authenticated user. + Acceptable values are: + - dislike: Returns only videos disliked by the authenticated user. + - like: Returns only video liked by the authenticated user. + hl: + Instructs the API to retrieve localized resource metadata for a specific application language + that the YouTube website supports. + max_height: + Specifies the maximum height of the embedded player returned the player.embedHtml property. + max_results: + Specifies the maximum number of items that should be returned the result set. + max_width: + Specifies the maximum width of the embedded player returned the player.embedHtml property. + on_behalf_of_content_owner: + The onBehalfOfContentOwner parameter indicates that the request's authorization + credentials identify a YouTube CMS user who is acting on behalf of the content + owner specified in the parameter value. This parameter is intended for YouTube + content partners that own and manage many difference YouTube channels. It allows + content owners to authenticate once and get access to all their video and channel + data, without having to provide authentication credentials for each individual channel. + The CMS account that the user authenticates with must be linked to the specified YouTube content owner. + page_token: + The parameter identifies a specific page in the result set that should be returned. + region_code: + Instructs the API to select a video chart available in the specified region. + video_category_id: + Identifies the video category for which the chart should be retrieved. + return_json: + Type for returned data. If you set True JSON data will be returned. + **kwargs: + Additional parameters for system parameters. + Refer: https://cloud.google.com/apis/docs/system-parameters. + + Returns: + Videos data. + Raises: + PyYouTubeException: Missing filter parameter. + """ + params = { + "part": enf_parts(resource="videos", value=parts), + "hl": hl, + "maxHeight": max_height, + "maxResults": max_results, + "maxWidth": max_width, + "onBehalfOfContentOwner": on_behalf_of_content_owner, + "pageToken": page_token, + "regionCode": region_code, + "videoCategoryId": video_category_id, + **kwargs, + } + if chart is not None: + params["chart"] = chart + elif video_id is not None: + params["id"] = enf_comma_separated(field="video_id", value=video_id) + elif my_rating is not None: + params["myRating"] = my_rating + else: + raise PyYouTubeException( + ErrorMessage( + status_code=ErrorCode.MISSING_PARAMS, + message=f"Specify at least one of for_username,channel_id,managedByMe or mine", + ) + ) + response = self._client.request(path="videos", params=params) + data = self._client.parse_response(response=response) + return data if return_json else VideoListResponse.from_dict(data) + + def update( + self, + body: Union[dict, Video], + parts: Optional[Union[str, list, tuple, set]] = None, + on_behalf_of_content_owner: Optional[str] = None, + return_json: bool = False, + **kwargs: Optional[dict], + ) -> Union[dict, Video]: + """Updates a video's metadata. + + Args: + body: + Provide video data in the request body. You can give dataclass or just a dict with data. + parts: + Comma-separated list of one or more channel resource properties. + Accepted values: id,contentDetails,fileDetails,liveStreamingDetails, + localizations,player,processingDetails,recordingDetails,snippet,statistics, + status,suggestions,topicDetails + on_behalf_of_content_owner: + The onBehalfOfContentOwner parameter indicates that the request's authorization + credentials identify a YouTube CMS user who is acting on behalf of the content + owner specified in the parameter value. This parameter is intended for YouTube + content partners that own and manage many different YouTube channels. It allows + content owners to authenticate once and get access to all their video and channel + data, without having to provide authentication credentials for each individual channel. + The CMS account that the user authenticates with must be linked to the specified YouTube content owner. + return_json: + Type for returned data. If you set True JSON data will be returned. + **kwargs: + Additional parameters for system parameters. + Refer: https://cloud.google.com/apis/docs/system-parameters. + + Returns: + Video updated data. + """ + params = { + "part": enf_parts(resource="videos", value=parts), + "onBehalfOfContentOwner": on_behalf_of_content_owner, + **kwargs, + } + response = self._client.request( + method="PUT", + path="videos", + params=params, + json=body, + ) + data = self._client.parse_response(response=response) + return data if return_json else Video.from_dict(data) From 38b5b60731226fe69b578d8f45482b91c2cd9d65 Mon Sep 17 00:00:00 2001 From: ikaros Date: Wed, 23 Nov 2022 14:12:27 +0800 Subject: [PATCH 061/141] feat(videos): :sparkles: add api for videos get --- pyyoutube/models/__init__.py | 10 +- pyyoutube/models/video.py | 34 +++++++ pyyoutube/resources/videos.py | 166 +++++++++++++++++++++++++++++++++- 3 files changed, 199 insertions(+), 11 deletions(-) diff --git a/pyyoutube/models/__init__.py b/pyyoutube/models/__init__.py index 35f41a85..31736df6 100644 --- a/pyyoutube/models/__init__.py +++ b/pyyoutube/models/__init__.py @@ -15,12 +15,4 @@ from .search_result import * # noqa from .subscription import * # noqa from .video_abuse_report_reason import * # noqa -from .video import ( - Video, - VideoContentDetails, - VideoListResponse, - VideoSnippet, - VideoStatistics, - VideoStatus, - VideoTopicDetails, -) +from .video import * # noqa diff --git a/pyyoutube/models/video.py b/pyyoutube/models/video.py index 6c433b7c..ba39b7b6 100644 --- a/pyyoutube/models/video.py +++ b/pyyoutube/models/video.py @@ -264,3 +264,37 @@ class VideoListResponse(BaseApiResponse): """ items: Optional[List[Video]] = field(default=None, repr=False) + + +@dataclass +class VideoReportAbuse(BaseModel): + """ + A class representing the video report abuse body. + """ + + videoId: Optional[str] = field(default=None) + reasonId: Optional[str] = field(default=None) + secondaryReasonId: Optional[str] = field(default=None) + comments: Optional[str] = field(default=None) + language: Optional[str] = field(default=None) + + +@dataclass +class VideoRatingItem(BaseModel): + """ + A class representing the video rating item info. + """ + + videoId: Optional[str] = field(default=None) + rating: Optional[str] = field(default=None) + + +@dataclass +class VideoGetRatingResponse(BaseApiResponse): + """ + A class representing the video rating response. + + References: https://developers.google.com/youtube/v3/docs/videos/getRating#properties + """ + + items: Optional[List[VideoRatingItem]] = field(default=None, repr=False) diff --git a/pyyoutube/resources/videos.py b/pyyoutube/resources/videos.py index cf92fefe..9496ec7b 100644 --- a/pyyoutube/resources/videos.py +++ b/pyyoutube/resources/videos.py @@ -6,7 +6,12 @@ from pyyoutube.error import PyYouTubeException, ErrorCode, ErrorMessage from pyyoutube.resources.base_resource import Resource -from pyyoutube.models import Video, VideoListResponse +from pyyoutube.models import ( + Video, + VideoListResponse, + VideoGetRatingResponse, + VideoReportAbuse, +) from pyyoutube.utils.params_checker import enf_comma_separated, enf_parts @@ -138,7 +143,7 @@ def update( The onBehalfOfContentOwner parameter indicates that the request's authorization credentials identify a YouTube CMS user who is acting on behalf of the content owner specified in the parameter value. This parameter is intended for YouTube - content partners that own and manage many different YouTube channels. It allows + content partners that own and manage many difference YouTube channels. It allows content owners to authenticate once and get access to all their video and channel data, without having to provide authentication credentials for each individual channel. The CMS account that the user authenticates with must be linked to the specified YouTube content owner. @@ -164,3 +169,160 @@ def update( ) data = self._client.parse_response(response=response) return data if return_json else Video.from_dict(data) + + def rate( + self, + video_id: str, + rating: Optional[str] = None, + **kwargs: Optional[dict], + ) -> bool: + """Add a like or dislike rating to a video or remove a rating from a video. + + Args: + video_id: + Specifies the YouTube video ID of the video that is being rated or having its rating removed. + rating: + Specifies the rating to record. + Acceptable values are: + - dislike: Records that the authenticated user disliked the video. + - like: Records that the authenticated user liked the video. + - none: Removes any rating that the authenticated user had previously set for the video. + **kwargs: + Additional parameters for system parameters. + Refer: https://cloud.google.com/apis/docs/system-parameters. + + Returns: + Video rating status + """ + params = { + "id": video_id, + "rating": rating, + **kwargs, + } + response = self._client.request( + method="PUT", + path="videos/rate", + params=params, + ) + if response.ok: + return True + self._client.parse_response(response=response) + + def get_rating( + self, + video_id: Optional[Union[str, list, tuple, set]], + on_behalf_of_content_owner: Optional[str] = None, + return_json: bool = False, + **kwargs: Optional[dict], + ) -> Union[dict, VideoGetRatingResponse]: + """Retrieves the ratings that the authorized user gave to a list of specified videos. + + Args: + video_id: + Specifies a comma-separated list of the YouTube video ID(s). + on_behalf_of_content_owner: + The onBehalfOfContentOwner parameter indicates that the request's authorization + credentials identify a YouTube CMS user who is acting on behalf of the content + owner specified in the parameter value. This parameter is intended for YouTube + content partners that own and manage many different YouTube channels. It allows + content owners to authenticate once and get access to all their video and channel + data, without having to provide authentication credentials for each individual channel. + The CMS account that the user authenticates with must be linked to the specified YouTube content owner. + return_json: + Type for returned data. If you set True JSON data will be returned. + **kwargs: + Additional parameters for system parameters. + Refer: https://cloud.google.com/apis/docs/system-parameters. + + Returns: + Video rating data. + """ + + params = { + "id": enf_comma_separated(field="video_id", value=video_id), + "onBehalfOfContentOwner": on_behalf_of_content_owner, + **kwargs, + } + response = self._client.request(path="videos/getRating", params=params) + data = self._client.parse_response(response=response) + return data if return_json else VideoGetRatingResponse.from_dict(data) + + def report_abuse( + self, + body: Optional[dict, VideoReportAbuse], + on_behalf_of_content_owner: Optional[str] = None, + **kwargs: Optional[dict], + ) -> bool: + """Reports a video for containing abusive content. + + Args: + body: + Provide report abuse data in the request body. You can give dataclass or just a dict with data. + on_behalf_of_content_owner: + The onBehalfOfContentOwner parameter indicates that the request's authorization + credentials identify a YouTube CMS user who is acting on behalf of the content + owner specified in the parameter value. This parameter is intended for YouTube + content partners that own and manage many different YouTube channels. It allows + content owners to authenticate once and get access to all their video and channel + data, without having to provide authentication credentials for each individual channel. + The CMS account that the user authenticates with must be linked to the specified YouTube content owner. + **kwargs: + Additional parameters for system parameters. + Refer: https://cloud.google.com/apis/docs/system-parameters. + + Returns: + report status. + """ + params = { + "onBehalfOfContentOwner": on_behalf_of_content_owner, + **kwargs, + } + response = self._client.request( + method="POST", + path="videos/reportAbuse", + params=params, + json=body, + ) + if response.ok: + return True + self._client.parse_response(response=response) + + def delete( + self, + video_id: str, + on_behalf_of_content_owner: Optional[str] = None, + **kwargs: Optional[dict], + ) -> bool: + """Deletes a YouTube video. + + Args: + video_id: + Specifies the YouTube video ID for the resource that is being deleted. + on_behalf_of_content_owner: + The onBehalfOfContentOwner parameter indicates that the request's authorization + credentials identify a YouTube CMS user who is acting on behalf of the content + owner specified in the parameter value. This parameter is intended for YouTube + content partners that own and manage many different YouTube channels. It allows + content owners to authenticate once and get access to all their video and channel + data, without having to provide authentication credentials for each individual channel. + The CMS account that the user authenticates with must be linked to the specified YouTube content owner. + **kwargs: + Additional parameters for system parameters. + Refer: https://cloud.google.com/apis/docs/system-parameters. + + Returns: + video delete status. + """ + params = { + "id": video_id, + "onBehalfOfContentOwner": on_behalf_of_content_owner, + **kwargs, + } + response = self._client.request( + method="DELETE", + path="videos", + params=params, + ) + if response.ok: + return True + self._client.parse_response(response=response) From 5e1d25233a09ff26aedcf610a5a5d0774130f7ef Mon Sep 17 00:00:00 2001 From: ikaros Date: Mon, 12 Dec 2022 15:36:22 +0800 Subject: [PATCH 062/141] WIP(videos): :construction: add simple upload video --- pyyoutube/client.py | 8 +- pyyoutube/media.py | 193 ++++++++++++++++++++++++++++++++ pyyoutube/resources/__init__.py | 1 + pyyoutube/resources/videos.py | 70 +++++++++++- 4 files changed, 270 insertions(+), 2 deletions(-) create mode 100644 pyyoutube/media.py diff --git a/pyyoutube/client.py b/pyyoutube/client.py index ef1a6706..4d4cc95d 100644 --- a/pyyoutube/client.py +++ b/pyyoutube/client.py @@ -27,6 +27,7 @@ class Client: """Client for YouTube resource""" BASE_URL = "https://www.googleapis.com/youtube/v3/" + BASE_UPLOAD_URL = "https://www.googleapis.com/upload/youtube/v3/" AUTHORIZATION_URL = "https://accounts.google.com/o/oauth2/v2/auth" EXCHANGE_ACCESS_TOKEN_URL = "https://oauth2.googleapis.com/token" REVOKE_TOKEN_URL = "https://oauth2.googleapis.com/revoke" @@ -53,6 +54,7 @@ class Client: subscriptions = resources.SubscriptionsResource() videoAbuseReportReasons = resources.VideoAbuseReportReasonsResource() videoCategories = resources.VideoCategoriesResource() + videos = resources.VideosResource() def __new__(cls, *args, **kwargs): self = super().__new__(cls) @@ -157,6 +159,7 @@ def request( data: Optional[dict] = None, json: Optional[dict] = None, enforce_auth: bool = True, + is_upload: bool = False, **kwargs, ): """Send request to YouTube. @@ -174,6 +177,8 @@ def request( Object json to send in the body of the request. enforce_auth: Whether to use user credentials. + is_upload: + Whether it is an upload job. kwargs: Additional parameters for request. @@ -185,7 +190,8 @@ def request( Request http error. """ if not path.startswith("http"): - path = self.BASE_URL + path + base_url = self.BASE_UPLOAD_URL if is_upload else self.BASE_URL + path = base_url + path # Add credentials to request if enforce_auth: diff --git a/pyyoutube/media.py b/pyyoutube/media.py new file mode 100644 index 00000000..f5b525f5 --- /dev/null +++ b/pyyoutube/media.py @@ -0,0 +1,193 @@ +""" + Media object to upload. +""" +import mimetypes +import os +from typing import Optional, IO + +from requests import Response + +from pyyoutube.error import PyYouTubeException, ErrorMessage, ErrorCode + +DEFAULT_CHUNK_SIZE = 100 * 1024 * 1024 + + +class Media: + def __init__( + self, + fd: Optional[IO] = None, + mimetype: Optional[str] = None, + filename: Optional[str] = None, + chunk_size: int = DEFAULT_CHUNK_SIZE, + ) -> None: + """Media representing a file to upload with metadata. + + Args: + fd: + The source of the bytes to upload. + mimetype: + Mime-type of the file. + filename: + Name of the file. + At least one of the `fd` or `filename`. + chunk_size: + File will be uploaded in chunks of this many bytes. Only + used if resumable=True. + """ + + if fd is not None: + self.fd = fd + elif filename is not None: + self._filename = filename + self.fd = open(self._filename, "rb") + else: + raise PyYouTubeException( + ErrorMessage( + status_code=ErrorCode.MISSING_PARAMS, + message="Specify at least one of fd or filename", + ) + ) + + if mimetype is None and filename is not None: + mimetype, _ = mimetypes.guess_type(filename) + if mimetype is None: + # Guess failed, use octet-stream. + mimetype = "application/octet-stream" + self.mimetype = mimetype + self.chunk_size = chunk_size + + self.fd.seek(0, os.SEEK_END) + self.size = self.fd.tell() + + def get_bytes(self, begin: int, length: int) -> bytes: + """Get bytes from the media. + + Args: + begin: + Offset from beginning of file. + length: + Number of bytes to read, starting at begin. + + Returns: + A string of bytes read. May be shorted than length if EOF was reached + first. + """ + self.fd.seek(begin) + return self.fd.read(length) + + @classmethod + def new_from_json(cls, data: dict): + return Media(**data) + + +class MediaUpload: + def __init__( + self, + client, + media: Media, + params: Optional[dict] = None, + body: Optional[dict] = None, + ): + """ + Instance to upload a file. + Args: + client: Client instance. + media: Media instance. + params: Parameters for the request. + body: Body for the request. + """ + self.client = client + self.media = media + self.params = params + self.body = body + + self.resumable_uri = None # Real uri to upload media. + self.resumable_progress = 0 # The bytes that have been uploaded. + + def next_chunk(self): + if self.media.size is None: + size = "*" + else: + size = str(self.media.size) + + # 1231244 + if self.resumable_uri is None: + start_headers = { + "X-Upload-Content-Type": self.media.mimetype, + "X-Upload-Content-Length": size, + "content-length": len(self.body or ""), + } + resp = self.client.request( + method="POST", + path="videos", + params=self.params, + json=self.body, + is_upload=True, + headers=start_headers, + ) + if resp.status == 200 and "location" in resp.headers: + self.resumable_uri = resp.headers["location"] + else: + raise PyYouTubeException(resp) + + data = self.media.get_bytes(self.resumable_progress, self.media.chunk_size) + + # A short read implies that we are at EOF, so finish the upload. + if len(data) < self.media.chunk_size: + size = str(self.resumable_progress + len(data)) + + chunk_end = self.resumable_progress + len(data) - 1 + + headers = { + "Content-Length": str(chunk_end - self.resumable_progress + 1), + } + if chunk_end != -1: + headers[ + "Content-Range" + ] = f"bytes {self.resumable_progress}-{chunk_end}/{size}" + + resp, content = self.client.request( + path=self.resumable_uri, + method="PUT", + body=data, + headers=headers, + ) + return self.process_response(resp) + + def process_response(self, resp: Response): + """ + Args: + resp: Response for request. + + Returns: + (UploadProgress, response body) + """ + if resp.ok: + return None, self.client.parse_response(response=resp) + elif resp.status_code == 308: + try: + self.resumable_progress = int(resp.headers["range"].split("-")[1]) + 1 + except KeyError: + # If resp doesn't contain range header, resumable progress is 0 + self.resumable_progress = 0 + if "location" in resp.headers: + self.resumable_uri = resp.headers["location"] + else: + raise PyYouTubeException(resp) + + return ( + MediaUploadProgress(self.resumable_progress, self.media.size), + None, + ) + + +class MediaUploadProgress: + def __init__(self, progressed_seize, total_size): + self.progressed_seize = progressed_seize + self.total_size = total_size + + def progress(self): + if self.total_size is not None and self.total_size != 0: + return float(self.progressed_seize) / float(self.total_size) + else: + return 0.0 diff --git a/pyyoutube/resources/__init__.py b/pyyoutube/resources/__init__.py index 1d629c0d..9fb0a45d 100644 --- a/pyyoutube/resources/__init__.py +++ b/pyyoutube/resources/__init__.py @@ -13,3 +13,4 @@ from .subscriptions import SubscriptionsResource # noqa from .video_abuse_report_reasons import VideoAbuseReportReasonsResource # noqa from .video_categories import VideoCategoriesResource # noqa +from .videos import VideosResource # noqa diff --git a/pyyoutube/resources/videos.py b/pyyoutube/resources/videos.py index 9496ec7b..4aec32ad 100644 --- a/pyyoutube/resources/videos.py +++ b/pyyoutube/resources/videos.py @@ -6,6 +6,7 @@ from pyyoutube.error import PyYouTubeException, ErrorCode, ErrorMessage from pyyoutube.resources.base_resource import Resource +from pyyoutube.media import Media, MediaUpload from pyyoutube.models import ( Video, VideoListResponse, @@ -121,6 +122,73 @@ def list( data = self._client.parse_response(response=response) return data if return_json else VideoListResponse.from_dict(data) + def insert( + self, + body: Union[dict, Video], + media: Media, + parts: Optional[Union[str, list, tuple, set]] = None, + notify_subscribers: Optional[bool] = None, + on_behalf_of_content_owner: Optional[str] = None, + on_behalf_of_content_owner_channel: Optional[str] = None, + **kwargs, + ) -> MediaUpload: + """Uploads a video to YouTube and optionally sets the video's metadata. + + Args: + body: + Provide video data in the request body. You can give dataclass or just a dict with data. + media: + Media data to upload. + parts: + Comma-separated list of one or more channel resource properties. + Accepted values: id,contentDetails,fileDetails,liveStreamingDetails, + localizations,player,processingDetails,recordingDetails,snippet,statistics, + status,suggestions,topicDetails + notify_subscribers: + Indicates whether YouTube should send a notification about the new video to users who + subscribe to the video's channel + on_behalf_of_content_owner: + The onBehalfOfContentOwner parameter indicates that the request's authorization + credentials identify a YouTube CMS user who is acting on behalf of the content + owner specified in the parameter value. This parameter is intended for YouTube + content partners that own and manage many different YouTube channels. It allows + content owners to authenticate once and get access to all their video and channel + data, without having to provide authentication credentials for each individual channel. + The CMS account that the user authenticates with must be linked to the specified YouTube content owner. + on_behalf_of_content_owner_channel: + The onBehalfOfContentOwnerChannel parameter specifies the YouTube channel ID of the channel + to which a video is being added. This parameter is required when a request specifies a value + for the onBehalfOfContentOwner parameter, and it can only be used in conjunction with that + parameter. In addition, the request must be authorized using a CMS account that is linked to + the content owner that the onBehalfOfContentOwner parameter specifies. Finally, the channel + that the onBehalfOfContentOwnerChannel parameter value specifies must be linked to the content + owner that the onBehalfOfContentOwner parameter specifies. + return_json: + Type for returned data. If you set True JSON data will be returned. + **kwargs: + Additional parameters for system parameters. + Refer: https://cloud.google.com/apis/docs/system-parameters. + Returns: + Video data. + + """ + params = { + "part": enf_parts(resource="videos", value=parts), + "notifySubscribers": notify_subscribers, + "onBehalfOfContentOwner": on_behalf_of_content_owner, + "onBehalfOfContentOwnerChannel": on_behalf_of_content_owner_channel, + **kwargs, + } + + # Build a media upload instance. + media_upload = MediaUpload( + client=self._client, + media=media, + params=params, + body=body.to_dict_ignore_none(), + ) + return media_upload + def update( self, body: Union[dict, Video], @@ -249,7 +317,7 @@ def get_rating( def report_abuse( self, - body: Optional[dict, VideoReportAbuse], + body: Optional[Union[dict, VideoReportAbuse]], on_behalf_of_content_owner: Optional[str] = None, **kwargs: Optional[dict], ) -> bool: From 59d6aad75578fcde6215cf5c0e3aa4f512e34ca8 Mon Sep 17 00:00:00 2001 From: ikaros Date: Mon, 12 Dec 2022 16:03:47 +0800 Subject: [PATCH 063/141] fix(body): :bug: make content-length to str --- pyyoutube/media.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyyoutube/media.py b/pyyoutube/media.py index f5b525f5..39a5d926 100644 --- a/pyyoutube/media.py +++ b/pyyoutube/media.py @@ -115,7 +115,7 @@ def next_chunk(self): start_headers = { "X-Upload-Content-Type": self.media.mimetype, "X-Upload-Content-Length": size, - "content-length": len(self.body or ""), + "content-length": str(len(str(self.body or ""))), } resp = self.client.request( method="POST", From 194c0e4dc61af4f5d15b53ff19b8654b8b3f2d08 Mon Sep 17 00:00:00 2001 From: ikaros Date: Mon, 12 Dec 2022 16:07:43 +0800 Subject: [PATCH 064/141] fix(status): :bug: make status well --- pyyoutube/media.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyyoutube/media.py b/pyyoutube/media.py index 39a5d926..9d446598 100644 --- a/pyyoutube/media.py +++ b/pyyoutube/media.py @@ -125,7 +125,7 @@ def next_chunk(self): is_upload=True, headers=start_headers, ) - if resp.status == 200 and "location" in resp.headers: + if resp.status_code == 200 and "location" in resp.headers: self.resumable_uri = resp.headers["location"] else: raise PyYouTubeException(resp) From cf8e849f4a2bc142ff763e2f6b0c263ebf6e3a5c Mon Sep 17 00:00:00 2001 From: ikaros Date: Mon, 12 Dec 2022 16:37:35 +0800 Subject: [PATCH 065/141] fix(upload): :bug: fix upload type --- pyyoutube/media.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/pyyoutube/media.py b/pyyoutube/media.py index 9d446598..1ab92876 100644 --- a/pyyoutube/media.py +++ b/pyyoutube/media.py @@ -101,6 +101,9 @@ def __init__( self.params = params self.body = body + if self.params is not None: + self.params["uploadType"] = "resumable" + self.resumable_uri = None # Real uri to upload media. self.resumable_progress = 0 # The bytes that have been uploaded. @@ -149,7 +152,7 @@ def next_chunk(self): resp, content = self.client.request( path=self.resumable_uri, method="PUT", - body=data, + data=data, headers=headers, ) return self.process_response(resp) From 39c2035bda92fdaf3f8f9b9a3de69261d9414062 Mon Sep 17 00:00:00 2001 From: ikaros Date: Mon, 12 Dec 2022 16:39:42 +0800 Subject: [PATCH 066/141] fix(video): :bug: fix requests --- pyyoutube/media.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyyoutube/media.py b/pyyoutube/media.py index 1ab92876..2826a5d8 100644 --- a/pyyoutube/media.py +++ b/pyyoutube/media.py @@ -149,7 +149,7 @@ def next_chunk(self): "Content-Range" ] = f"bytes {self.resumable_progress}-{chunk_end}/{size}" - resp, content = self.client.request( + resp = self.client.request( path=self.resumable_uri, method="PUT", data=data, From 5f325c0928ea6707ba775ec617f49e361726bf8c Mon Sep 17 00:00:00 2001 From: ikaros Date: Mon, 12 Dec 2022 17:10:03 +0800 Subject: [PATCH 067/141] fix(status_code): :bug: response handler --- pyyoutube/media.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyyoutube/media.py b/pyyoutube/media.py index 2826a5d8..c39b2d54 100644 --- a/pyyoutube/media.py +++ b/pyyoutube/media.py @@ -165,7 +165,7 @@ def process_response(self, resp: Response): Returns: (UploadProgress, response body) """ - if resp.ok: + if resp.status_code in [200, 201]: return None, self.client.parse_response(response=resp) elif resp.status_code == 308: try: From f7ba45c5121e1450449d813a838e128f032a8efd Mon Sep 17 00:00:00 2001 From: ikaros Date: Mon, 12 Dec 2022 17:45:12 +0800 Subject: [PATCH 068/141] feat(video): :sparkles: complete for upload video media --- pyyoutube/media.py | 68 ++++++++++++++++++++++------------- pyyoutube/resources/videos.py | 28 +++++++++++++++ 2 files changed, 71 insertions(+), 25 deletions(-) diff --git a/pyyoutube/media.py b/pyyoutube/media.py index c39b2d54..9414b130 100644 --- a/pyyoutube/media.py +++ b/pyyoutube/media.py @@ -3,13 +3,13 @@ """ import mimetypes import os -from typing import Optional, IO +from typing import IO, Optional, Tuple from requests import Response from pyyoutube.error import PyYouTubeException, ErrorMessage, ErrorCode -DEFAULT_CHUNK_SIZE = 100 * 1024 * 1024 +DEFAULT_CHUNK_SIZE = 20 * 1024 * 1024 class Media: @@ -75,9 +75,32 @@ def get_bytes(self, begin: int, length: int) -> bytes: self.fd.seek(begin) return self.fd.read(length) - @classmethod - def new_from_json(cls, data: dict): - return Media(**data) + +class MediaUploadProgress: + def __init__(self, progressed_seize: int, total_size: int): + """ + Args: + progressed_seize: Bytes sent so far. + total_size: Total bytes in complete upload, or None if the total + upload size isn't known ahead of time. + """ + self.progressed_seize = progressed_seize + self.total_size = total_size + + def progress(self) -> float: + """Percent of upload completed, as a float. + + Returns: + the percentage complete as a float, returning 0.0 if the total size of + the upload is unknown. + """ + if self.total_size is not None and self.total_size != 0: + return float(self.progressed_seize) / float(self.total_size) + else: + return 0.0 + + def __repr__(self) -> str: + return f"Media upload {int(self.progress() * 100)} complete." class MediaUpload: @@ -87,9 +110,9 @@ def __init__( media: Media, params: Optional[dict] = None, body: Optional[dict] = None, - ): - """ - Instance to upload a file. + ) -> None: + """Constructor for upload a file. + Args: client: Client instance. media: Media instance. @@ -107,13 +130,17 @@ def __init__( self.resumable_uri = None # Real uri to upload media. self.resumable_progress = 0 # The bytes that have been uploaded. - def next_chunk(self): + def next_chunk(self) -> Tuple[Optional[MediaUploadProgress], Optional[dict]]: + """Execute the next step of a resumable upload. + + Returns: + The body will be None until the resumable media is fully uploaded. + """ if self.media.size is None: size = "*" else: size = str(self.media.size) - # 1231244 if self.resumable_uri is None: start_headers = { "X-Upload-Content-Type": self.media.mimetype, @@ -157,13 +184,16 @@ def next_chunk(self): ) return self.process_response(resp) - def process_response(self, resp: Response): - """ + def process_response( + self, resp: Response + ) -> Tuple[Optional[MediaUploadProgress], Optional[dict]]: + """Process the response from chunk upload. + Args: resp: Response for request. Returns: - (UploadProgress, response body) + The body will be None until the resumable media is fully uploaded. """ if resp.status_code in [200, 201]: return None, self.client.parse_response(response=resp) @@ -182,15 +212,3 @@ def process_response(self, resp: Response): MediaUploadProgress(self.resumable_progress, self.media.size), None, ) - - -class MediaUploadProgress: - def __init__(self, progressed_seize, total_size): - self.progressed_seize = progressed_seize - self.total_size = total_size - - def progress(self): - if self.total_size is not None and self.total_size != 0: - return float(self.progressed_seize) / float(self.total_size) - else: - return 0.0 diff --git a/pyyoutube/resources/videos.py b/pyyoutube/resources/videos.py index 4aec32ad..9ee1923a 100644 --- a/pyyoutube/resources/videos.py +++ b/pyyoutube/resources/videos.py @@ -134,6 +134,34 @@ def insert( ) -> MediaUpload: """Uploads a video to YouTube and optionally sets the video's metadata. + Example: + + import pyyoutube.models as mds + from pyyoutube.media import Media + + body = mds.Video( + snippet=mds.VideoSnippet( + title="video title", + description="video description" + ) + ) + + media = Media(filename="video.mp4") + + upload = client.videos.insert( + body=body, + media=media, + parts=["snippet"], + ) + + response = None + while response is None: + status, response = upload.next_chunk() + if status: + print(f"Upload {int(status.progress() * 100)} complete.") + + print(f"Response body: {response}") + Args: body: Provide video data in the request body. You can give dataclass or just a dict with data. From 927e00d026293c749918eda765546e53352625d5 Mon Sep 17 00:00:00 2001 From: ikaros Date: Tue, 13 Dec 2022 11:55:09 +0800 Subject: [PATCH 069/141] test(media): :white_check_mark: update tests for media --- pyyoutube/media.py | 8 +- testdata/apidata/videos/insert_response.json | 72 ++++++++++ tests/clients/test_media.py | 130 +++++++++++++++++++ 3 files changed, 206 insertions(+), 4 deletions(-) create mode 100644 testdata/apidata/videos/insert_response.json create mode 100644 tests/clients/test_media.py diff --git a/pyyoutube/media.py b/pyyoutube/media.py index 9414b130..582fc3f8 100644 --- a/pyyoutube/media.py +++ b/pyyoutube/media.py @@ -136,10 +136,7 @@ def next_chunk(self) -> Tuple[Optional[MediaUploadProgress], Optional[dict]]: Returns: The body will be None until the resumable media is fully uploaded. """ - if self.media.size is None: - size = "*" - else: - size = str(self.media.size) + size = str(self.media.size) if self.resumable_uri is None: start_headers = { @@ -171,6 +168,9 @@ def next_chunk(self) -> Tuple[Optional[MediaUploadProgress], Optional[dict]]: headers = { "Content-Length": str(chunk_end - self.resumable_progress + 1), } + # An empty file results in chunk_end = -1 and size = 0 + # sending "bytes 0--1/0" results in an invalid request + # Only add header "Content-Range" if chunk_end != -1 if chunk_end != -1: headers[ "Content-Range" diff --git a/testdata/apidata/videos/insert_response.json b/testdata/apidata/videos/insert_response.json new file mode 100644 index 00000000..457f094d --- /dev/null +++ b/testdata/apidata/videos/insert_response.json @@ -0,0 +1,72 @@ +{ + "kind": "youtube#video", + "etag": "\"j6xRRd8dTPVVptg711_CSPADRfg/dbCtFPFQrd6OMTnWAYrcpZDPai0\"", + "id": "D-lhorsDlUQ", + "snippet": { + "publishedAt": "2019-03-21T20:37:49.000Z", + "channelId": "UC_x5XG1OV2P6uZZ5FSM9Ttw", + "title": "What are Actions on Google (Assistant on Air)", + "description": "In the first episode of Assistant on Air, Luke Davis and guest Jessica Dene Early-Cha introduce the concept of Actions on Google, and talk about common terminology.\n\nActions on Google docs → http://bit.ly/2YabdS5\n\nOther episodes of Assistant on Air → https://goo.gle/2X0nBqG\nSubscribe to Google Devs for the latest Actions on Google videos → https://bit.ly/googledevs", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/D-lhorsDlUQ/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/D-lhorsDlUQ/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/D-lhorsDlUQ/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/D-lhorsDlUQ/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/D-lhorsDlUQ/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "Google Developers", + "tags": [ + "Google", + "developers", + "aog", + "Actions on Google", + "Assistant", + "Google Assistant", + "actions", + "google home", + "actions on google", + "google assistant developers", + "google assistant sdk", + "Actions on google developers", + "smarthome developers", + "common terminology", + "custom action on google", + "google assistant in your app", + "add google assistant", + "assistant on air", + "how to use google assistant on air", + "Actions on Google how to" + ], + "categoryId": "28", + "liveBroadcastContent": "none", + "defaultLanguage": "en", + "localized": { + "title": "What are Actions on Google (Assistant on Air)", + "description": "In the first episode of Assistant on Air, Luke Davis and guest Jessica Dene Early-Cha introduce the concept of Actions on Google, and talk about common terminology.\n\nActions on Google docs → http://bit.ly/2YabdS5\n\nOther episodes of Assistant on Air → https://goo.gle/2X0nBqG\nSubscribe to Google Devs for the latest Actions on Google videos → https://bit.ly/googledevs" + }, + "defaultAudioLanguage": "en" + }, + "player": { + "embedHtml": "\u003ciframe width=\"480\" height=\"270\" src=\"//www.youtube.com/embed/D-lhorsDlUQ\" frameborder=\"0\" allow=\"accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture\" allowfullscreen\u003e\u003c/iframe\u003e" + } +} \ No newline at end of file diff --git a/tests/clients/test_media.py b/tests/clients/test_media.py new file mode 100644 index 00000000..98063d37 --- /dev/null +++ b/tests/clients/test_media.py @@ -0,0 +1,130 @@ +""" + Tests for media upload. +""" +import io + +import pytest +import responses +from requests import Response + +from pyyoutube.error import PyYouTubeException +from pyyoutube.media import Media, MediaUpload, MediaUploadProgress + + +class TestMedia: + def test_initial(self, tmp_path): + with pytest.raises(PyYouTubeException): + Media() + + d = tmp_path / "sub" + d.mkdir() + f = d / "simple.vvv" + f.write_bytes(b"asd") + m = Media(filename=str(f)) + assert m.mimetype == "application/octet-stream" + + f1 = d / "video.mp4" + f1.write_text("video") + m = Media(fd=f1.open("rb")) + assert m.size == 5 + assert m.get_bytes(0, 2) + + +class TestMediaUploadProgress: + def test_progress(self): + pg = MediaUploadProgress(10, 20) + assert pg.progress() == 0.5 + assert str(pg) + + pg = MediaUploadProgress(10, 0) + assert pg.progress() == 0.0 + + +class TestMediaUpload: + def test_upload(self, helpers, authed_cli): + location = "https://youtube.googleapis.com/upload/youtube/v3/videos?part=snippet&alt=json&uploadType=resumable&upload_id=upload_id" + + media = Media(fd=io.StringIO("1234567890"), mimetype="video/mp4", chunk_size=5) + upload = MediaUpload( + client=authed_cli, + media=media, + params={"part": "snippet"}, + body={"body": '{"snippet": {dasd}}'}, + ) + + with responses.RequestsMock() as m: + m.add( + method="POST", + url="https://www.googleapis.com/upload/youtube/v3/videos", + status=200, + adding_headers={"location": location}, + ) + m.add( + method="PUT", + url=location, + status=308, + adding_headers={ + "range": "0-4", + }, + ) + m.add( + method="PUT", + url=location, + json=helpers.load_json("testdata/apidata/videos/insert_response.json"), + ) + + pg, body = upload.next_chunk() + assert pg.progress() == 0.5 + assert body is None + + pg, body = upload.next_chunk() + assert pg is None + assert body["id"] == "D-lhorsDlUQ" + + def test_upload_response(self, authed_cli, helpers): + location = "https://youtube.googleapis.com/upload/youtube/v3/videos?part=snippet&alt=json&uploadType=resumable&upload_id=upload_id" + media = Media( + fd=io.StringIO("1234567890"), + mimetype="video/mp4", + ) + upload = MediaUpload( + client=authed_cli, + media=media, + params={"part": "snippet"}, + body={"body": '{"snippet": {dasd}}'}, + ) + + with pytest.raises(PyYouTubeException): + with responses.RequestsMock() as m: + m.add( + method="POST", + url="https://www.googleapis.com/upload/youtube/v3/videos", + status=400, + json=helpers.load_json("testdata/error_response.json"), + ) + upload.next_chunk() + + with responses.RequestsMock() as m: + m.add( + method="PUT", + url=location, + status=308, + ) + upload.resumable_uri = location + upload.next_chunk() + + with pytest.raises(PyYouTubeException): + with responses.RequestsMock() as m: + m.add( + method="PUT", + url=location, + status=400, + json=helpers.load_json("testdata/error_response.json"), + ) + upload.resumable_uri = location + upload.next_chunk() + + resp = Response() + resp.status_code = 308 + resp.headers = {"location": location} + upload.process_response(resp=resp) From 9c53c47861cfc9ed8cef35cb58a2452e8035c094 Mon Sep 17 00:00:00 2001 From: ikaros Date: Tue, 13 Dec 2022 14:42:12 +0800 Subject: [PATCH 070/141] test(videos): :white_check_mark: update tests for videos --- pyyoutube/resources/videos.py | 4 +- .../apidata/videos/get_rating_response.json | 10 ++ tests/clients/test_videos.py | 157 ++++++++++++++++++ 3 files changed, 169 insertions(+), 2 deletions(-) create mode 100644 testdata/apidata/videos/get_rating_response.json create mode 100644 tests/clients/test_videos.py diff --git a/pyyoutube/resources/videos.py b/pyyoutube/resources/videos.py index 9ee1923a..6500ee72 100644 --- a/pyyoutube/resources/videos.py +++ b/pyyoutube/resources/videos.py @@ -296,7 +296,7 @@ def rate( **kwargs, } response = self._client.request( - method="PUT", + method="POST", path="videos/rate", params=params, ) @@ -306,7 +306,7 @@ def rate( def get_rating( self, - video_id: Optional[Union[str, list, tuple, set]], + video_id: Union[str, list, tuple, set], on_behalf_of_content_owner: Optional[str] = None, return_json: bool = False, **kwargs: Optional[dict], diff --git a/testdata/apidata/videos/get_rating_response.json b/testdata/apidata/videos/get_rating_response.json new file mode 100644 index 00000000..75fdc542 --- /dev/null +++ b/testdata/apidata/videos/get_rating_response.json @@ -0,0 +1,10 @@ +{ + "kind": "youtube#videoGetRatingResponse", + "etag": "jHmA6WPghQxwUKfIGg5LVYotT3Y", + "items": [ + { + "videoId": "D-lhorsDlUQ", + "rating": "none" + } + ] +} diff --git a/tests/clients/test_videos.py b/tests/clients/test_videos.py new file mode 100644 index 00000000..60e60f33 --- /dev/null +++ b/tests/clients/test_videos.py @@ -0,0 +1,157 @@ +import io + +import pytest +import responses + +import pyyoutube.models as mds +from .base import BaseTestCase +from pyyoutube.error import PyYouTubeException +from pyyoutube.media import Media + + +class TestVideosResource(BaseTestCase): + RESOURCE = "videos" + + def test_list(self, helpers, authed_cli, key_cli): + with pytest.raises(PyYouTubeException): + key_cli.videos.list() + + with responses.RequestsMock() as m: + m.add( + method="GET", + url=self.url, + json=self.load_json("videos/videos_info_multi.json", helpers), + ) + + res = key_cli.videos.list( + video_id=["D-lhorsDlUQ", "ovdbrdCIP7U"], parts=["snippet"] + ) + assert len(res.items) == 2 + + with responses.RequestsMock() as m: + m.add( + method="GET", + url=self.url, + json=self.load_json("videos/videos_chart_paged_1.json", helpers), + ) + + res = key_cli.videos.list(chart="mostPopular", parts=["snippet"]) + assert len(res.items) == 5 + + with responses.RequestsMock() as m: + m.add( + method="GET", + url=self.url, + json=self.load_json("videos/videos_myrating_paged_1.json", helpers), + ) + + res = key_cli.videos.list(my_rating="like", parts=["snippet"]) + assert len(res.items) == 2 + + def test_insert(self, helpers, authed_cli): + body = mds.Video( + snippet=mds.VideoSnippet( + title="video title", + description="video description", + ) + ) + media = Media(fd=io.StringIO("video content"), mimetype="video/mp4") + + upload = authed_cli.videos.insert( + body=body, + media=media, + notify_subscribers=True, + ) + assert upload.resumable_progress == 0 + + def test_update(self, helpers, authed_cli): + body = mds.Video( + snippet=mds.VideoSnippet( + title="updated video title", + ) + ) + + with responses.RequestsMock() as m: + m.add( + method="PUT", + url=self.url, + json=self.load_json("videos/insert_response.json", helpers), + ) + video = authed_cli.videos.update(body=body, parts=["snippet"]) + assert video.id == "D-lhorsDlUQ" + + def test_rate(self, helpers, authed_cli): + video_id = "D-lhorsDlUQ" + + with responses.RequestsMock() as m: + m.add(method="POST", url=f"{self.url}/rate", status=204) + + assert authed_cli.videos.rate( + video_id=video_id, + rating="like", + ) + + with pytest.raises(PyYouTubeException): + with responses.RequestsMock() as m: + m.add( + method="POST", + url=f"{self.url}/rate", + status=403, + json=self.load_json("error_permission_resp.json", helpers), + ) + authed_cli.videos.rate( + video_id=video_id, + rating="like", + ) + + def test_get_rating(self, helpers, authed_cli): + video_id = "D-lhorsDlUQ" + + with responses.RequestsMock() as m: + m.add( + method="GET", + url=f"{self.url}/getRating", + json=self.load_json("videos/get_rating_response.json", helpers), + ) + + res = authed_cli.videos.get_rating( + video_id=video_id, + ) + assert res.items[0].rating == "none" + + def test_report_abuse(self, helpers, authed_cli): + body = mds.VideoReportAbuse( + videoId="D-lhorsDlUQ", + reasonId="xxxxxx", + ) + + with responses.RequestsMock() as m: + m.add(method="POST", url=f"{self.url}/reportAbuse", status=204) + assert authed_cli.videos.report_abuse(body=body) + + with pytest.raises(PyYouTubeException): + with responses.RequestsMock() as m: + m.add( + method="POST", + url=f"{self.url}/reportAbuse", + status=403, + json=self.load_json("error_permission_resp.json", helpers), + ) + authed_cli.videos.report_abuse(body=body) + + def test_delete(self, helpers, authed_cli): + video_id = "D-lhorsDlUQ" + + with responses.RequestsMock() as m: + m.add(method="DELETE", url=self.url, status=204) + assert authed_cli.videos.delete(video_id=video_id) + + with pytest.raises(PyYouTubeException): + with responses.RequestsMock() as m: + m.add( + method="DELETE", + url=self.url, + status=403, + json=self.load_json("error_permission_resp.json", helpers), + ) + authed_cli.videos.delete(video_id=video_id) From 783cbc2751d580731b0ae48bf1b92f795a81a8cf Mon Sep 17 00:00:00 2001 From: ikaros Date: Tue, 13 Dec 2022 14:48:19 +0800 Subject: [PATCH 071/141] chore(action): :green_heart: fix python on ubuntu --- .github/workflows/test.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 8f4fed35..81cfb6f7 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -8,7 +8,7 @@ on: jobs: test: - runs-on: ubuntu-latest + runs-on: ubuntu-20.04 strategy: matrix: python-version: [3.6, 3.7, 3.8, 3.9] From 9f1106e3ef5435efd29bb4c56469083842115976 Mon Sep 17 00:00:00 2001 From: ikaros Date: Tue, 13 Dec 2022 15:35:28 +0800 Subject: [PATCH 072/141] feat(captions): :sparkles: add captions upload and update --- pyyoutube/client.py | 1 + pyyoutube/media.py | 18 +++++++--- pyyoutube/resources/__init__.py | 1 + pyyoutube/resources/captions.py | 64 ++++++++++++++++++++------------- pyyoutube/resources/videos.py | 3 +- 5 files changed, 55 insertions(+), 32 deletions(-) diff --git a/pyyoutube/client.py b/pyyoutube/client.py index 4d4cc95d..34c306f0 100644 --- a/pyyoutube/client.py +++ b/pyyoutube/client.py @@ -40,6 +40,7 @@ class Client: DEFAULT_STATE = "Python-YouTube" activities = resources.ActivitiesResource() + captions = resources.CaptionsResource() channels = resources.ChannelsResource() channelSections = resources.ChannelSectionsResource() comments = resources.CommentsResource() diff --git a/pyyoutube/media.py b/pyyoutube/media.py index 582fc3f8..e8493b8e 100644 --- a/pyyoutube/media.py +++ b/pyyoutube/media.py @@ -107,6 +107,7 @@ class MediaUpload: def __init__( self, client, + resource: str, media: Media, params: Optional[dict] = None, body: Optional[dict] = None, @@ -114,15 +115,22 @@ def __init__( """Constructor for upload a file. Args: - client: Client instance. - media: Media instance. - params: Parameters for the request. - body: Body for the request. + client: + Client instance. + resource: + Resource like videos,captions and so on. + media: + Media instance. + params: + Parameters for the request. + body: + Body for the request. """ self.client = client self.media = media self.params = params self.body = body + self.resource = resource if self.params is not None: self.params["uploadType"] = "resumable" @@ -146,7 +154,7 @@ def next_chunk(self) -> Tuple[Optional[MediaUploadProgress], Optional[dict]]: } resp = self.client.request( method="POST", - path="videos", + path=self.resource, params=self.params, json=self.body, is_upload=True, diff --git a/pyyoutube/resources/__init__.py b/pyyoutube/resources/__init__.py index 9fb0a45d..8ac7aeca 100644 --- a/pyyoutube/resources/__init__.py +++ b/pyyoutube/resources/__init__.py @@ -1,4 +1,5 @@ from .activities import ActivitiesResource # noqa +from .captions import CaptionsResource # noqa from .channels import ChannelsResource # noqa from .channel_sections import ChannelSectionsResource # noqa from .comments import CommentsResource # noqa diff --git a/pyyoutube/resources/captions.py b/pyyoutube/resources/captions.py index 694013ff..c03e46bf 100644 --- a/pyyoutube/resources/captions.py +++ b/pyyoutube/resources/captions.py @@ -6,6 +6,7 @@ from requests import Response from pyyoutube.resources.base_resource import Resource +from pyyoutube.media import Media, MediaUpload from pyyoutube.models import Caption, CaptionListResponse from pyyoutube.utils.params_checker import enf_comma_separated, enf_parts @@ -60,24 +61,25 @@ def list( data = self._client.parse_response(response=response) return data if return_json else CaptionListResponse.from_dict(data) - # TODO upload file def insert( self, - part: str, body: Union[dict, Caption], + media: Media, + parts: Optional[Union[str, list, tuple, set]] = None, on_behalf_of_content_owner: Optional[str] = None, sync: Optional[bool] = None, - return_json: bool = False, **kwargs, - ) -> Union[dict, CaptionListResponse]: + ) -> MediaUpload: """Uploads a caption track. Args: - part: - The part parameter specifies the caption resource parts that - the API response will include. Set the parameter value to snippet. body: Provide caption data in the request body. You can give dataclass or just a dict with data. + media: + Caption media data to upload. + parts: + The part parameter specifies the caption resource parts that + the API response will include. Set the parameter value to snippet. on_behalf_of_content_owner: This parameter can only be used in a properly authorized request. Note: This parameter is intended exclusively for YouTube content partners. @@ -86,8 +88,6 @@ def insert( file with the audio track of the video. If you set the value to true, YouTube will disregard any time codes that are in the uploaded caption file and generate new time codes for the captions. - return_json: - Type for returned data. If you set True JSON data will be returned. **kwargs: Additional parameters for system parameters. Refer: https://cloud.google.com/apis/docs/system-parameters. @@ -97,38 +97,41 @@ def insert( """ params = { - "part": part, + "part": enf_parts(resource="captions", value=parts), "onBehalfOfContentOwner": on_behalf_of_content_owner, "sync": sync, **kwargs, } - response = self._client.request( - method="POST", - path="", + # Build a media upload instance. + media_upload = MediaUpload( + client=self._client, + resource="captions", + media=media, params=params, - json=body, + body=body.to_dict_ignore_none(), ) - data = self._client.parse_response(response=response) - return data if return_json else CaptionListResponse.from_dict(data) + return media_upload - # TODO upload file def update( self, - part: str, body: Union[dict, Caption], + media: Optional[Media] = None, + parts: Optional[Union[str, list, tuple, set]] = None, on_behalf_of_content_owner: Optional[str] = None, sync: Optional[bool] = None, return_json: bool = False, **kwargs, - ) -> Union[dict, CaptionListResponse]: + ) -> Union[dict, CaptionListResponse, MediaUpload]: """Updates a caption track. Args: - part: - The part parameter specifies the caption resource parts that - the API response will include. Set the parameter value to snippet. body: Provide caption data in the request body. You can give dataclass or just a dict with data. + media: + New caption media. + parts: + The part parameter specifies the caption resource parts that + the API response will include. Set the parameter value to snippet. on_behalf_of_content_owner: This parameter can only be used in a properly authorized request. Note: This parameter is intended exclusively for YouTube content partners. @@ -147,19 +150,30 @@ def update( """ params = { - "part": part, + "part": enf_parts(resource="captions", value=parts), "onBehalfOfContentOwner": on_behalf_of_content_owner, "sync": sync, **kwargs, } + if media is not None: + # Build a media upload instance. + media_upload = MediaUpload( + client=self._client, + resource="captions", + media=media, + params=params, + body=body.to_dict_ignore_none(), + ) + return media_upload + response = self._client.request( method="PUT", - path="", + path="captions", params=params, json=body, ) data = self._client.parse_response(response=response) - return data if return_json else CaptionListResponse.from_dict(data) + return data if return_json else Caption.from_dict(data) def download( self, diff --git a/pyyoutube/resources/videos.py b/pyyoutube/resources/videos.py index 6500ee72..e08fc004 100644 --- a/pyyoutube/resources/videos.py +++ b/pyyoutube/resources/videos.py @@ -191,8 +191,6 @@ def insert( the content owner that the onBehalfOfContentOwner parameter specifies. Finally, the channel that the onBehalfOfContentOwnerChannel parameter value specifies must be linked to the content owner that the onBehalfOfContentOwner parameter specifies. - return_json: - Type for returned data. If you set True JSON data will be returned. **kwargs: Additional parameters for system parameters. Refer: https://cloud.google.com/apis/docs/system-parameters. @@ -211,6 +209,7 @@ def insert( # Build a media upload instance. media_upload = MediaUpload( client=self._client, + resource="videos", media=media, params=params, body=body.to_dict_ignore_none(), From a8f0c36ac27ec63914d9e746ebeb9184b71e4139 Mon Sep 17 00:00:00 2001 From: ikaros Date: Tue, 13 Dec 2022 15:42:25 +0800 Subject: [PATCH 073/141] feat(banner): :sparkles: add banner upload --- pyyoutube/client.py | 1 + pyyoutube/resources/__init__.py | 1 + pyyoutube/resources/channel_banners.py | 26 +++++++++++++++----------- 3 files changed, 17 insertions(+), 11 deletions(-) diff --git a/pyyoutube/client.py b/pyyoutube/client.py index 34c306f0..f4947eb4 100644 --- a/pyyoutube/client.py +++ b/pyyoutube/client.py @@ -42,6 +42,7 @@ class Client: activities = resources.ActivitiesResource() captions = resources.CaptionsResource() channels = resources.ChannelsResource() + channelBanners = resources.ChannelBannersResource() channelSections = resources.ChannelSectionsResource() comments = resources.CommentsResource() commentThreads = resources.CommentThreadsResource() diff --git a/pyyoutube/resources/__init__.py b/pyyoutube/resources/__init__.py index 8ac7aeca..ccd4783d 100644 --- a/pyyoutube/resources/__init__.py +++ b/pyyoutube/resources/__init__.py @@ -1,5 +1,6 @@ from .activities import ActivitiesResource # noqa from .captions import CaptionsResource # noqa +from .channel_banners import ChannelBannersResource # noqa from .channels import ChannelsResource # noqa from .channel_sections import ChannelSectionsResource # noqa from .comments import CommentsResource # noqa diff --git a/pyyoutube/resources/channel_banners.py b/pyyoutube/resources/channel_banners.py index 7d0a0afa..eb5d6dc1 100644 --- a/pyyoutube/resources/channel_banners.py +++ b/pyyoutube/resources/channel_banners.py @@ -1,10 +1,10 @@ """ Channel banners resource implementation. """ -from typing import Optional, Union +from typing import Optional from pyyoutube.resources.base_resource import Resource -from pyyoutube.models import ChannelBanner +from pyyoutube.media import Media, MediaUpload class ChannelBannersResource(Resource): @@ -14,26 +14,25 @@ class ChannelBannersResource(Resource): References: https://developers.google.com/youtube/v3/docs/channelBanners """ - # TODO upload file def insert( self, + media: Media, on_behalf_of_content_owner: Optional[str] = None, - return_json: bool = False, **kwargs: Optional[dict], - ) -> Union[dict, ChannelBanner]: + ) -> MediaUpload: """Uploads a channel banner image to YouTube. Args: + media: + Banner media data. on_behalf_of_content_owner: - The onBehalfOfContentOwner parameter indicates that the request's authorization + The onBehalfOfContentOwner parameter indicates that the request's authorization credentials identify a YouTube CMS user who is acting on behalf of the content owner specified in the parameter value. This parameter is intended for YouTube content partners that own and manage many different YouTube channels. It allows content owners to authenticate once and get access to all their video and channel data, without having to provide authentication credentials for each individual channel. The CMS account that the user authenticates with must be linked to the specified YouTube content owner. - return_json: - Type for returned data. If you set True JSON data will be returned. **kwargs: Additional parameters for system parameters. Refer: https://cloud.google.com/apis/docs/system-parameters. @@ -42,6 +41,11 @@ def insert( Channel banner data. """ params = {"onBehalfOfContentOwner": on_behalf_of_content_owner, **kwargs} - response = self._client.request(path="channelBanners", params=params) - data = self._client.parse_response(response=response) - return data if return_json else ChannelBanner.from_dict(data) + # Build a media upload instance. + media_upload = MediaUpload( + client=self._client, + resource="channelBanners/insert", + media=media, + params=params, + ) + return media_upload From 9bd471d8cd816cba712345e0848d39da4fce9032 Mon Sep 17 00:00:00 2001 From: ikaros Date: Tue, 13 Dec 2022 21:51:57 +0800 Subject: [PATCH 074/141] test(captions): :white_check_mark: update tests for captions --- pyyoutube/resources/captions.py | 4 +- .../apidata/captions/insert_response.json | 19 +++ .../apidata/captions/update_response.json | 19 +++ .../channel_banners/insert_response.json | 5 + tests/clients/test_captions.py | 112 ++++++++++++++++++ tests/clients/test_media.py | 2 + 6 files changed, 159 insertions(+), 2 deletions(-) create mode 100644 testdata/apidata/captions/insert_response.json create mode 100644 testdata/apidata/captions/update_response.json create mode 100644 testdata/apidata/channel_banners/insert_response.json create mode 100644 tests/clients/test_captions.py diff --git a/pyyoutube/resources/captions.py b/pyyoutube/resources/captions.py index c03e46bf..c1bb2569 100644 --- a/pyyoutube/resources/captions.py +++ b/pyyoutube/resources/captions.py @@ -121,7 +121,7 @@ def update( sync: Optional[bool] = None, return_json: bool = False, **kwargs, - ) -> Union[dict, CaptionListResponse, MediaUpload]: + ) -> Union[dict, Caption, MediaUpload]: """Updates a caption track. Args: @@ -251,7 +251,7 @@ def delete( **kwargs, } - response = self._client.request(path="captions", params=params) + response = self._client.request(path="captions", method="DELETE", params=params) if response.ok: return True self._client.parse_response(response=response) diff --git a/testdata/apidata/captions/insert_response.json b/testdata/apidata/captions/insert_response.json new file mode 100644 index 00000000..5af7915d --- /dev/null +++ b/testdata/apidata/captions/insert_response.json @@ -0,0 +1,19 @@ +{ + "kind": "youtube#caption", + "etag": "R7KYT4aJbHp2wxlTmtFuKJ4pmF8", + "id": "AUieDabWmL88_xoRtxyxjTMtmvdoF9dLTW3WxfJvaThUXkNptljUijDFS-kDjyA", + "snippet": { + "videoId": "zxTVeyG1600", + "lastUpdated": "2022-12-13T08:20:45.636548Z", + "trackKind": "standard", + "language": "ja", + "name": "\\u65e5\\u6587\\u5b57\\u5e55", + "audioTrackType": "unknown", + "isCC": false, + "isLarge": false, + "isEasyReader": false, + "isDraft": true, + "isAutoSynced": false, + "status": "serving" + } +} \ No newline at end of file diff --git a/testdata/apidata/captions/update_response.json b/testdata/apidata/captions/update_response.json new file mode 100644 index 00000000..a9013812 --- /dev/null +++ b/testdata/apidata/captions/update_response.json @@ -0,0 +1,19 @@ +{ + "kind": "youtube#caption", + "etag": "R7KYT4aJbHp2wxlTmtFuKJ4pmF8", + "id": "AUieDabWmL88_xoRtxyxjTMtmvdoF9dLTW3WxfJvaThUXkNptljUijDFS-kDjyA", + "snippet": { + "videoId": "zxTVeyG1600", + "lastUpdated": "2022-12-13T08:20:45.636548Z", + "trackKind": "standard", + "language": "ja", + "name": "\\u65e5\\u6587\\u5b57\\u5e55", + "audioTrackType": "unknown", + "isCC": false, + "isLarge": false, + "isEasyReader": false, + "isDraft": false, + "isAutoSynced": false, + "status": "serving" + } +} \ No newline at end of file diff --git a/testdata/apidata/channel_banners/insert_response.json b/testdata/apidata/channel_banners/insert_response.json new file mode 100644 index 00000000..38d070c3 --- /dev/null +++ b/testdata/apidata/channel_banners/insert_response.json @@ -0,0 +1,5 @@ +{ + "kind": "youtube#channelBannerResource", + "etag": "ezPZq6gkoCbM-5C4P-ved0Irol0", + "url": "https://yt3.googleusercontent.com/1mrHHBsTG4JhGAQg_dmFf3ByELNVnXu7qCvmuhC81TFemB8XpaDgYuMgh5w220bh4APAj-xDeA" +} \ No newline at end of file diff --git a/tests/clients/test_captions.py b/tests/clients/test_captions.py new file mode 100644 index 00000000..62016ec8 --- /dev/null +++ b/tests/clients/test_captions.py @@ -0,0 +1,112 @@ +""" + Tests for captions resources. +""" +import io + +import pytest +import responses + +import pyyoutube.models as mds +from .base import BaseTestCase +from pyyoutube.error import PyYouTubeException +from pyyoutube.media import Media + + +class TestCaptionsResource(BaseTestCase): + RESOURCE = "captions" + + def test_list(self, helpers, key_cli): + with responses.RequestsMock() as m: + m.add( + method="GET", + url=self.url, + json=self.load_json("captions/captions_by_video.json", helpers), + ) + + res = key_cli.captions.list(parts=["snippet"], video_id="oHR3wURdJ94") + assert res.items[0].id == "SwPOvp0r7kd9ttt_XhcHdZthMwXG7Z0I" + + def test_insert(self, helpers, authed_cli): + video_id = "zxTVeyG1600" + + body = mds.Caption( + snippet=mds.CaptionSnippet( + name="日文字幕", language="ja", videoId=video_id, isDraft=True + ) + ) + media = Media( + io.StringIO( + """ + 1 + 00:00:00,036 --> 00:00:00,703 + ジメジメした天気 + """ + ) + ) + + upload = authed_cli.captions.insert( + body=body, + media=media, + ) + assert upload.resumable_progress == 0 + + def test_update(self, helpers, authed_cli): + caption_id = "AUieDabWmL88_xoRtxyxjTMtmvdoF9dLTW3WxfJvaThUXkNptljUijDFS-kDjyA" + + new_body = mds.Caption( + id=caption_id, + snippet=mds.CaptionSnippet(videoId="zxTVeyG1600", isDraft=False), + ) + media = Media( + io.StringIO( + """ + 1 + 00:00:00,036 --> 00:00:00,703 + ジメジメした天気 + """ + ), + ) + + upload = authed_cli.captions.update( + body=new_body, + media=media, + ) + assert upload.resumable_progress == 0 + + with responses.RequestsMock() as m: + m.add( + method="PUT", + url=self.url, + json=self.load_json("captions/update_response.json", helpers), + ) + + caption = authed_cli.captions.update(body=new_body) + assert not caption.snippet.isDraft + + def test_download(self, authed_cli): + caption_id = "AUieDabWmL88_xoRtxyxjTMtmvdoF9dLTW3WxfJvaThUXkNptljUijDFS-kDjyA" + + with responses.RequestsMock() as m: + m.add( + method="GET", + url=f"{self.url}/{caption_id}", + ) + res = authed_cli.captions.download(caption_id=caption_id) + assert res.status_code == 200 + + def test_delete(self, helpers, authed_cli): + caption_id = "AUieDabWmL88_xoRtxyxjTMtmvdoF9dLTW3WxfJvaThUXkNptljUijDFS-kDjyA" + + with responses.RequestsMock() as m: + m.add(method="DELETE", url=self.url) + assert authed_cli.captions.delete(caption_id=caption_id) + + with pytest.raises(PyYouTubeException): + with responses.RequestsMock() as m: + m.add( + method="DELETE", + url=self.url, + status=403, + json=self.load_json("error_permission_resp.json", helpers), + ) + authed_cli.captions.delete(caption_id=caption_id) diff --git a/tests/clients/test_media.py b/tests/clients/test_media.py index 98063d37..98545c34 100644 --- a/tests/clients/test_media.py +++ b/tests/clients/test_media.py @@ -47,6 +47,7 @@ def test_upload(self, helpers, authed_cli): media = Media(fd=io.StringIO("1234567890"), mimetype="video/mp4", chunk_size=5) upload = MediaUpload( client=authed_cli, + resource="videos", media=media, params={"part": "snippet"}, body={"body": '{"snippet": {dasd}}'}, @@ -89,6 +90,7 @@ def test_upload_response(self, authed_cli, helpers): ) upload = MediaUpload( client=authed_cli, + resource="videos", media=media, params={"part": "snippet"}, body={"body": '{"snippet": {dasd}}'}, From 7db5d3fc6a328bb55060297eb37f1e7f112d0080 Mon Sep 17 00:00:00 2001 From: ikaros Date: Tue, 13 Dec 2022 21:57:14 +0800 Subject: [PATCH 075/141] test(channel banners): :white_check_mark: update tests for channel banners --- tests/clients/test_channel_banners.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 tests/clients/test_channel_banners.py diff --git a/tests/clients/test_channel_banners.py b/tests/clients/test_channel_banners.py new file mode 100644 index 00000000..6d6fb71e --- /dev/null +++ b/tests/clients/test_channel_banners.py @@ -0,0 +1,15 @@ +""" + Tests for channel banners +""" +import io + +from .base import BaseTestCase +from pyyoutube.media import Media + + +class TestChannelBanners(BaseTestCase): + def test_insert(self, helpers, authed_cli): + media = Media(fd=io.StringIO("jpg content"), mimetype="image/jpeg") + upload = authed_cli.channelBanners.insert(media=media) + + assert upload.resumable_progress == 0 From cd3a354b5e4fce811ead87b86e0ffd7d28c35aec Mon Sep 17 00:00:00 2001 From: ikaros Date: Tue, 13 Dec 2022 22:22:25 +0800 Subject: [PATCH 076/141] feat(thumbnails): :sparkles: add resource for thumbnails --- pyyoutube/client.py | 1 + pyyoutube/resources/__init__.py | 1 + pyyoutube/resources/thumbnails.py | 35 +++++++++++++++++++++++++++++++ tests/clients/test_thumbnails.py | 21 +++++++++++++++++++ 4 files changed, 58 insertions(+) create mode 100644 pyyoutube/resources/thumbnails.py create mode 100644 tests/clients/test_thumbnails.py diff --git a/pyyoutube/client.py b/pyyoutube/client.py index f4947eb4..d703c359 100644 --- a/pyyoutube/client.py +++ b/pyyoutube/client.py @@ -54,6 +54,7 @@ class Client: playlists = resources.PlaylistsResource() search = resources.SearchResource() subscriptions = resources.SubscriptionsResource() + thumbnails = resources.ThumbnailsResource() videoAbuseReportReasons = resources.VideoAbuseReportReasonsResource() videoCategories = resources.VideoCategoriesResource() videos = resources.VideosResource() diff --git a/pyyoutube/resources/__init__.py b/pyyoutube/resources/__init__.py index ccd4783d..86d79dd4 100644 --- a/pyyoutube/resources/__init__.py +++ b/pyyoutube/resources/__init__.py @@ -13,6 +13,7 @@ from .playlists import PlaylistsResource # noqa from .search import SearchResource # noqa from .subscriptions import SubscriptionsResource # noqa +from .thumbnails import ThumbnailsResource # noqa from .video_abuse_report_reasons import VideoAbuseReportReasonsResource # noqa from .video_categories import VideoCategoriesResource # noqa from .videos import VideosResource # noqa diff --git a/pyyoutube/resources/thumbnails.py b/pyyoutube/resources/thumbnails.py new file mode 100644 index 00000000..27641be5 --- /dev/null +++ b/pyyoutube/resources/thumbnails.py @@ -0,0 +1,35 @@ +""" + Thumbnails resources implementation. +""" +from typing import Optional + +from pyyoutube.resources.base_resource import Resource +from pyyoutube.media import Media, MediaUpload + + +class ThumbnailsResource(Resource): + """A thumbnail resource identifies different thumbnail image sizes associated with a resource. + + References: https://developers.google.com/youtube/v3/docs/thumbnails + """ + + def set( + self, + video_id: str, + media: Media, + on_behalf_of_content_owner: Optional[str] = None, + **kwargs: Optional[dict], + ) -> MediaUpload: + params = { + "videoId": video_id, + "onBehalfOfContentOwner": on_behalf_of_content_owner, + **kwargs, + } + # Build a media upload instance. + media_upload = MediaUpload( + client=self._client, + resource="thumbnails/set", + media=media, + params=params, + ) + return media_upload diff --git a/tests/clients/test_thumbnails.py b/tests/clients/test_thumbnails.py new file mode 100644 index 00000000..e5b5b1d3 --- /dev/null +++ b/tests/clients/test_thumbnails.py @@ -0,0 +1,21 @@ +""" + Tests for thumbnails. +""" +import io + +from .base import BaseTestCase +from pyyoutube.media import Media + + +class TestThumbnailsResource(BaseTestCase): + RESOURCE = "thumbnails" + + def test_set(self, authed_cli): + video_id = "zxTVeyG1600" + media = Media(fd=io.StringIO("jpeg content"), mimetype="image/jpeg") + + upload = authed_cli.thumbnails.set( + video_id=video_id, + media=media, + ) + assert upload.resumable_progress == 0 From 195e407d1522c6c1010574e10d654370eec31c66 Mon Sep 17 00:00:00 2001 From: ikaros Date: Wed, 14 Dec 2022 12:37:22 +0800 Subject: [PATCH 077/141] feat(watermarks): :sparkles: add watermarks resource --- pyyoutube/client.py | 1 + pyyoutube/models/__init__.py | 1 + pyyoutube/models/watermark.py | 36 ++++++++++++ pyyoutube/resources/__init__.py | 1 + pyyoutube/resources/watermarks.py | 97 +++++++++++++++++++++++++++++++ 5 files changed, 136 insertions(+) create mode 100644 pyyoutube/models/watermark.py create mode 100644 pyyoutube/resources/watermarks.py diff --git a/pyyoutube/client.py b/pyyoutube/client.py index d703c359..b819c788 100644 --- a/pyyoutube/client.py +++ b/pyyoutube/client.py @@ -58,6 +58,7 @@ class Client: videoAbuseReportReasons = resources.VideoAbuseReportReasonsResource() videoCategories = resources.VideoCategoriesResource() videos = resources.VideosResource() + watermarks = resources.WatermarksResource() def __new__(cls, *args, **kwargs): self = super().__new__(cls) diff --git a/pyyoutube/models/__init__.py b/pyyoutube/models/__init__.py index 31736df6..44603d8e 100644 --- a/pyyoutube/models/__init__.py +++ b/pyyoutube/models/__init__.py @@ -16,3 +16,4 @@ from .subscription import * # noqa from .video_abuse_report_reason import * # noqa from .video import * # noqa +from .watermark import * # noqa diff --git a/pyyoutube/models/watermark.py b/pyyoutube/models/watermark.py new file mode 100644 index 00000000..812fc96a --- /dev/null +++ b/pyyoutube/models/watermark.py @@ -0,0 +1,36 @@ +""" + These are watermark related models. +""" + +from dataclasses import dataclass, field +from typing import Optional + +from .base import BaseModel + + +@dataclass +class WatermarkTiming(BaseModel): + type: Optional[str] = field(default=None) + offsetMs: Optional[int] = field(default=None, repr=False) + durationMs: Optional[int] = field(default=None, repr=False) + + +@dataclass +class WatermarkPosition(BaseModel): + type: Optional[str] = field(default=None) + cornerPosition: Optional[str] = field(default=None, repr=False) + + +@dataclass +class Watermark(BaseModel): + """ + A class representing the watermark info. + + References: https://developers.google.com/youtube/v3/docs/watermarks#resource-representation + """ + + timing: Optional[WatermarkTiming] = field(default=None, repr=False) + position: Optional[WatermarkPosition] = field(default=None, repr=False) + imageUrl: Optional[str] = field(default=None) + imageBytes: Optional[bytes] = field(default=None, repr=False) + targetChannelId: Optional[str] = field(default=None, repr=False) diff --git a/pyyoutube/resources/__init__.py b/pyyoutube/resources/__init__.py index 86d79dd4..8d2eace2 100644 --- a/pyyoutube/resources/__init__.py +++ b/pyyoutube/resources/__init__.py @@ -17,3 +17,4 @@ from .video_abuse_report_reasons import VideoAbuseReportReasonsResource # noqa from .video_categories import VideoCategoriesResource # noqa from .videos import VideosResource # noqa +from .watermarks import WatermarksResource # noqa diff --git a/pyyoutube/resources/watermarks.py b/pyyoutube/resources/watermarks.py new file mode 100644 index 00000000..8ca6820c --- /dev/null +++ b/pyyoutube/resources/watermarks.py @@ -0,0 +1,97 @@ +""" + Watermarks resource implementation. +""" +from typing import Optional, Union + +from pyyoutube.resources.base_resource import Resource +from pyyoutube.media import Media, MediaUpload +from pyyoutube.models import Watermark + + +class WatermarksResource(Resource): + def set( + self, + channel_id: str, + body: Union[dict, Watermark], + media: Media, + on_behalf_of_content_owner: Optional[str] = None, + **kwargs: Optional[dict], + ) -> MediaUpload: + """ + + Args: + channel_id: + Specifies the YouTube channel ID for which the watermark is being provided. + body: + Provide watermark data in the request body. You can give dataclass or just a dict with data. + media: + Media for watermark image. + on_behalf_of_content_owner: + The onBehalfOfContentOwner parameter indicates that the request's authorization + credentials identify a YouTube CMS user who is acting on behalf of the content + owner specified in the parameter value. This parameter is intended for YouTube + content partners that own and manage many difference YouTube channels. It allows + content owners to authenticate once and get access to all their video and channel + data, without having to provide authentication credentials for each individual channel. + The CMS account that the user authenticates with must be linked to the specified YouTube content owner. + **kwargs: + Additional parameters for system parameters. + Refer: https://cloud.google.com/apis/docs/system-parameters. + Returns: + Watermark set status. + """ + params = { + "channel_id": channel_id, + "onBehalfOfContentOwner": on_behalf_of_content_owner, + **kwargs, + } + + # Build a media upload instance. + media_upload = MediaUpload( + client=self._client, + resource="watermarks/set", + media=media, + params=params, + body=body.to_dict_ignore_none(), + ) + return media_upload + + def unset( + self, + channel_id: str, + on_behalf_of_content_owner: Optional[str] = None, + **kwargs: Optional[dict], + ) -> bool: + """Deletes a channel's watermark image. + + Args: + channel_id: + Specifies the YouTube channel ID for which the watermark is being unset. + on_behalf_of_content_owner: + The onBehalfOfContentOwner parameter indicates that the request's authorization + credentials identify a YouTube CMS user who is acting on behalf of the content + owner specified in the parameter value. This parameter is intended for YouTube + content partners that own and manage many difference YouTube channels. It allows + content owners to authenticate once and get access to all their video and channel + data, without having to provide authentication credentials for each individual channel. + The CMS account that the user authenticates with must be linked to the specified YouTube content owner. + **kwargs: + Additional parameters for system parameters. + Refer: https://cloud.google.com/apis/docs/system-parameters. + + Returns: + watermark unset status. + """ + params = { + "channelId": channel_id, + "onBehalfOfContentOwner": on_behalf_of_content_owner, + **kwargs, + } + response = self._client.request( + method="POST", + path="watermarks/unset", + params=params, + ) + if response.ok: + return True + self._client.parse_response(response=response) From c908dd6e2e767205bfc7513b2b1622571b61bf28 Mon Sep 17 00:00:00 2001 From: ikaros Date: Wed, 14 Dec 2022 15:40:43 +0800 Subject: [PATCH 078/141] test(watermarks): :white_check_mark: update tests for watermarks --- tests/clients/test_watermarks.py | 53 ++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 tests/clients/test_watermarks.py diff --git a/tests/clients/test_watermarks.py b/tests/clients/test_watermarks.py new file mode 100644 index 00000000..0c023bfc --- /dev/null +++ b/tests/clients/test_watermarks.py @@ -0,0 +1,53 @@ +""" + Tests for watermarks. +""" + +import io + +import pytest +import responses + +import pyyoutube.models as mds +from .base import BaseTestCase +from pyyoutube.error import PyYouTubeException +from pyyoutube.media import Media + + +class TestWatermarksResource(BaseTestCase): + RESOURCE = "watermarks" + + def test_set(self, authed_cli): + body = mds.Watermark( + timing=mds.WatermarkTiming( + type="offsetFromStart", + offsetMs=1000, + durationMs=3000, + ), + position=mds.WatermarkPosition( + type="corner", + cornerPosition="topRight", + ), + ) + media = Media(fd=io.StringIO("image content"), mimetype="image/jpeg") + + upload = authed_cli.watermarks.set( + channel_id="id", + body=body, + media=media, + ) + assert upload.resumable_progress == 0 + + def test_unset(self, helpers, authed_cli): + with responses.RequestsMock() as m: + m.add(method="POST", url=f"{self.url}/unset", status=204) + assert authed_cli.watermarks.unset(channel_id="id") + + with pytest.raises(PyYouTubeException): + with responses.RequestsMock() as m: + m.add( + method="POST", + url=f"{self.url}/unset", + status=403, + json=self.load_json("error_permission_resp.json", helpers), + ) + assert authed_cli.watermarks.unset(channel_id="id") From a54dd8a210e533a8352255b87cb6e493b47b9bc3 Mon Sep 17 00:00:00 2001 From: ikaroskun Date: Thu, 15 Dec 2022 11:54:37 +0800 Subject: [PATCH 079/141] feat(examples): :memo: Add examples for client implementation, like get info, upload video. --- examples/README.md | 33 +++++++++++++++ examples/apis/__init__.py | 0 examples/{ => apis}/channel_videos.py | 0 ...et_all_videos_id_with_channel_by_search.py | 0 .../{ => apis}/get_subscription_with_oauth.py | 0 examples/clients/__init__.py | 0 examples/clients/channel_info.py | 22 ++++++++++ examples/clients/oauth_flow.py | 33 +++++++++++++++ examples/clients/upload_video.py | 42 +++++++++++++++++++ 9 files changed, 130 insertions(+) create mode 100644 examples/README.md create mode 100644 examples/apis/__init__.py rename examples/{ => apis}/channel_videos.py (100%) rename examples/{ => apis}/get_all_videos_id_with_channel_by_search.py (100%) rename examples/{ => apis}/get_subscription_with_oauth.py (100%) create mode 100644 examples/clients/__init__.py create mode 100644 examples/clients/channel_info.py create mode 100644 examples/clients/oauth_flow.py create mode 100644 examples/clients/upload_video.py diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 00000000..6d6f8401 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,33 @@ +# Examples + +Now we provide two entry for operate YouTube DATA API. + +- Use Api `from pyyoutube import Api`: This is an old implementation used to be compatible with older versions of code. +- Use Client `from pyyoutube import Client`: This is a new implementation for operating the API and provides additional + capabilities. + +# Basic Usage + +## API + +```python +from pyyoutube import Api + +api = Api(api_key="your key") +api.get_channel_info(channel_id="id for channel") +# ChannelListResponse(kind='youtube#channelListResponse') +``` + +You can get more examples at [this](/examples/apis/). + +## Client + +```python +from pyyoutube import Client + +cli = Client(api_key="your key") +cli.channels.list(channel_id="UC_x5XG1OV2P6uZZ5FSM9Ttw") +# ChannelListResponse(kind='youtube#channelListResponse') +``` + +You can get more examples at [this](/examples/clients/). diff --git a/examples/apis/__init__.py b/examples/apis/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/examples/channel_videos.py b/examples/apis/channel_videos.py similarity index 100% rename from examples/channel_videos.py rename to examples/apis/channel_videos.py diff --git a/examples/get_all_videos_id_with_channel_by_search.py b/examples/apis/get_all_videos_id_with_channel_by_search.py similarity index 100% rename from examples/get_all_videos_id_with_channel_by_search.py rename to examples/apis/get_all_videos_id_with_channel_by_search.py diff --git a/examples/get_subscription_with_oauth.py b/examples/apis/get_subscription_with_oauth.py similarity index 100% rename from examples/get_subscription_with_oauth.py rename to examples/apis/get_subscription_with_oauth.py diff --git a/examples/clients/__init__.py b/examples/clients/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/examples/clients/channel_info.py b/examples/clients/channel_info.py new file mode 100644 index 00000000..629fcb5e --- /dev/null +++ b/examples/clients/channel_info.py @@ -0,0 +1,22 @@ +""" + This example demonstrates how to retrieve information for a channel. +""" + +from pyyoutube import Client + +API_KEY = "Your key" # replace this with your api key. + + +def get_channel_info(): + cli = Client(api_key=API_KEY) + + channel_id = "UC_x5XG1OV2P6uZZ5FSM9Ttw" + + resp = cli.channels.list( + channel_id=channel_id, parts=["id", "snippet", "statistics"], return_json=True + ) + print(f"Channel info: {resp['items'][0]}") + + +if __name__ == "__main__": + get_channel_info() diff --git a/examples/clients/oauth_flow.py b/examples/clients/oauth_flow.py new file mode 100644 index 00000000..5fa13971 --- /dev/null +++ b/examples/clients/oauth_flow.py @@ -0,0 +1,33 @@ +""" + This example demonstrates how to perform authorization. +""" + +from pyyoutube import Client + +CLIENT_ID = "xxx" # Your app id +CLIENT_SECRET = "xxx" # Your app secret +SCOPE = [ + "https://www.googleapis.com/auth/youtube", + "https://www.googleapis.com/auth/youtube.force-ssl", + "https://www.googleapis.com/auth/userinfo.profile", +] + + +def do_authorize(): + cli = Client(client_id=CLIENT_ID, client_secret=CLIENT_SECRET) + + authorize_url, state = cli.get_authorize_url(scope=SCOPE) + print(f"Click url to do authorize: {authorize_url}") + + response_uri = input("Input youtube redirect uri:\n") + + token = cli.generate_access_token(authorization_response=response_uri, scope=SCOPE) + print(f"Your token: {token}") + + # get data + resp = cli.channels.list(mine=True) + print(f"Your channel id: {resp.items[0].id}") + + +if __name__ == "__main__": + do_authorize() diff --git a/examples/clients/upload_video.py b/examples/clients/upload_video.py new file mode 100644 index 00000000..345627fa --- /dev/null +++ b/examples/clients/upload_video.py @@ -0,0 +1,42 @@ +""" + This example demonstrates how to upload a video. +""" + +import pyyoutube.models as mds +from pyyoutube import Client +from pyyoutube.media import Media + +# Access token with scope: +# https://www.googleapis.com/auth/youtube.upload +# https://www.googleapis.com/auth/youtube +# https://www.googleapis.com/auth/youtube.force-ssl +ACCESS_TOKEN = "xxx" + + +def upload_video(): + cli = Client(access_token=ACCESS_TOKEN) + + body = mds.Video( + snippet=mds.VideoSnippet(title="video title", description="video description") + ) + + media = Media(filename="target_video.mp4") + + upload = cli.videos.insert( + body=body, media=media, parts=["snippet"], notify_subscribers=True + ) + + response = None + while response is None: + print(f"Uploading video...") + status, response = upload.next_chunk() + if status is not None: + print(f"Uploading video progress: {status.progress()}...") + + # Use video class to representing the video resource. + video = mds.Video.from_dict(response) + print(f"Video id {video.id} was successfully uploaded.") + + +if __name__ == "__main__": + upload_video() From 4ea782d415950ff3974196aa50c3815647d3b5f4 Mon Sep 17 00:00:00 2001 From: ikaroskun Date: Fri, 16 Dec 2022 12:32:07 +0800 Subject: [PATCH 080/141] docs(docs): :memo: update docs --- docs/docs/authorization.md | 21 +- docs/docs/getting_started.md | 7 +- docs/docs/usage/work-with-api.md | 583 ++++++++++++++++++++++++++++ docs/docs/usage/work-with-client.md | 204 ++++++++++ docs/mkdocs.yml | 3 + examples/apis/oauth_flow.py | 33 ++ 6 files changed, 833 insertions(+), 18 deletions(-) create mode 100644 docs/docs/usage/work-with-api.md create mode 100644 docs/docs/usage/work-with-client.md create mode 100644 examples/apis/oauth_flow.py diff --git a/docs/docs/authorization.md b/docs/docs/authorization.md index 514fb085..8d38f6ec 100644 --- a/docs/docs/authorization.md +++ b/docs/docs/authorization.md @@ -30,11 +30,11 @@ Now we can begin do the follows step. Initialize the api instance with you app credentials ``` -In [1]: from pyyoutube import Api +In [1]: from pyyoutube import Client -In [2]: api = Api(client_id="you client id", client_secret="you client secret") +In [2]: cli = Client(client_id="you client id", client_secret="you client secret") -In [3]: api.get_authorization_url() +In [3]: cli.get_authorize_url() Out[3]: ('https://accounts.google.com/o/oauth2/v2/auth?response_type=code&client_id=client_id&redirect_uri=https%3A%2F%2Flocalhost%2F&scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fyoutube+https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fuserinfo.profile&state=PyYouTube&access_type=offline&prompt=select_account', 'PyYouTube') @@ -72,7 +72,7 @@ Then you will get a Connection Error, don't worry. This just because we set the Now you need to copy the full url in the browser address bar. Then back to you console. ``` -In [4]: token = api.generate_access_token(authorization_response="the whole url") +In [4]: token = cli.generate_access_token(authorization_response="the whole url") In [5]: token Out[5]: AccessToken(access_token='access token', expires_in=3600, token_type='Bearer') @@ -87,7 +87,7 @@ now you have got your access token to visit your self data. For example, you can get your playlists. ``` -In [6]: playlists = api.get_playlists(mine=True) +In [6]: playlists = cli.playlists.list(mine=True) In [7]: playlists.items Out[7]: @@ -95,15 +95,6 @@ Out[7]: Playlist(kind='youtube#playlist', id='PLBaidt0ilCMbUdj0EppB710c_X5OuCP2g')] ``` -get your self profile. - -``` -In [8]: profile = api.get_profile() - -In [9]: profile -Out[9]: UserProfile(id='110109920178558006671', name='ikaros-life') -``` - !!! note "Tips" - If you have some confuse. you need to read the [Authorize Requests](https://developers.google.com/youtube/v3/guides/authentication) first. \ No newline at end of file + If you have some confuse. you need to read the [Authorize Requests](https://developers.google.com/youtube/v3/guides/authentication) first. diff --git a/docs/docs/getting_started.md b/docs/docs/getting_started.md index 05bd1a53..906c77eb 100644 --- a/docs/docs/getting_started.md +++ b/docs/docs/getting_started.md @@ -54,9 +54,10 @@ Now you have generated one api key. Use this key. You can retrieve public data for YouTube data by our library -```pythonregexp -In [1]: import pyyoutube -In [2]: api = pyyoutube.Api(api_key='your api key') +```python +from pyyoutube import Client + +cli = Client(api_key="your api key") ``` If you want to get some examples to see, check out the [examples](https://github.com/sns-sdks/python-youtube/tree/master/examples). diff --git a/docs/docs/usage/work-with-api.md b/docs/docs/usage/work-with-api.md new file mode 100644 index 00000000..e1d3e6c2 --- /dev/null +++ b/docs/docs/usage/work-with-api.md @@ -0,0 +1,583 @@ +# Work with Api + +!!! note "Tips" + + This is previous version to operate YouTube DATA API. + + We recommend using the latest version of methods to operate YouTube DATA API. + +The API is exposed via the ``pyyoutube.Api`` class. + +## INSTANTIATE + +There provide two method to create instance the ``pyyoutube.Api``. + +You can just initialize with an api key. + +``` +>>> from pyyoutube import Api + +>>> api = Api(api_key="your api key") +``` + +If you want to get some authorization data. you need to initialize with access token. + +``` +>>> from pyyoutube import Api + +>>> api = Api(access_token='your api key') +``` + +You can read the docs to see how to get an access token. + +Or you can ask for user to do oauth flow: + +``` +>>> from pyyoutube import Api + +>>> api = Api(client_id="client key", client_secret="client secret") +# Get authorization url +>>> api.get_authorization_url() +# ('https://accounts.google.com/o/oauth2/v2/auth?response_type=code&client_id=id&redirect_uri=https%3A%2F%2Flocalhost%2F&scope=scope&state=PyYouTube&access_type=offline&prompt=select_account', 'PyYouTube') +# user to do +# copy the response url +>>> api.generate_access_token(authorization_response="link for response") +# AccessToken(access_token='token', expires_in=3599, token_type='Bearer') +``` + +## Usage + +Now you can use the instance to get data from YouTube. + +### CHANNEL DATA + +The library provides several ways to get channel's data. + +If a channel is not found, the property ``items`` will return with blank list. + +You can use channel id: + +``` +>>> channel_by_id = api.get_channel_info(channel_id="UC_x5XG1OV2P6uZZ5FSM9Ttw") +>>> channel_by_id.items +[Channel(kind='youtube#channel', id='UC_x5XG1OV2P6uZZ5FSM9Ttw')] +>>> channel_by_id.items[0].to_dict() +{'kind': 'youtube#channel', + 'etag': '"j6xRRd8dTPVVptg711_CSPADRfg/AW8QEqbNRoIJv9KuzCIg0CG6aJA"', + 'id': 'UC_x5XG1OV2P6uZZ5FSM9Ttw', + 'snippet': {'title': 'Google Developers', + 'description': 'The Google Developers channel features talks from events, educational series, best practices, tips, and the latest updates across our products and platforms.', + 'customUrl': 'googlecode', + 'publishedAt': '2007-08-23T00:34:43.000Z', + 'thumbnails': {'default': {'url': 'https://yt3.ggpht.com/a/AGF-l78iFtAxyRZcUBzG91kbKMES19z-zGW5KT20_g=s88-c-k-c0xffffffff-no-rj-mo', + 'width': 88, + 'height': 88}, + 'medium': {'url': 'https://yt3.ggpht.com/a/AGF-l78iFtAxyRZcUBzG91kbKMES19z-zGW5KT20_g=s240-c-k-c0xffffffff-no-rj-mo', + 'width': 240, + 'height': 240}, + 'high': {'url': 'https://yt3.ggpht.com/a/AGF-l78iFtAxyRZcUBzG91kbKMES19z-zGW5KT20_g=s800-c-k-c0xffffffff-no-rj-mo', + 'width': 800, + 'height': 800}, + 'standard': None, + 'maxres': None}, + 'defaultLanguage': None, + 'localized': {'title': 'Google Developers', + 'description': 'The Google Developers channel features talks from events, educational series, best practices, tips, and the latest updates across our products and platforms.'}, + 'country': 'US'}, + ... + } +``` + +You can pass a channel id with comma-separated id string or a list, tuple or set of ids to get multiple channels. +Many methods also provide this functionality. + +with ids: + +``` +>>> channel_by_ids = api.get_channel_info(channel_id="UC_x5XG1OV2P6uZZ5FSM9Ttw,UCa-vrCLQHviTOVnEKDOdetQ") +>>> channel_by_ids.items +[Channel(kind='youtube#channel', id='UC_x5XG1OV2P6uZZ5FSM9Ttw'), + Channel(kind='youtube#channel', id='UCa-vrCLQHviTOVnEKDOdetQ')] +``` + +You can also use channel name: + +``` +>>> channel_by_username = api.get_channel_info(for_username="GoogleDevelopers") +>>> channel_by_username.items[0] +Channel(kind='youtube#channel', id='UC_x5XG1OV2P6uZZ5FSM9Ttw') +``` + +If you have authorized, you can get your channels: + +``` +>>> channel_by_mine = api_with_authorization.get_channel_info(mine=True) +>>> channel_by_mine.items[0] +Channel(kind='youtube#channel', id='UCa-vrCLQHviTOVnEKDOdetQ') +``` + +!!! note "Tips" + + To get your channel, you must do authorization first, otherwise you will get an error. + +### PLAYLIST + +There are methods to get playlists by playlist id, channel id or get your own playlists. + +Get playlists by id: + +``` +>>> playlists_by_id = api.get_playlist_by_id(playlist_id="PLOU2XLYxmsIKpaV8h0AGE05so0fAwwfTw") +>>> playlists_by_id.items +[Playlist(kind='youtube#playlist', id='PLOU2XLYxmsIKpaV8h0AGE05so0fAwwfTw')] +``` + +Get playlists by channel (If you want to get all playlists for the target channel's , just provide the +parameter `count=None`): + +``` +>>> playlists_by_channel = api.get_playlists(channel_id="UC_x5XG1OV2P6uZZ5FSM9Ttw") +>>> playlists_by_channel.items +[Playlist(kind='youtube#playlist', id='PLOU2XLYxmsIKpaV8h0AGE05so0fAwwfTw'), + Playlist(kind='youtube#playlist', id='PLOU2XLYxmsIJO83u2UmyC8ud41AvUnhgj'), + Playlist(kind='youtube#playlist', id='PLOU2XLYxmsILfV1LiUhDjbh1jkFjQWrYB'), + Playlist(kind='youtube#playlist', id='PLOU2XLYxmsIKNr3Wfhm8o0TSojW7hEPPY'), + Playlist(kind='youtube#playlist', id='PLOU2XLYxmsIJ8ItHmK4bRlY4GCzMgXLAJ')] +``` + +Get your playlists(this requires authorization): + +``` +>>> playlists_by_mine = api.get_playlists(mine=True) +``` + +### PLAYLIST ITEM + +Similarly, you can get playlist items by playlist item id or playlist id. + +Get playlist items by id: + +``` +>>> playlist_item_by_id = api.get_playlist_item_by_id(playlist_item_id="UExPVTJYTFl4bXNJS3BhVjhoMEFHRTA" +... "1c28wZkF3d2ZUdy41NkI0NEY2RDEwNTU3Q0M2") + +>>> playlist_item_by_id.items +[PlaylistItem(kind='youtube#playlistItem', id='UExPVTJYTFl4bXNJS3BhVjhoMEFHRTA1c28wZkF3d2ZUdy41NkI0NEY2RDEwNTU3Q0M2')] +``` + +Get playlist items by playlist id (If you want to get target playlist all items, just provide the +parameter `count=None`): + +``` +>>> playlist_item_by_playlist = api.get_playlist_items(playlist_id="PLOU2XLYxmsIKpaV8h0AGE05so0fAwwfTw", count=2) + +>>> playlist_item_by_playlist.items +[PlaylistItem(kind='youtube#playlistItem', id='UExPVTJYTFl4bXNJS3BhVjhoMEFHRTA1c28wZkF3d2ZUdy41NkI0NEY2RDEwNTU3Q0M2'), + PlaylistItem(kind='youtube#playlistItem', id='UExPVTJYTFl4bXNJS3BhVjhoMEFHRTA1c28wZkF3d2ZUdy4yODlGNEE0NkRGMEEzMEQy')] +>>> playlist_item_by_id.items[0].snippet.resourceId +ResourceId(kind='youtube#video', videoId='CvTApw9X8aA') +``` + +### VIDEO + +You can get a video's information by several methods. + +Get videos by video id(s): + +``` +>>> video_by_id = api.get_video_by_id(video_id="CvTApw9X8aA") + +>>> video_by_id +VideoListResponse(kind='youtube#videoListResponse') + +>>> video_by_id.items +[Video(kind='youtube#video', id='CvTApw9X8aA')] +``` + +Get videos by chart (If you want to get all videos, just provide the parameter `count=None`): + +``` +>>> video_by_chart = api.get_videos_by_chart(chart="mostPopular", region_code="US", count=2) + +>>> video_by_chart.items +[Video(kind='youtube#video', id='RwnN2FVaHmw'), + Video(kind='youtube#video', id='hDeuSfo_Ys0')] +``` + +Get videos by your rating (this requires authorization, also if you want to get all videos, just provide the +parameter `count=None`): + +``` +>>> videos_by_rating = api.get_videos_by_myrating(rating="like", count=2) +``` + +### COMMENT THREAD + +You can get comment thread information by id or some filter. + +Get comment thread by id(s): + +``` +>>> ct_by_id = api.get_comment_thread_by_id(comment_thread_id='Ugz097FRhsQy5CVhAjp4AaABAg,UgzhytyP79_Pwa +... Dd4UB4AaABAg') + +>>> ct_by_id.items +[CommentThread(kind='youtube#commentThread', id='Ugz097FRhsQy5CVhAjp4AaABAg'), + CommentThread(kind='youtube#commentThread', id='UgzhytyP79_PwaDd4UB4AaABAg')] +``` + +Get all comment threads related to a channel (including comment threads for the channel's video, also if you want to get +all comment threads, just provide the parameter `count=None`): + +``` +>>> ct_by_all = api.get_comment_threads(all_to_channel_id="UC_x5XG1OV2P6uZZ5FSM9Ttw", count=2) + +>>> ct_by_all.items +[CommentThread(kind='youtube#commentThread', id='UgwlB_Cza9WtzUWahYN4AaABAg'), + CommentThread(kind='youtube#commentThread', id='UgyvoQJ2LsxCBwGEpMB4AaABAg')] +``` + +Get comment threads only for the channel (If you want to get all comment threads, just provide the +parameter `count=None`): + +``` +>>> ct_by_channel = api.get_comment_threads(channel_id="UC_x5XG1OV2P6uZZ5FSM9Ttw", count=2) + +>>> ct_by_channel.items +[CommentThread(kind='youtube#commentThread', id='UgyUBI0HsgL9emxcZpR4AaABAg'), + CommentThread(kind='youtube#commentThread', id='Ugzi3lkqDPfIOirGFLh4AaABAg')] +``` + +Get comment threads only for the video (If you want to get all comment threads, just provide the +parameter `count=None`): + +``` +>>> ct_by_video = api.get_comment_threads(video_id="D-lhorsDlUQ", count=2) + +>>> ct_by_video.items +[CommentThread(kind='youtube#commentThread', id='UgydxWWoeA7F1OdqypJ4AaABAg'), + CommentThread(kind='youtube#commentThread', id='UgxKREWxIgDrw8w2e_Z4AaABAg')] +``` + +### COMMENT + +You can get comment information by id or use the top-level comment id to get replies. + +!!! note "Tips" + + The reply has the same structure as a comment. + +Get comments by id(s): + +``` +>>> comment_by_id = api.get_comment_by_id(comment_id='UgxKREWxIgDrw8w2e_Z4AaABAg,UgyrVQaFfEdvaSzstj14AaABAg') + +>>> comment_by_id.items +[Comment(kind='youtube#comment', id='UgxKREWxIgDrw8w2e_Z4AaABAg', snippet=CommentSnippet(authorDisplayName='Hieu Nguyen', likeCount=0)), + Comment(kind='youtube#comment', id='UgyrVQaFfEdvaSzstj14AaABAg', snippet=CommentSnippet(authorDisplayName='Mani Kanta', likeCount=0))] +``` + +Get replies by comment id (If you want to get all comments, just provide the parameter `count=None`): + +``` +>>> comment_by_parent = api.get_comments(parent_id="UgwYjZXfNCUTKPq9CZp4AaABAg") +>>> comment_by_parent.items +[Comment(kind='youtube#comment', id='UgwYjZXfNCUTKPq9CZp4AaABAg.8yxhlQJogG18yz_cXK9Kcj', snippet=CommentSnippet(authorDisplayName='Marlon López', likeCount=0))] +``` + +### VIDEO CATEGORY + +You can get video category with id or region. + +Get video categories with id(s): + +``` +>>> video_category_by_id = api.get_video_categories(category_id="17,18") + +>>> video_category_by_id.items +[VideoCategory(kind='youtube#videoCategory', id='17'), + VideoCategory(kind='youtube#videoCategory', id='18')] +``` + +Get video categories with region code: + +``` +>>> video_categories_by_region = api.get_video_categories(region_code="US") + +>>> video_categories_by_region.items +[VideoCategory(kind='youtube#videoCategory', id='1'), + VideoCategory(kind='youtube#videoCategory', id='2'), + VideoCategory(kind='youtube#videoCategory', id='10'), + VideoCategory(kind='youtube#videoCategory', id='15'), + ...] +``` + +### SUBSCRIPTIONS + +You can get subscription information by id, by point channel, or your own. + +!!! note "Tips" + + If you want to get the subscriptions not set to public, you need do authorization first and get the access token. + You can see the demo [A demo for get my subscription](examples/subscription.py). + +To get subscription info by id(s), this needs your token to have the permission for the subscriptions belonging to a +channel or user: + +``` +>>> r = api.get_subscription_by_id( +... subscription_id=[ +... "zqShTXi-2-Tx7TtwQqhCBwViE_j9IEgnmRmPnqJljxo", +... "zqShTXi-2-Rya5uUxEp3ZsPI3fZrFQnSXNQCwvHBGGo"]) +>>> r +SubscriptionListResponse(kind='youtube#subscriptionListResponse') +>>> r.items +[Subscription(kind='youtube#subscription', id='zqShTXi-2-Tx7TtwQqhCBwViE_j9IEgnmRmPnqJljxo', snippet=SubscriptionSnippet(title='PyCon 2015', description='')), + Subscription(kind='youtube#subscription', id='zqShTXi-2-Rya5uUxEp3ZsPI3fZrFQnSXNQCwvHBGGo', snippet=SubscriptionSnippet(title='ikaros-life', description='This is a test channel.'))] +``` + +Get your own subscriptions, this need you do authorization first or give the authorized access token: + +``` +>>> r = api.get_subscription_by_me( +... mine=True, +... parts=["id", "snippet"], +... count=2 +... ) +>>> r +SubscriptionListResponse(kind='youtube#subscriptionListResponse') +>>> r.items +[Subscription(kind='youtube#subscription', id='zqShTXi-2-Tx7TtwQqhCBwtJ-Aho6DZeutqZiP4Q79Q', snippet=SubscriptionSnippet(title='Next Day Video', description='')), + Subscription(kind='youtube#subscription', id='zqShTXi-2-Tx7TtwQqhCBwViE_j9IEgnmRmPnqJljxo', snippet=SubscriptionSnippet(title='PyCon 2015', description=''))] +``` + +Get public channel's subscriptions: + +``` +>>> r = api.get_subscription_by_channel( +... channel_id="UCAuUUnT6oDeKwE6v1NGQxug", +... parts="id,snippet", +... count=2 +... ) +>>> r +SubscriptionListResponse(kind='youtube#subscriptionListResponse') +>>> r.items +[Subscription(kind='youtube#subscription', id='FMP3Mleijt-52zZDGkHtR5KhwkvCcdQKWWWIA1j5eGc', snippet=SubscriptionSnippet(title='TEDx Talks', description="TEDx is an international community that organizes TED-style events anywhere and everywhere -- celebrating locally-driven ideas and elevating them to a global stage. TEDx events are produced independently of TED conferences, each event curates speakers on their own, but based on TED's format and rules.\n\nFor more information on using TED for commercial purposes (e.g. employee learning, in a film, or in an online course), please submit a media request using the link below.")), + Subscription(kind='youtube#subscription', id='FMP3Mleijt_ZKvy5M-HhRlsqI4wXY7VmP5g8lvmRhVU', snippet=SubscriptionSnippet(title='TED Residency', description='The TED Residency program is an incubator for breakthrough ideas. It is free and open to all via a semi-annual competitive application. Those chosen as TED Residents spend four months at TED headquarters in New York City, working on their idea. Selection criteria include the strength of their idea, their character, and their ability to bring a fresh perspective and positive contribution to the diverse TED community.'))] +``` + +### ACTIVITIES + +You can get activities by channel id. You can also get your own activities after you have completed authorization. + +Get public channel activities: + +``` +>>> r = api.get_activities_by_channel(channel_id="UC_x5XG1OV2P6uZZ5FSM9Ttw", count=2) +>>> r +ActivityListResponse(kind='youtube#activityListResponse') +>>> r.items +[Activity(kind='youtube#activity', id='MTUxNTc3NzM2MDAyODIxOTQxNDM0NjAwMA==', snippet=ActivitySnippet(title='2019 Year in Review - The Developer Show', description='Here to bring you the latest developer news from across Google this year is Developer Advocate Timothy Jordan. In this last week of the year, we’re taking a look back at some of the coolest and biggest announcements we covered in 2019! \n\nFollow Google Developers on Instagram → https://goo.gle/googledevs\n\nWatch more #DevShow → https://goo.gle/GDevShow\nSubscribe to Google Developers → https://goo.gle/developers')), + Activity(kind='youtube#activity', id='MTUxNTc3MTI4NzIzODIxOTQxNDM0NzI4MA==', snippet=ActivitySnippet(title='GDE Promo - Lara Martin', description='Meet Lara Martin, a Flutter/Dart Google Developers Expert and get inspired by her journey. Watch now for a preview of her story! #GDESpotlights #IncludedWithGoogle\n\nLearn about the GDE program → https://goo.gle/2qWOvAy\n\nGoogle Developers Experts → https://goo.gle/GDE\nSubscribe to Google Developers → https://goo.gle/developers'))] +``` + +Get your activities: + +``` +>>> r = api_with_token.get_activities_by_me() +>>> r.items +[Activity(kind='youtube#activity', id='MTUxNTc0OTk2MjI3NDE0MjYwMDY1NjAwODA=', snippet=ActivitySnippet(title='华山日出', description='冷冷的山头')), + Activity(kind='youtube#activity', id='MTUxNTc0OTk1OTAyNDE0MjYwMDY1NTc2NDg=', snippet=ActivitySnippet(title='海上日出', description='美美美'))] +``` + +Get your video captions: + +``` +>>> r = api.get_captions_by_video(video_id="oHR3wURdJ94", parts=["id", "snippet"]) +>>> r +CaptionListResponse(kind='youtube#captionListResponse') +>>> r.items +[Caption(kind='youtube#caption', id='SwPOvp0r7kd9ttt_XhcHdZthMwXG7Z0I', snippet=CaptionSnippet(videoId='oHR3wURdJ94', lastUpdated='2020-01-14T09:40:49.981Z')), + Caption(kind='youtube#caption', id='fPMuDm722CIRcUAT3NTPQHQZJZJxt39kU7JvrHk8Kzs=', snippet=CaptionSnippet(videoId='oHR3wURdJ94', lastUpdated='2020-01-14T09:39:46.991Z'))] +``` + +If you already have caption id(s), you can get video caption by id(s): + +``` +>>> r = api.get_captions_by_video(video_id="oHR3wURdJ94", parts=["id", "snippet"], caption_id="SwPOvp0r7kd9ttt_XhcHdZthMwXG7Z0I") +>>> r +CaptionListResponse(kind='youtube#captionListResponse') +>>> r.items +[Caption(kind='youtube#caption', id='SwPOvp0r7kd9ttt_XhcHdZthMwXG7Z0I', snippet=CaptionSnippet(videoId='oHR3wURdJ94', lastUpdated='2020-01-14T09:40:49.981Z'))] +``` + +### CHANNEL SECTIONS + +You can get channel sections by self id or belonged channel id or your own channel. + +Get channel sections by channel id: + +``` +>>> r = api.get_channel_sections_by_channel(channel_id="UC_x5XG1OV2P6uZZ5FSM9Ttw") +>>>> r +ChannelSectionResponse(kind='youtube#channelSectionListResponse') +>>> r.items +[ChannelSection(kind='youtube#channelSection', id='UC_x5XG1OV2P6uZZ5FSM9Ttw.e-Fk7vMPqLE'), + ChannelSection(kind='youtube#channelSection', id='UC_x5XG1OV2P6uZZ5FSM9Ttw.B8DTd9ZXJqM'), + ChannelSection(kind='youtube#channelSection', id='UC_x5XG1OV2P6uZZ5FSM9Ttw.MfvRjkWLxgk'), + ChannelSection(kind='youtube#channelSection', id='UC_x5XG1OV2P6uZZ5FSM9Ttw.fEjJOXRoWwg'), + ChannelSection(kind='youtube#channelSection', id='UC_x5XG1OV2P6uZZ5FSM9Ttw.PvTmxDBxtLs'), + ChannelSection(kind='youtube#channelSection', id='UC_x5XG1OV2P6uZZ5FSM9Ttw.pmcIOsL7s98'), + ChannelSection(kind='youtube#channelSection', id='UC_x5XG1OV2P6uZZ5FSM9Ttw.c3r3vYf9uD0'), + ChannelSection(kind='youtube#channelSection', id='UC_x5XG1OV2P6uZZ5FSM9Ttw.ZJpkBl-mXfM'), + ChannelSection(kind='youtube#channelSection', id='UC_x5XG1OV2P6uZZ5FSM9Ttw.9_wU0qhEPR8'), + ChannelSection(kind='youtube#channelSection', id='UC_x5XG1OV2P6uZZ5FSM9Ttw.npYvuMz0_es')] +``` + +Get authorized user's channel sections: + +``` +>>> r = api.get_channel_sections_by_channel(mine=True) +>>> r.items +[ChannelSection(kind='youtube#channelSection', id='UCa-vrCLQHviTOVnEKDOdetQ.jNQXAC9IVRw'), + ChannelSection(kind='youtube#channelSection', id='UCa-vrCLQHviTOVnEKDOdetQ.LeAltgu_pbM'), + ChannelSection(kind='youtube#channelSection', id='UCa-vrCLQHviTOVnEKDOdetQ.nGzAI5pLbMY')] +``` + +Get channel section detail info by id: + +``` +>>> r = api.get_channel_section_by_id(section_id="UC_x5XG1OV2P6uZZ5FSM9Ttw.e-Fk7vMPqLE") +>>> r +ChannelSectionResponse(kind='youtube#channelSectionListResponse') +>>> r1.items +[ChannelSection(kind='youtube#channelSection', id='UC_x5XG1OV2P6uZZ5FSM9Ttw.e-Fk7vMPqLE')] +``` + +### I18N RESOURCE + +You can get a list of content regions that the YouTube website supports: + +``` +>>> r = api.get_i18n_regions(parts=["snippet"]) +>>> r.items +[I18nRegion(kind='youtube#i18nRegion', id='DZ', snippet=I18nRegionSnippet(gl='DZ', name='Algeria')), + I18nRegion(kind='youtube#i18nRegion', id='AR', snippet=I18nRegionSnippet(gl='AR', name='Argentina')), + I18nRegion(kind='youtube#i18nRegion', id='AU', snippet=I18nRegionSnippet(gl='AU', name='Australia')) + ...] +``` + +You can get a list of application languages that the YouTube website supports: + +``` +>>> r = api.get_i18n_languages(parts=["snippet"]) +>>> r.items +[I18nLanguage(kind='youtube#i18nLanguage', id='af', snippet=I18nLanguageSnippet(hl='af', name='Afrikaans')), + I18nLanguage(kind='youtube#i18nLanguage', id='az', snippet=I18nLanguageSnippet(hl='az', name='Azerbaijani')), + I18nLanguage(kind='youtube#i18nLanguage', id='id', snippet=I18nLanguageSnippet(hl='id', name='Indonesian')), + ...] +``` + +### MEMBER + +The API request must be authorized by the channel owner. + +You can retrieve a list of members (formerly known as "sponsors") for a channel: + +``` +>>> r = api_with_token.get_members(parts=["snippet"]) +>>> r.items +[MemberListResponse(kind='youtube#memberListResponse'), + MemberListResponse(kind='youtube#memberListResponse')] +``` + +### MEMBERSHIP LEVEL + +The API request must be authorized by the channel owner. + +You can retrieve a list membership levels for a channel: + +``` +>>> r = api_with_token.get_membership_levels(parts=["snippet"]) +>>> r.items +[MembershipsLevelListResponse(kind='youtube#membershipsLevelListResponse'), + MembershipsLevelListResponse(kind='youtube#membershipsLevelListResponse')] +``` + +### VIDEO ABUSE REPORT REASON + +You can retrieve a list of reasons that can be used to report abusive videos: + +``` +>>> r = api_with_token.get_video_abuse_report_reason(parts=["snippet"]) +>>> r.items +[VideoAbuseReportReason(kind='youtube#videoAbuseReportReason'), + VideoAbuseReportReason(kind='youtube#videoAbuseReportReason')] +``` + +### SEARCH + +You can use those methods to search the video,playlist,channel data. For more info, you can see +the [Search Request Docs](https://developers.google.com/youtube/v3/docs/search/list). + +You can search different type of resource with keywords: + +``` +>>> r = api.search_by_keywords(q="surfing", search_type=["channel","video", "playlist"], count=5, limit=5) +>>> r.items +[SearchResult(kind='youtube#searchResult'), + SearchResult(kind='youtube#searchResult'), + SearchResult(kind='youtube#searchResult'), + SearchResult(kind='youtube#searchResult'), + SearchResult(kind='youtube#searchResult')] +``` + +You can search your app send videos: + +``` +>>> r = api_with_token.search_by_developer(q="news", count=1) +>>> r.items +[SearchResult(kind='youtube#searchResult')] +``` + +You can search your videos: + +``` +>>> r = api_with_token.search_by_mine(q="news", count=1) +>>> r.items +[SearchResult(kind='youtube#searchResult')] +``` + +Or you can build your request using the `search` method: + +``` +>>> r = api.search( +... location="21.5922529, -158.1147114", +... location_radius="10mi", +... q="surfing", +... parts=["snippet"], +... count=5, +... published_after="2020-02-01T00:00:00Z", +... published_before="2020-03-01T00:00:00Z", +... safe_search="moderate", +... search_type="video") +>>> r.items +[SearchResult(kind='youtube#searchResult'), + SearchResult(kind='youtube#searchResult'), + SearchResult(kind='youtube#searchResult'), + SearchResult(kind='youtube#searchResult'), + SearchResult(kind='youtube#searchResult')] + +>>> r = api.search( +... event_type="live", +... q="news", +... count=3, +... parts=["snippet"], +... search_type="video", +... topic_id="/m/09s1f", +... order="viewCount") +>>> r.items +[SearchResult(kind='youtube#searchResult'), + SearchResult(kind='youtube#searchResult'), + SearchResult(kind='youtube#searchResult')] +``` diff --git a/docs/docs/usage/work-with-client.md b/docs/docs/usage/work-with-client.md new file mode 100644 index 00000000..c4bb9269 --- /dev/null +++ b/docs/docs/usage/work-with-client.md @@ -0,0 +1,204 @@ +# Work with Client + +We have refactored the project code to support more methods and improve code usability. + +And new structure like follows. + +![structure-uml](../images/structure-uml.png) + +In this structure, we identify each entity as a class of resources and perform operations on the resources. + +## INSTANTIATE + +Client is exposed via the ``pyyoutube.Client`` class. + +You can initialize it with `api key`, to get public data. + +```python +from pyyoutube import Client + +cli = Client(api_key="your api key") +``` + +If you want to update your channel data. or upload video. You need initialize with `access token`, Or do auth flow. + +```python +from pyyoutube import Client + +cli = Client(access_token="Access Token with permissions") +``` + +```python +from pyyoutube import Client + +cli = Client(client_id="ID for app", client_secret="Secret for app") +# Get authorization url +cli.get_authorize_url() +# ('https://accounts.google.com/o/oauth2/v2/auth?response_type=code&client_id=id&redirect_uri=https%3A%2F%2Flocalhost%2F&scope=scope&state=PyYouTube&access_type=offline&prompt=select_account', 'PyYouTube') +# Click url and give permissions. +# Copy the redirected url. +cli.generate_access_token(authorization_response="redirected url") +# AccessToken(access_token='token', expires_in=3599, token_type='Bearer') +``` + +Once initialize client. Then you can operate API to get data. + +## Usage + +### Channel Resource + +The API supports the following methods for `channels` resources: + +- list: Returns a collection of zero or more channel resources that match the request criteria. +- update: Updates a channel's metadata. Note that this method currently only supports updates to the channel resource's + brandingSettings and invideoPromotion objects and their child properties + +#### List channel data + +```python +resp = cli.channels.list(channel_id="UC_x5XG1OV2P6uZZ5FSM9Ttw") +# ChannelListResponse(kind='youtube#channelListResponse') +print(resp.items) +# [Channel(kind='youtube#channel', id='UC_x5XG1OV2P6uZZ5FSM9Ttw')] +``` + +#### update channel metadata + +```python +import pyyoutube.models as mds + +body = mds.Channel( + id="channel id", + brandingSettings=mds.ChannelBrandingSetting( + image=mds.ChannelBrandingSettingImage( + bannerExternalUrl="new banner url" + ) + ) +) + +channel = cli.channels.update( + part="brandingSettings", + body=body +) +print(channel.brandingSettings.image.bannerExternalUrl) +# 'https://yt3.googleusercontent.com/AegVxoIusdXEmsJ9j3bcJR3zuImOd6TngNw58iJAP0AOAXCnb1xHPcuEDOQC8J85SCZvt5i8A_g' +``` + +### Video Resource + +The API supports the following methods for `videos` resources. + +#### getRating + +Retrieves the ratings that the authorized user gave to a list of specified videos. + +```python +resp = cli.videos.get_rating(video_id="Z56Jmr9Z34Q") + +print(resp.items) +# [VideoRatingItem(videoId='Z56Jmr9Z34Q', rating='none')] +``` + +#### list + +Returns a list of videos that match the API request parameters. + +```python +resp = cli.videos.list(video_id="Z56Jmr9Z34Q") + +print(resp.items) +# [Video(kind='youtube#video', id='Z56Jmr9Z34Q')] +``` + +#### insert + +Uploads a video to YouTube and optionally sets the video's metadata. + +```python +import pyyoutube.models as mds +from pyyoutube.media import Media + +body = mds.Video( + snippet=mds.VideoSnippet( + title="video title", + description="video description" + ) +) + +media = Media(filename="video.mp4") + +upload = cli.videos.insert( + body=body, + media=media, + parts=["snippet"], + notify_subscribers=True +) + +video_body = None + +while video_body is None: + status, video_body = upload.next_chunk() + if status: + print(f"Upload progress: {status.progress()}") + +print(video_body) +# {"kind": "youtube#video", "etag": "17W46NjVxoxtaoh1E6GmbQ2hv5c",....} +``` + +#### update + +Updates a video's metadata. + +```python +import pyyoutube.models as mds + +body = mds.Video( + id="fTK1Jj6QlDw", + snippet=mds.VideoSnippet( + title="What a nice day", + description="Blue sky with cloud. updated.", + categoryId="1", + ) +) + +resp = cli.videos.update( + parts=["snippet"], + body=body, + return_json=True, +) +print(resp) +# {"kind": "youtube#video", "etag": "BQUtovVd0TBJwC5S8-Pu-dK_I6s", "id": "fTK1Jj6QlDw", "snippet": {"publishedAt": "2022-12-15T03:45:16Z", "channelId": "UCa-vrCLQHviTOVnEKDOdetQ", "title": "What a nice day", "description": "Blue sky with cloud. updated.", "thumbnails": {"default": {"url": "https://i.ytimg.com/vi/fTK1Jj6QlDw/default.jpg", "width": 120, "height": 90}, "medium": {"url": "https://i.ytimg.com/vi/fTK1Jj6QlDw/mqdefault.jpg", "width": 320, "height": 180}, "high": {"url": "https://i.ytimg.com/vi/fTK1Jj6QlDw/hqdefault.jpg", "width": 480, "height": 360}, "standard": {"url": "https://i.ytimg.com/vi/fTK1Jj6QlDw/sddefault.jpg", "width": 640, "height": 480}, "maxres": {"url": "https://i.ytimg.com/vi/fTK1Jj6QlDw/maxresdefault.jpg", "width": 1280, "height": 720}}, "channelTitle": "ikaros data", "categoryId": "1", "liveBroadcastContent": "none", "localized": {"title": "What a nice day", "description": "Blue sky with cloud. updated."}, "defaultAudioLanguage": "en-US"}} +``` + +#### delete + +Deletes a YouTube video. + +```python +cli.videos.delete(video_id="fTK1Jj6QlDw") +# True +``` + +#### rate + +Add a like or dislike rating to a video or remove a rating from a video. + +```python +cli.videos.rate(video_id="fTK1Jj6QlDw", rating="like") +# True +``` + +#### reportAbuse + +Report a video for containing abusive content. + +```python +import pyyoutube.models as mds + +body = mds.VideoReportAbuse( + videoId="fTK1Jj6QlDw", + reasonId="32" +) +cli.videos.report_abuse(body=body) +# True +``` diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 176bdb40..05473021 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -31,6 +31,9 @@ theme: nav: - Introduction: index.md - Introduce Structure: introduce-new-structure.md + - Usage: + - Work With `Api`: usage/work-with-api.md + - Work With `Client`: usage/work-with-client.md - Installation: installation.md - Getting Started: getting_started.md - Authorization: authorization.md diff --git a/examples/apis/oauth_flow.py b/examples/apis/oauth_flow.py new file mode 100644 index 00000000..92ff4ff2 --- /dev/null +++ b/examples/apis/oauth_flow.py @@ -0,0 +1,33 @@ +""" + This example demonstrates how to perform authorization. +""" + +from pyyoutube import Api + +CLIENT_ID = "xxx" # Your app id +CLIENT_SECRET = "xxx" # Your app secret +SCOPE = [ + "https://www.googleapis.com/auth/youtube", + "https://www.googleapis.com/auth/youtube.force-ssl", + "https://www.googleapis.com/auth/userinfo.profile", +] + + +def do_authorize(): + api = Api(client_id=CLIENT_ID, client_secret=CLIENT_SECRET) + + authorize_url, state = api.get_authorization_url(scope=SCOPE) + print(f"Click url to do authorize: {authorize_url}") + + response_uri = input("Input youtube redirect uri:\n") + + token = api.generate_access_token(authorization_response=response_uri, scope=SCOPE) + print(f"Your token: {token}") + + # get data + profile = api.get_profile() + print(f"Your channel id: {profile.id}") + + +if __name__ == "__main__": + do_authorize() From 2de041eb9c0808f1233ff838612306707741453e Mon Sep 17 00:00:00 2001 From: ikaroskun Date: Fri, 16 Dec 2022 12:52:54 +0800 Subject: [PATCH 081/141] docs(changelog): :memo: update changelog --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 199572db..1a390051 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ All notable changes to this project will be documented in this file. +## Version 0.9.0 (in development) + +### What's New + +- Introduce new `Client` to operate YouTube DATA API. [#120](https://github.com/sns-sdks/python-youtube/issues/120). + ## Version 0.8.3 (2022-10-17) ### What's New From 338cce84e249d81989019df46c2c808f56370009 Mon Sep 17 00:00:00 2001 From: ikaroskun Date: Mon, 26 Dec 2022 10:40:02 +0800 Subject: [PATCH 082/141] docs(changelog): :memo: update changelog for release --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a390051..964877db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,11 +2,12 @@ All notable changes to this project will be documented in this file. -## Version 0.9.0 (in development) +## Version 0.9.0 (2022-12-26) ### What's New - Introduce new `Client` to operate YouTube DATA API. [#120](https://github.com/sns-sdks/python-youtube/issues/120). +- More example to show library usage. ## Version 0.8.3 (2022-10-17) From 4f44839bc6dfe034ef47b04d7d6180e11575c5da Mon Sep 17 00:00:00 2001 From: ikaroskun Date: Mon, 26 Dec 2022 10:40:33 +0800 Subject: [PATCH 083/141] =?UTF-8?q?Bump=20version:=200.8.3=20=E2=86=92=200?= =?UTF-8?q?.9.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .bumpversion.cfg | 2 +- pyproject.toml | 2 +- pyyoutube/__version__.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 90a98973..ae633b4e 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.8.3 +current_version = 0.9.0 commit = True tag = True diff --git a/pyproject.toml b/pyproject.toml index 9e843070..de305a30 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "python-youtube" -version = "0.8.3" +version = "0.9.0" description = "A Python wrapper around for YouTube Data API." authors = ["ikaroskun "] license = "MIT" diff --git a/pyyoutube/__version__.py b/pyyoutube/__version__.py index 722189c6..eafa166e 100644 --- a/pyyoutube/__version__.py +++ b/pyyoutube/__version__.py @@ -5,4 +5,4 @@ # 88 88 88 88 88 `8b d8' 88 V888 88 `8b d8' 88b d88 88 88b d88 88 8D 88. # 88 YP YP YP YP `Y88P' VP V8P YP `Y88P' ~Y8888P' YP ~Y8888P' Y8888P' Y88888P -__version__ = "0.8.3" +__version__ = "0.9.0" From 1816eb1014c5249a28229a7579ff057d75ac49cd Mon Sep 17 00:00:00 2001 From: ikaroskun Date: Mon, 26 Dec 2022 14:39:53 +0800 Subject: [PATCH 084/141] docs(readme): :memo: update readme --- README.rst | 627 ++++++----------------------------------------------- 1 file changed, 64 insertions(+), 563 deletions(-) diff --git a/README.rst b/README.rst index 1401df02..70d4a312 100644 --- a/README.rst +++ b/README.rst @@ -56,13 +56,62 @@ You can install this lib from PyPI: Using ===== -The API is exposed via the ``pyyoutube.Api`` class. +Now, the library covers all resource methods, including ``insert``,``update`` and so on. ------------ -INSTANTIATE ------------ +Currently, we recommend using ``pyyoutube.Client`` to operate DATA API. It has more features. -There provide two method to create instance the ``pyyoutube.Api``. +Work with Client +---------------- + +You can just initialize with an api key: + +.. code-block:: python + + >>> from pyyoutube import Client + >>> client = Client(api_key="your api key") + +If you want to get some authorization data. you need to initialize with an access token: + +.. code-block:: python + + >>> from pyyoutube import Client + >>> client = Client(access_token='your access token') + +You can read the docs to see how to get an access token. + +Or you can ask for user to do oauth flow: + +.. code-block:: python + + >>> from pyyoutube import Client + >>> client = Client(client_id="client key", client_secret="client secret") + + >>> client.get_authorize_url() + ('https://accounts.google.com/o/oauth2/v2/auth?response_type=code&client_id=id&redirect_uri=https%3A%2F%2Flocalhost%2F&scope=scope&state=PyYouTube&access_type=offline&prompt=select_account', 'PyYouTube') + + >>> client.generate_access_token(authorization_response="link for response") + AccessToken(access_token='token', expires_in=3599, token_type='Bearer') + +Now you can use the instance to get data from YouTube. + +Get channel detail: + + >>> cli.channels.list(channel_id="UC_x5XG1OV2P6uZZ5FSM9Ttw") + ChannelListResponse(kind='youtube#channelListResponse') + >>> cli.channels.list(channel_id="UC_x5XG1OV2P6uZZ5FSM9Ttw", return_json=True) + {'kind': 'youtube#channelListResponse', + 'etag': 'eHSYpB_FqHX8vJiGi_sLCu0jkmE', + ... + } + +To get more usage to see our `docs `_, or `examples `_ + +Work with API +---------------- + +.. + + We still support the old way for the sake of compatibility with older users. You can just initialize with an api key: @@ -76,7 +125,7 @@ If you want to get some authorization data. you need to initialize with an acces .. code-block:: python >>> from pyyoutube import Api - >>> api = Api(access_token='your api key') + >>> api = Api(access_token='your access token') You can read the docs to see how to get an access token. @@ -96,15 +145,7 @@ Or you can ask for user to do oauth flow: Now you can use the instance to get data from YouTube. ------------- -CHANNEL DATA ------------- - -The library provides several ways to get channel's data. - -If a channel is not found, the property ``items`` will return with blank list. - -You can use channel id: +Get channel detail: .. code-block:: python @@ -136,551 +177,11 @@ You can use channel id: 'country': 'US'}, ... } - -You can pass a channel id with comma-separated id string or a list, tuple or set of ids to get multiple channels. -Many methods also provide this functionality. - -with ids: - -.. code-block:: python - - >>> channel_by_ids = api.get_channel_info(channel_id="UC_x5XG1OV2P6uZZ5FSM9Ttw,UCa-vrCLQHviTOVnEKDOdetQ") - >>> channel_by_ids.items - [Channel(kind='youtube#channel', id='UC_x5XG1OV2P6uZZ5FSM9Ttw'), - Channel(kind='youtube#channel', id='UCa-vrCLQHviTOVnEKDOdetQ')] - -You can also use channel name: - -.. code-block:: python - - >>> channel_by_username = api.get_channel_info(for_username="GoogleDevelopers") - >>> channel_by_username.items[0] - Channel(kind='youtube#channel', id='UC_x5XG1OV2P6uZZ5FSM9Ttw') - -If you have authorized, you can get your channels: - -.. code-block:: python - - >>> channel_by_mine = api_with_authorization.get_channel_info(mine=True) - >>> channel_by_mine.items[0] - Channel(kind='youtube#channel', id='UCa-vrCLQHviTOVnEKDOdetQ') - -.. note:: - To get your channel, you must do authorization first, otherwise you will get an error. - --------- -PLAYLIST --------- - -There are methods to get playlists by playlist id, channel id or get your own playlists. - -Get playlists by id: - -.. code-block:: python - - >>> playlists_by_id = api.get_playlist_by_id(playlist_id="PLOU2XLYxmsIKpaV8h0AGE05so0fAwwfTw") - >>> playlists_by_id.items - [Playlist(kind='youtube#playlist', id='PLOU2XLYxmsIKpaV8h0AGE05so0fAwwfTw')] - -Get playlists by channel (If you want to get all of atarget channel's playlists, just provide the parameter ``count=None``): - -.. code-block:: python - - >>> playlists_by_channel = api.get_playlists(channel_id="UC_x5XG1OV2P6uZZ5FSM9Ttw") - >>> playlists_by_channel.items - [Playlist(kind='youtube#playlist', id='PLOU2XLYxmsIKpaV8h0AGE05so0fAwwfTw'), - Playlist(kind='youtube#playlist', id='PLOU2XLYxmsIJO83u2UmyC8ud41AvUnhgj'), - Playlist(kind='youtube#playlist', id='PLOU2XLYxmsILfV1LiUhDjbh1jkFjQWrYB'), - Playlist(kind='youtube#playlist', id='PLOU2XLYxmsIKNr3Wfhm8o0TSojW7hEPPY'), - Playlist(kind='youtube#playlist', id='PLOU2XLYxmsIJ8ItHmK4bRlY4GCzMgXLAJ')] - -Get your playlists(this requires authorization): - -.. code:: python - - playlists_by_mine = api.get_playlists(mine=True) - -------------- -PLAYLIST ITEM -------------- - -Similarly, you can get playlist items by playlist item id or playlist id. - -Get playlist items by id: - -.. code-block:: python - - >>> playlist_item_by_id = api.get_playlist_item_by_id(playlist_item_id="UExPVTJYTFl4bXNJS3BhVjhoMEFHRTA" - ... "1c28wZkF3d2ZUdy41NkI0NEY2RDEwNTU3Q0M2") - - >>> playlist_item_by_id.items - [PlaylistItem(kind='youtube#playlistItem', id='UExPVTJYTFl4bXNJS3BhVjhoMEFHRTA1c28wZkF3d2ZUdy41NkI0NEY2RDEwNTU3Q0M2')] - - -Get playlist items by playlist id (If you want to get target playlist all items, just provide the parameter ``count=None``): - -.. code-block:: python - - >>> playlist_item_by_playlist = api.get_playlist_items(playlist_id="PLOU2XLYxmsIKpaV8h0AGE05so0fAwwfTw", count=2) - - >>> playlist_item_by_playlist.items - [PlaylistItem(kind='youtube#playlistItem', id='UExPVTJYTFl4bXNJS3BhVjhoMEFHRTA1c28wZkF3d2ZUdy41NkI0NEY2RDEwNTU3Q0M2'), - PlaylistItem(kind='youtube#playlistItem', id='UExPVTJYTFl4bXNJS3BhVjhoMEFHRTA1c28wZkF3d2ZUdy4yODlGNEE0NkRGMEEzMEQy')] - >>> playlist_item_by_id.items[0].snippet.resourceId - ResourceId(kind='youtube#video', videoId='CvTApw9X8aA') - ------ -VIDEO ------ - -You can get a video's information by several methods. - -Get videos by video id(s): - -.. code-block:: python - - >>> video_by_id = api.get_video_by_id(video_id="CvTApw9X8aA") - - >>> video_by_id - VideoListResponse(kind='youtube#videoListResponse') - - >>> video_by_id.items - [Video(kind='youtube#video', id='CvTApw9X8aA')] - - -Get videos by chart (If you want to get all videos, just provide the parameter ``count=None``): - -.. code-block:: python - - >>> video_by_chart = api.get_videos_by_chart(chart="mostPopular", region_code="US", count=2) - - >>> video_by_chart.items - [Video(kind='youtube#video', id='RwnN2FVaHmw'), - Video(kind='youtube#video', id='hDeuSfo_Ys0')] - - -Get videos by your rating (this requires authorization, also if you want to get all videos, just provide the parameter ``count=None``): - -.. code:: python - - videos_by_rating = api.get_videos_by_myrating(rating="like", count=2) - --------------- -COMMENT THREAD --------------- - -You can get comment thread information by id or some filter. - -Get comment thread by id(s): - -.. code-block:: python - - >>> ct_by_id = api.get_comment_thread_by_id(comment_thread_id='Ugz097FRhsQy5CVhAjp4AaABAg,UgzhytyP79_Pwa - ... Dd4UB4AaABAg') - - >>> ct_by_id.items - [CommentThread(kind='youtube#commentThread', id='Ugz097FRhsQy5CVhAjp4AaABAg'), - CommentThread(kind='youtube#commentThread', id='UgzhytyP79_PwaDd4UB4AaABAg')] - -Get all comment threads related to a channel (including comment threads for the channel's video, also if you want to get all comment threads, just provide the parameter ``count=None``): - -.. code-block:: python - - >>> ct_by_all = api.get_comment_threads(all_to_channel_id="UC_x5XG1OV2P6uZZ5FSM9Ttw", count=2) - - >>> ct_by_all.items - [CommentThread(kind='youtube#commentThread', id='UgwlB_Cza9WtzUWahYN4AaABAg'), - CommentThread(kind='youtube#commentThread', id='UgyvoQJ2LsxCBwGEpMB4AaABAg')] - -Get comment threads only for the channel (If you want to get all comment threads, just provide the parameter ``count=None``): - -.. code-block:: python - - >>> ct_by_channel = api.get_comment_threads(channel_id="UC_x5XG1OV2P6uZZ5FSM9Ttw", count=2) - - >>> ct_by_channel.items - [CommentThread(kind='youtube#commentThread', id='UgyUBI0HsgL9emxcZpR4AaABAg'), - CommentThread(kind='youtube#commentThread', id='Ugzi3lkqDPfIOirGFLh4AaABAg')] - -Get comment threads only for the video (If you want to get all comment threads, just provide the parameter ``count=None``): - -.. code-block:: python - - >>> ct_by_video = api.get_comment_threads(video_id="D-lhorsDlUQ", count=2) - - >>> ct_by_video.items - [CommentThread(kind='youtube#commentThread', id='UgydxWWoeA7F1OdqypJ4AaABAg'), - CommentThread(kind='youtube#commentThread', id='UgxKREWxIgDrw8w2e_Z4AaABAg')] - -------- -COMMENT -------- - -You can get comment information by id or use the top-level comment id to get replies. - -.. note:: - The reply has the same structure as a comment. - -Get comments by id(s): - -.. code-block:: python - - >>> comment_by_id = api.get_comment_by_id(comment_id='UgxKREWxIgDrw8w2e_Z4AaABAg,UgyrVQaFfEdvaSzstj14Aa - ... ABAg') - - >>> comment_by_id.items - [Comment(kind='youtube#comment', id='UgxKREWxIgDrw8w2e_Z4AaABAg', snippet=CommentSnippet(authorDisplayName='Hieu Nguyen', likeCount=0)), - Comment(kind='youtube#comment', id='UgyrVQaFfEdvaSzstj14AaABAg', snippet=CommentSnippet(authorDisplayName='Mani Kanta', likeCount=0))] - -Get replies by comment id (If you want to get all comments, just provide the parameter ``count=None``): - -.. code-block:: python - - >>> comment_by_parent = api.get_comments(parent_id="UgwYjZXfNCUTKPq9CZp4AaABAg") - - >>> comment_by_parent.items - [Comment(kind='youtube#comment', id='UgwYjZXfNCUTKPq9CZp4AaABAg.8yxhlQJogG18yz_cXK9Kcj', snippet=CommentSnippet(authorDisplayName='Marlon López', likeCount=0))] - --------------- -VIDEO CATEGORY --------------- - -You can get video category with id or region. - -Get video categories with id(s): - -.. code-block:: python - - >>> video_category_by_id = api.get_video_categories(category_id="17,18") - - >>> video_category_by_id.items - [VideoCategory(kind='youtube#videoCategory', id='17'), - VideoCategory(kind='youtube#videoCategory', id='18')] - -Get video categories with region code: - -.. code-block:: python - - >>> video_categories_by_region = api.get_video_categories(region_code="US") - - >>> video_categories_by_region.items - [VideoCategory(kind='youtube#videoCategory', id='1'), - VideoCategory(kind='youtube#videoCategory', id='2'), - VideoCategory(kind='youtube#videoCategory', id='10'), - VideoCategory(kind='youtube#videoCategory', id='15'), - ...] - -------------- -SUBSCRIPTIONS -------------- - -You can get subscription information by id, by point channel, or your own. - -.. note:: - If you want to get the subscriptions not set to public, you need do authorization first and get the access token. - You can see the demo `A demo for get my subscription `_. - -To get subscription info by id(s), this needs your token to have the permission for the subscriptions belonging to a channel or user: - -.. code-block:: python - - >>> r = api.get_subscription_by_id( - ... subscription_id=[ - ... "zqShTXi-2-Tx7TtwQqhCBwViE_j9IEgnmRmPnqJljxo", - ... "zqShTXi-2-Rya5uUxEp3ZsPI3fZrFQnSXNQCwvHBGGo"]) - >>> r - SubscriptionListResponse(kind='youtube#subscriptionListResponse') - >>> r.items - [Subscription(kind='youtube#subscription', id='zqShTXi-2-Tx7TtwQqhCBwViE_j9IEgnmRmPnqJljxo', snippet=SubscriptionSnippet(title='PyCon 2015', description='')), - Subscription(kind='youtube#subscription', id='zqShTXi-2-Rya5uUxEp3ZsPI3fZrFQnSXNQCwvHBGGo', snippet=SubscriptionSnippet(title='ikaros-life', description='This is a test channel.'))] - -Get your own subscriptions, this need you do authorization first or give the authorized access token: - -.. code-block:: python - - >>> r = api.get_subscription_by_me( - ... mine=True, - ... parts=["id", "snippet"], - ... count=2 - ... ) - >>> r - SubscriptionListResponse(kind='youtube#subscriptionListResponse') - >>> r.items - [Subscription(kind='youtube#subscription', id='zqShTXi-2-Tx7TtwQqhCBwtJ-Aho6DZeutqZiP4Q79Q', snippet=SubscriptionSnippet(title='Next Day Video', description='')), - Subscription(kind='youtube#subscription', id='zqShTXi-2-Tx7TtwQqhCBwViE_j9IEgnmRmPnqJljxo', snippet=SubscriptionSnippet(title='PyCon 2015', description=''))] - -Get public channel's subscriptions: - -.. code-block:: python - - >>> r = api.get_subscription_by_channel( - ... channel_id="UCAuUUnT6oDeKwE6v1NGQxug", - ... parts="id,snippet", - ... count=2 - ... ) - >>> r - SubscriptionListResponse(kind='youtube#subscriptionListResponse') - >>> r.items - [Subscription(kind='youtube#subscription', id='FMP3Mleijt-52zZDGkHtR5KhwkvCcdQKWWWIA1j5eGc', snippet=SubscriptionSnippet(title='TEDx Talks', description="TEDx is an international community that organizes TED-style events anywhere and everywhere -- celebrating locally-driven ideas and elevating them to a global stage. TEDx events are produced independently of TED conferences, each event curates speakers on their own, but based on TED's format and rules.\n\nFor more information on using TED for commercial purposes (e.g. employee learning, in a film, or in an online course), please submit a media request using the link below.")), - Subscription(kind='youtube#subscription', id='FMP3Mleijt_ZKvy5M-HhRlsqI4wXY7VmP5g8lvmRhVU', snippet=SubscriptionSnippet(title='TED Residency', description='The TED Residency program is an incubator for breakthrough ideas. It is free and open to all via a semi-annual competitive application. Those chosen as TED Residents spend four months at TED headquarters in New York City, working on their idea. Selection criteria include the strength of their idea, their character, and their ability to bring a fresh perspective and positive contribution to the diverse TED community.'))] - - ----------- -ACTIVITIES ----------- - -You can get activities by channel id. You can also get your own activities after you have completed authorization. - -Get public channel activities: - -.. code-block:: python - - >>> r = api.get_activities_by_channel(channel_id="UC_x5XG1OV2P6uZZ5FSM9Ttw", count=2) - >>> r - ActivityListResponse(kind='youtube#activityListResponse') - >>> r.items - [Activity(kind='youtube#activity', id='MTUxNTc3NzM2MDAyODIxOTQxNDM0NjAwMA==', snippet=ActivitySnippet(title='2019 Year in Review - The Developer Show', description='Here to bring you the latest developer news from across Google this year is Developer Advocate Timothy Jordan. In this last week of the year, we’re taking a look back at some of the coolest and biggest announcements we covered in 2019! \n\nFollow Google Developers on Instagram → https://goo.gle/googledevs\n\nWatch more #DevShow → https://goo.gle/GDevShow\nSubscribe to Google Developers → https://goo.gle/developers')), - Activity(kind='youtube#activity', id='MTUxNTc3MTI4NzIzODIxOTQxNDM0NzI4MA==', snippet=ActivitySnippet(title='GDE Promo - Lara Martin', description='Meet Lara Martin, a Flutter/Dart Google Developers Expert and get inspired by her journey. Watch now for a preview of her story! #GDESpotlights #IncludedWithGoogle\n\nLearn about the GDE program → https://goo.gle/2qWOvAy\n\nGoogle Developers Experts → https://goo.gle/GDE\nSubscribe to Google Developers → https://goo.gle/developers'))] - - -Get your activities: - -.. code-block:: python - - >>> r = api_with_token.get_activities_by_me() - >>> r.items - [Activity(kind='youtube#activity', id='MTUxNTc0OTk2MjI3NDE0MjYwMDY1NjAwODA=', snippet=ActivitySnippet(title='华山日出', description='冷冷的山头')), - Activity(kind='youtube#activity', id='MTUxNTc0OTk1OTAyNDE0MjYwMDY1NTc2NDg=', snippet=ActivitySnippet(title='海上日出', description='美美美'))] - -Get your video captions: - -.. code-block:: python - - >>> r = api.get_captions_by_video(video_id="oHR3wURdJ94", parts=["id", "snippet"]) - >>> r - CaptionListResponse(kind='youtube#captionListResponse') - >>> r.items - [Caption(kind='youtube#caption', id='SwPOvp0r7kd9ttt_XhcHdZthMwXG7Z0I', snippet=CaptionSnippet(videoId='oHR3wURdJ94', lastUpdated='2020-01-14T09:40:49.981Z')), - Caption(kind='youtube#caption', id='fPMuDm722CIRcUAT3NTPQHQZJZJxt39kU7JvrHk8Kzs=', snippet=CaptionSnippet(videoId='oHR3wURdJ94', lastUpdated='2020-01-14T09:39:46.991Z'))] - - -If you already have caption id(s), you can get video caption by id(s): - -.. code-block:: python - - >>> r = api.get_captions_by_video(video_id="oHR3wURdJ94", parts=["id", "snippet"], caption_id="SwPOvp0r7kd9ttt_XhcHdZthMwXG7Z0I") - >>> r - CaptionListResponse(kind='youtube#captionListResponse') - >>> r.items - [Caption(kind='youtube#caption', id='SwPOvp0r7kd9ttt_XhcHdZthMwXG7Z0I', snippet=CaptionSnippet(videoId='oHR3wURdJ94', lastUpdated='2020-01-14T09:40:49.981Z'))] - ----------------- -CHANNEL SECTIONS ----------------- - -You can get channel sections by self id or belonged channel id or your own channel. - -Get channel sections by channel id: - -.. code-block:: python - - >>> r = api.get_channel_sections_by_channel(channel_id="UC_x5XG1OV2P6uZZ5FSM9Ttw") - >>>> r - ChannelSectionResponse(kind='youtube#channelSectionListResponse') - >>> r.items - [ChannelSection(kind='youtube#channelSection', id='UC_x5XG1OV2P6uZZ5FSM9Ttw.e-Fk7vMPqLE'), - ChannelSection(kind='youtube#channelSection', id='UC_x5XG1OV2P6uZZ5FSM9Ttw.B8DTd9ZXJqM'), - ChannelSection(kind='youtube#channelSection', id='UC_x5XG1OV2P6uZZ5FSM9Ttw.MfvRjkWLxgk'), - ChannelSection(kind='youtube#channelSection', id='UC_x5XG1OV2P6uZZ5FSM9Ttw.fEjJOXRoWwg'), - ChannelSection(kind='youtube#channelSection', id='UC_x5XG1OV2P6uZZ5FSM9Ttw.PvTmxDBxtLs'), - ChannelSection(kind='youtube#channelSection', id='UC_x5XG1OV2P6uZZ5FSM9Ttw.pmcIOsL7s98'), - ChannelSection(kind='youtube#channelSection', id='UC_x5XG1OV2P6uZZ5FSM9Ttw.c3r3vYf9uD0'), - ChannelSection(kind='youtube#channelSection', id='UC_x5XG1OV2P6uZZ5FSM9Ttw.ZJpkBl-mXfM'), - ChannelSection(kind='youtube#channelSection', id='UC_x5XG1OV2P6uZZ5FSM9Ttw.9_wU0qhEPR8'), - ChannelSection(kind='youtube#channelSection', id='UC_x5XG1OV2P6uZZ5FSM9Ttw.npYvuMz0_es')] - -Get authorized user's channel sections: - -.. code-block:: python - - >>> r = api.get_channel_sections_by_channel(mine=True) - >>> r.items - [ChannelSection(kind='youtube#channelSection', id='UCa-vrCLQHviTOVnEKDOdetQ.jNQXAC9IVRw'), - ChannelSection(kind='youtube#channelSection', id='UCa-vrCLQHviTOVnEKDOdetQ.LeAltgu_pbM'), - ChannelSection(kind='youtube#channelSection', id='UCa-vrCLQHviTOVnEKDOdetQ.nGzAI5pLbMY')] - -Get channel section detail info by id: - -.. code-block:: python - - >>> r = api.get_channel_section_by_id(section_id="UC_x5XG1OV2P6uZZ5FSM9Ttw.e-Fk7vMPqLE") - >>> r - ChannelSectionResponse(kind='youtube#channelSectionListResponse') - >>> r1.items - [ChannelSection(kind='youtube#channelSection', id='UC_x5XG1OV2P6uZZ5FSM9Ttw.e-Fk7vMPqLE')] - -------------- -I18N RESOURCE -------------- - -You can get a list of content regions that the YouTube website supports: - -.. code-block:: python - - >>> r = api.get_i18n_regions(parts=["snippet"]) - >>> r.items - [I18nRegion(kind='youtube#i18nRegion', id='DZ', snippet=I18nRegionSnippet(gl='DZ', name='Algeria')), - I18nRegion(kind='youtube#i18nRegion', id='AR', snippet=I18nRegionSnippet(gl='AR', name='Argentina')), - I18nRegion(kind='youtube#i18nRegion', id='AU', snippet=I18nRegionSnippet(gl='AU', name='Australia')) - ...] - -You can get a list of application languages that the YouTube website supports: - -.. code-block:: python - - >>> r = api.get_i18n_languages(parts=["snippet"]) - >>> r.items - [I18nLanguage(kind='youtube#i18nLanguage', id='af', snippet=I18nLanguageSnippet(hl='af', name='Afrikaans')), - I18nLanguage(kind='youtube#i18nLanguage', id='az', snippet=I18nLanguageSnippet(hl='az', name='Azerbaijani')), - I18nLanguage(kind='youtube#i18nLanguage', id='id', snippet=I18nLanguageSnippet(hl='id', name='Indonesian')), - ...] - - -------- -MEMBER -------- - -The API request must be authorized by the channel owner. - -You can retrieve a list of members (formerly known as "sponsors") for a channel: - -.. code-block:: python - - >>> r = api_with_token.get_members(parts=["snippet"]) - >>> r.items - [MemberListResponse(kind='youtube#memberListResponse'), - MemberListResponse(kind='youtube#memberListResponse')] - - ----------------- -MEMBERSHIP LEVEL ----------------- - -The API request must be authorized by the channel owner. - -You can retrieve a list membership levels for a channel: - -.. code-block:: python - - >>> r = api_with_token.get_membership_levels(parts=["snippet"]) - >>> r.items - [MembershipsLevelListResponse(kind='youtube#membershipsLevelListResponse'), - MembershipsLevelListResponse(kind='youtube#membershipsLevelListResponse')] - - -------------------------- -VIDEO ABUSE REPORT REASON -------------------------- - -You can retrieve a list of reasons that can be used to report abusive videos: - -.. code-block:: python - - >>> r = api_with_token.get_video_abuse_report_reason(parts=["snippet"]) - >>> r.items - [VideoAbuseReportReason(kind='youtube#videoAbuseReportReason'), - VideoAbuseReportReason(kind='youtube#videoAbuseReportReason')] - ------- -SEARCH ------- - -You can use those methods to search the video,playlist,channel data. For more info, you can see the `Search Request Docs `_ . - -You can search different type of resource with keywords: - -.. code-block:: python - - >>> r = api.search_by_keywords(q="surfing", search_type=["channel","video", "playlist"], count=5, limit=5) - >>> r.items - [SearchResult(kind='youtube#searchResult'), - SearchResult(kind='youtube#searchResult'), - SearchResult(kind='youtube#searchResult'), - SearchResult(kind='youtube#searchResult'), - SearchResult(kind='youtube#searchResult')] - -You can search your app send videos: - -.. code-block:: python - - >>> r = api_with_token.search_by_developer(q="news", count=1) - >>> r.items - [SearchResult(kind='youtube#searchResult')] - -You can search your videos: - -.. code-block:: python - - >>> r = api_with_token.search_by_mine(q="news", count=1) - >>> r.items - [SearchResult(kind='youtube#searchResult')] - -Or you can build your request using the ``search`` method: - -.. code-block:: python - - >>> r = api.search( - ... location="21.5922529, -158.1147114", - ... location_radius="10mi", - ... q="surfing", - ... parts=["snippet"], - ... count=5, - ... published_after="2020-02-01T00:00:00Z", - ... published_before="2020-03-01T00:00:00Z", - ... safe_search="moderate", - ... search_type="video") - >>> r.items - [SearchResult(kind='youtube#searchResult'), - SearchResult(kind='youtube#searchResult'), - SearchResult(kind='youtube#searchResult'), - SearchResult(kind='youtube#searchResult'), - SearchResult(kind='youtube#searchResult')] - - >>> r = api.search( - ... event_type="live", - ... q="news", - ... count=3, - ... parts=["snippet"], - ... search_type="video", - ... topic_id="/m/09s1f", - ... order="viewCount") - >>> r.items - [SearchResult(kind='youtube#searchResult'), - SearchResult(kind='youtube#searchResult'), - SearchResult(kind='youtube#searchResult')] - -==== -TODO -==== - -The following apis are now available: - -- OAuth Flow -- Channel Info -- Playlist Info -- PlaylistItem Info -- Video Info -- Comment Thread Info -- Comment Info -- Video Categories Info -- Subscriptions Info -- Activities Info -- Captions Info -- Channel Sections Info -- Search Requests and simple usage. -- Members Info -- Membership Level Info - -Doing - -- Update, Insert and so on. + # Get json response from youtube + >>> api.get_channel_info(channel_id="UC_x5XG1OV2P6uZZ5FSM9Ttw", return_json=True) + {'kind': 'youtube#channelListResponse', + 'etag': '17FOkdjp-_FPTiIJXdawBS4jWtc', + ... + } + +To get more usage to see our `docs `_, or `examples `_ From 4e1a8d10c76c10f87e8a4d23fe0c99f3e1a96e5b Mon Sep 17 00:00:00 2001 From: Tom Huibregtse Date: Sat, 20 May 2023 07:24:07 -0500 Subject: [PATCH 085/141] Update index.md Fix wording --- docs/docs/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/index.md b/docs/docs/index.md index 5fe1e4b6..0e541a58 100644 --- a/docs/docs/index.md +++ b/docs/docs/index.md @@ -17,4 +17,4 @@ Library could work on Python 3.6+. !!! tip "Tips" - This library only have `DATA API`, Not contains `Analytics and Reporting APIs` and `Live Streaming API`. + This library only supports `DATA API`, It does not support `Analytics and Reporting APIs` and `Live Streaming API`. From 2ef45429c60eb675c708ae3425911c9bab92278d Mon Sep 17 00:00:00 2001 From: David Arnold Date: Sun, 16 Jul 2023 17:39:03 -0500 Subject: [PATCH 086/141] chore(build): :art: follow new poetry core namespace --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index de305a30..2c6ec99c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,5 +39,5 @@ pytest = "^6.0.2" pytest-cov = "^2.10.1" [build-system] -requires = ["poetry>=0.12"] -build-backend = "poetry.masonry.api" +requires = ["poetry-core>=1.0.0"] +build-backend = "poetry.core.masonry.api" From d07c85b71f5b7f00a583085c99df09ff89e1cb5b Mon Sep 17 00:00:00 2001 From: David Arnold Date: Sun, 16 Jul 2023 17:44:42 -0500 Subject: [PATCH 087/141] fix(tests): :white_check_mark: compare int to int, not string --- tests/models/test_channel.py | 2 +- tests/models/test_videos.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/models/test_channel.py b/tests/models/test_channel.py index 0bb58d19..59564d62 100644 --- a/tests/models/test_channel.py +++ b/tests/models/test_channel.py @@ -59,7 +59,7 @@ def testChannelSnippet(self) -> None: def testChannelStatistics(self) -> None: m = models.ChannelStatistics.from_dict(self.STATISTICS_INFO) - self.assertEqual(m.viewCount, "160361638") + self.assertEqual(m.viewCount, 160361638) def testChannelStatus(self) -> None: m = models.ChannelStatus.from_dict(self.STATUS_INFO) diff --git a/tests/models/test_videos.py b/tests/models/test_videos.py index 9910fe07..5574bf2a 100644 --- a/tests/models/test_videos.py +++ b/tests/models/test_videos.py @@ -73,7 +73,7 @@ def testVideoSnippet(self) -> None: def testVideoStatistics(self) -> None: m = models.VideoStatistics.from_dict(self.STATISTICS_INFO) - self.assertEqual(m.viewCount, "8087") + self.assertEqual(m.viewCount, 8087) def testVideoStatus(self) -> None: m = models.VideoStatus.from_dict(self.STATUS_INFO) From 872e4f7c97916a50c605b5296bd5dabf15525dd4 Mon Sep 17 00:00:00 2001 From: klein Date: Tue, 18 Jul 2023 12:06:13 +0800 Subject: [PATCH 088/141] fix tests data --- testdata/modeldata/channels/channel_statistics.json | 2 +- testdata/modeldata/videos/video_statistics.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/testdata/modeldata/channels/channel_statistics.json b/testdata/modeldata/channels/channel_statistics.json index 8540e72e..3e0363ba 100644 --- a/testdata/modeldata/channels/channel_statistics.json +++ b/testdata/modeldata/channels/channel_statistics.json @@ -1,5 +1,5 @@ { - "viewCount": "160361638", + "viewCount": 160361638, "commentCount": "0", "subscriberCount": "1927873", "hiddenSubscriberCount": false, diff --git a/testdata/modeldata/videos/video_statistics.json b/testdata/modeldata/videos/video_statistics.json index 4a88784c..f5e7d77d 100644 --- a/testdata/modeldata/videos/video_statistics.json +++ b/testdata/modeldata/videos/video_statistics.json @@ -1,5 +1,5 @@ { - "viewCount": "8087", + "viewCount": 8087, "likeCount": "190", "dislikeCount": "23", "favoriteCount": "0", From cb41226e14bc5d71014d87d1998cdf6ac8506702 Mon Sep 17 00:00:00 2001 From: ikaroskun Date: Wed, 19 Jul 2023 17:55:52 +0800 Subject: [PATCH 089/141] =?UTF-8?q?Bump=20version:=200.9.0=20=E2=86=92=200?= =?UTF-8?q?.9.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .bumpversion.cfg | 2 +- pyproject.toml | 2 +- pyyoutube/__version__.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index ae633b4e..a39ba3f2 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.9.0 +current_version = 0.9.1 commit = True tag = True diff --git a/pyproject.toml b/pyproject.toml index 2c6ec99c..d92754c7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "python-youtube" -version = "0.9.0" +version = "0.9.1" description = "A Python wrapper around for YouTube Data API." authors = ["ikaroskun "] license = "MIT" diff --git a/pyyoutube/__version__.py b/pyyoutube/__version__.py index eafa166e..ba1fec40 100644 --- a/pyyoutube/__version__.py +++ b/pyyoutube/__version__.py @@ -5,4 +5,4 @@ # 88 88 88 88 88 `8b d8' 88 V888 88 `8b d8' 88b d88 88 88b d88 88 8D 88. # 88 YP YP YP YP `Y88P' VP V8P YP `Y88P' ~Y8888P' YP ~Y8888P' Y8888P' Y88888P -__version__ = "0.9.0" +__version__ = "0.9.1" From 81e51cd2e9f7e4215374c6bbc20228e475a8b6fc Mon Sep 17 00:00:00 2001 From: ikaroskun Date: Wed, 19 Jul 2023 18:03:07 +0800 Subject: [PATCH 090/141] =?UTF-8?q?docs:=20=E2=9C=8F=EF=B8=8F=20changelog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit update changelog --- .bumpversion.cfg | 2 +- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- pyyoutube/__version__.py | 2 +- 4 files changed, 9 insertions(+), 3 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index a39ba3f2..ae633b4e 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.9.1 +current_version = 0.9.0 commit = True tag = True diff --git a/CHANGELOG.md b/CHANGELOG.md index 964877db..1c55276e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ All notable changes to this project will be documented in this file. +## Version 0.9.1 (2023-07-19) + +### What's New + +- upgrade poetry. Thanks for [@blaggacao](https://github.com/blaggacao) + ## Version 0.9.0 (2022-12-26) ### What's New diff --git a/pyproject.toml b/pyproject.toml index d92754c7..2c6ec99c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "python-youtube" -version = "0.9.1" +version = "0.9.0" description = "A Python wrapper around for YouTube Data API." authors = ["ikaroskun "] license = "MIT" diff --git a/pyyoutube/__version__.py b/pyyoutube/__version__.py index ba1fec40..eafa166e 100644 --- a/pyyoutube/__version__.py +++ b/pyyoutube/__version__.py @@ -5,4 +5,4 @@ # 88 88 88 88 88 `8b d8' 88 V888 88 `8b d8' 88b d88 88 88b d88 88 8D 88. # 88 YP YP YP YP `Y88P' VP V8P YP `Y88P' ~Y8888P' YP ~Y8888P' Y8888P' Y88888P -__version__ = "0.9.1" +__version__ = "0.9.0" From b0a27722c4c204e7241d4a606f7e043207832dfc Mon Sep 17 00:00:00 2001 From: ikaroskun Date: Wed, 19 Jul 2023 18:03:10 +0800 Subject: [PATCH 091/141] =?UTF-8?q?Bump=20version:=200.9.0=20=E2=86=92=200?= =?UTF-8?q?.9.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .bumpversion.cfg | 2 +- pyproject.toml | 2 +- pyyoutube/__version__.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index ae633b4e..a39ba3f2 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.9.0 +current_version = 0.9.1 commit = True tag = True diff --git a/pyproject.toml b/pyproject.toml index 2c6ec99c..d92754c7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "python-youtube" -version = "0.9.0" +version = "0.9.1" description = "A Python wrapper around for YouTube Data API." authors = ["ikaroskun "] license = "MIT" diff --git a/pyyoutube/__version__.py b/pyyoutube/__version__.py index eafa166e..ba1fec40 100644 --- a/pyyoutube/__version__.py +++ b/pyyoutube/__version__.py @@ -5,4 +5,4 @@ # 88 88 88 88 88 `8b d8' 88 V888 88 `8b d8' 88b d88 88 88b d88 88 8D 88. # 88 YP YP YP YP `Y88P' VP V8P YP `Y88P' ~Y8888P' YP ~Y8888P' Y8888P' Y88888P -__version__ = "0.9.0" +__version__ = "0.9.1" From a76bbd4ff2c579c1ce538fd8a87664fa2deeab3a Mon Sep 17 00:00:00 2001 From: ikaroskun Date: Wed, 19 Jul 2023 18:20:04 +0800 Subject: [PATCH 092/141] =?UTF-8?q?docs:=20=E2=9C=8F=EF=B8=8F=20readme?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix duplicate explicit name in readme --- .bumpversion.cfg | 2 +- .github/workflows/release.yaml | 2 +- README.rst | 4 ++-- pyproject.toml | 2 +- pyyoutube/__version__.py | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index a39ba3f2..ae633b4e 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.9.1 +current_version = 0.9.0 commit = True tag = True diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index c69115de..297696f2 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -9,7 +9,7 @@ jobs: steps: - uses: actions/checkout@v2 - name: Build and publish to pypi - uses: JRubics/poetry-publish@v1.13 + uses: JRubics/poetry-publish@v1.17 with: pypi_token: ${{ secrets.PYPI_TOKEN }} diff --git a/README.rst b/README.rst index 70d4a312..855396f8 100644 --- a/README.rst +++ b/README.rst @@ -104,7 +104,7 @@ Get channel detail: ... } -To get more usage to see our `docs `_, or `examples `_ +To get more usage to see our `client docs `_, or `client examples `_ Work with API ---------------- @@ -184,4 +184,4 @@ Get channel detail: ... } -To get more usage to see our `docs `_, or `examples `_ +To get more usage to see our `api docs `_, or `api examples `_ diff --git a/pyproject.toml b/pyproject.toml index d92754c7..2c6ec99c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "python-youtube" -version = "0.9.1" +version = "0.9.0" description = "A Python wrapper around for YouTube Data API." authors = ["ikaroskun "] license = "MIT" diff --git a/pyyoutube/__version__.py b/pyyoutube/__version__.py index ba1fec40..eafa166e 100644 --- a/pyyoutube/__version__.py +++ b/pyyoutube/__version__.py @@ -5,4 +5,4 @@ # 88 88 88 88 88 `8b d8' 88 V888 88 `8b d8' 88b d88 88 88b d88 88 8D 88. # 88 YP YP YP YP `Y88P' VP V8P YP `Y88P' ~Y8888P' YP ~Y8888P' Y8888P' Y88888P -__version__ = "0.9.1" +__version__ = "0.9.0" From 1ed2f67a55b8df75c5fab9aacd7d9ff4d460812a Mon Sep 17 00:00:00 2001 From: ikaroskun Date: Wed, 19 Jul 2023 18:20:10 +0800 Subject: [PATCH 093/141] =?UTF-8?q?Bump=20version:=200.9.0=20=E2=86=92=200?= =?UTF-8?q?.9.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .bumpversion.cfg | 2 +- pyproject.toml | 2 +- pyyoutube/__version__.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index ae633b4e..a39ba3f2 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.9.0 +current_version = 0.9.1 commit = True tag = True diff --git a/pyproject.toml b/pyproject.toml index 2c6ec99c..d92754c7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "python-youtube" -version = "0.9.0" +version = "0.9.1" description = "A Python wrapper around for YouTube Data API." authors = ["ikaroskun "] license = "MIT" diff --git a/pyyoutube/__version__.py b/pyyoutube/__version__.py index eafa166e..ba1fec40 100644 --- a/pyyoutube/__version__.py +++ b/pyyoutube/__version__.py @@ -5,4 +5,4 @@ # 88 88 88 88 88 `8b d8' 88 V888 88 `8b d8' 88b d88 88 88b d88 88 8D 88. # 88 YP YP YP YP `Y88P' VP V8P YP `Y88P' ~Y8888P' YP ~Y8888P' Y8888P' Y88888P -__version__ = "0.9.0" +__version__ = "0.9.1" From ac2ae0592218a9b39411241994122bbeb1ab4f1f Mon Sep 17 00:00:00 2001 From: ik Date: Tue, 26 Sep 2023 10:27:23 +0800 Subject: [PATCH 094/141] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20search?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit add new parameter for search method --- pyyoutube/api.py | 3 +++ pyyoutube/resources/comments.py | 2 ++ pyyoutube/resources/search.py | 9 +++++++++ 3 files changed, 14 insertions(+) diff --git a/pyyoutube/api.py b/pyyoutube/api.py index a580a0d3..252af52c 100644 --- a/pyyoutube/api.py +++ b/pyyoutube/api.py @@ -1600,6 +1600,7 @@ def search( video_duration: Optional[str] = None, video_embeddable: Optional[str] = None, video_license: Optional[str] = None, + video_paid_product_placement: Optional[str] = None, video_syndicated: Optional[str] = None, video_type: Optional[str] = None, page_token: Optional[str] = None, @@ -1670,6 +1671,8 @@ def search( args["videoEmbeddable"] = video_embeddable if video_license: args["videoLicense"] = video_license + if video_paid_product_placement: + args["videoPaidProductPlacement"] = video_paid_product_placement if video_syndicated: args["videoSyndicated"] = video_syndicated if video_type: diff --git a/pyyoutube/resources/comments.py b/pyyoutube/resources/comments.py index 492aab81..3aa32582 100644 --- a/pyyoutube/resources/comments.py +++ b/pyyoutube/resources/comments.py @@ -162,6 +162,8 @@ def mark_as_spam( ) -> bool: """Expresses the caller's opinion that one or more comments should be flagged as spam. + Deprecated at [2023.09.12](https://developers.google.com/youtube/v3/revision_history#september-12,-2023) + Args: comment_id: ID for the target comment. diff --git a/pyyoutube/resources/search.py b/pyyoutube/resources/search.py index dfc13dae..03dc77a0 100644 --- a/pyyoutube/resources/search.py +++ b/pyyoutube/resources/search.py @@ -46,6 +46,7 @@ def list( video_duration: Optional[str] = None, video_embeddable: Optional[str] = None, video_license: Optional[str] = None, + video_paid_product_placement: Optional[str] = None, video_syndicated: Optional[str] = None, video_type: Optional[str] = None, return_json: bool = False, @@ -71,6 +72,7 @@ def list( Parameter restricts the search to only retrieve videos owned by the authenticated user. related_to_video_id: Parameter retrieves a list of videos that are related to the video that the parameter value identifies. + Deprecated at [2023.08.07](https://developers.google.com/youtube/v3/revision_history#august-7,-2023) channel_id: Indicates that the API response should only contain resources created by the channel. channel_type: @@ -182,6 +184,12 @@ def list( - creativeCommon – Only return videos that have a Creative Commons license. Users can reuse videos with this license in other videos that they create. Learn more. - youtube – Only return videos that have the standard YouTube license. + video_paid_product_placement: + Parameter filters search results to only include videos that the creator has denoted as + having a paid promotion. + Acceptable values are: + - any – Return all videos, regardless of whether they contain paid promotions. + - true – Only retrieve videos with paid promotions. video_syndicated: Parameter lets you to restrict a search to only videos that can be played outside youtube.com. Acceptable values are: @@ -229,6 +237,7 @@ def list( "videoDuration": video_duration, "videoEmbeddable": video_embeddable, "videoLicense": video_license, + "videoPaidProductPlacement": video_paid_product_placement, "videoSyndicated": video_syndicated, "videoType": video_type, **kwargs, From 0cbad4ca9eb3177784075b494fa6e853d763c974 Mon Sep 17 00:00:00 2001 From: ik Date: Tue, 26 Sep 2023 10:33:22 +0800 Subject: [PATCH 095/141] =?UTF-8?q?test:=20=F0=9F=92=8D=20coverage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/apis/test_search.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/apis/test_search.py b/tests/apis/test_search.py index 51a2a0d5..1786bc8f 100644 --- a/tests/apis/test_search.py +++ b/tests/apis/test_search.py @@ -158,6 +158,7 @@ def testSearchByDeveloper(self) -> None: video_duration="any", video_embeddable="any", video_license="any", + video_paid_product_placement="any", video_syndicated="any", video_type="any", ) From 177c968de8a5ca0c2731e147392a18bd5c068502 Mon Sep 17 00:00:00 2001 From: ikaroskun Date: Tue, 26 Sep 2023 10:42:41 +0800 Subject: [PATCH 096/141] =?UTF-8?q?docs:=20=E2=9C=8F=EF=B8=8F=20changelog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit update changelog for new version --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c55276e..50e5c2b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ All notable changes to this project will be documented in this file. +## Version 0.9.2 (2023-09-26) + +### What's New + +- Add new parameter for search method +- Mark some parameter or method to be deprecated. + ## Version 0.9.1 (2023-07-19) ### What's New From 67596627ea4ab89860a66d3b46cab95ac2f41f8e Mon Sep 17 00:00:00 2001 From: ikaroskun Date: Tue, 26 Sep 2023 10:43:21 +0800 Subject: [PATCH 097/141] =?UTF-8?q?Bump=20version:=200.9.1=20=E2=86=92=200?= =?UTF-8?q?.9.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .bumpversion.cfg | 2 +- pyproject.toml | 2 +- pyyoutube/__version__.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index a39ba3f2..25e2f59c 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.9.1 +current_version = 0.9.2 commit = True tag = True diff --git a/pyproject.toml b/pyproject.toml index d92754c7..36feb135 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "python-youtube" -version = "0.9.1" +version = "0.9.2" description = "A Python wrapper around for YouTube Data API." authors = ["ikaroskun "] license = "MIT" diff --git a/pyyoutube/__version__.py b/pyyoutube/__version__.py index ba1fec40..3075670c 100644 --- a/pyyoutube/__version__.py +++ b/pyyoutube/__version__.py @@ -5,4 +5,4 @@ # 88 88 88 88 88 `8b d8' 88 V888 88 `8b d8' 88b d88 88 88b d88 88 8D 88. # 88 YP YP YP YP `Y88P' VP V8P YP `Y88P' ~Y8888P' YP ~Y8888P' Y8888P' Y88888P -__version__ = "0.9.1" +__version__ = "0.9.2" From de537d704b3f7ae69e700e4a3f2f48791b57711e Mon Sep 17 00:00:00 2001 From: pidi3000 Date: Thu, 12 Oct 2023 00:16:15 +0200 Subject: [PATCH 098/141] Added client_secret.json support for Client class --- pyyoutube/client.py | 77 +++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 75 insertions(+), 2 deletions(-) diff --git a/pyyoutube/client.py b/pyyoutube/client.py index b819c788..9fd29d06 100644 --- a/pyyoutube/client.py +++ b/pyyoutube/client.py @@ -18,6 +18,8 @@ ) from pyyoutube.resources.base_resource import Resource +import json + def _is_resource_endpoint(obj): return isinstance(obj, Resource) @@ -77,6 +79,7 @@ def __init__( access_token: Optional[str] = None, refresh_token: Optional[str] = None, api_key: Optional[str] = None, + client_secret_path: Optional[str] = None, timeout: Optional[int] = None, proxies: Optional[dict] = None, headers: Optional[dict] = None, @@ -94,6 +97,8 @@ def __init__( Refresh Token for user. api_key: API key for your app which generated from api console. + client_secret_path: + path to the client_secret.json file provided by google console timeout: Timeout for every request. proxies: @@ -116,9 +121,14 @@ def __init__( self.session = requests.Session() self.merge_headers() + if not self._has_client_data() and client_secret_path is not None: + # try to use client_secret file + self._from_client_secrets_file(client_secret_path) + # Auth settings if not ( - (self.client_id and self.client_secret) or self.api_key or self.access_token + self._has_auth_credentials() or + self._has_client_data() ): raise PyYouTubeException( ErrorMessage( @@ -127,6 +137,68 @@ def __init__( ) ) + def _from_client_secrets_file(self, client_secret_path: str): + """Set credentials from client_sectet file + + Args: + client_secret_path: + path to the client_secret.json file, provided by google console + + Raises: + PyYouTubeException: missing required key, client_secret file not in 'web' format. + """ + try: + secrets_data = None + + with open(client_secret_path, "r") as f: + secrets_data = json.load(f) + + # For now only 'web' client_secret files are support, + # some 'installed' type files can have missing 'client_secret' key + if "web" in secrets_data: + secrets_data = secrets_data["web"] + + self.client_id = secrets_data["client_id"] + self.client_secret = secrets_data["client_secret"] + + # Set default redirect to first defined in client_secrets file + if "redirect_uris" in secrets_data and len(secrets_data["redirect_uris"]) > 0: + self.DEFAULT_REDIRECT_URI = secrets_data["redirect_uris"][0] + + return + + else: + raise PyYouTubeException( + ErrorMessage( + status_code=ErrorCode.INVALID_PARAMS, + message="Only 'web' client_secret file are supported.", + ) + ) + + except KeyError as ke: + raise PyYouTubeException( + ErrorMessage( + status_code=ErrorCode.INVALID_PARAMS, + message=f"client_secret file is missing required key: '{ke.args[0]}'", + ) + ) + + except Exception: + # Not sure if catching any other error and + # wrapping it in a PyYouTubeException is a good idea + raise PyYouTubeException( + ErrorMessage( + status_code=ErrorCode.INVALID_PARAMS, + message="Can not load from client_secret file.", + ) + ) + + def _has_auth_credentials(self) -> bool: + return self.api_key or self.access_token + + def _has_client_data(self) -> bool: + return (self.client_id and self.client_secret) + def merge_headers(self): """Merge custom headers to session.""" if self.headers: @@ -227,7 +299,8 @@ def request( ) except requests.HTTPError as e: raise PyYouTubeException( - ErrorMessage(status_code=ErrorCode.HTTP_ERROR, message=e.args[0]) + ErrorMessage(status_code=ErrorCode.HTTP_ERROR, + message=e.args[0]) ) else: return response From f4cfee5ab4ff86b07230347b6292a6bef8ffa5b5 Mon Sep 17 00:00:00 2001 From: pidi3000 Date: Thu, 12 Oct 2023 00:19:26 +0200 Subject: [PATCH 099/141] Added example for client_secret.json --- examples/clients/oauth_flow.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/examples/clients/oauth_flow.py b/examples/clients/oauth_flow.py index 5fa13971..ef358b12 100644 --- a/examples/clients/oauth_flow.py +++ b/examples/clients/oauth_flow.py @@ -4,8 +4,11 @@ from pyyoutube import Client + CLIENT_ID = "xxx" # Your app id CLIENT_SECRET = "xxx" # Your app secret +CLIENT_SECRET_PATH = None # or your path/to/client_secret_web.json + SCOPE = [ "https://www.googleapis.com/auth/youtube", "https://www.googleapis.com/auth/youtube.force-ssl", @@ -15,6 +18,8 @@ def do_authorize(): cli = Client(client_id=CLIENT_ID, client_secret=CLIENT_SECRET) + # or if you want to use a web type client_secret.json + # cli = Client(client_secret_path=CLIENT_SECRET_PATH) authorize_url, state = cli.get_authorize_url(scope=SCOPE) print(f"Click url to do authorize: {authorize_url}") From 570db0fdd04c7488e74b2689e1c86dee9fdac500 Mon Sep 17 00:00:00 2001 From: pidi3000 Date: Wed, 25 Oct 2023 23:04:09 +0200 Subject: [PATCH 100/141] Code formatting --- pyyoutube/client.py | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/pyyoutube/client.py b/pyyoutube/client.py index 9fd29d06..29da87fa 100644 --- a/pyyoutube/client.py +++ b/pyyoutube/client.py @@ -98,7 +98,7 @@ def __init__( api_key: API key for your app which generated from api console. client_secret_path: - path to the client_secret.json file provided by google console + path to the client_secret.json file provided by google console timeout: Timeout for every request. proxies: @@ -126,10 +126,7 @@ def __init__( self._from_client_secrets_file(client_secret_path) # Auth settings - if not ( - self._has_auth_credentials() or - self._has_client_data() - ): + if not (self._has_auth_credentials() or self._has_client_data()): raise PyYouTubeException( ErrorMessage( status_code=ErrorCode.MISSING_PARAMS, @@ -162,7 +159,10 @@ def _from_client_secrets_file(self, client_secret_path: str): self.client_secret = secrets_data["client_secret"] # Set default redirect to first defined in client_secrets file - if "redirect_uris" in secrets_data and len(secrets_data["redirect_uris"]) > 0: + if ( + "redirect_uris" in secrets_data + and len(secrets_data["redirect_uris"]) > 0 + ): self.DEFAULT_REDIRECT_URI = secrets_data["redirect_uris"][0] return @@ -197,7 +197,7 @@ def _has_auth_credentials(self) -> bool: return self.api_key or self.access_token def _has_client_data(self) -> bool: - return (self.client_id and self.client_secret) + return self.client_id and self.client_secret def merge_headers(self): """Merge custom headers to session.""" @@ -299,8 +299,7 @@ def request( ) except requests.HTTPError as e: raise PyYouTubeException( - ErrorMessage(status_code=ErrorCode.HTTP_ERROR, - message=e.args[0]) + ErrorMessage(status_code=ErrorCode.HTTP_ERROR, message=e.args[0]) ) else: return response From 6f2bd3adc0c1da39dc09d55169965a67b206a501 Mon Sep 17 00:00:00 2001 From: pidi3000 Date: Wed, 25 Oct 2023 23:05:13 +0200 Subject: [PATCH 101/141] Added test for client secret web --- testdata/apidata/client_secret_web.json | 13 +++++++++++++ tests/clients/test_client.py | 9 +++++++++ 2 files changed, 22 insertions(+) create mode 100644 testdata/apidata/client_secret_web.json diff --git a/testdata/apidata/client_secret_web.json b/testdata/apidata/client_secret_web.json new file mode 100644 index 00000000..38b1d8d3 --- /dev/null +++ b/testdata/apidata/client_secret_web.json @@ -0,0 +1,13 @@ +{ + "web": { + "client_id": "client_id", + "project_id": "project_id", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token", + "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", + "client_secret": "client_secret", + "redirect_uris": [ + "http://localhost:5000/oauth2callback" + ] + } +} \ No newline at end of file diff --git a/tests/clients/test_client.py b/tests/clients/test_client.py index feb6c0f6..34fe3d8e 100644 --- a/tests/clients/test_client.py +++ b/tests/clients/test_client.py @@ -21,6 +21,15 @@ def test_initial(self): cli = Client(api_key="key", headers={"HA": "P"}) assert cli.session.headers["HA"] == "P" + def test_client_secret_web(self): + filename = "apidata/client_secret_web.json" + client_secret_path = f"{self.BASE_PATH}/{filename}" + cli = Client(client_secret_path=client_secret_path) + + assert cli.client_id == "client_id" + assert cli.client_secret == "client_secret" + assert cli.DEFAULT_REDIRECT_URI == "http://localhost:5000/oauth2callback" + def test_request(self, key_cli): with pytest.raises(PyYouTubeException): cli = Client(client_id="id", client_secret="secret") From a0c38fc91cc6d57d724daaaa534d236d32120b7c Mon Sep 17 00:00:00 2001 From: ikaroskun Date: Tue, 31 Oct 2023 11:12:31 +0800 Subject: [PATCH 102/141] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20python?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit update tests for new python version --- .github/workflows/test.yaml | 8 ++++---- pyproject.toml | 3 +++ 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 81cfb6f7..16a9cac9 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -11,15 +11,15 @@ jobs: runs-on: ubuntu-20.04 strategy: matrix: - python-version: [3.6, 3.7, 3.8, 3.9] + python-version: ['3.6', '3.7', '3.8', '3.9', '3.10', '3.11', '3.12'] include: - - python-version: 3.8 + - python-version: '3.8' update-coverage: true steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - name: Cache pip diff --git a/pyproject.toml b/pyproject.toml index 36feb135..3f03fb54 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,9 @@ classifiers = [ "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", "Topic :: Software Development :: Libraries :: Python Modules", From c49dc3651b07f9e395962c167c925816d2197342 Mon Sep 17 00:00:00 2001 From: ikaroskun Date: Tue, 31 Oct 2023 11:47:00 +0800 Subject: [PATCH 103/141] =?UTF-8?q?chore:=20=F0=9F=A4=96=20dependency?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit update dependencies for tests --- pyproject.toml | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 3f03fb54..335fe857 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,9 +37,18 @@ isodate = "^0.6.0" dataclasses-json = "^0.5.3" [tool.poetry.dev-dependencies] -responses = "^0.12.0" -pytest = "^6.0.2" -pytest-cov = "^2.10.1" +responses = [ + {version = "^0.17.0", python = "<3.7"}, + {version = "^0.23.0", python = ">=3.7"} +] +pytest = [ + {version = "^6.2", python = "<3.7"}, + {version = "^7.1", python = ">=3.7"} +] +pytest-cov = [ + {version = "^2.10.1", python = "<3.7"}, + {version = "^3.0.0", python = ">=3.7"} +] [build-system] requires = ["poetry-core>=1.0.0"] From bb27f5280ee6ed7056d50209940f4a71a384aa52 Mon Sep 17 00:00:00 2001 From: ikaroskun Date: Tue, 31 Oct 2023 12:01:03 +0800 Subject: [PATCH 104/141] =?UTF-8?q?chore:=20=F0=9F=A4=96=20dependency?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit make dataclasses json work well on 3.12 --- pyproject.toml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 335fe857..df175659 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,7 +34,10 @@ python = "^3.6" requests = "^2.24.0" requests-oauthlib = "^1.3.0" isodate = "^0.6.0" -dataclasses-json = "^0.5.3" +dataclasses-json = [ + {version = "^0.5.3", python = "<3.7"}, + {version = "^0.6.0", python = ">=3.7"} +] [tool.poetry.dev-dependencies] responses = [ From e5bc83e8c58225822cdaef6c00d6bb5ae0c324d0 Mon Sep 17 00:00:00 2001 From: pidi3000 Date: Wed, 1 Nov 2023 05:16:01 +0100 Subject: [PATCH 105/141] Resolve review comments --- pyyoutube/client.py | 57 ++++++++++++--------------------------------- 1 file changed, 15 insertions(+), 42 deletions(-) diff --git a/pyyoutube/client.py b/pyyoutube/client.py index 29da87fa..a47680ff 100644 --- a/pyyoutube/client.py +++ b/pyyoutube/client.py @@ -2,6 +2,7 @@ New Client for YouTube API """ import inspect +import json from typing import List, Optional, Tuple, Union import requests @@ -18,8 +19,6 @@ ) from pyyoutube.resources.base_resource import Resource -import json - def _is_resource_endpoint(obj): return isinstance(obj, Resource) @@ -144,54 +143,28 @@ def _from_client_secrets_file(self, client_secret_path: str): Raises: PyYouTubeException: missing required key, client_secret file not in 'web' format. """ - try: - secrets_data = None - - with open(client_secret_path, "r") as f: - secrets_data = json.load(f) - - # For now only 'web' client_secret files are support, - # some 'installed' type files can have missing 'client_secret' key - if "web" in secrets_data: - secrets_data = secrets_data["web"] - self.client_id = secrets_data["client_id"] - self.client_secret = secrets_data["client_secret"] + with open(client_secret_path, "r") as f: + secrets_data = json.load(f) - # Set default redirect to first defined in client_secrets file - if ( - "redirect_uris" in secrets_data - and len(secrets_data["redirect_uris"]) > 0 - ): - self.DEFAULT_REDIRECT_URI = secrets_data["redirect_uris"][0] - - return - - else: - raise PyYouTubeException( - ErrorMessage( - status_code=ErrorCode.INVALID_PARAMS, - message="Only 'web' client_secret file are supported.", - ) - ) - - except KeyError as ke: + # For now only 'web' client_secret files are support, + # some 'installed' type files can have missing 'client_secret' key + if "web" not in secrets_data: raise PyYouTubeException( ErrorMessage( status_code=ErrorCode.INVALID_PARAMS, - message=f"client_secret file is missing required key: '{ke.args[0]}'", + message="Only 'web' type client_secret files are supported.", ) ) - except Exception: - # Not sure if catching any other error and - # wrapping it in a PyYouTubeException is a good idea - raise PyYouTubeException( - ErrorMessage( - status_code=ErrorCode.INVALID_PARAMS, - message="Can not load from client_secret file.", - ) - ) + secrets_data = secrets_data["web"] + + self.client_id = secrets_data["client_id"] + self.client_secret = secrets_data["client_secret"] + + # Set default redirect to first defined in client_secrets file + if "redirect_uris" in secrets_data and len(secrets_data["redirect_uris"]) > 0: + self.DEFAULT_REDIRECT_URI = secrets_data["redirect_uris"][0] def _has_auth_credentials(self) -> bool: return self.api_key or self.access_token From 98f7680fbcbd5bd2c70b6892dd15dd1c6b3ec56d Mon Sep 17 00:00:00 2001 From: pidi3000 Date: Thu, 2 Nov 2023 03:20:37 +0100 Subject: [PATCH 106/141] Support for 'installed' type + better field checking --- pyyoutube/client.py | 31 +++++++++++++++++++++---------- 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/pyyoutube/client.py b/pyyoutube/client.py index a47680ff..610a2de1 100644 --- a/pyyoutube/client.py +++ b/pyyoutube/client.py @@ -147,24 +147,35 @@ def _from_client_secrets_file(self, client_secret_path: str): with open(client_secret_path, "r") as f: secrets_data = json.load(f) - # For now only 'web' client_secret files are support, - # some 'installed' type files can have missing 'client_secret' key - if "web" not in secrets_data: + credentials = None + for secrets_type in ["web", "installed"]: + if secrets_type in secrets_data: + credentials = secrets_data[secrets_type] + + if not credentials: raise PyYouTubeException( ErrorMessage( status_code=ErrorCode.INVALID_PARAMS, - message="Only 'web' type client_secret files are supported.", + message="Only 'web' and 'installed' type client_secret files are supported.", ) ) - secrets_data = secrets_data["web"] + # check for reqiered fields + for field in ["client_secret", "client_id"]: + if field not in credentials: + raise PyYouTubeException( + ErrorMessage( + status_code=ErrorCode.MISSING_PARAMS, + message=f"file is missing required field '{field}'.", + ) + ) - self.client_id = secrets_data["client_id"] - self.client_secret = secrets_data["client_secret"] + self.client_id = credentials["client_id"] + self.client_secret = credentials["client_secret"] - # Set default redirect to first defined in client_secrets file - if "redirect_uris" in secrets_data and len(secrets_data["redirect_uris"]) > 0: - self.DEFAULT_REDIRECT_URI = secrets_data["redirect_uris"][0] + # Set default redirect to first defined in client_secrets file if any + if "redirect_uris" in credentials and len(credentials["redirect_uris"]) > 0: + self.DEFAULT_REDIRECT_URI = credentials["redirect_uris"][0] def _has_auth_credentials(self) -> bool: return self.api_key or self.access_token From 8efa3d9a449518bc52b36437c94e14383c4f3e79 Mon Sep 17 00:00:00 2001 From: pidi3000 Date: Thu, 2 Nov 2023 03:33:17 +0100 Subject: [PATCH 107/141] Typo fix --- pyyoutube/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyyoutube/client.py b/pyyoutube/client.py index 610a2de1..aa7006c9 100644 --- a/pyyoutube/client.py +++ b/pyyoutube/client.py @@ -166,7 +166,7 @@ def _from_client_secrets_file(self, client_secret_path: str): raise PyYouTubeException( ErrorMessage( status_code=ErrorCode.MISSING_PARAMS, - message=f"file is missing required field '{field}'.", + message=f"missing required field '{field}'.", ) ) From 29f83bc7189c40e03933ebf9801fece9c0a591eb Mon Sep 17 00:00:00 2001 From: pidi3000 Date: Thu, 2 Nov 2023 03:42:36 +0100 Subject: [PATCH 108/141] Improved testing for client_secret file --- .../client_secret_installed_bad.json | 9 +++++++ .../client_secret_installed_good.json | 10 ++++++++ .../client_secret_unsupported.json | 9 +++++++ .../client_secret_web.json | 0 tests/clients/test_client.py | 24 ++++++++++++++++++- 5 files changed, 51 insertions(+), 1 deletion(-) create mode 100644 testdata/apidata/client_secrets/client_secret_installed_bad.json create mode 100644 testdata/apidata/client_secrets/client_secret_installed_good.json create mode 100644 testdata/apidata/client_secrets/client_secret_unsupported.json rename testdata/apidata/{ => client_secrets}/client_secret_web.json (100%) diff --git a/testdata/apidata/client_secrets/client_secret_installed_bad.json b/testdata/apidata/client_secrets/client_secret_installed_bad.json new file mode 100644 index 00000000..d24d0728 --- /dev/null +++ b/testdata/apidata/client_secrets/client_secret_installed_bad.json @@ -0,0 +1,9 @@ +{ + "installed": { + "client_id": "client_id", + "project_id": "project_id", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token", + "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs" + } +} \ No newline at end of file diff --git a/testdata/apidata/client_secrets/client_secret_installed_good.json b/testdata/apidata/client_secrets/client_secret_installed_good.json new file mode 100644 index 00000000..1b805c13 --- /dev/null +++ b/testdata/apidata/client_secrets/client_secret_installed_good.json @@ -0,0 +1,10 @@ +{ + "installed": { + "client_id": "client_id", + "project_id": "project_id", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token", + "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", + "client_secret": "client_secret" + } +} \ No newline at end of file diff --git a/testdata/apidata/client_secrets/client_secret_unsupported.json b/testdata/apidata/client_secrets/client_secret_unsupported.json new file mode 100644 index 00000000..c2e656db --- /dev/null +++ b/testdata/apidata/client_secrets/client_secret_unsupported.json @@ -0,0 +1,9 @@ +{ + "unsupported": { + "client_id": "client_id", + "project_id": "project_id", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token", + "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs" + } +} \ No newline at end of file diff --git a/testdata/apidata/client_secret_web.json b/testdata/apidata/client_secrets/client_secret_web.json similarity index 100% rename from testdata/apidata/client_secret_web.json rename to testdata/apidata/client_secrets/client_secret_web.json diff --git a/tests/clients/test_client.py b/tests/clients/test_client.py index 34fe3d8e..9010454e 100644 --- a/tests/clients/test_client.py +++ b/tests/clients/test_client.py @@ -22,7 +22,7 @@ def test_initial(self): assert cli.session.headers["HA"] == "P" def test_client_secret_web(self): - filename = "apidata/client_secret_web.json" + filename = "apidata/client_secrets/client_secret_web.json" client_secret_path = f"{self.BASE_PATH}/{filename}" cli = Client(client_secret_path=client_secret_path) @@ -30,6 +30,28 @@ def test_client_secret_web(self): assert cli.client_secret == "client_secret" assert cli.DEFAULT_REDIRECT_URI == "http://localhost:5000/oauth2callback" + def test_client_secret_installed(self): + filename_good = "apidata/client_secrets/client_secret_installed_good.json" + client_secret_good_path = f"{self.BASE_PATH}/{filename_good}" + + cli = Client(client_secret_path=client_secret_good_path) + + assert cli.client_id == "client_id" + assert cli.client_secret == "client_secret" + + def test_client_secret_bad(self): + filename_bad = "apidata/client_secrets/client_secret_installed_bad.json" + filename_unsupported = "apidata/client_secrets/client_secret_unsupported.json" + + client_secret_bad_path = f"{self.BASE_PATH}/{filename_bad}" + client_secret_unsupported_path = f"{self.BASE_PATH}/{filename_unsupported}" + + with pytest.raises(PyYouTubeException): + Client(client_secret_path=client_secret_bad_path) + + with pytest.raises(PyYouTubeException): + Client(client_secret_path=client_secret_unsupported_path) + def test_request(self, key_cli): with pytest.raises(PyYouTubeException): cli = Client(client_id="id", client_secret="secret") From adeb017fdac4c4fe138ed7f175bfb493799e31d1 Mon Sep 17 00:00:00 2001 From: pidi3000 Date: Thu, 2 Nov 2023 04:05:11 +0100 Subject: [PATCH 109/141] Added docs for client_secret --- docs/docs/usage/work-with-client.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/docs/docs/usage/work-with-client.md b/docs/docs/usage/work-with-client.md index c4bb9269..3e2adffc 100644 --- a/docs/docs/usage/work-with-client.md +++ b/docs/docs/usage/work-with-client.md @@ -41,6 +41,23 @@ cli.generate_access_token(authorization_response="redirected url") # AccessToken(access_token='token', expires_in=3599, token_type='Bearer') ``` +### from client_secret + +Only `web` and some `installed` type client_secrets are supported. + +The fields `client_id` and `client_secret` must be set. + +If the field `redirect_uris` has 1 or more values set, the `Client.DEFAULT_REDIRECT_URI` will be set to the first entrie. + +```python +from pyyoutube import Client + +file_path = "path/to/client_secret.json" +cli = Client(client_secret_path=file_path) + +# Then go through auth flow descriped above +``` + Once initialize client. Then you can operate API to get data. ## Usage From 107bb36f73667e381cae71442256a27433482efb Mon Sep 17 00:00:00 2001 From: ikaroskun Date: Wed, 22 Nov 2023 16:00:41 +0800 Subject: [PATCH 110/141] docs(comments): :wastebasket: deprecation for comments resource videoId field --- pyyoutube/models/comment.py | 1 + 1 file changed, 1 insertion(+) diff --git a/pyyoutube/models/comment.py b/pyyoutube/models/comment.py index 1c1ad8f0..e979c3bc 100644 --- a/pyyoutube/models/comment.py +++ b/pyyoutube/models/comment.py @@ -36,6 +36,7 @@ class CommentSnippet(BaseModel, DatetimeTimeMixin): default=None, repr=False ) channelId: Optional[str] = field(default=None, repr=False) + # videoId has deprecated, see https://developers.google.com/youtube/v3/revision_history#november-09,-2023 videoId: Optional[str] = field(default=None, repr=False) textDisplay: Optional[str] = field(default=None, repr=False) textOriginal: Optional[str] = field(default=None, repr=False) From 2bdbeff090927ac7fbc611e48cd79b5d9eb6bf5b Mon Sep 17 00:00:00 2001 From: liukun Date: Wed, 22 Nov 2023 16:09:30 +0800 Subject: [PATCH 111/141] docs(changelog): :memo: update changelog for new release --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 50e5c2b6..aca8edb4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ All notable changes to this project will be documented in this file. +## Version 0.9.3 (2023-11-22) + +### What's New + +- Add initial client with client_secret file. Thanks for [@pidi3000](https://github.com/pidi3000) + + ## Version 0.9.2 (2023-09-26) ### What's New From a531987cf5f426170399f227ca07a85ecba1358f Mon Sep 17 00:00:00 2001 From: liukun Date: Wed, 22 Nov 2023 16:10:22 +0800 Subject: [PATCH 112/141] =?UTF-8?q?Bump=20version:=200.9.2=20=E2=86=92=200?= =?UTF-8?q?.9.3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .bumpversion.cfg | 2 +- pyproject.toml | 2 +- pyyoutube/__version__.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 25e2f59c..70b147d9 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.9.2 +current_version = 0.9.3 commit = True tag = True diff --git a/pyproject.toml b/pyproject.toml index df175659..b2b70bcf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "python-youtube" -version = "0.9.2" +version = "0.9.3" description = "A Python wrapper around for YouTube Data API." authors = ["ikaroskun "] license = "MIT" diff --git a/pyyoutube/__version__.py b/pyyoutube/__version__.py index 3075670c..bd3cab4c 100644 --- a/pyyoutube/__version__.py +++ b/pyyoutube/__version__.py @@ -5,4 +5,4 @@ # 88 88 88 88 88 `8b d8' 88 V888 88 `8b d8' 88b d88 88 88b d88 88 8D 88. # 88 YP YP YP YP `Y88P' VP V8P YP `Y88P' ~Y8888P' YP ~Y8888P' Y8888P' Y88888P -__version__ = "0.9.2" +__version__ = "0.9.3" From a918104be69fee2a43f72f8f5793017623e790de Mon Sep 17 00:00:00 2001 From: ikaroskun Date: Sun, 4 Feb 2024 11:44:11 +0800 Subject: [PATCH 113/141] feat(channel): :sparkles: support get channel by channel handle --- pyyoutube/api.py | 10 +++++++++- pyyoutube/resources/channels.py | 10 +++++++++- testdata/apidata/channel_info_single.json | 2 +- tests/apis/test_channels.py | 8 ++++++++ tests/clients/test_channels.py | 6 ++++++ 5 files changed, 33 insertions(+), 3 deletions(-) diff --git a/pyyoutube/api.py b/pyyoutube/api.py index 252af52c..37fada2d 100644 --- a/pyyoutube/api.py +++ b/pyyoutube/api.py @@ -725,6 +725,7 @@ def get_channel_info( self, *, channel_id: Optional[Union[str, list, tuple, set]] = None, + for_handle: Optional[str] = None, for_username: Optional[str] = None, mine: Optional[bool] = None, parts: Optional[Union[str, list, tuple, set]] = None, @@ -744,6 +745,11 @@ def get_channel_info( channel_id ((str,list,tuple,set), optional): The id or comma-separated id string for youtube channel which you want to get. You can also pass this with an id list, tuple, set. + for_handle (str, optional): + The parameter specifies a YouTube handle, thereby requesting the channel associated with that handle. + The parameter value can be prepended with an @ symbol. For example, to retrieve the resource for + the "Google for Developers" channel, set the forHandle parameter value to + either GoogleDevelopers or @GoogleDevelopers. for_username (str, optional): The name for YouTube username which you want to get. Note: This name may the old youtube version's channel's user's username, Not the the channel name. @@ -768,7 +774,9 @@ def get_channel_info( "part": enf_parts(resource="channels", value=parts), "hl": hl, } - if for_username is not None: + if for_handle is not None: + args["forHandle"] = for_handle + elif for_username is not None: args["forUsername"] = for_username elif channel_id is not None: args["id"] = enf_comma_separated("channel_id", channel_id) diff --git a/pyyoutube/resources/channels.py b/pyyoutube/resources/channels.py index 2fe11912..4dac199d 100644 --- a/pyyoutube/resources/channels.py +++ b/pyyoutube/resources/channels.py @@ -18,6 +18,7 @@ class ChannelsResource(Resource): def list( self, parts: Optional[Union[str, list, tuple, set]] = None, + for_handle: Optional[str] = None, for_username: Optional[str] = None, channel_id: Optional[Union[str, list, tuple, set]] = None, managed_by_me: Optional[bool] = None, @@ -36,6 +37,11 @@ def list( Comma-separated list of one or more channel resource properties. Accepted values: id,auditDetails,brandingSettings,contentDetails,contentOwnerDetails, localizations,snippet,statistics,status,topicDetails + for_handle: + The parameter specifies a YouTube handle, thereby requesting the channel associated with that handle. + The parameter value can be prepended with an @ symbol. For example, to retrieve the resource for + the "Google for Developers" channel, set the forHandle parameter value to + either GoogleDevelopers or @GoogleDevelopers. for_username: The parameter specifies a YouTube username, thereby requesting the channel associated with that username. @@ -90,7 +96,9 @@ def list( "pageToken": page_token, **kwargs, } - if for_username is not None: + if for_handle is not None: + params["forHandle"] = for_handle + elif for_username is not None: params["forUsername"] = for_username elif channel_id is not None: params["id"] = enf_comma_separated(field="channel_id", value=channel_id) diff --git a/testdata/apidata/channel_info_single.json b/testdata/apidata/channel_info_single.json index 086a4065..2e4e3902 100644 --- a/testdata/apidata/channel_info_single.json +++ b/testdata/apidata/channel_info_single.json @@ -13,7 +13,7 @@ "snippet": { "title": "Google Developers", "description": "The Google Developers channel features talks from events, educational series, best practices, tips, and the latest updates across our products and platforms.", - "customUrl": "googledevelopers", + "customUrl": "@googledevelopers", "publishedAt": "2007-08-23T00:34:43.000Z", "thumbnails": { "default": { diff --git a/tests/apis/test_channels.py b/tests/apis/test_channels.py index 351a4a98..b91611e0 100644 --- a/tests/apis/test_channels.py +++ b/tests/apis/test_channels.py @@ -62,6 +62,14 @@ def testGetChannelInfo(self) -> None: ) self.assertEqual(res_by_channel_id.items[0].id, "UC_x5XG1OV2P6uZZ5FSM9Ttw") + res_by_channel_handle = self.api.get_channel_info( + for_username="googledevelopers", return_json=True + ) + self.assertEqual( + res_by_channel_handle["items"][0]["snippet"]["customUrl"], + "@googledevelopers", + ) + res_by_channel_name = self.api.get_channel_info( for_username="GoogleDevelopers", return_json=True ) diff --git a/tests/clients/test_channels.py b/tests/clients/test_channels.py index 7f4c68e1..ee9c5467 100644 --- a/tests/clients/test_channels.py +++ b/tests/clients/test_channels.py @@ -28,6 +28,12 @@ def test_list(self, helpers, authed_cli, key_cli): assert res.items[0].id == self.channel_id assert key_cli.channels.api_key == "api key" + res = key_cli.channels.list( + parts="id,snippet", + for_handle="@googledevelopers", + ) + assert res.items[0].snippet.customUrl == "@googledevelopers" + res = key_cli.channels.list( parts=["id", "snippet"], for_username="googledevelopers" ) From d77ca389f745496b18a3866c37cfeeb82afc7af1 Mon Sep 17 00:00:00 2001 From: ikaroskun Date: Sun, 4 Feb 2024 11:47:05 +0800 Subject: [PATCH 114/141] docs(channel): :memo: update changelog --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index aca8edb4..28ed9303 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ All notable changes to this project will be documented in this file. + +## Version 0.9.4 (development) + +### What's New + +- Add new parameter `for_handle` to get channel by handle. + + ## Version 0.9.3 (2023-11-22) ### What's New From 507c3e59dd79d2a0d362d9ba28a89bb18a7c2901 Mon Sep 17 00:00:00 2001 From: ikaroskun Date: Sun, 4 Feb 2024 11:53:56 +0800 Subject: [PATCH 115/141] style(black): :art: use black new 2024 stable style --- pyyoutube/client.py | 1 + pyyoutube/media.py | 7 ++++--- pyyoutube/models/category.py | 1 + pyyoutube/models/channel.py | 1 + pyyoutube/models/channel_section.py | 3 +-- pyyoutube/models/comment_thread.py | 1 + pyyoutube/models/common.py | 1 + pyyoutube/models/mixins.py | 1 + pyyoutube/models/playlist.py | 1 + pyyoutube/models/search_result.py | 1 + pyyoutube/models/video.py | 1 + pyyoutube/resources/activities.py | 1 + pyyoutube/resources/base_resource.py | 1 + pyyoutube/resources/captions.py | 1 + pyyoutube/resources/channel_banners.py | 1 + pyyoutube/resources/channels.py | 1 + pyyoutube/resources/comment_threads.py | 1 + pyyoutube/resources/membership_levels.py | 1 + pyyoutube/resources/search.py | 1 + pyyoutube/resources/thumbnails.py | 1 + pyyoutube/resources/video_abuse_report_reasons.py | 1 + pyyoutube/resources/watermarks.py | 1 + pyyoutube/utils/params_checker.py | 1 + tests/clients/test_captions.py | 1 + tests/clients/test_channel_banners.py | 1 + tests/clients/test_client.py | 1 + tests/clients/test_media.py | 1 + tests/clients/test_thumbnails.py | 1 + 28 files changed, 31 insertions(+), 5 deletions(-) diff --git a/pyyoutube/client.py b/pyyoutube/client.py index aa7006c9..bdbdfa10 100644 --- a/pyyoutube/client.py +++ b/pyyoutube/client.py @@ -1,6 +1,7 @@ """ New Client for YouTube API """ + import inspect import json from typing import List, Optional, Tuple, Union diff --git a/pyyoutube/media.py b/pyyoutube/media.py index e8493b8e..02e04a11 100644 --- a/pyyoutube/media.py +++ b/pyyoutube/media.py @@ -1,6 +1,7 @@ """ Media object to upload. """ + import mimetypes import os from typing import IO, Optional, Tuple @@ -180,9 +181,9 @@ def next_chunk(self) -> Tuple[Optional[MediaUploadProgress], Optional[dict]]: # sending "bytes 0--1/0" results in an invalid request # Only add header "Content-Range" if chunk_end != -1 if chunk_end != -1: - headers[ - "Content-Range" - ] = f"bytes {self.resumable_progress}-{chunk_end}/{size}" + headers["Content-Range"] = ( + f"bytes {self.resumable_progress}-{chunk_end}/{size}" + ) resp = self.client.request( path=self.resumable_uri, diff --git a/pyyoutube/models/category.py b/pyyoutube/models/category.py index 0d8395f0..7260e3db 100644 --- a/pyyoutube/models/category.py +++ b/pyyoutube/models/category.py @@ -2,6 +2,7 @@ These are category related models. Include VideoCategory """ + from dataclasses import dataclass, field from typing import List, Optional diff --git a/pyyoutube/models/channel.py b/pyyoutube/models/channel.py index 2b4a7d6e..4986602e 100644 --- a/pyyoutube/models/channel.py +++ b/pyyoutube/models/channel.py @@ -3,6 +3,7 @@ References: https://developers.google.com/youtube/v3/docs/channels#properties """ + from dataclasses import dataclass, field from typing import List, Optional diff --git a/pyyoutube/models/channel_section.py b/pyyoutube/models/channel_section.py index cfbbbb41..73d4c9ed 100644 --- a/pyyoutube/models/channel_section.py +++ b/pyyoutube/models/channel_section.py @@ -61,5 +61,4 @@ class ChannelSectionResponse(BaseApiResponse): @dataclass -class ChannelSectionListResponse(ChannelSectionResponse): - ... +class ChannelSectionListResponse(ChannelSectionResponse): ... diff --git a/pyyoutube/models/comment_thread.py b/pyyoutube/models/comment_thread.py index baab3f28..eed6ffc8 100644 --- a/pyyoutube/models/comment_thread.py +++ b/pyyoutube/models/comment_thread.py @@ -1,6 +1,7 @@ """ These are comment threads related models. """ + from dataclasses import dataclass, field from typing import Optional, List diff --git a/pyyoutube/models/common.py b/pyyoutube/models/common.py index 7c81c362..6e554f14 100644 --- a/pyyoutube/models/common.py +++ b/pyyoutube/models/common.py @@ -1,6 +1,7 @@ """ These are common models for multi resource. """ + from dataclasses import dataclass, field from typing import Optional, List diff --git a/pyyoutube/models/mixins.py b/pyyoutube/models/mixins.py index fa92b067..24e463fe 100644 --- a/pyyoutube/models/mixins.py +++ b/pyyoutube/models/mixins.py @@ -1,6 +1,7 @@ """ These are some mixin for models """ + import datetime from typing import Optional diff --git a/pyyoutube/models/playlist.py b/pyyoutube/models/playlist.py index c8cb3c58..a1fa680b 100644 --- a/pyyoutube/models/playlist.py +++ b/pyyoutube/models/playlist.py @@ -1,6 +1,7 @@ """ These are playlist related models. """ + from dataclasses import dataclass, field from typing import Optional, List diff --git a/pyyoutube/models/search_result.py b/pyyoutube/models/search_result.py index d3d92308..c8b242d3 100644 --- a/pyyoutube/models/search_result.py +++ b/pyyoutube/models/search_result.py @@ -1,6 +1,7 @@ """ These are search result related models. """ + from dataclasses import dataclass, field from typing import Optional, List diff --git a/pyyoutube/models/video.py b/pyyoutube/models/video.py index ba39b7b6..19477162 100644 --- a/pyyoutube/models/video.py +++ b/pyyoutube/models/video.py @@ -1,6 +1,7 @@ """ These are video related models. """ + from dataclasses import dataclass, field from typing import Optional, List diff --git a/pyyoutube/resources/activities.py b/pyyoutube/resources/activities.py index 9885957b..57538f4d 100644 --- a/pyyoutube/resources/activities.py +++ b/pyyoutube/resources/activities.py @@ -1,6 +1,7 @@ """ Activities resource implementation """ + from typing import Optional, Union from pyyoutube.error import PyYouTubeException, ErrorMessage, ErrorCode diff --git a/pyyoutube/resources/base_resource.py b/pyyoutube/resources/base_resource.py index 8359688b..48230a18 100644 --- a/pyyoutube/resources/base_resource.py +++ b/pyyoutube/resources/base_resource.py @@ -1,6 +1,7 @@ """ Base resource class. """ + from typing import Optional, TYPE_CHECKING if TYPE_CHECKING: diff --git a/pyyoutube/resources/captions.py b/pyyoutube/resources/captions.py index c1bb2569..cebf11ea 100644 --- a/pyyoutube/resources/captions.py +++ b/pyyoutube/resources/captions.py @@ -1,6 +1,7 @@ """ Captions resource implementation """ + from typing import Optional, Union from requests import Response diff --git a/pyyoutube/resources/channel_banners.py b/pyyoutube/resources/channel_banners.py index eb5d6dc1..4c28f1f0 100644 --- a/pyyoutube/resources/channel_banners.py +++ b/pyyoutube/resources/channel_banners.py @@ -1,6 +1,7 @@ """ Channel banners resource implementation. """ + from typing import Optional from pyyoutube.resources.base_resource import Resource diff --git a/pyyoutube/resources/channels.py b/pyyoutube/resources/channels.py index 4dac199d..b8eee7fa 100644 --- a/pyyoutube/resources/channels.py +++ b/pyyoutube/resources/channels.py @@ -1,6 +1,7 @@ """ Channel resource implementation. """ + from typing import Optional, Union from pyyoutube.error import PyYouTubeException, ErrorMessage, ErrorCode diff --git a/pyyoutube/resources/comment_threads.py b/pyyoutube/resources/comment_threads.py index 05803d97..d5ae48fc 100644 --- a/pyyoutube/resources/comment_threads.py +++ b/pyyoutube/resources/comment_threads.py @@ -1,6 +1,7 @@ """ Comment threads resource implementation. """ + from typing import Optional, Union from pyyoutube.error import PyYouTubeException, ErrorMessage, ErrorCode diff --git a/pyyoutube/resources/membership_levels.py b/pyyoutube/resources/membership_levels.py index 4bbc790d..3799b936 100644 --- a/pyyoutube/resources/membership_levels.py +++ b/pyyoutube/resources/membership_levels.py @@ -1,6 +1,7 @@ """ Membership levels resource implementation. """ + from typing import Optional, Union from pyyoutube.models import MembershipsLevelListResponse diff --git a/pyyoutube/resources/search.py b/pyyoutube/resources/search.py index 03dc77a0..45007d73 100644 --- a/pyyoutube/resources/search.py +++ b/pyyoutube/resources/search.py @@ -1,6 +1,7 @@ """ Search resource implementation. """ + from typing import Optional, Union from pyyoutube.resources.base_resource import Resource diff --git a/pyyoutube/resources/thumbnails.py b/pyyoutube/resources/thumbnails.py index 27641be5..67d92a4a 100644 --- a/pyyoutube/resources/thumbnails.py +++ b/pyyoutube/resources/thumbnails.py @@ -1,6 +1,7 @@ """ Thumbnails resources implementation. """ + from typing import Optional from pyyoutube.resources.base_resource import Resource diff --git a/pyyoutube/resources/video_abuse_report_reasons.py b/pyyoutube/resources/video_abuse_report_reasons.py index 1ab85014..4697fac1 100644 --- a/pyyoutube/resources/video_abuse_report_reasons.py +++ b/pyyoutube/resources/video_abuse_report_reasons.py @@ -1,6 +1,7 @@ """ Video abuse report reasons resource implementation. """ + from typing import Optional, Union from pyyoutube.resources.base_resource import Resource diff --git a/pyyoutube/resources/watermarks.py b/pyyoutube/resources/watermarks.py index 8ca6820c..c4ac8b4f 100644 --- a/pyyoutube/resources/watermarks.py +++ b/pyyoutube/resources/watermarks.py @@ -1,6 +1,7 @@ """ Watermarks resource implementation. """ + from typing import Optional, Union from pyyoutube.resources.base_resource import Resource diff --git a/pyyoutube/utils/params_checker.py b/pyyoutube/utils/params_checker.py index 43d0fe08..7f51fcb6 100644 --- a/pyyoutube/utils/params_checker.py +++ b/pyyoutube/utils/params_checker.py @@ -1,6 +1,7 @@ """ function's params checker. """ + import logging from typing import Optional, Union diff --git a/tests/clients/test_captions.py b/tests/clients/test_captions.py index 62016ec8..bb5dab33 100644 --- a/tests/clients/test_captions.py +++ b/tests/clients/test_captions.py @@ -1,6 +1,7 @@ """ Tests for captions resources. """ + import io import pytest diff --git a/tests/clients/test_channel_banners.py b/tests/clients/test_channel_banners.py index 6d6fb71e..295674d7 100644 --- a/tests/clients/test_channel_banners.py +++ b/tests/clients/test_channel_banners.py @@ -1,6 +1,7 @@ """ Tests for channel banners """ + import io from .base import BaseTestCase diff --git a/tests/clients/test_client.py b/tests/clients/test_client.py index 9010454e..6b31ef2d 100644 --- a/tests/clients/test_client.py +++ b/tests/clients/test_client.py @@ -1,6 +1,7 @@ """ Tests for client. """ + import pytest import responses diff --git a/tests/clients/test_media.py b/tests/clients/test_media.py index 98545c34..283e820b 100644 --- a/tests/clients/test_media.py +++ b/tests/clients/test_media.py @@ -1,6 +1,7 @@ """ Tests for media upload. """ + import io import pytest diff --git a/tests/clients/test_thumbnails.py b/tests/clients/test_thumbnails.py index e5b5b1d3..4826c1eb 100644 --- a/tests/clients/test_thumbnails.py +++ b/tests/clients/test_thumbnails.py @@ -1,6 +1,7 @@ """ Tests for thumbnails. """ + import io from .base import BaseTestCase From ad051fee9c82776dbeaa19d762332807fa679224 Mon Sep 17 00:00:00 2001 From: ikaroskun Date: Sun, 4 Feb 2024 11:57:55 +0800 Subject: [PATCH 116/141] test(channel): :white_check_mark: fix tests for api --- tests/apis/test_channels.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/apis/test_channels.py b/tests/apis/test_channels.py index b91611e0..942789c3 100644 --- a/tests/apis/test_channels.py +++ b/tests/apis/test_channels.py @@ -63,7 +63,7 @@ def testGetChannelInfo(self) -> None: self.assertEqual(res_by_channel_id.items[0].id, "UC_x5XG1OV2P6uZZ5FSM9Ttw") res_by_channel_handle = self.api.get_channel_info( - for_username="googledevelopers", return_json=True + for_handle="googledevelopers", return_json=True ) self.assertEqual( res_by_channel_handle["items"][0]["snippet"]["customUrl"], From 7643cf7582db18cffb304794ecbd15d57d873f9b Mon Sep 17 00:00:00 2001 From: ikaroskun Date: Sun, 18 Feb 2024 10:40:14 +0800 Subject: [PATCH 117/141] docs(message): :memo: update method error message --- pyyoutube/resources/activities.py | 2 +- pyyoutube/resources/channel_sections.py | 2 +- pyyoutube/resources/channels.py | 2 +- pyyoutube/resources/comment_threads.py | 2 +- pyyoutube/resources/comments.py | 2 +- pyyoutube/resources/playlists.py | 2 +- pyyoutube/resources/videos.py | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/pyyoutube/resources/activities.py b/pyyoutube/resources/activities.py index 57538f4d..9fa57f66 100644 --- a/pyyoutube/resources/activities.py +++ b/pyyoutube/resources/activities.py @@ -81,7 +81,7 @@ def list( raise PyYouTubeException( ErrorMessage( status_code=ErrorCode.MISSING_PARAMS, - message=f"Specify at least one of for_username, id, managedByMe or mine", + message="Specify at least one of channel_id or mine", ) ) diff --git a/pyyoutube/resources/channel_sections.py b/pyyoutube/resources/channel_sections.py index 4db55bd2..ea441df3 100644 --- a/pyyoutube/resources/channel_sections.py +++ b/pyyoutube/resources/channel_sections.py @@ -79,7 +79,7 @@ def list( raise PyYouTubeException( ErrorMessage( status_code=ErrorCode.MISSING_PARAMS, - message=f"Specify at least one of for_username, id, managedByMe or mine", + message="Specify at least one of channel_id, section_id or mine", ) ) response = self._client.request(path="channelSections", params=params) diff --git a/pyyoutube/resources/channels.py b/pyyoutube/resources/channels.py index b8eee7fa..311726c3 100644 --- a/pyyoutube/resources/channels.py +++ b/pyyoutube/resources/channels.py @@ -111,7 +111,7 @@ def list( raise PyYouTubeException( ErrorMessage( status_code=ErrorCode.MISSING_PARAMS, - message=f"Specify at least one of for_username,channel_id,managedByMe or mine", + message="Specify at least one of for_handle,for_username,channel_id,managedByMe or mine", ) ) diff --git a/pyyoutube/resources/comment_threads.py b/pyyoutube/resources/comment_threads.py index d5ae48fc..3637fce1 100644 --- a/pyyoutube/resources/comment_threads.py +++ b/pyyoutube/resources/comment_threads.py @@ -108,7 +108,7 @@ def list( raise PyYouTubeException( ErrorMessage( status_code=ErrorCode.MISSING_PARAMS, - message="Specify at least one of all_threads_related_to_channel_id, channel_id, thread_id or video_id", + message="Specify at least one of all_threads_related_to_channel_id,channel_id,thread_id or video_id", ) ) response = self._client.request(path="commentThreads", params=params) diff --git a/pyyoutube/resources/comments.py b/pyyoutube/resources/comments.py index 3aa32582..492451b6 100644 --- a/pyyoutube/resources/comments.py +++ b/pyyoutube/resources/comments.py @@ -76,7 +76,7 @@ def list( raise PyYouTubeException( ErrorMessage( status_code=ErrorCode.MISSING_PARAMS, - message=f"Specify at least one of for_username, id, managedByMe or mine", + message="Specify at least one of comment_id, or parent_id", ) ) diff --git a/pyyoutube/resources/playlists.py b/pyyoutube/resources/playlists.py index 0ec5c034..fd7b153a 100644 --- a/pyyoutube/resources/playlists.py +++ b/pyyoutube/resources/playlists.py @@ -102,7 +102,7 @@ def list( raise PyYouTubeException( ErrorMessage( status_code=ErrorCode.MISSING_PARAMS, - message=f"Specify at least one of channel_id, playlist_id or mine", + message="Specify at least one of channel_id, playlist_id or mine", ) ) response = self._client.request(path="playlists", params=params) diff --git a/pyyoutube/resources/videos.py b/pyyoutube/resources/videos.py index e08fc004..699f9a37 100644 --- a/pyyoutube/resources/videos.py +++ b/pyyoutube/resources/videos.py @@ -115,7 +115,7 @@ def list( raise PyYouTubeException( ErrorMessage( status_code=ErrorCode.MISSING_PARAMS, - message=f"Specify at least one of for_username,channel_id,managedByMe or mine", + message="Specify at least one of chart,video_id or my_rating", ) ) response = self._client.request(path="videos", params=params) From f2d33bc11c1441c046020efbf7b4b46e62a25eae Mon Sep 17 00:00:00 2001 From: liukun Date: Sun, 18 Feb 2024 14:50:07 +0800 Subject: [PATCH 118/141] =?UTF-8?q?=F0=9F=93=83=20docs(changelog):=20updat?= =?UTF-8?q?e=20changelog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 28ed9303..ca49b77a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,11 +3,12 @@ All notable changes to this project will be documented in this file. -## Version 0.9.4 (development) +## Version 0.9.4 (2024-02-18) ### What's New - Add new parameter `for_handle` to get channel by handle. +- fix some wrong error message. ## Version 0.9.3 (2023-11-22) From 0a2e59a1f53d6d8793684a7562093db9c4aa99ab Mon Sep 17 00:00:00 2001 From: liukun Date: Sun, 18 Feb 2024 14:51:07 +0800 Subject: [PATCH 119/141] =?UTF-8?q?Bump=20version:=200.9.3=20=E2=86=92=200?= =?UTF-8?q?.9.4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .bumpversion.cfg | 2 +- pyproject.toml | 2 +- pyyoutube/__version__.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 70b147d9..0918f267 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.9.3 +current_version = 0.9.4 commit = True tag = True diff --git a/pyproject.toml b/pyproject.toml index b2b70bcf..bc75b606 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "python-youtube" -version = "0.9.3" +version = "0.9.4" description = "A Python wrapper around for YouTube Data API." authors = ["ikaroskun "] license = "MIT" diff --git a/pyyoutube/__version__.py b/pyyoutube/__version__.py index bd3cab4c..d4667860 100644 --- a/pyyoutube/__version__.py +++ b/pyyoutube/__version__.py @@ -5,4 +5,4 @@ # 88 88 88 88 88 `8b d8' 88 V888 88 `8b d8' 88b d88 88 88b d88 88 8D 88. # 88 YP YP YP YP `Y88P' VP V8P YP `Y88P' ~Y8888P' YP ~Y8888P' Y8888P' Y88888P -__version__ = "0.9.3" +__version__ = "0.9.4" From 770a69849e66d6a9e61eef242969cc5bd4df71cf Mon Sep 17 00:00:00 2001 From: ikaroskun Date: Tue, 19 Mar 2024 16:06:52 +0800 Subject: [PATCH 120/141] docs(revision): :memo: add some tips for deprecated field and parameter --- pyyoutube/models/channel.py | 2 ++ pyyoutube/resources/captions.py | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/pyyoutube/models/channel.py b/pyyoutube/models/channel.py index 4986602e..b39478ef 100644 --- a/pyyoutube/models/channel.py +++ b/pyyoutube/models/channel.py @@ -42,6 +42,8 @@ class ChannelBrandingSettingChannel(BaseModel): description: Optional[str] = field(default=None) keywords: Optional[str] = field(default=None, repr=False) trackingAnalyticsAccountId: Optional[str] = field(default=None, repr=False) + # Important: + # moderateComments has been deprecated at March 7, 2024. moderateComments: Optional[bool] = field(default=None, repr=False) unsubscribedTrailer: Optional[str] = field(default=None, repr=False) defaultLanguage: Optional[str] = field(default=None, repr=False) diff --git a/pyyoutube/resources/captions.py b/pyyoutube/resources/captions.py index cebf11ea..1a433f14 100644 --- a/pyyoutube/resources/captions.py +++ b/pyyoutube/resources/captions.py @@ -89,6 +89,8 @@ def insert( file with the audio track of the video. If you set the value to true, YouTube will disregard any time codes that are in the uploaded caption file and generate new time codes for the captions. + Important: + This parameter will be deprecated at April 12, 2024. **kwargs: Additional parameters for system parameters. Refer: https://cloud.google.com/apis/docs/system-parameters. @@ -141,6 +143,8 @@ def update( file with the audio track of the video. If you set the value to true, YouTube will disregard any time codes that are in the uploaded caption file and generate new time codes for the captions. + Important: + This parameter will be deprecated at April 12, 2024. return_json: Type for returned data. If you set True JSON data will be returned. **kwargs: From 27777e5a0d3b4257eb243775318df5106a2e2d0b Mon Sep 17 00:00:00 2001 From: pidi3000 Date: Mon, 29 Apr 2024 02:52:35 +0200 Subject: [PATCH 121/141] fix(examples): `get_all_videos_id_by_channel` returning no results --- ...et_all_videos_id_with_channel_by_search.py | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/examples/apis/get_all_videos_id_with_channel_by_search.py b/examples/apis/get_all_videos_id_with_channel_by_search.py index 8391fc87..aeafdaf7 100644 --- a/examples/apis/get_all_videos_id_with_channel_by_search.py +++ b/examples/apis/get_all_videos_id_with_channel_by_search.py @@ -11,21 +11,25 @@ def get_all_videos_id_by_channel(channel_id, limit=50, count=50): api = pyyoutube.Api(api_key=API_KEY) - res = api.search(channel_id=channel_id, limit=limit, count=count) - next_page = res.nextPageToken - videos = [] - while next_page: - next_page = res.nextPageToken - for item in res.items: - if item.id.videoId: - videos.append(item.id.videoId) + videos = [] + next_page = None + while True: res = api.search( channel_id=channel_id, limit=limit, count=count, - page_token=res.nextPageToken, + page_token=next_page, ) + next_page = res.nextPageToken + + for item in res.items: + if item.id.videoId: + videos.append(item.id.videoId) + + if not next_page: + break + return videos From 4c6ad5940897563c335bf739863cd2ac1ebdb1a1 Mon Sep 17 00:00:00 2001 From: pidi3000 Date: Tue, 21 May 2024 22:38:52 +0200 Subject: [PATCH 122/141] remove leading/trailing whitespace from `part` parameter values --- pyyoutube/utils/params_checker.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pyyoutube/utils/params_checker.py b/pyyoutube/utils/params_checker.py index 7f51fcb6..250abb68 100644 --- a/pyyoutube/utils/params_checker.py +++ b/pyyoutube/utils/params_checker.py @@ -83,6 +83,10 @@ def enf_parts(resource: str, value: Optional[Union[str, list, tuple, set]], chec message=f"Parameter (parts) must be single str,comma-separated str,list,tuple or set", ) ) + + # Remove leading/trailing whitespaces + parts = set({part.strip() for part in parts}) + # check parts whether support. if check: support_parts = RESOURCE_PARTS_MAPPING[resource] From 066879ac6e86794054e0e25bbc96a913013b5f06 Mon Sep 17 00:00:00 2001 From: pidi3000 Date: Fri, 21 Jun 2024 17:28:24 +0200 Subject: [PATCH 123/141] made fields of `RegionRestriction` model optional --- pyyoutube/models/video.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyyoutube/models/video.py b/pyyoutube/models/video.py index 19477162..1572519b 100644 --- a/pyyoutube/models/video.py +++ b/pyyoutube/models/video.py @@ -29,8 +29,8 @@ class RegionRestriction(BaseModel): Refer: https://developers.google.com/youtube/v3/docs/videos#contentDetails.regionRestriction """ - allowed: List[str] = field(default=None) - blocked: List[str] = field(default=None, repr=False) + allowed: Optional[List[str]] = field(default=None) + blocked: Optional[List[str]] = field(default=None, repr=False) # TODO get detail rating description From a4a1128a0597644221e8390a0b8d17fa33a10a33 Mon Sep 17 00:00:00 2001 From: liukun Date: Fri, 9 Aug 2024 10:16:11 +0800 Subject: [PATCH 124/141] =?UTF-8?q?=F0=9F=93=83=20docs(changelog):=20updat?= =?UTF-8?q?e=20changelog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ca49b77a..6b320491 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ All notable changes to this project will be documented in this file. +## Version 0.9.5 (2024-08-09) + +### What's New + +- Make video regionRestriction fields to Optional. Thanks for [@pidi3000](https://github.com/pidi3000) +- Modify some examples. Thanks for [@pidi3000](https://github.com/pidi3000) +- fix enf_parts for part with whitespaces. Thanks for [@pidi3000](https://github.com/pidi3000) + ## Version 0.9.4 (2024-02-18) @@ -10,14 +18,12 @@ All notable changes to this project will be documented in this file. - Add new parameter `for_handle` to get channel by handle. - fix some wrong error message. - ## Version 0.9.3 (2023-11-22) ### What's New - Add initial client with client_secret file. Thanks for [@pidi3000](https://github.com/pidi3000) - ## Version 0.9.2 (2023-09-26) ### What's New From 748b98ca99c43d52abd145d207118197aaf070ec Mon Sep 17 00:00:00 2001 From: liukun Date: Fri, 9 Aug 2024 10:17:39 +0800 Subject: [PATCH 125/141] =?UTF-8?q?Bump=20version:=200.9.4=20=E2=86=92=200?= =?UTF-8?q?.9.5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .bumpversion.cfg | 2 +- pyproject.toml | 2 +- pyyoutube/__version__.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 0918f267..86a10187 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.9.4 +current_version = 0.9.5 commit = True tag = True diff --git a/pyproject.toml b/pyproject.toml index bc75b606..66e6a68f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "python-youtube" -version = "0.9.4" +version = "0.9.5" description = "A Python wrapper around for YouTube Data API." authors = ["ikaroskun "] license = "MIT" diff --git a/pyyoutube/__version__.py b/pyyoutube/__version__.py index d4667860..805b8646 100644 --- a/pyyoutube/__version__.py +++ b/pyyoutube/__version__.py @@ -5,4 +5,4 @@ # 88 88 88 88 88 `8b d8' 88 V888 88 `8b d8' 88b d88 88 88b d88 88 8D 88. # 88 YP YP YP YP `Y88P' VP V8P YP `Y88P' ~Y8888P' YP ~Y8888P' Y8888P' Y88888P -__version__ = "0.9.4" +__version__ = "0.9.5" From 7716a139e950b3f0015df793f35d6186cb30c90f Mon Sep 17 00:00:00 2001 From: Volker Mische Date: Sun, 25 Aug 2024 02:34:44 +0200 Subject: [PATCH 126/141] feat(videos): add video recording details Make it possible to add a recording date to a video. The `recordingDate` is the only non-deprecated property of the `recordingDetails` object. --- pyyoutube/models/video.py | 12 ++++++++++++ pyyoutube/utils/constants.py | 1 + .../modeldata/videos/video_recording_details.json | 3 +++ tests/models/test_videos.py | 10 ++++++++++ 4 files changed, 26 insertions(+) create mode 100644 testdata/modeldata/videos/video_recording_details.json diff --git a/pyyoutube/models/video.py b/pyyoutube/models/video.py index 1572519b..89648e2f 100644 --- a/pyyoutube/models/video.py +++ b/pyyoutube/models/video.py @@ -221,6 +221,17 @@ class VideoStatus(BaseModel, DatetimeTimeMixin): selfDeclaredMadeForKids: Optional[bool] = field(default=None, repr=False) +@dataclass +class VideoRecordingDetails(BaseModel, DatetimeTimeMixin): + """ + A class representing the video recording details. + + Refer: https://developers.google.com/youtube/v3/docs/videos#recordingDetails + """ + + recordingDate: Optional[str] = field(default=None, repr=False) + + @dataclass class VideoLiveStreamingDetails(BaseModel, DatetimeTimeMixin): """ @@ -251,6 +262,7 @@ class Video(BaseResource): statistics: Optional[VideoStatistics] = field(default=None, repr=False) topicDetails: Optional[VideoTopicDetails] = field(default=None, repr=False) player: Optional[Player] = field(default=None, repr=False) + recordingDetails: Optional[VideoRecordingDetails] = field(default=None, repr=False) liveStreamingDetails: Optional[VideoLiveStreamingDetails] = field( default=None, repr=False ) diff --git a/pyyoutube/utils/constants.py b/pyyoutube/utils/constants.py index e91532f4..1961ad82 100644 --- a/pyyoutube/utils/constants.py +++ b/pyyoutube/utils/constants.py @@ -63,6 +63,7 @@ "statistics", "status", "topicDetails", + "recordingDetails", "liveStreamingDetails", } diff --git a/testdata/modeldata/videos/video_recording_details.json b/testdata/modeldata/videos/video_recording_details.json new file mode 100644 index 00000000..162ccf67 --- /dev/null +++ b/testdata/modeldata/videos/video_recording_details.json @@ -0,0 +1,3 @@ +{ + "recordingDate": "2024-07-03T00:00:00Z" +} diff --git a/tests/models/test_videos.py b/tests/models/test_videos.py index 5574bf2a..f9e38f4d 100644 --- a/tests/models/test_videos.py +++ b/tests/models/test_videos.py @@ -21,6 +21,8 @@ class VideoModelTest(unittest.TestCase): VIDEO_INFO = json.loads(f.read().decode("utf-8")) with open(BASE_PATH + "video_api_response.json", "rb") as f: VIDEO_API_RESPONSE = json.loads(f.read().decode("utf-8")) + with open(BASE_PATH + "video_recording_details.json", "rb") as f: + RECORDING_DETAILS = json.loads(f.read().decode("utf-8")) def testVideoContentDetails(self) -> None: m = models.VideoContentDetails.from_dict(self.CONTENT_DETAILS_INFO) @@ -97,3 +99,11 @@ def testVideoListResponse(self) -> None: self.assertEqual(m.kind, "youtube#videoListResponse") self.assertEqual(m.pageInfo.totalResults, 1) self.assertEqual(m.items[0].id, "D-lhorsDlUQ") + + def testVideoRecordingDetails(self) -> None: + m = models.VideoRecordingDetails.from_dict(self.RECORDING_DETAILS) + + self.assertEqual( + m.string_to_datetime(m.recordingDate).isoformat(), + "2024-07-03T00:00:00+00:00", + ) From 867b6aa954608d433440f9d46c24f824339a3b38 Mon Sep 17 00:00:00 2001 From: liukun Date: Mon, 9 Sep 2024 18:08:14 +0800 Subject: [PATCH 127/141] =?UTF-8?q?=F0=9F=93=83=20docs:=20update=20changel?= =?UTF-8?q?og?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b320491..0ff83677 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ All notable changes to this project will be documented in this file. +## Version 0.9.6 (2024-09-09) + +### What's New + +-Add new part field `recordingDetails` for video resource. Thanks for [@vmx](https://github.com/vmx) + + ## Version 0.9.5 (2024-08-09) ### What's New @@ -10,7 +17,6 @@ All notable changes to this project will be documented in this file. - Modify some examples. Thanks for [@pidi3000](https://github.com/pidi3000) - fix enf_parts for part with whitespaces. Thanks for [@pidi3000](https://github.com/pidi3000) - ## Version 0.9.4 (2024-02-18) ### What's New From 365082cf381d501e0d434d73494ec98f980e696e Mon Sep 17 00:00:00 2001 From: liukun Date: Mon, 9 Sep 2024 18:09:40 +0800 Subject: [PATCH 128/141] =?UTF-8?q?Bump=20version:=200.9.5=20=E2=86=92=200?= =?UTF-8?q?.9.6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .bumpversion.cfg | 2 +- pyproject.toml | 2 +- pyyoutube/__version__.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 86a10187..69a130ff 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.9.5 +current_version = 0.9.6 commit = True tag = True diff --git a/pyproject.toml b/pyproject.toml index 66e6a68f..6c050bf5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "python-youtube" -version = "0.9.5" +version = "0.9.6" description = "A Python wrapper around for YouTube Data API." authors = ["ikaroskun "] license = "MIT" diff --git a/pyyoutube/__version__.py b/pyyoutube/__version__.py index 805b8646..e0bcaf7f 100644 --- a/pyyoutube/__version__.py +++ b/pyyoutube/__version__.py @@ -5,4 +5,4 @@ # 88 88 88 88 88 `8b d8' 88 V888 88 `8b d8' 88b d88 88 88b d88 88 8D 88. # 88 YP YP YP YP `Y88P' VP V8P YP `Y88P' ~Y8888P' YP ~Y8888P' Y8888P' Y88888P -__version__ = "0.9.5" +__version__ = "0.9.6" From 3c5eee7518eec55e9a343884f950c84e940123a5 Mon Sep 17 00:00:00 2001 From: dyl_m Date: Mon, 14 Oct 2024 11:13:15 +0200 Subject: [PATCH 129/141] deps: isodate upgrade - 0.6 >> 0.7 Partial fix for #173 Changes introduced with isodate 0.7 release (according to their CHANGELOG)): 0.7.2 (2024-10-08) ------------------ - drop end of life python versions - Don't match garbage characters at the end of parsed strings #16 (Gabriel de Perthuis) Potentially breaking changes: ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - Fractional seconds are cut off to microseconds (always round down) - Allow control over return type of parse_duration #64 (Felix Claessen) - Python >= 3.7 required --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 6c050bf5..0d0c6d0f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,7 @@ packages = [ python = "^3.6" requests = "^2.24.0" requests-oauthlib = "^1.3.0" -isodate = "^0.6.0" +isodate = "^0.7.0" dataclasses-json = [ {version = "^0.5.3", python = "<3.7"}, {version = "^0.6.0", python = ">=3.7"} From 75fe80616b02cd26ec59ecede4098754697fcd09 Mon Sep 17 00:00:00 2001 From: dyl_m Date: Mon, 14 Oct 2024 23:03:49 +0200 Subject: [PATCH 130/141] deps: isodate upgrade - 0.7.0 >> 0.7.2 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 0d0c6d0f..3e95fbfd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,7 @@ packages = [ python = "^3.6" requests = "^2.24.0" requests-oauthlib = "^1.3.0" -isodate = "^0.7.0" +isodate = "^0.7.2" dataclasses-json = [ {version = "^0.5.3", python = "<3.7"}, {version = "^0.6.0", python = ">=3.7"} From a721964e07aa9ab72e7860931c56b061fe4eed14 Mon Sep 17 00:00:00 2001 From: ikaroskun Date: Mon, 21 Oct 2024 11:22:52 +0800 Subject: [PATCH 131/141] feat(dependencies): :arrow_up: update dependencies --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 3e95fbfd..46db9859 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,8 +32,8 @@ packages = [ [tool.poetry.dependencies] python = "^3.6" requests = "^2.24.0" -requests-oauthlib = "^1.3.0" -isodate = "^0.7.2" +requests-oauthlib = "=1.3.0,<3.0.0" +isodate = ">=0.6.0,<=0.7.2" dataclasses-json = [ {version = "^0.5.3", python = "<3.7"}, {version = "^0.6.0", python = ">=3.7"} From 362a033abdff091d7d69f3d1ae010746db20686e Mon Sep 17 00:00:00 2001 From: ikaroskun Date: Mon, 21 Oct 2024 11:29:48 +0800 Subject: [PATCH 132/141] feat(ci): :green_heart: add codecov token --- .github/workflows/test.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 16a9cac9..a3376ed1 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -36,10 +36,11 @@ jobs: poetry run pytest - name: Upload coverage to Codecov if: ${{ matrix.update-coverage }} - uses: codecov/codecov-action@v1 + uses: codecov/codecov-action@v4 with: file: ./coverage.xml fail_ci_if_error: true + token: ${{ secrets.CODECOV_TOKEN }} lint: name: black From da51ea339001519d397b6006dce5e6e8da044438 Mon Sep 17 00:00:00 2001 From: ikaroskun Date: Mon, 21 Oct 2024 11:39:50 +0800 Subject: [PATCH 133/141] feat(ci): :green_heart: nothing --- pyproject.toml | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 46db9859..3875705b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,9 @@ readme = "README.rst" homepage = "https://github.com/sns-sdks/python-youtube" repository = "https://github.com/sns-sdks/python-youtube" classifiers = [ + "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", + "Topic :: Software Development :: Libraries :: Python Modules", "Programming Language :: Python", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", @@ -20,8 +22,6 @@ classifiers = [ "Programming Language :: Python :: 3.12", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", - "Topic :: Software Development :: Libraries :: Python Modules", - "Intended Audience :: Developers", ] packages = [ @@ -35,22 +35,22 @@ requests = "^2.24.0" requests-oauthlib = "=1.3.0,<3.0.0" isodate = ">=0.6.0,<=0.7.2" dataclasses-json = [ - {version = "^0.5.3", python = "<3.7"}, - {version = "^0.6.0", python = ">=3.7"} + { version = "^0.5.3", python = "<3.7" }, + { version = "^0.6.0", python = ">=3.7" } ] [tool.poetry.dev-dependencies] responses = [ - {version = "^0.17.0", python = "<3.7"}, - {version = "^0.23.0", python = ">=3.7"} + { version = "^0.17.0", python = "<3.7" }, + { version = "^0.23.0", python = ">=3.7" } ] pytest = [ - {version = "^6.2", python = "<3.7"}, - {version = "^7.1", python = ">=3.7"} + { version = "^6.2", python = "<3.7" }, + { version = "^7.1", python = ">=3.7" } ] pytest-cov = [ - {version = "^2.10.1", python = "<3.7"}, - {version = "^3.0.0", python = ">=3.7"} + { version = "^2.10.1", python = "<3.7" }, + { version = "^3.0.0", python = ">=3.7" } ] [build-system] From 6bb02f6e8d70add28548bdf24442bc47d2104d3f Mon Sep 17 00:00:00 2001 From: liukun Date: Mon, 28 Oct 2024 17:16:42 +0800 Subject: [PATCH 134/141] =?UTF-8?q?=F0=9F=93=83=20docs(changelog):=20updat?= =?UTF-8?q?e=20changelog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ff83677..9900bc63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,13 +2,18 @@ All notable changes to this project will be documented in this file. +## Version 0.9.7 (2024-10-28) + +### What's New + +- Fix dependencies. + ## Version 0.9.6 (2024-09-09) ### What's New -Add new part field `recordingDetails` for video resource. Thanks for [@vmx](https://github.com/vmx) - ## Version 0.9.5 (2024-08-09) ### What's New From 61d0a4dfa6cad3469e64a1b6733590659e5f9591 Mon Sep 17 00:00:00 2001 From: liukun Date: Mon, 28 Oct 2024 17:16:55 +0800 Subject: [PATCH 135/141] =?UTF-8?q?Bump=20version:=200.9.6=20=E2=86=92=200?= =?UTF-8?q?.9.7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .bumpversion.cfg | 2 +- pyproject.toml | 2 +- pyyoutube/__version__.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 69a130ff..c6e31428 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.9.6 +current_version = 0.9.7 commit = True tag = True diff --git a/pyproject.toml b/pyproject.toml index 3875705b..cd8ebbf5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "python-youtube" -version = "0.9.6" +version = "0.9.7" description = "A Python wrapper around for YouTube Data API." authors = ["ikaroskun "] license = "MIT" diff --git a/pyyoutube/__version__.py b/pyyoutube/__version__.py index e0bcaf7f..6f311c38 100644 --- a/pyyoutube/__version__.py +++ b/pyyoutube/__version__.py @@ -5,4 +5,4 @@ # 88 88 88 88 88 `8b d8' 88 V888 88 `8b d8' 88b d88 88 88b d88 88 8D 88. # 88 YP YP YP YP `Y88P' VP V8P YP `Y88P' ~Y8888P' YP ~Y8888P' Y8888P' Y88888P -__version__ = "0.9.6" +__version__ = "0.9.7" From a2c46e1c0680ef34a5bfbb7eec0231b12f75f0ce Mon Sep 17 00:00:00 2001 From: Skiy Date: Sun, 2 Feb 2025 18:24:36 +0000 Subject: [PATCH 136/141] grammar and wording --- README.rst | 28 +++++++------- docs/docs/authorization.md | 45 ++++++++++++----------- docs/docs/getting_started.md | 38 +++++++++---------- docs/docs/index.md | 2 +- docs/docs/installation.md | 6 +-- docs/docs/introduce-new-structure.md | 4 +- docs/docs/usage/work-with-api.md | 55 ++++++++++++++-------------- docs/docs/usage/work-with-client.md | 8 ++-- examples/README.md | 10 ++--- 9 files changed, 99 insertions(+), 97 deletions(-) diff --git a/README.rst b/README.rst index 855396f8..9b5d120a 100644 --- a/README.rst +++ b/README.rst @@ -1,6 +1,6 @@ Python YouTube -A Python wrapper around for YouTube Data API V3. +A Python wrapper for the YouTube Data API V3. .. image:: https://github.com/sns-sdks/python-youtube/workflows/Test/badge.svg :target: https://github.com/sns-sdks/python-youtube/actions @@ -21,17 +21,17 @@ THANKS Inspired by `Python-Twitter `_. -Thanks a lot for Python-Twitter Developers. +Thanks a lot to Python-Twitter Developers. ============ Introduction ============ -Library provides an easy way to use YouTube Data API V3. +This library provides an easy way to use the YouTube Data API V3. .. - Recently, we are working on the new structure for the library. `Read docs `_ to get more detail. + We have recently been working on the new structure for the library. `Read docs `_ to get more detail. ============= Documentation @@ -56,21 +56,21 @@ You can install this lib from PyPI: Using ===== -Now, the library covers all resource methods, including ``insert``,``update`` and so on. +The library covers all resource methods, including ``insert``,``update``, and so on. -Currently, we recommend using ``pyyoutube.Client`` to operate DATA API. It has more features. +We recommend using the ``pyyoutube.Client`` to operate DATA API. It is more modern and feature rich than ``pyyoutube.Api``. Work with Client ---------------- -You can just initialize with an api key: +You can initialize with an api key: .. code-block:: python >>> from pyyoutube import Client >>> client = Client(api_key="your api key") -If you want to get some authorization data. you need to initialize with an access token: +To access additional data that requires authorization, you need to initialize with an access token: .. code-block:: python @@ -79,7 +79,7 @@ If you want to get some authorization data. you need to initialize with an acces You can read the docs to see how to get an access token. -Or you can ask for user to do oauth flow: +Or you can ask for user to do OAuth: .. code-block:: python @@ -104,14 +104,14 @@ Get channel detail: ... } -To get more usage to see our `client docs `_, or `client examples `_ +See the `client docs `_, or `client examples `_, for additional usage Work with API ---------------- .. - We still support the old way for the sake of compatibility with older users. + For compatibility with older code, we continue to support the old way. You can just initialize with an api key: @@ -120,7 +120,7 @@ You can just initialize with an api key: >>> from pyyoutube import Api >>> api = Api(api_key="your api key") -If you want to get some authorization data. you need to initialize with an access token: +To access additional data that requires authorization, you need to initialize with an access token: .. code-block:: python @@ -129,7 +129,7 @@ If you want to get some authorization data. you need to initialize with an acces You can read the docs to see how to get an access token. -Or you can ask for user to do oauth flow: +Or you can ask for user to do OAuth flow: .. code-block:: python @@ -184,4 +184,4 @@ Get channel detail: ... } -To get more usage to see our `api docs `_, or `api examples `_ +See the `api docs `_, or `api examples `_, for additional usage. diff --git a/docs/docs/authorization.md b/docs/docs/authorization.md index 8d38f6ec..147071be 100644 --- a/docs/docs/authorization.md +++ b/docs/docs/authorization.md @@ -1,20 +1,20 @@ -If you want to get some more data for your channel. You need provide the authorization first. +If you want to get more data for your channel, You need provide the authorization. -So, this doc show how to do authorize. +This doc shows how to authorize a client. ## Prerequisite -At the beginning. You must know what is authorization. +To begin with, you must know what authorization is. You can see some information at the [Official Documentation](https://developers.google.com/youtube/v3/guides/authentication). -Then you need have an app with the [Access scopes](https://developers.google.com/youtube/v3/guides/auth/server-side-web-apps#identify-access-scopes) approval by YouTube. +You will need to create an app with [Access scope](https://developers.google.com/youtube/v3/guides/auth/server-side-web-apps#identify-access-scopes) approval by YouTube. -If everything goes well. Now let do a simple authorize with `Python-Youtube` library. +Once complete, you will be able to do a simple authorize with `Python-Youtube` library. ## Get authorization url -Suppose now we want to get user's permission to manage his youtube account. +Suppose now we want to get user's permission to manage their YouTube account. For the `Python-YouTube` library, the default scopes are: @@ -23,11 +23,11 @@ For the `Python-YouTube` library, the default scopes are: You can get more scope information at [Access scopes](https://developers.google.com/youtube/v3/guides/auth/server-side-web-apps#identify-access-scopes). -And We set the default redirect url is `https://localhost/`. +(The defailt redirect URI used in PyYoutube is `https://localhost/`) -Now we can begin do the follows step. +We can now perform the following steps: -Initialize the api instance with you app credentials +Initialize the api instance with your app credentials ``` In [1]: from pyyoutube import Client @@ -40,46 +40,47 @@ Out[3]: 'PyYouTube') ``` -Now you get the authorization url, you just need copy the link, and open browser to paste the link, click the enter bar. +Open your broswer of choice and copy the link returned by `get_authorize_url()` into the searchbar. ## Do authorization -If you enter the url. you will see this. +On entering the URL, you will see the following: ![auth-1-chose-account](images/auth-1-chose-account.png) -Now you need to chose or enter you google account with youtube. +Select the account to authorize your app to read data from. -If your app have not got the approval from youtube. You will get an warning from youtube. If you have been approved, you will -see the next image show directly. +If your app is not approved for use, you will recieve a warning. You can prevent this by adding your chosen Google account as a test member on your created OAuth application. +Otherwise, you will see the following: ![auth-2-not-approval](images/auth-2-not-approval.png) -For now, you need to click the button ``Advanced``, then click the ``Go to Python-YouTube (unsafe)``. +You will need to click ``Advanced``, then click the ``Go to Python-YouTube (unsafe)``. ![auth-3-advanced](images/auth-3-advanced.png) -Now you can get a window to give permissions. +You should now see a window to select permissions granted to the application. ![auth-4-allow-permission](images/auth-4-allow-permission.png) -click the blue button `allow` to give the permission. +Click `allow` to give the permission. -Then you will get a Connection Error, don't worry. This just because we set the redirect link to `localhost`. +You will see a Connection Error, as the link is redirecting to `localhost`. This is standard behaviour, so don't close the window or return to a previous page! ## Retrieve access token -Now you need to copy the full url in the browser address bar. Then back to you console. +Copy the full redicted URL from the browser address bar, and return to your original console. ``` -In [4]: token = cli.generate_access_token(authorization_response="the whole url") +In [4]: token = cli.generate_access_token(authorization_response="$redirect_url") In [5]: token Out[5]: AccessToken(access_token='access token', expires_in=3600, token_type='Bearer') ``` +(Replace `$redirect_url` with the URL you copied) -now you have got your access token to visit your self data. +You now have an access token to view your account data. ## Get your data @@ -97,4 +98,4 @@ Out[7]: !!! note "Tips" - If you have some confuse. you need to read the [Authorize Requests](https://developers.google.com/youtube/v3/guides/authentication) first. + If you are confused, it is beneficial to read the [Authorize Requests](https://developers.google.com/youtube/v3/guides/authentication) guide first. diff --git a/docs/docs/getting_started.md b/docs/docs/getting_started.md index 906c77eb..8c4fa7b9 100644 --- a/docs/docs/getting_started.md +++ b/docs/docs/getting_started.md @@ -1,44 +1,44 @@ -This doc is a simple tutorial to show how to use this library to get data from YouTube DATA API. +This document is a simple tutorial to show how to use this library to get data from YouTube data API. -You can get the whole description for YouTube API at [YouTube API Reference](https://developers.google.com/youtube/v3/docs/). +You can get the whole description for the YouTube API at [YouTube API Reference](https://developers.google.com/youtube/v3/docs/). ## Prerequisite -At the beginning. You need to create a [Google Project](https://console.cloud.google.com) by your google account. +To begin, you need to create a [Google Project](https://console.cloud.google.com) with your google account. -Every new account has 12 project to cost. +Every new account has a free quota of 12 projects. ## Create your project -Click the `Select a project-> NEW PROJECT` to create a new project to use our library. +Click `Select a project-> NEW PROJECT` to create a new project to use our library. -Fill the basic info to finish created. +Fill in the basic info and create the project. ![gt-create-app-1](images/gt-create-app-1.png) ## Enable YouTube DATA API service -Once the project created, the browser will redirect project home page. +Once the project created, the browser will redirect you to the project home page. -Then click the `≡≡` symbol on the left top. Chose the `APIs & Services` tab. +Click the `≡≡` symbol on the top left and select the `APIs & Services` tab. -You will see follow info. +You will see following info: ![gt-create-app-2](images/gt-create-app-2.png) -Click the `+ ENABLE APIS AND SERVICES` symbol. And input `YouTube DATA API` to search. +Click the `+ ENABLE APIS AND SERVICES` symbol, and input `YouTube DATA API` to search. ![gt-create-app-3](images/gt-create-app-3.png) -Then chose the ``YouTube DATA API`` item. +Chose the ``YouTube DATA API`` item. ![gt-create-app-4](images/gt-create-app-4.png) -Then click the `ENABLE` blue button. Now the service has been activated. +Then click the `ENABLE` blue button. After a short period where the API is added to your project, the service will be activated. ## Create credentials -To use this API, you may need credentials. Click 'Create credentials' to get started. +To use this API, you need credentials. Click `Create credentials` to get started. ![gt-create-app-5](images/gt-create-app-5.png) @@ -50,9 +50,9 @@ Then click the blue button `What credentials do I need?` to create. ![gt-create-app-6](images/gt-create-app-6.png) -Now you have generated one api key. +You have now generated an api key. -Use this key. You can retrieve public data for YouTube data by our library +Using this key, you can retrieve public YouTube data with our library ```python from pyyoutube import Client @@ -60,10 +60,10 @@ from pyyoutube import Client cli = Client(api_key="your api key") ``` -If you want to get some examples to see, check out the [examples](https://github.com/sns-sdks/python-youtube/tree/master/examples). +Check out the [examples](https://github.com/sns-sdks/python-youtube/tree/master/examples) directory for some examples of using the library. -If you have an opens source application using python-youtube, send me a link, and I am very happy to add a link to it here. +If you have an open source application using python-youtube, send me a link. I am very happy to add a link to it here. -But if you want to get user data by OAuth. You need create the credential for ``OAuth client ID``. +If you want to get user data by OAuth. You need create the credential for ``OAuth client ID``. -And get more info at next page for [Authorization](authorization.md). +You will find more information on OAth at the [Authorization](authorization.md) page. diff --git a/docs/docs/index.md b/docs/docs/index.md index 0e541a58..e8663acc 100644 --- a/docs/docs/index.md +++ b/docs/docs/index.md @@ -13,7 +13,7 @@ Use the API to upload videos, manage playlists and subscriptions, update channel This library provides a Python interface for the [YouTube DATA API](https://developers.google.com/youtube/v3). -Library could work on Python 3.6+. +This library has works on all Python versions 3.6 and newer. !!! tip "Tips" diff --git a/docs/docs/installation.md b/docs/docs/installation.md index 19b25016..16f5c8e1 100644 --- a/docs/docs/installation.md +++ b/docs/docs/installation.md @@ -17,7 +17,7 @@ $ pip install --upgrade python-youtube ``` -Also, you can build this library from source code +You can also build this library from source ```shell $ git clone https://github.com/sns-sdks/python-youtube.git @@ -28,8 +28,8 @@ $ make build ## Testing -If you have been installing the requirements use ``make env``. -You can use following command to test the code +Run `make env` after you have installed the project requirements. +Once completed, you can run code tests with ```shell $ make tests-html diff --git a/docs/docs/introduce-new-structure.md b/docs/docs/introduce-new-structure.md index 594641ca..701ab800 100644 --- a/docs/docs/introduce-new-structure.md +++ b/docs/docs/introduce-new-structure.md @@ -2,12 +2,12 @@ This doc will show you the new api structure for this library. ## Brief -To make the package easier to maintain and easy to use. We are shifted to using classes for different YouTube resources in an easier, higher-level programming experience. +To make the package easier to maintain and easy to use. We have shifted to using classes for different YouTube resources in an easier, higher-level, programming experience. ![structure-uml](images/structure-uml.png) -In this structure, every resource will have self class. And to operate with YouTube API. +In this structure, every resource has a self class. ## Simple usage diff --git a/docs/docs/usage/work-with-api.md b/docs/docs/usage/work-with-api.md index e1d3e6c2..1d2070dc 100644 --- a/docs/docs/usage/work-with-api.md +++ b/docs/docs/usage/work-with-api.md @@ -2,7 +2,7 @@ !!! note "Tips" - This is previous version to operate YouTube DATA API. + This is the previous version to operate YouTube DATA API. We recommend using the latest version of methods to operate YouTube DATA API. @@ -10,7 +10,7 @@ The API is exposed via the ``pyyoutube.Api`` class. ## INSTANTIATE -There provide two method to create instance the ``pyyoutube.Api``. +We provide two method to create instances of the ``pyyoutube.Api``. You can just initialize with an api key. @@ -20,7 +20,7 @@ You can just initialize with an api key. >>> api = Api(api_key="your api key") ``` -If you want to get some authorization data. you need to initialize with access token. +If you want to get authorization data, you will need to initialize with an access token. ``` >>> from pyyoutube import Api @@ -30,7 +30,7 @@ If you want to get some authorization data. you need to initialize with access t You can read the docs to see how to get an access token. -Or you can ask for user to do oauth flow: +Or you can ask for the user to do oauth flow: ``` >>> from pyyoutube import Api @@ -51,9 +51,9 @@ Now you can use the instance to get data from YouTube. ### CHANNEL DATA -The library provides several ways to get channel's data. +The library provides several ways to get a channels data. -If a channel is not found, the property ``items`` will return with blank list. +If a channel is not found, the property ``items`` will return an empty list. You can use channel id: @@ -88,8 +88,9 @@ You can use channel id: } ``` -You can pass a channel id with comma-separated id string or a list, tuple or set of ids to get multiple channels. -Many methods also provide this functionality. +To get multiple channels, you can pass any of: a string containing comma-seperated ids; or an enumarable (list, tuple, or set) of ids + +Many other methods also provide this functionality. with ids: @@ -100,7 +101,7 @@ with ids: Channel(kind='youtube#channel', id='UCa-vrCLQHviTOVnEKDOdetQ')] ``` -You can also use channel name: +You can also use a channel name: ``` >>> channel_by_username = api.get_channel_info(for_username="GoogleDevelopers") @@ -108,7 +109,7 @@ You can also use channel name: Channel(kind='youtube#channel', id='UC_x5XG1OV2P6uZZ5FSM9Ttw') ``` -If you have authorized, you can get your channels: +If you have authorized your client, you can get your channels directly: ``` >>> channel_by_mine = api_with_authorization.get_channel_info(mine=True) @@ -122,7 +123,7 @@ Channel(kind='youtube#channel', id='UCa-vrCLQHviTOVnEKDOdetQ') ### PLAYLIST -There are methods to get playlists by playlist id, channel id or get your own playlists. +There are methods to get playlists by playlist id, channel id, or get your own playlists. Get playlists by id: @@ -132,7 +133,7 @@ Get playlists by id: [Playlist(kind='youtube#playlist', id='PLOU2XLYxmsIKpaV8h0AGE05so0fAwwfTw')] ``` -Get playlists by channel (If you want to get all playlists for the target channel's , just provide the +Get playlists by channel (If you want to get all playlists for the target channels, provide the parameter `count=None`): ``` @@ -145,7 +146,7 @@ parameter `count=None`): Playlist(kind='youtube#playlist', id='PLOU2XLYxmsIJ8ItHmK4bRlY4GCzMgXLAJ')] ``` -Get your playlists(this requires authorization): +Get your playlists (this requires authorization): ``` >>> playlists_by_mine = api.get_playlists(mine=True) @@ -165,7 +166,7 @@ Get playlist items by id: [PlaylistItem(kind='youtube#playlistItem', id='UExPVTJYTFl4bXNJS3BhVjhoMEFHRTA1c28wZkF3d2ZUdy41NkI0NEY2RDEwNTU3Q0M2')] ``` -Get playlist items by playlist id (If you want to get target playlist all items, just provide the +Get playlist items by playlist id (If you want to get return all items in a playlist, provide the parameter `count=None`): ``` @@ -204,7 +205,7 @@ Get videos by chart (If you want to get all videos, just provide the parameter ` Video(kind='youtube#video', id='hDeuSfo_Ys0')] ``` -Get videos by your rating (this requires authorization, also if you want to get all videos, just provide the +Get videos by your rating (this requires authorization. If you also want to get all videos, provide the parameter `count=None`): ``` @@ -213,7 +214,7 @@ parameter `count=None`): ### COMMENT THREAD -You can get comment thread information by id or some filter. +You can get comment thread information by id or by a filter. Get comment thread by id(s): @@ -226,8 +227,8 @@ Get comment thread by id(s): CommentThread(kind='youtube#commentThread', id='UgzhytyP79_PwaDd4UB4AaABAg')] ``` -Get all comment threads related to a channel (including comment threads for the channel's video, also if you want to get -all comment threads, just provide the parameter `count=None`): +Get all comment threads related to a channel (including comment threads for the channel's video. If you want to get +all comment threads, provide the parameter `count=None`): ``` >>> ct_by_all = api.get_comment_threads(all_to_channel_id="UC_x5XG1OV2P6uZZ5FSM9Ttw", count=2) @@ -237,7 +238,7 @@ all comment threads, just provide the parameter `count=None`): CommentThread(kind='youtube#commentThread', id='UgyvoQJ2LsxCBwGEpMB4AaABAg')] ``` -Get comment threads only for the channel (If you want to get all comment threads, just provide the +Get comment threads only for the channel (If you want to get all comment threads, provide the parameter `count=None`): ``` @@ -248,7 +249,7 @@ parameter `count=None`): CommentThread(kind='youtube#commentThread', id='Ugzi3lkqDPfIOirGFLh4AaABAg')] ``` -Get comment threads only for the video (If you want to get all comment threads, just provide the +Get comment threads only for the video (If you want to get all comment threads, provide the parameter `count=None`): ``` @@ -318,10 +319,10 @@ You can get subscription information by id, by point channel, or your own. !!! note "Tips" - If you want to get the subscriptions not set to public, you need do authorization first and get the access token. - You can see the demo [A demo for get my subscription](examples/subscription.py). + If you want to get the non-public subscriptions, you need to authorize and obtain the access token first. + See the demo [A demo for get my subscription](examples/subscription.py). -To get subscription info by id(s), this needs your token to have the permission for the subscriptions belonging to a +To get subscription info by id(s), your token needs to have the permission for the subscriptions belonging to a channel or user: ``` @@ -336,7 +337,7 @@ SubscriptionListResponse(kind='youtube#subscriptionListResponse') Subscription(kind='youtube#subscription', id='zqShTXi-2-Rya5uUxEp3ZsPI3fZrFQnSXNQCwvHBGGo', snippet=SubscriptionSnippet(title='ikaros-life', description='This is a test channel.'))] ``` -Get your own subscriptions, this need you do authorization first or give the authorized access token: +Get your own subscriptions, you need to authorize first, and supply the token: ``` >>> r = api.get_subscription_by_me( @@ -351,7 +352,7 @@ SubscriptionListResponse(kind='youtube#subscriptionListResponse') Subscription(kind='youtube#subscription', id='zqShTXi-2-Tx7TtwQqhCBwViE_j9IEgnmRmPnqJljxo', snippet=SubscriptionSnippet(title='PyCon 2015', description=''))] ``` -Get public channel's subscriptions: +Get public channel subscriptions: ``` >>> r = api.get_subscription_by_channel( @@ -413,7 +414,7 @@ CaptionListResponse(kind='youtube#captionListResponse') ### CHANNEL SECTIONS -You can get channel sections by self id or belonged channel id or your own channel. +You can get channel sections by channel id, section id, or your own channel. Get channel sections by channel id: @@ -517,7 +518,7 @@ You can retrieve a list of reasons that can be used to report abusive videos: ### SEARCH -You can use those methods to search the video,playlist,channel data. For more info, you can see +You can use those methods to search the video, playlist, or channel data. For more info, you can see the [Search Request Docs](https://developers.google.com/youtube/v3/docs/search/list). You can search different type of resource with keywords: diff --git a/docs/docs/usage/work-with-client.md b/docs/docs/usage/work-with-client.md index 3e2adffc..a2d95826 100644 --- a/docs/docs/usage/work-with-client.md +++ b/docs/docs/usage/work-with-client.md @@ -20,7 +20,7 @@ from pyyoutube import Client cli = Client(api_key="your api key") ``` -If you want to update your channel data. or upload video. You need initialize with `access token`, Or do auth flow. +If you want to update your channel data. or upload video. You need to initialize with `access token`, or do the auth flow. ```python from pyyoutube import Client @@ -47,7 +47,7 @@ Only `web` and some `installed` type client_secrets are supported. The fields `client_id` and `client_secret` must be set. -If the field `redirect_uris` has 1 or more values set, the `Client.DEFAULT_REDIRECT_URI` will be set to the first entrie. +`Client.DEFAULT_REDIRECT_URI` will be set the first entry of the field `redirect_uris`. ```python from pyyoutube import Client @@ -58,13 +58,13 @@ cli = Client(client_secret_path=file_path) # Then go through auth flow descriped above ``` -Once initialize client. Then you can operate API to get data. +Once initialize to the client, you can operate the API to get data. ## Usage ### Channel Resource -The API supports the following methods for `channels` resources: +The API supports the following methods for the `channels` resources: - list: Returns a collection of zero or more channel resources that match the request criteria. - update: Updates a channel's metadata. Note that this method currently only supports updates to the channel resource's diff --git a/examples/README.md b/examples/README.md index 6d6f8401..e5a753a7 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,9 +1,9 @@ # Examples -Now we provide two entry for operate YouTube DATA API. +We provide two entry points to operate the YouTube DATA API. -- Use Api `from pyyoutube import Api`: This is an old implementation used to be compatible with older versions of code. -- Use Client `from pyyoutube import Client`: This is a new implementation for operating the API and provides additional +- Api `from pyyoutube import Api`: This is an old implementation used to be compatible with older versions of code. +- Client `from pyyoutube import Client`: This is a new implementation for operating the API and provides additional capabilities. # Basic Usage @@ -18,7 +18,7 @@ api.get_channel_info(channel_id="id for channel") # ChannelListResponse(kind='youtube#channelListResponse') ``` -You can get more examples at [this](/examples/apis/). +You can get more examples at [api examples](/examples/apis/). ## Client @@ -30,4 +30,4 @@ cli.channels.list(channel_id="UC_x5XG1OV2P6uZZ5FSM9Ttw") # ChannelListResponse(kind='youtube#channelListResponse') ``` -You can get more examples at [this](/examples/clients/). +You can get more examples at [client examples](/examples/clients/). From 179685dd8393ad3504dda903424023def93e93fb Mon Sep 17 00:00:00 2001 From: Skiy Date: Tue, 4 Feb 2025 22:58:15 +0000 Subject: [PATCH 137/141] add refresh example --- examples/clients/oauth_refreshing.py | 80 ++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 examples/clients/oauth_refreshing.py diff --git a/examples/clients/oauth_refreshing.py b/examples/clients/oauth_refreshing.py new file mode 100644 index 00000000..557f504c --- /dev/null +++ b/examples/clients/oauth_refreshing.py @@ -0,0 +1,80 @@ +""" + This example demonstrates how to automatically (re)generate tokens for continuous OAuth. + We store the Access Token in a seperate .env file to be used later. +""" + +from pyyoutube import Client +from json import loads, dumps +from pathlib import Path + + +CLIENT_ID = "xxx" # Your app id +CLIENT_SECRET = "xxx" # Your app secret +CLIENT_SECRET_PATH = None # or your path/to/client_secret_web.json + +TOKEN_PERSISTENT_PATH = None # path/to/persistent_token_storage_location + +SCOPE = [ + "https://www.googleapis.com/auth/youtube", + "https://www.googleapis.com/auth/youtube.force-ssl", + "https://www.googleapis.com/auth/userinfo.profile", +] + +def do_refresh(): + token_location = Path(TOKEN_PERSISTENT_PATH) + + # Read the persistent token data if it exists + token_data = {} + if token_location.exists(): + token_data = loads(token_location.read_text()) + + + cli = Client( + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + access_token=token_data.get("access_token"), + refresh_token=token_data.get("refresh_token") + ) + # or if you want to use a web type client_secret.json + # cli = Client( + # client_secret_path=CLIENT_SECRET_PATH, + # access_token=token_data.get("access_token"), + # refresh_token=token_data.get("refresh_token") + # ) + + # If no access token is provided, this is the same as oauth_flow.py + if not cli._has_auth_credentials(): + authorize_url, state = cli.get_authorize_url(scope=SCOPE) + print(f"Click url to do authorize: {authorize_url}") + + response_uri = input("Input youtube redirect uri:\n") + + token = cli.generate_access_token(authorization_response=response_uri, scope=SCOPE) + print(f"Your token: {token}") + + # Otherwise, refresh the access token if it has expired + else: + token = cli.refresh_access_token(cli.refresh_token) + + # we add the token data to the client and token objects so that they are complete + token.refresh_token = cli.refresh_token + cli.access_token = token.access_token + print(f"Your token: {token}") + + # Write the token data to the persistent location to be used again, ensuring the file exists + token_location.mkdir(parents=True, exist_ok=True) + token_location.write_text( + dumps( + { + "access_token": token.access_token, + "refresh_token": token.refresh_token + } + ) + ) + + # Now you can do things with the client + resp = cli.channels.list(mine=True) + print(f"Your channel id: {resp.items[0].id}") + +if __name__ == "__main__": + do_refresh() From 4622d0aef673dc72260263b01f4c02aecb67b4c0 Mon Sep 17 00:00:00 2001 From: Sagar Vora <16315650+sagarvora@users.noreply.github.com> Date: Thu, 26 Jun 2025 14:08:20 +0530 Subject: [PATCH 138/141] fix(dependencies): remove strict pinning for `requests-oauthlib` --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index cd8ebbf5..35eb2d2e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,7 +32,7 @@ packages = [ [tool.poetry.dependencies] python = "^3.6" requests = "^2.24.0" -requests-oauthlib = "=1.3.0,<3.0.0" +requests-oauthlib = ">=1.3.0,<3.0.0" isodate = ">=0.6.0,<=0.7.2" dataclasses-json = [ { version = "^0.5.3", python = "<3.7" }, From f7f643e8f4aa7691dfaf9bf1b0210a231d76b5e2 Mon Sep 17 00:00:00 2001 From: YuriiMaiboroda <47284191+YuriiMaiboroda@users.noreply.github.com> Date: Sun, 29 Jun 2025 23:50:15 +0300 Subject: [PATCH 139/141] Fix import common Most types from `common.py` can be obtained directly from `pyyoutube`. But `Thumbnail`, `Topic` and `PageInfo` are only available through `pyyoutube.models.common`. Adding this import fixes this problem. --- pyyoutube/models/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/pyyoutube/models/__init__.py b/pyyoutube/models/__init__.py index 44603d8e..a8d20fb8 100644 --- a/pyyoutube/models/__init__.py +++ b/pyyoutube/models/__init__.py @@ -7,6 +7,7 @@ from .channel_section import * # noqa from .comment import * # noqa from .comment_thread import * # noqa +from .common import * # noqa from .i18n import * # noqa from .member import * # noqa from .memberships_level import * # noqa From 5bf2d8842398b57d2ca066968abba58d91a01e1c Mon Sep 17 00:00:00 2001 From: ikaroskun Date: Fri, 22 Aug 2025 16:37:03 +0800 Subject: [PATCH 140/141] docs(changelog): :memo: update changelog --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9900bc63..3b69c516 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ All notable changes to this project will be documented in this file. +## Version 0.9.8 (2025-08-22) + +### What's New + +- Fix dependencies and update docs. Thanks for [@sagarvora](https://github.com/sagarvora) + ## Version 0.9.7 (2024-10-28) ### What's New From 43d813daf0c629b27661e8799325be4ff6c21955 Mon Sep 17 00:00:00 2001 From: ikaroskun Date: Fri, 22 Aug 2025 16:39:18 +0800 Subject: [PATCH 141/141] =?UTF-8?q?Bump=20version:=200.9.7=20=E2=86=92=200?= =?UTF-8?q?.9.8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .bumpversion.cfg | 2 +- pyproject.toml | 2 +- pyyoutube/__version__.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index c6e31428..d5e0343c 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.9.7 +current_version = 0.9.8 commit = True tag = True diff --git a/pyproject.toml b/pyproject.toml index 35eb2d2e..19613675 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "python-youtube" -version = "0.9.7" +version = "0.9.8" description = "A Python wrapper around for YouTube Data API." authors = ["ikaroskun "] license = "MIT" diff --git a/pyyoutube/__version__.py b/pyyoutube/__version__.py index 6f311c38..d0da5dec 100644 --- a/pyyoutube/__version__.py +++ b/pyyoutube/__version__.py @@ -5,4 +5,4 @@ # 88 88 88 88 88 `8b d8' 88 V888 88 `8b d8' 88b d88 88 88b d88 88 8D 88. # 88 YP YP YP YP `Y88P' VP V8P YP `Y88P' ~Y8888P' YP ~Y8888P' Y8888P' Y88888P -__version__ = "0.9.7" +__version__ = "0.9.8"