211 lines
7.7 KiB
Python
211 lines
7.7 KiB
Python
# 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() |