- 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
151 lines
5.4 KiB
Python
151 lines
5.4 KiB
Python
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)])
|
|
|
|
|
|
class CategoryCreate(BaseModel):
|
|
name: str = Field(min_length=1, max_length=255)
|
|
|
|
|
|
class CategoryUpdate(BaseModel):
|
|
name: str = Field(min_length=1, max_length=255)
|
|
|
|
|
|
class CategoryReorder(BaseModel):
|
|
category_ids: list[int]
|
|
|
|
|
|
def _existing_slugs(db: Session, exclude_id: int | None = None) -> set[str]:
|
|
query = db.query(Category.slug)
|
|
if exclude_id is not None:
|
|
query = query.filter(Category.id != exclude_id)
|
|
return {row[0] for row in query.all()}
|
|
|
|
|
|
def _name_taken(db: Session, name: str, exclude_id: int | None = None) -> bool:
|
|
query = db.query(Category.name)
|
|
if exclude_id is not None:
|
|
query = query.filter(Category.id != exclude_id)
|
|
target = name.casefold()
|
|
return any(row[0].casefold() == target for row in query.all())
|
|
|
|
|
|
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),
|
|
}
|
|
|
|
|
|
@router.get("/categories")
|
|
def list_categories(db: Session = Depends(get_db)) -> list[dict]:
|
|
categories = db.query(Category).order_by(Category.sort_order.asc(), Category.id.asc()).all()
|
|
count_rows = (
|
|
db.query(channel_categories.c.category_id, func.count(channel_categories.c.channel_id))
|
|
.group_by(channel_categories.c.category_id)
|
|
.all()
|
|
)
|
|
counts = dict(count_rows)
|
|
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)
|
|
def create_category(payload: CategoryCreate, db: Session = Depends(get_db)) -> dict:
|
|
name = payload.name.strip()
|
|
if not name:
|
|
raise HTTPException(status_code=400, detail="Category name must not be empty")
|
|
|
|
if _name_taken(db, name):
|
|
raise HTTPException(status_code=409, detail="Category with this name already exists")
|
|
|
|
slug = unique_slugify(name, _existing_slugs(db))
|
|
max_sort_order = db.query(func.max(Category.sort_order)).scalar() or 0
|
|
|
|
category = Category(name=name, slug=slug, sort_order=max_sort_order + 1)
|
|
db.add(category)
|
|
try:
|
|
db.commit()
|
|
except IntegrityError:
|
|
db.rollback()
|
|
raise HTTPException(status_code=409, detail="Category with this name already exists")
|
|
|
|
return _serialize(db, category, {})
|
|
|
|
|
|
@router.patch("/categories/{category_id}")
|
|
def update_category(category_id: int, payload: CategoryUpdate, db: Session = Depends(get_db)) -> dict:
|
|
category = db.get(Category, category_id)
|
|
if category is None:
|
|
raise HTTPException(status_code=404, detail="Category not found")
|
|
|
|
name = payload.name.strip()
|
|
if not name:
|
|
raise HTTPException(status_code=400, detail="Category name must not be empty")
|
|
|
|
if _name_taken(db, name, exclude_id=category_id):
|
|
raise HTTPException(status_code=409, detail="Category with this name already exists")
|
|
|
|
category.name = name
|
|
category.slug = unique_slugify(name, _existing_slugs(db, exclude_id=category_id))
|
|
try:
|
|
db.commit()
|
|
except IntegrityError:
|
|
db.rollback()
|
|
raise HTTPException(status_code=409, detail="Category with this name already exists")
|
|
|
|
return _serialize(db, category, {})
|
|
|
|
|
|
@router.delete("/categories/{category_id}", status_code=204)
|
|
def delete_category(category_id: int, db: Session = Depends(get_db)) -> None:
|
|
category = db.get(Category, category_id)
|
|
if category is None:
|
|
raise HTTPException(status_code=404, detail="Category not found")
|
|
db.delete(category)
|
|
db.commit()
|
|
|
|
|
|
@router.post("/categories/reorder")
|
|
def reorder_categories(payload: CategoryReorder, db: Session = Depends(get_db)) -> list[dict]:
|
|
categories = {c.id: c for c in db.query(Category).all()}
|
|
|
|
if set(payload.category_ids) != set(categories.keys()):
|
|
raise HTTPException(status_code=400, detail="category_ids must contain exactly all existing category ids")
|
|
|
|
for index, category_id in enumerate(payload.category_ids):
|
|
categories[category_id].sort_order = index
|
|
|
|
db.commit()
|
|
|
|
ordered = db.query(Category).order_by(Category.sort_order.asc(), Category.id.asc()).all()
|
|
return [_serialize(db, c, {}) for c in ordered]
|