feat: M2 (part 1) — subscription import, YouTube client, quota guard
- Channel/Subscription/Video/ApiQuotaUsage models + migration 0002 - Synchronous YouTube Data API client with OAuth token refresh and per-call quota accounting (API key preferred for public reads when configured) - Subscription import: upsert channels + subscription links, prune unsubscribed, fetch channel details (uploads playlist, topics, stats, handle, country) - Central quota guard tracking units per US-Pacific day against a daily budget - POST /api/sync/subscriptions and GET /api/sync/status endpoints
This commit is contained in:
parent
3a774cf7d6
commit
24b6e0026d
10 changed files with 603 additions and 3 deletions
115
backend/app/sync/subscriptions.py
Normal file
115
backend/app/sync/subscriptions.py
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
"""Import the authenticated user's YouTube subscriptions and channel metadata."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import Channel, Subscription, User
|
||||
from app.youtube.client import YouTubeClient, best_thumbnail
|
||||
|
||||
|
||||
def _to_int(value) -> int | None:
|
||||
try:
|
||||
return int(value) if value is not None else None
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def apply_channel_details(db: Session, items: list[dict]) -> None:
|
||||
now = datetime.now(timezone.utc)
|
||||
for item in items:
|
||||
channel = db.get(Channel, item["id"])
|
||||
if channel is None:
|
||||
continue
|
||||
snippet = item.get("snippet", {})
|
||||
content = item.get("contentDetails", {})
|
||||
stats = item.get("statistics", {})
|
||||
topics = item.get("topicDetails", {})
|
||||
|
||||
channel.title = snippet.get("title") or channel.title
|
||||
channel.handle = snippet.get("customUrl")
|
||||
channel.description = snippet.get("description")
|
||||
channel.thumbnail_url = (
|
||||
best_thumbnail(snippet.get("thumbnails")) or channel.thumbnail_url
|
||||
)
|
||||
channel.default_language = snippet.get("defaultLanguage")
|
||||
channel.country = snippet.get("country")
|
||||
channel.uploads_playlist_id = content.get("relatedPlaylists", {}).get("uploads")
|
||||
channel.subscriber_count = _to_int(stats.get("subscriberCount"))
|
||||
channel.video_count = _to_int(stats.get("videoCount"))
|
||||
channel.topic_categories = topics.get("topicCategories")
|
||||
channel.details_synced_at = now
|
||||
|
||||
|
||||
def import_subscriptions(db: Session, user: User) -> dict:
|
||||
"""Pull all subscriptions for `user`, upsert channels + subscription links, prune
|
||||
channels the user has unsubscribed from on YouTube, and fetch channel details for
|
||||
any channel we don't have details for yet."""
|
||||
fetched: dict[str, dict] = {}
|
||||
new_channels = 0
|
||||
|
||||
with YouTubeClient(db, user) as yt:
|
||||
for sub in yt.iter_subscriptions():
|
||||
cid = sub.get("channel_id")
|
||||
if cid:
|
||||
fetched[cid] = sub
|
||||
|
||||
for cid, sub in fetched.items():
|
||||
channel = db.get(Channel, cid)
|
||||
if channel is None:
|
||||
channel = Channel(
|
||||
id=cid,
|
||||
title=sub.get("title"),
|
||||
description=sub.get("description"),
|
||||
thumbnail_url=sub.get("thumbnail_url"),
|
||||
)
|
||||
db.add(channel)
|
||||
new_channels += 1
|
||||
sub_row = db.execute(
|
||||
select(Subscription).where(
|
||||
Subscription.user_id == user.id, Subscription.channel_id == cid
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if sub_row is None:
|
||||
sub_row = Subscription(user_id=user.id, channel_id=cid)
|
||||
db.add(sub_row)
|
||||
sub_row.yt_subscription_id = sub.get("yt_subscription_id")
|
||||
db.flush()
|
||||
|
||||
# Prune subscriptions removed on YouTube.
|
||||
removed = 0
|
||||
existing = (
|
||||
db.execute(select(Subscription).where(Subscription.user_id == user.id))
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
for sub_row in existing:
|
||||
if sub_row.channel_id not in fetched:
|
||||
db.delete(sub_row)
|
||||
removed += 1
|
||||
db.commit()
|
||||
|
||||
# Fetch details for channels that don't have them yet.
|
||||
need_details = (
|
||||
db.execute(
|
||||
select(Channel).where(
|
||||
Channel.id.in_(fetched.keys()),
|
||||
Channel.details_synced_at.is_(None),
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
detailed = 0
|
||||
if need_details:
|
||||
items = yt.get_channels([c.id for c in need_details])
|
||||
apply_channel_details(db, items)
|
||||
detailed = len(items)
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"subscriptions": len(fetched),
|
||||
"channels_new": new_channels,
|
||||
"channels_detailed": detailed,
|
||||
"removed_stale": removed,
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue