Compare commits

..
16 Commits
Author SHA1 Message Date
peter f7e036abef merge: e2e page loads retried on a machine out of buffers (improvement/e2e-goto-retry) 2026-11-04 00:53:23 +01:00
peter f2de8f73aa test(e2e): a page load the machine had no buffer for is tried again
net::ERR_NO_BUFFER_SPACE stopped a release at its first page with nothing else running, and the
unchanged rerun passed. Every page load of the suite now goes through one helper, held by a test.
2026-11-04 00:26:50 +01:00
peter 643f305814 release: 0.116.3 (bug/lost-stylesheet) 2026-11-04 00:19:42 +01:00
peter cbe1b3bd63 chore(release): 0.116.3 2026-11-04 00:05:30 +01:00
peter ae775b383c fix(boot): a stylesheet lost on the way is asked for again
A lost stylesheet left the page unstyled until a reload, and every e2e step that finds elements by
testid still passed on it. Only axe noticed: the one-off target-size failure on eight scattered
controls. Losing the main stylesheet on purpose gives those same eight.
2026-11-03 23:32:40 +01:00
peter 358d5af70b release: 0.116.2 (bug/e56-hls-reap) 2026-11-03 21:51:59 +01:00
peter c6831090b5 chore(release): 0.116.2 2026-11-03 21:35:19 +01:00
peter 38fc95d772 docs(plex): a quiet reclaim's replay is named as accepted 2026-11-03 21:22:17 +01:00
peter 3f6638bf96 fix(plex): a seek during a quiet reclaim is no longer undone
A quiet request leaves `live` alone, so a seek inside the old session's
range went native and the answer then jumped back to where it asked from.
`seek` now also goes by `pending`; the server-away retry had the same hole.

Comments that still named 404 for a lost session say 410, and the hls.js
retry comment now tells a finished playlist from a growing one.
2026-11-03 21:10:51 +01:00
peter 469c8aa481 test(e2e): the drag onto the rail waits for the rail's lists 2026-11-03 20:31:41 +01:00
peter df2997e9f3 fix(plex): a lost HLS session is reclaimed at the first answer
The route answers 410 for a session it no longer has and keeps 404 for a segment not there yet.
hls.js retries even a 4xx on a stream with one video variant, so the player waited about half a
minute before asking again, and minutes in a throttled page.
2026-11-03 19:20:22 +01:00
peter f69725503f fix(plex): a finished HLS session outlives a throttled heartbeat 2026-11-03 19:15:18 +01:00
peter d5bc7e8031 feat(plex): every HLS session reap logs its rule and idle time 2026-11-03 18:35:33 +01:00
peter efdffc82ae release: 0.116.1 (bug/e53-order-from-server) 2026-11-03 03:27:54 +01:00
peter e23544a46b chore(release): 0.116.1 2026-11-03 03:14:48 +01:00
peter 035ee937a0 fix(playlists): the list shows the order the server holds
A read with the same rows in another order never reached the screen: a
revert to YouTube, a sync or another device left the old order showing, and
the next write put it back on the server. With no write out on any list the
screen now takes it, and the undo history built on the old order goes. Own
reorders still keep theirs. This replaces the one flag all lists shared after
a rollback, and shows a reorder accepted after its list was picked again.

