feat(m5a): channel manager, tabbed settings panel, per-user sync status
Backend: /api/channels (list + PATCH priority/hidden + attach/detach user tags), user-tag CRUD on /api/tags, /api/sync/my-status (per-user channel sync counts). Frontend: feed|channels page nav (URL-synced) from the account menu; a slide-in tabbed Settings panel (Appearance, Notifications=6b sound+duration, Sync status + actions + admin pause/resume, Account); a channel manager with priority, hide, per-channel user tags, sync badges and 'view in feed'. Notifications now honor the configurable sound + auto-dismiss settings.
This commit is contained in:
parent
1630a3d8c9
commit
b46c8300d1
11 changed files with 1063 additions and 45 deletions
143
backend/app/routes/channels.py
Normal file
143
backend/app/routes/channels.py
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
"""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}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy import and_, case, func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import quota, state
|
||||
|
|
@ -93,6 +93,50 @@ def sync_status(
|
|||
}
|
||||
|
||||
|
||||
@router.get("/my-status")
|
||||
def my_status(
|
||||
user: User = Depends(current_user), db: Session = Depends(get_db)
|
||||
) -> dict:
|
||||
"""Per-user sync progress: how far the channels *this* user subscribes to have synced."""
|
||||
sub_join = and_(
|
||||
Subscription.channel_id == Channel.id, Subscription.user_id == user.id
|
||||
)
|
||||
total, details_synced, recent_synced, deep_done = db.execute(
|
||||
select(
|
||||
func.count(Channel.id),
|
||||
func.count(Channel.details_synced_at),
|
||||
func.count(Channel.recent_synced_at),
|
||||
func.sum(case((Channel.backfill_done.is_(True), 1), else_=0)),
|
||||
)
|
||||
.select_from(Channel)
|
||||
.join(Subscription, sub_join)
|
||||
).one()
|
||||
total = total or 0
|
||||
deep_done = int(deep_done or 0)
|
||||
my_videos = db.scalar(
|
||||
select(func.count(Video.id)).join(
|
||||
Subscription,
|
||||
and_(
|
||||
Subscription.channel_id == Video.channel_id,
|
||||
Subscription.user_id == user.id,
|
||||
Subscription.hidden.is_(False),
|
||||
),
|
||||
)
|
||||
)
|
||||
return {
|
||||
"channels_total": total,
|
||||
"channels_details_synced": details_synced,
|
||||
"channels_recent_synced": recent_synced,
|
||||
"channels_deep_done": deep_done,
|
||||
"channels_recent_pending": total - recent_synced,
|
||||
"channels_deep_pending": total - deep_done,
|
||||
"my_videos": my_videos or 0,
|
||||
"quota_used_today": quota.units_used_today(db),
|
||||
"quota_remaining_today": quota.remaining_today(db),
|
||||
"paused": state.is_sync_paused(db),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/pause")
|
||||
def pause_sync(
|
||||
user: User = Depends(current_user), db: Session = Depends(get_db)
|
||||
|
|
|
|||
|
|
@ -32,6 +32,66 @@ def list_tags(user: User = Depends(current_user), db: Session = Depends(get_db))
|
|||
]
|
||||
|
||||
|
||||
@router.post("")
|
||||
def create_tag(
|
||||
payload: dict,
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
name = (payload.get("name") or "").strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="name is required")
|
||||
category = payload.get("category") or "other"
|
||||
tag = Tag(
|
||||
user_id=user.id,
|
||||
name=name,
|
||||
color=payload.get("color"),
|
||||
category=category if category in ("language", "topic", "other") else "other",
|
||||
)
|
||||
db.add(tag)
|
||||
db.commit()
|
||||
return {"id": tag.id, "name": tag.name, "color": tag.color, "category": tag.category}
|
||||
|
||||
|
||||
def _own_tag(db: Session, user: User, tag_id: int) -> Tag:
|
||||
tag = db.get(Tag, tag_id)
|
||||
if tag is None or tag.user_id != user.id:
|
||||
# System tags (user_id NULL) are not user-editable.
|
||||
raise HTTPException(status_code=404, detail="Unknown tag")
|
||||
return tag
|
||||
|
||||
|
||||
@router.patch("/{tag_id}")
|
||||
def update_tag(
|
||||
tag_id: int,
|
||||
payload: dict,
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
tag = _own_tag(db, user, tag_id)
|
||||
if "name" in payload:
|
||||
name = (payload.get("name") or "").strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="name cannot be empty")
|
||||
tag.name = name
|
||||
if "color" in payload:
|
||||
tag.color = payload.get("color")
|
||||
db.commit()
|
||||
return {"id": tag.id, "name": tag.name, "color": tag.color, "category": tag.category}
|
||||
|
||||
|
||||
@router.delete("/{tag_id}")
|
||||
def delete_tag(
|
||||
tag_id: int,
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
tag = _own_tag(db, user, tag_id)
|
||||
db.delete(tag) # ChannelTag rows cascade on tag delete.
|
||||
db.commit()
|
||||
return {"deleted": tag_id}
|
||||
|
||||
|
||||
@router.post("/recompute")
|
||||
def recompute(
|
||||
user: User = Depends(current_user),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue