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:
npeter83 2026-06-11 20:45:48 +02:00
parent a178279674
commit bf54b4b5a5
11 changed files with 1063 additions and 45 deletions

View file

@ -26,7 +26,7 @@ from starlette.middleware.sessions import SessionMiddleware
from app import auth
from app.config import settings
from app.routes import feed, health, me, sync, tags
from app.routes import channels, feed, health, me, sync, tags
from app.scheduler import shutdown_scheduler, start_scheduler
@ -65,6 +65,7 @@ app.include_router(sync.router)
app.include_router(tags.router)
app.include_router(feed.router)
app.include_router(me.router)
app.include_router(channels.router)
# The built SPA (populated by the Docker frontend build stage).
STATIC_DIR = Path(__file__).parent / "static_spa"

View 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}

View file

@ -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)

View file

@ -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),