2026-09-16 19:47:35 +00:00
|
|
|
import logging
|
|
|
|
|
|
Implement Phases 1-5: skeleton, OAuth, categories, video sync/feed, playback
- FastAPI + PostgreSQL + Alembic + React/Vite skeleton, Docker Compose, healthcheck
- Google OAuth (single allowed account), encrypted refresh token storage
- Subscriptions sync with pagination, uploads playlist batch fetch
- Categories CRUD, many-to-many channel assignment, category filtering
- Video sync (playlistItems + videos.list batching), cached feed with cursor
pagination, background scheduler (APScheduler)
- Video detail page with YouTube embed player
- SPA fallback routing, optimistic UI updates, client-side query caching
40 backend tests covering OAuth allow-list, sync idempotency, cascade deletes,
cursor pagination, and category filtering.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 18:44:30 +00:00
|
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
|
|
|
from pydantic import BaseModel
|
|
|
|
|
from sqlalchemy import select
|
|
|
|
|
from sqlalchemy.orm import Session
|
|
|
|
|
|
|
|
|
|
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
|
2026-09-16 19:47:35 +00:00
|
|
|
from app.services import sync
|
|
|
|
|
from app.services.google_oauth import OAuthNotConnected
|
|
|
|
|
from app.services.youtube_client import YouTubeAPIError, YouTubeInsufficientScope, YouTubeQuotaExceeded
|
|
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
Implement Phases 1-5: skeleton, OAuth, categories, video sync/feed, playback
- FastAPI + PostgreSQL + Alembic + React/Vite skeleton, Docker Compose, healthcheck
- Google OAuth (single allowed account), encrypted refresh token storage
- Subscriptions sync with pagination, uploads playlist batch fetch
- Categories CRUD, many-to-many channel assignment, category filtering
- Video sync (playlistItems + videos.list batching), cached feed with cursor
pagination, background scheduler (APScheduler)
- Video detail page with YouTube embed player
- SPA fallback routing, optimistic UI updates, client-side query caching
40 backend tests covering OAuth allow-list, sync idempotency, cascade deletes,
cursor pagination, and category filtering.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 18:44:30 +00:00
|
|
|
|
|
|
|
|
router = APIRouter(dependencies=[Depends(require_session)])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ChannelCategoriesUpdate(BaseModel):
|
|
|
|
|
category_ids: list[int]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _category_ids_by_channel(db: Session, channel_ids: list[int]) -> dict[int, list[int]]:
|
|
|
|
|
if not channel_ids:
|
|
|
|
|
return {}
|
|
|
|
|
rows = db.execute(
|
|
|
|
|
select(channel_categories.c.channel_id, channel_categories.c.category_id).where(
|
|
|
|
|
channel_categories.c.channel_id.in_(channel_ids)
|
|
|
|
|
)
|
|
|
|
|
).all()
|
|
|
|
|
result: dict[int, list[int]] = {}
|
|
|
|
|
for channel_id, category_id in rows:
|
|
|
|
|
result.setdefault(channel_id, []).append(category_id)
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _serialize(channel: Channel, category_ids: list[int]) -> dict:
|
|
|
|
|
return {
|
|
|
|
|
"id": channel.id,
|
|
|
|
|
"youtube_channel_id": channel.youtube_channel_id,
|
|
|
|
|
"title": channel.title,
|
|
|
|
|
"description": channel.description,
|
|
|
|
|
"thumbnail_url": channel.thumbnail_url,
|
|
|
|
|
"uploads_playlist_id": channel.uploads_playlist_id,
|
|
|
|
|
"subscribed": channel.subscribed,
|
|
|
|
|
"last_synced_at": channel.last_synced_at,
|
|
|
|
|
"category_ids": category_ids,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/channels")
|
|
|
|
|
def list_channels(
|
|
|
|
|
subscribed: bool | None = None,
|
|
|
|
|
search: str | None = None,
|
|
|
|
|
category_id: int | None = None,
|
|
|
|
|
uncategorized: bool = False,
|
|
|
|
|
db: Session = Depends(get_db),
|
|
|
|
|
) -> list[dict]:
|
|
|
|
|
query = db.query(Channel)
|
|
|
|
|
if subscribed is not None:
|
|
|
|
|
query = query.filter(Channel.subscribed == subscribed)
|
|
|
|
|
if search:
|
|
|
|
|
query = query.filter(Channel.title.ilike(f"%{search}%"))
|
|
|
|
|
|
|
|
|
|
if uncategorized:
|
|
|
|
|
categorized_ids = select(channel_categories.c.channel_id)
|
|
|
|
|
query = query.filter(~Channel.id.in_(categorized_ids))
|
|
|
|
|
elif category_id is not None:
|
|
|
|
|
channel_ids_in_category = select(channel_categories.c.channel_id).where(
|
|
|
|
|
channel_categories.c.category_id == category_id
|
|
|
|
|
)
|
|
|
|
|
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]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/channels/{channel_id}")
|
|
|
|
|
def get_channel(channel_id: int, db: Session = Depends(get_db)) -> dict:
|
|
|
|
|
channel = db.get(Channel, channel_id)
|
|
|
|
|
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, []))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.put("/channels/{channel_id}/categories")
|
|
|
|
|
def set_channel_categories(channel_id: int, payload: ChannelCategoriesUpdate, db: Session = Depends(get_db)) -> dict:
|
|
|
|
|
channel = db.get(Channel, channel_id)
|
|
|
|
|
if channel is None:
|
|
|
|
|
raise HTTPException(status_code=404, detail="Channel not found")
|
|
|
|
|
|
|
|
|
|
unique_ids = set(payload.category_ids)
|
|
|
|
|
if unique_ids:
|
|
|
|
|
found = db.query(Category.id).filter(Category.id.in_(unique_ids)).all()
|
|
|
|
|
found_ids = {row[0] for row in found}
|
|
|
|
|
missing = unique_ids - found_ids
|
|
|
|
|
if missing:
|
|
|
|
|
raise HTTPException(status_code=400, detail=f"Unknown category ids: {sorted(missing)}")
|
|
|
|
|
|
|
|
|
|
db.execute(channel_categories.delete().where(channel_categories.c.channel_id == channel_id))
|
|
|
|
|
if unique_ids:
|
|
|
|
|
db.execute(
|
|
|
|
|
channel_categories.insert(),
|
|
|
|
|
[{"channel_id": channel_id, "category_id": cid} for cid in unique_ids],
|
|
|
|
|
)
|
|
|
|
|
db.commit()
|
|
|
|
|
|
|
|
|
|
return _serialize(channel, sorted(unique_ids))
|
2026-09-16 19:47:35 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/channels/{channel_id}/unsubscribe")
|
|
|
|
|
def unsubscribe_channel(channel_id: int, db: Session = Depends(get_db)) -> dict:
|
|
|
|
|
channel = db.get(Channel, channel_id)
|
|
|
|
|
if channel is None:
|
|
|
|
|
raise HTTPException(status_code=404, detail="Channel not found")
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
sync.unsubscribe_channel(db, channel)
|
|
|
|
|
except sync.ChannelHasNoSubscriptionId as exc:
|
|
|
|
|
raise HTTPException(status_code=400, detail=str(exc))
|
|
|
|
|
except OAuthNotConnected:
|
|
|
|
|
raise HTTPException(status_code=400, detail="Google account is not connected")
|
|
|
|
|
except YouTubeInsufficientScope:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=403,
|
|
|
|
|
detail="Недостаточно прав для отписки — переподключи YouTube, чтобы выдать разрешение на управление подписками",
|
|
|
|
|
)
|
|
|
|
|
except YouTubeQuotaExceeded:
|
|
|
|
|
raise HTTPException(status_code=503, detail="YouTube API quota exhausted")
|
|
|
|
|
except YouTubeAPIError as exc:
|
|
|
|
|
raise HTTPException(status_code=502, detail=f"YouTube API error: {exc}")
|
|
|
|
|
except Exception:
|
|
|
|
|
logger.exception("Failed to unsubscribe from channel %s", channel_id)
|
|
|
|
|
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, []))
|