Compare commits

...
29 Commits
Author SHA1 Message Date
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
peter bbb4063362 release: 0.116.0 (improvement/e53-playlist-multiselect) 2026-11-03 01:02:33 +01:00
peter b4ba9d95b1 chore(release): 0.116.0 2026-11-03 00:48:36 +01:00
peter c18a2063c8 fix(playlists): a reorder counts among the writes, and what the third review found
A reorder was invisible to the overlap bookkeeping: a failed removal reset the
screen from a cache without it, and the membership guard never corrects an order.
It now counts as a write, a rollback leaves the order to the read after the last
write, and the accepted order keeps the rows it does not name, as the server does.
Undo places come from the order on screen. A stored position landing during a write
reaches the screen, and what a read brought is taken once the refresh is in. A write
that went through drops its rows from the cache again, past a read from before it.

Closing the player after Trash took the row it opened from no longer strands the
focus: the row in that place takes it, else the header's box, else the list's menu.
The page menu's grouping row closes its menu when it goes with the view. One hidden
row gets its own wording. /search/clear refuses a body without its list.
2026-11-02 23:43:34 +01:00
peter b636ef38bc fix(playlists): overlapping writes, and what the second review found
A read from before a write no longer brings a row back: a write calls off the
read on its way, what lands while one is out stays off the screen, and the last
write out does the refresh. A failure puts back its own rows, an accepted reorder
brings back none, and an Undo reads past the cache. A list the cache has dropped
is read before a removal, so its Undo has places.

undo-move refuses the same list twice, positions cannot repeat, and undo-move
and the Plex key bodies refuse a body without their videos. Focus goes to the
header's box when the last shown row or Save this order goes.
2026-11-02 22:15:13 +01:00
peter 195b5f3917 test(playlists): walk to the row with a key, as the keyboard does 2026-11-02 04:01:56 +01:00
peter 1a1e9d9542 fix(playlists): what the first review of bulk actions found
An Esc during a pointer drag is spent on the drag and leaves the selection.
A wheel over the rail re-reads the target each frame, so a drop lands on the
row under the pointer. After a bulk removal the focus goes to the next row
left, and to the list's menu when the list is emptied.

remove-bulk names the videos that went, and its Undo brings back only those.
An Undo of a move keeps each row (and when it was saved), leaves out a video
that moved on from the target, closes up its place, and says how many came
back. Delete with every tick hidden removes nothing and says why. A list gone
before the lock answers 404, and a body without its videos 422.

One removal path now: the single row goes through the bulk one, the same
toast and undo, which also retires the add-at-position route. railDrop reuses
the store and scrollParent, the drag image the overlay and the z-index token,
and the sensors keep their number. The count is read out from a region that
outlives the band. Tests fill the gaps the review named, each seen failing.
2026-11-02 03:41:08 +01:00
peter d09b5d6abb fix(dev): vite stops watching what an e2e run writes
On Windows a report file being rewritten answered the watcher with EBUSY, which
took the dev server down, and each run reloaded every open page.
2026-11-02 01:17:39 +01:00
peter 439f5cd67b test(playlists): a person's pause before the arrow in the keyboard drag 2026-11-02 00:57:58 +01:00
peter d79cb528fd feat(playlists): drag rows onto a list in the rail
A row's grip carries it, or every selected row with a selected one, onto another
list, under a sort or a filter too, and the toast undoes. The rail and the page
are separate React trees, so the page reads the rail by what lies under the
pointer. An unpinned rail peeks by the drag's own hover and scrolls at its edges.
Many rows dropped inside the list stay; a key still moves its own row.
2026-11-02 00:38:39 +01:00
peter 9b76d2f96c fix(playlists): the list's name keeps its room on a narrow window
Below xl Unsynced is its dot; on a phone the cover is gone, Play all is its
glyph, the band's count is its number and the undo pair stays out. The name was
0 px wide in Hungarian at 768 and on any phone, and a tick moved the list there.
2026-11-01 23:07:42 +01:00
peter bf54a3ce5f fix(playlists): the selection band keeps to one row on a tablet, and a tick moves no row
Below xl the band's buttons are glyphs named in their tooltips, and the row it
replaces stays in the same grid cell, invisible, so the first tick leaves the
list where the second tap expects it. The test's row menu asked for the pins
unmocked, and the offline toast could land before the check: mocked.
2026-11-01 22:44:32 +01:00
peter efbbb23525 feat(playlists): act on the selected rows from the toolbar, and undo either
While rows are selected the toolbar's second row is the band: the count,
the last target as one click, Move to, Remove, Clear selection. Both write
in one request and undo back to the places; a video the target held before
stays there. Delete takes the selection when there is one. A failure puts
the rows back ticked.

A row's own move goes through the same write, so it undoes too, and the
single-row move route and service function are gone. A playlist row drops
its grip below a 24rem table: the row menu keeps the moves.
2026-11-01 22:07:09 +01:00
peter 3b85881af4 docs(design): the selecting row, formatted 2026-11-01 20:55:28 +01:00
peter 8cf8c4139c feat(playlists): select rows with a box, Shift for a range, x and Esc
A box first in every row and one over the column for the rows shown, the
selected row tinted. A filter keeps what it hides selected; another list
starts over; a row that left the list drops out. Watch later has none.

