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:
parent
fde9a439df
commit
e10df8dcbd
27 changed files with 1420 additions and 107 deletions
|
|
@ -1,14 +1,19 @@
|
|||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import settings
|
||||
from app.core.auth_dependency import require_session
|
||||
from app.core.slugify import unique_slugify
|
||||
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
|
||||
|
||||
router = APIRouter(dependencies=[Depends(require_session)])
|
||||
|
||||
|
|
@ -40,13 +45,15 @@ def _name_taken(db: Session, name: str, exclude_id: int | None = None) -> bool:
|
|||
return any(row[0].casefold() == target for row in query.all())
|
||||
|
||||
|
||||
def _serialize(db: Session, category: Category, counts: dict[int, int]) -> dict:
|
||||
def _serialize(db: Session, category: Category, counts: dict[int, int], new_videos: dict[int, int] | None = None) -> dict:
|
||||
new_videos = new_videos or {}
|
||||
return {
|
||||
"id": category.id,
|
||||
"name": category.name,
|
||||
"slug": category.slug,
|
||||
"sort_order": category.sort_order,
|
||||
"channel_count": counts.get(category.id, 0),
|
||||
"new_videos_count": new_videos.get(category.id, 0),
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -59,7 +66,17 @@ def list_categories(db: Session = Depends(get_db)) -> list[dict]:
|
|||
.all()
|
||||
)
|
||||
counts = dict(count_rows)
|
||||
return [_serialize(db, c, counts) for c in categories]
|
||||
since = datetime.now(timezone.utc) - timedelta(days=settings.new_videos_window_days)
|
||||
new_videos_rows = (
|
||||
db.query(channel_categories.c.category_id, func.count(Video.id))
|
||||
.join(Channel, Channel.id == channel_categories.c.channel_id)
|
||||
.join(Video, Video.channel_id == Channel.id)
|
||||
.filter(Channel.subscribed.is_(True), Video.published_at >= since)
|
||||
.group_by(channel_categories.c.category_id)
|
||||
.all()
|
||||
)
|
||||
new_videos = dict(new_videos_rows)
|
||||
return [_serialize(db, c, counts, new_videos) for c in categories]
|
||||
|
||||
|
||||
@router.post("/categories", status_code=201)
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import base64
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import func, or_, 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.channel import Channel
|
||||
|
|
@ -80,6 +81,7 @@ def get_feed(
|
|||
channel_id: int | None = None,
|
||||
downloaded: bool = False,
|
||||
search: str | None = Query(None, max_length=200),
|
||||
new_only: bool = False,
|
||||
limit: int = Query(DEFAULT_LIMIT, ge=1, le=MAX_LIMIT),
|
||||
cursor: str | None = None,
|
||||
db: Session = Depends(get_db),
|
||||
|
|
@ -97,6 +99,15 @@ def get_feed(
|
|||
)
|
||||
query = query.filter(Video.channel_id.in_(channel_ids_in_category))
|
||||
|
||||
if new_only:
|
||||
since = datetime.now(timezone.utc) - timedelta(days=settings.new_videos_window_days)
|
||||
query = query.filter(Video.published_at >= since)
|
||||
# Align with the sidebar badge count (categories.py), which only
|
||||
# counts subscribed channels: after unsubscribing, a channel's
|
||||
# fresh videos must disappear from "new only" too.
|
||||
subscribed_channel_ids = select(Channel.id).where(Channel.subscribed.is_(True))
|
||||
query = query.filter(Video.channel_id.in_(subscribed_channel_ids))
|
||||
|
||||
if downloaded:
|
||||
query = query.filter(Video.id.in_(_completed_video_ids()))
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue