144 lines
4.7 KiB
Python
144 lines
4.7 KiB
Python
|
|
"""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,
|
||
|
|
"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"])
|
||
|
|
db.commit()
|
||
|
|
return {"id": channel_id, "priority": sub.priority, "hidden": sub.hidden}
|
||
|
|
|
||
|
|
|
||
|
|
@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}
|