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>
This commit is contained in:
vrubelroman 2026-09-16 18:44:30 +00:00
commit 0ed20bb838
90 changed files with 8545 additions and 0 deletions

View file

96
backend/app/api/auth.py Normal file
View file

@ -0,0 +1,96 @@
import logging
import secrets
from fastapi import APIRouter, BackgroundTasks, Depends, Request
from fastapi.responses import RedirectResponse
from sqlalchemy.orm import Session
from app.config import settings
from app.core.auth_dependency import require_session
from app.db import SessionLocal, get_db
from app.services import google_oauth, sync
logger = logging.getLogger(__name__)
router = APIRouter()
@router.get("/auth/status")
def auth_status(request: Request, db: Session = Depends(get_db)) -> dict:
connected = google_oauth.is_connected(db)
return {
"authenticated": bool(request.session.get("authenticated")),
"connected": connected,
"email": google_oauth.get_connected_email(db) if connected else None,
}
@router.get("/auth/google/start")
def google_start(request: Request):
auth_url, state = google_oauth.build_authorization_url()
request.session["oauth_state"] = state
return RedirectResponse(auth_url)
def _run_initial_sync() -> None:
db = SessionLocal()
try:
sync.sync_subscriptions(db)
sync.sync_videos(db)
except Exception:
logger.exception("Initial sync after OAuth failed")
finally:
db.close()
@router.get("/auth/google/callback")
def google_callback(
request: Request,
background_tasks: BackgroundTasks,
code: str | None = None,
state: str | None = None,
error: str | None = None,
db: Session = Depends(get_db),
):
expected_state = request.session.pop("oauth_state", None)
if error:
logger.warning("Google OAuth returned error: %s", error)
return RedirectResponse(f"{settings.app_base_url}/?auth_error=google_error")
if not code or not state or not expected_state or not secrets.compare_digest(state, expected_state):
logger.warning("Google OAuth callback with invalid/missing state")
return RedirectResponse(f"{settings.app_base_url}/?auth_error=invalid_state")
try:
credentials = google_oauth.exchange_code(code, state)
except Exception:
logger.exception("Failed to exchange Google OAuth code")
return RedirectResponse(f"{settings.app_base_url}/?auth_error=exchange_failed")
try:
userinfo = google_oauth.fetch_userinfo(credentials.token)
except Exception:
logger.exception("Failed to fetch Google userinfo")
return RedirectResponse(f"{settings.app_base_url}/?auth_error=userinfo_failed")
email = (userinfo.get("email") or "").lower()
allowed_email = settings.allowed_google_email.lower()
if not allowed_email or email != allowed_email:
logger.warning("Rejected Google OAuth login for disallowed account")
google_oauth.revoke_token(credentials.refresh_token or credentials.token)
return RedirectResponse(f"{settings.app_base_url}/?auth_error=account_not_allowed")
google_oauth.store_credentials(db, email, credentials)
request.session["authenticated"] = True
background_tasks.add_task(_run_initial_sync)
return RedirectResponse(f"{settings.app_base_url}/")
@router.post("/auth/logout")
def logout(request: Request, _: None = Depends(require_session)):
request.session.clear()
return {"ok": True}

View file

@ -0,0 +1,134 @@
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.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_category import channel_categories
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]) -> dict:
return {
"id": category.id,
"name": category.name,
"slug": category.slug,
"sort_order": category.sort_order,
"channel_count": counts.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)
return [_serialize(db, c, counts) 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]

106
backend/app/api/channels.py Normal file
View file

@ -0,0 +1,106 @@
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
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))

87
backend/app/api/feed.py Normal file
View file

