fix(r5): address the second /code-review high round — 7 findings

- the volume keys no longer defer to a scrollable card while in fullscreen
  (mirrored into a ref: the keydown handler is bound once and state would be stale)
- the debounced volume flush writes straight to storage; persisting inside a
  setState updater is not guaranteed to run for an unmounting component
- the HLS sweep also matches the original {key}_{start} directory naming, which
  is what the oldest leftovers on disk actually use
- run_rss_poll returns {new, failed} so an egress outage stops rendering as
  "ok — 0" in the scheduler card and the audit log; /api/sync/rss reports it too
- drop the unused poll_rss_channel (it silently gained a new exception)
- _Breaker.run is generic, so a call site keeps its item-id type
- test helper instead of the generator-throw idiom
This commit is contained in:
2026-07-23 23:59:58 +02:00
parent 2a7f49c981
commit 53ff61653d
7 changed files with 55 additions and 26 deletions
+5 -3
View File
@@ -34,11 +34,13 @@ log = logging.getLogger("siftlode.plex")
_HLS_ROOT = Path(settings.plex_hls_dir) if settings.plex_hls_dir else Path(settings.download_root) / ".plex-hls" _HLS_ROOT = Path(settings.plex_hls_dir) if settings.plex_hls_dir else Path(settings.download_root) / ".plex-hls"
_SEG_SECONDS = 6 _SEG_SECONDS = 6
_SESSION_IDLE_S = 600 # reap a session with no access for this long _SESSION_IDLE_S = 600 # reap a session with no access for this long
# A session directory is named `{rating_key}_{int(start_s)}_{tag}_{aoff:+.2f}` (see start_session). # A session directory is named `{rating_key}_{int(start_s)}_{tag}_{aoff:+.2f}` (see start_session);
# `sweep_orphans` deletes ONLY names matching this shape: `_HLS_ROOT` is admin-configurable # before multi-audio it was just `{rating_key}_{int(start_s)}`, so the tail is OPTIONAL here — the
# oldest leftovers on disk carry the old shape and are exactly what the sweep exists to remove.
# `sweep_orphans` deletes ONLY names matching this: `_HLS_ROOT` is admin-configurable
# (PLEX_HLS_DIR), so it may legitimately point at a shared scratch path we do not own — a blanket # (PLEX_HLS_DIR), so it may legitimately point at a shared scratch path we do not own — a blanket
# "delete every directory under the root" would take the neighbours with it. # "delete every directory under the root" would take the neighbours with it.
_SESSION_DIR_RE = re.compile(r"^.+_\d+_[^_]+_[+-]\d+\.\d{2}$") _SESSION_DIR_RE = re.compile(r"^.+_\d+(_[^_]+_[+-]\d+\.\d{2})?$")
_lock = threading.Lock() _lock = threading.Lock()
_sessions: dict[str, "HlsSession"] = {} _sessions: dict[str, "HlsSession"] = {}
+4 -2
View File
@@ -52,8 +52,10 @@ def sync_subscriptions(
def sync_rss( def sync_rss(
user: User = Depends(require_human), db: Session = Depends(get_db) user: User = Depends(require_human), db: Session = Depends(get_db)
) -> dict: ) -> dict:
new = run_rss_poll(db, _user_channels(db, user)) result = run_rss_poll(db, _user_channels(db, user))
return {"new_videos": new} # `failed` = feeds that could not be read at all (see run_rss_poll); surfaced so a manual poll
# reports an outage instead of an innocent-looking "0 new".
return {"new_videos": result["new"], "failed_feeds": result["failed"]}
@router.post("/backfill") @router.post("/backfill")
+11 -3
View File
@@ -9,6 +9,7 @@ import hashlib
import logging import logging
from collections.abc import Callable from collections.abc import Callable
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import TypeVar
from sqlalchemy import delete, select from sqlalchemy import delete, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -272,8 +273,15 @@ def plan_push(db: Session, user: User, pl: Playlist, *, live=None) -> dict:
_MAX_CONSECUTIVE_FAILURES = 5 _MAX_CONSECUTIVE_FAILURES = 5
class _Failed:
"""Type of the `FAILED` sentinel, so `run()` can stay generic in the op's return type: a call
site gets back `str | _Failed` (not a bare `object`) and keeps its item id typed."""
#: Returned by `_Breaker.run` when the write did not happen (refused or failed). #: Returned by `_Breaker.run` when the write did not happen (refused or failed).
FAILED = object() FAILED = _Failed()
T = TypeVar("T")
class _Breaker: class _Breaker:
@@ -305,7 +313,7 @@ class _Breaker:
self.limit, self.limit,
) )
def run(self, op: Callable[[], object], on_fail: Callable[[], None]) -> object: def run(self, op: Callable[[], T], on_fail: Callable[[], None]) -> T | _Failed:
"""Attempt one YouTube write. Returns the op's result, or `FAILED` when the breaker is """Attempt one YouTube write. Returns the op's result, or `FAILED` when the breaker is
already tripped (op not called) or the op raised YouTubeError. `on_fail` records the item already tripped (op not called) or the op raised YouTubeError. `on_fail` records the item
in the caller's failure list either way.""" in the caller's failure list either way."""
@@ -387,7 +395,7 @@ def push_playlist(db: Session, user: User, pl: Playlist, *, live=None) -> dict:
lambda v=vid: yt.add_playlist_item(pl.yt_playlist_id, v), lambda v=vid: yt.add_playlist_item(pl.yt_playlist_id, v),
lambda v=vid: failures.append(v), lambda v=vid: failures.append(v),
) )
if item_id is not FAILED: if not isinstance(item_id, _Failed):
item_id_by_vid[vid] = item_id item_id_by_vid[vid] = item_id
inserted += 1 inserted += 1
# Reorder to match `desired` via insertion-sort moves over a local model. # Reorder to match `desired` via insertion-sort moves over a local model.
+6 -2
View File
@@ -48,7 +48,11 @@ def get_service_user(db: Session) -> User | None:
return users[0] if users else None return users[0] if users else None
def run_rss_poll(db: Session, channels: list[Channel] | None = None) -> int: def run_rss_poll(db: Session, channels: list[Channel] | None = None) -> dict:
"""Poll every channel's free RSS feed. Returns `{"new": inserted, "failed": unreadable_feeds}`
— the failure count is part of the RESULT, not just a log line, so the scheduler card and the
audit summary can tell "no new uploads" apart from "every feed was unreachable" (an egress
outage otherwise renders identically to a quiet hour)."""
if channels is None: if channels is None:
channels = db.execute(select(Channel)).scalars().all() channels = db.execute(select(Channel)).scalars().all()
total = len(channels) total = len(channels)
@@ -89,7 +93,7 @@ def run_rss_poll(db: Session, channels: list[Channel] | None = None) -> int:
progress.report(done, total, "rss_poll") progress.report(done, total, "rss_poll")
if failed: if failed:
log.warning("RSS poll: %d/%d channel feeds could not be read", failed, total) log.warning("RSS poll: %d/%d channel feeds could not be read", failed, total)
return new return {"new": new, "failed": failed}
def run_enrich(db: Session, max_batches: int = 200, floor: int = 200) -> int: def run_enrich(db: Session, max_batches: int = 200, floor: int = 200) -> int:
-5
View File
@@ -14,7 +14,6 @@ from app import progress, sysconfig
from app.models import LIVE_OR_UPCOMING, Channel, Video from app.models import LIVE_OR_UPCOMING, Channel, Video
from app.titles import normalize_title from app.titles import normalize_title
from app.youtube.client import YouTubeClient, best_thumbnail from app.youtube.client import YouTubeClient, best_thumbnail
from app.youtube.rss import fetch_channel_feed
from app.youtube.shorts import make_client, probe_is_short from app.youtube.shorts import make_client, probe_is_short
_DURATION_RE = re.compile( _DURATION_RE = re.compile(
@@ -84,10 +83,6 @@ def apply_rss_feed(db: Session, channel: Channel, entries: list[dict]) -> int:
return inserted return inserted
def poll_rss_channel(db: Session, channel: Channel) -> int:
return apply_rss_feed(db, channel, fetch_channel_feed(channel.id))
# --- Backfill (uploads playlist; costs quota) --- # --- Backfill (uploads playlist; costs quota) ---
def _stub_from_playlist_item(item: dict, channel_id: str) -> dict | None: def _stub_from_playlist_item(item: dict, channel_id: str) -> dict | None:
content = item.get("contentDetails", {}) content = item.get("contentDetails", {})
+12 -4
View File
@@ -51,6 +51,11 @@ class TestBreaker:
assert not b.open() assert not b.open()
def _quota_dead():
"""A write op that fails the way a quota-exhausted / scope-revoked account fails."""
raise YouTubeError("quota exceeded")
class TestBreakerRun: class TestBreakerRun:
def test_returns_the_ops_result_and_leaves_failures_alone(self): def test_returns_the_ops_result_and_leaves_failures_alone(self):
b, failed = _Breaker(), [] b, failed = _Breaker(), []
@@ -59,22 +64,25 @@ class TestBreakerRun:
def test_a_youtube_error_records_the_failure_and_counts_towards_the_trip(self): def test_a_youtube_error_records_the_failure_and_counts_towards_the_trip(self):
b, failed = _Breaker(limit=2), [] b, failed = _Breaker(limit=2), []
assert b.run(lambda: (_ for _ in ()).throw(YouTubeError("quota")), lambda: failed.append("v1")) is FAILED assert b.run(_quota_dead, lambda: failed.append("v1")) is FAILED
assert failed == ["v1"] and b.open() assert failed == ["v1"] and b.open()
b.run(lambda: (_ for _ in ()).throw(YouTubeError("quota")), lambda: failed.append("v2")) b.run(_quota_dead, lambda: failed.append("v2"))
assert not b.open() assert not b.open()
def test_a_tripped_breaker_never_calls_the_op(self): def test_a_tripped_breaker_never_calls_the_op(self):
b, failed, calls = _Breaker(limit=1), [], [] b, failed, calls = _Breaker(limit=1), [], []
b.run(lambda: (_ for _ in ()).throw(YouTubeError("quota")), lambda: failed.append("v1")) b.run(_quota_dead, lambda: failed.append("v1"))
assert b.run(lambda: calls.append("called"), lambda: failed.append("v2")) is FAILED assert b.run(lambda: calls.append("called"), lambda: failed.append("v2")) is FAILED
assert calls == [] # the whole point: no more quota spent assert calls == [] # the whole point: no more quota spent
assert failed == ["v1", "v2"] # …but the item is still reported assert failed == ["v1", "v2"] # …but the item is still reported
def test_other_exceptions_are_not_swallowed(self): def test_other_exceptions_are_not_swallowed(self):
# Only YouTubeError is a "write failed" signal; a bug must still surface. # Only YouTubeError is a "write failed" signal; a bug must still surface.
def boom():
raise ValueError("bug")
with pytest.raises(ValueError): with pytest.raises(ValueError):
_Breaker().run(lambda: (_ for _ in ()).throw(ValueError("bug")), lambda: None) _Breaker().run(boom, lambda: None)
class TestRssErrorVsEmpty: class TestRssErrorVsEmpty:
+17 -7
View File
@@ -35,7 +35,7 @@ import {
} from "../lib/format"; } from "../lib/format";
import { renderDescription } from "../lib/descriptionLinks"; import { renderDescription } from "../lib/descriptionLinks";
import { useBackToClose } from "../lib/history"; import { useBackToClose } from "../lib/history";
import { LS, useAccountPersistedObject } from "../lib/storage"; import { LS, useAccountPersistedObject, writeAccount } from "../lib/storage";
import { useScrollFade } from "../lib/useScrollFade"; import { useScrollFade } from "../lib/useScrollFade";
// Experiment (branch experiment/inline-player): play the video in-app via the // Experiment (branch experiment/inline-player): play the video in-app via the
@@ -176,6 +176,9 @@ export default function PlayerModal({
// while the iframe itself sees mouse movement, so an armed overlay kept the bar — and with it // while the iframe itself sees mouse movement, so an armed overlay kept the bar — and with it
// the native fullscreen button — permanently hidden after pressing F. // the native fullscreen button — permanently hidden after pressing F.
const [isFullscreen, setIsFullscreen] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false);
// Mirrored into a ref because the keydown handler is bound once (deps: [onClose]) and would
// otherwise read `false` forever — the house pattern here (see navRef/volumeRef).
const isFullscreenRef = useRef(false);
// Volume survives the modal, the next video and a reload: a YT.Player is created per video and // Volume survives the modal, the next video and a reload: a YT.Player is created per video and
// always starts at full volume. Per-account, like the Plex player's prefs. Level 0 IS the muted // always starts at full volume. Per-account, like the Plex player's prefs. Level 0 IS the muted
// state (that's how YouTube's own control behaves), so there's no separate `muted` flag to // state (that's how YouTube's own control behaves), so there's no separate `muted` flag to
@@ -217,6 +220,11 @@ export default function PlayerModal({
const persistVolume = () => { const persistVolume = () => {
window.clearTimeout(volSaveTimerRef.current); window.clearTimeout(volSaveTimerRef.current);
volSaveTimerRef.current = undefined; volSaveTimerRef.current = undefined;
// Write STRAIGHT to storage, not through patchPlayerPrefs: that helper persists inside a
// `setState` updater, and React is under no obligation to run an updater for a component that
// is unmounting — which is exactly when the flush below fires (close the player right after a
// wheel spin). The state copy is only needed while mounted, so patch that separately.
writeAccount(LS.ytPlayerPrefs, { volume: volumeRef.current });
patchPlayerPrefs({ volume: volumeRef.current }); patchPlayerPrefs({ volume: volumeRef.current });
}; };
const applyVolume = (level: number, flash = true) => { const applyVolume = (level: number, flash = true) => {
@@ -468,9 +476,10 @@ export default function PlayerModal({
// fullscreen, where the wheel goes to YouTube's own iframe. But the modal card scrolls // fullscreen, where the wheel goes to YouTube's own iframe. But the modal card scrolls
// (a long description) and Up/Down is how you scroll it from the keyboard — so defer // (a long description) and Up/Down is how you scroll it from the keyboard — so defer
// whenever the focused element can still scroll that way, exactly like Space defers to a // whenever the focused element can still scroll that way, exactly like Space defers to a
// focused button. // focused button. NOT in fullscreen though: the card is off-screen there, so deferring
// would scroll something invisible and leave fullscreen with no volume control at all.
const up = e.key === "ArrowUp"; const up = e.key === "ArrowUp";
if (typing || canScrollBy(el, up ? -1 : 1)) return; if (typing || (!isFullscreenRef.current && canScrollBy(el, up ? -1 : 1))) return;
e.preventDefault(); e.preventDefault();
nudgeVolume(up ? 5 : -5); nudgeVolume(up ? 5 : -5);
} else if (e.key === "ArrowLeft" || e.key === "ArrowRight") { } else if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
@@ -524,10 +533,11 @@ export default function PlayerModal({
// Track whether we're the fullscreen element (F, our button, or the browser's own Esc exit). // Track whether we're the fullscreen element (F, our button, or the browser's own Esc exit).
useEffect(() => { useEffect(() => {
const onChange = () => const onChange = () => {
setIsFullscreen( const ours = !!document.fullscreenElement && document.fullscreenElement === stageRef.current;
!!document.fullscreenElement && document.fullscreenElement === stageRef.current, isFullscreenRef.current = ours; // read by the once-bound keydown handler
); setIsFullscreen(ours); // drives the overlay's pointer-events
};
document.addEventListener("fullscreenchange", onChange); document.addEventListener("fullscreenchange", onChange);
return () => document.removeEventListener("fullscreenchange", onChange); return () => document.removeEventListener("fullscreenchange", onChange);
}, []); }, []);