merge: M2 ingest core
This commit is contained in:
commit
64b18b3cc1
16 changed files with 1171 additions and 6 deletions
|
|
@ -9,8 +9,12 @@ Each user signs in with their own Google account (invite-only) and sees only the
|
|||
subscriptions. All the expensive data (channels, videos, metadata) is fetched from YouTube
|
||||
once and stored locally, so filtering/searching/sorting are instant and don't burn API quota.
|
||||
|
||||
> Status: early development. Milestone **M1** (foundation) is in place: docker-compose stack,
|
||||
> FastAPI backend, Google OAuth login with an email invite-list, and encrypted token storage.
|
||||
> Status: early development.
|
||||
> - **M1** (foundation): docker-compose stack, FastAPI backend, Google OAuth login with an
|
||||
> email invite-list, encrypted token storage.
|
||||
> - **M2** (ingest core): subscription import, free RSS detection, recent-first + deep backfill
|
||||
> from the uploads playlist, enrichment (duration/stats/category, Shorts & livestream
|
||||
> classification), a shared daily quota guard, and a background scheduler.
|
||||
|
||||
## Requirements
|
||||
|
||||
|
|
|
|||
127
backend/alembic/versions/0002_ingest_core.py
Normal file
127
backend/alembic/versions/0002_ingest_core.py
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
"""ingest core: channels, subscriptions, videos, api_quota_usage
|
||||
|
||||
Revision ID: 0002_ingest_core
|
||||
Revises: 0001_initial
|
||||
Create Date: 2026-06-11
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = "0002_ingest_core"
|
||||
down_revision: Union[str, None] = "0001_initial"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"channels",
|
||||
sa.Column("id", sa.String(length=32), primary_key=True),
|
||||
sa.Column("title", sa.String(length=255), nullable=True),
|
||||
sa.Column("handle", sa.String(length=255), nullable=True),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("thumbnail_url", sa.String(length=1024), nullable=True),
|
||||
sa.Column("uploads_playlist_id", sa.String(length=64), nullable=True),
|
||||
sa.Column("subscriber_count", sa.BigInteger(), nullable=True),
|
||||
sa.Column("video_count", sa.BigInteger(), nullable=True),
|
||||
sa.Column("topic_categories", sa.JSON(), nullable=True),
|
||||
sa.Column("default_language", sa.String(length=16), nullable=True),
|
||||
sa.Column("country", sa.String(length=8), nullable=True),
|
||||
sa.Column("details_synced_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("recent_synced_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("last_rss_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("backfill_cursor", sa.String(length=128), nullable=True),
|
||||
sa.Column("backfill_done", sa.Boolean(), nullable=False, server_default="false"),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"subscriptions",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column(
|
||||
"user_id",
|
||||
sa.Integer(),
|
||||
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"channel_id",
|
||||
sa.String(length=32),
|
||||
sa.ForeignKey("channels.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("yt_subscription_id", sa.String(length=128), nullable=True),
|
||||
sa.Column("priority", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("hidden", sa.Boolean(), nullable=False, server_default="false"),
|
||||
sa.Column("subscribed_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
sa.UniqueConstraint("user_id", "channel_id", name="uq_user_channel"),
|
||||
)
|
||||
op.create_index("ix_subscriptions_user_id", "subscriptions", ["user_id"])
|
||||
op.create_index("ix_subscriptions_channel_id", "subscriptions", ["channel_id"])
|
||||
|
||||
op.create_table(
|
||||
"videos",
|
||||
sa.Column("id", sa.String(length=16), primary_key=True),
|
||||
sa.Column(
|
||||
"channel_id",
|
||||
sa.String(length=32),
|
||||
sa.ForeignKey("channels.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("title", sa.Text(), nullable=True),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("published_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("thumbnail_url", sa.String(length=1024), nullable=True),
|
||||
sa.Column("duration_seconds", sa.Integer(), nullable=True),
|
||||
sa.Column("view_count", sa.BigInteger(), nullable=True),
|
||||
sa.Column("like_count", sa.BigInteger(), nullable=True),
|
||||
sa.Column("category_id", sa.Integer(), nullable=True),
|
||||
sa.Column("topic_categories", sa.JSON(), nullable=True),
|
||||
sa.Column("default_language", sa.String(length=16), nullable=True),
|
||||
sa.Column("detected_language", sa.String(length=16), nullable=True),
|
||||
sa.Column("is_short", sa.Boolean(), nullable=False, server_default="false"),
|
||||
sa.Column("live_status", sa.String(length=16), nullable=False, server_default="none"),
|
||||
sa.Column("enriched_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
)
|
||||
op.create_index("ix_videos_channel_id", "videos", ["channel_id"])
|
||||
op.create_index("ix_videos_published_at", "videos", ["published_at"])
|
||||
op.create_index("ix_videos_is_short", "videos", ["is_short"])
|
||||
op.create_index("ix_videos_live_status", "videos", ["live_status"])
|
||||
|
||||
op.create_table(
|
||||
"api_quota_usage",
|
||||
sa.Column("day", sa.Date(), primary_key=True),
|
||||
sa.Column("units_used", sa.Integer(), nullable=False, server_default="0"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("api_quota_usage")
|
||||
op.drop_index("ix_videos_live_status", table_name="videos")
|
||||
op.drop_index("ix_videos_is_short", table_name="videos")
|
||||
op.drop_index("ix_videos_published_at", table_name="videos")
|
||||
op.drop_index("ix_videos_channel_id", table_name="videos")
|
||||
op.drop_table("videos")
|
||||
op.drop_index("ix_subscriptions_channel_id", table_name="subscriptions")
|
||||
op.drop_index("ix_subscriptions_user_id", table_name="subscriptions")
|
||||
op.drop_table("subscriptions")
|
||||
op.drop_table("channels")
|
||||
|
|
@ -24,6 +24,30 @@ class Settings(BaseSettings):
|
|||
# Origin of a separately served frontend dev server (enables CORS). Empty in production.
|
||||
frontend_origin: str = ""
|
||||
|
||||
# --- Sync / YouTube Data API ---
|
||||
# Optional API key for public reads (channels/videos/playlistItems). When set it is
|
||||
# preferred for shared backfill/enrichment so it doesn't depend on a specific user.
|
||||
youtube_api_key: str = ""
|
||||
# Daily quota budget (units). Default leaves headroom under the 10,000/day free limit.
|
||||
quota_daily_budget: int = 9000
|
||||
# Recent-first backfill: how far back to fetch on the first pass per channel.
|
||||
backfill_recent_max_videos: int = 100
|
||||
backfill_recent_max_days: int = 365
|
||||
|
||||
# Videos at or below this duration (seconds) are treated as Shorts.
|
||||
shorts_max_seconds: int = 60
|
||||
# videos.list accepts up to 50 ids per call.
|
||||
enrich_batch_size: int = 50
|
||||
|
||||
# --- Background scheduler ---
|
||||
scheduler_enabled: bool = True
|
||||
rss_poll_minutes: int = 20
|
||||
enrich_interval_minutes: int = 3
|
||||
backfill_interval_minutes: int = 10
|
||||
# Keep this many quota units in reserve so scheduled backfill never starves
|
||||
# interactive syncs / enrichment of fresh videos.
|
||||
backfill_quota_reserve: int = 2000
|
||||
|
||||
@property
|
||||
def allowed_email_set(self) -> set[str]:
|
||||
return {e.strip().lower() for e in self.allowed_emails.split(",") if e.strip()}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
|
@ -8,9 +9,20 @@ from starlette.middleware.sessions import SessionMiddleware
|
|||
|
||||
from app import auth
|
||||
from app.config import settings
|
||||
from app.routes import health
|
||||
from app.routes import health, sync
|
||||
from app.scheduler import shutdown_scheduler, start_scheduler
|
||||
|
||||
app = FastAPI(title=settings.app_name)
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
start_scheduler()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
shutdown_scheduler()
|
||||
|
||||
|
||||
app = FastAPI(title=settings.app_name, lifespan=lifespan)
|
||||
|
||||
app.add_middleware(
|
||||
SessionMiddleware,
|
||||
|
|
@ -30,6 +42,7 @@ if settings.frontend_origin:
|
|||
|
||||
app.include_router(health.router)
|
||||
app.include_router(auth.router)
|
||||
app.include_router(sync.router)
|
||||
|
||||
STATIC_DIR = Path(__file__).parent / "static"
|
||||
app.mount("/assets", StaticFiles(directory=STATIC_DIR / "assets"), name="assets")
|
||||
|
|
|
|||
|
|
@ -1,6 +1,18 @@
|
|||
from datetime import datetime
|
||||
from datetime import date, datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, String, func
|
||||
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
|
||||
|
|
@ -22,6 +34,9 @@ class User(Base):
|
|||
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):
|
||||
|
|
@ -41,3 +56,110 @@ class OAuthToken(Base):
|
|||
)
|
||||
|
||||
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")
|
||||
|
|
|
|||
50
backend/app/quota.py
Normal file
50
backend/app/quota.py
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
"""Central YouTube Data API quota guard.
|
||||
|
||||
The whole app shares one daily budget (the API resets at midnight US Pacific time).
|
||||
Steady-state work (RSS detection is free; enrichment of new videos is cheap) should
|
||||
always be allowed; expensive backfill must yield to the remaining budget.
|
||||
"""
|
||||
from datetime import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import settings
|
||||
from app.models import ApiQuotaUsage
|
||||
|
||||
_PACIFIC = ZoneInfo("America/Los_Angeles")
|
||||
|
||||
|
||||
def pacific_today():
|
||||
return datetime.now(_PACIFIC).date()
|
||||
|
||||
|
||||
def units_used_today(db: Session) -> int:
|
||||
row = db.get(ApiQuotaUsage, pacific_today())
|
||||
return row.units_used if row else 0
|
||||
|
||||
|
||||
def remaining_today(db: Session) -> int:
|
||||
return max(0, settings.quota_daily_budget - units_used_today(db))
|
||||
|
||||
|
||||
def can_spend(db: Session, units: int) -> bool:
|
||||
return remaining_today(db) >= units
|
||||
|
||||
|
||||
def record_usage(db: Session, units: int) -> None:
|
||||
"""Atomically add `units` to today's counter (upsert)."""
|
||||
if units <= 0:
|
||||
return
|
||||
day = pacific_today()
|
||||
stmt = (
|
||||
pg_insert(ApiQuotaUsage)
|
||||
.values(day=day, units_used=units)
|
||||
.on_conflict_do_update(
|
||||
index_elements=[ApiQuotaUsage.day],
|
||||
set_={"units_used": ApiQuotaUsage.units_used + units},
|
||||
)
|
||||
)
|
||||
db.execute(stmt)
|
||||
db.commit()
|
||||
85
backend/app/routes/sync.py
Normal file
85
backend/app/routes/sync.py
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import quota
|
||||
from app.auth import current_user
|
||||
from app.db import get_db
|
||||
from app.models import Channel, Subscription, User, Video
|
||||
from app.sync.runner import run_enrich, run_recent_backfill, run_rss_poll
|
||||
from app.sync.subscriptions import import_subscriptions
|
||||
|
||||
router = APIRouter(prefix="/api/sync", tags=["sync"])
|
||||
|
||||
|
||||
def _user_channels(db: Session, user: User) -> list[Channel]:
|
||||
return (
|
||||
db.execute(
|
||||
select(Channel)
|
||||
.join(Subscription, Subscription.channel_id == Channel.id)
|
||||
.where(Subscription.user_id == user.id)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
@router.post("/subscriptions")
|
||||
def sync_subscriptions(
|
||||
user: User = Depends(current_user), db: Session = Depends(get_db)
|
||||
) -> dict:
|
||||
before = quota.units_used_today(db)
|
||||
result = import_subscriptions(db, user)
|
||||
result["quota_used_estimate"] = quota.units_used_today(db) - before
|
||||
result["quota_remaining_today"] = quota.remaining_today(db)
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/rss")
|
||||
def sync_rss(
|
||||
user: User = Depends(current_user), db: Session = Depends(get_db)
|
||||
) -> dict:
|
||||
new = run_rss_poll(db, _user_channels(db, user))
|
||||
return {"new_videos": new}
|
||||
|
||||
|
||||
@router.post("/backfill")
|
||||
def sync_backfill(
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
max_channels: int = 25,
|
||||
) -> dict:
|
||||
before = quota.units_used_today(db)
|
||||
result = run_recent_backfill(db, _user_channels(db, user), max_channels=max_channels)
|
||||
result["quota_used_estimate"] = quota.units_used_today(db) - before
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/enrich")
|
||||
def sync_enrich(
|
||||
user: User = Depends(current_user), db: Session = Depends(get_db)
|
||||
) -> dict:
|
||||
before = quota.units_used_today(db)
|
||||
enriched = run_enrich(db)
|
||||
return {
|
||||
"enriched": enriched,
|
||||
"quota_used_estimate": quota.units_used_today(db) - before,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
def sync_status(
|
||||
user: User = Depends(current_user), db: Session = Depends(get_db)
|
||||
) -> dict:
|
||||
sub_count = db.scalar(
|
||||
select(func.count())
|
||||
.select_from(Subscription)
|
||||
.where(Subscription.user_id == user.id)
|
||||
)
|
||||
return {
|
||||
"subscriptions": sub_count,
|
||||
"channels_total": db.scalar(select(func.count()).select_from(Channel)),
|
||||
"videos_total": db.scalar(select(func.count()).select_from(Video)),
|
||||
"quota_used_today": quota.units_used_today(db),
|
||||
"quota_remaining_today": quota.remaining_today(db),
|
||||
}
|
||||
77
backend/app/scheduler.py
Normal file
77
backend/app/scheduler.py
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
"""Background scheduler: free RSS detection, enrichment of new videos, and quota-aware
|
||||
recent-first / deep backfill. Runs inside the API process (single worker)."""
|
||||
import logging
|
||||
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
|
||||
from app.config import settings
|
||||
from app.db import SessionLocal
|
||||
from app.sync.runner import (
|
||||
run_deep_backfill,
|
||||
run_enrich,
|
||||
run_recent_backfill,
|
||||
run_rss_poll,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("subfeed.scheduler")
|
||||
|
||||
_scheduler: BackgroundScheduler | None = None
|
||||
|
||||
|
||||
def _job(name: str, fn) -> None:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
result = fn(db)
|
||||
logger.info("job %s -> %s", name, result)
|
||||
except Exception:
|
||||
db.rollback()
|
||||
logger.exception("job %s failed", name)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _rss_job() -> None:
|
||||
_job("rss_poll", lambda db: run_rss_poll(db))
|
||||
|
||||
|
||||
def _enrich_job() -> None:
|
||||
_job("enrich", lambda db: run_enrich(db))
|
||||
|
||||
|
||||
def _backfill_job() -> None:
|
||||
# Recent-first for not-yet-synced channels, then deep backfill for the rest.
|
||||
def work(db):
|
||||
recent = run_recent_backfill(db, max_channels=25)
|
||||
deep = run_deep_backfill(db, max_channels=10)
|
||||
return {"recent": recent, "deep": deep}
|
||||
|
||||
_job("backfill", work)
|
||||
|
||||
|
||||
def start_scheduler() -> None:
|
||||
global _scheduler
|
||||
if not settings.scheduler_enabled or _scheduler is not None:
|
||||
return
|
||||
scheduler = BackgroundScheduler(timezone="UTC")
|
||||
scheduler.add_job(
|
||||
_rss_job, "interval", minutes=settings.rss_poll_minutes, id="rss_poll"
|
||||
)
|
||||
scheduler.add_job(
|
||||
_enrich_job, "interval", minutes=settings.enrich_interval_minutes, id="enrich"
|
||||
)
|
||||
scheduler.add_job(
|
||||
_backfill_job,
|
||||
"interval",
|
||||
minutes=settings.backfill_interval_minutes,
|
||||
id="backfill",
|
||||
)
|
||||
scheduler.start()
|
||||
_scheduler = scheduler
|
||||
logger.info("scheduler started")
|
||||
|
||||
|
||||
def shutdown_scheduler() -> None:
|
||||
global _scheduler
|
||||
if _scheduler is not None:
|
||||
_scheduler.shutdown(wait=False)
|
||||
_scheduler = None
|
||||
0
backend/app/sync/__init__.py
Normal file
0
backend/app/sync/__init__.py
Normal file
116
backend/app/sync/runner.py
Normal file
116
backend/app/sync/runner.py
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
"""Reusable sync routines shared by the scheduler and the manual /api/sync endpoints.
|
||||
|
||||
Channel/video data is shared across users, so background work acts through a "service
|
||||
user" (any user with a stored refresh token) unless a YOUTUBE_API_KEY is configured for
|
||||
public reads.
|
||||
"""
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import quota
|
||||
from app.config import settings
|
||||
from app.models import Channel, OAuthToken, User
|
||||
from app.sync.videos import (
|
||||
backfill_channel_deep,
|
||||
backfill_channel_recent,
|
||||
enrich_pending,
|
||||
poll_rss_channel,
|
||||
)
|
||||
from app.youtube.client import YouTubeClient
|
||||
|
||||
|
||||
def get_service_user(db: Session) -> User | None:
|
||||
return (
|
||||
db.execute(
|
||||
select(User)
|
||||
.join(OAuthToken)
|
||||
.where(OAuthToken.refresh_token_enc.is_not(None))
|
||||
.order_by(User.id)
|
||||
)
|
||||
.scalars()
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def run_rss_poll(db: Session, channels: list[Channel] | None = None) -> int:
|
||||
if channels is None:
|
||||
channels = db.execute(select(Channel)).scalars().all()
|
||||
new = 0
|
||||
for channel in channels:
|
||||
try:
|
||||
new += poll_rss_channel(db, channel)
|
||||
except Exception:
|
||||
db.rollback()
|
||||
return new
|
||||
|
||||
|
||||
def run_enrich(db: Session, max_batches: int = 200, floor: int = 200) -> int:
|
||||
user = get_service_user(db)
|
||||
if user is None:
|
||||
return 0
|
||||
total = 0
|
||||
with YouTubeClient(db, user) as yt:
|
||||
for _ in range(max_batches):
|
||||
if quota.remaining_today(db) <= floor:
|
||||
break
|
||||
n = enrich_pending(db, yt)
|
||||
total += n
|
||||
if n == 0:
|
||||
break
|
||||
return total
|
||||
|
||||
|
||||
def run_recent_backfill(
|
||||
db: Session, channels: list[Channel] | None = None, max_channels: int | None = None
|
||||
) -> dict:
|
||||
user = get_service_user(db)
|
||||
if user is None:
|
||||
return {"channels_processed": 0, "videos_added": 0, "reason": "no service user"}
|
||||
if channels is None:
|
||||
channels = (
|
||||
db.execute(select(Channel).where(Channel.recent_synced_at.is_(None)))
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
if max_channels:
|
||||
channels = channels[:max_channels]
|
||||
processed = 0
|
||||
videos_added = 0
|
||||
with YouTubeClient(db, user) as yt:
|
||||
for channel in channels:
|
||||
if quota.remaining_today(db) <= settings.backfill_quota_reserve:
|
||||
break
|
||||
try:
|
||||
videos_added += backfill_channel_recent(db, yt, channel)
|
||||
processed += 1
|
||||
except Exception:
|
||||
db.rollback()
|
||||
return {"channels_processed": processed, "videos_added": videos_added}
|
||||
|
||||
|
||||
def run_deep_backfill(db: Session, max_channels: int = 10, max_pages: int = 5) -> dict:
|
||||
user = get_service_user(db)
|
||||
if user is None:
|
||||
return {"channels_processed": 0, "videos_added": 0, "reason": "no service user"}
|
||||
channels = (
|
||||
db.execute(
|
||||
select(Channel).where(
|
||||
Channel.backfill_done.is_(False),
|
||||
Channel.recent_synced_at.is_not(None),
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)[:max_channels]
|
||||
processed = 0
|
||||
videos_added = 0
|
||||
with YouTubeClient(db, user) as yt:
|
||||
for channel in channels:
|
||||
if quota.remaining_today(db) <= settings.backfill_quota_reserve:
|
||||
break
|
||||
try:
|
||||
videos_added += backfill_channel_deep(db, yt, channel, max_pages)
|
||||
processed += 1
|
||||
except Exception:
|
||||
db.rollback()
|
||||
return {"channels_processed": processed, "videos_added": videos_added}
|
||||
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,
|
||||
}
|
||||
227
backend/app/sync/videos.py
Normal file
227
backend/app/sync/videos.py
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
"""Video ingestion: free RSS detection, recent-first (and deep) backfill from the
|
||||
uploads playlist, and enrichment via videos.list (duration, stats, category, Shorts
|
||||
and livestream classification)."""
|
||||
import re
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import settings
|
||||
from app.models import Channel, Video
|
||||
from app.youtube.client import YouTubeClient, best_thumbnail
|
||||
from app.youtube.rss import fetch_channel_feed
|
||||
|
||||
_DURATION_RE = re.compile(
|
||||
r"P(?:(\d+)D)?T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?", re.IGNORECASE
|
||||
)
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def parse_iso8601_duration(value: str | None) -> int | None:
|
||||
if not value:
|
||||
return None
|
||||
match = _DURATION_RE.fullmatch(value)
|
||||
if not match:
|
||||
return None
|
||||
days, hours, minutes, seconds = (int(g) if g else 0 for g in match.groups())
|
||||
return days * 86400 + hours * 3600 + minutes * 60 + seconds
|
||||
|
||||
|
||||
def parse_dt(value: str | None) -> datetime | None:
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _insert_stubs(db: Session, rows: list[dict]) -> int:
|
||||
"""Idempotently insert video stubs; returns the number of newly inserted rows.
|
||||
|
||||
Uses RETURNING so the count is exact (ON CONFLICT DO NOTHING leaves rowcount
|
||||
unreliable across drivers). Duplicates within the batch are dropped first so a
|
||||
single INSERT can't raise CardinalityViolation on the conflict target."""
|
||||
if not rows:
|
||||
return 0
|
||||
seen: dict[str, dict] = {}
|
||||
for row in rows:
|
||||
seen[row["id"]] = row
|
||||
stmt = (
|
||||
pg_insert(Video)
|
||||
.values(list(seen.values()))
|
||||
.on_conflict_do_nothing(index_elements=[Video.id])
|
||||
.returning(Video.id)
|
||||
)
|
||||
inserted = len(db.execute(stmt).fetchall())
|
||||
db.commit()
|
||||
return inserted
|
||||
|
||||
|
||||
# --- RSS (free) ---
|
||||
def poll_rss_channel(db: Session, channel: Channel) -> int:
|
||||
entries = fetch_channel_feed(channel.id)
|
||||
inserted = _insert_stubs(db, entries)
|
||||
channel.last_rss_at = _now()
|
||||
db.add(channel)
|
||||
db.commit()
|
||||
return inserted
|
||||
|
||||
|
||||
# --- Backfill (uploads playlist; costs quota) ---
|
||||
def _stub_from_playlist_item(item: dict, channel_id: str) -> dict | None:
|
||||
content = item.get("contentDetails", {})
|
||||
snippet = item.get("snippet", {})
|
||||
video_id = content.get("videoId")
|
||||
if not video_id:
|
||||
return None
|
||||
published = parse_dt(content.get("videoPublishedAt") or snippet.get("publishedAt"))
|
||||
return {
|
||||
"id": video_id,
|
||||
"channel_id": channel_id,
|
||||
"title": snippet.get("title"),
|
||||
"published_at": published,
|
||||
"thumbnail_url": best_thumbnail(snippet.get("thumbnails")),
|
||||
}
|
||||
|
||||
|
||||
def backfill_channel_recent(db: Session, yt: YouTubeClient, channel: Channel) -> int:
|
||||
"""First pass: newest videos up to the configured count or age cutoff. Records a
|
||||
resume cursor for the later deep backfill."""
|
||||
if not channel.uploads_playlist_id:
|
||||
channel.recent_synced_at = _now()
|
||||
db.add(channel)
|
||||
db.commit()
|
||||
return 0
|
||||
|
||||
cutoff = _now() - timedelta(days=settings.backfill_recent_max_days)
|
||||
collected: list[dict] = []
|
||||
page_token: str | None = None
|
||||
next_cursor: str | None = None
|
||||
done = False
|
||||
|
||||
while len(collected) < settings.backfill_recent_max_videos:
|
||||
data = yt.get_playlist_items(channel.uploads_playlist_id, page_token)
|
||||
reached_cutoff = False
|
||||
for item in data.get("items", []):
|
||||
stub = _stub_from_playlist_item(item, channel.id)
|
||||
if stub is None:
|
||||
continue
|
||||
if stub["published_at"] and stub["published_at"] < cutoff:
|
||||
reached_cutoff = True
|
||||
break
|
||||
collected.append(stub)
|
||||
if len(collected) >= settings.backfill_recent_max_videos:
|
||||
break
|
||||
page_token = data.get("nextPageToken")
|
||||
if reached_cutoff or not page_token:
|
||||
next_cursor = page_token
|
||||
done = not page_token
|
||||
break
|
||||
next_cursor = page_token
|
||||
|
||||
channel.recent_synced_at = _now()
|
||||
channel.backfill_cursor = next_cursor
|
||||
channel.backfill_done = done
|
||||
inserted = _insert_stubs(db, collected)
|
||||
db.add(channel)
|
||||
db.commit()
|
||||
return inserted
|
||||
|
||||
|
||||
def backfill_channel_deep(
|
||||
db: Session, yt: YouTubeClient, channel: Channel, max_pages: int = 5
|
||||
) -> int:
|
||||
"""Continue paging older uploads from the stored cursor (bounded per run)."""
|
||||
if channel.backfill_done or not channel.uploads_playlist_id:
|
||||
return 0
|
||||
page_token = channel.backfill_cursor
|
||||
total = 0
|
||||
for _ in range(max_pages):
|
||||
data = yt.get_playlist_items(channel.uploads_playlist_id, page_token)
|
||||
rows = [
|
||||
s
|
||||
for s in (
|
||||
_stub_from_playlist_item(it, channel.id) for it in data.get("items", [])
|
||||
)
|
||||
if s is not None
|
||||
]
|
||||
total += _insert_stubs(db, rows)
|
||||
page_token = data.get("nextPageToken")
|
||||
channel.backfill_cursor = page_token
|
||||
if not page_token:
|
||||
channel.backfill_done = True
|
||||
break
|
||||
db.add(channel)
|
||||
db.commit()
|
||||
return total
|
||||
|
||||
|
||||
# --- Enrichment (videos.list) ---
|
||||
def _live_status(snippet: dict, live_details: dict | None) -> str:
|
||||
broadcast = snippet.get("liveBroadcastContent")
|
||||
if broadcast == "live":
|
||||
return "live"
|
||||
if broadcast == "upcoming":
|
||||
return "upcoming"
|
||||
if live_details and live_details.get("actualEndTime"):
|
||||
return "was_live"
|
||||
return "none"
|
||||
|
||||
|
||||
def apply_video_details(video: Video, item: dict) -> None:
|
||||
snippet = item.get("snippet", {})
|
||||
content = item.get("contentDetails", {})
|
||||
stats = item.get("statistics", {})
|
||||
topics = item.get("topicDetails", {})
|
||||
live = item.get("liveStreamingDetails")
|
||||
|
||||
video.title = snippet.get("title") or video.title
|
||||
video.description = snippet.get("description")
|
||||
if not video.published_at:
|
||||
video.published_at = parse_dt(snippet.get("publishedAt"))
|
||||
video.thumbnail_url = best_thumbnail(snippet.get("thumbnails")) or video.thumbnail_url
|
||||
video.duration_seconds = parse_iso8601_duration(content.get("duration"))
|
||||
video.view_count = int(stats["viewCount"]) if stats.get("viewCount") else None
|
||||
video.like_count = int(stats["likeCount"]) if stats.get("likeCount") else None
|
||||
video.category_id = int(snippet["categoryId"]) if snippet.get("categoryId") else None
|
||||
video.topic_categories = topics.get("topicCategories")
|
||||
video.default_language = snippet.get("defaultLanguage") or snippet.get(
|
||||
"defaultAudioLanguage"
|
||||
)
|
||||
video.live_status = _live_status(snippet, live)
|
||||
video.is_short = bool(
|
||||
video.live_status == "none"
|
||||
and video.duration_seconds
|
||||
and 0 < video.duration_seconds <= settings.shorts_max_seconds
|
||||
)
|
||||
|
||||
|
||||
def enrich_pending(db: Session, yt: YouTubeClient, limit: int | None = None) -> int:
|
||||
limit = limit or settings.enrich_batch_size
|
||||
videos = (
|
||||
db.execute(select(Video).where(Video.enriched_at.is_(None)).limit(limit))
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
if not videos:
|
||||
return 0
|
||||
items = {it["id"]: it for it in yt.get_videos([v.id for v in videos])}
|
||||
now = _now()
|
||||
enriched = 0
|
||||
for video in videos:
|
||||
item = items.get(video.id)
|
||||
if item is None:
|
||||
# Unavailable (deleted/private): stamp so we don't keep retrying.
|
||||
video.enriched_at = now
|
||||
continue
|
||||
apply_video_details(video, item)
|
||||
video.enriched_at = now
|
||||
enriched += 1
|
||||
db.commit()
|
||||
return enriched
|
||||
0
backend/app/youtube/__init__.py
Normal file
0
backend/app/youtube/__init__.py
Normal file
161
backend/app/youtube/client.py
Normal file
161
backend/app/youtube/client.py
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
"""Thin synchronous YouTube Data API v3 client bound to a user's OAuth token.
|
||||
|
||||
Each list call costs 1 quota unit and is recorded via the central quota guard.
|
||||
Public reads (channels/videos/playlistItems) use the configured API key when available
|
||||
so they don't depend on a specific user's token; subscriptions.list?mine=true always
|
||||
uses OAuth.
|
||||
"""
|
||||
from collections.abc import Iterator
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import httpx
|
||||
|
||||
from app import quota
|
||||
from app.config import settings
|
||||
from app.models import User
|
||||
from app.security import decrypt
|
||||
|
||||
GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"
|
||||
API_BASE = "https://www.googleapis.com/youtube/v3"
|
||||
|
||||
|
||||
class YouTubeError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def _chunks(items: list, size: int) -> Iterator[list]:
|
||||
for i in range(0, len(items), size):
|
||||
yield items[i : i + size]
|
||||
|
||||
|
||||
def best_thumbnail(thumbnails: dict | None) -> str | None:
|
||||
if not thumbnails:
|
||||
return None
|
||||
for key in ("maxres", "standard", "high", "medium", "default"):
|
||||
if key in thumbnails and thumbnails[key].get("url"):
|
||||
return thumbnails[key]["url"]
|
||||
return None
|
||||
|
||||
|
||||
class YouTubeClient:
|
||||
def __init__(self, db, user: User):
|
||||
self.db = db
|
||||
self.user = user
|
||||
self._http = httpx.Client(timeout=30.0)
|
||||
|
||||
def __enter__(self) -> "YouTubeClient":
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc) -> None:
|
||||
self._http.close()
|
||||
|
||||
# --- auth ---
|
||||
def _access_token(self) -> str:
|
||||
tok = self.user.token
|
||||
if tok is None:
|
||||
raise YouTubeError("User has no stored OAuth token")
|
||||
now = datetime.now(timezone.utc)
|
||||
if tok.access_token and tok.expiry and tok.expiry > now + timedelta(seconds=60):
|
||||
return tok.access_token
|
||||
refresh = decrypt(tok.refresh_token_enc)
|
||||
if not refresh:
|
||||
raise YouTubeError("No refresh token; user must re-authenticate")
|
||||
resp = self._http.post(
|
||||
GOOGLE_TOKEN_URL,
|
||||
data={
|
||||
"client_id": settings.google_client_id,
|
||||
"client_secret": settings.google_client_secret,
|
||||
"refresh_token": refresh,
|
||||
"grant_type": "refresh_token",
|
||||
},
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
raise YouTubeError(f"Token refresh failed: {resp.status_code} {resp.text[:200]}")
|
||||
data = resp.json()
|
||||
tok.access_token = data["access_token"]
|
||||
tok.expiry = now + timedelta(seconds=int(data.get("expires_in", 3600)))
|
||||
self.db.add(tok)
|
||||
self.db.commit()
|
||||
return tok.access_token
|
||||
|
||||
# --- core request ---
|
||||
def _get(self, path: str, params: dict, cost: int = 1, allow_key: bool = True) -> dict:
|
||||
p = dict(params)
|
||||
headers = {}
|
||||
if allow_key and settings.youtube_api_key:
|
||||
p["key"] = settings.youtube_api_key
|
||||
else:
|
||||
headers["Authorization"] = f"Bearer {self._access_token()}"
|
||||
resp = self._http.get(f"{API_BASE}/{path}", params=p, headers=headers)
|
||||
quota.record_usage(self.db, cost)
|
||||
if resp.status_code != 200:
|
||||
raise YouTubeError(f"GET {path} -> {resp.status_code}: {resp.text[:300]}")
|
||||
return resp.json()
|
||||
|
||||
# --- endpoints ---
|
||||
def iter_subscriptions(self) -> Iterator[dict]:
|
||||
"""Yield the authenticated user's subscriptions (requires OAuth)."""
|
||||
page_token = None
|
||||
while True:
|
||||
params = {
|
||||
"part": "snippet",
|
||||
"mine": "true",
|
||||
"maxResults": 50,
|
||||
"order": "alphabetical",
|
||||
}
|
||||
if page_token:
|
||||
params["pageToken"] = page_token
|
||||
data = self._get("subscriptions", params, cost=1, allow_key=False)
|
||||
for item in data.get("items", []):
|
||||
snippet = item.get("snippet", {})
|
||||
resource = snippet.get("resourceId", {})
|
||||
yield {
|
||||
"channel_id": resource.get("channelId"),
|
||||
"title": snippet.get("title"),
|
||||
"description": snippet.get("description"),
|
||||
"thumbnail_url": best_thumbnail(snippet.get("thumbnails")),
|
||||
"yt_subscription_id": item.get("id"),
|
||||
}
|
||||
page_token = data.get("nextPageToken")
|
||||
if not page_token:
|
||||
break
|
||||
|
||||
def get_channels(self, channel_ids: list[str]) -> list[dict]:
|
||||
items: list[dict] = []
|
||||
for batch in _chunks(channel_ids, 50):
|
||||
data = self._get(
|
||||
"channels",
|
||||
{
|
||||
"part": "snippet,contentDetails,statistics,topicDetails",
|
||||
"id": ",".join(batch),
|
||||
"maxResults": 50,
|
||||
},
|
||||
)
|
||||
items.extend(data.get("items", []))
|
||||
return items
|
||||
|
||||
def get_playlist_items(self, playlist_id: str, page_token: str | None = None) -> dict:
|
||||
"""One page (up to 50) of a playlist's items, most-recent-first for uploads
|
||||
playlists. Returns the raw response (items + nextPageToken)."""
|
||||
params = {
|
||||
"part": "contentDetails,snippet",
|
||||
"playlistId": playlist_id,
|
||||
"maxResults": 50,
|
||||
}
|
||||
if page_token:
|
||||
params["pageToken"] = page_token
|
||||
return self._get("playlistItems", params)
|
||||
|
||||
def get_videos(self, video_ids: list[str]) -> list[dict]:
|
||||
items: list[dict] = []
|
||||
for batch in _chunks(video_ids, 50):
|
||||
data = self._get(
|
||||
"videos",
|
||||
{
|
||||
"part": "snippet,contentDetails,statistics,topicDetails,liveStreamingDetails",
|
||||
"id": ",".join(batch),
|
||||
"maxResults": 50,
|
||||
},
|
||||
)
|
||||
items.extend(data.get("items", []))
|
||||
return items
|
||||
42
backend/app/youtube/rss.py
Normal file
42
backend/app/youtube/rss.py
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
"""Free per-channel RSS feed reader (no API quota). Returns the channel's most recent
|
||||
~15 uploads as lightweight stubs for fast fresh-video detection."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import feedparser
|
||||
import httpx
|
||||
|
||||
RSS_URL = "https://www.youtube.com/feeds/videos.xml?channel_id={channel_id}"
|
||||
|
||||
|
||||
def fetch_channel_feed(channel_id: str) -> list[dict]:
|
||||
url = RSS_URL.format(channel_id=channel_id)
|
||||
try:
|
||||
resp = httpx.get(url, timeout=20.0, headers={"User-Agent": "Subfeed/1.0"})
|
||||
except httpx.HTTPError:
|
||||
return []
|
||||
if resp.status_code != 200:
|
||||
return []
|
||||
|
||||
parsed = feedparser.parse(resp.content)
|
||||
out: list[dict] = []
|
||||
for entry in parsed.entries:
|
||||
video_id = entry.get("yt_videoid")
|
||||
if not video_id:
|
||||
continue
|
||||
published = None
|
||||
if entry.get("published_parsed"):
|
||||
published = datetime(*entry.published_parsed[:6], tzinfo=timezone.utc)
|
||||
thumbnail = None
|
||||
media = entry.get("media_thumbnail") or []
|
||||
if media:
|
||||
thumbnail = media[0].get("url")
|
||||
out.append(
|
||||
{
|
||||
"id": video_id,
|
||||
"channel_id": channel_id,
|
||||
"title": entry.get("title"),
|
||||
"published_at": published,
|
||||
"thumbnail_url": thumbnail,
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
|
@ -7,5 +7,7 @@ pydantic>=2.7,<3.0
|
|||
pydantic-settings>=2.3,<3.0
|
||||
authlib>=1.3,<2.0
|
||||
httpx>=0.27,<1.0
|
||||
feedparser>=6.0,<7.0
|
||||
apscheduler>=3.10,<4.0
|
||||
itsdangerous>=2.1,<3.0
|
||||
cryptography>=42,<46
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue