"""YouTube Data API v3 client. Thin async wrapper using google-api-python-client. Mirrors Sony.Filtr.YouTube.Api.YouTubeApi / AuthenticatedYoutubeApi from the .NET codebase. Authentication: service accounts store OAuth2 credentials (access_token + refresh_token) in tblServiceAccount. Build a google.oauth2.credentials. Credentials object from them. """ import logging from typing import Any, Optional from google.auth.transport.requests import Request from google.oauth2.credentials import Credentials from googleapiclient.discovery import build from googleapiclient.errors import HttpError from playlist_sync.config import settings logger = logging.getLogger("playlist_sync.youtube_client") _YOUTUBE_SCOPES = ["https://www.googleapis.com/auth/youtube"] def build_credentials( access_token: str, refresh_token: Optional[str] = None ) -> Credentials: """Build a Google OAuth2 Credentials object from stored tokens.""" creds = Credentials( token=access_token, refresh_token=refresh_token, token_uri="https://oauth2.googleapis.com/token", # Client ID/secret are not required when using refresh_token directly, # but needed to refresh. Use a server-side client registered in GCP. client_id=settings.YOUTUBE_CLIENT_ID, client_secret=settings.YOUTUBE_CLIENT_SECRET, scopes=_YOUTUBE_SCOPES, ) return creds def _build_youtube(credentials: Credentials) -> Any: """Build a YouTube API service client.""" if credentials.expired and credentials.refresh_token: credentials.refresh(Request()) return build("youtube", "v3", credentials=credentials) def get_all_playlist_items( playlist_id: str, credentials: Credentials ) -> Optional[list[dict]]: """Fetch all items from a YouTube playlist (handles pagination). Returns None if an HTTP error occurs while fetching items. Mirrors: AuthenticatedYoutubeApi.GetAllPlaylistItemsAsync() """ youtube = _build_youtube(credentials) items = [] page_token = None while True: try: request = youtube.playlistItems().list( part="snippet,contentDetails", playlistId=playlist_id, maxResults=50, pageToken=page_token, ) response = request.execute() except HttpError as exc: logger.error( "Failed to fetch YouTube playlist items for %s: %s", playlist_id, exc ) return None items.extend(response.get("items", [])) page_token = response.get("nextPageToken") if not page_token: break return items def search_videos( query: str, credentials: Credentials, max_results: int = 10 ) -> list[dict]: """Search YouTube for videos matching a query. Mirrors: AuthenticatedYoutubeApi.SearchAsync() / video search logic in YoutubeSynchronizer. """ youtube = _build_youtube(credentials) try: response = ( youtube.search() .list( part="snippet", q=query, type="video", maxResults=max_results, videoCategoryId="10", # Music category ) .execute() ) return response.get("items", []) # type: ignore[no-any-return] except HttpError as exc: logger.warning("YouTube search failed for query '%s': %s", query, exc) return [] def add_video_to_playlist( playlist_id: str, video_id: str, position: int, credentials: Credentials, ) -> Optional[dict]: """Add a video to a YouTube playlist at a specific position. Mirrors: AuthenticatedYoutubeApi.AddVideoToPlaylistAsync() """ youtube = _build_youtube(credentials) try: result: dict[Any, Any] = ( youtube.playlistItems() .insert( part="snippet", body={ "snippet": { "playlistId": playlist_id, "position": position, "resourceId": {"kind": "youtube#video", "videoId": video_id}, } }, ) .execute() ) return result except HttpError as exc: logger.error( "Failed to add video %s to playlist %s: %s", video_id, playlist_id, exc ) raise def delete_playlist_items(item_ids: list[str], credentials: Credentials) -> None: """Delete playlist items by their item IDs. Mirrors: AuthenticatedYoutubeApi.DeletePlaylistItemsAsync() """ youtube = _build_youtube(credentials) for item_id in item_ids: try: youtube.playlistItems().delete(id=item_id).execute() except HttpError as exc: logger.error("Failed to delete YouTube playlist item %s: %s", item_id, exc) raise def update_playlist_item(item: dict, credentials: Credentials) -> Optional[dict]: """Update a playlist item (used for reordering by setting snippet.position). Mirrors: AuthenticatedYoutubeApi.UpdatePlaylistItemAsync() """ youtube = _build_youtube(credentials) try: return youtube.playlistItems().update(part="snippet", body=item).execute() # type: ignore[no-any-return] except HttpError as exc: logger.error("Failed to update YouTube playlist item: %s", exc) raise def get_playlist_info(playlist_id: str, credentials: Credentials) -> Optional[dict]: """Fetch metadata (title, thumbnail) for a YouTube playlist. Returns the playlist snippet dict or None if not found / on error. """ youtube = _build_youtube(credentials) try: response = youtube.playlists().list(part="snippet", id=playlist_id).execute() items = response.get("items", []) return items[0].get("snippet") if items else None except HttpError as exc: logger.error( "Failed to fetch YouTube playlist info for %s: %s", playlist_id, exc ) return None