diff --git a/VERSION b/VERSION index d2e2400..23b64a4 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.39.1 +0.40.0 \ No newline at end of file diff --git a/backend/app/downloads/gc.py b/backend/app/downloads/gc.py index 570d130..35536d7 100644 --- a/backend/app/downloads/gc.py +++ b/backend/app/downloads/gc.py @@ -1,12 +1,14 @@ """Download retention garbage collector (a scheduler job). -Three passes each run: +Four passes each run: 1. Pre-expiry warning — for ready assets expiring within the grace window, notify each holder once (gc_notified flag) so a download isn't silently deleted. 2. Expiry deletion — delete assets past their TTL (files + row). The FK SET NULL nulls the holders' jobs' asset_id, so those jobs surface as "expired" (re-downloadable) in the UI. 3. LRU eviction — if a total cache cap is set and exceeded, delete least-recently-accessed ready assets until back under it. + 4. Orphan reap — delete errored, fileless, unreferenced asset rows (they carry no TTL, so + passes 1-3 skip them; benign row-count hygiene). Runs in the API process (which mounts DOWNLOAD_ROOT). Pure disk/DB work — no YouTube quota. """ @@ -58,7 +60,7 @@ def _delete_asset(db: Session, asset: MediaAsset, ntype: str) -> None: def run_download_gc(db: Session) -> dict: now = datetime.now(timezone.utc) grace_days = sysconfig.get_int(db, "download_gc_grace_days") - warned = expired = evicted = 0 + warned = expired = evicted = reaped = 0 # 1. Pre-expiry warnings. warn_before = now + timedelta(days=grace_days) @@ -114,6 +116,22 @@ def run_download_gc(db: Session) -> dict: evicted += 1 db.commit() - result = {"warned": warned, "expired": expired, "evicted": evicted} + # 4. Reap orphaned errored rows (no file, no holder). get_or_create_asset resets a still- + # referenced errored row to pending on re-enqueue, so these only accumulate once every + # holder is gone; they carry no expires_at, so passes 1-3 never touch them. + orphans = db.execute( + select(MediaAsset).where( + MediaAsset.status == "error", + MediaAsset.ref_count == 0, + MediaAsset.rel_path.is_(None), + ) + ).scalars().all() + for asset in orphans: + db.delete(asset) # no file to unlink; FK SET NULL clears any stray job.asset_id + reaped += 1 + if orphans: + db.commit() + + result = {"warned": warned, "expired": expired, "evicted": evicted, "reaped": reaped} log.info("download_gc %s", result) return result diff --git a/backend/app/routes/channels.py b/backend/app/routes/channels.py index bdb5230..38b5dff 100644 --- a/backend/app/routes/channels.py +++ b/backend/app/routes/channels.py @@ -175,15 +175,21 @@ def discover_channels( rows = _discovery_rows(db, user) - # Enrich stub channels' metadata (uses the API key — no write scope needed). Re-read - # the rows afterwards so the response carries the fresh subscriber counts/thumbnails. + # Enrich stub channels' metadata (uses the API key — no write scope needed). The Channel + # objects are identity-mapped, so apply_channel_details mutates the very rows we already hold + # (fresh subscriber counts/thumbnails carry through without a re-query). Only the title can + # change, which affects the tie-break order → re-sort in Python instead of re-hitting the DB. need = [ch.id for ch, _, _ in rows if ch.details_synced_at is None][:DISCOVERY_ENRICH_CAP] if need: try: with quota.attribute(user.id, quota.QuotaAction.CHANNELS_DISCOVER), YouTubeClient(db, user) as yt: apply_channel_details(db, yt.get_channels(need)) db.commit() - rows = _discovery_rows(db, user) + # Match _discovery_rows' ORDER BY exactly: video-count DESC, then title (NULLs LAST, + # as Postgres orders them — coercing NULL→"" would wrongly float untitled channels up). + rows = sorted( + rows, key=lambda r: (-int(r[1]), r[0].title is None, (r[0].title or "").lower()) + ) except YouTubeError as exc: log.warning("discovery: channel enrich failed (user %s): %s", user.id, exc) db.rollback() @@ -418,11 +424,18 @@ def reset_backfill( channel = db.get(Channel, channel_id) if channel is None: raise HTTPException(status_code=404, detail="Unknown channel") - sub = _user_subscription(db, user, channel_id) channel.backfill_done = False channel.backfill_cursor = None channel.recent_synced_at = None - sub.deep_requested = True + # Re-arm the demand-driven deep scheduler for this channel. run_deep_backfill gates on an + # EXISTS over subscriptions with deep_requested=True, so flip it on EVERY subscriber rather + # than requiring the admin to be personally subscribed (they reset from the manager, not + # their own feed). No-op row count if the channel has no subscribers. + db.execute( + update(Subscription) + .where(Subscription.channel_id == channel_id) + .values(deep_requested=True) + ) db.commit() with quota.attribute(user.id, quota.QuotaAction.VIDEOS_BACKFILL_RECENT): run_recent_backfill(db, [channel], max_channels=1) @@ -455,25 +468,30 @@ def subscribe( ).scalar_one_or_none() if existing is not None: raise HTTPException(status_code=409, detail="Already subscribed to this channel.") - try: - with quota.attribute(user.id, quota.QuotaAction.CHANNELS_SUBSCRIBE), YouTubeClient(db, user) as yt: + with quota.attribute(user.id, quota.QuotaAction.CHANNELS_SUBSCRIBE), YouTubeClient(db, user) as yt: + try: yt_sub_id = yt.insert_subscription(channel_id) - # Enrich a stub channel (title/thumbnail/subscriber count) so it shows up - # properly right away; no video pull here. - if channel.details_synced_at is None: + except YouTubeError as exc: + # Already following on YouTube but not recorded here (a desync — e.g. a stale import + # dropped the local row, so it resurfaced in discovery). That's not a failure: the end + # state the user wanted already holds, so record it locally and move on. The next + # subscription resync fills in the resource id. Any other YouTube error is a real + # problem — surface it with a clear message (422, not a 502 that the client would + # mistake for a transient "connection lost"). + msg = str(exc).lower() + if "already exists" in msg or "duplicate" in msg: + yt_sub_id = "" + else: + raise HTTPException(status_code=422, detail=f"YouTube couldn't subscribe you: {exc}") + # Enrich a stub channel (title/thumbnail/subscriber count) so it shows up properly right + # away, on BOTH the normal and the desync path — a resurfaced stub still needs metadata to + # render. Keep the client open (we're still inside the `with`); a metadata failure here + # must not block recording the subscription the user asked for. + if channel.details_synced_at is None: + try: apply_channel_details(db, yt.get_channels([channel_id])) - except YouTubeError as exc: - # Already following on YouTube but not recorded here (a desync — e.g. a stale import - # dropped the local row, so it resurfaced in discovery). That's not a failure: the end - # state the user wanted already holds, so record it locally and move on. The next - # subscription resync fills in the resource id. Any other YouTube error is a real - # problem — surface it with a clear message (422, not a 502 that the client would - # mistake for a transient "connection lost"). - msg = str(exc).lower() - if "already exists" in msg or "duplicate" in msg: - yt_sub_id = "" - else: - raise HTTPException(status_code=422, detail=f"YouTube couldn't subscribe you: {exc}") + except YouTubeError as exc: + log.warning("subscribe: stub enrich failed (%s): %s", channel_id, exc) db.add( Subscription( user_id=user.id, diff --git a/backend/app/routes/downloads.py b/backend/app/routes/downloads.py index 71153d5..3317130 100644 --- a/backend/app/routes/downloads.py +++ b/backend/app/routes/downloads.py @@ -265,7 +265,7 @@ def enqueue_download( db, user.id, source_kind, source_ref, spec, profile_id, display_name ) except quota.QuotaExceeded as e: - raise HTTPException(status_code=422, detail=_quota_message(e)) + raise HTTPException(status_code=422, detail=_quota_detail(e)) return _serialize_job(db, job) @@ -288,27 +288,16 @@ def enqueue_edit( try: job = service.enqueue_edit(db, user.id, source_job, spec, display_name) except quota.QuotaExceeded as e: - raise HTTPException(status_code=422, detail=_quota_message(e)) + raise HTTPException(status_code=422, detail=_quota_detail(e)) except service.EditError as e: - raise HTTPException(status_code=400, detail=_edit_message(e)) + raise HTTPException(status_code=400, detail={"code": "edit", "reason": e.reason}) return _serialize_job(db, job) -def _edit_message(e: service.EditError) -> str: - if e.reason == "empty_edit": - return "Choose a trim range or crop area first." - if e.reason == "source_not_ready": - return "The source download isn't ready to edit." - return "That edit couldn't be created." - - -def _quota_message(e: quota.QuotaExceeded) -> str: - if e.reason == "max_jobs": - return f"You've reached your download limit ({e.limit} items). Remove some first." - if e.reason == "max_bytes": - gb = e.limit / 1_073_741_824 - return f"You've used your download storage quota ({gb:.1f} GB). Free up space first." - return "Download quota exceeded." +def _quota_detail(e: quota.QuotaExceeded) -> dict: + """Structured error the trilingual client localizes (reason key + numbers), instead of a + pre-baked English string with a dot-decimal GB value. See lib/api.ts localizeDetail().""" + return {"code": "quota", "reason": e.reason, "limit": e.limit, "current": e.current} @router.get("") diff --git a/backend/app/routes/messages.py b/backend/app/routes/messages.py index 6089550..de41d13 100644 --- a/backend/app/routes/messages.py +++ b/backend/app/routes/messages.py @@ -17,7 +17,7 @@ from datetime import datetime, timezone from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect from pydantic import BaseModel -from sqlalchemy import and_, func, or_, select +from sqlalchemy import and_, case, func, or_, select from sqlalchemy.orm import Session from app.auth import require_human, resolved_user_id @@ -220,25 +220,40 @@ def list_conversations( with the latest message + my unread count, newest first. Lazily creates the welcome on the first open. User-message previews are ciphertext — the client decrypts them.""" ensure_welcome(db, user) - msgs = ( + # The conversation partner for a message, from this user's perspective (system → 0). + partner_expr = case( + (Message.kind == "system", SYSTEM_PARTNER_ID), + (Message.sender_id == user.id, Message.recipient_id), + else_=Message.sender_id, + ) + mine = or_(Message.sender_id == user.id, Message.recipient_id == user.id) + # Latest message per conversation in ONE query (DISTINCT ON, Postgres) instead of loading the + # user's whole message history into Python — bounded by the number of conversations. + latest = ( db.execute( select(Message) - .where(or_(Message.sender_id == user.id, Message.recipient_id == user.id)) - .order_by(Message.created_at.desc()) + .where(mine) + .distinct(partner_expr) + .order_by(partner_expr, Message.created_at.desc()) ) .scalars() .all() ) - convs: dict[int, dict] = {} - for m in msgs: + # Unread incoming, grouped per conversation. + unread = dict( + db.execute( + select(partner_expr, func.count()) + .where(mine, Message.recipient_id == user.id, Message.read_at.is_(None)) + .group_by(partner_expr) + ).all() + ) + + def _pid(m: Message) -> int: if m.kind == "system": - pid = SYSTEM_PARTNER_ID - else: - pid = m.recipient_id if m.sender_id == user.id else m.sender_id - c = convs.setdefault(pid, {"last": m, "unread": 0}) - if m.recipient_id == user.id and m.read_at is None: - c["unread"] += 1 - user_ids = [pid for pid in convs if pid != SYSTEM_PARTNER_ID] + return SYSTEM_PARTNER_ID + return m.recipient_id if m.sender_id == user.id else m.sender_id + + user_ids = [_pid(m) for m in latest if m.kind != "system"] partners = {} if user_ids: partners = { @@ -246,7 +261,8 @@ def list_conversations( for u in db.execute(select(User).where(User.id.in_(user_ids))).scalars().all() } items = [] - for pid, c in convs.items(): + for m in latest: + pid = _pid(m) if pid == SYSTEM_PARTNER_ID: partner = _system_partner() elif pid in partners: @@ -254,7 +270,7 @@ def list_conversations( else: continue items.append( - {"partner": partner, "last_message": _serialize_msg(c["last"]), "unread": c["unread"]} + {"partner": partner, "last_message": _serialize_msg(m), "unread": int(unread.get(pid, 0))} ) items.sort(key=lambda x: x["last_message"]["created_at"] or "", reverse=True) return {"items": items} @@ -275,9 +291,6 @@ def get_thread( cond = and_(Message.recipient_id == user.id, Message.kind == "system") else: p = db.get(User, partner_id) - if p is None: - raise HTTPException(status_code=404, detail="User not found") - partner = _serialize_user(p) cond = and_( Message.kind == "user", or_( @@ -285,6 +298,13 @@ def get_thread( and_(Message.sender_id == partner_id, Message.recipient_id == user.id), ), ) + # MB2: don't leak arbitrary user identities. Resolve a partner only if they're messageable + # OR we already share a thread (so a conversation with a since-suspended/deactivated user + # still opens). Otherwise the same 404 as a non-existent id → no enumeration oracle. + has_history = db.scalar(select(Message.id).where(cond).limit(1)) + if p is None or (not is_messageable_user(p) and not has_history): + raise HTTPException(status_code=404, detail="User not found") + partner = _serialize_user(p) msgs = ( db.execute(select(Message).where(cond).order_by(Message.created_at.asc())) .scalars() @@ -299,6 +319,10 @@ def get_thread( changed = True if changed: db.commit() + # MB3: ping this user's OTHER tabs/devices that their unread dropped so the badge + # refreshes live instead of waiting for the next 20s poll. The client re-fetches the + # count on this signal, so no number is carried here. + manager.push(user.id, {"type": "unread"}) return {"partner": partner, "items": [_serialize_msg(m) for m in msgs]} diff --git a/backend/app/routes/playlists.py b/backend/app/routes/playlists.py index e81a2c9..e37d625 100644 --- a/backend/app/routes/playlists.py +++ b/backend/app/routes/playlists.py @@ -92,19 +92,12 @@ def _remove_item(db: Session, pl: Playlist, video_id: str, *, mark_dirty: bool) def _summary( - db: Session, pl: Playlist, count: int, total_duration: int = 0, has_video: bool | None = None, + cover: str | None = None, ) -> dict: - cover = db.scalar( - select(Video.thumbnail_url) - .join(PlaylistItem, PlaylistItem.video_id == Video.id) - .where(PlaylistItem.playlist_id == pl.id) - .order_by(PlaylistItem.position) - .limit(1) - ) out = { "id": pl.id, "name": pl.name, @@ -121,18 +114,6 @@ def _summary( return out -def _total_duration(db: Session, playlist_id: int) -> int: - return ( - db.scalar( - select(func.coalesce(func.sum(Video.duration_seconds), 0)) - .select_from(PlaylistItem) - .join(Video, Video.id == PlaylistItem.video_id) - .where(PlaylistItem.playlist_id == playlist_id) - ) - or 0 - ) - - @router.get("") def list_playlists( contains: str | None = None, @@ -183,13 +164,34 @@ def list_playlists( .scalars() .all() ) + # Batch the cover thumbnails (first item per playlist by position) in ONE window query + # instead of a per-playlist SELECT inside _summary (the old N+1). + rn = func.row_number().over( + partition_by=PlaylistItem.playlist_id, + order_by=PlaylistItem.position, + ).label("rn") + first_items = ( + select( + PlaylistItem.playlist_id.label("pid"), + Video.thumbnail_url.label("thumb"), + rn, + ) + .join(Video, Video.id == PlaylistItem.video_id) + .where(PlaylistItem.playlist_id.in_(ids)) + .subquery() + ) + covers = dict( + db.execute( + select(first_items.c.pid, first_items.c.thumb).where(first_items.c.rn == 1) + ).all() + ) return [ _summary( - db, p, counts.get(p.id, 0), durations.get(p.id, 0), (p.id in member) if contains else None, + covers.get(p.id), ) for p in pls ] @@ -207,7 +209,7 @@ def create_playlist( pl = Playlist(user_id=user.id, name=name, position=_next_playlist_position(db, user.id)) db.add(pl) db.commit() - return _summary(db, pl, 0) + return _summary(pl, 0) def _watch_later(db: Session, user: User) -> Playlist: @@ -350,13 +352,23 @@ def push( ) try: with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PUSH): - plan = plan_push(db, user, pl) + # Read the live playlist ONCE (linked playlists only) and share it with both the + # plan and the push, halving read-quota and closing the second read→write TOCTOU + # window. A fresh export (no yt_playlist_id) reads nothing. + live = None + if pl.yt_playlist_id: + with YouTubeClient(db, user) as yt: + live = ( + list(yt.iter_playlist_items_with_ids(pl.yt_playlist_id)), + yt.get_playlist_snippet(pl.yt_playlist_id), + ) + plan = plan_push(db, user, pl, live=live) if plan["units_estimate"] > quota.remaining_today(db): raise HTTPException( status_code=429, detail="Not enough YouTube quota left today for this sync.", ) - return push_playlist(db, user, pl) + return push_playlist(db, user, pl, live=live) except YouTubeError: db.rollback() raise HTTPException(status_code=422, detail="YouTube playlist sync failed.") @@ -405,10 +417,25 @@ def rename_playlist( pl.name = name recompute_dirty(db, pl) db.commit() - count = db.scalar( - select(func.count()).where(PlaylistItem.playlist_id == pl.id) + # One aggregate for count + duration (outer join so an empty playlist still returns 0), + # plus a single cover lookup — was 3 separate round-trips. + count, total = db.execute( + select( + func.count(PlaylistItem.id), + func.coalesce(func.sum(Video.duration_seconds), 0), + ) + .select_from(PlaylistItem) + .outerjoin(Video, Video.id == PlaylistItem.video_id) + .where(PlaylistItem.playlist_id == pl.id) + ).one() + cover = db.scalar( + select(Video.thumbnail_url) + .join(PlaylistItem, PlaylistItem.video_id == Video.id) + .where(PlaylistItem.playlist_id == pl.id) + .order_by(PlaylistItem.position) + .limit(1) ) - return _summary(db, pl, count or 0, _total_duration(db, pl.id)) + return _summary(pl, count or 0, total or 0, cover=cover) @router.delete("/{playlist_id}") diff --git a/backend/app/routes/search.py b/backend/app/routes/search.py index d744714..b5489dd 100644 --- a/backend/app/routes/search.py +++ b/backend/app/routes/search.py @@ -60,6 +60,11 @@ def _classify_shorts(db: Session, videos: list[Video]) -> None: for v in videos: if v.shorts_probed: continue + if v.enriched_at is None: + # Enrichment failed (proxy blip) or YouTube omitted the row — duration is unknown, + # so DON'T finalise shorts_probed off a null duration; leave it for the scheduler + # (which only classifies enriched rows) to retry. Otherwise a real Short would leak. + continue if ( v.live_status != "none" or v.duration_seconds is None @@ -164,7 +169,13 @@ def _ingest_candidates( # 4) Confirm/deny Shorts (no quota) so none enter via search. _classify_shorts(db, videos) - keep = {v.id for v in videos if not v.is_short and v.live_status not in LIVE_OR_UPCOMING} + keep = { + v.id + for v in videos + if v.enriched_at is not None + and not v.is_short + and v.live_status not in LIVE_OR_UPCOMING + } return [vid for vid in ids if vid in keep] diff --git a/backend/app/sync/playlists.py b/backend/app/sync/playlists.py index 9634a99..59dbdc0 100644 --- a/backend/app/sync/playlists.py +++ b/backend/app/sync/playlists.py @@ -217,11 +217,12 @@ def _reorder_moves(current: list[str], desired: list[str]) -> int: return moves -def plan_push(db: Session, user: User, pl: Playlist) -> dict: +def plan_push(db: Session, user: User, pl: Playlist, *, live=None) -> dict: """Compute (without writing) what pushing this local playlist to YouTube would do: create vs update, how many inserts/deletes/reorders, the quota estimate, and whether YouTube has diverged (items present there that local will remove). Costs read units - (1/page) for a linked playlist; nothing for a fresh export.""" + (1/page) for a linked playlist; nothing for a fresh export. `live` = a pre-fetched + (current_items, snippet) tuple so the caller can share one read with push_playlist.""" desired = _local_video_ids(db, pl) if not pl.yt_playlist_id: ops = 1 + len(desired) # create the playlist + one insert per video @@ -234,9 +235,12 @@ def plan_push(db: Session, user: User, pl: Playlist) -> dict: "yt_extra": 0, "units_estimate": ops * WRITE_UNIT_COST, } - with YouTubeClient(db, user) as yt: - current = list(yt.iter_playlist_items_with_ids(pl.yt_playlist_id)) - snippet = yt.get_playlist_snippet(pl.yt_playlist_id) + if live is not None: + current, snippet = live + else: + with YouTubeClient(db, user) as yt: + current = list(yt.iter_playlist_items_with_ids(pl.yt_playlist_id)) + snippet = yt.get_playlist_snippet(pl.yt_playlist_id) rename = bool(snippet) and (snippet.get("title") or "") != pl.name cur_vids = [it["video_id"] for it in current] cur_set = set(cur_vids) @@ -259,10 +263,11 @@ def plan_push(db: Session, user: User, pl: Playlist) -> dict: } -def push_playlist(db: Session, user: User, pl: Playlist) -> dict: +def push_playlist(db: Session, user: User, pl: Playlist, *, live=None) -> dict: """Push a local playlist to YouTube: create it if needed, then reconcile membership and order so YouTube matches local (local wins). Marks the playlist clean on success. - Caller must have checked the write scope and the quota budget.""" + Caller must have checked the write scope and the quota budget. `live` = a pre-fetched + (current_items, snippet) tuple (from plan_push's read) to avoid a second live page-through.""" desired = _local_video_ids(db, pl) inserted = deleted = reordered = created = 0 failures: list[str] = [] @@ -291,14 +296,17 @@ def push_playlist(db: Session, user: User, pl: Playlist) -> dict: "yt_playlist_id": pl.yt_playlist_id, } - # Existing link: diff against the live YouTube state. - snippet = yt.get_playlist_snippet(pl.yt_playlist_id) + # Existing link: diff against the live YouTube state (reuse plan_push's read if given). + if live is not None: + current, snippet = live + else: + snippet = yt.get_playlist_snippet(pl.yt_playlist_id) + current = list(yt.iter_playlist_items_with_ids(pl.yt_playlist_id)) if snippet is not None and (snippet.get("title") or "") != pl.name: try: yt.update_playlist_title(pl.yt_playlist_id, pl.name) except YouTubeError: failures.append("title") - current = list(yt.iter_playlist_items_with_ids(pl.yt_playlist_id)) item_id_by_vid = {it["video_id"]: it["item_id"] for it in current} cur_vids = [it["video_id"] for it in current] desired_set = set(desired) diff --git a/backend/app/sysconfig.py b/backend/app/sysconfig.py index 6a6742a..8264fb5 100644 --- a/backend/app/sysconfig.py +++ b/backend/app/sysconfig.py @@ -25,6 +25,7 @@ class ConfigSpec: secret: bool = False min: int | None = None max: int | None = None + allow_empty: bool = False # str keys: a stored "" is an explicit empty value, not "unset" # Registry of DB-overridable keys. Add a key here + wire its read through get_*() to move it @@ -33,7 +34,7 @@ SPECS: tuple[ConfigSpec, ...] = ( # --- Email / SMTP (one logical group; the password is the only secret) --- ConfigSpec("smtp_host", "str", "email", "smtp_host"), ConfigSpec("smtp_port", "int", "email", "smtp_port", min=1, max=65535), - ConfigSpec("smtp_user", "str", "email", "smtp_user"), + ConfigSpec("smtp_user", "str", "email", "smtp_user", allow_empty=True), ConfigSpec("smtp_from", "str", "email", "smtp_from"), ConfigSpec("smtp_password", "str", "email", "smtp_password", secret=True), # --- Quota (shared YouTube Data API daily budget) --- @@ -59,7 +60,7 @@ SPECS: tuple[ConfigSpec, ...] = ( # --- YouTube Data API key (secret; optional — public reads prefer it over OAuth) --- ConfigSpec("youtube_api_key", "str", "youtube", "youtube_api_key", secret=True), # Optional egress proxy for YouTube API calls (fixed-IP host for an IP-restricted key). - ConfigSpec("youtube_api_proxy", "str", "youtube", "youtube_api_proxy"), + ConfigSpec("youtube_api_proxy", "str", "youtube", "youtube_api_proxy", allow_empty=True), # --- Google OAuth client (Sign in with Google; both encrypted, env fallback). Set from the # install wizard / Configuration page so no restart is needed when the creds change. --- ConfigSpec("google_client_id", "str", "google", "google_client_id", secret=True), @@ -124,7 +125,12 @@ def get(db: Session, key: str): """Effective value: the DB override (typed) if set, else the env/config default.""" s = _BY_KEY[key] raw = _raw_override(db, key) - if raw is None or raw == "": + if raw is None: + return _default(s) + if raw == "" and not s.allow_empty: + # For most keys an empty override means "fall back to the env default". Keys that opt + # into allow_empty (e.g. smtp_user, youtube_api_proxy) treat "" as a real value — so an + # admin can blank one to DISABLE it rather than being stuck with the env default. return _default(s) try: return _coerce(s, raw) @@ -197,6 +203,7 @@ def describe(db: Session) -> dict: "min": s.min, "max": s.max, "is_set": is_set, # an override row exists + "allow_empty": s.allow_empty, # if true, the UI stores "" rather than deleting the row } if s.secret: item["default_is_set"] = bool(_default(s)) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 68d1339..a498575 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -275,8 +275,10 @@ export default function App() { // "Focus this channel in the manager": jump to the Channels page and seed its name filter // so the channel is isolated. Cleared when leaving the page so it doesn't re-apply later. const [focusChannelName, setFocusChannelName] = useState(null); + const [focusChannelToken, setFocusChannelToken] = useState(0); const focusChannel = (name: string) => { setFocusChannelName(name); + setFocusChannelToken((n) => n + 1); // re-seed even when the same channel is re-focused setChannelsView("subscribed"); // the name filter applies to the subscriptions table setPage("channels"); }; @@ -803,6 +805,7 @@ export default function App() { canWrite={meQuery.data!.can_write} isAdmin={meQuery.data!.role === "admin"} focusChannelName={focusChannelName} + focusChannelToken={focusChannelToken} onFocusChannel={focusChannel} statusFilter={channelFilter} setStatusFilter={setChannelFilter} diff --git a/frontend/src/components/AdminUsers.tsx b/frontend/src/components/AdminUsers.tsx index 20b69af..9c56ba2 100644 --- a/frontend/src/components/AdminUsers.tsx +++ b/frontend/src/components/AdminUsers.tsx @@ -52,19 +52,32 @@ function UsersRoles({ me }: { me: Me }) { const qc = useQueryClient(); const confirm = useConfirm(); const q = useQuery({ queryKey: ["admin-users"], queryFn: api.adminUsers }); + // Which rows have an action in flight, so we disable ONLY those rows' buttons (SC1) — a Set, since + // with per-row disabling the admin can now start actions on several rows concurrently. + const [pendingIds, setPendingIds] = useState>(new Set()); + const startPending = (id: number) => setPendingIds((s) => new Set(s).add(id)); + const endPending = (id: number) => + setPendingIds((s) => { + const n = new Set(s); + n.delete(id); + return n; + }); const setRole = useMutation({ mutationFn: ({ id, role }: { id: number; role: "user" | "admin"; email: string }) => api.setUserRole(id, role), + onMutate: (vars) => startPending(vars.id), onSuccess: (_d, vars) => { qc.invalidateQueries({ queryKey: ["admin-users"] }); notify({ level: "success", message: t("users.roles.updated", { email: vars.email }) }); }, + onSettled: (_d, _e, vars) => endPending(vars.id), // Errors (e.g. last-admin / self) surface via the global error dialog with the server's detail. }); const suspend = useMutation({ mutationFn: ({ id, suspended }: { id: number; suspended: boolean; email: string }) => api.setUserSuspended(id, suspended), + onMutate: (vars) => startPending(vars.id), onSuccess: (_d, vars) => { qc.invalidateQueries({ queryKey: ["admin-users"] }); notify({ @@ -74,14 +87,17 @@ function UsersRoles({ me }: { me: Me }) { }), }); }, + onSettled: (_d, _e, vars) => endPending(vars.id), // Guards (demo / self / last admin) surface via the global error dialog. }); const del = useMutation({ mutationFn: ({ id }: { id: number; email: string }) => api.adminDeleteUser(id), + onMutate: (vars) => startPending(vars.id), onSuccess: (_d, vars) => { qc.invalidateQueries({ queryKey: ["admin-users"] }); notify({ level: "success", message: t("users.delete.done", { email: vars.email }) }); }, + onSettled: (_d, _e, vars) => endPending(vars.id), }); const onToggle = async (u: AdminUserRow) => { @@ -162,7 +178,7 @@ function UsersRoles({ me }: { me: Me }) { >