From 4f31ae797d1913696cdabab8fd4c44e25058bf03 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Tue, 21 Jul 2026 21:47:26 +0200 Subject: [PATCH 01/19] fix(auth): rate-limit request-access + close two token races (C-3.5, C-3.11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit C-3.5: /auth/request-access had no rate limiter (unlike register/login/reset/demo) and answered "approved" for an allow-listed email — a public enumeration oracle for the allow-list, and an unthrottled invite/admin-mail firehose. It now rate-limits per client IP and returns a uniform "pending" for every outcome (allowed, pending, new, throttled); an already-allowed user can still just sign in with Google. C-3.11: _consume_token was a read-check-write, so two concurrent requests could both consume the same verify/reset token. It's now a single conditional UPDATE (...WHERE used_at IS NULL AND expires_at >= now RETURNING user_id) — only the request that flips used_at gets a user back. --- backend/app/auth.py | 40 ++++++++++++++++++++++++++++++---------- 1 file changed, 30 insertions(+), 10 deletions(-) diff --git a/backend/app/auth.py b/backend/app/auth.py index cdd8490..cb5b83e 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -8,7 +8,7 @@ from authlib.integrations.starlette_client import OAuth, OAuthError from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request from fastapi.responses import JSONResponse, RedirectResponse from starlette.requests import HTTPConnection -from sqlalchemy import delete, func, select +from sqlalchemy import delete, func, select, update from sqlalchemy.orm import Session from app import email as email_mod @@ -31,6 +31,7 @@ _DECOY_PASSWORD_HASH = hash_password(secrets.token_urlsafe(16)) _register_limiter = RateLimiter(max_events=5, window_seconds=300) _login_limiter = RateLimiter(max_events=10, window_seconds=300) _reset_limiter = RateLimiter(max_events=5, window_seconds=300) +_request_access_limiter = RateLimiter(max_events=5, window_seconds=300) # At most one "your account is suspended" email per address per hour, so a suspended user who # keeps retrying (or a script with their valid credentials) can't be used to mailbomb them. _suspend_email_limiter = RateLimiter(max_events=1, window_seconds=3600) @@ -512,16 +513,26 @@ async def logout(request: Request): @router.post("/request-access") async def request_access( payload: dict, + request: Request, background: BackgroundTasks, db: Session = Depends(get_db), ) -> dict: """Public: ask for access. Idempotent — repeat requests for the same email don't - duplicate or re-spam admins (upsert returns None for an already-pending row).""" + duplicate or re-spam admins (upsert returns None for an already-pending row). + + Answers a uniform ``"pending"`` for EVERY outcome — already-allowed, newly-pending, + already-pending, and rate-limited alike. The old ``"approved"`` reply for an allow-listed + address made this a public enumeration oracle for the allow-list (which the rest of the module + is careful to avoid, cf. demo_login); an already-allowed user can still just sign in with Google. + Rate-limited per client IP like register/login/reset, and a block returns "pending" too so the + endpoint can't be probed for throttling either.""" email = (payload.get("email") or "").strip().lower() if not valid_email(email): raise HTTPException(status_code=400, detail="Enter a valid email address.") + if not _request_access_limiter.allow(_client_ip(request)): + return {"status": "pending"} if is_allowed(db, email): - return {"status": "approved"} # already allowed — just sign in + return {"status": "pending"} # already allowed — don't reveal it inv = upsert_pending_invite(db, email) admins = admin_notify_emails(db) if inv is not None and admins: @@ -583,18 +594,27 @@ def _issue_token(db: Session, user: User, kind: str, ttl: timedelta) -> str: def _consume_token(db: Session, raw: str | None, kind: str) -> User | None: - """Validate + burn a token. Returns its user, or None if missing/expired/used/wrong kind.""" + """Validate + burn a token atomically. Returns its user, or None if missing/expired/used/wrong + kind. The burn is a single conditional UPDATE (…WHERE used_at IS NULL…RETURNING) rather than a + read-check-write, so two concurrent requests can't both consume the same verify/reset token — + only the one whose UPDATE flips used_at gets a row back.""" if not raw: return None + now = datetime.now(timezone.utc) row = db.execute( - select(AuthToken).where( - AuthToken.token_hash == hash_token(raw), AuthToken.kind == kind + update(AuthToken) + .where( + AuthToken.token_hash == hash_token(raw), + AuthToken.kind == kind, + AuthToken.used_at.is_(None), + AuthToken.expires_at >= now, ) - ).scalar_one_or_none() - if row is None or row.used_at is not None or row.expires_at < datetime.now(timezone.utc): - return None - row.used_at = datetime.now(timezone.utc) + .values(used_at=now) + .returning(AuthToken.user_id) + ).first() db.commit() + if row is None: + return None return db.get(User, row.user_id) From dd99206ed24781193e33340f72f2edd435ad1ae3 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Tue, 21 Jul 2026 21:47:26 +0200 Subject: [PATCH 02/19] fix(worker): don't revive a cancelled job when requeuing a busy asset (C-3.10) The asset-busy and claim-fail branches wrote status='queued' unconditionally, so a pause/cancel the route had just written was clobbered back to queued and the job silently revived. New _requeue_if_running does the requeue as a conditional UPDATE (...WHERE status='running'), a no-op once the route has moved the job off 'running'. --- backend/app/worker.py | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/backend/app/worker.py b/backend/app/worker.py index b35d6f1..d808046 100644 --- a/backend/app/worker.py +++ b/backend/app/worker.py @@ -66,6 +66,22 @@ def _job_status(job_id: int) -> str | None: ).scalar() +def _requeue_if_running(job_id: int, phase: str) -> None: + """Put the job back to 'queued' for a later retry, but ONLY while it is still 'running'. The + route flips status to paused/cancelled to stop a job; a blind `status='queued'` here would + clobber that and silently revive a job the user just cancelled. The conditional WHERE makes the + requeue a no-op unless we still own a running job.""" + with SessionLocal() as db: + db.execute( + text( + "UPDATE download_jobs SET status='queued', phase=:phase, updated_at=now() " + "WHERE id=:id AND status='running'" + ), + {"id": job_id, "phase": phase}, + ) + db.commit() + + def _parse_upload_date(raw) -> date | None: if not raw: return None @@ -179,13 +195,13 @@ def _process_job(job_id: int) -> None: return if asset_status == "downloading": # Another worker owns this asset; wait and let the job be reclaimed later. - _set_job(job_id, status="queued", phase="waiting") + _requeue_if_running(job_id, "waiting") time.sleep(2) return # asset_status == "pending": try to win the download / edit. if not _claim_asset(asset_id): - _set_job(job_id, status="queued", phase="waiting") + _requeue_if_running(job_id, "waiting") time.sleep(2) return @@ -414,7 +430,7 @@ def _download(job_id: int, asset_id: int, source_kind: str, source_ref: str, spe ) layout, retention_days = _download_settings() - rel = storage.rel_path(meta, ext, layout) + rel = storage.rel_path(meta, ext, layout, asset_id=asset_id) thumb = _find_thumbnail(staging, produced) storage.place_file(produced, settings.download_root, rel) nfo_ok = storage.write_sidecars(settings.download_root, rel, meta, thumb) From 3dfa35f3964c7b67e78aa1a875656e2f36346d1b Mon Sep 17 00:00:00 2001 From: npeter83 Date: Tue, 21 Jul 2026 21:47:26 +0200 Subject: [PATCH 03/19] perf(notifications): bulk-delete trimmed read notifications (C-4.1) trim_read looped a db.get()+db.delete() per id (N+1). The ids are already in hand, so one DELETE ... WHERE id IN (...) replaces the loop. --- backend/app/notifications.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/app/notifications.py b/backend/app/notifications.py index c4ce120..1e7b6ce 100644 --- a/backend/app/notifications.py +++ b/backend/app/notifications.py @@ -5,7 +5,7 @@ survive reloads/devices and are produced server-side. First producer is the main job. Unread rows never auto-expire; read rows are trimmed past a soft per-user cap so the table can't grow without bound for a heavy user. """ -from sqlalchemy import select +from sqlalchemy import delete, select from sqlalchemy.orm import Session from app.models import Notification @@ -52,7 +52,7 @@ def trim_read(db: Session, user_id: int, cap: int = READ_SOFT_CAP) -> int: ) if not ids: return 0 - for nid in ids: - db.delete(db.get(Notification, nid)) + # One bulk DELETE rather than a get+delete per row (the ids are already in hand). + db.execute(delete(Notification).where(Notification.id.in_(ids))) db.commit() return len(ids) From 0dfc5e9ea222505044514b1ecf124591abe01265 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Tue, 21 Jul 2026 21:47:45 +0200 Subject: [PATCH 04/19] fix(quota): keep the demo account off three quota-burning paths (C-3.6) The demo account could spend real YouTube quota where the human-only guard was missing (the channel_detail enrichment already had it): - channels PATCH: an immediate run_recent_backfill when deep_requested is turned on; - discovery: the channel-detail enrich on the Discover tab; - feed get_video_detail: the off-catalog videos.list lookup for a video we don't store (the DB-hit path stays free; demo now gets a 404 for unknown videos). --- backend/app/routes/channels.py | 12 ++++++++++-- backend/app/routes/feed.py | 5 +++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/backend/app/routes/channels.py b/backend/app/routes/channels.py index dca498e..7919e9a 100644 --- a/backend/app/routes/channels.py +++ b/backend/app/routes/channels.py @@ -180,7 +180,8 @@ def discover_channels( # (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: + # Skip the quota-spending enrich for the demo account (it just sees the stub metadata). + if need and not user.is_demo: try: with quota.attribute(user.id, quota.QuotaAction.CHANNELS_DISCOVER), YouTubeClient(db, user) as yt: apply_channel_details(db, yt.get_channels(need)) @@ -402,7 +403,14 @@ def update_channel( # Opting a channel into full history should give an immediate feed: if we haven't even # fetched its recent uploads yet, do that one channel now (cheap) instead of waiting for # the scheduler. Deep paging still follows on the scheduler's next run (recent-then-deep). - if deep_turned_on and channel is not None and channel.recent_synced_at is None: + # Not for the demo account: it must not be able to trigger an immediate quota-burning backfill + # (the scheduler's deep paths are already human-only). Same guard as channel_detail's enrichment. + if ( + deep_turned_on + and channel is not None + and channel.recent_synced_at is None + and not user.is_demo + ): with quota.attribute(user.id, quota.QuotaAction.VIDEOS_BACKFILL_RECENT): run_recent_backfill(db, [channel], max_channels=1) diff --git a/backend/app/routes/feed.py b/backend/app/routes/feed.py index e5d2952..443ee19 100644 --- a/backend/app/routes/feed.py +++ b/backend/app/routes/feed.py @@ -737,6 +737,11 @@ def get_video_detail( "duration_seconds": v.duration_seconds, } + # The DB path above is free; the off-catalog YouTube lookup below spends a quota unit, so the + # demo account can't reach it — it just sees "unknown" for a video we don't already store. + if user.is_demo: + raise HTTPException(status_code=404, detail="Unknown video") + try: with quota.attribute(user.id, quota.QuotaAction.VIDEOS_LOOKUP), YouTubeClient(db, user) as yt: items = yt.get_videos([video_id]) From 0317f53b1e73fb4b2067e28698d3cc249d37b72f Mon Sep 17 00:00:00 2001 From: npeter83 Date: Tue, 21 Jul 2026 21:47:45 +0200 Subject: [PATCH 05/19] fix(downloads): disambiguate the media path by asset id (C-3.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A MediaAsset is unique on (source_kind, source_ref, format_sig), so the same video in two formats is two assets — but rel_path keyed only on video_id, so they computed the same path: the second download silently overwrote the first, and deleting either removed the shared file. rel_path now takes an optional asset_id and appends "_a{id}" (which Plex ignores — the .nfo sidecar carries the metadata); the worker passes the asset id. Only affects NEW downloads (existing assets keep their stored rel_path). Covered by a new test (two ids → two paths; omitted → the plain name, unchanged). --- backend/app/downloads/storage.py | 15 +++++++++++---- backend/tests/test_storage.py | 10 ++++++++++ 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/backend/app/downloads/storage.py b/backend/app/downloads/storage.py index 1aee7b8..d8e1279 100644 --- a/backend/app/downloads/storage.py +++ b/backend/app/downloads/storage.py @@ -74,16 +74,23 @@ def display_filename(name: str) -> str: return text or "download" -def rel_path(meta: MediaMeta, ext: str, layout: str = "plex") -> str: - """Path of the media file relative to DOWNLOAD_ROOT (forward slashes).""" +def rel_path(meta: MediaMeta, ext: str, layout: str = "plex", asset_id: int | None = None) -> str: + """Path of the media file relative to DOWNLOAD_ROOT (forward slashes). + + `asset_id` disambiguates the file. A MediaAsset is unique on (source_kind, source_ref, + format_sig), so the SAME video downloaded in two formats is two distinct assets — but without + the id they compute the identical path, so the second silently overwrites the first and deleting + either removes the shared file. The `_a{id}` suffix (which Plex ignores — the .nfo sidecar carries + the metadata) keeps them apart. Optional so callers/tests that don't need it keep the plain name.""" title = sanitize(meta.title) vid = meta.video_id + disc = f"_a{asset_id}" if asset_id is not None else "" if layout == "flat": - return f"{title}_[{vid}].{ext}" + return f"{title}_[{vid}]{disc}.{ext}" channel = sanitize(meta.uploader or "Unknown_Channel", 80) year = meta.upload_date.year if meta.upload_date else 0 d = meta.upload_date.isoformat() if meta.upload_date else "0000-00-00" - epname = f"{channel}_-_{d}_-_{title}_[{vid}].{ext}" + epname = f"{channel}_-_{d}_-_{title}_[{vid}]{disc}.{ext}" return f"{channel}/Season_{year}/{epname}" diff --git a/backend/tests/test_storage.py b/backend/tests/test_storage.py index ab2c8e7..f1ce671 100644 --- a/backend/tests/test_storage.py +++ b/backend/tests/test_storage.py @@ -54,6 +54,16 @@ class TestRelPath: out = rel_path(meta, "mp4") assert "Season_0/" in out and "0000-00-00" in out + def test_asset_id_disambiguates_two_formats_of_the_same_video(self): + # Same video, two assets (e.g. 1080p vs 720p) must NOT collide on one path. + a = rel_path(self.meta, "mp4", asset_id=5) + b = rel_path(self.meta, "mp4", asset_id=6) + assert a != b + assert a.endswith("_[abc123]_a5.mp4") and b.endswith("_[abc123]_a6.mp4") + # Omitting it keeps the plain name (unchanged for callers/tests that don't pass one). + assert rel_path(self.meta, "mp4", layout="flat") == "Cool_Clip_[abc123].mp4" + assert rel_path(self.meta, "mp4", layout="flat", asset_id=7) == "Cool_Clip_[abc123]_a7.mp4" + class TestDownloadFilename: def test_appends_the_container_extension(self): From 3d5662df22d3ea6e8c6eea7ff77e578f16fef14d Mon Sep 17 00:00:00 2001 From: npeter83 Date: Tue, 21 Jul 2026 21:47:45 +0200 Subject: [PATCH 06/19] perf(frontend): keep the changelog out of the eager App chunk (C-3.19) App.tsx imported CURRENT_VERSION from releaseNotes, which pulled its ~900-line changelog into the eager App chunk and defeated the lazy ReleaseNotes split. App now uses FRONTEND_VERSION (the built VITE_APP_VERSION, equal to RELEASE_NOTES[0].version by release convention) from lib/version, and CURRENT_VERSION is removed. Verified in the built bundle: the changelog text now lives only in the ReleaseNotes lazy chunk. --- frontend/src/App.tsx | 7 +++++-- frontend/src/lib/releaseNotes.ts | 2 -- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b84dea2..4c4c06d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -35,7 +35,10 @@ import ErrorDialog from "./components/ErrorDialog"; import VersionBanner from "./components/VersionBanner"; import DemoBanner from "./components/DemoBanner"; import { focusAccessRequestsTab } from "./lib/adminUsersTab"; -import { CURRENT_VERSION } from "./lib/releaseNotes"; +// The built app version, NOT imported from releaseNotes — importing anything from that module +// pulls its whole ~900-line changelog into the eager App chunk and defeats the lazy ReleaseNotes +// split. FRONTEND_VERSION equals RELEASE_NOTES[0].version by release convention (both bump together). +import { FRONTEND_VERSION } from "./lib/version"; // The page modules (and their side rails) are code-split per route via the module registry // (components/moduleRegistry). App keeps only the persistent-shell chunks + the channel page and the @@ -389,7 +392,7 @@ export default function App() { banners={ <> {meQuery.data!.is_demo && } - openReleaseNotes(CURRENT_VERSION)} /> + openReleaseNotes(FRONTEND_VERSION)} /> } > diff --git a/frontend/src/lib/releaseNotes.ts b/frontend/src/lib/releaseNotes.ts index c984645..09d8beb 100644 --- a/frontend/src/lib/releaseNotes.ts +++ b/frontend/src/lib/releaseNotes.ts @@ -923,5 +923,3 @@ export const RELEASE_NOTES: ReleaseEntry[] = [ ], }, ]; - -export const CURRENT_VERSION = RELEASE_NOTES[0]?.version ?? "dev"; From f590be2bef67d8a7aebdda1c357ac956c7f8e149 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Tue, 21 Jul 2026 22:29:39 +0200 Subject: [PATCH 07/19] fix(a11y): keyboard-reorder for panel groups (U-3.1.5) PanelGroups registered only a PointerSensor, so its focusable, "reorderable" group grips did nothing from the keyboard. Added KeyboardSensor + sortableKeyboardCoordinates, matching the other sortable lists (Playlists/PlexPlaylistView already had it). --- frontend/src/components/PanelGroups.tsx | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/PanelGroups.tsx b/frontend/src/components/PanelGroups.tsx index be4d270..1b997d4 100644 --- a/frontend/src/components/PanelGroups.tsx +++ b/frontend/src/components/PanelGroups.tsx @@ -2,12 +2,18 @@ import { type ReactNode } from "react"; import { closestCenter, DndContext, + KeyboardSensor, PointerSensor, useSensor, useSensors, type DragEndEvent, } from "@dnd-kit/core"; -import { arrayMove, SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable"; +import { + arrayMove, + SortableContext, + sortableKeyboardCoordinates, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; import type { PanelLayout } from "../lib/panelLayout"; import PanelGroup from "./PanelGroup"; @@ -29,7 +35,12 @@ export default function PanelGroups({ setLayout: (l: PanelLayout) => void; editing: boolean; }) { - const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 4 } })); + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 4 } }), + // The group grips are focusable and call themselves reorderable; without this only a pointer + // could actually move them (keyboard drag matched the other sortable lists). + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), + ); const byId = new Map(items.map((it) => [it.id, it])); const orderedAvailable = layout.order.filter((id) => byId.has(id)); From 94dfa6a34b0d51a8bc09137041d2080375a4e78f Mon Sep 17 00:00:00 2001 From: npeter83 Date: Tue, 21 Jul 2026 22:29:40 +0200 Subject: [PATCH 08/19] fix(player): Escape coordination, focused-Space, light-theme menus (U-C, U-3.1.4, U-3.3.12) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three player/overlay interaction fixes (PlexPlayer changes share a file, hence one commit): - U-C (Escape double-close): useDismiss's Escape now stopPropagation()s (its document listener bubbles before a player's window listener, so one Escape closes only the popover, not the popover AND the player). Modal exports modalCount(); both players' Escape defers when a Modal is open above them, so Escape closes the dialog, not the player under it and then the dialog. - U-3.1.4: PlexPlayer's Space now defers to a focused BUTTON/A (e.g. "Skip intro") instead of toggling playback — matching PlayerModal. - U-3.3.12: the audio/subtitle menus used theme-adaptive glass-menu + forced text-white (white-on-white in light theme); they now use the player's own explicit dark panel (border-white/15 + bg-neutral-900/95), readable in both themes. --- frontend/src/components/Modal.tsx | 7 +++++++ frontend/src/components/PlayerModal.tsx | 4 ++++ frontend/src/components/PlexPlayer.tsx | 13 +++++++++++-- frontend/src/lib/useDismiss.ts | 8 +++++++- 4 files changed, 29 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/Modal.tsx b/frontend/src/components/Modal.tsx index 85906b1..99b5b6b 100644 --- a/frontend/src/components/Modal.tsx +++ b/frontend/src/components/Modal.tsx @@ -8,6 +8,13 @@ import { useBackToClose } from "../lib/history"; let modalStack: number[] = []; let nextModalId = 1; +/** How many s are currently open. The players (which use their own window-level Escape + * listeners, not this stack) check it so their Escape defers to a Modal opened above them — + * otherwise one Escape closes the player UNDER the dialog and then the dialog too (U-C). */ +export function modalCount(): number { + return modalStack.length; +} + // Small centered modal shell (portaled to ): backdrop + ESC + scroll-lock close. export default function Modal({ title, diff --git a/frontend/src/components/PlayerModal.tsx b/frontend/src/components/PlayerModal.tsx index e90e071..1fc3d1d 100644 --- a/frontend/src/components/PlayerModal.tsx +++ b/frontend/src/components/PlayerModal.tsx @@ -23,6 +23,7 @@ import { } from "lucide-react"; import Avatar from "./Avatar"; import AddToPlaylist from "./AddToPlaylist"; +import { modalCount } from "./Modal"; import DownloadButton from "./DownloadButton"; import { api, type Video } from "../lib/api"; import { @@ -375,6 +376,9 @@ export default function PlayerModal({ if (e.key === "Escape") { // In fullscreen, let the browser's Esc exit fullscreen only — don't also close the modal. if (document.fullscreenElement) return; + // Defer to any Modal opened above the player (e.g. the download dialog): its Escape closes + // it, ours would otherwise also fire and close the player underneath it (U-C). + if (modalCount() > 0) return; onClose(); return; } diff --git a/frontend/src/components/PlexPlayer.tsx b/frontend/src/components/PlexPlayer.tsx index 8e9fccd..f445d0d 100644 --- a/frontend/src/components/PlexPlayer.tsx +++ b/frontend/src/components/PlexPlayer.tsx @@ -39,6 +39,7 @@ import { LS, useAccountPersistedObject } from "../lib/storage"; import { useDismiss } from "../lib/useDismiss"; import { useScrollFade } from "../lib/useScrollFade"; import { useBackToClose } from "../lib/history"; +import { modalCount } from "./Modal"; // The rich info overlay (poster/cast/ratings) reuses the same component as the card's info page. const PlexInfo = lazy(() => import("./PlexInfo")); @@ -809,6 +810,12 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) { wake(); // keyboard use also reveals the controls switch (e.key) { case " ": + // Let Space activate a focused button/link natively (e.g. "Skip intro"); only toggle + // playback when the focus isn't on a control — matching PlayerModal's guard. + if (tag === "BUTTON" || tag === "A") return; + e.preventDefault(); + togglePlay(); + break; case "k": e.preventDefault(); togglePlay(); @@ -861,6 +868,8 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) { else handleBack(); break; case "Escape": + // Defer to any Modal opened above the player — let its Escape close it, not ours (U-C). + if (modalCount() > 0) break; if (skipProgressRef.current != null) cancelAutoSkipRef.current(); else if (menuOpenRef.current) { setMenuOpen(false); @@ -1332,7 +1341,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) { style={tracksFade.style} onClick={(e) => e.stopPropagation()} onWheel={(e) => e.stopPropagation()} - className="glass-menu absolute bottom-full right-0 mb-2 w-56 max-h-[60vh] overflow-auto no-scrollbar rounded-xl p-2 text-sm text-white shadow-2xl" + className="border border-white/15 bg-neutral-900/95 absolute bottom-full right-0 mb-2 w-56 max-h-[60vh] overflow-auto no-scrollbar rounded-xl p-2 text-sm text-white shadow-2xl" > {detail.audio_streams.length > 1 && ( <> @@ -1395,7 +1404,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) { ref={menuRef} onWheel={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()} - className="glass-menu absolute bottom-full right-0 mb-2 w-80 rounded-xl p-2 text-sm text-white shadow-2xl" + className="border border-white/15 bg-neutral-900/95 absolute bottom-full right-0 mb-2 w-80 rounded-xl p-2 text-sm text-white shadow-2xl" >
{settingsTabs.map((tab) => ( diff --git a/frontend/src/lib/useDismiss.ts b/frontend/src/lib/useDismiss.ts index 58c7fbe..9845d2f 100644 --- a/frontend/src/lib/useDismiss.ts +++ b/frontend/src/lib/useDismiss.ts @@ -17,7 +17,13 @@ export function useDismiss( if (!list.some((r) => r.current?.contains(target))) onClose(); }; const onKey = (e: KeyboardEvent) => { - if (e.key === "Escape") onClose(); + if (e.key === "Escape") { + // Stop the same Escape from also reaching a player's window-level listener underneath — + // this document listener bubbles first, so one Escape closes only this popover, not the + // popover AND the player behind it. (U-C quick-fix.) + e.stopPropagation(); + onClose(); + } }; document.addEventListener("mousedown", onDown); document.addEventListener("keydown", onKey); From ed4b5e6413cb412452590073d574302449e8d7a0 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Tue, 21 Jul 2026 22:29:40 +0200 Subject: [PATCH 09/19] fix(toast): clear a toast's auto-dismiss timer before re-arming it (C-3.25) A coalesced repeat re-surfaced the toast and armed a new dismiss timer without clearing the old one, so it vanished at the ORIGINAL deadline (6s not the expected 11s). Timers are now tracked in a Map and cleared before re-arming (and on manual dismiss). --- frontend/src/lib/notifications.ts | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/frontend/src/lib/notifications.ts b/frontend/src/lib/notifications.ts index dba09ad..0c31872 100644 --- a/frontend/src/lib/notifications.ts +++ b/frontend/src/lib/notifications.ts @@ -129,6 +129,20 @@ let items: Notification[] = load(); let listeners: Array<() => void> = []; let counter = items.reduce((m, n) => Math.max(m, n.id), 0) + 1; +// Per-toast auto-dismiss timers. Keyed by id so re-arming (a coalesced repeat re-surfacing the same +// toast) can CLEAR the previous timer first — otherwise the old one still fires at the original +// deadline and dismisses the re-surfaced toast early (C-3.25). +const dismissTimers = new Map>(); + +function armDismiss(id: number, duration: number): void { + const existing = dismissTimers.get(id); + if (existing) clearTimeout(existing); + dismissTimers.set( + id, + setTimeout(() => dismiss(id), duration), + ); +} + // Cached derived snapshots — useSyncExternalStore needs stable references between // emits, so we recompute these only when `items` changes. let cachedActive: Notification[] = []; @@ -243,7 +257,7 @@ export function notify(input: NotifyInput): number { : n, ); emit(); - if (duration) setTimeout(() => dismiss(prior.id), duration); + if (duration) armDismiss(prior.id, duration); // Deliberately no re-beep: the first occurrence already sounded; a loop must stay quiet. return prior.id; } @@ -275,12 +289,17 @@ export function notify(input: NotifyInput): number { ) { beep(); } - if (duration) setTimeout(() => dismiss(id), duration); + if (duration) armDismiss(id, duration); return id; } /** Close the transient toast surface; the entry stays in the center's history. */ export function dismiss(id: number): void { + const t = dismissTimers.get(id); + if (t) { + clearTimeout(t); + dismissTimers.delete(id); + } items = items.map((n) => (n.id === id ? { ...n, dismissed: true } : n)); emit(); } From 4f7153bb7de862aa7f1d72b7c795b2eefd25d885 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Tue, 21 Jul 2026 22:37:42 +0200 Subject: [PATCH 10/19] =?UTF-8?q?fix(toast,overlay):=20review=20round=201?= =?UTF-8?q?=20=E2=80=94=20close=20toast-timer=20edge,=20document=20Escape?= =?UTF-8?q?=20limit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review found two low-severity items: - toast: armDismiss now always clears the prior timer (and arms only when duration is truthy), so a coalesced repeat that upgrades a toast to requiresInteraction no longer lets the original timer fire and dismiss it. Was a pre-existing gap the C-3.25 change hadn't closed. - overlay: documented the known limitation of useDismiss's blanket stopPropagation — a PlexPlayer menu open during an auto-skip countdown swallows the Escape that would also cancel the skip. Proper fix needs R8's shared layer-registry (topmost-only dispatch), not a broad stopPropagation; accepted until then. --- frontend/src/lib/notifications.ts | 23 +++++++++++++++-------- frontend/src/lib/useDismiss.ts | 6 ++++++ 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/frontend/src/lib/notifications.ts b/frontend/src/lib/notifications.ts index 0c31872..b57cc7c 100644 --- a/frontend/src/lib/notifications.ts +++ b/frontend/src/lib/notifications.ts @@ -134,13 +134,20 @@ let counter = items.reduce((m, n) => Math.max(m, n.id), 0) + 1; // deadline and dismisses the re-surfaced toast early (C-3.25). const dismissTimers = new Map>(); -function armDismiss(id: number, duration: number): void { +function armDismiss(id: number, duration: number | undefined): void { + // Always clear any existing timer first. A falsy duration then arms nothing — so a toast that a + // coalesced repeat upgraded to requiresInteraction (duration undefined) correctly STAYS, instead + // of the original timer firing and dismissing the now-must-interact toast. const existing = dismissTimers.get(id); - if (existing) clearTimeout(existing); - dismissTimers.set( - id, - setTimeout(() => dismiss(id), duration), - ); + if (existing) { + clearTimeout(existing); + dismissTimers.delete(id); + } + if (duration) + dismissTimers.set( + id, + setTimeout(() => dismiss(id), duration), + ); } // Cached derived snapshots — useSyncExternalStore needs stable references between @@ -257,7 +264,7 @@ export function notify(input: NotifyInput): number { : n, ); emit(); - if (duration) armDismiss(prior.id, duration); + armDismiss(prior.id, duration); // clears the prior timer even when the repeat is now must-interact // Deliberately no re-beep: the first occurrence already sounded; a loop must stay quiet. return prior.id; } @@ -289,7 +296,7 @@ export function notify(input: NotifyInput): number { ) { beep(); } - if (duration) armDismiss(id, duration); + armDismiss(id, duration); return id; } diff --git a/frontend/src/lib/useDismiss.ts b/frontend/src/lib/useDismiss.ts index 9845d2f..94b365b 100644 --- a/frontend/src/lib/useDismiss.ts +++ b/frontend/src/lib/useDismiss.ts @@ -21,6 +21,12 @@ export function useDismiss( // Stop the same Escape from also reaching a player's window-level listener underneath — // this document listener bubbles first, so one Escape closes only this popover, not the // popover AND the player behind it. (U-C quick-fix.) + // + // KNOWN LIMITATION (accepted, → R8 popover-platform): because this is a blanket + // stopPropagation, a PlexPlayer settings/tracks menu open DURING an auto-skip countdown + // swallows the Escape that would otherwise ALSO cancel the skip — Escape closes the menu, + // and the skip proceeds. Properly fixing this needs the shared layer-registry R8 builds + // (topmost-only dispatch), not a broad stopPropagation. e.stopPropagation(); onClose(); } From 58b969146720b037c21ff29588cec65eb3f1647f Mon Sep 17 00:00:00 2001 From: npeter83 Date: Tue, 21 Jul 2026 23:13:53 +0200 Subject: [PATCH 11/19] i18n(a11y): translate the modal + Welcome close buttons and the feed fallback (C-3.22) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit House policy is HU+EN for every user-facing string. Modal's shared close button was a hardcoded title="Close" (in every modal) — now t("common.close") + an aria-label. Welcome's lightbox close aria-label and Feed's "this video" notification fallback are translated too (new feed.thisVideo, HU+EN). The other Close hardcodes the review listed were already fixed since. --- frontend/src/components/Feed.tsx | 4 ++-- frontend/src/components/Modal.tsx | 5 ++++- frontend/src/components/Welcome.tsx | 3 ++- frontend/src/i18n/locales/en/feed.json | 1 + frontend/src/i18n/locales/hu/feed.json | 1 + 5 files changed, 10 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/Feed.tsx b/frontend/src/components/Feed.tsx index 210c1ae..ad46558 100644 --- a/frontend/src/components/Feed.tsx +++ b/frontend/src/components/Feed.tsx @@ -255,7 +255,7 @@ export default function Feed({ meta: { kind: "video-hidden", videoId: id, - title: v?.title ?? "this video", + title: v?.title ?? i18n.t("feed.thisVideo"), channelId: v?.channel_id ?? "", channelName: v?.channel_title ?? "", }, @@ -270,7 +270,7 @@ export default function Feed({ meta: { kind: "video-watched", videoId: id, - title: v?.title ?? "this video", + title: v?.title ?? i18n.t("feed.thisVideo"), channelId: v?.channel_id ?? "", channelName: v?.channel_title ?? "", }, diff --git a/frontend/src/components/Modal.tsx b/frontend/src/components/Modal.tsx index 99b5b6b..805743f 100644 --- a/frontend/src/components/Modal.tsx +++ b/frontend/src/components/Modal.tsx @@ -1,5 +1,6 @@ import { useEffect, useRef, type ReactNode } from "react"; import { createPortal } from "react-dom"; +import { useTranslation } from "react-i18next"; import { X } from "lucide-react"; import { useBackToClose } from "../lib/history"; @@ -27,6 +28,7 @@ export default function Modal({ children: ReactNode; maxWidth?: string; }) { + const { t } = useTranslation(); // Browser/mouse Back closes the topmost modal instead of navigating away. useBackToClose(onClose); @@ -76,7 +78,8 @@ export default function Modal({

{title}