"""Channel manager: the user's subscriptions with their personal overrides (priority, hidden, tags) and per-channel sync state. Read + local-write only — YouTube-side writes (unsubscribe) live behind the optional write scope and are added in a later phase.""" from fastapi import APIRouter, Depends, HTTPException from sqlalchemy import and_, func, select from sqlalchemy.orm import Session from app.auth import current_user from app.db import get_db from app.models import Channel, ChannelTag, Subscription, Tag, User, Video router = APIRouter(prefix="/api/channels", tags=["channels"]) @router.get("") def list_channels( user: User = Depends(current_user), db: Session = Depends(get_db) ) -> list[dict]: rows = db.execute( select(Subscription, Channel) .join(Channel, Channel.id == Subscription.channel_id) .where(Subscription.user_id == user.id) .order_by(Subscription.priority.desc(), func.lower(Channel.title)) ).all() # Per-channel stored video count (shared data) in one grouped query. channel_ids = [c.id for _, c in rows] stored: dict[str, int] = {} if channel_ids: for cid, count in db.execute( select(Video.channel_id, func.count(Video.id)) .where(Video.channel_id.in_(channel_ids)) .group_by(Video.channel_id) ).all(): stored[cid] = count # The user's personal tag links, grouped by channel. tags_by_channel: dict[str, list[int]] = {} for cid, tag_id in db.execute( select(ChannelTag.channel_id, ChannelTag.tag_id).where( ChannelTag.user_id == user.id ) ).all(): tags_by_channel.setdefault(cid, []).append(tag_id) return [ { "id": ch.id, "title": ch.title, "handle": ch.handle, "thumbnail_url": ch.thumbnail_url, "subscriber_count": ch.subscriber_count, "video_count": ch.video_count, "stored_videos": stored.get(ch.id, 0), "priority": sub.priority, "hidden": sub.hidden, "deep_requested": sub.deep_requested, "tag_ids": tags_by_channel.get(ch.id, []), "details_synced": ch.details_synced_at is not None, "recent_synced": ch.recent_synced_at is not None, "backfill_done": ch.backfill_done, } for sub, ch in rows ] def _user_subscription(db: Session, user: User, channel_id: str) -> Subscription: sub = db.execute( select(Subscription).where( Subscription.user_id == user.id, Subscription.channel_id == channel_id ) ).scalar_one_or_none() if sub is None: raise HTTPException(status_code=404, detail="Not subscribed to this channel") return sub @router.patch("/{channel_id}") def update_channel( channel_id: str, payload: dict, user: User = Depends(current_user), db: Session = Depends(get_db), ) -> dict: sub = _user_subscription(db, user, channel_id) if "priority" in payload: sub.priority = int(payload["priority"]) if "hidden" in payload: sub.hidden = bool(payload["hidden"]) if "deep_requested" in payload: # Opt this channel into (or out of) full-history backfill. The deep scheduler # picks the flag up on its next run; turning it off won't undo already-fetched # videos, it just stops further deep paging if nobody else still wants it. sub.deep_requested = bool(payload["deep_requested"]) db.commit() return { "id": channel_id, "priority": sub.priority, "hidden": sub.hidden, "deep_requested": sub.deep_requested, } @router.post("/{channel_id}/tags") def attach_tag( channel_id: str, payload: dict, user: User = Depends(current_user), db: Session = Depends(get_db), ) -> dict: _user_subscription(db, user, channel_id) tag_id = payload.get("tag_id") tag = db.get(Tag, tag_id) if tag_id is not None else None # Only the user's own tags may be attached as personal links. if tag is None or tag.user_id != user.id: raise HTTPException(status_code=404, detail="Unknown tag") exists = db.execute( select(ChannelTag).where( ChannelTag.channel_id == channel_id, ChannelTag.tag_id == tag_id, ChannelTag.user_id == user.id, ) ).scalar_one_or_none() if exists is None: db.add( ChannelTag( channel_id=channel_id, tag_id=tag_id, user_id=user.id, source="user", ) ) db.commit() return {"channel_id": channel_id, "tag_id": tag_id} @router.delete("/{channel_id}/tags/{tag_id}") def detach_tag( channel_id: str, tag_id: int, user: User = Depends(current_user), db: Session = Depends(get_db), ) -> dict: row = db.execute( select(ChannelTag).where( ChannelTag.channel_id == channel_id, ChannelTag.tag_id == tag_id, ChannelTag.user_id == user.id, ) ).scalar_one_or_none() if row is not None: db.delete(row) db.commit() return {"channel_id": channel_id, "tag_id": tag_id}