Merge: promote dev to prod

This commit is contained in:
2026-07-22 00:34:57 +02:00
25 changed files with 249 additions and 42 deletions
+1 -1
View File
@@ -1 +1 @@
0.49.0 0.50.0
+30 -10
View File
@@ -8,7 +8,7 @@ from authlib.integrations.starlette_client import OAuth, OAuthError
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request
from fastapi.responses import JSONResponse, RedirectResponse from fastapi.responses import JSONResponse, RedirectResponse
from starlette.requests import HTTPConnection from starlette.requests import HTTPConnection
from sqlalchemy import delete, func, select from sqlalchemy import delete, func, select, update
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app import email as email_mod 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) _register_limiter = RateLimiter(max_events=5, window_seconds=300)
_login_limiter = RateLimiter(max_events=10, window_seconds=300) _login_limiter = RateLimiter(max_events=10, window_seconds=300)
_reset_limiter = RateLimiter(max_events=5, 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 # 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. # 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) _suspend_email_limiter = RateLimiter(max_events=1, window_seconds=3600)
@@ -512,16 +513,26 @@ async def logout(request: Request):
@router.post("/request-access") @router.post("/request-access")
async def request_access( async def request_access(
payload: dict, payload: dict,
request: Request,
background: BackgroundTasks, background: BackgroundTasks,
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
"""Public: ask for access. Idempotent — repeat requests for the same email don't """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() email = (payload.get("email") or "").strip().lower()
if not valid_email(email): if not valid_email(email):
raise HTTPException(status_code=400, detail="Enter a valid email address.") 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): 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) inv = upsert_pending_invite(db, email)
admins = admin_notify_emails(db) admins = admin_notify_emails(db)
if inv is not None and admins: 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: 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: if not raw:
return None return None
now = datetime.now(timezone.utc)
row = db.execute( row = db.execute(
select(AuthToken).where( update(AuthToken)
AuthToken.token_hash == hash_token(raw), AuthToken.kind == kind .where(
AuthToken.token_hash == hash_token(raw),
AuthToken.kind == kind,
AuthToken.used_at.is_(None),
AuthToken.expires_at >= now,
) )
).scalar_one_or_none() .values(used_at=now)
if row is None or row.used_at is not None or row.expires_at < datetime.now(timezone.utc): .returning(AuthToken.user_id)
return None ).first()
row.used_at = datetime.now(timezone.utc)
db.commit() db.commit()
if row is None:
return None
return db.get(User, row.user_id) return db.get(User, row.user_id)
+17 -6
View File
@@ -58,8 +58,12 @@ def sanitize(name: str, limit: int = _MAX_TITLE) -> str:
text = re.sub(r"\s+", "_", text.strip()) text = re.sub(r"\s+", "_", text.strip())
# Tidy separator pile-ups and trim junk from the ends. # Tidy separator pile-ups and trim junk from the ends.
text = re.sub(r"_{2,}", "_", text).strip("_.-·—–|,;:!?ّ ") text = re.sub(r"_{2,}", "_", text).strip("_.-·—–|,;:!?ّ ")
if len(text) > limit: # Cap by BYTES, not characters: a path component's hard limit is 255 BYTES, and rel_path packs
text = text[:limit].rstrip("_.-") # the (byte-capped) channel + date + id + ext into that same component — so an accented/multi-byte
# title capped only by char count could still overflow to ENAMETOOLONG. Truncate on a UTF-8
# boundary (decode(..., "ignore") drops a trailing partial char) so we never split a character.
if len(text.encode("utf-8")) > limit:
text = text.encode("utf-8")[:limit].decode("utf-8", "ignore").rstrip("_.-")
return text or "untitled" return text or "untitled"
@@ -74,16 +78,23 @@ def display_filename(name: str) -> str:
return text or "download" return text or "download"
def rel_path(meta: MediaMeta, ext: str, layout: str = "plex") -> str: 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).""" """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) title = sanitize(meta.title)
vid = meta.video_id vid = meta.video_id
disc = f"_a{asset_id}" if asset_id is not None else ""
if layout == "flat": if layout == "flat":
return f"{title}_[{vid}].{ext}" return f"{title}_[{vid}]{disc}.{ext}"
channel = sanitize(meta.uploader or "Unknown_Channel", 80) channel = sanitize(meta.uploader or "Unknown_Channel", 80)
year = meta.upload_date.year if meta.upload_date else 0 year = meta.upload_date.year if meta.upload_date else 0
d = meta.upload_date.isoformat() if meta.upload_date else "0000-00-00" 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}" return f"{channel}/Season_{year}/{epname}"
+3 -3
View File
@@ -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 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. 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 sqlalchemy.orm import Session
from app.models import Notification 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: if not ids:
return 0 return 0
for nid in ids: # One bulk DELETE rather than a get+delete per row (the ids are already in hand).
db.delete(db.get(Notification, nid)) db.execute(delete(Notification).where(Notification.id.in_(ids)))
db.commit() db.commit()
return len(ids) return len(ids)
+7 -2
View File
@@ -180,7 +180,8 @@ def discover_channels(
# (fresh subscriber counts/thumbnails carry through without a re-query). Only the title can # (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. # 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] 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: try:
with quota.attribute(user.id, quota.QuotaAction.CHANNELS_DISCOVER), YouTubeClient(db, user) as yt: with quota.attribute(user.id, quota.QuotaAction.CHANNELS_DISCOVER), YouTubeClient(db, user) as yt:
apply_channel_details(db, yt.get_channels(need)) apply_channel_details(db, yt.get_channels(need))
@@ -390,7 +391,10 @@ def update_channel(
sub.priority = int(payload["priority"]) sub.priority = int(payload["priority"])
if "hidden" in payload: if "hidden" in payload:
sub.hidden = bool(payload["hidden"]) sub.hidden = bool(payload["hidden"])
if "deep_requested" in payload: # The demo account is SHARED, so it must not persist deep_requested (a quota-relevant flag) onto
# the shared subscription — skip the write entirely for demo, which also keeps deep_turned_on
# False so the immediate backfill below can never fire for it.
if "deep_requested" in payload and not user.is_demo:
# Opt this channel into (or out of) full-history backfill. The deep scheduler # Opt this channel into (or out of) full-history backfill. The deep scheduler
# picks the flag up on its next run; turning it off won't undo already-fetched # picks the flag up on its next run; turning it off won't undo already-fetched
# videos, it just stops further deep paging if nobody else still wants it. # videos, it just stops further deep paging if nobody else still wants it.
@@ -402,6 +406,7 @@ def update_channel(
# Opting a channel into full history should give an immediate feed: if we haven't even # 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 # 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). # the scheduler. Deep paging still follows on the scheduler's next run (recent-then-deep).
# (deep_turned_on is only ever True for a non-demo user — see the guard above.)
if deep_turned_on and channel is not None and channel.recent_synced_at is None: if deep_turned_on and channel is not None and channel.recent_synced_at is None:
with quota.attribute(user.id, quota.QuotaAction.VIDEOS_BACKFILL_RECENT): with quota.attribute(user.id, quota.QuotaAction.VIDEOS_BACKFILL_RECENT):
run_recent_backfill(db, [channel], max_channels=1) run_recent_backfill(db, [channel], max_channels=1)
+5
View File
@@ -737,6 +737,11 @@ def get_video_detail(
"duration_seconds": v.duration_seconds, "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: try:
with quota.attribute(user.id, quota.QuotaAction.VIDEOS_LOOKUP), YouTubeClient(db, user) as yt: with quota.attribute(user.id, quota.QuotaAction.VIDEOS_LOOKUP), YouTubeClient(db, user) as yt:
items = yt.get_videos([video_id]) items = yt.get_videos([video_id])
+19 -3
View File
@@ -66,6 +66,22 @@ def _job_status(job_id: int) -> str | None:
).scalar() ).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: def _parse_upload_date(raw) -> date | None:
if not raw: if not raw:
return None return None
@@ -179,13 +195,13 @@ def _process_job(job_id: int) -> None:
return return
if asset_status == "downloading": if asset_status == "downloading":
# Another worker owns this asset; wait and let the job be reclaimed later. # 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) time.sleep(2)
return return
# asset_status == "pending": try to win the download / edit. # asset_status == "pending": try to win the download / edit.
if not _claim_asset(asset_id): if not _claim_asset(asset_id):
_set_job(job_id, status="queued", phase="waiting") _requeue_if_running(job_id, "waiting")
time.sleep(2) time.sleep(2)
return 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() 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) thumb = _find_thumbnail(staging, produced)
storage.place_file(produced, settings.download_root, rel) storage.place_file(produced, settings.download_root, rel)
nfo_ok = storage.write_sidecars(settings.download_root, rel, meta, thumb) nfo_ok = storage.write_sidecars(settings.download_root, rel, meta, thumb)
+31
View File
@@ -29,6 +29,14 @@ class TestSanitize:
def test_length_is_capped(self): def test_length_is_capped(self):
assert len(sanitize("x" * 500, limit=10)) <= 10 assert len(sanitize("x" * 500, limit=10)) <= 10
def test_cap_is_by_bytes_not_characters(self):
# Accented (2-byte UTF-8) letters must not blow past the byte limit — the FS component cap
# is 255 BYTES, so a char-only cap would overflow for multi-byte titles.
out = sanitize("á" * 500, limit=10)
assert len(out.encode("utf-8")) <= 10
# And it never splits a character (would raise otherwise / leave a replacement char).
out.encode("utf-8").decode("utf-8")
def test_empty_or_all_junk_falls_back_to_untitled(self): def test_empty_or_all_junk_falls_back_to_untitled(self):
assert sanitize("") == "untitled" assert sanitize("") == "untitled"
assert sanitize("🔥🔥") == "untitled" assert sanitize("🔥🔥") == "untitled"
@@ -54,6 +62,29 @@ class TestRelPath:
out = rel_path(meta, "mp4") out = rel_path(meta, "mp4")
assert "Season_0/" in out and "0000-00-00" in out 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"
def test_every_component_stays_within_the_255_byte_fs_limit(self):
# A long accented channel AND title, plus the asset-id suffix, must not overflow any path
# component (the byte cap in sanitize is what guarantees this).
meta = MediaMeta(
video_id="abcdefghijk",
title="Á" * 300,
uploader="Ö" * 300,
upload_date=date(2024, 5, 2),
)
out = rel_path(meta, "webm", asset_id=999999)
for component in out.split("/"):
assert len(component.encode("utf-8")) <= 255, component
class TestDownloadFilename: class TestDownloadFilename:
def test_appends_the_container_extension(self): def test_appends_the_container_extension(self):
+5 -2
View File
@@ -35,7 +35,10 @@ import ErrorDialog from "./components/ErrorDialog";
import VersionBanner from "./components/VersionBanner"; import VersionBanner from "./components/VersionBanner";
import DemoBanner from "./components/DemoBanner"; import DemoBanner from "./components/DemoBanner";
import { focusAccessRequestsTab } from "./lib/adminUsersTab"; 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 // 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 // (components/moduleRegistry). App keeps only the persistent-shell chunks + the channel page and the
@@ -389,7 +392,7 @@ export default function App() {
banners={ banners={
<> <>
{meQuery.data!.is_demo && <DemoBanner />} {meQuery.data!.is_demo && <DemoBanner />}
<VersionBanner onOpen={() => openReleaseNotes(CURRENT_VERSION)} /> <VersionBanner onOpen={() => openReleaseNotes(FRONTEND_VERSION)} />
</> </>
} }
> >
+4
View File
@@ -120,6 +120,9 @@ export default function ChannelPage({
qc.invalidateQueries({ queryKey: ["feed"] }); qc.invalidateQueries({ queryKey: ["feed"] });
qc.invalidateQueries({ queryKey: ["blocked-channels"] }); qc.invalidateQueries({ queryKey: ["blocked-channels"] });
}, },
// No onError: block/unblock is a local DB op (not a YouTube write, so no 403 "connect" case),
// and its 400/409/422/500 are already surfaced by the global error modal (see api.ts req) —
// a caller toast here would just double it.
}); });
const subscribe = useMutation({ const subscribe = useMutation({
@@ -144,6 +147,7 @@ export default function ChannelPage({
qc.invalidateQueries({ queryKey: ["feed"] }); qc.invalidateQueries({ queryKey: ["feed"] });
qc.invalidateQueries({ queryKey: ["channels"] }); qc.invalidateQueries({ queryKey: ["channels"] });
}, },
onError: (err) => notifyYouTubeActionError(err, t("channels.notify.unsubscribeFailed")),
}); });
const onUnsubscribe = async () => { const onUnsubscribe = async () => {
const ok = await confirm({ const ok = await confirm({
@@ -681,6 +681,8 @@ export default function DownloadCenter() {
? api.cancelDownload(id) ? api.cancelDownload(id)
: api.deleteDownload(id), : api.deleteDownload(id),
onSuccess: invalidate, onSuccess: invalidate,
// No onError: a failed job action returns 400/409/422/500, already surfaced by the global
// error modal (api.ts req) with the backend's specific message — a caller toast would double it.
}); });
// Remove a shared-with-me item from your list (only your grant; the owner's file is untouched). // Remove a shared-with-me item from your list (only your grant; the owner's file is untouched).
+2 -2
View File
@@ -255,7 +255,7 @@ export default function Feed({
meta: { meta: {
kind: "video-hidden", kind: "video-hidden",
videoId: id, videoId: id,
title: v?.title ?? "this video", title: v?.title ?? i18n.t("feed.thisVideo"),
channelId: v?.channel_id ?? "", channelId: v?.channel_id ?? "",
channelName: v?.channel_title ?? "", channelName: v?.channel_title ?? "",
}, },
@@ -270,7 +270,7 @@ export default function Feed({
meta: { meta: {
kind: "video-watched", kind: "video-watched",
videoId: id, videoId: id,
title: v?.title ?? "this video", title: v?.title ?? i18n.t("feed.thisVideo"),
channelId: v?.channel_id ?? "", channelId: v?.channel_id ?? "",
channelName: v?.channel_title ?? "", channelName: v?.channel_title ?? "",
}, },
+11 -1
View File
@@ -1,5 +1,6 @@
import { useEffect, useRef, type ReactNode } from "react"; import { useEffect, useRef, type ReactNode } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { X } from "lucide-react"; import { X } from "lucide-react";
import { useBackToClose } from "../lib/history"; import { useBackToClose } from "../lib/history";
@@ -8,6 +9,13 @@ import { useBackToClose } from "../lib/history";
let modalStack: number[] = []; let modalStack: number[] = [];
let nextModalId = 1; let nextModalId = 1;
/** How many <Modal>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 <body>): backdrop + ESC + scroll-lock close. // Small centered modal shell (portaled to <body>): backdrop + ESC + scroll-lock close.
export default function Modal({ export default function Modal({
title, title,
@@ -20,6 +28,7 @@ export default function Modal({
children: ReactNode; children: ReactNode;
maxWidth?: string; maxWidth?: string;
}) { }) {
const { t } = useTranslation();
// Browser/mouse Back closes the topmost modal instead of navigating away. // Browser/mouse Back closes the topmost modal instead of navigating away.
useBackToClose(onClose); useBackToClose(onClose);
@@ -69,7 +78,8 @@ export default function Modal({
<h2 className="text-lg font-semibold leading-snug">{title}</h2> <h2 className="text-lg font-semibold leading-snug">{title}</h2>
<button <button
onClick={onClose} onClick={onClose}
title="Close" title={t("common.close")}
aria-label={t("common.close")}
className="shrink-0 p-1.5 rounded-lg text-muted hover:text-fg hover:bg-surface transition" className="shrink-0 p-1.5 rounded-lg text-muted hover:text-fg hover:bg-surface transition"
> >
<X className="w-4 h-4" /> <X className="w-4 h-4" />
+13 -2
View File
@@ -2,12 +2,18 @@ import { type ReactNode } from "react";
import { import {
closestCenter, closestCenter,
DndContext, DndContext,
KeyboardSensor,
PointerSensor, PointerSensor,
useSensor, useSensor,
useSensors, useSensors,
type DragEndEvent, type DragEndEvent,
} from "@dnd-kit/core"; } 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 type { PanelLayout } from "../lib/panelLayout";
import PanelGroup from "./PanelGroup"; import PanelGroup from "./PanelGroup";
@@ -29,7 +35,12 @@ export default function PanelGroups({
setLayout: (l: PanelLayout) => void; setLayout: (l: PanelLayout) => void;
editing: boolean; 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 byId = new Map(items.map((it) => [it.id, it]));
const orderedAvailable = layout.order.filter((id) => byId.has(id)); const orderedAvailable = layout.order.filter((id) => byId.has(id));
+4
View File
@@ -23,6 +23,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import Avatar from "./Avatar"; import Avatar from "./Avatar";
import AddToPlaylist from "./AddToPlaylist"; import AddToPlaylist from "./AddToPlaylist";
import { modalCount } from "./Modal";
import DownloadButton from "./DownloadButton"; import DownloadButton from "./DownloadButton";
import { api, type Video } from "../lib/api"; import { api, type Video } from "../lib/api";
import { import {
@@ -375,6 +376,9 @@ export default function PlayerModal({
if (e.key === "Escape") { if (e.key === "Escape") {
// In fullscreen, let the browser's Esc exit fullscreen only — don't also close the modal. // In fullscreen, let the browser's Esc exit fullscreen only — don't also close the modal.
if (document.fullscreenElement) return; 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(); onClose();
return; return;
} }
+11 -2
View File
@@ -39,6 +39,7 @@ import { LS, useAccountPersistedObject } from "../lib/storage";
import { useDismiss } from "../lib/useDismiss"; import { useDismiss } from "../lib/useDismiss";
import { useScrollFade } from "../lib/useScrollFade"; import { useScrollFade } from "../lib/useScrollFade";
import { useBackToClose } from "../lib/history"; 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. // The rich info overlay (poster/cast/ratings) reuses the same component as the card's info page.
const PlexInfo = lazy(() => import("./PlexInfo")); const PlexInfo = lazy(() => import("./PlexInfo"));
@@ -809,6 +810,12 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
wake(); // keyboard use also reveals the controls wake(); // keyboard use also reveals the controls
switch (e.key) { switch (e.key) {
case " ": 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": case "k":
e.preventDefault(); e.preventDefault();
togglePlay(); togglePlay();
@@ -861,6 +868,8 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
else handleBack(); else handleBack();
break; break;
case "Escape": 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(); if (skipProgressRef.current != null) cancelAutoSkipRef.current();
else if (menuOpenRef.current) { else if (menuOpenRef.current) {
setMenuOpen(false); setMenuOpen(false);
@@ -1332,7 +1341,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
style={tracksFade.style} style={tracksFade.style}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
onWheel={(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 && ( {detail.audio_streams.length > 1 && (
<> <>
@@ -1395,7 +1404,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
ref={menuRef} ref={menuRef}
onWheel={(e) => e.stopPropagation()} onWheel={(e) => e.stopPropagation()}
onClick={(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"
> >
<div className="mb-1.5 flex gap-1"> <div className="mb-1.5 flex gap-1">
{settingsTabs.map((tab) => ( {settingsTabs.map((tab) => (
+7 -1
View File
@@ -20,13 +20,19 @@ export default function ReleaseNotes({
highlight?: string; highlight?: string;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
// Ring the newest note if `highlight` isn't one of the listed versions — e.g. a Vite dev build
// where the caller's FRONTEND_VERSION is "dev", or a VERSION bump whose note hasn't landed yet —
// so the banner always rings *something* current instead of nothing.
const active = RELEASE_NOTES.some((r) => r.version === highlight)
? highlight
: RELEASE_NOTES[0]?.version;
return ( return (
<Modal title={t("about.releaseNotes")} onClose={onClose} maxWidth="max-w-2xl"> <Modal title={t("about.releaseNotes")} onClose={onClose} maxWidth="max-w-2xl">
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
{RELEASE_NOTES.map((r) => ( {RELEASE_NOTES.map((r) => (
<section <section
key={r.version} key={r.version}
className={highlight === r.version ? "rounded-xl ring-1 ring-accent/40 p-3 -m-3" : ""} className={active === r.version ? "rounded-xl ring-1 ring-accent/40 p-3 -m-3" : ""}
> >
<div className="flex items-baseline gap-2 flex-wrap"> <div className="flex items-baseline gap-2 flex-wrap">
<h3 className="text-base font-semibold">v{r.version}</h3> <h3 className="text-base font-semibold">v{r.version}</h3>
@@ -533,6 +533,9 @@ function PlexWatchSync() {
} }
}; };
// No onError on these: they're Plex, not YouTube (notifyYouTubeActionError would wrongly say
// "connect YouTube"), and their 409/500 are already surfaced by the global error modal — a caller
// toast would double it. Actual import failures come back as 200 {error}, handled by announce.
const toggle = useMutation({ const toggle = useMutation({
mutationFn: (next: boolean) => api.plexWatchSetLink(next), mutationFn: (next: boolean) => api.plexWatchSetLink(next),
onSuccess: (res) => { onSuccess: (res) => {
+12 -1
View File
@@ -16,12 +16,23 @@ export default function Toaster() {
const toasts = useSyncExternalStore(subscribe, getActiveToasts, getActiveToasts); const toasts = useSyncExternalStore(subscribe, getActiveToasts, getActiveToasts);
return ( return (
<div className="absolute bottom-4 left-4 z-overlay flex flex-col gap-2 w-80 max-w-[calc(100vw-2rem)]"> // Stable, always-mounted polite live region — a polite region must PRE-EXIST in the DOM for a
// screen reader to announce content later inserted into it (a region created together with its
// first message is unreliably announced). `aria-live` (not role="status") on purpose:
// role="status" implies aria-atomic="true", which would re-read the WHOLE stack on every toast;
// aria-live defaults atomic=false, so only the newly-added toast is spoken. Error/fatal toasts
// additionally carry role="alert", which announces assertively on insertion regardless.
<div
aria-live="polite"
className="absolute bottom-4 left-4 z-overlay flex flex-col gap-2 w-80 max-w-[calc(100vw-2rem)]"
>
{toasts.map((toast) => { {toasts.map((toast) => {
const { icon: Icon, color, bar } = LEVEL_STYLE[toast.level]; const { icon: Icon, color, bar } = LEVEL_STYLE[toast.level];
const urgent = toast.level === "error" || toast.level === "fatal";
return ( return (
<div <div
key={toast.id} key={toast.id}
role={urgent ? "alert" : undefined}
className="toast-card glass relative overflow-hidden rounded-xl px-3 py-3 flex items-start gap-3 animate-[popIn_0.16s_ease]" className="toast-card glass relative overflow-hidden rounded-xl px-3 py-3 flex items-start gap-3 animate-[popIn_0.16s_ease]"
> >
<Icon className={`w-5 h-5 shrink-0 mt-0.5 ${color}`} /> <Icon className={`w-5 h-5 shrink-0 mt-0.5 ${color}`} />
+2 -1
View File
@@ -212,6 +212,7 @@ function Preview({
// Full-size image viewer: a dimmed, blurred backdrop with the screenshot fit to the viewport // Full-size image viewer: a dimmed, blurred backdrop with the screenshot fit to the viewport
// (aspect ratio preserved via object-contain). Closes on backdrop click, the ✕, or Escape. // (aspect ratio preserved via object-contain). Closes on backdrop click, the ✕, or Escape.
function Lightbox({ src, label, onClose }: { src: string; label: string; onClose: () => void }) { function Lightbox({ src, label, onClose }: { src: string; label: string; onClose: () => void }) {
const { t } = useTranslation();
useEffect(() => { useEffect(() => {
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose(); if (e.key === "Escape") onClose();
@@ -235,7 +236,7 @@ function Lightbox({ src, label, onClose }: { src: string; label: string; onClose
<button <button
type="button" type="button"
onClick={onClose} onClick={onClose}
aria-label="Close" aria-label={t("common.close")}
className="absolute top-4 right-4 p-2 rounded-full glass-card glass-hover text-fg" className="absolute top-4 right-4 p-2 rounded-full glass-card glass-hover text-fg"
> >
<X className="w-5 h-5" /> <X className="w-5 h-5" />
+1
View File
@@ -45,6 +45,7 @@
"undo": "Undo", "undo": "Undo",
"markedWatchedNamed": "Marked watched “{{title}}”", "markedWatchedNamed": "Marked watched “{{title}}”",
"markedWatched": "Marked watched", "markedWatched": "Marked watched",
"thisVideo": "this video",
"unwatch": "Unwatch", "unwatch": "Unwatch",
"source": { "source": {
"label": "Source", "label": "Source",
+1
View File
@@ -45,6 +45,7 @@
"undo": "Visszavonás", "undo": "Visszavonás",
"markedWatchedNamed": "Megnézettnek jelölve: „{{title}}”", "markedWatchedNamed": "Megnézettnek jelölve: „{{title}}”",
"markedWatched": "Megnézettnek jelölve", "markedWatched": "Megnézettnek jelölve",
"thisVideo": "ez a videó",
"unwatch": "Megnézés visszavonása", "unwatch": "Megnézés visszavonása",
"source": { "source": {
"label": "Forrás", "label": "Forrás",
+28 -2
View File
@@ -129,6 +129,27 @@ let items: Notification[] = load();
let listeners: Array<() => void> = []; let listeners: Array<() => void> = [];
let counter = items.reduce((m, n) => Math.max(m, n.id), 0) + 1; 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<number, ReturnType<typeof setTimeout>>();
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.delete(id);
}
if (duration)
dismissTimers.set(
id,
setTimeout(() => dismiss(id), duration),
);
}
// Cached derived snapshots — useSyncExternalStore needs stable references between // Cached derived snapshots — useSyncExternalStore needs stable references between
// emits, so we recompute these only when `items` changes. // emits, so we recompute these only when `items` changes.
let cachedActive: Notification[] = []; let cachedActive: Notification[] = [];
@@ -243,7 +264,7 @@ export function notify(input: NotifyInput): number {
: n, : n,
); );
emit(); emit();
if (duration) setTimeout(() => dismiss(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. // Deliberately no re-beep: the first occurrence already sounded; a loop must stay quiet.
return prior.id; return prior.id;
} }
@@ -275,12 +296,17 @@ export function notify(input: NotifyInput): number {
) { ) {
beep(); beep();
} }
if (duration) setTimeout(() => dismiss(id), duration); armDismiss(id, duration);
return id; return id;
} }
/** Close the transient toast surface; the entry stays in the center's history. */ /** Close the transient toast surface; the entry stays in the center's history. */
export function dismiss(id: number): void { 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)); items = items.map((n) => (n.id === id ? { ...n, dismissed: true } : n));
emit(); emit();
} }
+17 -2
View File
@@ -14,6 +14,23 @@ export interface ReleaseEntry {
} }
export const RELEASE_NOTES: ReleaseEntry[] = [ export const RELEASE_NOTES: ReleaseEntry[] = [
{
version: "0.50.0",
date: "2026-07-22",
summary: "A sweep of small correctness, keyboard and accessibility fixes across the app.",
fixes: [
"Downloads: two different formats of the same video no longer overwrite each other on disk (and deleting one no longer removes the other's file). Videos with very long titles — especially accented, non-English ones — no longer fail to save.",
"In the video player, pressing Escape now closes just the top layer — the open popover or dialog — instead of also closing the player behind it.",
"Plex player: the audio and subtitle menus are readable in the light theme again (they were white-on-white).",
"A repeated notification that resurfaces now stays on screen for its full time instead of vanishing early.",
"Unsubscribing from a channel now tells you if it failed, instead of failing silently.",
"The ✕ close button on dialogs is now translated (it was always English), and screen readers now announce toast notifications.",
],
chores: [
"Keyboard: reorder a side panel's sections with the keyboard, and Space now activates a focused button in the player instead of toggling playback.",
"Hardening: rate-limited the access-request endpoint and closed a couple of sign-in token races; the demo account can no longer spend YouTube quota; assorted internal safety and performance fixes.",
],
},
{ {
version: "0.49.0", version: "0.49.0",
date: "2026-07-21", date: "2026-07-21",
@@ -923,5 +940,3 @@ export const RELEASE_NOTES: ReleaseEntry[] = [
], ],
}, },
]; ];
export const CURRENT_VERSION = RELEASE_NOTES[0]?.version ?? "dev";
+13 -1
View File
@@ -17,7 +17,19 @@ export function useDismiss(
if (!list.some((r) => r.current?.contains(target))) onClose(); if (!list.some((r) => r.current?.contains(target))) onClose();
}; };
const onKey = (e: KeyboardEvent) => { 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.)
//
// 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();
}
}; };
document.addEventListener("mousedown", onDown); document.addEventListener("mousedown", onDown);
document.addEventListener("keydown", onKey); document.addEventListener("keydown", onKey);