A box no longer counts as a typing target: a click on one left the focus
there and the page's keys went dead.
2026-11-01 20:49:59 +01:00
peter cd773e8f33 feat(playlists): remove and move many videos at once, and undo either, in one transaction 2026-11-01 20:22:34 +01:00
62 changed files with 6594 additions and 520 deletions
+30
View File
@@ -7,6 +7,36 @@ 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).
## 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
### New
- In a playlist, tick rows with their box, Shift+click or x, then move or remove them together. An Undo puts them back at their places, and a single row's move has one too.
- Drag rows onto another list in the rail: one row, or all the selected ones, also under a sort or a filter.
### Fixed
- Removing a video from inside the player no longer loses your place in the list when the player closes.
## 0.115.0 — 2026-11-01
### New
+1 -1
View File
@@ -1 +1 @@
0.115.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.
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
held one whole segmented copy of a film per item on the download volume."""
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
# 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.
# 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.
self.id = secrets.token_urlsafe(9)
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
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
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.
# 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
# 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
s = respawned
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
# How long a session whose ffmpeg has FINISHED may go unfetched before a new session request reaps
# it (see reap_idle).
_FINISHED_IDLE_S = 30
# The slowest heartbeat a live player sends: a browser cuts the timers of a hidden page that makes no
# sound to one a minute (measured on Chromium: a paused and a muted player, about a minute after
# 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:
@@ -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
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
killed a one-minute pause, and playback died ~30 s after resuming.
and with its forward buffer full a paused player fetches nothing at all.
What makes the short rule safe again is that fetching is no longer the only signal: a player
parked MID-ITEM checkpoints its position every 10 s, and that route calls `touch_session`, so a
viewer who is merely paused says so far more often than this rule asks. The heartbeat
What makes the short rule safe is that fetching is not the only signal: a player parked MID-ITEM
checkpoints its position and that route calls `touch_session`. That beat is every 10 s only while
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
states where reaping is correct, and where the player's 404 → reclaim recovers a viewer who
scrubs back in anyway. Do not restate this as "every 10 s whether or not it is playing":
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 "whether or not it is playing":
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
one a whole segmented copy of the film on a volume shared with the download center."""
now = time.time()
doomed: list[tuple[HlsSession, Path | None]] = []
reaped: list[tuple[str, str, int]] = []
with _lock:
for reg, s in list(_sessions.items()):
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):
_sessions.pop(reg, None)
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)
return len(doomed)
+152 -52
View File
@@ -292,7 +292,7 @@ def set_progress(db: Session, user: User, video_id: str, position: int) -> None:
# Every reason a move is refused; the UI names each one (`playlists.moveErrors.<reason>`, held by the
# frontend's backendIds contract, which reads this Literal).
MoveReason = Literal["same_playlist", "not_in_playlist"]
MoveReason = Literal["same_playlist"]
class MoveRefused(Exception):
@@ -406,30 +406,6 @@ def remove_item(db: Session, pl: Playlist, video_id: str, *, mark_dirty: bool) -
return True
def place_item(db: Session, pl: Playlist, video_id: str, position: int) -> None:
"""Put a video already in the list at `position` (clamped to the end), renumbering the whole
list: positions can have gaps after removals, so "index N" is only meaningful over the
ordered rows."""
rows = queries.items(db, pl.id)
new = next(r for r in rows if r.video_id == video_id)
rows.remove(new)
rows.insert(min(position, len(rows)), new)
for pos, row in enumerate(rows):
row.position = pos
db.flush()
def add_to_playlist(db: Session, pl: Playlist, video_id: str, position: int | None) -> None:
"""Add a video to one of the user's lists, at `position` when given (the list page's "Undo" of
a removal puts the row back where it was in ONE request), else at the end. A video already in
the list stays where it is."""
added = add_item(db, pl, video_id, mark_dirty=False)
if added and position is not None:
place_item(db, pl, video_id, position)
recompute_dirty(db, pl)
db.flush()
def rename_playlist(db: Session, pl: Playlist, name: str) -> None:
"""A new name moves `dirty`; the caller holds the playlist's lock (the scheduled mirror refresh
reads `dirty` under it)."""
@@ -447,39 +423,163 @@ def delete_playlist(db: Session, pl: Playlist) -> None:
db.flush()
def move_item(db: Session, src: Playlist, dst: Playlist, video_id: str) -> None:
"""Move one video from `src` into `dst`, in the caller's one transaction.
def remove_items(db: Session, pl: Playlist, video_ids: list[str]) -> list[str]:
"""Remove these videos from a playlist, in one transaction; a video not in it any more is simply
not among them. Says which went: an Undo must not bring back one this removal never took."""
lock_playlists(db, pl.id)
wanted = set(video_ids)
rows = [it for it in queries.items(db, pl.id) if it.video_id in wanted]
for row in rows:
db.delete(row)
if rows:
recompute_dirty(db, pl)
touch(pl)
db.flush()
return [row.video_id for row in rows]
The client-side shape — add to the target, then remove from the source — leaves the video in
BOTH lists whenever the second call fails. Idempotent: a video already in the target is simply
dropped from the source (moving into a list that has it is a removal, not a duplicate). Raises
`MoveRefused` for the same list twice, or a video a concurrent edit already took out."""
def _insert_at(
db: Session,
pl: Playlist,
placed: list[tuple[int, str]],
leaving: dict[str, PlaylistItem] | None = None,
staying_out: frozenset[str] = frozenset(),
) -> int:
"""Add videos at their indices (ascending, so each index is where the row stood when the rows
before it were back) and renumber the list 0..n-1, as positions can have gaps. A video already in
the list stays where it is, and one the catalog has lost or in `staying_out` is left out, the
ones after it closing up. A video with a row in `leaving` brings that row along, for the reason
`move_items` re-parents. Says how many were added; the caller holds the playlist's lock."""
leaving = leaving or {}
rows = queries.items(db, pl.id)
present = {r.video_id for r in rows}
known = queries.known_video_ids(db, [vid for _, vid in placed])
added = 0
left_out = 0
for at, vid in sorted(placed, key=lambda p: p[0]):
if vid in present or vid not in known or vid in staying_out:
left_out += 1
continue
row = leaving.pop(vid, None)
if row is None:
row = PlaylistItem(playlist_id=pl.id, video_id=vid, position=0)
db.add(row)
else:
row.playlist_id = pl.id
rows.insert(min(at - left_out, len(rows)), row)
present.add(vid)
added += 1
for pos, row in enumerate(rows):
row.position = pos
return added
def restore_items(db: Session, pl: Playlist, video_ids: list[str], positions: list[int]) -> int:
"""Put removed videos back at their places, in ONE request: an append plus a whole-list reorder
leaves them at the end whenever the second call fails. Says how many came back."""
lock_playlists(db, pl.id)
added = _insert_at(db, pl, list(zip(positions, video_ids, strict=True)))
if added:
recompute_dirty(db, pl)
touch(pl)
db.flush()
return added
class ListGone(Exception):
"""A list of a two-list write was deleted between the route's read and the lock."""
def __init__(self, *, source: bool):
super().__init__("source" if source else "target")
self.source = source
def _lock_both(db: Session, src: Playlist, dst: Playlist) -> None:
"""Both lists, in id order: a move A→B beside a move B→A would otherwise lock crosswise. A list
already gone is said here: a write into it would answer with its foreign key's 500."""
found = lock_playlists(db, src.id, dst.id)
if src.id not in found:
raise ListGone(source=True)
if dst.id not in found:
raise ListGone(source=False)
def move_items(
db: Session, src: Playlist, dst: Playlist, video_ids: list[str]
) -> tuple[list[str], list[str]]:
"""Move these videos from `src` into `dst`, in the caller's one transaction, in the source's
order whatever order they were picked in. Returns the ones that landed in the target and the ones
it already held, which only left the source: the Undo needs to tell them apart. A video a
concurrent edit already took out of the source is in neither. Raises `MoveRefused` for the same
list twice."""
if src.id == dst.id:
raise MoveRefused("same_playlist")
# Both lists, in id order: a move A→B beside a move B→A would otherwise lock crosswise.
lock_playlists(db, src.id, dst.id)
row = queries.item(db, src.id, video_id)
if row is None:
raise MoveRefused("not_in_playlist")
dst_changed = False
if queries.item(db, dst.id, video_id) is None:
# Re-parent the existing row rather than delete+insert: `added_at` ("when I saved this")
# survives the move, and there is no window where the video belongs to neither list. The
# check above holds: both lists are locked, and an insert into the target (another tab's
# add, a YouTube merge) waits for the lock — its foreign key needs the target row.
row.playlist_id = dst.id
row.position = next_item_position(db, dst.id)
dst_changed = True
if not dst_changed:
db.delete(row)
# The source always changed; the target only when the row landed in it. Touching a target
# that already had the video promoted an untouched list in the rail's "recently changed" sort.
recompute_dirty(db, src)
touch(src)
if dst_changed:
_lock_both(db, src, dst)
wanted = set(video_ids)
rows = [it for it in queries.items(db, src.id) if it.video_id in wanted]
held = queries.in_playlist(db, dst.id, [r.video_id for r in rows])
position = next_item_position(db, dst.id)
moved: list[str] = []
already_there: list[str] = []
for row in rows:
if row.video_id in held:
already_there.append(row.video_id)
db.delete(row)
else:
moved.append(row.video_id)
# Re-parented rather than deleted and inserted: `added_at` ("when I saved this") survives
# the move, and there is no window where the video belongs to neither list. `held` stays
# true: both lists are locked, and an insert into the target (another tab's add, a
# YouTube merge) waits for the lock, as its foreign key needs the target row.
row.playlist_id = dst.id
row.position = position
position += 1
if rows:
recompute_dirty(db, src)
touch(src)
# Only when a row landed in it: touching a target that already had the video promoted an untouched
# list in the rail's "recently changed" sort.
if moved:
recompute_dirty(db, dst)
touch(dst)
db.flush()
return moved, already_there
def undo_move(
db: Session,
src: Playlist,
dst: Playlist,
moved: list[str],
already_there: list[str],
positions: list[int],
) -> int:
"""Take a move back in one transaction: the `moved` videos leave `dst` and return to their places
in `src`, keeping their rows, as the move did. The `already_there` ones return too and stay in
`dst`, which held them before. A moved video `dst` no longer holds has gone on from there, and
bringing it back would leave it in lists the reader never put it in, so it stays out, as a
move skips a video a concurrent edit took out. `positions` pairs with `moved` + `already_there`.
Says how many came back. Raises `MoveRefused` for the same list twice, as a move does."""
if src.id == dst.id:
raise MoveRefused("same_playlist")
_lock_both(db, src, dst)
wanted = set(moved)
rows = {it.video_id: it for it in queries.items(db, dst.id) if it.video_id in wanted}
changed = bool(rows)
placed = list(zip(positions, [*moved, *already_there], strict=True))
back = _insert_at(db, src, placed, leaving=rows, staying_out=frozenset(wanted - rows.keys()))
# What `src` holds again by now only leaves `dst`.
for row in rows.values():
db.delete(row)
db.flush()
if changed:
recompute_dirty(db, dst)
touch(dst)
if back:
recompute_dirty(db, src)
touch(src)
db.flush()
return back
def reorder_items(db: Session, pl: Playlist, order: list[str]) -> int:
+131 -38
View File
@@ -6,7 +6,7 @@ out (`/{id}/push-plan`, `/{id}/revert-youtube`), behind the optional write scope
from typing import TYPE_CHECKING, Literal
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from pydantic import BaseModel, model_validator
from sqlalchemy.orm import Session
from app import quota
@@ -386,24 +386,17 @@ def delete_playlist(
return {"deleted": playlist_id}
class AddItemIn(VideoRefIn):
# Where the video lands among the list's items, in their stored order (0 = first). Omitted, it
# is appended. The list page's "Undo" of a removal puts the row back where it was with this —
# ONE request, because append-then-reorder leaves it at the end whenever the second call fails.
position: int | None = Field(default=None, ge=0)
@router.post("/{playlist_id}/items")
def add_item(
playlist_id: int,
body: AddItemIn,
body: VideoRefIn,
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
pl = _own_playlist(db, user, playlist_id)
pl = _own_playlist(db, user, playlist_id, lock=True)
video_id = body.video_id
_video_or_404(db, video_id)
service.add_to_playlist(db, pl, video_id, body.position)
service.add_item(db, pl, video_id, mark_dirty=True)
db.commit()
return {"ok": True, "playlist_id": pl.id, "video_id": video_id}
@@ -415,58 +408,158 @@ def remove_item(
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
pl = _own_playlist(db, user, playlist_id)
pl = _own_playlist(db, user, playlist_id, lock=True)
service.remove_item(db, pl, video_id, mark_dirty=True)
db.commit()
return {"ok": True, "playlist_id": pl.id, "video_id": video_id}
class MoveItemIn(BaseModel):
class VideoIdsIn(BaseModel):
# Required: with a default, a misnamed key answered 200 having acted on no video.
video_ids: list[str]
def _paired(video_count: int, positions: list[int]) -> None:
"""One place per video, none before the start and none twice: both undo bodies come back from
the client, and the insert's arithmetic counts on places that only rise."""
if len(positions) != video_count:
raise ValueError("positions must pair one-to-one with the videos")
if any(at < 0 for at in positions):
raise ValueError("positions cannot be negative")
if len(set(positions)) != len(positions):
raise ValueError("positions cannot repeat")
class RestoreItemsIn(VideoIdsIn):
# Where each video goes back: an index over the ordered rows, one per video.
positions: list[int] = []
@model_validator(mode="after")
def _one_position_per_video(self):
_paired(len(self.video_ids), self.positions)
return self
class MoveItemsIn(VideoIdsIn):
target_playlist_id: int
@router.post("/{playlist_id}/items/{video_id}/move")
def move_item(
class UndoMoveIn(BaseModel):
from_playlist_id: int
# Required, as `video_ids` is. An empty one is still a real body: the target held them all.
moved: list[str]
already_there: list[str] = []
# One per video of `moved` + `already_there`, in that order.
positions: list[int]
@model_validator(mode="after")
def _one_position_per_video(self):
_paired(len(self.moved) + len(self.already_there), self.positions)
return self
def _list_gone(e: service.ListGone) -> HTTPException:
"""Each list of a two-list write under the reason the route's own read gives it."""
return not_found("playlist") if e.source else not_found("move_target")
def _move_refused(e: service.MoveRefused) -> HTTPException:
return HTTPException(status_code=400, detail={"code": "playlist_move", "reason": e.reason})
def _other_list(db: Session, user: User, playlist_id: int) -> "Playlist":
"""The other list of a move. Its own reason: "this playlist doesn't exist any more" about a list
that is gone was read as the one on screen, which is still there."""
other = queries.own_playlist(db, user, playlist_id)
if other is None:
raise not_found("move_target")
return other
@router.post("/{playlist_id}/items/remove-bulk")
def remove_items(
playlist_id: int,
video_id: str,
body: MoveItemIn,
body: VideoIdsIn,
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
"""Move one video from a playlist into another of the caller's playlists, atomically.
"""Remove many videos from a playlist at once. Returns the ones that went."""
pl = _own_playlist(db, user, playlist_id, lock=True)
removed = service.remove_items(db, pl, body.video_ids)
db.commit()
return {"ok": True, "playlist_id": pl.id, "removed": removed}
@router.post("/{playlist_id}/items/restore")
def restore_items(
playlist_id: int,
body: RestoreItemsIn,
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
"""The "Undo" of a removal: the videos back at their places. Returns how many came back."""
pl = _own_playlist(db, user, playlist_id, lock=True)
added = service.restore_items(db, pl, body.video_ids, body.positions)
db.commit()
return {"ok": True, "playlist_id": pl.id, "added": added}
@router.post("/{playlist_id}/items/move-bulk")
def move_items(
playlist_id: int,
body: MoveItemsIn,
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
"""Move videos into another of the caller's playlists, atomically.
ONE transaction on purpose. The obvious client-side shape — add to the target, then remove
from the source — leaves the video in BOTH lists whenever the second call fails, which is
exactly the kind of half-applied state this codebase treats as a bug rather than an edge
case. Idempotent: if the video is already in the target, the source row is simply dropped
(moving into a list that already has it is a removal, not a duplicate)."""
from the source — leaves a video in BOTH lists whenever the second call fails. A video the
target already has only leaves the source, and one a concurrent edit (another tab, a YouTube
re-pull) already took out is skipped. Says which landed there and which it already held, for
the "Undo"."""
src = _own_playlist(db, user, playlist_id)
# Its own reason: "this playlist doesn't exist any more" about a TARGET that is gone was read as
# the list on screen — which is still there.
dst = queries.own_playlist(db, user, body.target_playlist_id)
if dst is None:
raise not_found("move_target")
dst = _other_list(db, user, body.target_playlist_id)
try:
service.move_item(db, src, dst, video_id)
moved, already_there = service.move_items(db, src, dst, body.video_ids)
except service.MoveRefused as e:
# A same-list move is a bad request; a video a concurrent edit (another tab, a YouTube
# re-pull) already took out is gone.
status = 400 if e.reason == "same_playlist" else 404
raise HTTPException(
status_code=status, detail={"code": "playlist_move", "reason": e.reason}
) from None
raise _move_refused(e) from None
except service.ListGone as e:
raise _list_gone(e) from None
db.commit()
return {"ok": True, "from_playlist_id": src.id, "to_playlist_id": dst.id, "video_id": video_id}
return {
"ok": True,
"from_playlist_id": src.id,
"to_playlist_id": dst.id,
"moved": moved,
"already_there": already_there,
}
class ReorderItemsIn(BaseModel):
video_ids: list[str] = []
@router.post("/{playlist_id}/items/undo-move")
def undo_move(
playlist_id: int,
body: UndoMoveIn,
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
"""The "Undo" of a move, back into this playlist in one transaction. Returns how many came back."""
src = _own_playlist(db, user, playlist_id)
dst = _other_list(db, user, body.from_playlist_id)
try:
added = service.undo_move(db, src, dst, body.moved, body.already_there, body.positions)
except service.MoveRefused as e:
raise _move_refused(e) from None
except service.ListGone as e:
raise _list_gone(e) from None
db.commit()
return {"ok": True, "playlist_id": src.id, "added": added}
@router.put("/{playlist_id}/order")
def reorder_items(
playlist_id: int,
body: ReorderItemsIn,
body: VideoIdsIn,
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
+10 -7
View File
@@ -323,13 +323,14 @@ def item_progress(
`_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."""
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,
# 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
# 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
# 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))
position = max(0, int(body.position_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
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
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
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")
s = plex_stream.current_session(user.id, str(rating_key))
if s is None or s.id != session_id:
raise refused(404, "session_gone")
raise refused(410, "session_gone")
path = s.dir / filename
# 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
@@ -648,13 +651,13 @@ def stream_hls_file(
except OSError:
# 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
# to present as the same 404 a gone session does: the player's recovery is keyed on the
# STATUS (`code === 404`), so
# to present as the same 410 a gone session does: the player's recovery is keyed on the
# STATUS, so
# 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
# 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.
raise refused(404, "session_gone") from None
raise refused(410, "session_gone") from None
return Response(
content=content,
media_type=_HLS_CT[ext],
+3 -1
View File
@@ -183,7 +183,9 @@ def playlist_remove_item(
class PlexItemKeysIn(BaseModel):
item_rating_keys: list[str] = []
# Required: with a default, a misnamed key answered 200 having acted on no item, and a reorder
# took it for an empty order.
item_rating_keys: list[str]
class PlexItemAddBulkIn(PlexItemKeysIn):
+2 -1
View File
@@ -314,7 +314,8 @@ def search_youtube(
class ClearSearchIn(BaseModel):
video_ids: list[str] = []
# Required: a default read a misnamed key as an empty list, a 200 that cleared nothing.
video_ids: list[str]
@router.post("/clear")
+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.
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
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
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).
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
_PAST_THE_RULE = stream._FINISHED_IDLE_S + 5
class _Session:
def __init__(self, tmp_path, name: str, finished: bool, idle_s: float):
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):
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 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(
tmp_path, sessions
):
@@ -116,8 +137,32 @@ def test_the_periodic_pass_still_reaps_what_nobody_has_touched_for_the_idle_limi
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):
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
stream.touch_session(1, "12345")
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")
db.add(user)
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
resp = _client(db, user, override_dep).post(
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
):
"""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."""
monkeypatch.setattr(state, "_configured_cache", True)
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")
db.add(user)
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
def fake_start(db_, item, user_id, start, audio=None, aoff=0.0, multi=False, transcode=False):
+16 -12
View File
@@ -1,4 +1,4 @@
"""Adding a video AT a position: the list page's "Undo" of a removal.
"""Putting a video back AT a position: the list page's "Undo" of a removal.
The row has to come back where it was in ONE request. The two-call shape (append, then send the
whole order) leaves it at the end of the list whenever the second call fails, and the reorder would
@@ -38,14 +38,17 @@ def _ids(db, pl_id):
]
def _add(client, pl_id, body):
return client.post(f"/api/playlists/{pl_id}/items", json=body)
def _restore(client, pl_id, video_id, position):
return client.post(
f"/api/playlists/{pl_id}/items/restore",
json={"video_ids": [video_id], "positions": [position]},
)
def test_a_position_puts_the_video_back_in_its_place(db, make_user, as_user):
user = make_user()
pl = _seed(db, user, ["a", "b", "c"])
r = _add(as_user(user), pl.id, {"video_id": "vX", "position": 1})
r = _restore(as_user(user), pl.id, "vX", 1)
assert r.status_code == 200
assert _ids(db, pl.id) == ["a", "vX", "b", "c"]
@@ -54,36 +57,37 @@ def test_the_position_counts_rows_not_stored_numbers(db, make_user, as_user):
# A removal leaves a gap (0, 5, 9): index 2 is before the THIRD row, not "position 2".
user = make_user()
pl = _seed(db, user, ["a", "b", "c"], positions=[0, 5, 9])
_add(as_user(user), pl.id, {"video_id": "vX", "position": 2})
_restore(as_user(user), pl.id, "vX", 2)
assert _ids(db, pl.id) == ["a", "b", "vX", "c"]
def test_a_position_past_the_end_appends(db, make_user, as_user):
user = make_user()
pl = _seed(db, user, ["a", "b"])
_add(as_user(user), pl.id, {"video_id": "vX", "position": 40})
_restore(as_user(user), pl.id, "vX", 40)
assert _ids(db, pl.id) == ["a", "b", "vX"]
def test_no_position_still_appends(db, make_user, as_user):
def test_an_add_appends(db, make_user, as_user):
user = make_user()
pl = _seed(db, user, ["a", "b"])
_add(as_user(user), pl.id, {"video_id": "vX"})
r = as_user(user).post(f"/api/playlists/{pl.id}/items", json={"video_id": "vX"})
assert r.status_code == 200
assert _ids(db, pl.id) == ["a", "b", "vX"]
def test_a_video_already_in_the_list_does_not_move(db, make_user, as_user):
# The add is idempotent; a position must not turn it into a reorder.
# A restore is idempotent; a position must not turn it into a reorder.
user = make_user()
pl = _seed(db, user, ["a", "b", "c"])
_add(as_user(user), pl.id, {"video_id": "c", "position": 0})
_restore(as_user(user), pl.id, "c", 0)
assert _ids(db, pl.id) == ["a", "b", "c"]
def test_a_negative_position_is_refused(db, make_user, as_user):
user = make_user()
pl = _seed(db, user, ["a"])
r = _add(as_user(user), pl.id, {"video_id": "vX", "position": -1})
r = _restore(as_user(user), pl.id, "vX", -1)
assert r.status_code == 422
assert _ids(db, pl.id) == ["a"]
@@ -101,6 +105,6 @@ def test_putting_a_removed_video_back_leaves_a_linked_list_clean(db, make_user,
assert client.delete(f"/api/playlists/{pl.id}/items/b").status_code == 200
db.expire_all()
assert db.get(Playlist, pl.id).dirty is True
_add(client, pl.id, {"video_id": "b", "position": 1})
_restore(client, pl.id, "b", 1)
db.expire_all()
assert db.get(Playlist, pl.id).dirty is False
+640
View File
@@ -0,0 +1,640 @@
"""Many videos at once: remove, move, and the Undo of each. Every test reads both lists after the call,
as `test_playlist_move` does: a half-applied move is the failure these routes exist to rule out."""
from datetime import datetime, timedelta, timezone
import pytest
from sqlalchemy import select
from app.catalog.models import Channel, Video
from app.reader import queries, service
from app.reader.models import Playlist, PlaylistItem
from app.sync.playlists import current_fingerprint
def _seed_videos(db, ids):
db.add(Channel(id="UCtest", title="Test"))
now = datetime.now(timezone.utc)
for v in ids:
db.add(Video(id=v, channel_id="UCtest", published_at=now))
db.commit()
def _playlist(db, user, name, video_ids, **kw):
pl = Playlist(user_id=user.id, name=name, **kw)
db.add(pl)
db.flush()
for i, vid in enumerate(video_ids):
db.add(PlaylistItem(playlist_id=pl.id, video_id=vid, position=i))
db.commit()
return pl
def _ids(db, pl_id):
db.expire_all()
return list(
db.execute(
select(PlaylistItem.video_id)
.where(PlaylistItem.playlist_id == pl_id)
.order_by(PlaylistItem.position)
).scalars()
)
def _positions(db, pl_id):
db.expire_all()
return list(
db.execute(
select(PlaylistItem.position)
.where(PlaylistItem.playlist_id == pl_id)
.order_by(PlaylistItem.position)
).scalars()
)
def test_remove_bulk_takes_out_the_named_videos_and_counts_what_was_there(db, make_user, as_user):
user = make_user()
_seed_videos(db, ["a", "b", "c", "d"])
pl = _playlist(db, user, "L", ["a", "b", "c", "d"])
r = as_user(user).post(
f"/api/playlists/{pl.id}/items/remove-bulk", json={"video_ids": ["b", "d", "gone"]}
)
assert r.status_code == 200
assert r.json() == {"ok": True, "playlist_id": pl.id, "removed": ["b", "d"]}
assert _ids(db, pl.id) == ["a", "c"]
def test_remove_bulk_of_nothing_leaves_the_list_untouched(db, make_user, as_user):
user = make_user()
_seed_videos(db, ["a"])
pl = _playlist(db, user, "L", ["a"])
stamp = datetime.now(timezone.utc) - timedelta(days=3)
pl.updated_at = stamp
db.commit()
r = as_user(user).post(
f"/api/playlists/{pl.id}/items/remove-bulk", json={"video_ids": ["gone"]}
)
assert r.json()["removed"] == []
db.expire_all()
assert db.get(Playlist, pl.id).updated_at == stamp
def test_restore_puts_the_videos_back_at_their_places(db, make_user, as_user):
user = make_user()
_seed_videos(db, ["a", "b", "c", "d"])
pl = _playlist(db, user, "L", ["b", "d"])
r = as_user(user).post(
f"/api/playlists/{pl.id}/items/restore",
json={"video_ids": ["c", "a"], "positions": [2, 0]},
)
assert r.json() == {"ok": True, "playlist_id": pl.id, "added": 2}
assert _ids(db, pl.id) == ["a", "b", "c", "d"]
assert _positions(db, pl.id) == [0, 1, 2, 3]
def test_restore_leaves_a_video_already_back_where_it_is_and_skips_one_the_catalog_lost(
db, make_user, as_user
):
user = make_user()
_seed_videos(db, ["a", "b"])
pl = _playlist(db, user, "L", ["b", "a"])
r = as_user(user).post(
f"/api/playlists/{pl.id}/items/restore",
json={"video_ids": ["a", "lost"], "positions": [0, 1]},
)
assert r.json()["added"] == 0
assert _ids(db, pl.id) == ["b", "a"]
@pytest.mark.parametrize(
"body",
[
{"video_ids": ["a"], "positions": []},
{"video_ids": ["a"], "positions": [0, 1]},
{"video_ids": ["a"], "positions": [-1]},
],
)
def test_restore_refuses_positions_that_do_not_pair(db, make_user, as_user, body):
user = make_user()
pl = _playlist(db, user, "L", [])
assert as_user(user).post(f"/api/playlists/{pl.id}/items/restore", json=body).status_code == 422
def test_move_bulk_lands_in_the_sources_order_whatever_order_they_were_picked_in(
db, make_user, as_user
):
user = make_user()
_seed_videos(db, ["a", "b", "c", "x"])
src = _playlist(db, user, "Source", ["a", "b", "c"])
dst = _playlist(db, user, "Target", ["x"])
r = as_user(user).post(
f"/api/playlists/{src.id}/items/move-bulk",
json={"video_ids": ["c", "a"], "target_playlist_id": dst.id},
)
assert r.status_code == 200
assert r.json() == {
"ok": True,
"from_playlist_id": src.id,
"to_playlist_id": dst.id,
"moved": ["a", "c"],
"already_there": [],
}
assert _ids(db, src.id) == ["b"]
assert _ids(db, dst.id) == ["x", "a", "c"]
def test_move_bulk_says_which_the_target_already_held_and_skips_one_already_gone(
db, make_user, as_user
):
user = make_user()
_seed_videos(db, ["a", "b", "c"])
src = _playlist(db, user, "Source", ["a", "b"])
dst = _playlist(db, user, "Target", ["b"])
r = as_user(user).post(
f"/api/playlists/{src.id}/items/move-bulk",
json={"video_ids": ["a", "b", "c"], "target_playlist_id": dst.id},
)
assert r.json()["moved"] == ["a"]
assert r.json()["already_there"] == ["b"]
assert _ids(db, src.id) == []
assert _ids(db, dst.id) == ["b", "a"]
def test_move_bulk_keeps_when_each_video_was_saved(db, make_user, as_user):
user = make_user()
_seed_videos(db, ["a"])
src = _playlist(db, user, "Source", ["a"])
dst = _playlist(db, user, "Target", [])
saved = datetime(2020, 1, 2, tzinfo=timezone.utc)
row = db.scalar(select(PlaylistItem).where(PlaylistItem.playlist_id == src.id))
row.added_at = saved
db.commit()
r = as_user(user).post(
f"/api/playlists/{src.id}/items/move-bulk",
json={"video_ids": ["a"], "target_playlist_id": dst.id},
)
assert r.status_code == 200
db.expire_all()
moved = db.scalar(select(PlaylistItem).where(PlaylistItem.playlist_id == dst.id))
assert moved.added_at == saved
def test_a_target_that_gained_nothing_does_not_rise_in_the_recently_changed_sort(
db, make_user, as_user
):
user = make_user()
_seed_videos(db, ["a"])
src = _playlist(db, user, "Source", ["a"])
dst = _playlist(db, user, "Target", ["a"])
stamp = datetime.now(timezone.utc) - timedelta(days=3)
dst.updated_at = stamp
db.commit()
r = as_user(user).post(
f"/api/playlists/{src.id}/items/move-bulk",
json={"video_ids": ["a"], "target_playlist_id": dst.id},
)
assert r.status_code == 200
db.expire_all()
assert db.get(Playlist, dst.id).updated_at == stamp
def test_move_bulk_into_the_same_list_is_refused(db, make_user, as_user):
user = make_user()
_seed_videos(db, ["a"])
pl = _playlist(db, user, "L", ["a"])
r = as_user(user).post(
f"/api/playlists/{pl.id}/items/move-bulk",
json={"video_ids": ["a"], "target_playlist_id": pl.id},
)
assert r.status_code == 400
assert r.json()["detail"] == {"code": "playlist_move", "reason": "same_playlist"}
assert _ids(db, pl.id) == ["a"]
def test_move_bulk_marks_a_linked_list_on_both_sides_as_unsynced(db, make_user, as_user):
user = make_user()
_seed_videos(db, ["a"])
src = _playlist(db, user, "Source", ["a"], yt_playlist_id="PLs")
dst = _playlist(db, user, "Target", [], yt_playlist_id="PLd")
r = as_user(user).post(
f"/api/playlists/{src.id}/items/move-bulk",
json={"video_ids": ["a"], "target_playlist_id": dst.id},
)
assert r.status_code == 200
db.expire_all()
assert db.get(Playlist, src.id).dirty is True
assert db.get(Playlist, dst.id).dirty is True
def test_undo_move_restores_both_lists_as_they_stood(db, make_user, as_user):
user = make_user()
_seed_videos(db, ["a", "b", "c", "d"])
src = _playlist(db, user, "Source", ["a", "b", "c", "d"])
dst = _playlist(db, user, "Target", ["c"])
client = as_user(user)
moved = client.post(
f"/api/playlists/{src.id}/items/move-bulk",
json={"video_ids": ["a", "c"], "target_playlist_id": dst.id},
).json()
assert _ids(db, src.id) == ["b", "d"]
assert _ids(db, dst.id) == ["c", "a"]
r = client.post(
f"/api/playlists/{src.id}/items/undo-move",
json={
"from_playlist_id": dst.id,
"moved": moved["moved"],
"already_there": moved["already_there"],
"positions": [0, 2],
},
)
assert r.json() == {"ok": True, "playlist_id": src.id, "added": 2}
assert _ids(db, src.id) == ["a", "b", "c", "d"]
# The one the target held before the move stays there.
assert _ids(db, dst.id) == ["c"]
def test_undo_move_keeps_when_each_video_was_saved(db, make_user, as_user):
user = make_user()
_seed_videos(db, ["a"])
src = _playlist(db, user, "Source", [])
dst = _playlist(db, user, "Target", ["a"])
saved = datetime(2020, 1, 2, tzinfo=timezone.utc)
row = db.scalar(select(PlaylistItem).where(PlaylistItem.playlist_id == dst.id))
row.added_at = saved
db.commit()
r = as_user(user).post(
f"/api/playlists/{src.id}/items/undo-move",
json={"from_playlist_id": dst.id, "moved": ["a"], "positions": [0]},
)
assert r.json()["added"] == 1
db.expire_all()
back = db.scalar(select(PlaylistItem).where(PlaylistItem.playlist_id == src.id))
assert back.added_at == saved
def test_undo_move_leaves_a_video_that_moved_on_where_it_is(db, make_user, as_user):
"""A to B, then B to C, then the first Undo: the video is not in the list the Undo takes it from,
and coming back would leave it in two lists the reader never put it in."""
user = make_user()
_seed_videos(db, ["a", "b"])
src = _playlist(db, user, "Source", [])
dst = _playlist(db, user, "Target", ["b"])
third = _playlist(db, user, "Third", ["a"])
r = as_user(user).post(
f"/api/playlists/{src.id}/items/undo-move",
json={"from_playlist_id": dst.id, "moved": ["a", "b"], "positions": [0, 1]},
)
assert r.json()["added"] == 1
assert _ids(db, src.id) == ["b"]
assert _ids(db, dst.id) == []
assert _ids(db, third.id) == ["a"]
def test_an_undo_closes_up_the_places_of_a_video_that_stayed_out(db, make_user, as_user):
"""Each index counts the rows put back before it, so one that stays out moves the rest up."""
user = make_user()
_seed_videos(db, ["a", "b", "c", "d"])
src = _playlist(db, user, "Source", ["c", "d"])
dst = _playlist(db, user, "Target", ["b"])
r = as_user(user).post(
f"/api/playlists/{src.id}/items/undo-move",
json={"from_playlist_id": dst.id, "moved": ["a", "b"], "positions": [0, 1]},
)
assert r.json()["added"] == 1
assert _ids(db, src.id) == ["b", "c", "d"]
def test_a_restore_closes_up_the_places_of_a_video_the_catalog_lost(db, make_user, as_user):
user = make_user()
_seed_videos(db, ["b", "c", "d"])
pl = _playlist(db, user, "L", ["c", "d"])
r = as_user(user).post(
f"/api/playlists/{pl.id}/items/restore",
json={"video_ids": ["lost", "b"], "positions": [0, 1]},
)
assert r.json()["added"] == 1
assert _ids(db, pl.id) == ["b", "c", "d"]
def test_undo_move_takes_a_video_out_of_the_target_when_the_source_has_it_again(
db, make_user, as_user
):
user = make_user()
_seed_videos(db, ["a"])
src = _playlist(db, user, "Source", ["a"])
dst = _playlist(db, user, "Target", ["a"])
r = as_user(user).post(
f"/api/playlists/{src.id}/items/undo-move",
json={"from_playlist_id": dst.id, "moved": ["a"], "positions": [0]},
)
assert r.json()["added"] == 0
assert _ids(db, src.id) == ["a"]
assert _ids(db, dst.id) == []
@pytest.mark.parametrize(
("path", "body", "gone", "reason"),
[
("move-bulk", {"video_ids": ["a"], "target_playlist_id": "dst"}, "dst", "move_target"),
("move-bulk", {"video_ids": ["a"], "target_playlist_id": "dst"}, "src", "playlist"),
(
"undo-move",
{"from_playlist_id": "dst", "moved": ["a"], "positions": [0]},
"src",
"playlist",
),
(
"undo-move",
{"from_playlist_id": "dst", "moved": ["a"], "positions": [0]},
"dst",
"move_target",
),
],
)
def test_a_list_deleted_before_the_lock_is_not_found(
db, make_user, as_user, monkeypatch, path, body, gone, reason
):
user = make_user()
_seed_videos(db, ["a"])
src = _playlist(db, user, "Source", ["a"])
dst = _playlist(db, user, "Target", ["a"])
ids = {"src": src.id, "dst": dst.id}
body = {k: (dst.id if v == "dst" else v) for k, v in body.items()}
# Another tab's delete committed between the route's read and this lock.
monkeypatch.setattr(service, "lock_playlists", lambda _db, *got: set(got) - {ids[gone]})
r = as_user(user).post(f"/api/playlists/{src.id}/items/{path}", json=body)
assert r.status_code == 404
assert reason in str(r.json()["detail"])
@pytest.mark.parametrize(
("method", "path", "body"),
[
("post", "items", {"video_id": "a"}),
("delete", "items/a", None),
("post", "items/remove-bulk", {"video_ids": ["a"]}),
("post", "items/restore", {"video_ids": ["a"], "positions": [0]}),
],
)
def test_a_one_list_write_finds_its_list_under_the_lock(
db, make_user, as_user, monkeypatch, method, path, body
):
"""Read with its lock, so a delete that won the row answers 404 rather than a foreign key's 500."""
user = make_user()
_seed_videos(db, ["a"])
pl = _playlist(db, user, "L", [])
real = queries.own_playlist
def locked_read_finds_nothing(db_, user_, playlist_id, *, lock=False):
return None if lock else real(db_, user_, playlist_id)
monkeypatch.setattr(queries, "own_playlist", locked_read_finds_nothing)
kw = {"json": body} if body is not None else {}
r = getattr(as_user(user), method)(f"/api/playlists/{pl.id}/{path}", **kw)
assert r.status_code == 404
@pytest.mark.parametrize(
("path", "body"),
[
("items/remove-bulk", {"ids": ["a"]}),
("items/move-bulk", {"ids": ["a"], "target_playlist_id": 1}),
("items/undo-move", {"ids": ["a"], "from_playlist_id": 1}),
],
)
def test_a_body_without_its_videos_is_refused_rather_than_acting_on_none(
db, make_user, as_user, path, body
):
user = make_user()
pl = _playlist(db, user, "L", [])
assert as_user(user).post(f"/api/playlists/{pl.id}/{path}", json=body).status_code == 422
def test_an_undo_of_only_videos_the_target_held_names_no_moved_ones(db, make_user, as_user):
"""An empty `moved` is a real body: every video of the move was already in the target."""
user = make_user()
_seed_videos(db, ["a"])
src = _playlist(db, user, "Source", [])
dst = _playlist(db, user, "Target", ["a"])
r = as_user(user).post(
f"/api/playlists/{src.id}/items/undo-move",
json={"from_playlist_id": dst.id, "moved": [], "already_there": ["a"], "positions": [0]},
)
assert r.json()["added"] == 1
assert _ids(db, src.id) == ["a"]
assert _ids(db, dst.id) == ["a"]
def test_an_undo_within_one_list_is_refused_and_keeps_its_videos(db, make_user, as_user):
"""A move refuses the same list twice, so no Undo of one exists; a row must not go for asking."""
user = make_user()
_seed_videos(db, ["a", "b"])
pl = _playlist(db, user, "L", ["a", "b"])
r = as_user(user).post(
f"/api/playlists/{pl.id}/items/undo-move",
json={"from_playlist_id": pl.id, "moved": ["a"], "positions": [0]},
)
assert r.status_code == 400
assert r.json()["detail"] == {"code": "playlist_move", "reason": "same_playlist"}
assert _ids(db, pl.id) == ["a", "b"]
@pytest.mark.parametrize(
("path", "key", "extra"),
[
("items/restore", "video_ids", {}),
("items/undo-move", "moved", {"from_playlist_id": None}),
],
)
def test_two_videos_cannot_ask_for_one_place(db, make_user, as_user, path, key, extra):
user = make_user()
_seed_videos(db, ["a", "b", "c"])
pl = _playlist(db, user, "L", ["c"])
other = _playlist(db, user, "Other", ["a", "b"])
body = {key: ["a", "b"], "positions": [0, 0]}
body.update({k: other.id for k in extra})
r = as_user(user).post(f"/api/playlists/{pl.id}/{path}", json=body)
assert r.status_code == 422
assert _ids(db, pl.id) == ["c"]
def test_a_reorder_without_its_videos_is_refused(db, make_user, as_user):
user = make_user()
pl = _playlist(db, user, "L", [])
assert as_user(user).put(f"/api/playlists/{pl.id}/order", json={"ids": []}).status_code == 422
def _synced(db, pl):
pl.synced_fingerprint = current_fingerprint(db, pl)
pl.dirty = False
db.commit()
def test_a_bulk_remove_and_its_undo_leave_a_linked_list_as_it_was(db, make_user, as_user):
user = make_user()
_seed_videos(db, ["a", "b"])
pl = _playlist(db, user, "L", ["a", "b"], yt_playlist_id="PLx")
_synced(db, pl)
client = as_user(user)
r = client.post(f"/api/playlists/{pl.id}/items/remove-bulk", json={"video_ids": ["a"]})
assert r.status_code == 200
db.expire_all()
assert db.get(Playlist, pl.id).dirty is True
r = client.post(
f"/api/playlists/{pl.id}/items/restore", json={"video_ids": ["a"], "positions": [0]}
)
assert r.status_code == 200
db.expire_all()
assert db.get(Playlist, pl.id).dirty is False
def test_a_move_and_its_undo_leave_both_linked_lists_as_they_were(db, make_user, as_user):
user = make_user()
_seed_videos(db, ["a", "b"])
src = _playlist(db, user, "Source", ["a", "b"], yt_playlist_id="PLs")
dst = _playlist(db, user, "Target", ["b"], yt_playlist_id="PLd")
_synced(db, src)
_synced(db, dst)
client = as_user(user)
moved = client.post(
f"/api/playlists/{src.id}/items/move-bulk",
json={"video_ids": ["a"], "target_playlist_id": dst.id},
).json()
assert moved["moved"] == ["a"]
db.expire_all()
assert db.get(Playlist, src.id).dirty is True
assert db.get(Playlist, dst.id).dirty is True
r = client.post(
f"/api/playlists/{src.id}/items/undo-move",
json={"from_playlist_id": dst.id, "moved": ["a"], "positions": [0]},
)
assert r.status_code == 200
db.expire_all()
assert db.get(Playlist, src.id).dirty is False
assert db.get(Playlist, dst.id).dirty is False
def test_undo_move_is_one_transaction(db, make_user, as_user, monkeypatch):
user = make_user()
_seed_videos(db, ["a"])
src = _playlist(db, user, "Source", [])
dst = _playlist(db, user, "Target", ["a"])
def boom(*a, **kw):
raise RuntimeError("second step failed")
monkeypatch.setattr(service, "_insert_at", boom)
client = as_user(user)
body = {"from_playlist_id": dst.id, "moved": ["a"], "already_there": [], "positions": [0]}
with pytest.raises(RuntimeError):
_ = client.post(f"/api/playlists/{src.id}/items/undo-move", json=body)
db.rollback()
assert _ids(db, dst.id) == ["a"]
assert _ids(db, src.id) == []
def test_undo_move_refuses_positions_that_do_not_pair(db, make_user, as_user):
user = make_user()
src = _playlist(db, user, "Source", [])
dst = _playlist(db, user, "Target", [])
r = as_user(user).post(
f"/api/playlists/{src.id}/items/undo-move",
json={"from_playlist_id": dst.id, "moved": ["a"], "already_there": ["b"], "positions": [0]},
)
assert r.status_code == 422
@pytest.mark.parametrize(
("path", "body"),
[
("remove-bulk", {"video_ids": ["a"]}),
("restore", {"video_ids": ["a"], "positions": [0]}),
("move-bulk", {"video_ids": ["a"], "target_playlist_id": 0}),
("undo-move", {"from_playlist_id": 0, "moved": ["a"], "positions": [0]}),
],
)
def test_another_accounts_list_is_not_found(db, make_user, as_user, path, body):
owner, other = make_user(), make_user()
_seed_videos(db, ["a"])
theirs = _playlist(db, owner, "Theirs", ["a"])
mine = _playlist(db, other, "Mine", [])
body = {
k: (mine.id if k in ("target_playlist_id", "from_playlist_id") else v)
for k, v in body.items()
}
r = as_user(other).post(f"/api/playlists/{theirs.id}/items/{path}", json=body)
assert r.status_code == 404
assert _ids(db, theirs.id) == ["a"]
@pytest.mark.parametrize(
("path", "key"), [("move-bulk", "target_playlist_id"), ("undo-move", "from_playlist_id")]
)
def test_the_other_list_of_a_move_must_be_the_callers_too(db, make_user, as_user, path, key):
owner, other = make_user(), make_user()
_seed_videos(db, ["a"])
theirs = _playlist(db, owner, "Theirs", ["a"])
mine = _playlist(db, other, "Mine", ["a"])
body = {"video_ids": ["a"], "moved": ["a"], "positions": [0], key: theirs.id}
r = as_user(other).post(f"/api/playlists/{mine.id}/items/{path}", json=body)
assert r.status_code == 404
assert "move_target" in str(r.json()["detail"])
assert _ids(db, theirs.id) == ["a"]
assert _ids(db, mine.id) == ["a"]
+7 -4
View File
@@ -51,7 +51,8 @@ def _ids(db, pl_id):
def _move(client, src, video_id, dst):
return client.post(
f"/api/playlists/{src}/items/{video_id}/move", json={"target_playlist_id": dst}
f"/api/playlists/{src}/items/move-bulk",
json={"video_ids": [video_id], "target_playlist_id": dst},
)
@@ -67,7 +68,8 @@ def test_move_leaves_the_video_in_exactly_one_list(db, make_user, as_user):
"ok": True,
"from_playlist_id": src.id,
"to_playlist_id": dst.id,
"video_id": "v1",
"moved": ["v1"],
"already_there": [],
}
db.expire_all()
assert _ids(db, src.id) == ["v2"]
@@ -116,8 +118,9 @@ def test_move_of_a_video_that_left_the_source_reports_it(db, make_user, as_user)
dst = _playlist(db, user, "Target", [])
r = _move(as_user(user), src.id, "v1", dst.id)
assert r.status_code == 404
assert r.json()["detail"] == {"code": "playlist_move", "reason": "not_in_playlist"}
assert r.status_code == 200
assert (r.json()["moved"], r.json()["already_there"]) == ([], [])
assert _ids(db, dst.id) == []
def test_move_into_someone_elses_list_404s_and_changes_nothing(db, make_user, as_user):
+49 -15
View File
@@ -102,16 +102,24 @@ PLEX = PlexPlaylist.__table__.name
"call",
[
lambda c, pid, other: c.post(f"/api/playlists/{pid}/items", json={"video_id": "v2"}),
lambda c, pid, other: c.post(
f"/api/playlists/{pid}/items", json={"video_id": "v2", "position": 0}
),
lambda c, pid, other: c.post("/api/playlists/watch-later", json={"video_id": "v2"}),
lambda c, pid, other: c.post(
f"/api/playlists/{pid}/items/v1/move", json={"target_playlist_id": other}
f"/api/playlists/{pid}/items/move-bulk",
json={"video_ids": ["v1"], "target_playlist_id": other},
),
lambda c, pid, other: c.put(f"/api/playlists/{pid}/order", json={"video_ids": ["v1"]}),
lambda c, pid, other: c.post(
f"/api/playlists/{pid}/items/remove-bulk", json={"video_ids": ["v1"]}
),
lambda c, pid, other: c.post(
f"/api/playlists/{pid}/items/restore", json={"video_ids": ["v2"], "positions": [0]}
),
lambda c, pid, other: c.post(
f"/api/playlists/{pid}/items/undo-move",
json={"from_playlist_id": other, "moved": ["v2"], "positions": [0]},
),
],
ids=["add", "add-at", "watch-later", "move", "reorder"],
ids=["add", "watch-later", "move", "reorder", "remove-bulk", "restore", "undo-move"],
)
def test_a_youtube_playlist_write_locks_the_playlist(db, make_user, as_user, locks, call):
user = make_user()
@@ -168,18 +176,35 @@ PLEX_ITEMS = PlexPlaylistItem.__table__.name
"call",
[
lambda c, pid, other: c.post(f"/api/playlists/{pid}/items", json={"video_id": "v1"}),
lambda c, pid, other: c.post(
f"/api/playlists/{pid}/items", json={"video_id": "v2", "position": 0}
),
lambda c, pid, other: c.delete(f"/api/playlists/{pid}/items/v1"),
lambda c, pid, other: c.post(
f"/api/playlists/{pid}/items/v1/move", json={"target_playlist_id": other}
f"/api/playlists/{pid}/items/move-bulk",
json={"video_ids": ["v1"], "target_playlist_id": other},
),
lambda c, pid, other: c.put(f"/api/playlists/{pid}/order", json={"video_ids": ["v1"]}),
# the ORM cascade deletes the items before the list row
lambda c, pid, other: c.delete(f"/api/playlists/{pid}"),
lambda c, pid, other: c.post(
f"/api/playlists/{pid}/items/remove-bulk", json={"video_ids": ["v1"]}
),
lambda c, pid, other: c.post(
f"/api/playlists/{pid}/items/restore", json={"video_ids": ["v2"], "positions": [0]}
),
lambda c, pid, other: c.post(
f"/api/playlists/{pid}/items/undo-move",
json={"from_playlist_id": other, "moved": ["v2"], "positions": [0]},
),
],
ids=[
"add-present",
"remove",
"move",
"reorder",
"delete",
"remove-bulk",
"restore",
"undo-move",
],
ids=["add-present", "add-at", "remove", "move", "reorder", "delete"],
)
def test_a_youtube_item_write_locks_the_playlist_before_any_item(
db, make_user, as_user, statements, call
@@ -193,14 +218,23 @@ def test_a_youtube_item_write_locks_the_playlist_before_any_item(
assert _locked_before_items(statements, YT, YT_ITEMS), statements
def test_a_move_locks_both_lists_in_one_ordered_statement(db, make_user, as_user, statements):
"""Two moves A→B and B→A each locking "its" list first would lock crosswise."""
@pytest.mark.parametrize(
("path", "body"),
[
("move-bulk", lambda other: {"video_ids": ["v1"], "target_playlist_id": other}),
("undo-move", lambda other: {"from_playlist_id": other, "moved": ["v2"], "positions": [0]}),
],
ids=["move", "undo"],
)
def test_a_move_locks_both_lists_in_one_ordered_statement(
db, make_user, as_user, statements, path, body
):
"""Two moves A→B and B→A each locking "its" list first would lock crosswise, and an Undo runs the
other way round from a move by its nature."""
user = make_user()
pid, other = _yt(db, user)
statements.clear()
r = as_user(user).post(
f"/api/playlists/{pid}/items/v1/move", json={"target_playlist_id": other}
)
r = as_user(user).post(f"/api/playlists/{pid}/items/{path}", json=body(other))
assert r.status_code == 200, r.text
lock = _locked_before_items(statements, YT, YT_ITEMS)
assert lock and "ORDER BY" in lock, lock
+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
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
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
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")
sessions[(7, "m1")] = _Session("sid-a", d)
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
):
"""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")
sessions[(VIEWER, "m1")] = _Session("sid-b", theirs)
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
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
):
"""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."""
started: list[float] = []
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),
)
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 == []
@@ -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
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
):
"""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
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"."""
d = tmp_path / "m1_0_0_+0.00"
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.
monkeypatch.setattr(plex_stream, "wait_for", lambda *a, **k: True)
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):
@@ -6,6 +6,8 @@ rows, since positions have gaps after a removal. One request: an append plus a s
leaves the rows at the end whenever that call fails, and would overwrite another tab's changes.
"""
import pytest
from app.plex.models import PlexItem, PlexLibrary, PlexPlaylist, PlexPlaylistItem
@@ -101,6 +103,22 @@ def test_positions_must_pair_with_the_keys(db, make_user, as_user):
assert _order(db, pl.id)[0] == ["a"]
@pytest.mark.parametrize(
("method", "path"),
[("post", "items/bulk"), ("post", "items/remove-bulk"), ("put", "order")],
)
def test_a_body_without_its_keys_is_refused_rather_than_acting_on_none(
db, make_user, as_user, method, path
):
user = make_user()
pl = _seed(db, user, ["a"], extra=())
r = getattr(as_user(user), method)(f"/api/plex/playlists/{pl.id}/{path}", json={"keys": ["a"]})
assert r.status_code == 422
assert _order(db, pl.id)[0] == ["a"]
def test_remove_then_put_back_is_no_change(db, make_user, as_user):
user = make_user()
pl = _seed(db, user, ["a", "b", "c", "d"], extra=())
+7 -9
View File
@@ -210,9 +210,8 @@ def test_an_add_is_idempotent_and_lands_where_asked(db, make_user):
pl = service.create_playlist(db, user, "L")
assert service.add_item(db, pl, "a", mark_dirty=False) is True
assert service.add_item(db, pl, "a", mark_dirty=False) is False
service.add_to_playlist(db, pl, "b", None)
service.add_to_playlist(db, pl, "c", 0)
service.add_to_playlist(db, pl, "a", 2) # already there: stays put
assert service.add_item(db, pl, "b", mark_dirty=True) is True
assert service.restore_items(db, pl, ["c", "a"], [0, 2]) == 1 # "a" is there: it stays put
assert _uncommitted(db)
db.commit()
assert _ids(db, pl) == ["c", "a", "b"]
@@ -235,14 +234,13 @@ def test_a_move_never_leaves_a_video_in_two_lists(db, make_user):
service.add_item(db, dst, "b", mark_dirty=False)
db.commit()
with pytest.raises(service.MoveRefused, match="same_playlist"):
service.move_item(db, src, src, "a")
service.move_item(db, src, dst, "a")
service.move_item(db, src, dst, "b") # already in the target: dropped from the source
service.move_items(db, src, src, ["a"])
assert service.move_items(db, src, dst, ["a"]) == (["a"], [])
# already in the target: dropped from the source
assert service.move_items(db, src, dst, ["b"]) == ([], ["b"])
db.commit()
assert (_ids(db, src), _ids(db, dst)) == ([], ["b", "a"])
with pytest.raises(service.MoveRefused, match="not_in_playlist"):
service.move_item(db, src, dst, "a")
db.rollback()
assert service.move_items(db, src, dst, ["a"]) == ([], [])
def test_a_reorder_keeps_what_it_left_out_after_the_listed(db, make_user):
+8
View File
@@ -310,6 +310,14 @@ def test_clearing_leaves_a_video_the_caller_never_found(db, make_user, as_user):
assert r.json() == {"ok": True, "removed": 0}
def test_clearing_refuses_a_body_without_its_list(db, make_user, as_user):
# A misnamed key read as an empty list: 200, and nothing cleared.
user = make_user()
r = as_user(user).post("/api/search/clear", json={"ids": ["s1"]})
assert r.status_code == 422
assert as_user(user).post("/api/search/clear", json={"video_ids": []}).status_code == 200
# --- Plex show and season marks ---------------------------------------------------------------
+29 -1
View File
@@ -536,6 +536,26 @@ of a full-width panel and nothing on the right.
shows them as four buttons with the item's place beside the selected view; a side panel in edit mode
gives each group an Up / Down pair (`ui/OrderButtons`, `compact`). A move that would go nowhere is
shown off, not hidden. A grip also picks up from the keyboard (space, the arrows, space).
- **Selecting rows:** a list whose rows take bulk actions shows a box first in every row, always, and one
over the column for the rows shown. Shift+click selects a range, `x` toggles the row under the keyboard,
Esc clears. A selected row wears the accent tint as well as its tick. An action covers the selected rows
shown; a row a filter hides stays selected and comes back ticked; another list starts over. The row's own
menu still acts on that row alone. While anything is selected the toolbar's second row is the action band:
the count (and how many a filter hides), the last move's target as one click, Move to, Remove, Clear
selection. An action clears the selection, undoes by its toast and, when it fails, ticks its rows again.
An Undo brings back what is still there to bring: a video the move put in the target and the target no
longer holds stays where it went, and a warning says how many came back. One the target held already only
left the source, so it returns there.
Delete takes the selection when there is one, else the row under the keyboard, with no confirm. With every
tick hidden by a filter it removes nothing and says so. An Esc during a drag puts the drag down, and only that.
Below `xl` the band's buttons are glyphs named in their tooltips, and a filter's hidden count is "+N" with
its sentence in the tooltip. The row the band replaces keeps its height, invisible, so a tick moves no row.
On a phone the count is its number (the words read out, and in the tooltip) and the undo pair stays out.
A row's grip also carries it onto a list in the rail, under a sort or a filter too (the grip then says
so, and no key lifts it); a selected row carries every selected one, named on the drag image below the
pointer. The target row lights up (`ring-accent`); the open list is none; an unpinned rail peeks by the
drag's own hover and scrolls at its edges. Dropped inside the list, one row takes its new place and many
stay. A key never leaves the list and moves its own row: the band and the row's menu are the other way.
- **Motion:** 150–300ms micro-interactions, transform/opacity only; `prefers-reduced-motion` is respected.
[WCAG 2.3.3]
@@ -833,4 +853,12 @@ of a full-width panel and nothing on the right.
| 2026-11-01 | Toolbar group glyphs (owner D9 a, D10 a, D11 a) | The feed's, channel page's and Plex Browse's filter pills name their group by a glyph (`Eye` watch state, `Shapes` content types, `ArrowDownUp` sort), the word in the tooltip and the accessible name; Sort + View end the toolbar's row, the chip row shows only with a chip; a channel page has no count pill | The value alone; Sort + View on a row of their own | "Watch state:" and "Sort:" cost a tablet row its room; the value says enough once the glyph names the group; the channel header already counts the videos |
| 2026-11-01 | Channel header on a tablet (owner E2) | Below `xl` the action row drops its two words: "Prio" (the stepper's tooltip names it) and "Subscribed" (a ✓ glyph; its name in the aria-label, Unsubscribe in the tooltip); the tags keep the row's rest, folding into "+N" | The tag lane on a full line of its own below ~12rem; the tags under the channel name | On an iPad (820 px, 44 px targets) the lane got 29 px, a sliver; now 130 px, and every tag shows at 1180 |
| 2026-11-01 | Clear watch progress (owner E a) | A video menu's Reset reads "Clear watch progress" in English (HU "Megtekintés visszaállítása" stays); "Reset view" is only a page's view reset (sort, filters, grouping) | "Reset view" for both; the playlist page's view reset renamed instead | On a playlist page the two stood one right-click apart under one name, and one of them erases the video's progress with no undo |
| 2026-11-01 | Clear watch progress clears only that (owner A) | The row shows only on a video with a resume point and clears that alone: a hidden video stays hidden, a watched one watched. Un-hiding and un-watching have rows of their own |
| 2026-11-01 | Clear watch progress clears only that (owner A) | The row shows only on a video with a resume point and clears that alone: a hidden video stays hidden, a watched one watched. Un-hiding and un-watching have rows of their own | The old Reset, which also un-hid and un-watched | A hidden video stays hidden: what the row's name says is all it does |
| 2026-11-01 | Selecting a list's rows (owner D2 a) | An always visible box first in the row and one over the column for the rows shown; Shift+click a range, `x` the row under the keyboard, Esc clears; the selected row wears `accent/12` with its tick. Rows a filter hides stay selected, an action covers those shown; Watch later offers no selecting. | A "Select" button that shows the boxes on demand | Found at once and works on touch, with no mode to leave; costs about 2 rem a row |
| 2026-11-01 | Selection band (owner D1 a, D3 a, D4 a) | The toolbar's second row while rows are selected, no floating bar: move (the last target as one click) and remove, no copy; both undo back to the places, a video the target held before stays there; a single row's move undoes too. | A floating bar at the bottom; copying to a list too; no undo on a move | It covers no row, and the player and the notices live at the bottom; twenty videos sent to the wrong list need a way back to their places |
| 2026-11-01 | Grip on a phone (owner A) | Below a 24rem table a playlist row has no grip: the title keeps its width, and the row menu keeps the moves. | The grip and its drag kept, the title at 44 px | The title gets its width back, and the rail is shut on a phone, so a drag had nowhere to go |
| 2026-11-01 | Selection band on a tablet (owner A) | Below `xl` the band is glyphs on one row, the names in tooltip and aria-label, as on the channel header; the replaced row stays in the same grid cell, invisible, so the first tick moves no row under the second tap. | The buttons in words, wrapping to a second row | The first tick pushed the list from under the second tap |
| 2026-11-01 | Playlist heading on a narrow window (owner A) | The list's name had no width left: below `xl` Unsynced is its dot (the word read out, the hint in the tooltip); on a phone the cover is gone and Play all is its glyph, named in the tooltip. | Every word kept, the list's name at no width | A heading without its name says nothing |
| 2026-11-02 | Dragging onto a list (owner D5 a, D6 a) | The grip carries a row, or every selected row with a selected one, onto a list in the rail; the toast undoes. Many dropped inside the list stay; a coarse pointer's drawer never peeks, so its rail is a target only when pinned. | Only the grabbed row going along; the grip off under a view | One row moving out of three lit ones misleads, and a filter is how the rows to move are found |
| 2026-11-02 | Delete with every tick hidden (owner a) | It removes nothing and a warning says the filter hides them | Taking the row under the keyboard | While the band shows a selection, Delete means that selection: a row no one ticked must not go |
| 2026-11-02 | Undo of a video gone further (owner a) | A video the move put in the target comes back only if the target still holds it, and a warning says how many | Everything back, whatever it did since | A move skips a video that left the source; the same rule back keeps a video out of lists the reader never put it in |
+2 -2
View File
@@ -108,8 +108,8 @@ project checks needs a row.
| Scope | Narrowed | Why | Measured |
| --- | --- | --- | --- |
| check | e2e (Playwright) not in `check` | It needs the :8080 stack built from the tree (minutes). It runs in `check-full`, and `release` rebuilds the stack and requires it (E32 D2) — nothing ships without it. | 9 specs: 6 × 3 engines, 3 on chromium only (the row below) |
| e2e | `a11y.spec.ts` `member.spec.ts` `devices.spec.ts` run on chromium only (`CHROMIUM_ONLY`, the webkit and ipad projects' `testIgnore`) | What they hold does not change with the engine: the axe scan reads the DOM and the accessibility tree, which the engines build alike; the member spec checks which pages and requests a role gets; the devices spec opens its own desktop and iPad contexts. Run three times they would triple their time and catch nothing more. What an engine does change (layout, events) the other specs hold on all three. | 3 of 9 specs |
| check | e2e (Playwright) not in `check` | It needs the :8080 stack built from the tree (minutes). It runs in `check-full`, and `release` rebuilds the stack and requires it (E32 D2) — nothing ships without it. | 12 specs: 9 × 3 engines, 3 on chromium only (the row below) |
| e2e | `a11y.spec.ts` `member.spec.ts` `devices.spec.ts` run on chromium only (`CHROMIUM_ONLY`, the webkit and ipad projects' `testIgnore`) | What they hold does not change with the engine: the axe scan reads the DOM and the accessibility tree, which the engines build alike; the member spec checks which pages and requests a role gets; the devices spec opens its own desktop and iPad contexts. Run three times they would triple their time and catch nothing more. What an engine does change (layout, events) the other specs hold on all three. | 3 of 12 specs |
| pushes | no server-side gate (no CI) | Owner decision 2026-10-04. The gate (`scripts/check.sh`) runs on the dev machine before work is handed over, and `release` runs it, then e2e, before anything is merged and Trivy before the image is pushed — the CI only repeated that, after the merge. Its runner shared the git host's container, and one run there starved Gitea of memory until it stopped answering. What it added — a fresh `npm ci` on Linux — the image build does at publish. | — |
| coverage | not in the gate; no threshold | Owner decision E32 D4: never enforced; "every regression test seen red" is the bar, and a percentage invites tests written to move it. Out of the lanes since E35: read once, by hand, it found 5 bugs; as a lane it was a number nobody acted on, and the slowest vitest run (the one that flaked). On demand: docs/development.md. | frontend 80% / backend 78% of lines (E32 S1) |
| CVE gate | HIGH/CRITICAL with a fix available only | `--ignore-unfixed` + `--severity HIGH,CRITICAL`: a finding with no fix cannot be acted on by an upgrade; it shows in the scan output. A fixable one fails the publish, and the only way past it is `.trivyignore.yaml`. | 0 open (E32 S1) |
+315
View File
@@ -0,0 +1,315 @@
import { test, expect, type Locator, type Page } from "@playwright/test";
import { openApp } from "./helpers";
// A video dragged onto a list in the rail, with a real pointer: the rail and the page are separate
// React trees, and jsdom lays out nothing to drag across. The browser projects share one account, so
// each test holds lists of its own, named for it, and takes them away after.
interface Listed {
id: number;
items: { id: string; title: string }[];
}
const made: number[] = [];
async function newList(page: Page, name: string, videos: string[] = []): Promise<number> {
const res = await page.request.post("/api/playlists", { data: { name } });
const { id } = (await res.json()) as { id: number };
made.push(id);
for (const video_id of videos) {
const added = await page.request.post(`/api/playlists/${String(id)}/items`, {
data: { video_id },
});
expect(added.ok()).toBe(true);
}
return id;
}
const contents = async (page: Page, id: number) =>
((await (await page.request.get(`/api/playlists/${String(id)}`)).json()) as Listed).items.map(
(v) => v.id,
);
async function setUp(page: Page, name: string, project: string) {
await pinOnTouch(page, project);
const feed = (await (await page.request.get("/api/feed?limit=3")).json()) as {
items: { id: string; title: string }[];
};
expect(feed.items.length).toBe(3);
const videos = feed.items;
const from = await newList(
page,
`${name} from`,
videos.map((v) => v.id),
);
const to = await newList(page, `${name} to`);
await openApp(page, `/?page=playlists&pl=${String(from)}`);
await expect(page.getByRole("heading", { name: `${name} from` })).toBeVisible();
await expect(page.locator("tbody tr")).toHaveCount(3);
return { videos, from, to, target: page.locator(`[data-playlist-drop="${String(to)}"]`) };
}
const centre = async (el: Locator) => {
const box = await el.boundingBox();
if (!box) throw new Error("nothing to point at");
return { x: box.x + box.width / 2, y: box.y + box.height / 2 };
};
/** Pick the row up by its grip and carry it to a point, the button still held. */
async function carry(page: Page, grip: Locator, to: { x: number; y: number }) {
const from = await centre(grip);
await page.mouse.move(from.x, from.y);
await page.mouse.down();
await page.mouse.move(from.x + 8, from.y + 8, { steps: 3 });
await page.mouse.move(to.x, to.y, { steps: 12 });
}
/** 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) {
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()) {
await carry(page, grip, await centre(target));
return;
}
await carry(page, grip, await centre(tab));
await expect(target).toBeVisible();
await page.mouse.move(...(Object.values(await centre(target)) as [number, number]), { steps: 6 });
}
/** What dnd-kit tells a screen reader: said once the drag has taken the step in, so a drop after it
* is the one a person makes, not one that beats the row to its new place. */
const said = (page: Page, text: RegExp) =>
expect(page.locator('[id^="DndLiveRegion"]').filter({ hasText: text })).toBeAttached();
/** A coarse pointer's rail never peeks, a drawer instead: on the tablet it is pinned, for these. */
async function pinOnTouch(page: Page, project: string) {
if (project !== "ipad") return;
await page.route("**/api/me", async (route) => {
const res = await route.fetch();
const me = (await res.json()) as { preferences?: Record<string, unknown> };
await route.fulfill({
response: res,
json: { ...me, preferences: { ...me.preferences, playlistsCollapsed: false } },
});
});
}
test.describe("acting on the selected rows", { tag: "@playlists" }, () => {
test.afterEach(async ({ page }) => {
for (const id of made.splice(0)) await page.request.delete(`/api/playlists/${String(id)}`);
});
const box = (page: Page, title: string) =>
page.getByRole("checkbox", { name: `Select “${title}”` });
test("a Shift range, the band's Remove, and its Undo back to the places", async ({
page,
}, info) => {
const { videos, from } = await setUp(page, `band ${info.project.name}`, info.project.name);
await box(page, videos[0]!.title).click();
await box(page, videos[1]!.title).click({ modifiers: ["Shift"] });
await expect(box(page, videos[1]!.title)).toBeChecked();
await expect(box(page, videos[2]!.title)).not.toBeChecked();
await page.getByRole("button", { name: "Remove", exact: true }).click();
await expect(page.locator("tbody tr")).toHaveCount(1);
await expect.poll(() => contents(page, from)).toEqual([videos[2]!.id]);
await page.getByRole("button", { name: "Undo", exact: true }).last().click();
await expect.poll(() => contents(page, from)).toEqual(videos.map((v) => v.id));
await expect(page.locator("tbody tr")).toHaveCount(3);
});
test("x ticks the walked row, Esc clears, and Delete takes the selection and goes on", async ({
page,
}, info) => {
const { videos, from } = await setUp(page, `keys ${info.project.name}`, info.project.name);
const firstRow = page.locator("tbody tr").first();
await page.keyboard.press("j");
await expect(firstRow).toBeFocused();
await page.keyboard.press("x");
await expect(box(page, videos[0]!.title)).toBeChecked();
await page.keyboard.press("Escape");
await expect(box(page, videos[0]!.title)).not.toBeChecked();
await page.keyboard.press("x");
await page.keyboard.press("j");
await page.keyboard.press("x");
await page.keyboard.press("Delete");
await expect.poll(() => contents(page, from)).toEqual([videos[2]!.id]);
await expect(page.locator("tbody tr")).toHaveCount(1);
await expect(firstRow).toBeFocused();
});
});
test.describe("dragging onto a list", { tag: "@playlists" }, () => {
test.afterEach(async ({ page }) => {
await page.mouse.up();
for (const id of made.splice(0)) await page.request.delete(`/api/playlists/${String(id)}`);
});
test("a row leaves for the list it is dropped on, lit up on the way, and Undo brings it back", async ({
page,
}, info) => {
const { videos, from, to, target } = await setUp(
page,
`drag one ${info.project.name}`,
info.project.name,
);
const grip = page.getByRole("button", { name: `Reorder “${videos[1]!.title}”`, exact: false });
await carryToRail(page, grip, target);
await expect(target).toHaveClass(/ring-2/);
await page.mouse.up();
await expect(target).not.toHaveClass(/ring-2/);
await expect(page.locator("tbody tr")).toHaveCount(2);
// The grip that held the focus has left: the keyboard keeps its place in the list.
await expect
.poll(() => page.evaluate(() => document.activeElement?.closest("tbody") != null))
.toBe(true);
await expect.poll(() => contents(page, to)).toEqual([videos[1]!.id]);
expect(await contents(page, from)).toEqual([videos[0]!.id, videos[2]!.id]);
await page.getByRole("button", { name: "Undo", exact: true }).last().click();
await expect.poll(() => contents(page, from)).toEqual(videos.map((v) => v.id));
expect(await contents(page, to)).toEqual([]);
});
test("a selected row carries the selected ones, named on the drag image", async ({
page,
}, info) => {
const { videos, from, to, target } = await setUp(
page,
`drag many ${info.project.name}`,
info.project.name,
);
await page.getByRole("checkbox", { name: videos[0]!.title, exact: false }).click();
await page.getByRole("checkbox", { name: videos[2]!.title, exact: false }).click();
const grip = page.getByRole("button", { name: `Reorder “${videos[2]!.title}”`, exact: false });
await carryToRail(page, grip, target);
const image = page.getByText("2 videos", { exact: true });
await expect(image).toBeVisible();
// Above every layer, by the tooltip's token: the overlay wrapping it takes the level.
expect(await image.evaluate((el) => getComputedStyle(el.parentElement!).zIndex)).toBe("100");
// Below the pointer, so it leaves the list's name to read.
const { y } = await centre(target);
expect((await image.boundingBox())!.y).toBeGreaterThan(y);
await page.mouse.up();
await expect.poll(() => contents(page, to)).toEqual([videos[0]!.id, videos[2]!.id]);
expect(await contents(page, from)).toEqual([videos[1]!.id]);
await expect(page.getByRole("checkbox", { name: "Select all" })).not.toBeChecked();
});
test("many rows dropped inside the list stay as they were", async ({ page }, info) => {
const { videos, from } = await setUp(
page,
`drag inside ${info.project.name}`,
info.project.name,
);
await page.getByRole("checkbox", { name: videos[0]!.title, exact: false }).click();
await page.getByRole("checkbox", { name: videos[1]!.title, exact: false }).click();
const grip = page.getByRole("button", { name: `Reorder “${videos[0]!.title}”`, exact: false });
await carry(page, grip, await centre(page.locator("tbody tr").nth(2)));
await page.mouse.up();
await expect(page.locator("tbody tr")).toHaveCount(3);
expect(await contents(page, from)).toEqual(videos.map((v) => v.id));
});
test("one row dropped inside the list takes the new place", async ({ page }, info) => {
const { videos, from } = await setUp(
page,
`drag order ${info.project.name}`,
info.project.name,
);
const grip = page.getByRole("button", { name: `Reorder “${videos[0]!.title}”`, exact: false });
await carry(page, grip, await centre(page.locator("tbody tr").nth(2)));
await said(page, /position 3/);
await page.mouse.up();
await expect
.poll(() => contents(page, from))
.toEqual([videos[1]!.id, videos[2]!.id, videos[0]!.id]);
});
test("Escape puts the drag down, with nothing moved and nothing lit", async ({ page }, info) => {
const { videos, from, to, target } = await setUp(
page,
`drag cancel ${info.project.name}`,
info.project.name,
);
// A tick on another row: the Esc is the drag's, and leaves the selection alone.
const ticked = page.getByRole("checkbox", { name: `Select “${videos[2]!.title}”` });
await ticked.click();
const grip = page.getByRole("button", { name: `Reorder “${videos[0]!.title}”`, exact: false });
await carryToRail(page, grip, target);
await expect(target).toHaveClass(/ring-2/);
await page.keyboard.press("Escape");
await expect(target).not.toHaveClass(/ring-2/);
await page.mouse.up();
expect(await contents(page, from)).toEqual(videos.map((v) => v.id));
expect(await contents(page, to)).toEqual([]);
await expect(ticked).toBeChecked();
});
test("a key still reorders a row, alone", async ({ page }, info) => {
const { videos, from } = await setUp(page, `drag key ${info.project.name}`, info.project.name);
await page.getByRole("checkbox", { name: videos[0]!.title, exact: false }).click();
await page.getByRole("checkbox", { name: videos[2]!.title, exact: false }).click();
await page.getByRole("button", { name: `Reorder “${videos[0]!.title}”`, exact: false }).focus();
await page.keyboard.press("Space");
await said(page, /picked up/i);
// A person's pause: dnd-kit drops an arrow pressed before it has measured the lifted row, which
// nothing on the page announces (11 of 31 runs without it, none of 31 with it).
await page.waitForTimeout(300);
await page.keyboard.press("ArrowDown");
await said(page, /position 2/);
await page.keyboard.press("Space");
await expect
.poll(() => contents(page, from))
.toEqual([videos[1]!.id, videos[0]!.id, videos[2]!.id]);
});
test("under a filter a row still goes to another list, and none is reordered", async ({
page,
}, info) => {
const { videos, from, to, target } = await setUp(
page,
`drag filter ${info.project.name}`,
info.project.name,
);
await page.getByRole("columnheader", { name: "Title" }).getByRole("button").last().click();
await page.getByPlaceholder("Title").fill(videos[1]!.title);
await page.keyboard.press("Escape");
await expect(page.locator("tbody tr")).toHaveCount(1);
const grip = page.getByRole("button", {
name: `Drag “${videos[1]!.title}” onto a list`,
exact: false,
});
await carryToRail(page, grip, target);
await page.mouse.up();
await expect.poll(() => contents(page, to)).toEqual([videos[1]!.id]);
expect(await contents(page, from)).toEqual([videos[0]!.id, videos[2]!.id]);
});
test("the rail scrolls while the drag holds at its edge", async ({ page }, info) => {
const name = `drag scroll ${info.project.name}`;
for (let i = 0; i < 12; i++) await newList(page, `${name} ${String(i)}`);
await page.setViewportSize({ width: 1280, height: 520 });
const { videos } = await setUp(page, name, info.project.name);
const grip = page.getByRole("button", { name: `Reorder “${videos[0]!.title}”`, exact: false });
const area = page.locator("[data-playlist-drop-area]");
await carryToRail(page, grip, area.locator("[data-playlist-drop]").first());
const scroller = () =>
area.evaluate((el) => {
for (let p = el.parentElement; p; p = p.parentElement) {
const { overflowY } = getComputedStyle(p);
if ((overflowY === "auto" || overflowY === "scroll") && p.scrollHeight > p.clientHeight) {
const { left, right, bottom } = p.getBoundingClientRect();
return { top: p.scrollTop, x: (left + right) / 2, bottom };
}
}
return { top: -1, x: 0, bottom: 0 };
});
const before = await scroller();
expect(before.top).toBeGreaterThanOrEqual(0);
await page.mouse.move(before.x, before.bottom - 8, { steps: 4 });
await expect.poll(async () => (await scroller()).top).toBeGreaterThan(before.top);
await page.keyboard.press("Escape");
});
});
+60
View File
@@ -0,0 +1,60 @@
import { test, expect, type Page } from "@playwright/test";
import { openApp } from "./helpers";
// 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 page.goto("/watch/no-such-token");
await expect(page.locator("#root")).not.toBeEmpty();
expect(lost.length).toBeGreaterThan(0);
await expectStyled(page);
});
+18 -6
View File
@@ -1,5 +1,5 @@
import clsx from "clsx";
import type { InputHTMLAttributes, ReactNode, Ref } from "react";
import type { InputHTMLAttributes, ReactNode, RefObject } from "react";
// A6 (docs/DESIGN.md): checkbox, slider and colour well are the BROWSER's controls, tinted
// with the chosen colour (the accent; the media amber on the player chrome) — keyboard, screen
@@ -10,18 +10,30 @@ import type { InputHTMLAttributes, ReactNode, Ref } from "react";
type Native = Omit<InputHTMLAttributes<HTMLInputElement>, "type" | "onChange">;
/** The bare checkbox: inside your own `<label>`, or with an `aria-label`. Prefer `CheckRow`. */
/** The bare checkbox: inside your own `<label>`, or with an `aria-label`. Prefer `CheckRow`.
* `onChange` also says whether Shift was held, for a range. `mixed` is the third state, some of what
* it covers: the DOM has no attribute for it, only the property. */
export function Checkbox({
onChange,
className,
ref,
mixed = false,
...rest
}: Native & { onChange?: (checked: boolean) => void; ref?: Ref<HTMLInputElement> }) {
}: Native & {
onChange?: (checked: boolean, shift: boolean) => void;
ref?: RefObject<HTMLInputElement | null>;
mixed?: boolean;
}) {
return (
<input
ref={ref}
ref={(el) => {
if (ref) ref.current = el;
if (el) el.indeterminate = mixed;
}}
type="checkbox"
onChange={(e) => onChange?.(e.target.checked)}
onChange={(e) =>
onChange?.(e.target.checked, e.nativeEvent instanceof MouseEvent && e.nativeEvent.shiftKey)
}
className={clsx(
"size-4 shrink-0 cursor-pointer accent-chosen disabled:cursor-not-allowed",
className,
@@ -54,7 +66,7 @@ export function CheckRow({
className,
)}
>
<Checkbox checked={checked} onChange={onChange} disabled={disabled} />
<Checkbox checked={checked} onChange={(on) => onChange(on)} disabled={disabled} />
{children}
</label>
);
+5 -2
View File
@@ -1,5 +1,5 @@
import { useRef, type ReactNode } from "react";
import { useListNav } from "../../lib/listNav";
import { useListNav, type NavSelection } from "../../lib/listNav";
// A plain list of cards or rows the keyboard can walk — `j`/`k`, the arrows, Enter (lib/listNav) — for
// the lists that mount every item (the Plex title, season and episode grids, the download lists, a
@@ -11,16 +11,19 @@ export default function NavGrid({
as: Tag = "div",
className,
onDelete,
selection,
children,
}: {
as?: "div" | "tbody";
className?: string;
/** The Delete key takes the focused item out (lib/listNav): its index among the items. */
onDelete?: (index: number) => void;
/** `x` selects the focused item, Esc clears (lib/listNav). */
selection?: NavSelection;
children: ReactNode;
}) {
const ref = useRef<HTMLDivElement & HTMLTableSectionElement>(null);
useListNav(ref, { enabled: true, onDelete });
useListNav(ref, { enabled: true, onDelete, selection });
return (
<Tag ref={ref} className={className}>
{children}
+7 -7
View File
@@ -35,13 +35,13 @@ Counter-examples, deliberately NOT here: `PosterTile` (knows about Plex montage
## What lives here
| area | components |
| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| overlays & anchoring | `AnchoredPopover` + `anchoredPopoverHooks` (the popover engine: arrows, typeahead, Escape, anchor-flip), `ActionMenu`, `MembershipMenu` (the body of a "which sets is it in" menu: checkbox rows + a field naming a new set), `ContextMenu` + `useContextMenu` (the SAME menu, opened by right-click / long-press / Shift+F10 / a row's `MoreButton` "…" via `buttonProps`; `anchor` for a panel a row opens; `PageMenu` for a page's background menu), `Modal` (+ `ModalFooter`; `initialFocus` by purpose, C4), `Overlay`, `SidePanel`, `Tooltip`, `Toaster` (+ `toastLevels`, `overlayOrder`; `lib/toastDock` docks one control under the stack, in every host), `CommandPalette`, `KeyboardHelp`, `ConfirmProvider` + `confirmContext` (`useConfirm` / `useChoice`: the app's promise-based confirm, mounted once at the root; a decision with more than two outcomes is one `useChoice`, never two chained confirms), `dialogFocus` (the focus half of the dialog contract: `Modal`'s, and the wizard's own shell), `menuGroups` (one menu from its groups, a rule only between non-empty ones), `pageMenuSlot` + `pageMenuItems` (a page's background menu, mirroring its own toolbar) |
| actions & inputs | `Button`, `IconButton`, `Pressable` (`selectable` when its text is a title or a name), `Chip` + `chipStyles`, `DragHandle` + `OrderButtons` / `useOrderItems` (the non-drag reorder, WCAG 2.5.7) (one per button family, docs/DESIGN.md; a raw `<button>` or a bare `FocusSafeButton` outside the control primitives — eslint `BUTTON_PRIMITIVES` — is a lint error), `Input` + `fieldStyles`, `RenameField` (a name typed in place of the name: Enter or blur saves, Escape cancels; the caller hands the keyboard back), `DropZone` (the dashed place a file upload starts from: a drop, or a click that opens the file dialog), `Meter` (how full something is against a limit — one danger line for every place showing the same measure), `TimecodeField` + `useTimecodeField` / `TimecodeError` (a cut's time typed in: the draft, held by the caller, until Enter or blur takes it, then the value as it landed; unreadable stays once offered, marked invalid, and the caller places the line saying how to type one in its flow, outside a label; a draft goes when the value moves under it), `NativeControls`, `MenuCheck` (the drawn check of a menu row or a whole-row checkbox button; its tick is the `chosen` colour, which `media-marks` turns amber on the player chrome), `mediaChrome` (`MEDIA_CHOSEN`, a chosen tab or toggle on the chrome), `SelectTrigger` (the field family; a raw `<input>` is a lint error), `useReturnFocus`, `unavailable` (an off control's props: with a reason it stays focusable, `aria-disabled` and the reason in its tooltip; without one, `disabled`), `surfaces` (the B5 glass roles; a bare glass class is a lint error), `Badge` (a static label), `LinkButton` / `IconLink` (a link wearing the button face) over `buttonStyles`, `Skeleton` (B8 first loads, through `LoadingState shape`), `NavRail` (B7; `NavRailGroup` folds a set of rare pages into one row with a panel), `form`, `DraftSaveBar`, `Combobox`, `FilterSelect`, `SegmentedToggle`, `SortControl`, `ColumnSortControl`, `PriorityStepper`, `UndoToolbar`, `ViewSwitcher` (a list's display modes behind one toolbar icon), `copyItems` (`useCopyItems`: the "Copy title" / "Copy link" rows every object's menu ends with) + `useCopyFeedback` (a Copy button's "Copied" moment), `MatchToggle` (a multi-value filter's any/all switch), `LanguageSwitcher` (presentational: the caller decides what a change does), `sortableA11y` (what a screen reader hears during a keyboard reorder) |
| data display | `DataTable` (+ `progressiveRows`: a long page mounts in steps; `rowOpen`: a press on the row runs its default action), `tableHeader` + `tableAlign`, `HiddenFilterCue` (names a filter whose column is off screen), `Pager` + `pagerContent`, `VirtualGrid` + `gridColumns` (the column/width maths, incl. the ultrawide `maxCols` cap), `readAhead`, `Carousel`, `MediaCard` + `mediaCardStyles` (the one media card, docs/DESIGN.md "Media card") + `MediaPreview` / `useMediaPreview` (its mouse-rest preview), `NavGrid` (a plain grid, list or table body the keyboard walks — `DataTable`, `VirtualGrid` and `Carousel` take `keyboardNav`), `RemoteImage` (every network picture: no Referer, retries past the negative cache, a fallback instead of the broken-image mark — a bare `<img>` is linted out), `Avatar`, `CountBadge`, `ItemCount`, `FilterChipGroup`, `RelativeTime` ("3d ago" with the exact minute on hover, docs/DESIGN.md "Copy"), `ClampedText` (a long text clamped to a few lines; More / Less only when it is cut), `StatTiles` + `StatTile` / `StatBars` (number tiles, a breakdown as horizontal bars), `RangeTrack` + `trackPosition` + `RulerTicks` (a time track over a window of a media item: regions, slider ends the arrows step, a playhead, a background slot — the editor's cuts, the players' clip panel), `CropFrame` (a crop frame over a picture, on the picture and not its bars: drag, edges, corners, the arrows; the editor's crop, the players' clip crop), `reveal` (a card's hover chrome: hover or keyboard focus on a mouse, never on touch) |
| page & panel structure | `PageShell` + `pageToolbarSlot` + `pageHeaderSlot` (`PageHeader`: a page fills the floating header) + `pageHeading`, `PageScroller`, `Headings` (`PageHeading`, `SectionLabel`), `SkipLink` + `landmarks`, `FilterFold` (B4), `scrollFold` (`useScrollFold`: a fixed header that folds once its content scrolls), `PanelGroup`, `PanelGroups`, `Tabs` + `tabPanel`, `BackToTop` + `backToTopState` (PageScroller keeps the page's end clear of it), `FilterRail` (the filter-rail shell a rail fills with its groups: title bar, customize, reset), `stickyTop` (a sticky header at the scroller's top turns its top fade off) |
| state & messaging | `QueryState`, `Banner`, `InlineNotice`, `CrashBoundary` |
| area | components |
| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| overlays & anchoring | `AnchoredPopover` + `anchoredPopoverHooks` (the popover engine: arrows, typeahead, Escape, anchor-flip), `ActionMenu`, `MembershipMenu` (the body of a "which sets is it in" menu: checkbox rows + a field naming a new set), `ContextMenu` + `useContextMenu` (the SAME menu, opened by right-click / long-press / Shift+F10 / a row's `MoreButton` "…" via `buttonProps`; `anchor` for a panel a row opens; `PageMenu` for a page's background menu), `Modal` (+ `ModalFooter`; `initialFocus` by purpose, C4), `Overlay`, `SidePanel`, `Tooltip`, `Toaster` (+ `toastLevels`, `overlayOrder`; `lib/toastDock` docks one control under the stack, in every host), `CommandPalette`, `KeyboardHelp`, `ConfirmProvider` + `confirmContext` (`useConfirm` / `useChoice`: the app's promise-based confirm, mounted once at the root; a decision with more than two outcomes is one `useChoice`, never two chained confirms), `dialogFocus` (the focus half of the dialog contract: `Modal`'s, and the wizard's own shell), `menuGroups` (one menu from its groups, a rule only between non-empty ones), `pageMenuSlot` + `pageMenuItems` (a page's background menu, mirroring its own toolbar) |
| actions & inputs | `Button`, `IconButton`, `Pressable` (`selectable` when its text is a title or a name), `Chip` + `chipStyles`, `RowSelectBox` (a table row's selecting box, Shift says a range), `DragHandle` + `OrderButtons` / `useOrderItems` (the non-drag reorder, WCAG 2.5.7) (one per button family, docs/DESIGN.md; a raw `<button>` or a bare `FocusSafeButton` outside the control primitives — eslint `BUTTON_PRIMITIVES` — is a lint error), `Input` + `fieldStyles`, `RenameField` (a name typed in place of the name: Enter or blur saves, Escape cancels; the caller hands the keyboard back), `DropZone` (the dashed place a file upload starts from: a drop, or a click that opens the file dialog), `Meter` (how full something is against a limit — one danger line for every place showing the same measure), `TimecodeField` + `useTimecodeField` / `TimecodeError` (a cut's time typed in: the draft, held by the caller, until Enter or blur takes it, then the value as it landed; unreadable stays once offered, marked invalid, and the caller places the line saying how to type one in its flow, outside a label; a draft goes when the value moves under it), `NativeControls`, `MenuCheck` (the drawn check of a menu row or a whole-row checkbox button; its tick is the `chosen` colour, which `media-marks` turns amber on the player chrome), `mediaChrome` (`MEDIA_CHOSEN`, a chosen tab or toggle on the chrome), `SelectTrigger` (the field family; a raw `<input>` is a lint error), `useReturnFocus`, `unavailable` (an off control's props: with a reason it stays focusable, `aria-disabled` and the reason in its tooltip; without one, `disabled`), `surfaces` (the B5 glass roles; a bare glass class is a lint error), `Badge` (a static label), `LinkButton` / `IconLink` (a link wearing the button face) over `buttonStyles`, `Skeleton` (B8 first loads, through `LoadingState shape`), `NavRail` (B7; `NavRailGroup` folds a set of rare pages into one row with a panel), `form`, `DraftSaveBar`, `Combobox`, `FilterSelect`, `SegmentedToggle`, `SortControl`, `ColumnSortControl`, `PriorityStepper`, `UndoToolbar`, `ViewSwitcher` (a list's display modes behind one toolbar icon), `copyItems` (`useCopyItems`: the "Copy title" / "Copy link" rows every object's menu ends with) + `useCopyFeedback` (a Copy button's "Copied" moment), `MatchToggle` (a multi-value filter's any/all switch), `LanguageSwitcher` (presentational: the caller decides what a change does), `sortableA11y` (what a screen reader hears during a keyboard reorder) |
| data display | `DataTable` (+ `progressiveRows`: a long page mounts in steps; `rowOpen`: a press on the row runs its default action), `tableHeader` + `tableAlign`, `HiddenFilterCue` (names a filter whose column is off screen), `Pager` + `pagerContent`, `VirtualGrid` + `gridColumns` (the column/width maths, incl. the ultrawide `maxCols` cap), `readAhead`, `Carousel`, `MediaCard` + `mediaCardStyles` (the one media card, docs/DESIGN.md "Media card") + `MediaPreview` / `useMediaPreview` (its mouse-rest preview), `NavGrid` (a plain grid, list or table body the keyboard walks — `DataTable`, `VirtualGrid` and `Carousel` take `keyboardNav`), `RemoteImage` (every network picture: no Referer, retries past the negative cache, a fallback instead of the broken-image mark — a bare `<img>` is linted out), `Avatar`, `CountBadge`, `ItemCount`, `FilterChipGroup`, `RelativeTime` ("3d ago" with the exact minute on hover, docs/DESIGN.md "Copy"), `ClampedText` (a long text clamped to a few lines; More / Less only when it is cut), `StatTiles` + `StatTile` / `StatBars` (number tiles, a breakdown as horizontal bars), `RangeTrack` + `trackPosition` + `RulerTicks` (a time track over a window of a media item: regions, slider ends the arrows step, a playhead, a background slot — the editor's cuts, the players' clip panel), `CropFrame` (a crop frame over a picture, on the picture and not its bars: drag, edges, corners, the arrows; the editor's crop, the players' clip crop), `reveal` (a card's hover chrome: hover or keyboard focus on a mouse, never on touch) |
| page & panel structure | `PageShell` + `pageToolbarSlot` + `pageHeaderSlot` (`PageHeader`: a page fills the floating header) + `pageHeading`, `PageScroller`, `Headings` (`PageHeading`, `SectionLabel`), `SkipLink` + `landmarks`, `FilterFold` (B4), `scrollFold` (`useScrollFold`: a fixed header that folds once its content scrolls), `PanelGroup`, `PanelGroups`, `Tabs` + `tabPanel`, `BackToTop` + `backToTopState` (PageScroller keeps the page's end clear of it), `FilterRail` (the filter-rail shell a rail fills with its groups: title bar, customize, reset), `stickyTop` (a sticky header at the scroller's top turns its top fade off) |
| state & messaging | `QueryState`, `Banner`, `InlineNotice`, `CrashBoundary` |
## The size contract (E10 S1c)
@@ -0,0 +1,40 @@
import type { RefObject } from "react";
import { Checkbox } from "./NativeControls";
/** A table row's selecting box, and the same box over the column for all the rows. The whole cell is
* the target, 44 on touch: the label starts at the cell's edge, past its padding, so it ends where
* the next cell's control starts instead of lying under it. `onToggle` says whether Shift was held. */
export function RowSelectBox({
label,
checked,
mixed,
title,
onToggle,
ref,
}: {
ref?: RefObject<HTMLInputElement | null>;
label: string;
checked: boolean;
mixed?: boolean;
title?: string;
onToggle: (shift: boolean) => void;
}) {
return (
<label
title={title}
// Shift+press would otherwise extend the page's text selection over the rows between.
onMouseDown={(e) => {
if (e.shiftKey) e.preventDefault();
}}
className="-ml-2 grid min-h-6 w-9 cursor-pointer place-items-center touch-target"
>
<Checkbox
ref={ref}
aria-label={label}
checked={checked}
mixed={mixed}
onChange={(_, shift) => onToggle(shift)}
/>
</label>
);
}
+3 -1
View File
@@ -232,7 +232,9 @@ export function ColumnHead<T>({
two of them per playlist table, whose grip and actions headers are empty. */}
{/* min-w-6 as well as min-h-6: a short header ("Ki", Hungarian "Who") was a 12px
target beside its funnel, under the 24px floor. */}
{col.sortable ? (
{col.headerCell ? (
col.headerCell
) : col.sortable ? (
<Pressable
onClick={() => onToggleSort(col.key)}
className="inline-flex min-h-6 min-w-6 items-center gap-1 hover:text-fg cursor-pointer touch-target"
@@ -1,4 +1,40 @@
[
{
"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",
"date": "2026-11-03",
"new": [
"In a playlist, tick rows with their box, Shift+click or x, then move or remove them together. An Undo puts them back at their places, and a single row's move has one too.",
"Drag rows onto another list in the rail: one row, or all the selected ones, also under a sort or a filter."
],
"fixed": [
"Removing a video from inside the player no longer loses your place in the list when the player closes."
]
},
{
"version": "0.115.0",
"date": "2026-11-01",
@@ -1,4 +1,40 @@
[
{
"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",
"date": "2026-11-03",
"new": [
"A listákban a sorok kijelölhetők a jelölőnégyzetükkel, Shift+kattintással vagy az x billentyűvel, és együtt áthelyezhetők vagy eltávolíthatók. A Visszavonás a helyükre teszi őket, és egyetlen sor áthelyezésénél is ott van.",
"A sorok ráhúzhatók a sáv egy másik listájára: egy sor vagy az összes kijelölt, rendezés és szűrés mellett is."
],
"fixed": [
"Ha a lejátszóból távolítasz el egy videót, a lejátszó bezárása után megmarad a helyed a listában."
]
},
{
"version": "0.115.0",
"date": "2026-11-01",
@@ -21,7 +21,7 @@ const ALLOWED = new Set([
// the built-in Watch later list's items ARE the mark. videoWrites makes those writes and the mark
// together; a direct api call skips the mark.
const WRITES =
/\bapi\s*\.\s*(setState|clearProgress|watchLaterAdd|watchLaterRemove|addToPlaylist|removeFromPlaylist|moveToPlaylist)\b/;
/\bapi\s*\.\s*(setState|clearProgress|watchLaterAdd|watchLaterRemove|addToPlaylist|removeFromPlaylist|removeManyFromPlaylist|restoreToPlaylist|moveManyToPlaylist|undoPlaylistMove)\b/;
describe("a video's state, Watch-later and playlist-item writes", () => {
it("go through features/video/videoWrites, never the api directly", () => {
@@ -38,7 +38,8 @@ describe("a video's state, Watch-later and playlist-item writes", () => {
it("catches a write named without being called on the spot, and a playlist item's", () => {
expect(WRITES.test("(saved ? api.watchLaterAdd : api.watchLaterRemove)(id)")).toBe(true);
expect(WRITES.test("await api\n .removeFromPlaylist(listId, id)")).toBe(true);
expect(WRITES.test("api.moveToPlaylist(a, id, b)")).toBe(true);
expect(WRITES.test("api.moveManyToPlaylist(a, ids, b)")).toBe(true);
expect(WRITES.test("api.undoPlaylistMove(a, b, m, t, p)")).toBe(true);
expect(WRITES.test("this.setState(next); api.playlists()")).toBe(false);
});
});
@@ -9,7 +9,10 @@ import { api } from "../../lib/api";
import { qk } from "../../lib/queryKeys";
import {
addPlaylistItem,
movePlaylistItem,
movePlaylistItems,
removePlaylistItems,
restorePlaylistItems,
undoPlaylistMove,
removePlaylistItem,
clearVideoProgress,
writeVideoState,
@@ -77,12 +80,47 @@ describe("videoWrites", () => {
expect(marks(qc)).toBeUndefined();
});
it("marks a move into and out of the Watch later list", async () => {
vi.spyOn(api, "moveToPlaylist").mockResolvedValue({ ok: true } as never);
it("marks a move into and out of the Watch later list, by what the server moved", async () => {
vi.spyOn(api, "moveManyToPlaylist").mockResolvedValue({
ok: true,
moved: ["X"],
already_there: ["Y"],
} as never);
const qc = new QueryClient();
await movePlaylistItem(qc, MINE, "X", WATCH_LATER);
await movePlaylistItems(qc, MINE, ["X", "Y", "gone"], WATCH_LATER);
expect(marks(qc)).toEqual({ X: { saved: true }, Y: { saved: true } });
await movePlaylistItems(qc, WATCH_LATER, ["X", "Y", "gone"], MINE);
expect(marks(qc)).toEqual({ X: { saved: false }, Y: { saved: false } });
});
it("marks a bulk removal from Watch later, and its restore", async () => {
vi.spyOn(api, "removeManyFromPlaylist").mockResolvedValue({
ok: true,
removed: ["X"],
} as never);
vi.spyOn(api, "restoreToPlaylist").mockResolvedValue({ ok: true } as never);
const qc = new QueryClient();
await removePlaylistItems(qc, WATCH_LATER, ["X", "gone"]);
expect(marks(qc)).toEqual({ X: { saved: false } });
await restorePlaylistItems(qc, WATCH_LATER, ["X"], [0]);
expect(marks(qc)).toEqual({ X: { saved: true } });
await movePlaylistItem(qc, WATCH_LATER, "X", MINE);
});
it("takes a move into Watch later back: only what landed there loses the mark", async () => {
vi.spyOn(api, "undoPlaylistMove").mockResolvedValue({ ok: true } as never);
const qc = new QueryClient();
await undoPlaylistMove(qc, MINE, WATCH_LATER, {
moved: ["X"],
already_there: ["Y"],
positions: [0, 1],
});
expect(api.undoPlaylistMove).toHaveBeenCalledWith(
MINE.id,
WATCH_LATER.id,
["X"],
["Y"],
[0, 1],
);
expect(marks(qc)).toEqual({ X: { saved: false } });
});
+48 -9
View File
@@ -43,10 +43,8 @@ const markIfWatchLater = (qc: QueryClient, list: ListRef, id: string, saved: boo
if (list.kind === "watch_later") markVideo(qc, id, { saved });
};
/** Add a video to a playlist; `position` (an index in the stored order) puts it back where a
* removal took it from. */
export function addPlaylistItem(qc: QueryClient, list: ListRef, id: string, position?: number) {
return api.addToPlaylist(list.id, id, position).then((r) => {
export function addPlaylistItem(qc: QueryClient, list: ListRef, id: string) {
return api.addToPlaylist(list.id, id).then((r) => {
markIfWatchLater(qc, list, id, true);
return r;
});
@@ -59,11 +57,52 @@ export function removePlaylistItem(qc: QueryClient, list: ListRef, id: string) {
});
}
/** Move a video from one playlist to another, in one request (see `api.moveToPlaylist`). */
export function movePlaylistItem(qc: QueryClient, from: ListRef, id: string, to: ListRef) {
return api.moveToPlaylist(from.id, id, to.id).then((r) => {
markIfWatchLater(qc, from, id, false);
markIfWatchLater(qc, to, id, true);
/** Take videos out of a playlist, in one request. Marks what the server says went. */
export function removePlaylistItems(qc: QueryClient, list: ListRef, ids: string[]) {
return api.removeManyFromPlaylist(list.id, ids).then((r) => {
for (const id of r.removed) markIfWatchLater(qc, list, id, false);
return r;
});
}
/** Put removed videos back at their places, in one request. */
export function restorePlaylistItems(
qc: QueryClient,
list: ListRef,
ids: string[],
positions: number[],
) {
return api.restoreToPlaylist(list.id, ids, positions).then((r) => {
for (const id of ids) markIfWatchLater(qc, list, id, true);
return r;
});
}
/** Move videos from one playlist to another, in one request (see `api.moveManyToPlaylist`). Marks
* what the server says it moved: a video a concurrent edit already took out is in neither answer. */
export function movePlaylistItems(qc: QueryClient, from: ListRef, ids: string[], to: ListRef) {
return api.moveManyToPlaylist(from.id, ids, to.id).then((r) => {
for (const id of [...r.moved, ...r.already_there]) {
markIfWatchLater(qc, from, id, false);
markIfWatchLater(qc, to, id, true);
}
return r;
});
}
/** Take a move back: every video returns to `from`, and only the ones that landed in `to` leave it;
* it held the others before the move. */
export function undoPlaylistMove(
qc: QueryClient,
from: ListRef,
to: ListRef,
back: { moved: string[]; already_there: string[]; positions: number[] },
) {
return api
.undoPlaylistMove(from.id, to.id, back.moved, back.already_there, back.positions)
.then((r) => {
for (const id of back.moved) markIfWatchLater(qc, to, id, false);
for (const id of [...back.moved, ...back.already_there]) markIfWatchLater(qc, from, id, true);
return r;
});
}
+4 -2
View File
@@ -34,8 +34,10 @@
"search": "Jump to the search box",
"listStep": "Next / previous item",
"gridMove": "Move around the list",
"open": "Open the selected item",
"listRemove": "Remove the selected item from the list",
"open": "Open the current item",
"listRemove": "Remove from the list",
"listSelect": "Select or deselect the current item",
"listClearSelection": "Clear the selection",
"cap": {
"space": "Space",
"menu": "Menu"
+39 -1
View File
@@ -94,7 +94,6 @@
"sync_all_busy": "Your playlists are already syncing with YouTube — try again when it finishes"
},
"moveErrors": {
"not_in_playlist": "That video isn't in this playlist any more — it changed somewhere else",
"same_playlist": "That video is already in this playlist"
},
"railMenu": "Playlist actions",
@@ -102,6 +101,45 @@
"removeItem": "Remove",
"removeItemOf": "Remove “{{title}}” from the playlist",
"dragHandleOf": "Reorder “{{title}}”",
"selectRow": "Select “{{title}}”",
"selectAllShown": "Select all the rows shown",
"selectedCount_one": "{{count}} selected",
"selectedCount_other": "{{count}} selected",
"selectedHidden_one": "{{count}} more hidden by the filter",
"selectedHidden_other": "{{count}} more hidden by the filter",
"selectedHiddenShort": "+{{n}}",
"clearSelection": "Clear selection",
"moveSelected": "Move to",
"removeSelected": "Remove",
"movedManyToast_one": "{{count}} video moved to “{{name}}”",
"movedManyToast_other": "{{count}} videos moved to “{{name}}”",
"removedManyToast_one": "{{count}} video removed from the playlist",
"removedManyToast_other": "{{count}} videos removed from the playlist",
"moveNoneLeft_one": "Nothing moved: it isn’t in this playlist any more",
"moveNoneLeft_other": "Nothing moved: they aren’t in this playlist any more",
"removeNoneLeft_one": "Nothing removed: it isn’t in this playlist any more",
"removeNoneLeft_other": "Nothing removed: they aren’t in this playlist any more",
"selectionCleared": "Selection cleared",
"removeAllHidden_one": "The selected row is hidden by the filter",
"removeAllHidden_other": "The selected rows are hidden by the filter",
"undoNoneBack_one": "Nothing came back: that video isn’t where it was any more",
"undoNoneBack_other": "Nothing came back: those videos aren’t where they were any more",
"undoSomeBack_one": "Only {{count}} video came back: the rest aren’t where they were any more",
"undoSomeBack_other": "Only {{count}} videos came back: the rest aren’t where they were any more",
"dragCancelledMany_one": "Move cancelled; {{count}} video stays where it was",
"dragCancelledMany_other": "Move cancelled; {{count}} videos stay where they were",
"moveTargetsFailed": "Your playlists couldn’t be loaded",
"moveTargetsLoading": "Loading your playlists…",
"moveManyFailed": "Couldn't move the videos",
"removeManyFailed": "Couldn't remove the videos",
"undoMoveFailed": "Couldn't move the video back",
"undoMoveManyFailed": "Couldn't move the videos back",
"undoRemoveManyFailed": "Couldn't put the videos back",
"dragHandleToList": "Drag “{{title}}” onto a list",
"dragPickedMany_one": "{{count}} video picked up",
"dragPickedMany_other": "{{count}} videos picked up",
"dragOverList": "Over “{{name}}”",
"dragDroppedOnList": "Dropped on “{{name}}”",
"dragBlockedSort": "Reordering by hand is off while a column sort or grouping is applied.",
"dragBlockedFilter": "Reordering by hand is off while a filter is applied.",
"clearView": "Reset view",
+4 -2
View File
@@ -34,8 +34,10 @@
"search": "Ugrás a keresőbe",
"listStep": "Következő / előző elem",
"gridMove": "Mozgás a listában",
"open": "A kijelölt elem megnyitása",
"listRemove": "A kijelölt elem eltávolítása a listából",
"open": "Az aktuális elem megnyitása",
"listRemove": "Eltávolítás a listából",
"listSelect": "Az aktuális elem kijelölése vagy a jelölés levétele",
"listClearSelection": "A kijelölés törlése",
"cap": {
"space": "Szóköz",
"menu": "Menü"
+39 -1
View File
@@ -94,7 +94,6 @@
"sync_all_busy": "A listáid épp szinkronizálnak a YouTube-bal — próbáld újra, ha végzett"
},
"moveErrors": {
"not_in_playlist": "Ez a videó már nincs ebben a listában — időközben máshol megváltozott",
"same_playlist": "Ez a videó már ebben a listában van"
},
"railMenu": "Lejátszási lista műveletei",
@@ -102,6 +101,45 @@
"removeItem": "Eltávolítás",
"removeItemOf": "„{{title}}” eltávolítása a listáról",
"dragHandleOf": "„{{title}}” átrendezése",
"selectRow": "„{{title}}” kijelölése",
"selectAllShown": "Az összes látható sor kijelölése",
"selectedCount_one": "{{count}} kijelölve",
"selectedCount_other": "{{count}} kijelölve",
"selectedHidden_one": "további {{count}} a szűrő mögött",
"selectedHidden_other": "további {{count}} a szűrő mögött",
"selectedHiddenShort": "+{{n}}",
"clearSelection": "Kijelölés törlése",
"moveSelected": "Áthelyezés ide",
"removeSelected": "Eltávolítás",
"movedManyToast_one": "{{count}} videó áthelyezve ide: „{{name}}”",
"movedManyToast_other": "{{count}} videó áthelyezve ide: „{{name}}”",
"removedManyToast_one": "{{count}} videó eltávolítva a listáról",
"removedManyToast_other": "{{count}} videó eltávolítva a listáról",
"moveNoneLeft_one": "Nem történt áthelyezés: ez már nincs ebben a listában",
"moveNoneLeft_other": "Nem történt áthelyezés: ezek már nincsenek ebben a listában",
"removeNoneLeft_one": "Nem történt eltávolítás: ez már nincs ebben a listában",
"removeNoneLeft_other": "Nem történt eltávolítás: ezek már nincsenek ebben a listában",
"selectionCleared": "Kijelölés törölve",
"removeAllHidden_one": "A kijelölt sort a szűrő elrejti",
"removeAllHidden_other": "A kijelölt sorokat a szűrő elrejti",
"undoNoneBack_one": "Semmi nem került vissza: ez a videó már nincs ott, ahol volt",
"undoNoneBack_other": "Semmi nem került vissza: ezek a videók már nincsenek ott, ahol voltak",
"undoSomeBack_one": "Csak {{count}} videó került vissza: a többi már nincs ott, ahol volt",
"undoSomeBack_other": "Csak {{count}} videó került vissza: a többi már nincs ott, ahol volt",
"dragCancelledMany_one": "Áthelyezés megszakítva; {{count}} videó a helyén marad",
"dragCancelledMany_other": "Áthelyezés megszakítva; {{count}} videó a helyén marad",
"moveTargetsFailed": "Nem sikerült betölteni a listáidat",
"moveTargetsLoading": "A listáid betöltése…",
"moveManyFailed": "Nem sikerült áthelyezni a videókat",
"removeManyFailed": "Nem sikerült eltávolítani a videókat",
"undoMoveFailed": "Nem sikerült visszahelyezni a videót",
"undoMoveManyFailed": "Nem sikerült visszahelyezni a videókat",
"undoRemoveManyFailed": "Nem sikerült visszatenni a videókat",
"dragHandleToList": "„{{title}}” húzása egy listára",
"dragPickedMany_one": "{{count}} videó felvéve",
"dragPickedMany_other": "{{count}} videó felvéve",
"dragOverList": "A(z) „{{name}}” lista fölött",
"dragDroppedOnList": "Letéve a(z) „{{name}}” listára",
"dragBlockedSort": "A kézi átrendezés ki van kapcsolva, amíg oszloprendezés vagy csoportosítás van érvényben.",
"dragBlockedFilter": "A kézi átrendezés ki van kapcsolva, amíg szűrő van érvényben.",
"clearView": "Nézet visszaállítása",
+51 -11
View File
@@ -68,25 +68,65 @@ export const playlistsApi = {
req(`/api/playlists/${id}`, { method: "PATCH", body: JSON.stringify({ name }) }),
deletePlaylist: (id: number, onYoutube = false) =>
req(`/api/playlists/${id}${onYoutube ? "?on_youtube=true" : ""}`, { method: "DELETE" }),
// `position` (an index in the stored order) puts it back where a removal took it from — the
// list page's Undo; omitted, it is appended.
addToPlaylist: (id: number, videoId: string, position?: number) =>
addToPlaylist: (id: number, videoId: string) =>
req(`/api/playlists/${id}/items`, {
method: "POST",
body: JSON.stringify({ video_id: videoId, ...(position != null && { position }) }),
body: JSON.stringify({ video_id: videoId }),
}),
removeFromPlaylist: (id: number, videoId: string) =>
req(`/api/playlists/${id}/items/${videoId}`, { method: "DELETE" }),
// One request on purpose: add-then-remove from the client leaves the video in BOTH lists when
// the second call fails. The server does it in one transaction.
moveToPlaylist: (
// One request on purpose: add-then-remove from the client leaves a video in BOTH lists when the
// second call fails. The server does it in one transaction, and says which landed in the target
// and which it already held: the Undo needs to tell them apart.
moveManyToPlaylist: (
id: number,
videoId: string,
videoIds: string[],
targetPlaylistId: number,
): Promise<{ ok: true; from_playlist_id: number; to_playlist_id: number; video_id: string }> =>
req(`/api/playlists/${id}/items/${videoId}/move`, {
): Promise<{
ok: true;
from_playlist_id: number;
to_playlist_id: number;
moved: string[];
already_there: string[];
}> =>
req(`/api/playlists/${id}/items/move-bulk`, {
method: "POST",
body: JSON.stringify({ target_playlist_id: targetPlaylistId }),
body: JSON.stringify({ video_ids: videoIds, target_playlist_id: targetPlaylistId }),
}),
// The Undo of a move, back into `id`. `positions` pairs with `moved` + `alreadyThere`.
undoPlaylistMove: (
id: number,
fromPlaylistId: number,
moved: string[],
alreadyThere: string[],
positions: number[],
): Promise<{ ok: true; playlist_id: number; added: number }> =>
req(`/api/playlists/${id}/items/undo-move`, {
method: "POST",
body: JSON.stringify({
from_playlist_id: fromPlaylistId,
moved,
already_there: alreadyThere,
positions,
}),
}),
removeManyFromPlaylist: (
id: number,
videoIds: string[],
): Promise<{ ok: true; playlist_id: number; removed: string[] }> =>
req(`/api/playlists/${id}/items/remove-bulk`, {
method: "POST",
body: JSON.stringify({ video_ids: videoIds }),
}),
// The Undo of a removal, in one request; an index in the stored order per video.
restoreToPlaylist: (
id: number,
videoIds: string[],
positions: number[],
): Promise<{ ok: true; playlist_id: number; added: number }> =>
req(`/api/playlists/${id}/items/restore`, {
method: "POST",
body: JSON.stringify({ video_ids: videoIds, positions }),
}),
reorderPlaylist: (id: number, videoIds: string[]) =>
req(`/api/playlists/${id}/order`, {
+98 -1
View File
@@ -65,6 +65,27 @@ class FakeHls implements HlsPort {
lost404() {
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). */
lost(status: number | null) {
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 () => {
const { engine, asks, hlses, state } = setup();
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)", () => {
// 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.
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])(
"is waited out on %s: no error, and the stream comes back where the viewer is",
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
// 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
// the stream keep the viewer on a spinner (see `onHlsError`).
export const MAX_SESSION_RECLAIMS = 2;
@@ -33,6 +33,8 @@ export const RECLAIM_WINDOW_MS = 120_000;
export const AWAY_WINDOW_MS = 180_000;
const AWAY_RETRY_MS = [2_000, 4_000, 8_000, 15_000];
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. */
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.
private readonly inFlight = new Map<string, Request>();
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 };
// Since when the server has been away, and how often it was asked since (see `serverAway`).
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
* 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. */
start(id: string, startAt: number, play = true, quiet = false): void {
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
// spinner up forever — it is said instead.
// A fatal HLS error (the file became unreadable) would otherwise leave the spinner up forever — it
// is said instead.
//
// Except a 404: the session is gone, not broken. Either the server reaped it (a pause longer than
// its idle limit, a deploy) — hls.js never retries a 4xx, so this used to end playback with
// "couldn't play" after the buffer ran out — or it was superseded before it ever played (two
// requests for one item: the server's LAST spawn wins). The server answers a superseded session
// exactly so the client asks again; the reclaim is that asking.
// Except a session the server no longer has (410): gone, not broken. Either the server reaped it
// or restarted, or it was superseded before it ever played (two requests for one item: the
// server's LAST spawn wins), and the reclaim is the asking again that answer wants. It does not
// wait for hls.js to give up. On a finished playlist, with one video variant and nothing to
// 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
// session, and every restart builds a fresh one, so it could never bound a loop. The server also answers
// 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.
// And except a fatal 404: hls.js gave up on something the server still does not have, and asking
// again is all that is left.
//
// 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 {
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 && e.status === 404) {
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.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.reclaim(req, media, false);
return;
}
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,
* 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
@@ -548,9 +569,10 @@ export class HlsEngine {
const rel = T - this.sessionStart.current;
// 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
// 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;
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
if (play) media.play().catch(() => {});
} else {
+23
View File
@@ -93,6 +93,24 @@ describe("isTypingTarget", () => {
it("is false on ordinary content", () => {
expect(isTypingTarget(press({ key: "a" }))).toBe(false);
});
it("is false on a box, where no letter types: the page's keys go on working after a tick", () => {
for (const type of ["checkbox", "radio"]) {
const el = document.createElement("input");
el.type = type;
document.body.append(el);
expect(isTypingTarget(press({ key: "a" }, el))).toBe(false);
el.remove();
}
});
it("stays true on a slider, whose arrows are its own", () => {
const el = document.createElement("input");
el.type = "range";
document.body.append(el);
expect(isTypingTarget(press({ key: "ArrowLeft" }, el))).toBe(true);
el.remove();
});
});
describe("isActivationTarget", () => {
@@ -101,6 +119,11 @@ describe("isActivationTarget", () => {
document.body.append(btn);
expect(isActivationTarget(press({ key: " " }, btn))).toBe(true);
btn.remove();
const box = document.createElement("input");
box.type = "checkbox";
document.body.append(box);
expect(isActivationTarget(press({ key: " " }, box))).toBe(true);
box.remove();
expect(isActivationTarget(press({ key: " " }))).toBe(false);
});
});
+20 -2
View File
@@ -33,6 +33,7 @@ export type KeyScope =
| "search"
| "list"
| "listDelete"
| "listSelect"
| "undo"
| "contextMenu"
| "ytPlayer"
@@ -46,6 +47,7 @@ const PAGE_LEVEL: ReadonlySet<KeyScope> = new Set<KeyScope>([
"search",
"list",
"listDelete",
"listSelect",
]);
/** True for a scope that acts on the page beneath the overlays (see `KeyScope`). */
export const isPageLevelScope = (scope: KeyScope): boolean => PAGE_LEVEL.has(scope);
@@ -138,6 +140,15 @@ export const SHORTCUTS = [
// Its own scope beside `list`: only a list that can take an item out (a playlist's rows) registers
// it, so the sheet does not promise Delete on the feed or a Plex grid, where nothing answers it.
{ id: "remove", scope: "listDelete", keys: ["Delete"], labelKey: "keys.listRemove" },
// …and only a list whose rows can be selected registers these. Escape stays quiet in a field: the
// field's own Escape comes first, and a selection is not what the reader is in while typing.
{ id: "select", scope: "listSelect", keys: ["x"], labelKey: "keys.listSelect" },
{
id: "clearSelection",
scope: "listSelect",
keys: ["Escape"],
labelKey: "keys.listClearSelection",
},
// --- the row context menu (wherever useContextMenu is wired) ---------------------------------
// Its OWN scope, not `global`: only the surfaces that actually call `useContextMenu` register it,
// so the sheet stops advertising "Open the context menu" on the feed, Plex, Downloads and Settings
@@ -264,15 +275,22 @@ export function isTypingTarget(e: KeyboardEvent): boolean {
const el = (e.target as HTMLElement | null) ?? (document.activeElement as HTMLElement | null);
if (!el) return false;
const tag = (el.tagName || "").toLowerCase();
// A box takes no letters, and a click on a table row's box leaves the focus on it: `j` went dead
// there. A slider stays a field, as its arrows are its own.
if (isBox(el)) return false;
return tag === "input" || tag === "textarea" || tag === "select" || !!el.isContentEditable;
}
function isBox(el: HTMLElement | null): boolean {
return el instanceof HTMLInputElement && (el.type === "checkbox" || el.type === "radio");
}
/** True while focus is on something Space/Enter activates natively. Both players defer Space to a
* focused button ("Skip intro") rather than toggling playback; this is that shared rule. */
export function isActivationTarget(e: KeyboardEvent): boolean {
const el = (e.target as HTMLElement | null) ?? (document.activeElement as HTMLElement | null);
const tag = (el?.tagName || "").toLowerCase();
return tag === "button" || tag === "a";
return tag === "button" || tag === "a" || isBox(el);
}
/** One printable character that is neither a letter nor a digit (`[`, `]`, `,`). */
@@ -462,7 +480,7 @@ export function useActiveScopes(): KeyScope[] {
// registers `page` in its own effect, which React runs after every child's — so by mount order
// the module jumps would head the sheet, above the list keys of the page you are looking at.
const surfaces = live.filter((s) => !isPageLevelScope(s));
const pageLevel = (["list", "listDelete", "search", "page"] as const).filter((s) =>
const pageLevel = (["list", "listSelect", "listDelete", "search", "page"] as const).filter((s) =>
live.includes(s),
);
return [...surfaces, ...pageLevel, "global"];
@@ -0,0 +1,205 @@
// @vitest-environment jsdom
//
// `x` selects the walked item and Esc clears, in a list that takes a selection (a playlist's rows).
// Its own file: listNav keeps module state.
import { useRef } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import { followRemoval, navItemAttrs, useListNav, type NavSelection } from "./listNav";
import { useActiveScopes } from "./keymap";
afterEach(cleanup);
function Rows({
selection,
onDelete,
rows = 3,
}: {
selection?: NavSelection;
onDelete?: (index: number) => void;
rows?: number;
}) {
const ref = useRef<HTMLDivElement>(null);
useListNav(ref, { enabled: true, selection, onDelete });
return (
<div ref={ref}>
{Array.from({ length: rows }, (_, i) => (
<div key={i} {...navItemAttrs(i)} data-testid={`r${i}`}>
<button data-testid={`r${i}-action`}>action</button>
<input data-testid={`r${i}-input`} />
<input type="checkbox" data-testid={`r${i}-box`} />
</div>
))}
</div>
);
}
function Scopes() {
return <output data-testid="scopes">{useActiveScopes().join(" ")}</output>;
}
function press(key: string, init: KeyboardEventInit = {}): KeyboardEvent {
const e = new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true, ...init });
(document.activeElement ?? document.body).dispatchEvent(e);
return e;
}
const selection = (cleared = true) => ({
toggle: vi.fn<(index: number) => void>(),
clear: vi.fn(() => cleared),
removeSelected: vi.fn(() => false),
});
describe("useListNav · selecting", () => {
it("toggles the walked item on x", () => {
const s = selection();
render(<Rows selection={s} />);
press("j");
press("j");
const e = press("x");
expect(s.toggle).toHaveBeenCalledExactlyOnceWith(1);
expect(e.defaultPrevented).toBe(true);
});
it("toggles the item a control inside it holds the focus for", () => {
const s = selection();
render(<Rows selection={s} />);
screen.getByTestId("r2-action").focus();
press("x");
expect(s.toggle).toHaveBeenCalledExactlyOnceWith(2);
});
it("goes on answering with the focus on the row's box, where a click leaves it", () => {
const s = selection();
render(<Rows selection={s} />);
screen.getByTestId("r1-box").focus();
press("x");
expect(s.toggle).toHaveBeenCalledExactlyOnceWith(1);
press("j");
expect(document.activeElement).toBe(screen.getByTestId("r2"));
});
it("does nothing on x with no item under the keyboard", () => {
const s = selection();
render(<Rows selection={s} />);
const e = press("x");
expect(s.toggle).not.toHaveBeenCalled();
expect(e.defaultPrevented).toBe(false);
});
it("types an x in a field instead of selecting", () => {
const s = selection();
render(<Rows selection={s} />);
screen.getByTestId("r0-input").focus();
press("x");
expect(s.toggle).not.toHaveBeenCalled();
});
it("does not flip back and forth while x is held down", () => {
const s = selection();
render(<Rows selection={s} />);
press("j");
press("x");
press("x", { repeat: true });
expect(s.toggle).toHaveBeenCalledTimes(1);
});
it("clears on Esc wherever the focus is, and spends the key only when it cleared", () => {
const s = selection();
render(<Rows selection={s} />);
expect(press("Escape").defaultPrevented).toBe(true);
expect(s.clear).toHaveBeenCalledTimes(1);
cleanup();
const idle = selection(false);
render(<Rows selection={idle} />);
expect(press("Escape").defaultPrevented).toBe(false);
expect(idle.clear).toHaveBeenCalledTimes(1);
});
it("takes the slot of an item that left by other hands, the focus on <body>", async () => {
render(<Rows />);
const leaving = screen.getByTestId("r1");
followRemoval(screen.getByTestId("r1-action"));
const list = leaving.parentElement!;
list.removeChild(leaving);
list.querySelector<HTMLElement>("[data-testid=r2]")!.setAttribute("data-nav-index", "1");
await waitFor(() => expect(document.activeElement).toBe(screen.getByTestId("r2")));
});
it("goes on to the next row left, past what a selection took from above it", async () => {
const s = selection();
s.removeSelected.mockImplementation(() => {
// As a commit does it, after the key's handler: the two rows go, and the ones left are numbered
// from the top.
queueMicrotask(() => {
screen.getByTestId("r0").remove();
screen.getByTestId("r1").remove();
screen.getByTestId("r2").setAttribute("data-nav-index", "0");
screen.getByTestId("r3").setAttribute("data-nav-index", "1");
});
return true;
});
render(<Rows selection={s} rows={4} />);
const r2 = screen.getByTestId("r2");
screen.getByTestId("r1-box").focus();
press("Delete");
await waitFor(() => expect(document.activeElement).toBe(r2));
});
it("leaves the focus where it is when something else took it meanwhile", async () => {
render(<Rows />);
const leaving = screen.getByTestId("r1");
const other = screen.getByTestId("r0-input");
other.focus();
followRemoval(screen.getByTestId("r1-action"));
leaving.remove();
await new Promise((r) => setTimeout(r, 60));
expect(document.activeElement).toBe(other);
});
it("has Delete go to the selection first, wherever the focus is", () => {
const s = selection();
s.removeSelected.mockReturnValue(true);
const onDelete = vi.fn();
render(<Rows selection={s} onDelete={onDelete} />);
expect(press("Delete").defaultPrevented).toBe(true);
expect(s.removeSelected).toHaveBeenCalledTimes(1);
expect(onDelete).not.toHaveBeenCalled();
});
it("has Delete go on to the walked row when there is no selection", () => {
const s = selection();
const onDelete = vi.fn();
render(<Rows selection={s} onDelete={onDelete} />);
press("j");
press("Delete");
expect(onDelete).toHaveBeenCalledExactlyOnceWith(0);
});
it("leaves both keys alone in a list that takes no selection", () => {
render(<Rows />);
press("j");
expect(press("x").defaultPrevented).toBe(false);
expect(press("Escape").defaultPrevented).toBe(false);
});
it("lists the keys only over a list that takes a selection and has items", async () => {
render(
<>
<Scopes />
<Rows selection={selection()} />
</>,
);
await waitFor(() => expect(screen.getByTestId("scopes").textContent).toContain("listSelect"));
cleanup();
render(
<>
<Scopes />
<Rows />
</>,
);
await waitFor(() => expect(screen.getByTestId("scopes").textContent).toContain("list"));
expect(screen.getByTestId("scopes").textContent).not.toContain("listSelect");
});
});
+62 -3
View File
@@ -36,6 +36,18 @@ interface NavList {
readonly reveal: (index: number) => void;
/** Takes the item at `index` out of the list (Delete), or null for a list that cannot. */
readonly remove: () => ((index: number) => void) | null;
/** The list's selection (`x`, Esc), or null for a list whose items cannot be selected. */
readonly selection: () => NavSelection | null;
}
/** What `x` and Esc do in a list whose items can be selected. `clear` says whether it cleared
* anything: an Esc that did not is left for whoever else answers it. */
export interface NavSelection {
readonly toggle: (index: number) => void;
readonly clear: () => boolean;
/** Delete: takes the selection out, wherever the focus is. False when there is none, and Delete
* goes on to the item under the keyboard. */
readonly removeSelected: () => boolean;
}
const lists = new Map<HTMLElement, NavList>();
@@ -423,6 +435,12 @@ function onKey(e: KeyboardEvent): void {
// On the item root, like Enter — and only in a list that registered a remover.
remove: (ev) => {
const cur = current();
for (const list of ordered()) {
if (!list.selection()?.removeSelected()) continue;
ev.preventDefault();
if (cur) refocusSlot(cur.list, cur.index, cur.item);
return;
}
const remove = cur?.list.remove();
if (!cur || !remove || document.activeElement !== cur.item) return;
ev.preventDefault();
@@ -432,15 +450,40 @@ function onKey(e: KeyboardEvent): void {
},
{ inTopLayer },
);
dispatch(
e,
"listSelect",
{
// Anywhere in the item, unlike Delete: a tick is undone by the same key, and after a click on
// the box the focus sits inside the row. Held down, it must not flip back and forth.
select: (ev) => {
const cur = current();
const selection = cur?.list.selection();
if (!cur || !selection || ev.repeat) return;
ev.preventDefault();
selection.toggle(cur.index);
},
clearSelection: (ev) => {
let cleared = false;
for (const list of ordered()) if (list.selection()?.clear()) cleared = true;
if (cleared) ev.preventDefault();
},
},
{ inTopLayer },
);
}
// How long a removal gets to take its row out of the DOM before the focus gives up following it.
const REMOVE_FRAMES = 30;
/** After the focused item was removed, focus the item now in its slot (the next one; the new last
* when it was the last), so a second Delete takes the next row — the way a mail list behaves. Only
* while nothing else took the focus meanwhile: the removal fell back to <body>, nothing more. */
/** After the focused item was removed, focus the item that followed it and is still there (the new
* last when none is), so a second Delete takes the next row — the way a mail list behaves. By element:
* rows a selection took from above it would shift an index past the ones left. Only while nothing else
* took the focus meanwhile: the removal fell back to <body>, nothing more. */
function refocusSlot(list: NavList, index: number, removed: HTMLElement): void {
const items = ownItems(list.el);
const at = items.indexOf(removed);
const after = at < 0 ? [] : items.slice(at + 1);
let frames = 0;
const wait = () => {
if (removed.isConnected) {
@@ -448,12 +491,24 @@ function refocusSlot(list: NavList, index: number, removed: HTMLElement): void {
return;
}
if (!focusIsUnclaimed()) return;
const next = after.find((el) => el.isConnected);
if (next) return focusItem(next);
const n = list.count();
if (n > 0) focusIndex(list, Math.min(index, n - 1));
};
requestAnimationFrame(wait);
}
/** The item holding `el` is leaving its list by other hands than Delete (a drag out of it): the focus
* takes its slot all the same, not <body>. By element, as a click on a button leaves the focus
* elsewhere in Safari. */
export function followRemoval(el: Element): void {
const item = el.closest<HTMLElement>(`[${INDEX}]`);
const list =
item && lists.get(item.parentElement?.closest<HTMLElement>(`[${LIST}]`) as HTMLElement);
if (item && list) refocusSlot(list, indexOf(item), item);
}
// ── The item a view was left from ────────────────────────────────────────────────────────────────
// Opening something that switches the page or the view — a Plex title from Home's rail, a channel from
// the Channels table — unmounts the list the keyboard was on, so Back used to hand focus to <body> and
@@ -599,6 +654,8 @@ export function useListNav(
reveal?: (index: number) => void;
/** A list whose items the Delete key takes out (a playlist's rows): the item's index. */
onDelete?: (index: number) => void;
/** A list whose items can be selected, by `x` and Esc. */
selection?: NavSelection;
},
): void {
// Read when a key is pressed, so the registration below never re-runs for a new `opts` object.
@@ -609,6 +666,7 @@ export function useListNav(
const [hasItems, setHasItems] = useState(false);
useKeyScope("list", opts.enabled && hasItems);
useKeyScope("listDelete", opts.enabled && hasItems && !!opts.onDelete);
useKeyScope("listSelect", opts.enabled && hasItems && !!opts.selection);
useEffect(() => {
const el = ref.current;
if (!opts.enabled || !el) return;
@@ -618,6 +676,7 @@ export function useListNav(
cols: () => latestOpts().cols ?? measuredCols(el),
reveal: (i) => latestOpts().reveal?.(i),
remove: () => latestOpts().onDelete ?? null,
selection: () => latestOpts().selection ?? null,
});
el.setAttribute(LIST, "");
const check = () => setHasItems(el.querySelector(`[${INDEX}]`) !== null);
@@ -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);
}
}
+12 -1
View File
@@ -24,6 +24,8 @@ type ColumnFilter<T> =
export interface Column<T> {
key: string;
header: string;
// A header that is a control of its own (the box that selects every row), in place of `header`.
headerCell?: ReactNode;
render: (row: T) => ReactNode;
align?: "left" | "right" | "center";
width?: string;
@@ -49,6 +51,9 @@ export interface Column<T> {
// one beside a panel: a secondary column that gives its room back to the name (the owner,
// 2026-09-27: at 1024 the channel name had 60 px while About took as much). Same `@container` rule.
tabletHidden?: boolean;
// …and one it leaves out while it is narrower than 24rem, a phone beside the nav rail: there the
// title kept 44 px. Only for a column whose job has another way (the grip: the row menu's moves).
phoneHidden?: boolean;
}
// The classes that hide a column below its width (`narrowHidden` / `tabletHidden`), for its cells and,
@@ -69,14 +74,20 @@ const HIDE_BELOW = {
col: " hidden @5xl:table-column",
cue: " @5xl:hidden",
},
phone: {
cell: " hidden @sm:table-cell",
col: " hidden @sm:table-column",
cue: " @sm:hidden",
},
} as const;
export function columnHideClass(
col: { narrowHidden?: boolean; tabletHidden?: boolean },
col: { narrowHidden?: boolean; tabletHidden?: boolean; phoneHidden?: boolean },
part: "cell" | "col" | "cue" = "cell",
): string {
if (col.narrowHidden) return HIDE_BELOW.narrow[part];
if (col.tabletHidden) return HIDE_BELOW.tablet[part];
if (col.phoneHidden) return HIDE_BELOW.phone[part];
return "";
}
@@ -0,0 +1,175 @@
// @vitest-environment jsdom
import { describe, it, expect } from "vitest";
import { act, renderHook } from "@testing-library/react";
import { useRowSelection } from "./useRowSelection";
const ABCDE = ["a", "b", "c", "d", "e"];
function setup(shown = ABCDE, all = ABCDE, scope: unknown = 1) {
return renderHook(
(p: { all: string[]; shown: string[]; scope: unknown }) =>
useRowSelection(p.all, p.shown, p.scope),
{ initialProps: { all, shown, scope } },
);
}
describe("useRowSelection", () => {
it("toggles one row at a time, and says the selected rows in the order shown", () => {
const { result } = setup();
act(() => result.current.toggle("d"));
act(() => result.current.toggle("b"));
expect(result.current.shownSelected).toEqual(["b", "d"]);
act(() => result.current.toggle("d"));
expect(result.current.shownSelected).toEqual(["b"]);
expect(result.current.isSelected("b")).toBe(true);
expect(result.current.isSelected("d")).toBe(false);
});
it("selects the range from the last row toggled, in either direction", () => {
const { result } = setup();
act(() => result.current.toggle("b"));
act(() => result.current.toggle("d", true));
expect(result.current.shownSelected).toEqual(["b", "c", "d"]);
act(() => {
result.current.clear();
});
act(() => result.current.toggle("d"));
act(() => result.current.toggle("b", true));
expect(result.current.shownSelected).toEqual(["b", "c", "d"]);
});
it("takes a range's state from the row the range ends on: it can clear rows too", () => {
const { result } = setup();
act(() => result.current.toggle("a"));
act(() => result.current.toggle("e", true));
expect(result.current.shownSelected).toEqual(ABCDE);
// `e` is selected, so a range ending on it clears; the anchor moved to `e` with the last click.
act(() => result.current.toggle("b", true));
expect(result.current.shownSelected).toEqual(["a"]);
});
it("leaves rows outside the range alone", () => {
const { result } = setup();
act(() => result.current.toggle("a"));
act(() => result.current.toggle("c"));
act(() => result.current.toggle("e", true));
expect(result.current.shownSelected).toEqual(["a", "c", "d", "e"]);
});
it("follows the order on screen, not the stored one", () => {
const { result } = setup(["e", "c", "a", "b", "d"]);
act(() => result.current.toggle("c"));
act(() => result.current.toggle("b", true));
expect(result.current.shownSelected).toEqual(["c", "a", "b"]);
});
it("makes a range with no row to start from a plain toggle", () => {
const { result } = setup();
act(() => result.current.toggle("c", true));
expect(result.current.shownSelected).toEqual(["c"]);
});
it("makes a range whose first row a filter hid a plain toggle", () => {
const { result, rerender } = setup();
act(() => result.current.toggle("a"));
rerender({ all: ABCDE, shown: ["c", "d", "e"], scope: 1 });
act(() => result.current.toggle("e", true));
expect(result.current.shownSelected).toEqual(["e"]);
});
it("has the header box cover the rows shown, and keep what a filter hides", () => {
const { result, rerender } = setup();
expect(result.current.headerState).toBe("none");
act(() => result.current.toggle("a"));
expect(result.current.headerState).toBe("some");
rerender({ all: ABCDE, shown: ["c", "d"], scope: 1 });
// The one selected row is hidden: nothing SHOWN is selected, and acting counts what is shown.
expect(result.current.headerState).toBe("none");
expect(result.current.shownSelected).toEqual([]);
act(() => result.current.toggleAllShown());
expect(result.current.headerState).toBe("all");
expect(result.current.shownSelected).toEqual(["c", "d"]);
act(() => result.current.toggleAllShown());
expect(result.current.headerState).toBe("none");
rerender({ all: ABCDE, shown: ABCDE, scope: 1 });
expect(result.current.shownSelected).toEqual(["a"]);
});
it("selects every row shown from a partial selection", () => {
const { result } = setup();
act(() => result.current.toggle("b"));
act(() => result.current.toggleAllShown());
expect(result.current.shownSelected).toEqual(ABCDE);
});
it("has no header state to show over no rows", () => {
const { result } = setup([], []);
expect(result.current.headerState).toBe("none");
act(() => result.current.toggleAllShown());
expect(result.current.headerState).toBe("none");
});
it("drops a row that left the list, so it does not come back selected", () => {
const { result, rerender } = setup();
act(() => result.current.toggle("b"));
act(() => result.current.toggle("c"));
rerender({ all: ["a", "c", "d", "e"], shown: ["a", "c", "d", "e"], scope: 1 });
expect(result.current.shownSelected).toEqual(["c"]);
rerender({ all: ABCDE, shown: ABCDE, scope: 1 });
expect(result.current.shownSelected).toEqual(["c"]);
});
it("clears on a change of scope, in the render that changes it", () => {
const { result, rerender } = setup();
act(() => result.current.toggle("a"));
// The same videos in another list are not selected there.
rerender({ all: ABCDE, shown: ABCDE, scope: 2 });
expect(result.current.shownSelected).toEqual([]);
act(() => result.current.toggle("c", true));
expect(result.current.shownSelected).toEqual(["c"]);
});
it("counts the selected rows a filter hides", () => {
const { result, rerender } = setup();
act(() => result.current.toggle("a"));
act(() => result.current.toggle("c"));
expect(result.current.hiddenCount).toBe(0);
rerender({ all: ABCDE, shown: ["c", "d"], scope: 1 });
expect(result.current.hiddenCount).toBe(1);
});
it("ticks rows again on top of what is selected by then", () => {
const { result } = setup();
act(() => result.current.toggle("e"));
act(() => result.current.select(["a", "b"]));
expect(result.current.shownSelected).toEqual(["a", "b", "e"]);
});
it("says whether clearing did anything", () => {
const { result } = setup();
let cleared = true;
act(() => {
cleared = result.current.clear();
});
expect(cleared).toBe(false);
act(() => result.current.toggle("a"));
act(() => {
cleared = result.current.clear();
});
expect(cleared).toBe(true);
expect(result.current.shownSelected).toEqual([]);
});
it("counts a selected row a filter hides as something to clear", () => {
const { result, rerender } = setup();
act(() => result.current.toggle("a"));
rerender({ all: ABCDE, shown: ["c"], scope: 1 });
let cleared = false;
act(() => {
cleared = result.current.clear();
});
expect(cleared).toBe(true);
rerender({ all: ABCDE, shown: ABCDE, scope: 1 });
expect(result.current.shownSelected).toEqual([]);
});
});
+82
View File
@@ -0,0 +1,82 @@
import { useState } from "react";
const NONE: ReadonlySet<string> = new Set();
/** Which rows of a table are ticked, for the bulk actions over them. `all` is every row the list
* holds and `shown` the ones on screen, in screen order. A row a filter hides stays selected and
* comes back ticked, but `shownSelected`, what an action covers, leaves it out: nothing happens to a
* row the reader cannot see. A row that left the list drops out for good, or an Undo would bring it
* back ticked. A change of `scope` starts over: the same video in another list is another row. */
export function useRowSelection(all: readonly string[], shown: readonly string[], scope: unknown) {
const [selected, setSelected] = useState(NONE);
// The last row toggled: where a Shift range starts.
const [anchor, setAnchor] = useState<string | null>(null);
const [selectedFor, setSelectedFor] = useState(scope);
if (selectedFor !== scope) {
setSelectedFor(scope);
setSelected(NONE);
setAnchor(null);
}
const held = new Set(all);
const kept = [...selected].filter((id) => held.has(id));
if (kept.length !== selected.size) setSelected(new Set(kept));
const shownSelected = shown.filter((id) => selected.has(id));
function toggle(id: string, range = false) {
const on = !selected.has(id);
const from = range && anchor != null ? shown.indexOf(anchor) : -1;
const to = shown.indexOf(id);
// Taken from where the range ends, the way a mail list does: it can clear a run of rows too.
const rows =
from >= 0 && to >= 0 ? shown.slice(Math.min(from, to), Math.max(from, to) + 1) : [id];
const next = new Set(selected);
for (const row of rows) {
if (on) next.add(row);
else next.delete(row);
}
setSelected(next);
setAnchor(id);
}
function toggleAllShown() {
const next = new Set(selected);
const on = shownSelected.length < shown.length;
for (const row of shown) {
if (on) next.add(row);
else next.delete(row);
}
setSelected(next);
setAnchor(null);
}
/** Says whether there was anything to clear: an Esc that cleared nothing is not spent. */
function clear(): boolean {
const had = selected.size > 0;
if (had) setSelected(NONE);
setAnchor(null);
return had;
}
/** Tick these again: an action that took them off and failed. */
function select(ids: readonly string[]) {
setSelected((now) => new Set([...now, ...ids]));
}
return {
shownSelected,
hiddenCount: selected.size - shownSelected.length,
isSelected: (id: string) => selected.has(id),
headerState:
shownSelected.length === 0
? ("none" as const)
: shownSelected.length === shown.length
? ("all" as const)
: ("some" as const),
toggle,
toggleAllShown,
clear,
select,
};
}
+10
View File
@@ -18,6 +18,16 @@ describe("useUndoable", () => {
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", () => {
const { result } = renderHook(() => useUndoable<number[]>([1]));
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.
export interface Undoable<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;
reset: (value: T) => void;
rebase: (map: (value: T) => T) => void;
@@ -110,8 +112,11 @@ export function useUndoable<T>(
setH(over);
}, []);
const peek = useCallback(() => ref.current.present, []);
return {
value: h.present,
peek,
set,
reset,
rebase,
+3
View File
@@ -17,12 +17,15 @@ import "flag-icons/css/flag-icons.min.css";
import { lazyWithPreload } from "./lib/lazyWithPreload";
import { installClientLog } from "./lib/logHooks";
import { installStaleBuildReload } from "./lib/staleBuild";
import { retryLostStylesheets } from "./lib/stylesheetRetry";
import { appCopyOf } from "./i18n/appCopy";
// First, before any render: a crash or a navigation during boot belongs in the log too.
installClientLog(appCopyOf, typedUrlKeys());
// A tab from before the last release reloads onto the new build instead of crashing on a lazy chunk.
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
// authenticated app bundle, and the app never carries it either. The legal pages live outside the
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,119 @@
// @vitest-environment jsdom
//
// The rail's rows as targets of a drag that starts in another tree: which ones are, and the one lit up.
import { describe, it, expect, vi, afterEach, beforeAll } from "vitest";
import { render, screen, cleanup, act } from "@testing-library/react";
import { FloatingTree } from "@floating-ui/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
vi.mock("../../lib/useRail", () => ({
useRail: () => ({
layout: { order: ["manage", "list"], collapsed: {}, hidden: {} },
setLayout: () => {},
collapsed: false,
onToggleCollapse: () => {},
}),
}));
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return { ...actual, useTranslation: () => ({ t: (k: string) => k, i18n: { language: "en" } }) };
});
vi.mock("./playlistsContext", () => ({
usePlaylists: () => ({ search: "", selectedId: 1 }),
usePlaylistsActions: () => ({ setSelectedId: vi.fn() }),
}));
vi.mock("../../lib/useMe", () => ({ useMe: () => ({ can_write: true }) }));
vi.mock("../../features/comfort/comfortPin", () => ({
useComfortPin: () => ({ item: () => [] }),
pinPlaylist: (id: number) => id,
}));
vi.mock("../../components/ui/confirmContext", () => ({
useChoice: () => async () => null,
useConfirm: () => async () => false,
}));
vi.mock("../../components/ui/FilterRail", () => ({
default: ({ items }: { items: { id: string; body: ReactNode }[] }) => (
<>
{items.map((i) => (
<div key={i.id}>{i.body}</div>
))}
</>
),
}));
import PlaylistsRail from "./PlaylistsRail";
import { api, type Playlist } from "../../lib/api";
import { startRailDrag, stopRailDrag } from "./railDrop";
beforeAll(() => {
class Stub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
Element.prototype.scrollIntoView ??= () => {};
});
afterEach(() => {
stopRailDrag();
cleanup();
vi.restoreAllMocks();
});
const pl = (id: number, name: string) =>
({
id,
name,
kind: "user",
source: "local",
yt_playlist_id: null,
item_count: 1,
total_duration_seconds: 0,
cover_thumbnail: null,
dirty: false,
}) as unknown as Playlist;
async function mount() {
vi.spyOn(api, "playlists").mockResolvedValue([pl(1, "Open"), pl(2, "Other")] as never);
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={qc}>
<FloatingTree>
<PlaylistsRail />
</FloatingTree>
</QueryClientProvider>,
);
const wrap = async (name: string) =>
(await screen.findByText(name)).closest("[class*='group']") as HTMLElement;
return { open: await wrap("Open"), other: await wrap("Other") };
}
describe("PlaylistsRail — a row as a drop target", () => {
it("offers every row but the open list", async () => {
const { open, other } = await mount();
expect(other.getAttribute("data-playlist-drop")).toBe("2");
expect(open.hasAttribute("data-playlist-drop")).toBe(false);
expect(open.closest("[data-playlist-drop-area]")).toBeTruthy();
});
it("lights up the row under the drag, and only while it is", async () => {
const { open, other } = await mount();
document.elementsFromPoint = () => [other];
expect(other.className).not.toContain("ring-2");
act(() => {
startRailDrag(0, 0);
});
expect(other.className).toContain("ring-2");
expect(open.className).not.toContain("ring-2");
act(() => {
stopRailDrag();
});
expect(other.className).not.toContain("ring-2");
});
});
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../../lib/queryKeys";
import clsx from "clsx";
import { ListVideo, Pin, Plus, RefreshCw } from "lucide-react";
import { YouTubeIcon } from "../../lib/brandIcons";
import { api, type Playlist } from "../../lib/api";
@@ -32,6 +33,7 @@ import { Input } from "../../components/ui/Input";
import { MoreButton } from "../../components/ui/MoreButton";
import RemoteImage from "../../components/ui/RemoteImage";
import { useRail } from "../../lib/useRail";
import { railDropAreaAttrs, railDropAttrs, useRailDropTarget } from "./railDrop";
type PlSort = {
key: "custom" | "name" | "count" | "duration" | "recent";
@@ -55,6 +57,7 @@ export default function PlaylistsRail() {
// The name filter + the selected playlist live in PlaylistsProvider (shared with the header search
// box and the Playlists detail pane); this rail owns the list query + its own controls.
const { search, selectedId } = usePlaylists();
const dropTarget = useRailDropTarget();
const { setSelectedId } = usePlaylistsActions();
const { t, i18n } = useTranslation();
const lang = i18n.language;
@@ -282,7 +285,7 @@ export default function PlaylistsRail() {
{playlists.length > 0 && visiblePlaylists.length === 0 && (
<div className="text-xs text-muted px-1 py-1">{t("playlists.noMatches")}</div>
)}
<div className="space-y-1">
<div className="space-y-1" {...railDropAreaAttrs}>
{visiblePlaylists.map((pl) =>
renamingId === pl.id ? (
// The row becomes its own rename field. A <button> can't contain an <input>, so the
@@ -328,7 +331,16 @@ export default function PlaylistsRail() {
) : (
// `group`: the row's "…" shows on its hover or focus (`.reveal`), and always on touch.
// A sibling of the row button, not inside it — a button cannot hold a button.
<div key={pl.id} className="group relative" {...railMenu.triggerProps(pl)}>
// The open list is no target: the video is already there.
<div
key={pl.id}
className={clsx(
"group relative rounded-lg",
dropTarget === pl.id && "bg-accent/15 ring-2 ring-accent ring-inset",
)}
{...railMenu.triggerProps(pl)}
{...(pl.id === selectedId ? {} : railDropAttrs(pl.id))}
>
<Pressable
selectable
ref={(el) => {
@@ -0,0 +1,134 @@
import type { MouseEventHandler, ReactNode, Ref } from "react";
import { useTranslation } from "react-i18next";
import { ChevronDown, FolderInput, History, ListRestart, Square, X } from "lucide-react";
import { Button } from "../../components/ui/Button";
/** The playlist's second toolbar row while rows are selected: how many, and what can be done to them.
* `count` is the rows shown, what an action covers; `hidden` the selected ones a filter hides, which
* it leaves alone. With only hidden ones the actions stay in place, off, and the way out stays on.
* Below `xl` the buttons are glyphs named in their tooltips: in words the row wraps there, and the
* first tick would push the list from under the second tap. On a phone the count is its number and
* the undo pair stays out, for the same one row. The page says the count out loud: this band goes
* when the selection does, and a live region that leaves with its news announces nothing. */
export function SelectionBand({
ref,
count,
hidden,
recentName,
onMoveRecent,
moveMenuProps,
moveMenuOpen,
onRemove,
onClear,
onClearView,
trailing,
}: {
ref: Ref<HTMLDivElement>;
count: number;
hidden: number;
/** The list the last move went to: one click away, absent when there is none to repeat. */
recentName: string | null;
onMoveRecent: () => void;
moveMenuProps: Record<string, unknown>;
moveMenuOpen: boolean;
onRemove: () => void;
onClear: () => void;
/** The view's reset, which the row this one replaces offers. */
onClearView?: MouseEventHandler<HTMLButtonElement>;
trailing: ReactNode;
}) {
const { t } = useTranslation();
const none = count === 0;
return (
<div ref={ref} className="flex items-center gap-1 mt-1 flex-wrap xl:gap-2">
<span className="text-xs whitespace-nowrap">
<span className="sr-only font-medium sm:not-sr-only">
{t("playlists.selectedCount", { count })}
</span>
<span
aria-hidden="true"
title={t("playlists.selectedCount", { count })}
className="font-medium sm:hidden"
>
{count}
</span>
{hidden > 0 && (
<span className="text-muted">
<span aria-hidden="true" className="hidden xl:inline">
{" · "}
</span>
<span className="sr-only xl:not-sr-only">
{t("playlists.selectedHidden", { count: hidden })}
</span>
<span
aria-hidden="true"
title={t("playlists.selectedHidden", { count: hidden })}
className="ml-1 xl:hidden"
>
{t("playlists.selectedHiddenShort", { n: hidden })}
</span>
</span>
)}
</span>
{recentName != null && (
<Button
variant="outline"
size="xs"
disabled={none}
onClick={onMoveRecent}
label={t("playlists.moveToRecent", { name: recentName })}
icon={<History className="w-3.5 h-3.5" />}
>
<span className="hidden max-w-48 truncate xl:inline">
{t("playlists.moveToRecent", { name: recentName })}
</span>
</Button>
)}
<Button
variant="outline"
size="xs"
disabled={none}
aria-expanded={moveMenuOpen}
label={t("playlists.moveSelected")}
icon={<FolderInput className="w-3.5 h-3.5" />}
{...moveMenuProps}
>
<span className="hidden xl:inline">{t("playlists.moveSelected")}</span>
<ChevronDown className="w-3.5 h-3.5" aria-hidden="true" />
</Button>
<Button
variant="outline-danger"
size="xs"
disabled={none}
onClick={onRemove}
label={t("playlists.removeSelected")}
icon={<X className="w-3.5 h-3.5" />}
>
<span className="hidden xl:inline">{t("playlists.removeSelected")}</span>
</Button>
<Button
variant="ghost"
size="xs"
onClick={onClear}
label={t("playlists.clearSelection")}
icon={<Square className="w-3.5 h-3.5 xl:hidden" />}
className="xl:underline xl:underline-offset-2"
>
<span className="hidden xl:inline">{t("playlists.clearSelection")}</span>
</Button>
{onClearView && (
<Button
variant="ghost"
size="xs"
onClick={onClearView}
label={t("playlists.clearView")}
icon={<ListRestart className="w-3.5 h-3.5 xl:hidden" />}
className="xl:underline xl:underline-offset-2"
>
<span className="hidden xl:inline">{t("playlists.clearView")}</span>
</Button>
)}
<div className="ml-auto hidden sm:block">{trailing}</div>
</div>
);
}
@@ -6,6 +6,7 @@ import { formatDuration } from "../../lib/format";
import type { Column } from "../../lib/tableColumns";
import { Pressable } from "../../components/ui/Pressable";
import RemoteImage from "../../components/ui/RemoteImage";
import { RowSelectBox } from "../../components/ui/RowSelectBox";
// Column definitions for the playlist detail table, in their own file like `channelColumns.tsx`.
//
@@ -30,7 +31,14 @@ export function playlistColumns({
more: (v: Video) => ReactNode;
}): Column<Video>[] {
const cols: Column<Video>[] = [
{ key: GRIP_KEY, header: "", width: "2.25rem", render: () => null, hideInCard: true },
{
key: GRIP_KEY,
header: "",
width: "2.25rem",
render: () => null,
hideInCard: true,
phoneHidden: true,
},
{
key: "pos",
header: "#",
@@ -145,6 +153,36 @@ export function playlistColumns({
return cols;
}
/** The box column, first in the row, for a list whose rows can be selected. A separate column from
* `playlistColumns`, whose array the view's filter and sort read: a tick must not re-run them. */
export function selectColumn({
t,
allBox,
isSelected,
onToggle,
}: {
t: TFunction;
/** The box over the column. Built by the page, which holds a ref to it. */
allBox: ReactNode;
isSelected: (id: string) => boolean;
onToggle: (id: string, range: boolean) => void;
}): Column<Video> {
return {
key: "select",
header: t("playlists.selectAllShown"),
headerCell: allBox,
width: "2.25rem",
hideInCard: true,
render: (v) => (
<RowSelectBox
label={t("playlists.selectRow", { title: v.title })}
checked={isSelected(v.id)}
onToggle={(shift) => onToggle(v.id, shift)}
/>
),
};
}
// A video's length, or its live / upcoming state when it has none. `onPicture`: the dark badge on the
// thumbnail (the narrow row), readable on any frame, like the media card's length badge.
function lengthCell(v: Video, t: TFunction, onPicture = false): ReactNode {
@@ -0,0 +1,226 @@
// @vitest-environment jsdom
import { act, renderHook } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { escapeHandled } from "../../lib/layerStack";
import {
isOverRail,
railDropAreaAttrs,
railDropAttrs,
startRailDrag,
stopRailDrag,
useRailDropTarget,
} from "./railDrop";
// jsdom lays nothing out: what lies under a point is whatever the test says, by row id.
let under: (x: number, y: number) => Element[] = () => [];
let frames: FrameRequestCallback[] = [];
const runFrame = () => {
const now = frames;
frames = [];
now.forEach((f) => f(0));
};
function rail({ short = false } = {}) {
const scroller = document.createElement("div");
scroller.style.overflowY = "auto";
Object.defineProperty(scroller, "scrollHeight", { value: short ? 400 : 2000 });
Object.defineProperty(scroller, "clientHeight", { value: 400 });
scroller.getBoundingClientRect = () =>
({ left: 0, right: 200, top: 100, bottom: 500, width: 200, height: 400 }) as DOMRect;
const area = document.createElement("div");
for (const [k, v] of Object.entries(railDropAreaAttrs)) area.setAttribute(k, v);
const row = (id: number) => {
const el = document.createElement("div");
for (const [k, v] of Object.entries(railDropAttrs(id))) el.setAttribute(k, String(v));
area.append(el);
return el;
};
const open = document.createElement("div");
area.append(open);
scroller.append(area);
document.body.append(scroller);
return { scroller, area, open, row };
}
const moveTo = (x: number, y: number) =>
document.dispatchEvent(new MouseEvent("pointermove", { clientX: x, clientY: y }));
beforeEach(() => {
under = () => [];
frames = [];
document.elementsFromPoint = (x, y) => under(x, y);
vi.stubGlobal("requestAnimationFrame", (f: FrameRequestCallback) => frames.push(f));
vi.stubGlobal("cancelAnimationFrame", () => {
frames = [];
});
});
afterEach(() => {
stopRailDrag();
document.body.replaceChildren();
vi.unstubAllGlobals();
});
describe("railDrop", () => {
it("says which row the pointer is over, through the drag image lying on top", () => {
const { row } = rail();
const image = document.createElement("div");
const r7 = row(7);
under = () => [image, r7];
const { result } = renderHook(() => useRailDropTarget());
expect(result.current).toBeNull();
act(() => {
startRailDrag(0, 0);
});
expect(result.current).toBe(7);
under = () => [];
act(() => {
moveTo(600, 300);
});
expect(result.current).toBeNull();
});
it("finds a row from its child, as the pointer lands on the row's cover", () => {
const { row } = rail();
const child = document.createElement("span");
row(3).append(child);
under = () => [child];
startRailDrag(0, 0);
expect(stopRailDrag()).toBe(3);
});
it("answers the last row on stop, and leaves no highlight or listener behind", () => {
const { row } = rail();
const r5 = row(5);
under = () => [r5];
const { result } = renderHook(() => useRailDropTarget());
act(() => {
startRailDrag(0, 0);
});
let dropped: number | null = null;
act(() => {
dropped = stopRailDrag();
});
expect(dropped).toBe(5);
expect(result.current).toBeNull();
act(() => {
moveTo(10, 10);
});
expect(result.current).toBeNull();
expect(stopRailDrag()).toBeNull();
});
it("tells the rail apart from a row: the open list is in the rail, though no target", () => {
const { open } = rail();
under = () => [open];
startRailDrag(0, 0);
expect(isOverRail()).toBe(true);
expect(stopRailDrag()).toBeNull();
expect(isOverRail()).toBe(false);
});
it("is not over the rail anywhere else on the page", () => {
rail();
const elsewhere = document.createElement("div");
document.body.append(elsewhere);
under = () => [elsewhere];
startRailDrag(0, 0);
expect(isOverRail()).toBe(false);
});
it("scrolls the rail while the pointer holds at its edge, and reads the row that came under it", () => {
const { scroller, row } = rail();
const r1 = row(1);
const r2 = row(2);
under = () => (scroller.scrollTop > 0 ? [r2] : [r1]);
const { result } = renderHook(() => useRailDropTarget());
act(() => {
startRailDrag(100, 300);
});
act(() => {
runFrame();
});
expect(scroller.scrollTop).toBe(0);
act(() => {
moveTo(100, 495);
});
act(() => {
runFrame();
});
expect(scroller.scrollTop).toBeGreaterThan(0);
expect(result.current).toBe(2);
const down = scroller.scrollTop;
act(() => {
moveTo(100, 105);
});
act(() => {
runFrame();
});
expect(scroller.scrollTop).toBeLessThan(down);
});
it("reads the row a wheel scrolled under a pointer that fired nothing", () => {
const { row } = rail();
const r1 = row(1);
const r2 = row(2);
let lying = r1;
under = () => [lying];
const { result } = renderHook(() => useRailDropTarget());
act(() => {
startRailDrag(100, 300);
});
expect(result.current).toBe(1);
lying = r2;
act(() => {
runFrame();
});
expect(result.current).toBe(2);
expect(stopRailDrag()).toBe(2);
});
it("keeps the rail it found when there is nothing to scroll, rather than look again each frame", () => {
rail({ short: true });
startRailDrag(100, 300);
const looked = vi.spyOn(window, "getComputedStyle");
runFrame();
runFrame();
expect(looked).not.toHaveBeenCalled();
looked.mockRestore();
});
it("spends the drag's Esc, and no other", () => {
const esc = () => {
const e = new KeyboardEvent("keydown", { key: "Escape", bubbles: true });
document.dispatchEvent(e);
return e;
};
startRailDrag(100, 300);
expect(escapeHandled(esc())).toBe(true);
stopRailDrag();
expect(escapeHandled(esc())).toBe(false);
});
it("finds the rail that opened only during the drag, as an unpinned one peeks", () => {
startRailDrag(100, 495);
runFrame();
const { scroller } = rail();
runFrame();
expect(scroller.scrollTop).toBeGreaterThan(0);
});
it("does not scroll the rail for a pointer beside it, at the same height", () => {
const { scroller } = rail();
startRailDrag(600, 495);
runFrame();
expect(scroller.scrollTop).toBe(0);
});
it("stops scrolling on stop", () => {
const { scroller } = rail();
startRailDrag(100, 495);
stopRailDrag();
expect(frames).toHaveLength(0);
runFrame();
expect(scroller.scrollTop).toBe(0);
});
});
+123
View File
@@ -0,0 +1,123 @@
import { markEscapeHandled } from "../../lib/layerStack";
import { scrollParent } from "../../lib/listPaging";
import { createStore } from "../../lib/store";
// Dragging a video onto a list in the rail. The rail and the page are separate React trees (the shell
// mounts them side by side, each lazily), so no drag context reaches both: the page's drag reads the
// rail by what lies under the pointer, and this says the answer to the rail's rows.
//
// The pointer is followed here rather than worked out from dnd-kit's `delta`: that one includes what
// the list has scrolled during the drag, which is no distance on the screen.
const RAIL_ROW = "data-playlist-drop";
const RAIL_AREA = "data-playlist-drop-area";
/** Marks a rail row as somewhere a drag can land. */
export const railDropAttrs = (id: number) => ({ [RAIL_ROW]: id });
/** Marks the rail's rows as a whole: over them a drag is out of the list, target or not. */
export const railDropAreaAttrs = { [RAIL_AREA]: "" };
/** How near the rail's edge the pointer scrolls it, and how fast at the very edge, per frame. */
const EDGE = 48;
const MAX_STEP = 12;
const over = createStore<number | null>(null);
let inRail = false;
let point: { x: number; y: number } | null = null;
let frame = 0;
let scroller: HTMLElement | null = null;
function setOver(id: number | null) {
if (id !== over.get()) over.set(id);
}
/** The rail row under the drag right now. */
export const useRailDropTarget = over.useValue;
/** The same, outside React. */
export const railDropTarget = over.get;
/** Whether the drag is over the rail at all, where the list's rows must not make way for it. */
export const isOverRail = (): boolean => inRail;
function readRail() {
if (!point) return;
let row: number | null = null;
let area = false;
// `elementsFromPoint`: a toast or a tooltip lying over the rail takes no drop, the row under it does.
for (const el of document.elementsFromPoint(point.x, point.y)) {
const hit = el.closest(`[${RAIL_ROW}]`);
if (hit) row = Number(hit.getAttribute(RAIL_ROW));
if (el.closest(`[${RAIL_AREA}]`)) area = true;
if (row != null) break;
}
inRail = area;
setOver(row);
}
function railScroller(): HTMLElement | null {
const area = document.querySelector(`[${RAIL_AREA}]`);
return area && scrollParent(area);
}
/** With 45 lists the row wanted is usually out of sight, and dnd-kit scrolls only what holds the
* dragged row: not the rail. */
function scrollAtEdge() {
// Looked for again: an unpinned rail has no rows until the drag's own hover peeks it open.
if (!scroller?.isConnected) scroller = railScroller();
if (!point || !scroller) return;
const box = scroller.getBoundingClientRect();
if (point.x < box.left || point.x > box.right) return;
const fromTop = point.y - box.top;
const fromBottom = box.bottom - point.y;
const step =
fromTop < EDGE
? -MAX_STEP * (1 - Math.max(fromTop, 0) / EDGE)
: fromBottom < EDGE
? MAX_STEP * (1 - Math.max(fromBottom, 0) / EDGE)
: 0;
if (step !== 0) scroller.scrollTop += step;
}
function followStep() {
frame = requestAnimationFrame(followStep);
scrollAtEdge();
// Every frame, not on a move alone: a wheel scrolls another row under a pointer that fires nothing,
// and the drop would land on the row that scrolled away.
readRail();
}
const onPointerMove = (e: PointerEvent) => {
point = { x: e.clientX, y: e.clientY };
readRail();
};
// The drag's Esc is spent on the drag. dnd-kit's pointer sensor cancels on the document and prevents
// no default, so without this the list's Esc clears the selection on the same key press.
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") markEscapeHandled(e);
};
/** Follows the pointer from where the drag began, until `stopRailDrag`. */
export function startRailDrag(x: number, y: number): void {
stopRailDrag();
point = { x, y };
scroller = railScroller();
document.addEventListener("pointermove", onPointerMove, true);
window.addEventListener("keydown", onKeyDown, true);
frame = requestAnimationFrame(followStep);
readRail();
}
/** Ends the following, and answers the row the drag was over. Safe without a drag. */
export function stopRailDrag(): number | null {
const last = over.get();
document.removeEventListener("pointermove", onPointerMove, true);
window.removeEventListener("keydown", onKeyDown, true);
cancelAnimationFrame(frame);
point = null;
scroller = null;
inRail = false;
setOver(null);
return last;
}
@@ -134,13 +134,13 @@ const lost404 = {
fatal: true,
type: Hls.ErrorTypes.NETWORK_ERROR,
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 () => {
// The server reaps a session nobody fetches from (a long pause; a deploy). hls.js never retries
// a 4xx, so the next segment's 404 used to end playback with "couldn't play".
// Once hls.js has given up on it, the 404 used to end playback with "couldn't play". A session
// the server no longer has answers 410 instead: `hlsEngine.test.ts` holds that case.
const session = mount();
await waitFor(() => expect(hlsInstances).toHaveLength(1));
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 () => {
// `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
// also answers 404 for "that segment is not there YET" (a transcode falling behind realtime),
// which is indistinguishable on the wire — so the player respawned ffmpeg forever behind a
// spinner that never became an error.
// so a session that parses its manifest and then 404s re-arms the guard every cycle. A transcode
// falling behind realtime answers 404 for "that segment is not there YET" every time, so the
// player respawned ffmpeg forever behind a spinner that never became an error.
const session = mount();
const v = () => document.querySelector("video") as Media;
// 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 "./index.css";
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
// 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.
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 });
+8 -1
View File
@@ -56,6 +56,13 @@ const watchEntry = (): Plugin => ({
export default defineConfig({
plugins: [reactWithCompiler(), devOnlyGuard(), chunkGuard(), watchEntry()],
server: { port: 5173, proxy },
server: {
port: 5173,
proxy,
// What an e2e run writes is no source. On Windows a file it is rewriting answers the watcher with
// EBUSY, which takes the whole dev server down; each run also reloaded every open page. (Its
// `test-results` is a default of vite's, and `ignored` adds to the defaults.)
watch: { ignored: ["**/playwright-report/**", "**/e2e/.auth/**"] },
},
build: { outDir: "dist", rollupOptions: { input: ["index.html", "watch.html"] } },
});