diff --git a/.bumpversion.cfg b/.bumpversion.cfg index d7152a1b..d5e0343c 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.8.1 +current_version = 0.9.8 commit = True tag = True 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..297696f2 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.17 + 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/.github/workflows/test.yaml b/.github/workflows/test.yaml index d843ae30..a3376ed1 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -8,15 +8,18 @@ on: jobs: test: - runs-on: ubuntu-latest + 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' + 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 @@ -32,10 +35,12 @@ jobs: run: | poetry run pytest - name: Upload coverage to Codecov - uses: codecov/codecov-action@v1 + if: ${{ matrix.update-coverage }} + uses: codecov/codecov-action@v4 with: file: ./coverage.xml fail_ci_if_error: true + token: ${{ secrets.CODECOV_TOKEN }} lint: name: black diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f739663..3b69c516 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,80 @@ # Changelog -## Version 0.8.1 +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 + +- 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 + +- 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) + +### 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) + +### 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 + +- Add new parameter for search method +- Mark some parameter or method to be deprecated. + +## 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 + +- 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) + +### 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 OAuthorize functions. +- Update for examples. + +## Version 0.8.1 (2021-05-14) ### Deprecation diff --git a/README.rst b/README.rst index e6a57d82..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,13 +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. + +.. + + We have recently been working on the new structure for the library. `Read docs `_ to get more detail. ============= Documentation @@ -52,13 +56,62 @@ You can install this lib from PyPI: Using ===== -The API is exposed via the ``pyyoutube.Api`` class. +The library covers all resource methods, including ``insert``,``update``, and so on. + +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 initialize with an api key: + +.. code-block:: python + + >>> from pyyoutube import Client + >>> client = Client(api_key="your api key") + +To access additional data that requires authorization, 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: + +.. 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', + ... + } + +See the `client docs `_, or `client examples `_, for additional usage + +Work with API +---------------- ------------ -INSTANTIATE ------------ +.. -There provide two method to create instance the ``pyyoutube.Api``. + For compatibility with older code, we continue to support the old way. You can just initialize with an api key: @@ -67,16 +120,16 @@ 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 >>> 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. -Or you can ask for user to do oauth flow: +Or you can ask for user to do OAuth flow: .. code-block:: python @@ -92,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 @@ -132,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', + ... + } + +See the `api docs `_, or `api examples `_, for additional usage. diff --git a/docs/docs/authorization.md b/docs/docs/authorization.md index 514fb085..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,63 +23,64 @@ 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 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') ``` -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 = api.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 @@ -87,7 +88,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 +96,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 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 05bd1a53..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,19 +50,20 @@ 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 -```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). +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/images/structure-uml.png b/docs/docs/images/structure-uml.png new file mode 100644 index 00000000..a48152c2 Binary files /dev/null and b/docs/docs/images/structure-uml.png differ diff --git a/docs/docs/index.md b/docs/docs/index.md index 5fe1e4b6..e8663acc 100644 --- a/docs/docs/index.md +++ b/docs/docs/index.md @@ -13,8 +13,8 @@ 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" - 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`. 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 new file mode 100644 index 00000000..701ab800 --- /dev/null +++ b/docs/docs/introduce-new-structure.md @@ -0,0 +1,36 @@ +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 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 has a self class. + +## Simple usage + + +### Initial Client + +```python +from pyyoutube import Client + +client = Client(api_key="your api key") +``` + +### Get data. + +for example to get channel data. + +```python +resp = client.channels.list( + parts=["id", "snippet"], + channel_id="UCa-vrCLQHviTOVnEKDOdetQ" +) +# resp output +# ChannelListResponse(kind='youtube#channelListResponse') +# resp.items[0].id output +# UCa-vrCLQHviTOVnEKDOdetQ +``` diff --git a/docs/docs/usage/work-with-api.md b/docs/docs/usage/work-with-api.md new file mode 100644 index 00000000..1d2070dc --- /dev/null +++ b/docs/docs/usage/work-with-api.md @@ -0,0 +1,584 @@ +# Work with Api + +!!! note "Tips" + + This is the 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 + +We provide two method to create instances of 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 authorization data, you will need to initialize with an 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 the 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 a channels data. + +If a channel is not found, the property ``items`` will return an empty 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'}, + ... + } +``` + +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: + +``` +>>> 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 a 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 your client, you can get your channels directly: + +``` +>>> 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 channels, 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 return all items in a playlist, 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. If you also want to get all videos, 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 by a 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. 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) + +>>> 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, 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, 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 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), your token needs 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, you need to authorize first, and supply the 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 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 channel id, section 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, 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: + +``` +>>> 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..a2d95826 --- /dev/null +++ b/docs/docs/usage/work-with-client.md @@ -0,0 +1,221 @@ +# 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 to initialize with `access token`, or do the 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') +``` + +### from client_secret + +Only `web` and some `installed` type client_secrets are supported. + +The fields `client_id` and `client_secret` must be set. + +`Client.DEFAULT_REDIRECT_URI` will be set the first entry of the field `redirect_uris`. + +```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 to the client, you can operate the API to get data. + +## Usage + +### Channel Resource + +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 + 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 63099674..05473021 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -30,6 +30,10 @@ 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/README.md b/examples/README.md new file mode 100644 index 00000000..e5a753a7 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,33 @@ +# Examples + +We provide two entry points to operate the YouTube DATA API. + +- 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 + +## 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 [api examples](/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 [client examples](/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 70% rename from examples/channel_videos.py rename to examples/apis/channel_videos.py index a11aba5b..2f4f158e 100644 --- a/examples/channel_videos.py +++ b/examples/apis/channel_videos.py @@ -13,19 +13,19 @@ 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( + 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 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 78% 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 index 8391fc87..aeafdaf7 100644 --- a/examples/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 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/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() 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..ef358b12 --- /dev/null +++ b/examples/clients/oauth_flow.py @@ -0,0 +1,38 @@ +""" + This example demonstrates how to perform authorization. +""" + +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", + "https://www.googleapis.com/auth/userinfo.profile", +] + + +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}") + + 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/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() 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() diff --git a/pyproject.toml b/pyproject.toml index e89513cb..19613675 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "python-youtube" -version = "0.8.1" +version = "0.9.8" description = "A Python wrapper around for YouTube Data API." authors = ["ikaroskun "] license = "MIT" @@ -9,16 +9,19 @@ 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", "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", - "Intended Audience :: Developers", ] packages = [ @@ -29,15 +32,27 @@ packages = [ [tool.poetry.dependencies] python = "^3.6" requests = "^2.24.0" -requests-oauthlib = "^1.3.0" -isodate = "^0.6.0" -dataclasses-json = "^0.5.3" +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" } +] [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>=0.12"] -build-backend = "poetry.masonry.api" +requires = ["poetry-core>=1.0.0"] +build-backend = "poetry.core.masonry.api" 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/__version__.py b/pyyoutube/__version__.py index 91cf9eac..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.8.1" +__version__ = "0.9.8" diff --git a/pyyoutube/api.py b/pyyoutube/api.py index a580a0d3..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) @@ -1600,6 +1608,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 +1679,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/client.py b/pyyoutube/client.py new file mode 100644 index 00000000..bdbdfa10 --- /dev/null +++ b/pyyoutube/client.py @@ -0,0 +1,522 @@ +""" + New Client for YouTube API +""" + +import inspect +import json +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.models.base import BaseModel +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/" + 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" + + DEFAULT_REDIRECT_URI = "https://localhost/" + DEFAULT_SCOPE = [ + "https://www.googleapis.com/auth/youtube", + "https://www.googleapis.com/auth/userinfo.profile", + ] + DEFAULT_STATE = "Python-YouTube" + + activities = resources.ActivitiesResource() + captions = resources.CaptionsResource() + channels = resources.ChannelsResource() + channelBanners = resources.ChannelBannersResource() + channelSections = resources.ChannelSectionsResource() + comments = resources.CommentsResource() + commentThreads = resources.CommentThreadsResource() + i18nLanguages = resources.I18nLanguagesResource() + i18nRegions = resources.I18nRegionsResource() + members = resources.MembersResource() + membershipsLevels = resources.MembershipLevelsResource() + playlistItems = resources.PlaylistItemsResource() + playlists = resources.PlaylistsResource() + search = resources.SearchResource() + subscriptions = resources.SubscriptionsResource() + thumbnails = resources.ThumbnailsResource() + videoAbuseReportReasons = resources.VideoAbuseReportReasonsResource() + videoCategories = resources.VideoCategoriesResource() + videos = resources.VideosResource() + watermarks = resources.WatermarksResource() + + 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, + client_secret_path: 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. + client_secret_path: + path to the client_secret.json file provided by google 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() + + 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._has_auth_credentials() or self._has_client_data()): + raise PyYouTubeException( + ErrorMessage( + status_code=ErrorCode.MISSING_PARAMS, + message="Must specify either client key info or api key.", + ) + ) + + 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. + """ + + with open(client_secret_path, "r") as f: + secrets_data = json.load(f) + + 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' and 'installed' type client_secret files are supported.", + ) + ) + + # 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"missing required field '{field}'.", + ) + ) + + self.client_id = credentials["client_id"] + self.client_secret = credentials["client_secret"] + + # 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 + + 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: + 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, + json: Optional[dict] = None, + enforce_auth: bool = True, + is_upload: bool = False, + **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. + json: + 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. + + Returns: + Response for request. + + Raises: + PyYouTubeException: Missing credentials when need credentials. + Request http error. + """ + if not path.startswith("http"): + base_url = self.BASE_UPLOAD_URL if is_upload else self.BASE_URL + path = base_url + path + + # Add credentials to request + if enforce_auth: + 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) + + # If json is dataclass convert to dict + if isinstance(json, BaseModel): + json = json.to_dict_ignore_none() + + try: + response = self.session.request( + method=method, + url=path, + params=params, + data=data, + json=json, + 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 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, + 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/identity/protocols/oauth2/scopes#youtube + 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) + + 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, + **kwargs, + ) + 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, + ) + 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): diff --git a/pyyoutube/media.py b/pyyoutube/media.py new file mode 100644 index 00000000..02e04a11 --- /dev/null +++ b/pyyoutube/media.py @@ -0,0 +1,223 @@ +""" + Media object to upload. +""" + +import mimetypes +import os +from typing import IO, Optional, Tuple + +from requests import Response + +from pyyoutube.error import PyYouTubeException, ErrorMessage, ErrorCode + +DEFAULT_CHUNK_SIZE = 20 * 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) + + +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: + def __init__( + self, + client, + resource: str, + media: Media, + params: Optional[dict] = None, + body: Optional[dict] = None, + ) -> None: + """Constructor for upload a file. + + Args: + 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" + + self.resumable_uri = None # Real uri to upload media. + self.resumable_progress = 0 # The bytes that have been uploaded. + + 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. + """ + size = str(self.media.size) + + if self.resumable_uri is None: + start_headers = { + "X-Upload-Content-Type": self.media.mimetype, + "X-Upload-Content-Length": size, + "content-length": str(len(str(self.body or ""))), + } + resp = self.client.request( + method="POST", + path=self.resource, + params=self.params, + json=self.body, + is_upload=True, + headers=start_headers, + ) + if resp.status_code == 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), + } + # 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"] = ( + f"bytes {self.resumable_progress}-{chunk_end}/{size}" + ) + + resp = self.client.request( + path=self.resumable_uri, + method="PUT", + data=data, + headers=headers, + ) + return self.process_response(resp) + + def process_response( + self, resp: Response + ) -> Tuple[Optional[MediaUploadProgress], Optional[dict]]: + """Process the response from chunk upload. + + Args: + resp: Response for request. + + Returns: + 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) + 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, + ) diff --git a/pyyoutube/models/__init__.py b/pyyoutube/models/__init__.py index de757642..a8d20fb8 100644 --- a/pyyoutube/models/__init__.py +++ b/pyyoutube/models/__init__.py @@ -1,163 +1,20 @@ -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_section import ( - ChannelSection, - ChannelSectionContentDetails, - ChannelSectionSnippet, - ChannelSectionResponse, -) -from .comment import ( - Comment, - CommentListResponse, - CommentSnippet, - CommentThread, - CommentThreadListResponse, - CommentThreadReplies, - CommentThreadSnippet, -) -from .playlist import ( - Playlist, - PlaylistContentDetails, - PlaylistListResponse, - PlaylistSnippet, - PlaylistStatus, -) -from .playlist_item import ( - PlaylistItem, - PlaylistItemContentDetails, - PlaylistItemListResponse, - PlaylistItemSnippet, - PlaylistItemStatus, -) -from .subscription import ( - Subscription, - SubscriptionContentDetails, - SubscriptionListResponse, - SubscriptionSnippet, - SubscriptionSubscriberSnippet, -) -from .video import ( - Video, - VideoContentDetails, - VideoListResponse, - VideoSnippet, - VideoStatistics, - VideoStatus, - VideoTopicDetails, -) -from .i18n import ( - I18nRegion, - I18nRegionListResponse, - I18nLanguage, - I18nLanguageListResponse, -) - -from .video_abuse_report_reason import ( - VideoAbuseReportReason, - VideoAbuseReportReasonListResponse, -) -from .search_result import ( - SearchResultId, - SearchResultSnippet, - SearchResult, - SearchListResponse, -) -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", -] +from .caption import * # noqa +from .category import * # noqa +from .channel import * # noqa +from .channel_banner import * # noqa +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 .playlist_item import * # noqa +from .playlist import * # noqa +from .search_result import * # noqa +from .subscription import * # noqa +from .video_abuse_report_reason import * # noqa +from .video import * # noqa +from .watermark import * # noqa 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/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/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 4f8ce4a4..b39478ef 100644 --- a/pyyoutube/models/channel.py +++ b/pyyoutube/models/channel.py @@ -1,26 +1,49 @@ """ 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) 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) @@ -28,76 +51,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 - """ - - 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) - - -@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 + References: https://developers.google.com/youtube/v3/docs/channels#brandingSettings.image """ - property: Optional[str] = field(default=None) - value: Optional[str] = field(default=None) + bannerExternalUrl: Optional[str] = field(default=None, repr=False) @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 +109,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 +120,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 +175,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 +184,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 +196,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/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/models/channel_section.py b/pyyoutube/models/channel_section.py index 61ea09d8..73d4c9ed 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,7 @@ class ChannelSectionResponse(BaseApiResponse): """ items: Optional[List[ChannelSection]] = field(default=None, repr=False) + + +@dataclass +class ChannelSectionListResponse(ChannelSectionResponse): ... diff --git a/pyyoutube/models/comment.py b/pyyoutube/models/comment.py index a6f3b497..e979c3bc 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 @@ -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) @@ -68,53 +69,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..eed6ffc8 --- /dev/null +++ b/pyyoutube/models/comment_thread.py @@ -0,0 +1,59 @@ +""" + 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/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/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/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 ea9bf18a..89648e2f 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 @@ -28,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 @@ -220,6 +221,33 @@ 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): + """ + 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 +262,10 @@ 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 + ) @dataclass @@ -245,3 +277,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/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 new file mode 100644 index 00000000..8d2eace2 --- /dev/null +++ b/pyyoutube/resources/__init__.py @@ -0,0 +1,20 @@ +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 +from .comment_threads import CommentThreadsResource # noqa +from .i18n_languages import I18nLanguagesResource # noqa +from .i18n_regions import I18nRegionsResource # noqa +from .members import MembersResource # noqa +from .membership_levels import MembershipLevelsResource # noqa +from .playlist_items import PlaylistItemsResource # noqa +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 +from .watermarks import WatermarksResource # noqa diff --git a/pyyoutube/resources/activities.py b/pyyoutube/resources/activities.py new file mode 100644 index 00000000..9fa57f66 --- /dev/null +++ b/pyyoutube/resources/activities.py @@ -0,0 +1,90 @@ +""" + 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="Specify at least one of channel_id 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/base_resource.py b/pyyoutube/resources/base_resource.py new file mode 100644 index 00000000..48230a18 --- /dev/null +++ b/pyyoutube/resources/base_resource.py @@ -0,0 +1,23 @@ +""" + Base resource class. +""" + +from typing import Optional, TYPE_CHECKING + +if TYPE_CHECKING: + from pyyoutube import Client # pragma: no cover + + +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/captions.py b/pyyoutube/resources/captions.py new file mode 100644 index 00000000..1a433f14 --- /dev/null +++ b/pyyoutube/resources/captions.py @@ -0,0 +1,262 @@ +""" + Captions resource implementation +""" + +from typing import Optional, Union + +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 + + +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, + caption_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. + 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: + 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="captions", value=parts), + "videoId": video_id, + "id": enf_comma_separated(field="caption_id", value=caption_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) + + def insert( + self, + 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, + **kwargs, + ) -> MediaUpload: + """Uploads a caption track. + + Args: + 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. + 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. + 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. + + Returns: + Caption data. + """ + + params = { + "part": enf_parts(resource="captions", value=parts), + "onBehalfOfContentOwner": on_behalf_of_content_owner, + "sync": sync, + **kwargs, + } + # 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 + + def update( + self, + 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, Caption, MediaUpload]: + """Updates a caption track. + + Args: + 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. + 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. + 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: + Additional parameters for system parameters. + Refer: https://cloud.google.com/apis/docs/system-parameters. + Returns: + Caption data. + + """ + params = { + "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="captions", + params=params, + json=body, + ) + data = self._client.parse_response(response=response) + return data if return_json else Caption.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", method="DELETE", params=params) + if response.ok: + return True + self._client.parse_response(response=response) diff --git a/pyyoutube/resources/channel_banners.py b/pyyoutube/resources/channel_banners.py new file mode 100644 index 00000000..4c28f1f0 --- /dev/null +++ b/pyyoutube/resources/channel_banners.py @@ -0,0 +1,52 @@ +""" + Channel banners resource implementation. +""" + +from typing import Optional + +from pyyoutube.resources.base_resource import Resource +from pyyoutube.media import Media, MediaUpload + + +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 + """ + + def insert( + self, + media: Media, + on_behalf_of_content_owner: Optional[str] = None, + **kwargs: Optional[dict], + ) -> 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 + 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 banner data. + """ + params = {"onBehalfOfContentOwner": on_behalf_of_content_owner, **kwargs} + # Build a media upload instance. + media_upload = MediaUpload( + client=self._client, + resource="channelBanners/insert", + media=media, + params=params, + ) + return media_upload diff --git a/pyyoutube/resources/channel_sections.py b/pyyoutube/resources/channel_sections.py new file mode 100644 index 00000000..ea441df3 --- /dev/null +++ b/pyyoutube/resources/channel_sections.py @@ -0,0 +1,240 @@ +""" + Channel Section 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 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="Specify at least one of channel_id, section_id 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, + 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, + **kwargs, + ) -> Union[dict, ChannelSection]: + """Adds a channel section to the authenticated user's channel. + A channel can create a maximum of 10 shelves. + + Args: + 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: + - 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": enf_parts(resource="channelSections", value=parts), + "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, + 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, + ) -> Union[dict, ChannelSection]: + """Updates a channel section. + + Args: + 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: + - 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": enf_parts(resource="channelSections", value=parts), + "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 new file mode 100644 index 00000000..311726c3 --- /dev/null +++ b/pyyoutube/resources/channels.py @@ -0,0 +1,171 @@ +""" + 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 Channel, ChannelListResponse +from pyyoutube.utils.params_checker import enf_comma_separated, enf_parts + + +class ChannelsResource(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_handle: Optional[str] = None, + for_username: Optional[str] = None, + channel_id: Optional[Union[str, list, tuple, set]] = 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. + 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. + channel_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 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. + 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_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) + 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="Specify at least one of for_handle,for_username,channel_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: Union[dict, Channel], + on_behalf_of_content_owner: Optional[str] = None, + return_json: bool = False, + **kwargs, + ) -> Union[dict, Channel]: + """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 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 + 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, + json=body, + ) + data = self._client.parse_response(response=response) + return data if return_json else Channel.from_dict(data) diff --git a/pyyoutube/resources/comment_threads.py b/pyyoutube/resources/comment_threads.py new file mode 100644 index 00000000..3637fce1 --- /dev/null +++ b/pyyoutube/resources/comment_threads.py @@ -0,0 +1,156 @@ +""" + 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 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: + 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 comment 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 new file mode 100644 index 00000000..492451b6 --- /dev/null +++ b/pyyoutube/resources/comments.py @@ -0,0 +1,258 @@ +""" + Comment 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 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 comment 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="Specify at least one of comment_id, or parent_id", + ) + ) + + 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, + body: Union[dict, Comment], + parts: Optional[Union[str, list, tuple, set]] = None, + 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: + 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: + Additional parameters for system parameters. + Refer: https://cloud.google.com/apis/docs/system-parameters. + + Returns: + Comment data. + """ + + params = {"part": enf_parts(resource="comments", value=parts), **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, + body: Union[dict, Comment], + parts: Optional[Union[str, list, tuple, set]] = None, + return_json: bool = False, + **kwargs, + ) -> Union[dict, Comment]: + """Modifies a comment. + + Args: + 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: + Additional parameters for system parameters. + Refer: https://cloud.google.com/apis/docs/system-parameters. + + Returns: + Comment updated data. + + """ + params = {"part": enf_parts(resource="comments", value=parts), **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. + + Deprecated at [2023.09.12](https://developers.google.com/youtube/v3/revision_history#september-12,-2023) + + 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) diff --git a/pyyoutube/resources/i18n_languages.py b/pyyoutube/resources/i18n_languages.py new file mode 100644 index 00000000..902e6aa3 --- /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 i18n languages 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/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/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) diff --git a/pyyoutube/resources/membership_levels.py b/pyyoutube/resources/membership_levels.py new file mode 100644 index 00000000..3799b936 --- /dev/null +++ b/pyyoutube/resources/membership_levels.py @@ -0,0 +1,46 @@ +""" + 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="membershipsLevels", params=params) + data = self._client.parse_response(response=response) + return data if return_json else MembershipsLevelListResponse.from_dict(data) diff --git a/pyyoutube/resources/playlist_items.py b/pyyoutube/resources/playlist_items.py new file mode 100644 index 00000000..cb86c2ba --- /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: Union[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: Union[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) diff --git a/pyyoutube/resources/playlists.py b/pyyoutube/resources/playlists.py new file mode 100644 index 00000000..fd7b153a --- /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="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/resources/search.py b/pyyoutube/resources/search.py new file mode 100644 index 00000000..45007d73 --- /dev/null +++ b/pyyoutube/resources/search.py @@ -0,0 +1,258 @@ +""" + 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_paid_product_placement: 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. + 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: + 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_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: + - 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, + "videoPaidProductPlacement": video_paid_product_placement, + "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/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) diff --git a/pyyoutube/resources/thumbnails.py b/pyyoutube/resources/thumbnails.py new file mode 100644 index 00000000..67d92a4a --- /dev/null +++ b/pyyoutube/resources/thumbnails.py @@ -0,0 +1,36 @@ +""" + 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/pyyoutube/resources/video_abuse_report_reasons.py b/pyyoutube/resources/video_abuse_report_reasons.py new file mode 100644 index 00000000..4697fac1 --- /dev/null +++ b/pyyoutube/resources/video_abuse_report_reasons.py @@ -0,0 +1,53 @@ +""" + 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) + ) 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/resources/videos.py b/pyyoutube/resources/videos.py new file mode 100644 index 00000000..699f9a37 --- /dev/null +++ b/pyyoutube/resources/videos.py @@ -0,0 +1,423 @@ +""" + Videos resource implementation. +""" + +from typing import Optional, Union + +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, + VideoGetRatingResponse, + VideoReportAbuse, +) +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="Specify at least one of chart,video_id or my_rating", + ) + ) + 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 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. + + 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. + 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. + **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, + resource="videos", + media=media, + params=params, + body=body.to_dict_ignore_none(), + ) + return media_upload + + 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 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: + 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) + + 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="POST", + path="videos/rate", + params=params, + ) + if response.ok: + return True + self._client.parse_response(response=response) + + def get_rating( + self, + video_id: 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[Union[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) diff --git a/pyyoutube/resources/watermarks.py b/pyyoutube/resources/watermarks.py new file mode 100644 index 00000000..c4ac8b4f --- /dev/null +++ b/pyyoutube/resources/watermarks.py @@ -0,0 +1,98 @@ +""" + 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) diff --git a/pyyoutube/utils/constants.py b/pyyoutube/utils/constants.py index 2d542b20..1961ad82 100644 --- a/pyyoutube/utils/constants.py +++ b/pyyoutube/utils/constants.py @@ -2,6 +2,10 @@ some constants for YouTube """ +ACTIVITIES_RESOURCE_PROPERTIES = {"id", "snippet", "contentDetails"} + +CAPTIONS_RESOURCE_PROPERTIES = {"id", "snippet"} + CHANNEL_RESOURCE_PROPERTIES = { "id", "brandingSettings", @@ -13,44 +17,32 @@ "topicDetails", } -CHANNEL_SECTIONS_PROPERTIES = { - "id", - "contentDetails", - "localizations", - "snippet", - "targeting", -} +CHANNEL_SECTIONS_PROPERTIES = {"id", "contentDetails", "snippet"} -PLAYLIST_RESOURCE_PROPERTIES = { - "id", - "contentDetails", - "localizations", - "player", - "snippet", - "status", -} +COMMENT_RESOURCE_PROPERTIES = {"id", "snippet"} + +COMMENT_THREAD_RESOURCE_PROPERTIES = {"id", "replies", "snippet"} + +I18N_LANGUAGE_PROPERTIES = {"snippet"} + +I18N_REGION_PROPERTIES = {"snippet"} + +MEMBER_PROPERTIES = {"snippet"} + +MEMBERSHIP_LEVEL_PROPERTIES = {"id", "snippet"} PLAYLIST_ITEM_RESOURCE_PROPERTIES = {"id", "contentDetails", "snippet", "status"} -VIDEO_RESOURCE_PROPERTIES = { +PLAYLIST_RESOURCE_PROPERTIES = { "id", "contentDetails", + "localizations", "player", "snippet", - "statistics", "status", - "topicDetails", } -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"} +SEARCH_RESOURCE_PROPERTIES = {"snippet"} SUBSCRIPTION_RESOURCE_PROPERTIES = { "id", @@ -59,61 +51,43 @@ "subscriberSnippet", } -ACTIVITIES_RESOURCE_PROPERTIES = { - "id", - "snippet", - "contentDetails", -} +VIDEO_ABUSE_REPORT_REASON_PROPERTIES = {"id", "snippet"} -CAPTIONS_RESOURCE_PROPERTIES = { - "id", - "snippet", -} +VIDEO_CATEGORY_RESOURCE_PROPERTIES = {"snippet"} -I18N_REGION_PROPERTIES = { - "id", - "snippet", -} - -I18N_LANGUAGE_PROPERTIES = { - "id", - "snippet", -} - -VIDEO_ABUSE_REPORT_REASON_PROPERTIES = { - "id", - "snippet", -} - -MEMBER_PROPERTIES = { +VIDEO_RESOURCE_PROPERTIES = { "id", + "contentDetails", + "player", "snippet", + "statistics", + "status", + "topicDetails", + "recordingDetails", + "liveStreamingDetails", } -MEMBERSHIP_LEVEL_PROPERTIES = { - "id", - "snippet", -} +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, - "activities": ACTIVITIES_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 = { diff --git a/pyyoutube/utils/params_checker.py b/pyyoutube/utils/params_checker.py index 43d0fe08..250abb68 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 @@ -82,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] 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/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/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/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/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_secrets/client_secret_web.json b/testdata/apidata/client_secrets/client_secret_web.json new file mode 100644 index 00000000..38b1d8d3 --- /dev/null +++ b/testdata/apidata/client_secrets/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/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/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/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/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/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/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/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/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/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/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_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/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", 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/apis/test_channels.py b/tests/apis/test_channels.py index 351a4a98..942789c3 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_handle="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/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") 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/apis/test_search.py b/tests/apis/test_search.py index c822b0c5..1786bc8f 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) @@ -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", ) 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_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_captions.py b/tests/clients/test_captions.py new file mode 100644 index 00000000..bb5dab33 --- /dev/null +++ b/tests/clients/test_captions.py @@ -0,0 +1,113 @@ +""" + 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_channel_banners.py b/tests/clients/test_channel_banners.py new file mode 100644 index 00000000..295674d7 --- /dev/null +++ b/tests/clients/test_channel_banners.py @@ -0,0 +1,16 @@ +""" + 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 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" + ) diff --git a/tests/clients/test_channels.py b/tests/clients/test_channels.py new file mode 100644 index 00000000..ee9c5467 --- /dev/null +++ b/tests/clients/test_channels.py @@ -0,0 +1,89 @@ +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, 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("channels/info.json", helpers), + ) + + res = key_cli.channels.list( + parts="id,snippet", + channel_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_handle="@googledevelopers", + ) + assert res.items[0].snippet.customUrl == "@googledevelopers" + + 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("channels/info_multiple.json", helpers), + ) + + res = authed_cli.channels.list( + parts="id,snippet,statistics,contentDetails,brandingSettings", + channel_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/clients/test_client.py b/tests/clients/test_client.py new file mode 100644 index 00000000..6b31ef2d --- /dev/null +++ b/tests/clients/test_client.py @@ -0,0 +1,115 @@ +""" + 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_client_secret_web(self): + filename = "apidata/client_secrets/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_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") + 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(channel_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/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_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", + ) 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" diff --git a/tests/clients/test_media.py b/tests/clients/test_media.py new file mode 100644 index 00000000..283e820b --- /dev/null +++ b/tests/clients/test_media.py @@ -0,0 +1,133 @@ +""" + 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, + resource="videos", + 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, + resource="videos", + 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) 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 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 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") 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" + ) 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 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" + ) diff --git a/tests/clients/test_thumbnails.py b/tests/clients/test_thumbnails.py new file mode 100644 index 00000000..4826c1eb --- /dev/null +++ b/tests/clients/test_thumbnails.py @@ -0,0 +1,22 @@ +""" + 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 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..dfe89f3d --- /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" 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 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) 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") diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..31448974 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,32 @@ +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") + + +@pytest.fixture(scope="class") +def key_cli(): + return Client(api_key="api key") 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..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) @@ -73,7 +75,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) @@ -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", + ) 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")