2026-06-11 01:22:07 +02:00
|
|
|
"""Import the authenticated user's YouTube subscriptions and channel metadata."""
|
2026-06-11 04:26:18 +02:00
|
|
|
import logging
|
2026-06-11 01:22:07 +02:00
|
|
|
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
|
|
|
|
|
|
2026-06-11 04:26:18 +02:00
|
|
|
log = logging.getLogger("subfeed.sync")
|
|
|
|
|
|
2026-06-11 01:22:07 +02:00
|
|
|
|
|
|
|
|
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()
|
|
|
|
|
|
2026-06-11 04:26:18 +02:00
|
|
|
result = {
|
2026-06-11 01:22:07 +02:00
|
|
|
"subscriptions": len(fetched),
|
|
|
|
|
"channels_new": new_channels,
|
|
|
|
|
"channels_detailed": detailed,
|
|
|
|
|
"removed_stale": removed,
|
|
|
|
|
}
|
2026-06-11 04:26:18 +02:00
|
|
|
log.info("Subscription import (user %s): %s", user.id, result)
|
|
|
|
|
return result
|