# youtube/services.py # ... imports ... import json import logging import datetime import time from google.oauth2.credentials import Credentials from google_auth_oauthlib.flow import Flow from googleapiclient.discovery import build from googleapiclient.errors import HttpError from google.auth.transport.requests import Request # CHANGE: Import install/uninstall if we want to suppress warnings, but better to fix via pip import warnings from django.conf import settings from django.utils import timezone from apps.social.utils.youtube import YOUTUBE_SCOPES, YOUTUBE_API_SERVICE_NAME, YOUTUBE_API_VERSION logger = logging.getLogger(__name__) class YouTubeAPIError(Exception): pass class RateLimitError(Exception): pass class YouTubeService: # --- AUTH --- @staticmethod def _get_flow(): return Flow.from_client_secrets_file( settings.YOUTUBE_CLIENT_SECRETS_FILE, scopes=YOUTUBE_SCOPES, redirect_uri=settings.YOUTUBE_REDIRECT_URI ) @staticmethod def get_auth_url(state=None): flow = YouTubeService._get_flow() auth_url, generated_state = flow.authorization_url( access_type='offline', prompt='consent', state=state ) return auth_url, generated_state @staticmethod def exchange_code_for_token(code): flow = YouTubeService._get_flow() flow.fetch_token(code=code) creds = flow.credentials return json.loads(creds.to_json()) @staticmethod def _get_credentials(account): if not account.credentials_json: raise YouTubeAPIError("No token found.") # FIX 1: Suppress noisy library warnings if necessary with warnings.catch_warnings(): warnings.simplefilter("ignore") creds = Credentials.from_authorized_user_info(account.credentials_json) if not creds.valid: if creds.expired and creds.refresh_token: try: logger.info(f"Refreshing token for {account.name}...") # FIX 2: Ensure Request object is correct creds.refresh(Request()) # Save refreshed token account.credentials_json = json.loads(creds.to_json()) # Recalculate expiration account.expires_at = timezone.now() + datetime.timedelta(hours=1) # Conservative buffer account.save() except Exception as e: logger.error(f"Token refresh failed: {e}") account.is_active = False account.save() raise YouTubeAPIError("Token refresh failed") elif creds.expired and not creds.refresh_token: logger.error("Token expired and no refresh token found.") account.is_active = False account.save() raise YouTubeAPIError("No refresh token") return creds @staticmethod def _build_client(account): try: return build(YOUTUBE_API_SERVICE_NAME, YOUTUBE_API_VERSION, credentials=YouTubeService._get_credentials(account)) except Exception as e: logger.error("Build client failed", exc_info=True) raise YouTubeAPIError(str(e)) # --- FETCHING LOGIC --- @staticmethod def fetch_user_videos(account): """Fetch all videos for a user""" youtube = YouTubeService._build_client(account) videos = [] next_page_token = None # FIX 3: Check if playlist ID exists playlist_id = account.credentials_json.get('uploads_playlist_id') if not playlist_id: logger.error(f"No 'uploads_playlist_id' found in account credentials for {account.name}. Cannot sync videos.") return [] while True: try: request = youtube.playlistItems().list( part="snippet,contentDetails", playlistId=playlist_id, maxResults=50, pageToken=next_page_token ) response = request.execute() for item in response.get('items', []): videos.append({ 'id': item['contentDetails']['videoId'], 'snippet': { 'title': item['snippet']['title'], 'description': item['snippet'].get('description', ''), 'publishedAt': item['contentDetails']['videoPublishedAt'] } }) next_page_token = response.get('nextPageToken') if not next_page_token: break time.sleep(0.5) except HttpError as e: # FIX 4: LOG THE ERROR! Don't just return empty. logger.error(f"API Error fetching videos for {account.name}: {e.resp.status} {e.content}") if e.resp.status == 429: raise RateLimitError("Quota limit") if e.resp.status in [401, 403]: raise YouTubeAPIError(f"Authentication Failed: {e}") break return videos @staticmethod def fetch_activities_incremental(account): youtube = YouTubeService._build_client(account) published_after = (timezone.now() - datetime.timedelta(days=2)).isoformat() + 'Z' comment_ids = [] next_page_token = None try: while True: request = youtube.activities().list( part='snippet,contentDetails', mine=True, publishedAfter=published_after, maxResults=50, pageToken=next_page_token ) response = request.execute() for item in response.get('items', []): if item['snippet']['type'] == 'comment': c_id = item.get('contentDetails', {}).get('comment', {}).get('id') if c_id: comment_ids.append(c_id) next_page_token = response.get('nextPageToken') if not next_page_token: break time.sleep(0.5) except HttpError as e: if e.resp.status == 429: raise RateLimitError("Quota limit") return comment_ids @staticmethod def fetch_video_comments(account, video_id): """Fetch comments for a video""" youtube = YouTubeService._build_client(account) comments = [] next_page_token = None while True: try: request = youtube.commentThreads().list( part="snippet", videoId=video_id, maxResults=100, order="time", pageToken=next_page_token ) response = request.execute() comments.extend(response.get('items', [])) next_page_token = response.get('nextPageToken') if not next_page_token: break time.sleep(1) except HttpError as e: if e.resp.status == 429: raise RateLimitError("Quota limit") break return comments @staticmethod def post_reply(account, parent_id, text): youtube = YouTubeService._build_client(account) request = youtube.comments().insert( part="snippet", body={"snippet": {"parentId": parent_id, "textOriginal": text}} ) return request.execute()