2026-09-16 19:47:35 +00:00
|
|
|
import logging
|
2026-09-17 21:07:33 +00:00
|
|
|
from datetime import datetime, timedelta, timezone
|
2026-09-16 19:47:35 +00:00
|
|
|
|
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
|
2026-09-17 21:07:33 +00:00
|
|
|
from sqlalchemy import func, select
|
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 sqlalchemy.orm import Session
|
|
|
|
|
|
2026-09-17 21:07:33 +00:00
|
|
|
from app.config import settings
|
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 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-17 21:07:33 +00:00
|
|
|
from app.models.video import Video
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-09-17 21:07:33 +00:00
|
|
|
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:
|
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
|
|
|
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,
|
2026-09-17 21:07:33 +00:00
|
|
|
"subscriber_count": channel.subscriber_count,
|
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
|
|
|
"subscribed": channel.subscribed,
|
|
|
|
|
"last_synced_at": channel.last_synced_at,
|
|
|
|
|
"category_ids": category_ids,
|
2026-09-17 21:07:33 +00:00
|
|
|
"new_videos_count": new_videos_count,
|
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.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()
|
2026-09-17 21:07:33 +00:00
|
|
|
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]
|
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.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])
|
2026-09-17 21:07:33 +00:00
|
|
|
new_videos_map = _new_videos_counts(db, [channel_id])
|
|
|
|
|
return _serialize(channel, category_map.get(channel_id, []), new_videos_map.get(channel_id, 0))
|
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.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()
|
|
|
|
|
|
2026-09-17 21:07:33 +00:00
|
|
|
new_videos_map = _new_videos_counts(db, [channel_id])
|
|
|
|
|
return _serialize(channel, sorted(unique_ids), new_videos_map.get(channel_id, 0))
|
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])
|
2026-09-17 21:07:33 +00:00
|
|
|
new_videos_map = _new_videos_counts(db, [channel_id])
|
|
|
|
|
return _serialize(channel, category_map.get(channel_id, []), new_videos_map.get(channel_id, 0))
|