Channel features: subscribers, new-videos badges, activity-driven sync

- Store subscriber count from YouTube statistics and show it on channel page
- Sync 50 videos per channel with playlistItems pagination support
- Show per-channel and per-category new-videos counters (2-day window)
- Replace hourly videos sync with activity trigger (2h idle) and
  incremental backfill (hard cap 200 per channel)
- Clicking the sidebar new-videos count filters the category feed to
  recent videos only (new_only)
- Update agent-team docs: deploy after green checks
This commit is contained in:
vrubelroman 2026-09-17 21:07:33 +00:00
parent fde9a439df
commit e10df8dcbd
27 changed files with 1420 additions and 107 deletions

View file

@ -1,15 +1,18 @@
import logging
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.config import settings
from app.core.auth_dependency import require_session
from app.db import get_db
from app.models.category import Category
from app.models.channel import Channel
from app.models.channel_category import channel_categories
from app.models.video import Video
from app.services import sync
from app.services.google_oauth import OAuthNotConnected
from app.services.youtube_client import YouTubeAPIError, YouTubeInsufficientScope, YouTubeQuotaExceeded
@ -37,7 +40,22 @@ def _category_ids_by_channel(db: Session, channel_ids: list[int]) -> dict[int, l
return result
def _serialize(channel: Channel, category_ids: list[int]) -> dict:
def _new_videos_counts(db: Session, channel_ids: list[int]) -> dict[int, int]:
"""Videos published within the new-videos window, per channel, in one
aggregate query."""
if not channel_ids:
return {}
since = datetime.now(timezone.utc) - timedelta(days=settings.new_videos_window_days)
rows = (
db.query(Video.channel_id, func.count(Video.id))
.filter(Video.channel_id.in_(channel_ids), Video.published_at >= since)
.group_by(Video.channel_id)
.all()
)
return {channel_id: count for channel_id, count in rows}
def _serialize(channel: Channel, category_ids: list[int], new_videos_count: int = 0) -> dict:
return {
"id": channel.id,
"youtube_channel_id": channel.youtube_channel_id,
@ -45,9 +63,11 @@ def _serialize(channel: Channel, category_ids: list[int]) -> dict:
"description": channel.description,
"thumbnail_url": channel.thumbnail_url,
"uploads_playlist_id": channel.uploads_playlist_id,
"subscriber_count": channel.subscriber_count,
"subscribed": channel.subscribed,
"last_synced_at": channel.last_synced_at,
"category_ids": category_ids,
"new_videos_count": new_videos_count,
}
@ -75,8 +95,10 @@ def list_channels(
query = query.filter(Channel.id.in_(channel_ids_in_category))
channels = query.order_by(Channel.title.asc()).all()
category_map = _category_ids_by_channel(db, [c.id for c in channels])
return [_serialize(c, category_map.get(c.id, [])) for c in channels]
channel_ids = [c.id for c in channels]
category_map = _category_ids_by_channel(db, channel_ids)
new_videos_map = _new_videos_counts(db, channel_ids)
return [_serialize(c, category_map.get(c.id, []), new_videos_map.get(c.id, 0)) for c in channels]
@router.get("/channels/{channel_id}")
@ -85,7 +107,8 @@ def get_channel(channel_id: int, db: Session = Depends(get_db)) -> dict:
if channel is None:
raise HTTPException(status_code=404, detail="Channel not found")
category_map = _category_ids_by_channel(db, [channel_id])
return _serialize(channel, category_map.get(channel_id, []))
new_videos_map = _new_videos_counts(db, [channel_id])
return _serialize(channel, category_map.get(channel_id, []), new_videos_map.get(channel_id, 0))
@router.put("/channels/{channel_id}/categories")
@ -110,7 +133,8 @@ def set_channel_categories(channel_id: int, payload: ChannelCategoriesUpdate, db
)
db.commit()
return _serialize(channel, sorted(unique_ids))
new_videos_map = _new_videos_counts(db, [channel_id])
return _serialize(channel, sorted(unique_ids), new_videos_map.get(channel_id, 0))
@router.post("/channels/{channel_id}/unsubscribe")
@ -139,4 +163,5 @@ def unsubscribe_channel(channel_id: int, db: Session = Depends(get_db)) -> dict:
raise HTTPException(status_code=502, detail="YouTube is unavailable")
category_map = _category_ids_by_channel(db, [channel_id])
return _serialize(channel, category_map.get(channel_id, []))
new_videos_map = _new_videos_counts(db, [channel_id])
return _serialize(channel, category_map.get(channel_id, []), new_videos_map.get(channel_id, 0))