@ -0,0 +1,87 @@
import base64
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Query
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.channel import Channel
from app.models.channel_category import channel_categories
from app.models.video import Video
from app.services.video_presentation import channel_categories_map, serialize_video
router = APIRouter(dependencies=[Depends(require_session)])
DEFAULT_LIMIT = 30
MAX_LIMIT = 100
def _encode_cursor(published_at: datetime, video_id: int) -> str:
raw = f"{published_at.isoformat()}|{video_id}"
return base64.urlsafe_b64encode(raw.encode()).decode()
def _decode_cursor(cursor: str) -> tuple[datetime, int]:
try:
raw = base64.urlsafe_b64decode(cursor.encode()).decode()
published_at_raw, video_id_raw = raw.rsplit("|", 1)
published_at = datetime.fromisoformat(published_at_raw)
if published_at.tzinfo is None:
published_at = published_at.replace(tzinfo=timezone.utc)
return published_at, int(video_id_raw)
except Exception:
raise HTTPException(status_code=400, detail="Invalid cursor")
@router.get("/feed")
def get_feed(
category_id: int | None = None,
uncategorized: bool = False,
channel_id: int | None = None,
limit: int = Query(DEFAULT_LIMIT, ge=1, le=MAX_LIMIT),
cursor: str | None = None,
db: Session = Depends(get_db),
) -> dict:
query = db.query(Video)
if channel_id is not None:
query = query.filter(Video.channel_id == channel_id)
elif uncategorized:
categorized_channel_ids = select(channel_categories.c.channel_id)
query = query.filter(~Video.channel_id.in_(categorized_channel_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(Video.channel_id.in_(channel_ids_in_category))
if cursor:
cursor_published_at, cursor_id = _decode_cursor(cursor)
query = query.filter(
(Video.published_at < cursor_published_at)
| ((Video.published_at == cursor_published_at) & (Video.id < cursor_id))
)
query = query.order_by(Video.published_at.desc(), Video.id.desc())
rows = query.limit(limit + 1).all()
next_cursor = None
if len(rows) > limit:
last_kept = rows[limit - 1]
next_cursor = _encode_cursor(last_kept.published_at, last_kept.id)
rows = rows[:limit]
channel_ids = list({v.channel_id for v in rows})
channels = {c.id: c for c in db.query(Channel).filter(Channel.id.in_(channel_ids)).all()}
categories_map = channel_categories_map(db, channel_ids)
items = []
for video in rows:
channel = channels.get(video.channel_id)
if channel is None:
continue
items.append(serialize_video(video, channel, categories_map.get(channel.id, [])))
return {"items": items, "next_cursor": next_cursor}

42
backend/app/api/health.py Normal file
View file

@ -0,0 +1,42 @@
import logging
import httpx
from fastapi import APIRouter, Depends
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.config import settings
from app.db import get_db
logger = logging.getLogger(__name__)
router = APIRouter()
def _check_database(db: Session) -> str:
try:
db.execute(text("SELECT 1"))
return "ok"
except Exception:
logger.exception("Database healthcheck failed")
return "error"
def _check_metube() -> str:
try:
response = httpx.get(settings.metube_api_base_url, timeout=5)
if response.status_code < 500:
return "ok"
return "error"
except Exception:
logger.warning("MeTube healthcheck failed", exc_info=True)
return "error"
@router.get("/health")
def health(db: Session = Depends(get_db)) -> dict:
return {
"status": "ok",
"database": _check_database(db),
"metube": _check_metube(),
}

50
backend/app/api/sync.py Normal file
View file

@ -0,0 +1,50 @@
import logging
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.core.auth_dependency import require_session
from app.db import get_db
from app.services import sync
from app.services.google_oauth import OAuthNotConnected
from app.services.youtube_client import YouTubeAPIError, YouTubeQuotaExceeded
logger = logging.getLogger(__name__)
router = APIRouter(dependencies=[Depends(require_session)])
@router.post("/sync/subscriptions")
def sync_subscriptions(db: Session = Depends(get_db)) -> dict:
try:
return sync.sync_subscriptions(db)
except sync.SyncInProgress:
raise HTTPException(status_code=409, detail="Subscriptions sync already in progress")
except OAuthNotConnected:
raise HTTPException(status_code=400, detail="Google account is not connected")
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}")
@router.post("/sync/videos")
def sync_videos(db: Session = Depends(get_db)) -> dict:
try:
return sync.sync_videos(db)
except sync.SyncInProgress:
raise HTTPException(status_code=409, detail="Videos sync already in progress")
except OAuthNotConnected:
raise HTTPException(status_code=400, detail="Google account is not connected")
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}")
@router.get("/sync/status")
def sync_status(db: Session = Depends(get_db)) -> dict:
return {
"subscriptions": sync.get_subscriptions_sync_status(db),
"videos": sync.get_videos_sync_status(db),
}

24
backend/app/api/videos.py Normal file
View file

@ -0,0 +1,24 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.core.auth_dependency import require_session
from app.db import get_db
from app.models.channel import Channel
from app.models.video import Video
from app.services.video_presentation import channel_categories_map, serialize_video
router = APIRouter(dependencies=[Depends(require_session)])
@router.get("/videos/{youtube_video_id}")
def get_video(youtube_video_id: str, db: Session = Depends(get_db)) -> dict:
video = db.query(Video).filter(Video.youtube_video_id == youtube_video_id).one_or_none()
if video is None:
raise HTTPException(status_code=404, detail="Video not found")
channel = db.get(Channel, video.channel_id)
if channel is None:
raise HTTPException(status_code=404, detail="Channel not found")
categories = channel_categories_map(db, [channel.id]).get(channel.id, [])
return serialize_video(video, channel, categories)