Also what the fourth review found: a list's reorders go one after the other,
a toast Undo asks for its place once the writes out have landed, an accepted
reorder keeps fresher rows from the cache, and a Play all disabled by the last
row going no longer strands the focus.
2026-11-03 03:01:17 +01:00
28 changed files with 1001 additions and 146 deletions
+19
View File
@@ -7,6 +7,25 @@ edit the release notes and re-run the generator.
Upgrading, pinning a version and rolling back: [docs/self-hosting.md](docs/self-hosting.md#upgrading). Upgrading, pinning a version and rolling back: [docs/self-hosting.md](docs/self-hosting.md#upgrading).
## 0.116.3 — 2026-11-04
### Fixed
- If a stylesheet is lost on the way, the app now asks for it again, so a poor connection no longer leaves the page unstyled until a reload.
## 0.116.2 — 2026-11-03
### Fixed
- A Plex video paused or muted in a background tab no longer loses its session after a minute, and if a session is lost anyway, playback comes back at once instead of after half a minute.
## 0.116.1 — 2026-11-03
### Fixed
- A playlist now shows the order the server holds after a revert to YouTube, a sync or a change on another device, and the next drag no longer brings back the old one.
- An Undo on a removed or moved row puts it back in the right place while another change is still saving.
## 0.116.0 — 2026-11-03 ## 0.116.0 — 2026-11-03
### New ### New
+1 -1
View File
@@ -1 +1 @@
0.116.0 0.116.3
+1 -1
View File
@@ -151,7 +151,7 @@ async def _reap_hls_sessions() -> None:
somebody pressed play again or the process restarted. somebody pressed play again or the process restarted.
A FINISHED session is reaped on the short rule here, not kept for the full idle window: an open A FINISHED session is reaped on the short rule here, not kept for the full idle window: an open
player checkpoints every 10 s and that route touches the session, so a paused viewer is no longer player keeps checkpointing and that route touches the session, so a paused viewer is no longer
invisible to this pass (see `reap_idle`). Keeping every finished session for ten minutes instead invisible to this pass (see `reap_idle`). Keeping every finished session for ten minutes instead
held one whole segmented copy of a film per item on the download volume.""" held one whole segmented copy of a film per item on the download volume."""
while True: while True:
+27 -13
View File
@@ -125,7 +125,7 @@ class HlsSession:
# replaces the viewer's registered session for the item — and the old player's next request # replaces the viewer's registered session for the item — and the old player's next request
# for `seg_12.ts`, resolved by (viewer, rating_key) alone, was answered 200 with the NEW session's twelfth segment, produced from a different offset # for `seg_12.ts`, resolved by (viewer, rating_key) alone, was answered 200 with the NEW session's twelfth segment, produced from a different offset
# under `-copyts`. Wrong bytes, not an error, so the client's reclaim never fired on it. # under `-copyts`. Wrong bytes, not an error, so the client's reclaim never fired on it.
# A request naming a session that is no longer registered is a 404, # A request naming a session that is no longer registered is a 410,
# which the player recovers from by restarting at the playhead. # which the player recovers from by restarting at the playhead.
self.id = secrets.token_urlsafe(9) self.id = secrets.token_urlsafe(9)
self.key = key # the item's rating_key (what the logs name) self.key = key # the item's rating_key (what the logs name)
@@ -729,7 +729,7 @@ def _enforce_caps(
whole segmented copy of the film under `_HLS_ROOT`, written faster than realtime. Counted only whole segmented copy of the film under `_HLS_ROOT`, written faster than realtime. Counted only
by the transcode cap, N distinct items were N ffmpegs and N film-sized scratch directories. by the transcode cap, N distinct items were N ffmpegs and N film-sized scratch directories.
Somebody's playback stops at each drop. The route turns the missing session into a retryable 404 Somebody's playback stops at each drop. The route turns the missing session into a 410
and the player restarts at the playhead, so from the server nothing failed — and "my film keeps and the player restarts at the playhead, so from the server nothing failed — and "my film keeps
rebuffering" has no other explanation anywhere, hence the line.""" rebuffering" has no other explanation anywhere, hence the line."""
@@ -918,7 +918,7 @@ def start_session(
# stale and stops the session it names (`stop_session`), which stopped the playback that won. # stale and stops the session it names (`stop_session`), which stopped the playback that won.
# Never None either: the POST /session route turns a None return into a "Media file not found # Never None either: the POST /session route turns a None return into a "Media file not found
# on disk" 404 — dishonest for a file that exists. A dead session instead degrades to a # on disk" 404 — dishonest for a file that exists. A dead session instead degrades to a
# retryable 404 ("No active playback session" / "Not available yet") the client re-requests. # 410 ("session_gone") on its URLs, and the client asks again.
return s return s
s = respawned s = respawned
ready = wait_for(_seg(0), timeout=25.0, proc=s.proc) ready = wait_for(_seg(0), timeout=25.0, proc=s.proc)
@@ -1160,9 +1160,13 @@ def sweep_orphans(older_than: float | None = None) -> int:
return dropped return dropped
# How long a session whose ffmpeg has FINISHED may go unfetched before a new session request reaps # The slowest heartbeat a live player sends: a browser cuts the timers of a hidden page that makes no
# it (see reap_idle). # sound to one a minute (measured on Chromium: a paused and a muted player, about a minute after
_FINISHED_IDLE_S = 30 # hiding; a hidden player with sound keeps its 10 s).
_THROTTLED_HEARTBEAT_S = 60
# How long a session whose ffmpeg has FINISHED may go without a signal before `reap_idle` takes it:
# the beat before the reaper looks, one lost on the way, and the time the next one takes to arrive.
_FINISHED_IDLE_S = 3 * _THROTTLED_HEARTBEAT_S
def reap_idle(finished_idle_s: float = _FINISHED_IDLE_S) -> int: def reap_idle(finished_idle_s: float = _FINISHED_IDLE_S) -> int:
@@ -1171,20 +1175,23 @@ def reap_idle(finished_idle_s: float = _FINISHED_IDLE_S) -> int:
A finished session is also what a PAUSED viewer has: a remux runs far faster than realtime, so A finished session is also what a PAUSED viewer has: a remux runs far faster than realtime, so
ffmpeg is done a minute or two in and writes ENDLIST; hls.js then stops asking for the playlist, ffmpeg is done a minute or two in and writes ENDLIST; hls.js then stops asking for the playlist,
and with its forward buffer full a paused player fetches nothing at all. With the 30 s rule that and with its forward buffer full a paused player fetches nothing at all.
killed a one-minute pause, and playback died ~30 s after resuming.
What makes the short rule safe again is that fetching is no longer the only signal: a player What makes the short rule safe is that fetching is not the only signal: a player parked MID-ITEM
parked MID-ITEM checkpoints its position every 10 s, and that route calls `touch_session`, so a checkpoints its position and that route calls `touch_session`. That beat is every 10 s only while
viewer who is merely paused says so far more often than this rule asks. The heartbeat the page is visible or making sound; a hidden, silent one sends it once a minute, which a 30 s
rule killed whenever the reaper happened to run more than 30 s after the beat (measured: a paused
session gone that way, and one that lived on the same cadence because its phase was luckier).
So the rule is sized to the throttled beat (`_THROTTLED_HEARTBEAT_S`). The heartbeat
stops once the item is finished or before a position is known (`usePlexProgress.flush`) — both stops once the item is finished or before a position is known (`usePlexProgress.flush`) — both
states where reaping is correct, and where the player's 404 → reclaim recovers a viewer who states where reaping is correct, and where the player's 410 → reclaim recovers a viewer who
scrubs back in anyway. Do not restate this as "every 10 s whether or not it is playing": scrubs back in anyway. Do not restate this as "whether or not it is playing":
a review found that claim in two comments and not in the code. An earlier blanket a review found that claim in two comments and not in the code. An earlier blanket
`reap_abandoned` bought the same safety by keeping every finished session for ten minutes — each `reap_abandoned` bought the same safety by keeping every finished session for ten minutes — each
one a whole segmented copy of the film on a volume shared with the download center.""" one a whole segmented copy of the film on a volume shared with the download center."""
now = time.time() now = time.time()
doomed: list[tuple[HlsSession, Path | None]] = [] doomed: list[tuple[HlsSession, Path | None]] = []
reaped: list[tuple[str, str, int]] = []
with _lock: with _lock:
for reg, s in list(_sessions.items()): for reg, s in list(_sessions.items()):
done = s.proc.poll() is not None done = s.proc.poll() is not None
@@ -1192,6 +1199,13 @@ def reap_idle(finished_idle_s: float = _FINISHED_IDLE_S) -> int:
if idle > _SESSION_IDLE_S or (done and idle > finished_idle_s): if idle > _SESSION_IDLE_S or (done and idle > finished_idle_s):
_sessions.pop(reg, None) _sessions.pop(reg, None)
doomed.append(_detach(s)) doomed.append(_detach(s))
reaped.append((s.key, "finished_idle" if done else "idle", int(idle)))
# The viewer of a session wrongly taken sees only a 410 later, so this line is the one record of why.
for key, reason, idle_s in reaped:
log.info(
"an idle session was reaped",
extra=logs.event("plex.stream.session_reaped", key=key, reason=reason, idle_s=idle_s),
)
_finish(doomed) _finish(doomed)
return len(doomed) return len(doomed)
+10 -7
View File
@@ -323,13 +323,14 @@ def item_progress(
`_should_push_resume`). `final` (sent on pause / seek / pagehide / unmount) pushes immediately and `_should_push_resume`). `final` (sent on pause / seek / pagehide / unmount) pushes immediately and
resets the throttle window, so Plex sees a settled position the instant the user pauses or seeks.""" resets the throttle window, so Plex sees a settled position the instant the user pauses or seeks."""
it = item_or_404(db, rating_key) it = item_or_404(db, rating_key)
# A player parked mid-item — playing or PAUSED — checkpoints every 10 s, so this is the signal # A player parked mid-item — playing or PAUSED — keeps checkpointing (every 10 s, once a minute
# in a hidden silent page), so this is the signal
# that such a viewer still wants their HLS session: the one thing `last_access` could not see, # that such a viewer still wants their HLS session: the one thing `last_access` could not see,
# since it moved only on a file fetch and a paused player fetches nothing (see # since it moved only on a file fetch and a paused player fetches nothing (see
# stream.reap_idle). It is NOT every open player: `usePlexProgress.flush` sends nothing once the # stream.reap_idle). It is NOT every open player: `usePlexProgress.flush` sends nothing once the
# finish latch is up, or before the first position is known. Those are the states in which # finish latch is up, or before the first position is known. Those are the states in which
# reaping is the right answer anyway, and a viewer who scrubs back into a reaped session gets # reaping is the right answer anyway, and a viewer who scrubs back into a reaped session gets
# the same 404 → reclaim the player already handles. # the same 410 → reclaim the player already handles.
plex_stream.touch_session(user.id, str(rating_key)) plex_stream.touch_session(user.id, str(rating_key))
position = max(0, int(body.position_seconds)) position = max(0, int(body.position_seconds))
duration = int(body.duration_seconds) duration = int(body.duration_seconds)
@@ -609,7 +610,9 @@ def stream_hls_file(
is per (key, viewer, offset, tag) — so resolving by the key alone handed the old player the new is per (key, viewer, offset, tag) — so resolving by the key alone handed the old player the new
session's `seg_12.ts`, produced from a different offset under `-copyts`: wrong session's `seg_12.ts`, produced from a different offset under `-copyts`: wrong
bytes with a 200, which the player has no way to notice. A request for a bytes with a 200, which the player has no way to notice. A request for a
session that is no longer the registered one is a 404, and the player reclaims at the playhead. session that is no longer the registered one is a 410, and the player reclaims at the playhead.
410, not 404: a 404 here means "that segment is not there yet", worth hls.js's retries, and the
player can only read the status (a gone session retried for half a minute behind that 404).
That reclaim is also why a request with no live session is no longer auto-started here: the That reclaim is also why a request with no live session is no longer auto-started here: the
auto-start could only ever begin at 0, silently throwing away the viewer's position.""" auto-start could only ever begin at 0, silently throwing away the viewer's position."""
@@ -625,7 +628,7 @@ def stream_hls_file(
raise not_found("file") raise not_found("file")
s = plex_stream.current_session(user.id, str(rating_key)) s = plex_stream.current_session(user.id, str(rating_key))
if s is None or s.id != session_id: if s is None or s.id != session_id:
raise refused(404, "session_gone") raise refused(410, "session_gone")
path = s.dir / filename path = s.dir / filename
# Only a name THIS session can actually produce is worth waiting for: its own playlists, and a # Only a name THIS session can actually produce is worth waiting for: its own playlists, and a
# segment of one of its own renditions whose predecessor is already on disk (ffmpeg writes them # segment of one of its own renditions whose predecessor is already on disk (ffmpeg writes them
@@ -648,13 +651,13 @@ def stream_hls_file(
except OSError: except OSError:
# The session was torn down between the wait and the read — teardown moves the whole # The session was torn down between the wait and the read — teardown moves the whole
# directory aside and deletes it, and nothing holds `_lock` across those two steps. It has # directory aside and deletes it, and nothing holds `_lock` across those two steps. It has
# to present as the same 404 a gone session does: the player's recovery is keyed on the # to present as the same 410 a gone session does: the player's recovery is keyed on the
# STATUS (`code === 404`), so # STATUS, so
# a 500 here skipped the reclaim and ended playback with "couldn't play" instead of # a 500 here skipped the reclaim and ended playback with "couldn't play" instead of
# restarting at the playhead. Ordinary trigger: `loadSession` awaits the new session — which # restarting at the playhead. Ordinary trigger: `loadSession` awaits the new session — which
# kills and rmtrees the old one — before destroying the old hls instance, so the outgoing # kills and rmtrees the old one — before destroying the old hls instance, so the outgoing
# instance's in-flight segment request races the rmtree on every seek-restart. # instance's in-flight segment request races the rmtree on every seek-restart.
raise refused(404, "session_gone") from None raise refused(410, "session_gone") from None
return Response( return Response(
content=content, content=content,
media_type=_HLS_CT[ext], media_type=_HLS_CT[ext],
+52 -7
View File
@@ -5,11 +5,11 @@ POST /session: after the last playback, an abandoned session (its ffmpeg still w
film, its directory a whole segmented copy of it) lived until somebody pressed play again. film, its directory a whole segmented copy of it) lived until somebody pressed play again.
And it must not take a PAUSED viewer's session: a remux finishes long before the film does, and a And it must not take a PAUSED viewer's session: a remux finishes long before the film does, and a
paused player with a full buffer fetches nothing, so "finished and unfetched for 30 s" does not mean paused player with a full buffer fetches nothing, so "finished and unfetched for a while" does not mean
"abandoned". An earlier fix bought that safety by keeping EVERY finished session for the full "abandoned". An earlier fix bought that safety by keeping EVERY finished session for the full
ten-minute idle window — one segmented copy of a film per item, on the volume the download center ten-minute idle window — one segmented copy of a film per item, on the volume the download center
shares. Its successor replaced the blanket retention with the signal that already existed: a player shares. Its successor replaced the blanket retention with the signal that already existed: a player
parked mid-item checkpoints its position every 10 s, paused or not, and that route says "still parked mid-item keeps checkpointing its position, paused or not, and that route says "still
here" (it stops once the item is finished, where reaping is correct). here" (it stops once the item is finished, where reaping is correct).
Both call sites are pinned here, because the bug each time was that only one of them moved: the Both call sites are pinned here, because the bug each time was that only one of them moved: the
@@ -81,6 +81,9 @@ class _Proc:
return 0 return 0
_PAST_THE_RULE = stream._FINISHED_IDLE_S + 5
class _Session: class _Session:
def __init__(self, tmp_path, name: str, finished: bool, idle_s: float): def __init__(self, tmp_path, name: str, finished: bool, idle_s: float):
self.key = name self.key = name
@@ -98,11 +101,29 @@ def sessions(monkeypatch):
def test_the_periodic_pass_reaps_a_finished_session_nobody_has_claimed(tmp_path, sessions): def test_the_periodic_pass_reaps_a_finished_session_nobody_has_claimed(tmp_path, sessions):
sessions["12345"] = _Session(tmp_path, "done", finished=True, idle_s=45) sessions["12345"] = _Session(tmp_path, "done", finished=True, idle_s=_PAST_THE_RULE)
assert stream.reap_idle() == 1 assert stream.reap_idle() == 1
assert sessions == {} assert sessions == {}
@pytest.mark.parametrize("idle_s", [65, 125])
def test_a_heartbeat_throttled_to_once_a_minute_keeps_a_finished_session(
tmp_path, sessions, idle_s
):
"""A hidden page that makes no sound gets its timers cut to one a minute (measured on a paused and
on a muted player, about a minute after hiding), so that is the viewer's heartbeat there: the
reaper can see a beat over a minute old, and two if one is lost on the way."""
s = _Session(tmp_path, "hidden", finished=True, idle_s=idle_s)
sessions[(1, "12345")] = s
assert stream.reap_idle() == 0
assert (1, "12345") in sessions and s.dir.exists() and not s.proc.killed
def test_an_abandoned_finished_session_is_still_gone_within_minutes(tmp_path, sessions):
sessions[(1, "12345")] = _Session(tmp_path, "gone", finished=True, idle_s=185)
assert stream.reap_idle() == 1
def test_the_periodic_pass_still_reaps_what_nobody_has_touched_for_the_idle_limit( def test_the_periodic_pass_still_reaps_what_nobody_has_touched_for_the_idle_limit(
tmp_path, sessions tmp_path, sessions
): ):
@@ -116,8 +137,32 @@ def test_the_periodic_pass_still_reaps_what_nobody_has_touched_for_the_idle_limi
assert sessions == {} assert sessions == {}
def test_every_reap_says_which_rule_took_the_session(tmp_path, sessions, caplog):
"""A session that vanishes leaves the viewer a 410 and nothing else: with no line here, a live
stall could only ever be blamed on the reaper by inference."""
sessions["1"] = _Session(
tmp_path, "gone_done", finished=True, idle_s=stream._SESSION_IDLE_S - 5
)
sessions["2"] = _Session(
tmp_path, "gone_live", finished=False, idle_s=stream._SESSION_IDLE_S + 5
)
sessions["3"] = _Session(tmp_path, "kept", finished=False, idle_s=5)
with caplog.at_level("INFO"):
assert stream.reap_idle() == 2
reaped = {
r.attrs["key"]: r.attrs
for r in caplog.records
if getattr(r, "event", "") == "plex.stream.session_reaped"
}
assert set(reaped) == {"gone_done", "gone_live"}
assert reaped["gone_done"]["reason"] == "finished_idle"
assert reaped["gone_live"]["reason"] == "idle"
assert reaped["gone_done"]["idle_s"] == pytest.approx(stream._SESSION_IDLE_S - 5, abs=2)
assert reaped["gone_live"]["idle_s"] == pytest.approx(stream._SESSION_IDLE_S + 5, abs=2)
def test_touching_a_session_keeps_it_through_the_short_rule(tmp_path, sessions): def test_touching_a_session_keeps_it_through_the_short_rule(tmp_path, sessions):
s = _Session(tmp_path, "paused", finished=True, idle_s=90) s = _Session(tmp_path, "paused", finished=True, idle_s=_PAST_THE_RULE)
sessions[(1, "12345")] = s sessions[(1, "12345")] = s
stream.touch_session(1, "12345") stream.touch_session(1, "12345")
assert stream.reap_idle() == 0 assert stream.reap_idle() == 0
@@ -160,7 +205,7 @@ def test_a_paused_viewers_checkpoint_keeps_their_session_alive(
user = User(email="paused@example.com") user = User(email="paused@example.com")
db.add(user) db.add(user)
db.commit() db.commit()
s = _Session(tmp_path, "paused", finished=True, idle_s=90) s = _Session(tmp_path, "paused", finished=True, idle_s=_PAST_THE_RULE)
sessions[(user.id, "paused1")] = s sessions[(user.id, "paused1")] = s
resp = _client(db, user, override_dep).post( resp = _client(db, user, override_dep).post(
f"/api/plex/item/{it.rating_key}/progress", f"/api/plex/item/{it.rating_key}/progress",
@@ -175,7 +220,7 @@ def test_starting_a_session_leaves_another_viewers_paused_one_alone(
db, monkeypatch, tmp_path, sessions, override_dep db, monkeypatch, tmp_path, sessions, override_dep
): ):
"""The opportunistic sweep this route used to run was not scoped to the key being started: it """The opportunistic sweep this route used to run was not scoped to the key being started: it
walked the WHOLE registry with the 30 s finished rule, so pressing play on any item killed a walked the WHOLE registry with the finished rule, so pressing play on any item killed a
paused viewer's session — the very case the retention fix set out to protect.""" paused viewer's session — the very case the retention fix set out to protect."""
monkeypatch.setattr(state, "_configured_cache", True) monkeypatch.setattr(state, "_configured_cache", True)
monkeypatch.setattr(plex_paths, "local_media_path", lambda db_, fp: "/local/a.mkv") monkeypatch.setattr(plex_paths, "local_media_path", lambda db_, fp: "/local/a.mkv")
@@ -183,7 +228,7 @@ def test_starting_a_session_leaves_another_viewers_paused_one_alone(
user = User(email="starter@example.com") user = User(email="starter@example.com")
db.add(user) db.add(user)
db.commit() db.commit()
paused = _Session(tmp_path, "paused", finished=True, idle_s=90) paused = _Session(tmp_path, "paused", finished=True, idle_s=_PAST_THE_RULE)
sessions[(999, "someone-else")] = paused sessions[(999, "someone-else")] = paused
def fake_start(db_, item, user_id, start, audio=None, aoff=0.0, multi=False, transcode=False): def fake_start(db_, item, user_id, start, audio=None, aoff=0.0, multi=False, transcode=False):
+10 -10
View File
@@ -5,7 +5,7 @@ seek-restart — or a second viewer of the same item — replaces the registered
viewer's next request for `seg_12.ts`, resolved by rating_key, was answered 200 with the NEW viewer's next request for `seg_12.ts`, resolved by rating_key, was answered 200 with the NEW
session's twelfth segment: bytes produced from a different `-ss` offset under `-copyts`, i.e. a session's twelfth segment: bytes produced from a different `-ss` offset under `-copyts`, i.e. a
timeline the client is not on. Wrong content with a success status is the one failure a player cannot timeline the client is not on. Wrong content with a success status is the one failure a player cannot
notice, and the client's reclaim-on-404 could never fire on it. notice, and the client's reclaim could never fire on it.
No `db` fixture: the route reads nothing from the database (its own `Depends(get_db)` went with the No `db` fixture: the route reads nothing from the database (its own `Depends(get_db)` went with the
removed auto-start, and `current_user` is overridden wholesale), so these must not sit in the DB removed auto-start, and `current_user` is overridden wholesale), so these must not sit in the DB
@@ -88,10 +88,10 @@ def test_another_viewers_session_of_the_same_item_is_not_this_viewers(client, tm
(d / "index.m3u8").write_bytes(b"#EXTM3U") (d / "index.m3u8").write_bytes(b"#EXTM3U")
sessions[(7, "m1")] = _Session("sid-a", d) sessions[(7, "m1")] = _Session("sid-a", d)
r = client.get("/api/plex/stream/m1/hls/sid-a/index.m3u8") r = client.get("/api/plex/stream/m1/hls/sid-a/index.m3u8")
assert r.status_code == 404, r.text assert r.status_code == 410, r.text
def test_a_segment_of_a_replaced_session_is_a_404_not_the_new_sessions_bytes( def test_a_segment_of_a_replaced_session_is_gone_not_the_new_sessions_bytes(
client, tmp_path, sessions client, tmp_path, sessions
): ):
"""Viewer A is at 0:00; viewer B seeks to 25:00 and their session takes the registry slot. A's """Viewer A is at 0:00; viewer B seeks to 25:00 and their session takes the registry slot. A's
@@ -102,15 +102,15 @@ def test_a_segment_of_a_replaced_session_is_a_404_not_the_new_sessions_bytes(
(theirs / "seg_12.ts").write_bytes(b"theirs") (theirs / "seg_12.ts").write_bytes(b"theirs")
sessions[(VIEWER, "m1")] = _Session("sid-b", theirs) sessions[(VIEWER, "m1")] = _Session("sid-b", theirs)
r = client.get("/api/plex/stream/m1/hls/sid-a/seg_12.ts") r = client.get("/api/plex/stream/m1/hls/sid-a/seg_12.ts")
assert r.status_code == 404, r.text assert r.status_code == 410, r.text
assert b"theirs" not in r.content assert b"theirs" not in r.content
def test_a_playlist_with_no_live_session_is_a_404_not_a_silent_restart_at_zero( def test_a_playlist_with_no_live_session_is_gone_not_a_silent_restart_at_zero(
client, monkeypatch, sessions client, monkeypatch, sessions
): ):
"""The entry playlist used to auto-start a session when none was registered — always at 0.0, """The entry playlist used to auto-start a session when none was registered — always at 0.0,
silently throwing away the viewer's position. A 404 sends the player through its own reclaim, silently throwing away the viewer's position. A 410 sends the player through its own reclaim,
which restarts at the playhead.""" which restarts at the playhead."""
started: list[float] = [] started: list[float] = []
monkeypatch.setattr( monkeypatch.setattr(
@@ -119,7 +119,7 @@ def test_a_playlist_with_no_live_session_is_a_404_not_a_silent_restart_at_zero(
lambda *a, **k: started.append(a[3] if len(a) > 3 else 0.0), lambda *a, **k: started.append(a[3] if len(a) > 3 else 0.0),
) )
r = client.get("/api/plex/stream/m1/hls/sid-gone/index.m3u8") r = client.get("/api/plex/stream/m1/hls/sid-gone/index.m3u8")
assert r.status_code == 404, r.text assert r.status_code == 410, r.text
assert started == [] assert started == []
@@ -235,12 +235,12 @@ def test_an_absurd_segment_index_is_a_404_not_a_500(client, tmp_path, sessions,
assert r.status_code == 404, r.text assert r.status_code == 404, r.text
def test_a_session_torn_down_during_the_read_is_a_404_not_a_500( def test_a_session_torn_down_during_the_read_is_gone_not_a_500(
client, monkeypatch, tmp_path, sessions client, monkeypatch, tmp_path, sessions
): ):
"""Teardown moves the whole directory aside and nothing holds `_lock` across the wait and the read, """Teardown moves the whole directory aside and nothing holds `_lock` across the wait and the read,
so the file can vanish in between — on every seek-restart, since `loadSession` awaits the new so the file can vanish in between — on every seek-restart, since `loadSession` awaits the new
session before destroying the old hls instance. The player's recovery is keyed on the 404 session before destroying the old hls instance. The player's recovery is keyed on the
STATUS, so a 500 here skipped the reclaim and ended playback with "couldn't play".""" STATUS, so a 500 here skipped the reclaim and ended playback with "couldn't play"."""
d = tmp_path / "m1_0_0_+0.00" d = tmp_path / "m1_0_0_+0.00"
d.mkdir() d.mkdir()
@@ -249,7 +249,7 @@ def test_a_session_torn_down_during_the_read_is_a_404_not_a_500(
# The file existed when the wait checked, and is gone by the time the route reads it. # The file existed when the wait checked, and is gone by the time the route reads it.
monkeypatch.setattr(plex_stream, "wait_for", lambda *a, **k: True) monkeypatch.setattr(plex_stream, "wait_for", lambda *a, **k: True)
r = client.get("/api/plex/stream/m1/hls/sid-a/seg_12.ts") r = client.get("/api/plex/stream/m1/hls/sid-a/seg_12.ts")
assert r.status_code == 404, r.text assert r.status_code == 410, r.text
def test_a_dead_producer_is_not_waited_out(client, tmp_path, sessions): def test_a_dead_producer_is_not_waited_out(client, tmp_path, sessions):
+4
View File
@@ -24,6 +24,10 @@ outlives an `expect` timeout); Node does not, so a request from the test side fa
that one error again and passes every other failure straight on. Don't run two e2e suites on the that one error again and passes every other failure straight on. Don't run two e2e suites on the
same machine at once. same machine at once.
**A page load the machine had no buffer for** — on the same host a page load can fail at once with
`net::ERR_NO_BUFFER_SPACE`. Every page load goes through `gotoRetrying` (same file), which tries that
one error again; a test that calls `page.goto` itself fails `src/lib/e2eRetryConnect.test.ts`.
**Browsers** — every flow runs in three projects: `chromium` (Desktop Chrome), `webkit` (Desktop **Browsers** — every flow runs in three projects: `chromium` (Desktop Chrome), `webkit` (Desktop
Safari) and `ipad` (iPad gen 7 landscape: 1080×810, touch, mobile) — except `a11y`, `member` and Safari) and `ipad` (iPad gen 7 landscape: 1080×810, touch, mobile) — except `a11y`, `member` and
`devices`, which run on chromium only (`CHROMIUM_ONLY`; why is in the `docs/gates.md` row). Install the engines once per `devices`, which run on chromium only (`CHROMIUM_ONLY`; why is in the `docs/gates.md` row). Install the engines once per
+2 -2
View File
@@ -4,7 +4,7 @@ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path"; import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { MEMBER_STORAGE, SEED_FILE } from "./helpers"; import { MEMBER_STORAGE, SEED_FILE } from "./helpers";
import { retryConnect } from "./retryConnect"; import { gotoRetrying, retryConnect } from "./retryConnect";
/** /**
* One-time setup for the characterization net (the `setup` project the tests depend on): * One-time setup for the characterization net (the `setup` project the tests depend on):
@@ -52,7 +52,7 @@ async function signIn(page: Page, email: string, path: string): Promise<void> {
// and player flows this net locks all open "/" expecting the feed; the opt-out is the one // and player flows this net locks all open "/" expecting the feed; the opt-out is the one
// Settings → Appearance → Startup writes. Both flags are the account's local state, held for the // Settings → Appearance → Startup writes. Both flags are the account's local state, held for the
// account the browser records as their owner (`siftlode.owner`, see storage.ts claimLocalData). // account the browser records as their owner (`siftlode.owner`, see storage.ts claimLocalData).
await page.goto("/"); await gotoRetrying(page, "/");
await page.evaluate((id: number) => { await page.evaluate((id: number) => {
localStorage.setItem("siftlode.owner", String(id)); localStorage.setItem("siftlode.owner", String(id));
localStorage.setItem("siftlode.lang", "en"); localStorage.setItem("siftlode.lang", "en");
+2 -1
View File
@@ -1,5 +1,6 @@
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
import { expect, type Page } from "@playwright/test"; import { expect, type Page } from "@playwright/test";
import { gotoRetrying } from "./retryConnect";
/** The plain member's saved session (global.setup.ts signs it in); `member.spec.ts` and /** The plain member's saved session (global.setup.ts signs it in); `member.spec.ts` and
* `devices.spec.ts` run from it. */ * `devices.spec.ts` run from it. */
@@ -16,7 +17,7 @@ export function seeded(key: string): string[] {
/** Open the app and wait for the authenticated shell (the nav rail) to be ready. */ /** Open the app and wait for the authenticated shell (the nav rail) to be ready. */
export async function openApp(page: Page, path = "/"): Promise<void> { export async function openApp(page: Page, path = "/"): Promise<void> {
await page.goto(path); await gotoRetrying(page, path);
await expect(page.getByTestId("nav-feed")).toBeVisible(); await expect(page.getByTestId("nav-feed")).toBeVisible();
} }
+3 -1
View File
@@ -66,11 +66,13 @@ async function carry(page: Page, grip: Locator, to: { x: number; y: number }) {
/** The same onto the rail. An unpinned one is a tab until the drag's own hover peeks it open. */ /** The same onto the rail. An unpinned one is a tab until the drag's own hover peeks it open. */
async function carryToRail(page: Page, grip: Locator, target: Locator) { async function carryToRail(page: Page, grip: Locator, target: Locator) {
const tab = page.getByRole("button", { name: "Pin Playlists open" });
// The rail's lists answer after the rows: until then a pinned rail shows neither the list nor a tab.
await target.or(tab).first().waitFor();
if (await target.isVisible()) { if (await target.isVisible()) {
await carry(page, grip, await centre(target)); await carry(page, grip, await centre(target));
return; return;
} }
const tab = page.getByRole("button", { name: "Pin Playlists open" });
await carry(page, grip, await centre(tab)); await carry(page, grip, await centre(tab));
await expect(target).toBeVisible(); await expect(target).toBeVisible();
await page.mouse.move(...(Object.values(await centre(target)) as [number, number]), { steps: 6 }); await page.mouse.move(...(Object.values(await centre(target)) as [number, number]), { steps: 6 });
+26 -11
View File
@@ -11,6 +11,19 @@ const METHODS = new Set(["get", "post", "put", "patch", "delete", "head", "fetch
const NO_CONNECTION = /\bconnect ETIMEDOUT\b/; const NO_CONNECTION = /\bconnect ETIMEDOUT\b/;
const WAITS_MS = [500, 1000, 2000, 4000, 8000]; const WAITS_MS = [500, 1000, 2000, 4000, 8000];
/** `attempt` again after each wait while it fails with `transient`; any other failure goes on. */
async function tryAgain<R>(attempt: () => Promise<R>, transient: RegExp): Promise<R> {
for (let i = 0; ; i++) {
try {
return await attempt();
} catch (e) {
const wait = WAITS_MS[i];
if (wait === undefined || !transient.test(String((e as Error)?.message))) throw e;
await new Promise((r) => setTimeout(r, wait));
}
}
}
/** `ctx` with its request methods trying a connect that timed out again (see above). */ /** `ctx` with its request methods trying a connect that timed out again (see above). */
export function retryConnect<T extends object>(ctx: T): T { export function retryConnect<T extends object>(ctx: T): T {
return new Proxy(ctx, { return new Proxy(ctx, {
@@ -18,17 +31,19 @@ export function retryConnect<T extends object>(ctx: T): T {
const v: unknown = Reflect.get(target, key, target); const v: unknown = Reflect.get(target, key, target);
if (typeof v !== "function") return v; if (typeof v !== "function") return v;
if (typeof key !== "string" || !METHODS.has(key)) return v.bind(target); if (typeof key !== "string" || !METHODS.has(key)) return v.bind(target);
return async (...args: unknown[]) => { return (...args: unknown[]) =>
for (let i = 0; ; i++) { tryAgain(() => Promise.resolve(v.apply(target, args)), NO_CONNECTION);
try {
return await v.apply(target, args);
} catch (e) {
const wait = WAITS_MS[i];
if (wait === undefined || !NO_CONNECTION.test(String((e as Error)?.message))) throw e;
await new Promise((r) => setTimeout(r, wait));
}
}
};
}, },
}); });
} }
// A page load has a failure of its own on the same machine: `net::ERR_NO_BUFFER_SPACE`, the socket
// call refused for the moment. It stopped a release's e2e at its first page (0.116.3, with nothing
// else running; 0.107.3 aborted on it too), and the unchanged rerun passed. A browser does not try
// a main document again by itself, and the request never left, so this does. The suite's every page
// load goes through here (`e2eRetryConnect.test.ts`).
const NO_BUFFER = /\bERR_NO_BUFFER_SPACE\b/;
export function gotoRetrying<R>(page: { goto(url: string): Promise<R> }, url: string): Promise<R> {
return tryAgain(() => page.goto(url), NO_BUFFER);
}
+3 -3
View File
@@ -1,7 +1,7 @@
import { test, type Page } from "@playwright/test"; import { test, type Page } from "@playwright/test";
import { execFileSync } from "node:child_process"; import { execFileSync } from "node:child_process";
import { mkdirSync, writeFileSync } from "node:fs"; import { mkdirSync, writeFileSync } from "node:fs";
import { retryConnect } from "./retryConnect"; import { gotoRetrying, retryConnect } from "./retryConnect";
/** /**
* The speed baseline: numbers, not assertions — read by `scripts/audit_metrics.py --speed`, which * The speed baseline: numbers, not assertions — read by `scripts/audit_metrics.py --speed`, which
@@ -78,7 +78,7 @@ test("speed baseline", async ({ browser, page: page0 }) => {
for (let i = 0; i < RUNS; i++) { for (let i = 0; i < RUNS; i++) {
const ctx = await browser.newContext(); const ctx = await browser.newContext();
const page = await ctx.newPage(); const page = await ctx.newPage();
await page.goto("/"); await gotoRetrying(page, "/");
// Timed inside the page, every frame: an `expect` would read the clock on its own retry // Timed inside the page, every frame: an `expect` would read the clock on its own retry
// schedule (100 ms … 1 s steps), not when the card appeared. // schedule (100 ms … 1 s steps), not when the card appeared.
const at = await page.waitForFunction( const at = await page.waitForFunction(
@@ -108,7 +108,7 @@ test("speed baseline", async ({ browser, page: page0 }) => {
for (let i = 0; i < RUNS; i++) { for (let i = 0; i < RUNS; i++) {
const ctx = await browser.newContext({ storageState: { cookies: [], origins: [] } }); const ctx = await browser.newContext({ storageState: { cookies: [], origins: [] } });
const page = await ctx.newPage(); const page = await ctx.newPage();
await page.goto(`/watch/${token}`); await gotoRetrying(page, `/watch/${token}`);
await page.waitForFunction(() => document.querySelector("video, audio"), null, { await page.waitForFunction(() => document.querySelector("video, audio"), null, {
polling: "raf", polling: "raf",
timeout: 30_000, timeout: 30_000,
+61
View File
@@ -0,0 +1,61 @@
import { test, expect, type Page } from "@playwright/test";
import { openApp } from "./helpers";
import { gotoRetrying } from "./retryConnect";
// A request can be lost on the way on a loaded machine or a poor line. A lost stylesheet leaves the
// whole page unstyled, and every test that finds its elements by testid still passes on it: only an
// axe run ever noticed (8 controls under the target size). So this looks at the styles themselves.
/** Lose the first request for every stylesheet of the page; the ones after it go through. */
async function loseEachStylesheetOnce(page: Page): Promise<string[]> {
const lost: string[] = [];
await page.route(/\/assets\/[^/]+\.css/, async (route) => {
const url = route.request().url();
if (lost.includes(url)) return route.continue();
lost.push(url);
return route.abort("failed");
});
return lost;
}
/** Every stylesheet link of the page holds rules (a lost one still has a `sheet`, whose rules throw
* on reading), and the page's own tokens are in force: `--bg` comes from the stylesheet alone, while
* `--font-scale` would prove nothing, the app also sets it from script. */
async function expectStyled(page: Page): Promise<void> {
await expect
.poll(() =>
page.evaluate(() => {
const hasRules = (l: HTMLLinkElement) => {
try {
return (l.sheet?.cssRules.length ?? 0) > 0;
} catch {
return false;
}
};
const links = [...document.querySelectorAll<HTMLLinkElement>('link[rel="stylesheet"]')];
const root = getComputedStyle(document.documentElement);
return {
links: links.length > 0,
rules: links.every(hasRules),
tokens: root.getPropertyValue("--bg").trim() !== "",
};
}),
)
.toEqual({ links: true, rules: true, tokens: true });
}
test("the app gets back a stylesheet lost on the way", async ({ page }) => {
const lost = await loseEachStylesheetOnce(page);
await openApp(page);
expect(lost.length).toBeGreaterThan(0);
await expectStyled(page);
});
test("a public /watch link gets back a stylesheet lost on the way", async ({ page }) => {
const lost = await loseEachStylesheetOnce(page);
// No such link: the page says so, and it is the same entry with the same stylesheet.
await gotoRetrying(page, "/watch/no-such-token");
await expect(page.locator("#root")).not.toBeEmpty();
expect(lost.length).toBeGreaterThan(0);
await expectStyled(page);
});
@@ -1,4 +1,29 @@
[ [
{
"version": "0.116.3",
"date": "2026-11-04",
"new": [],
"fixed": [
"If a stylesheet is lost on the way, the app now asks for it again, so a poor connection no longer leaves the page unstyled until a reload."
]
},
{
"version": "0.116.2",
"date": "2026-11-03",
"new": [],
"fixed": [
"A Plex video paused or muted in a background tab no longer loses its session after a minute, and if a session is lost anyway, playback comes back at once instead of after half a minute."
]
},
{
"version": "0.116.1",
"date": "2026-11-03",
"new": [],
"fixed": [
"A playlist now shows the order the server holds after a revert to YouTube, a sync or a change on another device, and the next drag no longer brings back the old one.",
"An Undo on a removed or moved row puts it back in the right place while another change is still saving."
]
},
{ {
"version": "0.116.0", "version": "0.116.0",
"date": "2026-11-03", "date": "2026-11-03",
@@ -1,4 +1,29 @@
[ [
{
"version": "0.116.3",
"date": "2026-11-04",
"new": [],
"fixed": [
"Ha egy stíluslap elvész útközben, az app most újra lekéri, így a rossz kapcsolat miatt nem marad formázatlan az oldal újratöltésig."
]
},
{
"version": "0.116.2",
"date": "2026-11-03",
"new": [],
"fixed": [
"A háttérfülön szüneteltetett vagy némított Plex-videó már nem veszíti el a munkamenetét egy perc után, és ha mégis elveszne, a lejátszás azonnal visszatér, nem fél perc múlva."
]
},
{
"version": "0.116.1",
"date": "2026-11-03",
"new": [],
"fixed": [
"A lista most a szerveren lévő sorrendet mutatja a YouTube-ra visszaállítás, a szinkron vagy egy másik eszközön végzett módosítás után, és a következő húzás már nem hozza vissza a régit.",
"Az eltávolított vagy áthelyezett sor Visszavonása akkor is jó helyre teszi vissza a sort, ha közben egy másik módosítás még mentés alatt áll."
]
},
{ {
"version": "0.116.0", "version": "0.116.0",
"date": "2026-11-03", "date": "2026-11-03",
+42 -1
View File
@@ -1,7 +1,8 @@
// The e2e suite's request wrapper (e2e/retryConnect.ts): a connect that never came about is tried // The e2e suite's request wrapper (e2e/retryConnect.ts): a connect that never came about is tried
// again, as every TCP stack would; any other failure, or an answer, goes straight to the test. // again, as every TCP stack would; any other failure, or an answer, goes straight to the test.
import { readdirSync, readFileSync } from "node:fs";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { retryConnect } from "../../e2e/retryConnect"; import { gotoRetrying, retryConnect } from "../../e2e/retryConnect";
const noConnection = () => new Error("apiRequestContext.get: connect ETIMEDOUT 127.0.0.1:8080"); const noConnection = () => new Error("apiRequestContext.get: connect ETIMEDOUT 127.0.0.1:8080");
@@ -39,3 +40,43 @@ describe("retryConnect", () => {
vi.useRealTimers(); vi.useRealTimers();
}); });
}); });
// The text Playwright gave when this stopped a release's e2e at its first page load (2026-11-04).
const noBuffer = () => new Error("page.goto: net::ERR_NO_BUFFER_SPACE at http://127.0.0.1:8080/");
describe("gotoRetrying", () => {
it("tries a page load the machine had no buffer for again", async () => {
vi.useFakeTimers();
const goto = vi.fn().mockRejectedValueOnce(noBuffer()).mockResolvedValue("response");
const answer = gotoRetrying({ goto }, "/feed");
await vi.runAllTimersAsync();
await expect(answer).resolves.toBe("response");
expect(goto).toHaveBeenCalledTimes(2);
expect(goto).toHaveBeenLastCalledWith("/feed");
vi.useRealTimers();
});
it("passes any other failure straight on, untried", async () => {
const goto = vi.fn().mockRejectedValue(new Error("page.goto: net::ERR_CONNECTION_REFUSED"));
await expect(gotoRetrying({ goto }, "/")).rejects.toThrow("ERR_CONNECTION_REFUSED");
expect(goto).toHaveBeenCalledTimes(1);
});
it("gives up after its last wait, with the error", async () => {
vi.useFakeTimers();
const goto = vi.fn().mockRejectedValue(noBuffer());
const settled = expect(gotoRetrying({ goto }, "/")).rejects.toThrow("ERR_NO_BUFFER_SPACE");
await vi.runAllTimersAsync();
await settled;
expect(goto).toHaveBeenCalledTimes(6);
vi.useRealTimers();
});
it("is the only way the suite loads a page", () => {
const dir = new URL("../../e2e/", import.meta.url);
const direct = readdirSync(dir)
.filter((f) => f.endsWith(".ts") && f !== "retryConnect.ts")
.filter((f) => /\.(goto|reload)\(/.test(readFileSync(new URL(f, dir), "utf-8")));
expect(direct).toEqual([]);
});
});
+98 -1
View File
@@ -65,6 +65,27 @@ class FakeHls implements HlsPort {
lost404() { lost404() {
this.lost(404); this.lost(404);
} }
/** A request answered "this session is gone". hls.js only reports it (not fatal) and retries for
* half a minute: with one video variant it has nowhere else to go. */
gone() {
this.handlers.onError({
fatal: false,
network: true,
status: 410,
type: "networkError",
details: "fragLoadError",
});
}
/** hls.js reports a failed request it will retry by itself. */
retrying(status: number) {
this.handlers.onError({
fatal: false,
network: true,
status,
type: "networkError",
details: "fragLoadError",
});
}
/** hls.js gave up on a playlist or fragment with this answer (null: no answer at all). */ /** hls.js gave up on a playlist or fragment with this answer (null: no answer at all). */
lost(status: number | null) { lost(status: number | null) {
this.handlers.onError({ this.handlers.onError({
@@ -353,7 +374,7 @@ describe("a failed session request", () => {
}); });
}); });
describe("a session the server no longer has (404)", () => { describe("something that never arrives (a fatal 404)", () => {
it("is reclaimed where the viewer is", async () => { it("is reclaimed where the viewer is", async () => {
const { engine, asks, hlses, state } = setup(); const { engine, asks, hlses, state } = setup();
engine.start("A", 600); engine.start("A", 600);
@@ -418,9 +439,85 @@ describe("a session the server no longer has (404)", () => {
}); });
}); });
describe("a session the server says is gone (410)", () => {
async function playing() {
const s = setup();
s.engine.start("A", 600);
await s.asks[0]!.resolve(hls("A", 600));
s.hlses[0]!.handlers.onManifestParsed();
s.media.fire("canplay");
s.state.position = 1299;
return s;
}
it("is reclaimed at the first answer, not after hls.js's half minute of retries", async () => {
const { asks, hlses } = await playing();
hlses[0]!.gone();
expect(asks[1]!.startAt).toBe(1299);
});
it("keeps the running picture until the new session answers", async () => {
const { engine, hlses } = await playing();
expect(engine.getSnapshot().ready).toBe(true);
hlses[0]!.gone();
expect(engine.getSnapshot().ready).toBe(true);
});
it("asks once, whatever the same instance still reports", async () => {
const { asks, hlses } = await playing();
hlses[0]!.gone();
hlses[0]!.gone();
hlses[0]!.lost(410);
expect(asks).toHaveLength(2);
});
it(`shares the reclaim budget, and says so after ${MAX_SESSION_RECLAIMS}`, async () => {
const { engine, asks, hlses } = setup();
engine.start("A", 600);
for (let i = 0; i <= MAX_SESSION_RECLAIMS; i++) {
await asks[i]!.resolve(hls("A", 600));
hlses[i]!.gone();
}
expect(asks).toHaveLength(MAX_SESSION_RECLAIMS + 1);
expect(engine.getSnapshot().error).toBe("generic");
});
it("keeps a seek made while it is out, inside what the old session still reports", async () => {
const { engine, asks, hlses, media } = await playing();
media.seekEnd = 800;
hlses[0]!.gone();
media.currentTime = 42;
engine.seek("A", 1250);
expect(media.currentTime).toBe(42); // the outgoing media stays where it is
await asks[1]!.resolve(hls("A", 1299));
expect(asks.map((a) => a.startAt)).toEqual([600, 1299, 1250]);
});
it("leaves a segment that is merely not there yet to hls.js's retries", async () => {
const { engine, asks, hlses } = await playing();
hlses[0]!.retrying(404);
expect(asks).toHaveLength(1);
expect(engine.getSnapshot().error).toBeNull();
});
});
describe("a server that is restarting (a deploy)", () => { describe("a server that is restarting (a deploy)", () => {
// The gateway answers 502 while the app restarts, and nothing at all may answer (status 0): the // The gateway answers 502 while the app restarts, and nothing at all may answer (status 0): the
// stream is not broken, the server is away. The buffer keeps playing meanwhile. // stream is not broken, the server is away. The buffer keeps playing meanwhile.
it("keeps a seek made while the retry is out", async () => {
const { engine, asks, hlses, state, fire, media } = setup();
engine.start("A", 600);
await asks[0]!.resolve(hls("A", 600));
hlses[0]!.handlers.onManifestParsed();
media.seekEnd = 800;
state.position = 1299;
hlses[0]!.lost(502);
expect(fire()).toBe(true);
engine.seek("A", 1250);
await asks[1]!.resolve(hls("A", 1299));
expect(asks.map((a) => a.startAt)).toEqual([600, 1299, 1250]);
});
it.each([502, 503, 504, 0, null])( it.each([502, 503, 504, 0, null])(
"is waited out on %s: no error, and the stream comes back where the viewer is", "is waited out on %s: no error, and the stream comes back where the viewer is",
async (status) => { async (status) => {
+53 -31
View File
@@ -14,7 +14,7 @@ import type { HlsLevelInfo } from "./plexStats";
// once: twenty presses of ← at key-repeat pace past the buffer were twenty requests, twenty ffmpeg // once: twenty presses of ← at key-repeat pace past the buffer were twenty requests, twenty ffmpeg
// spawns, and five seconds before the picture moved again. // spawns, and five seconds before the picture moved again.
// Reclaims of a 404'd HLS session, within `RECLAIM_WINDOW_MS`, before the player stops and says so. // Reclaims of a lost HLS session, within `RECLAIM_WINDOW_MS`, before the player stops and says so.
// Two covers the real case (the session was reaped, once) without letting a server that cannot serve // Two covers the real case (the session was reaped, once) without letting a server that cannot serve
// the stream keep the viewer on a spinner (see `onHlsError`). // the stream keep the viewer on a spinner (see `onHlsError`).
export const MAX_SESSION_RECLAIMS = 2; export const MAX_SESSION_RECLAIMS = 2;
@@ -33,6 +33,8 @@ export const RECLAIM_WINDOW_MS = 120_000;
export const AWAY_WINDOW_MS = 180_000; export const AWAY_WINDOW_MS = 180_000;
const AWAY_RETRY_MS = [2_000, 4_000, 8_000, 15_000]; const AWAY_RETRY_MS = [2_000, 4_000, 8_000, 15_000];
const SERVER_AWAY = new Set([0, 502, 503, 504]); const SERVER_AWAY = new Set([0, 502, 503, 504]);
// The server's answer for a session it no longer has; its 404 means a segment not there yet.
const SESSION_GONE = 410;
/** Why a session could not play, in the three buckets the player has a sentence for. */ /** Why a session could not play, in the three buckets the player has a sentence for. */
type EngineError = "transcode" | "notFound" | "generic"; type EngineError = "transcode" | "notFound" | "generic";
@@ -183,7 +185,7 @@ export class HlsEngine {
// the way back to the first item then sent a second request beside the one still out for it. // the way back to the first item then sent a second request beside the one still out for it.
private readonly inFlight = new Map<string, Request>(); private readonly inFlight = new Map<string, Request>();
private queued: Request | null = null; private queued: Request | null = null;
// How often a session the server answered 404 for may be reclaimed (see `onHlsError`). // How often a session the server lost may be reclaimed (see `onHlsError`).
private reclaims = { n: 0, at: 0 }; private reclaims = { n: 0, at: 0 };
// Since when the server has been away, and how often it was asked since (see `serverAway`). // Since when the server has been away, and how often it was asked since (see `serverAway`).
private away: { since: number; tries: number } | null = null; private away: { since: number; tries: number } | null = null;
@@ -283,7 +285,7 @@ export class HlsEngine {
/** (Re)start `id` at `startAt` seconds. Another item's request goes out at once; one for the item /** (Re)start `id` at `startAt` seconds. Another item's request goes out at once; one for the item
* already being asked for waits for that answer (see the header). `quiet`: the attached stream is * already being asked for waits for that answer (see the header). `quiet`: the attached stream is
* still playing from its buffer (the server went away under it), so it stays the player's until * still playing from its buffer (the server went away or lost its session), so it stays the player's until
* the answer replaces it — no "Loading…" over a running picture, its clock still true. */ * the answer replaces it — no "Loading…" over a running picture, its clock still true. */
start(id: string, startAt: number, play = true, quiet = false): void { start(id: string, startAt: number, play = true, quiet = false): void {
if (!this.deps.media()) return; if (!this.deps.media()) return;
@@ -459,42 +461,61 @@ export class HlsEngine {
}; };
} }
// A fatal HLS error (the session died, the file became unreadable) would otherwise leave the // A fatal HLS error (the file became unreadable) would otherwise leave the spinner up forever — it
// spinner up forever — it is said instead. // is said instead.
// //
// Except a 404: the session is gone, not broken. Either the server reaped it (a pause longer than // Except a session the server no longer has (410): gone, not broken. Either the server reaped it
// its idle limit, a deploy) — hls.js never retries a 4xx, so this used to end playback with // or restarted, or it was superseded before it ever played (two requests for one item: the
// "couldn't play" after the buffer ran out — or it was superseded before it ever played (two // server's LAST spawn wins), and the reclaim is the asking again that answer wants. It does not
// requests for one item: the server's LAST spawn wins). The server answers a superseded session // wait for hls.js to give up. On a finished playlist, with one video variant and nothing to
// exactly so the client asks again; the reclaim is that asking. // switch to, it retries even a 4xx, six times over about half a minute (measured: seven requests,
// then the reclaim), and in a throttled page once a minute (four minutes). On a growing one it
// takes the segment for a gap and moves on, so the error is never fatal. Either way the first
// answer is reported. Quiet, since the buffer is still playing: the picture runs on until the
// answer, which restarts from where it was asked, so what played meanwhile is replayed (accepted:
// a brief loading state marks it, and nothing the viewer has saved is lost).
// //
// Bounded by the budget, not by whether the session got as far as playing: that belongs to ONE // And except a fatal 404: hls.js gave up on something the server still does not have, and asking
// session, and every restart builds a fresh one, so it could never bound a loop. The server also answers // again is all that is left.
// 404 for "that segment is not there YET" — indistinguishable on the wire — so a transcode falling //
// behind realtime restarted ffmpeg forever behind a spinner that never became an error. // Both are bounded by the budget, not by whether the session got as far as playing: that belongs to
// ONE session, and every restart builds a fresh one, so it could never bound a loop. A transcode
// falling behind realtime restarted ffmpeg forever behind a spinner that never became an error.
private onHlsError(req: Request, media: HTMLMediaElement, e: HlsFailure): void { private onHlsError(req: Request, media: HTMLMediaElement, e: HlsFailure): void {
this.deps.onHlsError?.(e); this.deps.onHlsError?.(e);
if (!e.fatal || !this.current(req)) return; if (!this.current(req)) return;
if (e.network && e.status === SESSION_GONE) {
this.reclaim(req, media, this.snapshot.ready);
return;
}
if (!e.fatal) return;
if (e.network && SERVER_AWAY.has(e.status ?? 0) && this.serverAway(req)) return; if (e.network && SERVER_AWAY.has(e.status ?? 0) && this.serverAway(req)) return;
if (e.network && e.status === 404) { if (e.network && e.status === 404) {
const now = this.deps.now(); this.reclaim(req, media, false);
// A new window when the last loss was long enough ago. The anchor moves with EVERY loss: left return;
// at the window's start, the window tumbled instead of rolling, and any loss period longer
// than half the window reset the budget on every second loss (measured: 200 losses 70 s apart
// → 200 reclaims, never an error).
if (now - this.reclaims.at > RECLAIM_WINDOW_MS) this.reclaims.n = 0;
this.reclaims.at = now;
if (this.reclaims.n < MAX_SESSION_RECLAIMS) {
this.reclaims.n += 1;
// The server dropped it already; nothing to stop.
this.attachedSession = null;
this.start(req.id, this.playhead(), this.wantsPlay(media));
return;
}
} }
this.set({ error: "generic" }); this.set({ error: "generic" });
} }
/** Ask for `req`'s item again where the viewer is, while the budget lasts; then say it cannot play. */
private reclaim(req: Request, media: HTMLMediaElement, quiet: boolean): void {
const now = this.deps.now();
// A new window when the last loss was long enough ago. The anchor moves with EVERY loss: left
// at the window's start, the window tumbled instead of rolling, and any loss period longer
// than half the window reset the budget on every second loss (measured: 200 losses 70 s apart
// → 200 reclaims, never an error).
if (now - this.reclaims.at > RECLAIM_WINDOW_MS) this.reclaims.n = 0;
this.reclaims.at = now;
if (this.reclaims.n >= MAX_SESSION_RECLAIMS) {
this.set({ error: "generic" });
return;
}
this.reclaims.n += 1;
// The server dropped it already; nothing to stop.
this.attachedSession = null;
this.start(req.id, this.playhead(), this.wantsPlay(media), quiet);
}
/** The server is away (see `AWAY_WINDOW_MS`): ask for `req`'s item again where the viewer is, /** The server is away (see `AWAY_WINDOW_MS`): ask for `req`'s item again where the viewer is,
* after a delay that grows with each try. False once it has been away too long — the caller then * after a delay that grows with each try. False once it has been away too long — the caller then
* says it cannot play. A restart the viewer asks for meanwhile (a seek, another item) replaces the * says it cannot play. A restart the viewer asks for meanwhile (a seek, another item) replaces the
@@ -548,9 +569,10 @@ export class HlsEngine {
const rel = T - this.sessionStart.current; const rel = T - this.sessionStart.current;
// Native only when the attached media IS the current session. While one is still being asked // Native only when the attached media IS the current session. While one is still being asked
// for, `sessionStart` and `seekable` describe the OLD one: a native seek there left the pending // for, `sessionStart` and `seekable` describe the OLD one: a native seek there left the pending
// restart current, and its answer then jumped to the earlier target. // restart current, and its answer then jumped to the earlier target. A quiet request leaves
// `live` alone, so `pending` is what says one is out.
const end = media.seekable.length ? media.seekable.end(0) : 0; const end = media.seekable.length ? media.seekable.end(0) : 0;
if (this.live && rel >= 0 && rel <= end + 0.5) { if (this.live && !this.pending && rel >= 0 && rel <= end + 0.5) {
media.currentTime = rel; // keeps playing on its own media.currentTime = rel; // keeps playing on its own
if (play) media.play().catch(() => {}); if (play) media.play().catch(() => {});
} else { } else {
@@ -0,0 +1,105 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { clear, events } from "./log";
import { retryLostStylesheets } from "./stylesheetRetry";
const LOADED = { cssRules: [{}] };
// A lost stylesheet still has a `sheet`, and reading its rules throws (measured: Chromium, WebKit).
const LOST = {
get cssRules(): never {
throw new DOMException("Cannot access rules", "SecurityError");
},
};
function link(href: string, loaded: boolean, sheet: unknown = loaded ? LOADED : LOST) {
const el = document.createElement("link");
el.rel = "stylesheet";
el.href = href;
el.setAttribute("crossorigin", "");
Object.defineProperty(el, "sheet", { value: sheet, configurable: true });
document.head.append(el);
return el;
}
const links = () => [...document.head.querySelectorAll<HTMLLinkElement>('link[rel="stylesheet"]')];
const logged = () => events().map((e) => e.event);
describe("retryLostStylesheets", () => {
beforeEach(() => {
vi.useFakeTimers();
clear();
});
afterEach(() => {
vi.useRealTimers();
document.head.innerHTML = "";
});
it("leaves a loaded stylesheet alone", () => {
const fine = link("/a.css", true);
retryLostStylesheets();
vi.runAllTimers();
expect(links()).toEqual([fine]);
expect(logged()).toEqual([]);
});
it("asks again for a lost one, in its place, keeping its attributes", () => {
const first = link("/a.css", true);
const lost = link("/b.css", false);
const last = link("/c.css", true);
retryLostStylesheets();
vi.advanceTimersByTime(0);
const [a, b, c] = links();
expect(a).toBe(first);
expect(b).not.toBe(lost);
expect(b!.getAttribute("href")).toBe("/b.css");
expect(b!.hasAttribute("crossorigin")).toBe(true);
expect(c).toBe(last);
expect(logged()).toEqual(["app.stylesheet_lost"]);
});
it.each([
["no sheet at all", null],
["an empty sheet", { cssRules: [] }],
])("takes a link with %s for lost too", (_, sheet) => {
const lost = link("/a.css", false, sheet);
retryLostStylesheets();
vi.advanceTimersByTime(0);
expect(links()[0]).not.toBe(lost);
});
it("says so once the new request has loaded", () => {
link("/a.css", false);
retryLostStylesheets();
vi.advanceTimersByTime(0);
links()[0]!.dispatchEvent(new Event("load"));
vi.runAllTimers();
expect(logged()).toEqual(["app.stylesheet_lost", "app.stylesheet_recovered"]);
});
it("waits longer each time and then gives up, reporting it", () => {
link("/a.css", false);
retryLostStylesheets();
const fail = () => links()[0]!.dispatchEvent(new Event("error"));
vi.advanceTimersByTime(0);
const second = links()[0];
fail();
vi.advanceTimersByTime(999);
expect(links()[0]).toBe(second);
vi.advanceTimersByTime(1);
const third = links()[0];
expect(third).not.toBe(second);
fail();
vi.advanceTimersByTime(2999);
expect(links()[0]).toBe(third);
vi.advanceTimersByTime(1);
const fourth = links()[0];
expect(fourth).not.toBe(third);
fail();
vi.runAllTimers();
expect(links()[0]).toBe(fourth);
expect(logged()).toEqual(["app.stylesheet_lost", "app.stylesheet_gave_up"]);
});
});
+50
View File
@@ -0,0 +1,50 @@
// A stylesheet can be lost on the way (a poor line, a loaded machine), and a browser never asks again
// for one by itself: the page then stays unstyled until a reload, every control a bare browser button.
// A module script waits for the document's stylesheets (measured: a sheet held 3 s held the module
// too), so by the time this runs each link has either loaded or failed, and no event is left to wait
// for. A lost one is asked for again in its own place, so the cascade's order stays as it was built.
//
// All of these sheets are this site's own: reading the rules of a loaded one is allowed, so a throw
// says it failed (measured: Chromium, WebKit). Three tries, then the page stays as it is. A reload here
// could loop, and the unstyled page still works.
import { log } from "./log";
const RETRY_AFTER_MS = [0, 1000, 3000];
function holdsRules(link: HTMLLinkElement): boolean {
try {
return (link.sheet?.cssRules.length ?? 0) > 0;
} catch {
return false;
}
}
function askAgain(link: HTMLLinkElement, attempt: number): void {
const wait = RETRY_AFTER_MS[attempt];
if (wait === undefined) {
log.error("app.stylesheet_gave_up", "a stylesheet could not be loaded", { href: link.href });
return;
}
window.setTimeout(() => {
const next = link.cloneNode(false) as HTMLLinkElement;
next.addEventListener("load", () =>
log.info("app.stylesheet_recovered", "a lost stylesheet loaded on a new request", {
href: next.href,
attempt: attempt + 1,
}),
);
next.addEventListener("error", () => askAgain(next, attempt + 1));
link.replaceWith(next);
}, wait);
}
/** Run once, from a page's entry module. */
export function retryLostStylesheets(): void {
for (const link of document.querySelectorAll<HTMLLinkElement>('link[rel="stylesheet"]')) {
if (holdsRules(link)) continue;
log.warn("app.stylesheet_lost", "a stylesheet was lost on the way; asking again", {
href: link.href,
});
askAgain(link, 0);
}
}
+10
View File
@@ -18,6 +18,16 @@ describe("useUndoable", () => {
expect(result.current.value).toEqual([10, 20]); expect(result.current.value).toEqual([10, 20]);
}); });
it("peeks at a value set in this tick, before it renders", () => {
const { result } = renderHook(() => useUndoable<number[]>([1]));
const { peek, set } = result.current;
act(() => {
set([1, 2]);
expect(peek()).toEqual([1, 2]);
});
expect(peek()).toEqual([1, 2]);
});
it("keeps the undo history through a rebase", () => { it("keeps the undo history through a rebase", () => {
const { result } = renderHook(() => useUndoable<number[]>([1])); const { result } = renderHook(() => useUndoable<number[]>([1]));
act(() => result.current.set([1, 2])); act(() => result.current.set([1, 2]));
+5
View File
@@ -16,6 +16,8 @@ import { useLatest } from "./useLatest";
// Render never reads the ref: a compiled component would otherwise memoize a stale value. // Render never reads the ref: a compiled component would otherwise memoize a stale value.
export interface Undoable<T> { export interface Undoable<T> {
value: T; value: T;
/** The value as of NOW, with a `set` not rendered yet, for code that runs after an await. */
peek: () => T;
set: (next: T) => void; set: (next: T) => void;
reset: (value: T) => void; reset: (value: T) => void;
rebase: (map: (value: T) => T) => void; rebase: (map: (value: T) => T) => void;
@@ -110,8 +112,11 @@ export function useUndoable<T>(
setH(over); setH(over);
}, []); }, []);
const peek = useCallback(() => ref.current.present, []);
return { return {
value: h.present, value: h.present,
peek,
set, set,
reset, reset,
rebase, rebase,
+3
View File
@@ -17,12 +17,15 @@ import "flag-icons/css/flag-icons.min.css";
import { lazyWithPreload } from "./lib/lazyWithPreload"; import { lazyWithPreload } from "./lib/lazyWithPreload";
import { installClientLog } from "./lib/logHooks"; import { installClientLog } from "./lib/logHooks";
import { installStaleBuildReload } from "./lib/staleBuild"; import { installStaleBuildReload } from "./lib/staleBuild";
import { retryLostStylesheets } from "./lib/stylesheetRetry";
import { appCopyOf } from "./i18n/appCopy"; import { appCopyOf } from "./i18n/appCopy";
// First, before any render: a crash or a navigation during boot belongs in the log too. // First, before any render: a crash or a navigation during boot belongs in the log too.
installClientLog(appCopyOf, typedUrlKeys()); installClientLog(appCopyOf, typedUrlKeys());
// A tab from before the last release reloads onto the new build instead of crashing on a lazy chunk. // A tab from before the last release reloads onto the new build instead of crashing on a lazy chunk.
installStaleBuildReload(); installStaleBuildReload();
// A stylesheet lost on the way is asked for again rather than leaving the page unstyled.
retryLostStylesheets();
// Split by top-level route so each entry point is its own chunk: a legal page never downloads the // Split by top-level route so each entry point is its own chunk: a legal page never downloads the
// authenticated app bundle, and the app never carries it either. The legal pages live outside the // authenticated app bundle, and the app never carries it either. The legal pages live outside the
@@ -2290,12 +2290,15 @@ describe("Playlists — writes that overlap", () => {
}; };
it("keeps a row out when a read sent before its removal lands after it, and its history too", async () => { it("keeps a row out when a read sent before its removal lands after it, and its history too", async () => {
vi.spyOn(api, "reorderPlaylist").mockResolvedValue(undefined as never); const lists = { 1: [...abc()[1], vid("D", 0)] };
vi.spyOn(api, "reorderPlaylist").mockImplementation((async () => {
lists[1] = [vid("A", 0), vid("C", 0), vid("B", 0), vid("D", 0)];
}) as never);
const second = later<unknown>(); const second = later<unknown>();
vi.spyOn(api, "removeManyFromPlaylist") vi.spyOn(api, "removeManyFromPlaylist")
.mockResolvedValueOnce(wentOut("A") as never) .mockResolvedValueOnce(wentOut("A") as never)
.mockReturnValueOnce(second.promise as never); .mockReturnValueOnce(second.promise as never);
mount({ 1: [...abc()[1], vid("D", 0)] }); mount(lists);
await inPage(); await inPage();
await rowMenu("Video C", "common.order.up"); await rowMenu("Video C", "common.order.up");
await waitFor(() => expect(undoOrder().disabled).toBe(false)); await waitFor(() => expect(undoOrder().disabled).toBe(false));
@@ -2591,16 +2594,17 @@ describe("Playlists — writes that overlap", () => {
}); });
it("keeps a row out, and the history, when a read from before its removal landed and a patch comes after", async () => { it("keeps a row out, and the history, when a read from before its removal landed and a patch comes after", async () => {
vi.spyOn(api, "reorderPlaylist").mockResolvedValue(undefined as never);
const write = later<unknown>(); const write = later<unknown>();
vi.spyOn(api, "removeManyFromPlaylist").mockReturnValue(write.promise as never); vi.spyOn(api, "removeManyFromPlaylist").mockReturnValue(write.promise as never);
const lists = abc(); const lists = abc();
vi.spyOn(api, "reorderPlaylist").mockImplementation((async () => {
lists[1] = [vid("A", 0), vid("C", 0), vid("B", 0)];
}) as never);
mount(lists); mount(lists);
await inPage(); await inPage();
await landed(); await landed();
await rowMenu("Video C", "common.order.up"); await rowMenu("Video C", "common.order.up");
await waitFor(() => expect(undoOrder().disabled).toBe(false)); await waitFor(() => expect(undoOrder().disabled).toBe(false));
lists[1] = [vid("A", 0), vid("C", 0), vid("B", 0)];
await landed(); await landed();
await removeRow("Video A", "Video C", "Video B"); await removeRow("Video A", "Video C", "Video B");
await act(async () => { await act(async () => {
@@ -2704,6 +2708,261 @@ describe("Playlists — writes that overlap", () => {
await waitFor(() => expect(restore).toHaveBeenCalledWith(1, ["B"], [2])); await waitFor(() => expect(restore).toHaveBeenCalledWith(1, ["B"], [2]));
}); });
describe("the order on the server", () => {
it("asks for an Undo's place only when the last of two writes out has landed", async () => {
const writes = [later<unknown>(), later<unknown>()];
const restore = vi
.spyOn(api, "restoreToPlaylist")
.mockResolvedValue({ ok: true, added: 1 } as never);
const lists = abc();
vi.spyOn(api, "removeManyFromPlaylist")
.mockImplementationOnce((async () => wentOut("D")) as never)
.mockReturnValueOnce(writes[0]!.promise as never)
.mockReturnValueOnce(writes[1]!.promise as never);
mount({ 1: [...lists[1], vid("D", 0)] });
vi.mocked(api.playlist).mockImplementation((async (id: number) =>
list(id, lists[1])) as never);
await inPage();
await landed();
await removeRow("Video D", "Video A", "Video B", "Video C");
await waitFor(() => expect(lastUndo()).toBeTruthy());
const undo = lastUndo()!;
await landed();
await removeRow("Video A", "Video B", "Video C");
await removeRow("Video B", "Video C");
await act(async () => undo.action!.onClick());
await act(async () => writes[0]!.ok(wentOut("A")));
await landed();
expect(restore).not.toHaveBeenCalled();
await act(async () => writes[1]!.ok(wentOut("B")));
await waitFor(() => expect(restore).toHaveBeenCalled());
});
const named = (lists: Record<number, Video[]>, id: number, ids: readonly string[]) => {
lists[id] = ids.map((v) => vid(v, 0));
};
const onList = async (id: number, title: string, rerender: () => void) => {
h.selectedId = id;
rerender();
await screen.findByRole("button", { name: `card.play — ${title}` });
await landed();
};
it("shows an order changed elsewhere, and forgets the history of the one it replaces", async () => {
const lists = abc();
vi.spyOn(api, "reorderPlaylist").mockImplementation((async (id: 1, ids: string[]) => {
named(lists, id, ids);
}) as never);
mount(lists);
await inPage();
await landed();
await rowMenu("Video C", "common.order.up");
await waitFor(() => expect(undoOrder().disabled).toBe(false));
await landed();
// A revert to YouTube, another device, a sync: the same rows, in another order.
named(lists, 1, ["B", "A", "C"]);
await act(async () => {
await qc.invalidateQueries({ queryKey: qk.playlist(1) });
});
await waitFor(() => expect(rowsOnScreen()).toEqual(["Video B", "Video A", "Video C"]));
expect(undoOrder().disabled).toBe(true);
});
it("keeps the history when a read only confirms the reorder", async () => {
const lists = abc();
vi.spyOn(api, "reorderPlaylist").mockImplementation((async (id: 1, ids: string[]) => {
named(lists, id, ids);
}) as never);
mount(lists);
await inPage();
await landed();
await rowMenu("Video C", "common.order.up");
await waitFor(() => expect(undoOrder().disabled).toBe(false));
await act(async () => {
await qc.invalidateQueries({ queryKey: qk.playlist(1) });
});
await landed();
expect(rowsOnScreen()).toEqual(["Video A", "Video C", "Video B"]);
expect(undoOrder().disabled).toBe(false);
});
it("waits with an order changed elsewhere until no list has a write out", async () => {
const post = later<unknown>();
vi.spyOn(api, "removeManyFromPlaylist").mockReturnValue(post.promise as never);
const lists: Record<number, Video[]> = { 1: [vid("A", 0)], 2: [vid("X", 0), vid("Y", 0)] };
const { rerender } = mount(lists);
await play("Video A");
await waitFor(() => expect(h.player).not.toBeNull());
await onList(2, "Video Y", rerender);
// The open player removes from the list it was launched from: a write out, on another list.
act(() => void h.player!.onRemoveFromList!(vid("A", 0)));
await landed();
named(lists, 2, ["Y", "X"]);
await act(async () => {
await qc.invalidateQueries({ queryKey: qk.playlist(2) });
});
await landed();
expect(rowsOnScreen()).toEqual(["Video X", "Video Y"]);
await act(async () => post.ok(wentOut("A")));
await waitFor(() => expect(rowsOnScreen()).toEqual(["Video Y", "Video X"]));
});
it("shows a reorder accepted after its list was picked again", async () => {
const put = later<unknown>();
vi.spyOn(api, "reorderPlaylist").mockReturnValue(put.promise as never);
const lists: Record<number, Video[]> = { ...abc(), 2: [vid("X", 0)] };
const { rerender } = mount(lists);
await inPage();
await landed();
qc.setQueryData(qk.playlist(2), list(2, lists[2]!));
await rowMenu("Video C", "common.order.up");
await waitFor(() => expect(api.reorderPlaylist).toHaveBeenCalled());
await onList(2, "Video X", rerender);
await onList(1, "Video A", rerender);
named(lists, 1, ["A", "C", "B"]);
await act(async () => put.ok(undefined));
await waitFor(() => expect(rowsOnScreen()).toEqual(["Video A", "Video C", "Video B"]));
});
it("keeps another list's history when a failure's read lands after a reorder there began", async () => {
const put = later<unknown>();
vi.spyOn(api, "reorderPlaylist").mockReturnValue(put.promise as never);
vi.spyOn(api, "removeManyFromPlaylist").mockRejectedValue(new TypeError("Failed to fetch"));
const lists: Record<number, Video[]> = { ...abc(), 2: [vid("X", 0), vid("Y", 0)] };
const { rerender } = mount(lists);
await inPage();
await landed();
qc.setQueryData(qk.playlist(2), list(2, lists[2]!));
const read = later<unknown>();
vi.mocked(api.playlist).mockReturnValue(read.promise as never);
await rowMenu("Video C", "playlists.removeItem");
await waitFor(() => expect(h.notify).toHaveBeenCalled());
await landed();
await onList(2, "Video Y", rerender);
await rowMenu("Video Y", "common.order.up");
await waitFor(() => expect(api.reorderPlaylist).toHaveBeenCalled());
vi.mocked(api.playlist).mockImplementation((async (id: number) =>
list(id, lists[id] ?? [])) as never);
await act(async () => read.ok(list(1, lists[1]!)));
await landed();
named(lists, 2, ["Y", "X"]);
await act(async () => put.ok(undefined));
await landed();
expect(rowsOnScreen()).toEqual(["Video Y", "Video X"]);
expect(undoOrder().disabled).toBe(false);
});
it("shows the server's place for a row whose removal failed on a list left meanwhile", async () => {
const post = later<unknown>();
vi.spyOn(api, "removeManyFromPlaylist").mockReturnValue(post.promise as never);
const lists: Record<number, Video[]> = {
1: [vid("A", 0), vid("B", 0), vid("C", 0), vid("D", 0)],
2: [vid("X", 0)],
};
// The server keeps a row the PUT does not name after the ones it does.
vi.spyOn(api, "reorderPlaylist").mockImplementation((async (id: 1, ids: string[]) => {
named(lists, id, [...ids, "B"]);
}) as never);
const { rerender } = mount(lists);
await inPage();
await landed();
qc.setQueryData(qk.playlist(2), list(2, lists[2]!));
await removeRow("Video B", "Video A", "Video C", "Video D");
await rowMenu("Video D", "common.order.up");
await waitFor(() => expect(api.reorderPlaylist).toHaveBeenCalled());
await landed();
await onList(2, "Video X", rerender);
await act(async () => post.no(new TypeError("Failed to fetch")));
await landed();
await onList(1, "Video A", rerender);
await waitFor(() =>
expect(rowsOnScreen()).toEqual(["Video A", "Video D", "Video C", "Video B"]),
);
});
it("sends a list's reorders one after the other", async () => {
const first = later<unknown>();
const reorder = vi
.spyOn(api, "reorderPlaylist")
.mockReturnValueOnce(first.promise as never)
.mockResolvedValueOnce(undefined as never);
mount(abc());
await inPage();
await landed();
await rowMenu("Video C", "common.order.up");
await waitFor(() => expect(rowsOnScreen()).toEqual(["Video A", "Video C", "Video B"]));
await rowMenu("Video C", "common.order.up");
await waitFor(() => expect(rowsOnScreen()).toEqual(["Video C", "Video A", "Video B"]));
await landed();
expect(reorder).toHaveBeenCalledTimes(1);
await act(async () => first.ok(undefined));
await waitFor(() => expect(reorder).toHaveBeenCalledTimes(2));
expect(reorder).toHaveBeenLastCalledWith(1, ["C", "A", "B"]);
});
it("keeps a stored position that landed during a reorder when the read after it fails", async () => {
const put = later<unknown>();
vi.spyOn(api, "reorderPlaylist").mockReturnValue(put.promise as never);
mount(abc());
await inPage();
await landed();
await rowMenu("Video C", "common.order.up");
await waitFor(() => expect(api.reorderPlaylist).toHaveBeenCalled());
act(() => patchCachedProgress(qc, new Map([["C", 830]])));
await landed();
vi.mocked(api.playlist).mockRejectedValue(new TypeError("Failed to fetch"));
await act(async () => put.ok(undefined));
await landed();
await play("Video C");
await waitFor(() => expect(h.player?.video.position_seconds).toBe(830));
});
it.each([
["a removal", "Video A", ["B", "D"], 1],
["a reorder", "Video D", ["A", "D", "B"], 3],
] as const)(
"asks for an Undo's place only when %s still out has landed",
async (kind, title, after, at) => {
const write = later<unknown>();
const restore = vi
.spyOn(api, "restoreToPlaylist")
.mockResolvedValue({ ok: true, added: 1 } as never);
const lists: Record<number, Video[]> = {
1: [vid("A", 0), vid("B", 0), vid("C", 0), vid("D", 0)],
};
const remove = vi.spyOn(api, "removeManyFromPlaylist");
remove.mockImplementationOnce((async () => {
lists[1] = lists[1]!.filter((v) => v.id !== "C");
return wentOut("C");
}) as never);
vi.spyOn(api, "reorderPlaylist").mockReturnValue(write.promise as never);
mount(lists);
await inPage();
await landed();
await removeRow("Video C", "Video A", "Video B", "Video D");
await waitFor(() => expect(lastUndo()).toBeTruthy());
const undo = lastUndo()!;
await landed();
if (kind === "a removal") {
remove.mockReturnValueOnce(write.promise as never);
await removeRow(title, "Video B", "Video D");
} else {
await rowMenu(title, "common.order.up");
await waitFor(() => expect(api.reorderPlaylist).toHaveBeenCalled());
}
await landed();
const before = reads();
await act(async () => undo.action!.onClick());
await landed();
expect(reads()).toBe(before);
expect(restore).not.toHaveBeenCalled();
named(lists, 1, after);
await act(async () => write.ok(wentOut("A")));
await waitFor(() => expect(restore).toHaveBeenCalledWith(1, ["C"], [at]));
},
);
});
it("speaks of one video when one row found nothing to do", async () => { it("speaks of one video when one row found nothing to do", async () => {
vi.spyOn(api, "removeManyFromPlaylist").mockResolvedValue(wentOut() as never); vi.spyOn(api, "removeManyFromPlaylist").mockResolvedValue(wentOut() as never);
mount(abc()); mount(abc());
@@ -2891,6 +3150,24 @@ describe("Playlists — the focus when what holds it goes", () => {
expect(document.activeElement?.closest("table")).toBeNull(); expect(document.activeElement?.closest("table")).toBeNull();
}); });
it("does not count on Play all, disabled by the last row going, to take it back", async () => {
mountList([vid("A", 0)], "A");
const all = await screen.findByRole<HTMLButtonElement>("button", {
name: "playlists.playAll",
});
all.focus();
fireEvent.click(all);
await waitFor(() => expect(h.mounted).toBe(true));
card = document.createElement("div");
card.tabIndex = -1;
document.body.appendChild(card);
card.focus();
await removeFromPlayer(vid("A", 0));
await waitFor(() => expect(all.disabled).toBe(true));
close();
await waitFor(() => expect(document.activeElement).not.toBe(document.body));
});
it("moves nothing while the one that opened it is still there", async () => { it("moves nothing while the one that opened it is still there", async () => {
mount({ 1: [vid("A", 0), vid("B", 0)] }); mount({ 1: [vid("A", 0), vid("B", 0)] });
await openFrom("Video B"); await openFrom("Video B");
+73 -44
View File
@@ -138,6 +138,8 @@ const idsKey = (items: Video[]) =>
.sort() .sort()
.join(","); .join(",");
const orderOf = (items: Video[]) => items.map((v) => v.id).join(",");
const moveTargetIcon = (pl: Playlist) => const moveTargetIcon = (pl: Playlist) =>
pl.source === "youtube" || pl.yt_playlist_id ? ( pl.source === "youtube" || pl.yt_playlist_id ? (
<YouTubeIcon className="w-4 h-4" /> <YouTubeIcon className="w-4 h-4" />
@@ -315,9 +317,11 @@ function PlaylistDetail() {
// only a row's own fields, a stored position, are taken. // only a row's own fields, a stored position, are taken.
const writesOut = useRef(new Map<number, { count: number; before?: PlaylistDetail }>()); const writesOut = useRef(new Map<number, { count: number; before?: PlaylistDetail }>());
const refreshDue = useRef(new Set<string>()); const refreshDue = useRef(new Set<string>());
// A rollback sets rows back by what the cache held, which an accepted reorder may have moved on from, // The last reorder sent on each list. Each is the whole list and the server applies what it is given,
// and the membership guard never corrects an order: the read after the last write decides it. // so the next one waits for it: two side by side could reach the server's lock either way round.
const orderInDoubt = useRef(false); const reordersOut = useRef(new Map<number, Promise<unknown>>());
// Who waits for a list's writes to land (see `writesLanded`).
const landingWaits = useRef(new Map<number, (() => void)[]>());
// The stored order is undoable: drag and "save this order" both go through `order.set`, so each // The stored order is undoable: drag and "save this order" both go through `order.set`, so each
// is reversible (buttons + Ctrl+Z/Y). onApply persists the new order to the server. // is reversible (buttons + Ctrl+Z/Y). onApply persists the new order to the server.
const order = useUndoable<Video[]>([], { const order = useUndoable<Video[]>([], {
@@ -325,36 +329,39 @@ function PlaylistDetail() {
if (selectedId == null) return; if (selectedId == null) return;
const listId = selectedId; const listId = selectedId;
writeStarts(listId); writeStarts(listId);
api const sent = (reordersOut.current.get(listId) ?? Promise.resolve())
.reorderPlaylist( .then(() =>
listId, api.reorderPlaylist(
next.map((v) => v.id), listId,
next.map((v) => v.id),
),
) )
.then(() => { .then(() => {
// Write the accepted order into the cache BEFORE the refetch. The cache is what // Write the accepted order into the cache BEFORE the refetch. The cache is what
// `restoreFromCache` rolls back to, and until this refetch landed it still held the order // `restoreFromCache` rolls back to, and until this refetch landed it still held the order
// from before this reorder — so a SECOND reorder failing in that window rolled the rows // from before this reorder — so a SECOND reorder failing in that window rolled the rows
// back to an order the server does not have, permanently (the membership guard cannot // back to an order the server does not have. One accepted write, one cache entry that
// correct an order). One accepted write, one cache entry that matches it. // matches it.
// A row taken out while this was on its way stays out: `next` is from before that. A row it // A row taken out while this was on its way stays out: `next` is from before that. A row it
// does not name stays too, after the ones it does, where the server keeps it: one whose // does not name stays too, after the ones it does, where the server keeps it: one whose
// removal failed meanwhile, or that came in from elsewhere. // removal failed meanwhile, or that came in from elsewhere. Each row as the cache has it: a
// stored position may have landed since `next` was made.
writeDone(listId); writeDone(listId);
qc.setQueryData<PlaylistDetail>(qk.playlist(listId), (old) => { qc.setQueryData<PlaylistDetail>(qk.playlist(listId), (old) => {
if (!old) return old; if (!old) return old;
const held = new Set(old.items.map((v) => v.id)); const held = new Map(old.items.map((v) => [v.id, v]));
const named = new Set(next.map((v) => v.id)); const named = new Set(next.map((v) => v.id));
return { return {
...old, ...old,
items: [ items: [
...next.filter((v) => held.has(v.id)), ...next.flatMap((v) => held.get(v.id) ?? []),
...old.items.filter((v) => !named.has(v.id)), ...old.items.filter((v) => !named.has(v.id)),
], ],
}; };
}); });
// Refresh the sidebar (cover may change) and the detail (so the now-dirty state, // Refresh the sidebar (cover may change) and the detail (so the now-dirty state,
// and the Reset/Unsynced indicators, show without an F5). The membership guard // and the Reset/Unsynced indicators, show without an F5). A read that only confirms
// keeps the refetch from wiping the undo history on a pure reorder. // this order keeps the undo history.
refreshAfterEdit([]); refreshAfterEdit([]);
}) })
.catch((e) => { .catch((e) => {
@@ -366,6 +373,7 @@ function PlaylistDetail() {
restoreFromCache(listId); restoreFromCache(listId);
refreshAfterEdit([]); refreshAfterEdit([]);
}); });
reordersOut.current.set(listId, sent);
}, },
}); });
const items = order.value; const items = order.value;
@@ -376,9 +384,9 @@ function PlaylistDetail() {
const [viewSort, setViewSort] = useState<SortState>(opened.sort); const [viewSort, setViewSort] = useState<SortState>(opened.sort);
const [filters, setFilters] = useState<FilterMap>(opened.filters); const [filters, setFilters] = useState<FilterMap>(opened.filters);
const [groupBy, setGroupBy] = useState(opened.groupBy); const [groupBy, setGroupBy] = useState(opened.groupBy);
// Tracks the membership (id set) currently loaded into `order`, so a refetch that only // Tracks the membership (id set) currently loaded into `order`: a different playlist, or an add or
// re-confirms our own reorder doesn't wipe the undo history — we reset history only when // a remove, resets the undo history. So does another order (see the effect below); a refetch that
// the actual set of items changes (different playlist, or an add/remove). // only re-confirms our own reorder does not.
const lastSetRef = useRef<string>(""); const lastSetRef = useRef<string>("");
// The selection as of NOW, for code that runs after an await or on behalf of the open player. // The selection as of NOW, for code that runs after an await or on behalf of the open player.
const selectedRef = useLatest(selectedId); const selectedRef = useLatest(selectedId);
@@ -393,7 +401,6 @@ function PlaylistDetail() {
if (!cur) return; if (!cur) return;
lastSetRef.current = `${listId}:${idsKey(cur.items)}`; lastSetRef.current = `${listId}:${idsKey(cur.items)}`;
order.reset(cur.items); order.reset(cur.items);
orderInDoubt.current = true;
} }
// What the player was LAUNCHED with, snapshotted when it opens: the video, the queue and the index // What the player was LAUNCHED with, snapshotted when it opens: the video, the queue and the index
// inside it. `shown` is derived (sort / filter / view), and the player freezes its queue and owns // inside it. `shown` is derived (sort / filter / view), and the player freezes its queue and owns
@@ -462,7 +469,7 @@ function PlaylistDetail() {
// Destructured so the effect below can name what it calls: both are stable useCallbacks, while // Destructured so the effect below can name what it calls: both are stable useCallbacks, while
// the object around them is rebuilt every render (lib/useUndoable). // the object around them is rebuilt every render (lib/useUndoable).
const { reset: resetOrder, rebase: rebaseOrder } = order; const { reset: resetOrder, rebase: rebaseOrder, peek: orderNow } = order;
useEffect(() => { useEffect(() => {
if (!detail) return; if (!detail) return;
// A write out on the list these rows are from: this may be a read from before it landed, and the // A write out on the list these rows are from: this may be a read from before it landed, and the
@@ -473,13 +480,19 @@ function PlaylistDetail() {
// Scoped to the playlist: two lists holding the SAME videos would otherwise look identical to // Scoped to the playlist: two lists holding the SAME videos would otherwise look identical to
// the guard, so the second would render in the first's order and inherit its undo stack. // the guard, so the second would render in the first's order and inherit its undo stack.
const key = `${selectedId}:${idsKey(detail.items)}`; const key = `${selectedId}:${idsKey(detail.items)}`;
if (!held && key !== lastSetRef.current) { // The same rows in another order: a revert to YouTube, a sync, another device. Our own writes are
// on screen before they are in the cache, so this only counts with none out on ANY list: a read
// can answer from before one, and the refresh that calls such a read off waits for them all
// (`refreshAfterEdit`, which then settles what this skipped). The history goes with it: it holds
// orders built on the one just replaced, and the next step back would write them over it.
const reordered = !writesOut.current.size && orderOf(detail.items) !== orderOf(orderNow());
if (!held && (key !== lastSetRef.current || reordered)) {
lastSetRef.current = key; lastSetRef.current = key;
resetOrder(detail.items); resetOrder(detail.items);
} else { } else {
// Same membership, fresher ROWS — a watch position the player just stored, a status. The // Same rows in the same order, fresher FIELDS — a watch position the player just stored, a
// guard above looks at the id set only, so without this the list kept showing, and handing the // status. Without this the list kept showing, and handing the player, each row as it was when
// player, each row as it was when the list first loaded: watch a video to 830 s, close it, // the list first loaded: watch a video to 830 s, close it,
// reopen it, and it resumed at the old spot and the next checkpoint wrote that over the 830. // reopen it, and it resumed at the old spot and the next checkpoint wrote that over the 830.
// `rebase` takes the fresh rows into every snapshot and keeps the order and its undo history. // `rebase` takes the fresh rows into every snapshot and keeps the order and its undo history.
const fresh = new Map(detail.items.map((v) => [v.id, v])); const fresh = new Map(detail.items.map((v) => [v.id, v]));
@@ -487,7 +500,7 @@ function PlaylistDetail() {
} }
// `reset` and `rebase` are stable useCallbacks (lib/useUndoable), so naming them costs nothing; // `reset` and `rebase` are stable useCallbacks (lib/useUndoable), so naming them costs nothing;
// `selectedId` moves only together with `detail` anyway, since detail IS the selected playlist's. // `selectedId` moves only together with `detail` anyway, since detail IS the selected playlist's.
}, [detail, selectedId, resetOrder, rebaseOrder]); }, [detail, selectedId, resetOrder, rebaseOrder, orderNow]);
// Reset the view when switching playlists (the stored order itself is per-playlist) — during the // Reset the view when switching playlists (the stored order itself is per-playlist) — during the
// render that switches, so the incoming playlist is never drawn once through the previous one's // render that switches, so the incoming playlist is never drawn once through the previous one's
@@ -789,13 +802,15 @@ function PlaylistDetail() {
linkedOutOfReach && selectedId === railMovedTo ? linkedOver : selectedId, linkedOutOfReach && selectedId === railMovedTo ? linkedOver : selectedId,
); );
// The player has handed the focus back: to a row gone by now it could not, and the next Tab would // The player has handed the focus back: to a row gone by now it could not, nor to a Play all disabled
// start over at the top of the page. The row now in that place takes it, else the header's box, else // by the last row going, and the next Tab would start over at the top of the page. The row now in
// (no rows, no table) the list's "…". A focus the reader has put elsewhere stays. // that place takes it, else the header's box, else (no rows, no table) the list's "…". A focus the
// reader has put elsewhere stays.
const focusAfterPlayer = useEffectEvent(() => { const focusAfterPlayer = useEffectEvent(() => {
const from = openedFrom.current; const from = openedFrom.current;
openedFrom.current = null; openedFrom.current = null;
if (!from || from.el?.isConnected || document.activeElement !== document.body) return; const heldItsPlace = from?.el?.isConnected && !from.el.matches(":disabled");
if (!from || heldItsPlace || document.activeElement !== document.body) return;
const rows = shownRef.current.length; const rows = shownRef.current.length;
if (rows && tableRef.current) focusNavItem(tableRef.current, Math.min(from.index, rows - 1)); if (rows && tableRef.current) focusNavItem(tableRef.current, Math.min(from.index, rows - 1));
else (selectAllRef.current ?? renameTarget.current)?.focus(); else (selectAllRef.current ?? renameTarget.current)?.focus();
@@ -863,8 +878,20 @@ function PlaylistDetail() {
} }
function writeDone(listId: number) { function writeDone(listId: number) {
const out = writesOut.current.get(listId); const out = writesOut.current.get(listId);
if (out && out.count > 1) out.count--; if (out && out.count > 1) {
else writesOut.current.delete(listId); out.count--;
return;
}
writesOut.current.delete(listId);
for (const go of landingWaits.current.get(listId) ?? []) go();
landingWaits.current.delete(listId);
}
/** When no write is out on the list any more: until then the server's list is about to change. */
function writesLanded(listId: number) {
if (!writesOut.current.has(listId)) return Promise.resolve();
return new Promise<void>((go) => {
landingWaits.current.set(listId, [...(landingWaits.current.get(listId) ?? []), go]);
});
} }
// The write went through: a read from before it may have brought its rows back into the cache since, // The write went through: a read from before it may have brought its rows back into the cache since,
// and the next thing to rewrite that entry would hand them to the membership guard. // and the next thing to rewrite that entry would hand them to the membership guard.
@@ -901,15 +928,13 @@ function PlaylistDetail() {
void read.then(settleOrder); void read.then(settleOrder);
} }
// The refresh is in. A read matching the cache exactly runs no effect, so what the cache got while a // The refresh is in. A read matching the cache exactly runs no effect, so what the cache got while a
// write was out is taken here, and so is the order after a rollback. A write started since has its // write was out is taken here, and so is the order after a rollback, which went by what the cache held
// own refresh to wait for. // and an accepted reorder may have moved on from. A write started since has its own refresh to wait for.
function settleOrder() { function settleOrder() {
const listId = selectedRef.current; const listId = selectedRef.current;
if (listId == null || writesOut.current.size) return; if (listId == null || writesOut.current.size) return;
const cur = qc.getQueryData<PlaylistDetail>(qk.playlist(listId)); const cur = qc.getQueryData<PlaylistDetail>(qk.playlist(listId));
const changed = cur && `${listId}:${idsKey(cur.items)}` !== lastSetRef.current; if (cur && orderOf(cur.items) !== orderOf(order.peek())) restoreFromCache(listId);
if (orderInDoubt.current || changed) restoreFromCache(listId);
orderInDoubt.current = false;
} }
const titleOf = (rows: Video[], id: string | undefined) => const titleOf = (rows: Video[], id: string | undefined) =>
raw(rows.find((v) => v.id === id)?.title ?? ""); raw(rows.find((v) => v.id === id)?.title ?? "");
@@ -917,16 +942,20 @@ function PlaylistDetail() {
const tickOf = (v: Video) => (selection.isSelected(v.id) ? [v.id] : []); const tickOf = (v: Video) => (selection.isSelected(v.id) ? [v.id] : []);
// Where rows go back when their Undo comes: against the list as it is NOW, fetched fresh, and after // Where rows go back when their Undo comes: against the list as it is NOW, fetched fresh, and after
// their neighbours in the STORED order they stood in, so neither a view sort on screen nor a change // their neighbours in the STORED order they stood in, so neither a view sort on screen nor a change
// in between shifts them (`reinsertAt`). Past the cache: a removal starting meanwhile calls off the // in between shifts them (`reinsertAt`). After the writes still out: the place is an index, and one
// cache's read, which then answers with what the cache holds, not with what is fresh. // landing between this read and the Undo's own write would shift it. Past the cache: a removal starting
// meanwhile calls off the cache's read, which then answers with what the cache holds, not with what
// is fresh.
const placesNow = (listId: number, storedBefore: string[], ids: string[]) => const placesNow = (listId: number, storedBefore: string[], ids: string[]) =>
api.playlist(listId).then((now) => writesLanded(listId)
reinsertAt( .then(() => api.playlist(listId))
storedBefore, .then((now) =>
ids, reinsertAt(
now.items.map((v) => v.id), storedBefore,
), ids,
); now.items.map((v) => v.id),
),
);
// An Undo that could not bring everything back says so: the server leaves out a video it cannot put // An Undo that could not bring everything back says so: the server leaves out a video it cannot put
// back (gone on from the target, or from the catalog), and silence would read as all of them. // back (gone on from the target, or from the catalog), and silence would read as all of them.
function sayWhatCameBack(asked: number, added: number) { function sayWhatCameBack(asked: number, added: number) {
@@ -134,13 +134,13 @@ const lost404 = {
fatal: true, fatal: true,
type: Hls.ErrorTypes.NETWORK_ERROR, type: Hls.ErrorTypes.NETWORK_ERROR,
details: "fragLoadError", details: "fragLoadError",
response: { code: 404, text: "No active playback session" }, response: { code: 404, text: "Not available yet" },
}; };
describe("an HLS session the server no longer has", () => { describe("an HLS segment that never arrives (a fatal 404)", () => {
it("is restarted where the viewer is, instead of ending playback", async () => { it("is restarted where the viewer is, instead of ending playback", async () => {
// The server reaps a session nobody fetches from (a long pause; a deploy). hls.js never retries // Once hls.js has given up on it, the 404 used to end playback with "couldn't play". A session
// a 4xx, so the next segment's 404 used to end playback with "couldn't play". // the server no longer has answers 410 instead: `hlsEngine.test.ts` holds that case.
const session = mount(); const session = mount();
await waitFor(() => expect(hlsInstances).toHaveLength(1)); await waitFor(() => expect(hlsInstances).toHaveLength(1));
act(() => hlsInstances[0]!.emit(Hls.Events.MANIFEST_PARSED)); act(() => hlsInstances[0]!.emit(Hls.Events.MANIFEST_PARSED));
@@ -155,10 +155,9 @@ describe("an HLS session the server no longer has", () => {
it("gives up and says so when the reclaimed session keeps 404ing", async () => { it("gives up and says so when the reclaimed session keeps 404ing", async () => {
// `parsed` cannot bound this: it lives on ONE hls instance and every restart builds a fresh one, // `parsed` cannot bound this: it lives on ONE hls instance and every restart builds a fresh one,
// so a session that parses its manifest and then 404s re-arms the guard every cycle. The server // so a session that parses its manifest and then 404s re-arms the guard every cycle. A transcode
// also answers 404 for "that segment is not there YET" (a transcode falling behind realtime), // falling behind realtime answers 404 for "that segment is not there YET" every time, so the
// which is indistinguishable on the wire — so the player respawned ffmpeg forever behind a // player respawned ffmpeg forever behind a spinner that never became an error.
// spinner that never became an error.
const session = mount(); const session = mount();
const v = () => document.querySelector("video") as Media; const v = () => document.querySelector("video") as Media;
// The first session plus the two it is allowed to reclaim; the third 404 has nothing left. // The first session plus the two it is allowed to reclaim; the third 404 has nothing left.
+3
View File
@@ -5,6 +5,7 @@ import WatchPage from "./watch/WatchPage";
import { T } from "./watch/watchStrings"; import { T } from "./watch/watchStrings";
import "./index.css"; import "./index.css";
import { installStaleBuildReload } from "./lib/staleBuild"; import { installStaleBuildReload } from "./lib/staleBuild";
import { retryLostStylesheets } from "./lib/stylesheetRetry";
// The entry of a public /watch/<token> link (watch.html). Whoever opens it got the link from a // The entry of a public /watch/<token> link (watch.html). Whoever opens it got the link from a
// friend: signed out, often on a phone, often on mobile data — so it imports the player page and // friend: signed out, often on a phone, often on mobile data — so it imports the player page and
@@ -14,6 +15,8 @@ import { installStaleBuildReload } from "./lib/staleBuild";
// A tab left open across a release reloads onto the new build instead of failing on a chunk. // A tab left open across a release reloads onto the new build instead of failing on a chunk.
installStaleBuildReload(); installStaleBuildReload();
// A stylesheet lost on the way is asked for again rather than leaving the page unstyled.
retryLostStylesheets();
const crashCopy = () => ({ title: T.crashTitle, subtitle: T.crashHint, reload: T.reload }); const crashCopy = () => ({ title: T.crashTitle, subtitle: T.crashHint, reload: T.reload });