HH/apps/social/services/youtube.py
2026-02-12 15:09:48 +03:00

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()