- 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
165 lines
6.7 KiB
Python
165 lines
6.7 KiB
Python
from datetime import date, datetime
|
|
|
|
from sqlalchemy import (
|
|
BigInteger,
|
|
Boolean,
|
|
Date,
|
|
DateTime,
|
|
ForeignKey,
|
|
Integer,
|
|
JSON,
|
|
String,
|
|
Text,
|
|
UniqueConstraint,
|
|
func,
|
|
)
|
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
|
|
from app.db import Base
|
|
|
|
|
|
class User(Base):
|
|
__tablename__ = "users"
|
|
|
|
id: Mapped[int] = mapped_column(primary_key=True)
|
|
google_sub: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
|
email: Mapped[str] = mapped_column(String(320), unique=True, index=True)
|
|
display_name: Mapped[str | None] = mapped_column(String(255))
|
|
avatar_url: Mapped[str | None] = mapped_column(String(1024))
|
|
role: Mapped[str] = mapped_column(String(16), default="user", server_default="user")
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), server_default=func.now()
|
|
)
|
|
|
|
token: Mapped["OAuthToken | None"] = relationship(
|
|
back_populates="user", uselist=False, cascade="all, delete-orphan"
|
|
)
|
|
subscriptions: Mapped[list["Subscription"]] = relationship(
|
|
back_populates="user", cascade="all, delete-orphan"
|
|
)
|
|
|
|
|
|
class OAuthToken(Base):
|
|
__tablename__ = "oauth_tokens"
|
|
|
|
id: Mapped[int] = mapped_column(primary_key=True)
|
|
user_id: Mapped[int] = mapped_column(
|
|
ForeignKey("users.id", ondelete="CASCADE"), unique=True
|
|
)
|
|
# Refresh token encrypted at rest (Fernet). Access token is short-lived and low risk.
|
|
refresh_token_enc: Mapped[str | None] = mapped_column(String)
|
|
access_token: Mapped[str | None] = mapped_column(String)
|
|
expiry: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
|
scopes: Mapped[str | None] = mapped_column(String)
|
|
updated_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
|
)
|
|
|
|
user: Mapped["User"] = relationship(back_populates="token")
|
|
|
|
|
|
class Channel(Base):
|
|
"""A YouTube channel. Shared across all users (one channel's videos are the same
|
|
for everyone), so its expensive metadata is fetched and stored only once."""
|
|
|
|
__tablename__ = "channels"
|
|
|
|
id: Mapped[str] = mapped_column(String(32), primary_key=True) # "UC..." channel id
|
|
title: Mapped[str | None] = mapped_column(String(255))
|
|
handle: Mapped[str | None] = mapped_column(String(255))
|
|
description: Mapped[str | None] = mapped_column(Text)
|
|
thumbnail_url: Mapped[str | None] = mapped_column(String(1024))
|
|
uploads_playlist_id: Mapped[str | None] = mapped_column(String(64))
|
|
subscriber_count: Mapped[int | None] = mapped_column(BigInteger)
|
|
video_count: Mapped[int | None] = mapped_column(BigInteger)
|
|
topic_categories: Mapped[list | None] = mapped_column(JSON)
|
|
default_language: Mapped[str | None] = mapped_column(String(16))
|
|
country: Mapped[str | None] = mapped_column(String(8))
|
|
|
|
# Sync bookkeeping.
|
|
details_synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
|
recent_synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
|
last_rss_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
|
backfill_cursor: Mapped[str | None] = mapped_column(String(128))
|
|
backfill_done: Mapped[bool] = mapped_column(
|
|
Boolean, default=False, server_default="false"
|
|
)
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), server_default=func.now()
|
|
)
|
|
|
|
videos: Mapped[list["Video"]] = relationship(back_populates="channel")
|
|
subscriptions: Mapped[list["Subscription"]] = relationship(back_populates="channel")
|
|
|
|
|
|
class Subscription(Base):
|
|
"""Per-user link to a channel, with the user's own priority/visibility overrides."""
|
|
|
|
__tablename__ = "subscriptions"
|
|
__table_args__ = (UniqueConstraint("user_id", "channel_id", name="uq_user_channel"),)
|
|
|
|
id: Mapped[int] = mapped_column(primary_key=True)
|
|
user_id: Mapped[int] = mapped_column(
|
|
ForeignKey("users.id", ondelete="CASCADE"), index=True
|
|
)
|
|
channel_id: Mapped[str] = mapped_column(
|
|
ForeignKey("channels.id", ondelete="CASCADE"), index=True
|
|
)
|
|
# YouTube's subscription resource id, needed to unsubscribe via the API.
|
|
yt_subscription_id: Mapped[str | None] = mapped_column(String(128))
|
|
priority: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
|
hidden: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
|
subscribed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), server_default=func.now()
|
|
)
|
|
|
|
user: Mapped["User"] = relationship(back_populates="subscriptions")
|
|
channel: Mapped["Channel"] = relationship(back_populates="subscriptions")
|
|
|
|
|
|
class Video(Base):
|
|
"""A YouTube video. Shared across users; per-user state lives elsewhere."""
|
|
|
|
__tablename__ = "videos"
|
|
|
|
id: Mapped[str] = mapped_column(String(16), primary_key=True) # video id
|
|
channel_id: Mapped[str] = mapped_column(
|
|
ForeignKey("channels.id", ondelete="CASCADE"), index=True
|
|
)
|
|
title: Mapped[str | None] = mapped_column(Text)
|
|
description: Mapped[str | None] = mapped_column(Text)
|
|
published_at: Mapped[datetime | None] = mapped_column(
|
|
DateTime(timezone=True), index=True
|
|
)
|
|
thumbnail_url: Mapped[str | None] = mapped_column(String(1024))
|
|
duration_seconds: Mapped[int | None] = mapped_column(Integer)
|
|
view_count: Mapped[int | None] = mapped_column(BigInteger)
|
|
like_count: Mapped[int | None] = mapped_column(BigInteger)
|
|
category_id: Mapped[int | None] = mapped_column(Integer)
|
|
topic_categories: Mapped[list | None] = mapped_column(JSON)
|
|
default_language: Mapped[str | None] = mapped_column(String(16))
|
|
detected_language: Mapped[str | None] = mapped_column(String(16))
|
|
is_short: Mapped[bool] = mapped_column(
|
|
Boolean, default=False, server_default="false", index=True
|
|
)
|
|
# none | live | upcoming | premiere | was_live
|
|
live_status: Mapped[str] = mapped_column(
|
|
String(16), default="none", server_default="none", index=True
|
|
)
|
|
enriched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), server_default=func.now()
|
|
)
|
|
|
|
channel: Mapped["Channel"] = relationship(back_populates="videos")
|
|
|
|
|
|
class ApiQuotaUsage(Base):
|
|
"""Tracks YouTube Data API units spent per Pacific-time day (the quota resets at
|
|
midnight Pacific). The whole app shares one daily budget."""
|
|
|
|
__tablename__ = "api_quota_usage"
|
|
|
|
day: Mapped[date] = mapped_column(Date, primary_key=True)
|
|
units_used: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|