Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
891507dc75 | ||
|
|
e6894ac0fc | ||
|
|
663a7e8012 | ||
|
|
93192dadd8 | ||
|
|
5b48018e92 | ||
|
|
8312c6d592 | ||
|
|
e1d325b942 | ||
|
|
977ba244fa | ||
|
|
89d18b46a6 | ||
|
|
c27cfdcb9c | ||
|
|
a867145bcb | ||
|
|
4d4eea2c54 | ||
|
|
c489b60c8b | ||
|
|
b4bf71ea64 | ||
|
|
72df11f2c0 | ||
|
|
0a16bc40ae | ||
|
|
520b0afb90 | ||
|
|
e301632765 | ||
|
|
50157cc2fd | ||
|
|
f221c1962a | ||
|
|
d466ae915b | ||
|
|
14598ec320 | ||
|
|
b16cf7668c | ||
|
|
3438aeb695 | ||
|
|
c003a48244 | ||
|
|
8a2334e198 | ||
|
|
1f010328df | ||
|
|
77638f7a0b | ||
|
|
2cff4ad954 | ||
|
|
8776586ca3 | ||
|
|
92a17aba83 | ||
|
|
fc53bc03bc | ||
|
|
6df257d7df | ||
|
|
5572499b56 | ||
|
|
76738f8ea9 | ||
|
|
568bf93510 | ||
|
|
942154cf49 | ||
|
|
3eb87e7108 | ||
|
|
851b97b09d | ||
|
|
ee94d1949a | ||
|
|
4f076b5d45 | ||
|
|
ece74a42ab | ||
|
|
600d5c39da | ||
|
|
4c7f73af9d | ||
|
|
5f42ff61c8 | ||
|
|
6dd82ef5e2 | ||
|
|
75f4efde98 | ||
|
|
c0059a0c4b | ||
|
|
a29cadf03e | ||
|
|
83886494ce | ||
|
|
4eaa55385f | ||
|
|
7541b1b016 | ||
|
|
24ad17fc7d | ||
|
|
48bd038c5f | ||
|
|
0fce732497 | ||
|
|
7e94da72b9 | ||
|
|
fadb4f17a9 | ||
|
|
57a81c5643 | ||
|
|
dae0b80f6a | ||
|
|
75093458c9 | ||
|
|
5db6dc8ee6 | ||
|
|
45a9ceae25 | ||
|
|
5249c41edc | ||
|
|
c3664826fe | ||
|
|
c9b8fe15a3 | ||
|
|
1044eaf8a9 | ||
|
|
ca949d22de | ||
|
|
49a9319f84 | ||
|
|
70d56ab939 |
@@ -7,6 +7,40 @@ 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.105.0 — 2026-10-10
|
||||
|
||||
### New
|
||||
|
||||
- Clips: mark a range of what you're watching — a YouTube video or a Plex item — set its ends to the tenth, crop it to a frame drawn on the picture, and cut it straight into your downloads.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Downloads: a download, a clip or an editor clip that doesn't fit in the storage you have left says so, and is never stored over your cap; YouTube downloads reach their format's height again.
|
||||
- Player: focus rings, the chosen track or tab, sliders and the progress bar's head are amber on the dark player in every theme, so they show in a light theme too.
|
||||
|
||||
## 0.104.0 — 2026-10-10
|
||||
|
||||
### New
|
||||
|
||||
- YouTube: a card's menu filters the feed to its channel and back, and the player gives the video more room — its side panel shuts on demand.
|
||||
- Plex: a card's picture plays it and its title opens its page; a show card puts on the episode it would continue or start, and an episode links up to its show.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Plex: summaries written as HTML read as clean text, and a Surprise draw stays put until you reshuffle. A video marked watched in the player stays out of an Unwatched feed. Plus smaller keyboard and focus fixes.
|
||||
|
||||
## 0.103.0 — 2026-10-08
|
||||
|
||||
### New
|
||||
|
||||
- Plex: “Add to collection…” in a movie's or show's menu ticks the title into or out of your collections, or names a new one with it inside — one step instead of a trip to the Collections page.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Plex player: subtitles rise above the control bar while it shows and slide back when it hides, so the bar never covers them — on an iPad too.
|
||||
- iPad: the header search fits the touch screen, tooltips no longer stick, the Plex player page no longer scrolls or shows a blue band, and the fullscreen button stays hidden where the browser cannot really go fullscreen.
|
||||
- The playlist, collection and tag menus say when their list is loading or failed to load (with a retry) instead of showing it as empty, and work from the keyboard. Plus smaller fixes in the YouTube player's title line.
|
||||
|
||||
## 0.102.0 — 2026-10-08
|
||||
|
||||
### New
|
||||
|
||||
@@ -437,14 +437,6 @@
|
||||
"lineCount": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": "reportArgumentType",
|
||||
"range": {
|
||||
"startColumn": 12,
|
||||
"endColumn": 13,
|
||||
"lineCount": 10
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": "reportAttributeAccessIssue",
|
||||
"range": {
|
||||
@@ -1134,22 +1126,6 @@
|
||||
"endColumn": 48,
|
||||
"lineCount": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": "reportArgumentType",
|
||||
"range": {
|
||||
"startColumn": 39,
|
||||
"endColumn": 45,
|
||||
"lineCount": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": "reportArgumentType",
|
||||
"range": {
|
||||
"startColumn": 38,
|
||||
"endColumn": 43,
|
||||
"lineCount": 1
|
||||
}
|
||||
}
|
||||
],
|
||||
"./app/routes/plex/playback.py": [
|
||||
@@ -1455,14 +1431,6 @@
|
||||
"lineCount": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": "reportArgumentType",
|
||||
"range": {
|
||||
"startColumn": 38,
|
||||
"endColumn": 43,
|
||||
"lineCount": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": "reportArgumentType",
|
||||
"range": {
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Plex summaries and taglines as plain text
|
||||
|
||||
Revision ID: 0101_plex_text_plain
|
||||
Revises: 0100_drop_plex_item_markers
|
||||
Create Date: 2026-10-08
|
||||
|
||||
Some Plex agents write a summary as HTML (`<p>…</p>`, `á`), and the app showed the tags as
|
||||
text. The sync now stores every summary and tagline through `plain_text` (app/plex/plaintext.py);
|
||||
this cleans the rows it wrote before, through the same function, so nothing waits for a full resync.
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
from app.plex.plaintext import plain_text
|
||||
|
||||
revision: str = "0101_plex_text_plain"
|
||||
down_revision: str | None = "0100_drop_plex_item_markers"
|
||||
branch_labels: str | None = None
|
||||
depends_on: str | None = None
|
||||
|
||||
_COLUMNS = {
|
||||
"plex_items": ("summary", "summary_hu", "tagline", "tagline_hu"),
|
||||
"plex_shows": ("summary", "summary_hu", "tagline", "tagline_hu"),
|
||||
"plex_collections": ("summary",),
|
||||
}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
for table, columns in _COLUMNS.items():
|
||||
for col in columns:
|
||||
rows = conn.execute(
|
||||
sa.text(f"SELECT id, {col} FROM {table} WHERE {col} ~ '[<&]'")
|
||||
).fetchall()
|
||||
updates = [
|
||||
{"id": rid, "v": clean}
|
||||
for rid, value in rows
|
||||
if (clean := plain_text(value)) != value
|
||||
]
|
||||
if updates:
|
||||
conn.execute(sa.text(f"UPDATE {table} SET {col} = :v WHERE id = :id"), updates)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass # the markup is not wanted back; the next sync would clean it again
|
||||
@@ -181,15 +181,18 @@ class Settings(BaseSettings):
|
||||
# for YouTube Proof-of-Origin tokens (bypasses bot-detection + unlocks high-quality
|
||||
# formats). Empty disables it (yt-dlp still works, but may hit "confirm you're not a bot").
|
||||
download_pot_base_url: str = "http://bgutil-pot:4416"
|
||||
# yt-dlp player clients (comma-separated). PRIMARY = android_vr: high-quality DASH, no PO
|
||||
# token / JS runtime needed, reliable for normal use. On a bot/DRM/format failure (YouTube's
|
||||
# per-client responses are flaky, and android_vr can get bot-flagged under heavy load) the
|
||||
# worker retries with the FALLBACK set — the POT-capable web clients (web needs the token +
|
||||
# Deno) plus android for audio. Both admin-tunable when YouTube shifts what works.
|
||||
# yt-dlp player clients (comma-separated). PRIMARY = tv_simply: measured 2026-10-10 as the one
|
||||
# client that is offered 1080p+ AND finishes the fetch, its stream token minted by the bgutil
|
||||
# sidecar. android_vr (the primary until then) was still offered 2160p, but every fetch died
|
||||
# with HTTP 403 — it now needs a stream PO token the sidecar cannot mint — and the fallback
|
||||
# below served 360p only, so every download came out at 360p without failing. On a
|
||||
# bot/DRM/format failure the worker retries with the FALLBACK set — the POT-capable web clients
|
||||
# (web needs the token + Deno) plus android, whose 360p is the last resort. Both admin-tunable
|
||||
# (env) when YouTube shifts what works.
|
||||
# A SIGNED-IN download (the user added YouTube cookies) keeps only the cookie-capable clients
|
||||
# of these two sets — yt-dlp drops the rest, so android_vr / android / ios never run there.
|
||||
# See adapters.YouTubeAdapter._COOKIE_CAPABLE.
|
||||
download_player_clients: str = "android_vr"
|
||||
download_player_clients: str = "tv_simply"
|
||||
download_player_clients_fallback: str = "web_safari,web,android"
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -24,7 +24,7 @@ from sqlalchemy.orm import Session
|
||||
from app.discover import cards, queries
|
||||
from app.models import Channel, ComfortPin, PlexItem, PlexShow, Playlist, User, Video
|
||||
from app.plex import rollup
|
||||
from app.plex.cards import leaf_card, show_card
|
||||
from app.plex.cards import SHOW_NEW, leaf_card, show_card
|
||||
from app.plex.lookups import by_rating_key
|
||||
|
||||
# kind -> the ComfortPin column that holds it. The client's vocabulary and the schema's, in one place.
|
||||
@@ -203,8 +203,8 @@ def _shelf_card(db: Session, user: User, pin: ComfortPin, batch: _Batch, lang: s
|
||||
sh = db.get(PlexShow, pin.plex_show_id)
|
||||
if sh is None:
|
||||
return None
|
||||
status = rollup.state_map(db, user.id, [sh.id]).get(sh.id, "new")
|
||||
card = cards.from_plex(show_card(sh, status, lang=lang))
|
||||
state = rollup.state_map(db, user.id, [sh.id]).get(sh.id, SHOW_NEW)
|
||||
card = cards.from_plex(show_card(sh, state, lang=lang))
|
||||
ep = queries.next_episode(db, user.id, sh.id, lang=lang)
|
||||
nxt = cards.from_plex(ep) if ep else None
|
||||
|
||||
|
||||
@@ -256,16 +256,20 @@ def plex_in_progress(
|
||||
if show_libs:
|
||||
prog = rollup.agg_subquery(db, uid, include_resumed=True).subquery()
|
||||
sq = apply_show_state_filter(
|
||||
select(PlexShow, prog.c.resumed_at).join(prog, prog.c.show_id == PlexShow.id),
|
||||
select(PlexShow, prog.c.resumed_at, prog.c.total, prog.c.watched, prog.c.inprog).join(
|
||||
prog, prog.c.show_id == PlexShow.id
|
||||
),
|
||||
prog.c.total,
|
||||
prog.c.watched,
|
||||
prog.c.inprog,
|
||||
"in_progress",
|
||||
).where(PlexShow.library_id.in_(show_libs))
|
||||
for sh, resumed_at in db.execute(
|
||||
for sh, resumed_at, total, watched, inprog in db.execute(
|
||||
sq.order_by(prog.c.resumed_at.desc().nullslast()).limit(limit)
|
||||
).all():
|
||||
out.append((resumed_at, show_card(sh, "in_progress", lang=lang)))
|
||||
out.append(
|
||||
(resumed_at, show_card(sh, rollup.show_state(total, watched, inprog), lang=lang))
|
||||
)
|
||||
|
||||
return out
|
||||
|
||||
|
||||
@@ -106,7 +106,8 @@ class YouTubeAdapter(SourceAdapter):
|
||||
# `player_client=android_vr` yt-dlp logs `Skipping client "android_vr" since it does not support
|
||||
# cookies`, drops it, and the request that is left with no client fails with "Requested format is
|
||||
# not available" — which is already a retriable marker above, so the cost is a wasted attempt
|
||||
# rather than a hard failure. Our primary set is android_vr ALONE, so without this filter every
|
||||
# rather than a hard failure. Our primary set is tv_simply ALONE, which cannot carry cookies either
|
||||
# (measured on yt-dlp 2026.08.19: `SUPPORTS_COOKIES` False), so without this filter every
|
||||
# signed-in download would burn its first attempt that way before reaching the POT-backed web
|
||||
# fallback the cookie exists for. The list is yt-dlp's own INNERTUBE_CLIENTS[...]
|
||||
# ["SUPPORTS_COOKIES"], pinned by test_adapters against the installed yt-dlp, so a version bump
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
"""Ad-hoc clips: a marked range of a YouTube video or a Plex item, cut into a new per-user asset.
|
||||
|
||||
A clip never needs the whole source first. A YouTube clip fetches only its range (yt-dlp's
|
||||
`download_ranges`; measured: 3-7 s whether the video runs four minutes or an hour, and the fetched
|
||||
range starts on the marked frame), and a Plex clip reads the library file in place. Either way the
|
||||
range then goes through ONE re-encode built here, so every clip has the same shape whatever its
|
||||
source: frame-exact at both ends, H.264 at most 1080p (an HDR source tonemapped to SDR — kept as
|
||||
10-bit PQ it played washed out), stereo AAC, in an .mp4 that plays on any phone.
|
||||
|
||||
A clip may also crop the picture to a frame the user drew over the playing video. The frame is
|
||||
given in fractions of the box it was drawn in, with that box's aspect: the YouTube iframe never
|
||||
tells its picture's size, so the frame there is drawn over the player's 16:9 box (a short's 9:16),
|
||||
and the cut maps it onto the real picture (`crop_filter`) — what lies over a letterbox bar is dropped.
|
||||
|
||||
The spec is stored on the job (`DownloadJob.edit_spec`) and is the clip's whole identity: its
|
||||
signature is the asset's cache key, so marking the same range again is an instant cache hit, and a
|
||||
different range or audio track is a different clip.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from app.downloads.edit import AAC_ARGS, EVEN_FRAME, INPUT_GUARD, X264_ARGS
|
||||
from app.plex.sync import HDR_TRANSFERS
|
||||
|
||||
SOURCES = ("youtube", "plex")
|
||||
# Owner's cap: a clip is a moment, not a way to export a whole film to a public link.
|
||||
MAX_CLIP_S = 600
|
||||
MIN_CLIP_S = 1
|
||||
MAX_HEIGHT = 1080
|
||||
# What yt-dlp fetches the range in (formats.normalize fills the rest): the clip's own ceiling, so the
|
||||
# re-encode never upscales and never throws away a better source than it keeps.
|
||||
YOUTUBE_FETCH_SPEC = {"mode": "av", "max_height": MAX_HEIGHT, "container": "mp4"}
|
||||
# Bump when the spec → output-bytes mapping changes, so a cached clip re-derives.
|
||||
_SIG_VERSION = 1
|
||||
|
||||
_YOUTUBE_ID = re.compile(r"[A-Za-z0-9_-]{11}")
|
||||
_RATING_KEY = re.compile(r"[0-9]{1,32}")
|
||||
_MAX_AUDIO_TRACK = 63
|
||||
# HDR → SDR as measured on the owner's 4K HDR files (2026-10-10): linearise, tonemap (hable),
|
||||
# convert the primaries and transfer to BT.709 and back to 8-bit 4:2:0.
|
||||
_TONEMAP = (
|
||||
"zscale=t=linear:npl=100,format=gbrpf32le,zscale=p=bt709,tonemap=hable:desat=0,"
|
||||
"zscale=t=bt709:m=bt709:r=tv,format=yuv420p"
|
||||
)
|
||||
_SCALE = f"scale=-2:'min({MAX_HEIGHT},ih)'"
|
||||
# A crop frame is at least this share of its box on each side (the player's frame holds the same
|
||||
# floor), and what is left of it on the real picture at least _MIN_VISIBLE of it.
|
||||
MIN_CROP = 0.1
|
||||
_MIN_VISIBLE = 0.05
|
||||
# The box aspects a frame may be drawn in: a 9:16 short to a 21:9 film, with room either side.
|
||||
_CROP_ASPECTS = (0.25, 4.0)
|
||||
# Float slack on the frame's far edges (x + w, y + h), which the client rounds to 4 decimals.
|
||||
_EDGE_SLACK = 1e-3
|
||||
|
||||
|
||||
class ClipError(Exception):
|
||||
"""A clip request that cannot be cut; `reason` is an i18n-mappable key, `params` the numbers its
|
||||
message names (the cap itself, so the client never says a stale one)."""
|
||||
|
||||
def __init__(self, reason: str, **params: float):
|
||||
self.reason = reason
|
||||
self.params = params
|
||||
super().__init__(reason)
|
||||
|
||||
|
||||
def normalize(
|
||||
source: str,
|
||||
ref: str,
|
||||
start_s: float,
|
||||
end_s: float,
|
||||
audio_track: int | None = None,
|
||||
crop: dict | None = None,
|
||||
) -> dict:
|
||||
"""The validated, canonical clip spec. Times are rounded to the millisecond and a crop to four
|
||||
decimals, so the same marks always give the same cache key. Raises ClipError."""
|
||||
if source not in SOURCES:
|
||||
raise ClipError("bad_source")
|
||||
pattern = _YOUTUBE_ID if source == "youtube" else _RATING_KEY
|
||||
if not isinstance(ref, str) or not pattern.fullmatch(ref):
|
||||
raise ClipError("bad_source")
|
||||
if not (math.isfinite(start_s) and math.isfinite(end_s)) or start_s < 0:
|
||||
raise ClipError("bad_range")
|
||||
start, end = round(start_s, 3), round(end_s, 3)
|
||||
if end - start < MIN_CLIP_S:
|
||||
raise ClipError("too_short")
|
||||
if end - start > MAX_CLIP_S:
|
||||
raise ClipError("too_long", limit_s=MAX_CLIP_S)
|
||||
spec: dict = {"source": source, "ref": ref, "start_s": start, "end_s": end}
|
||||
if source == "plex" and audio_track is not None:
|
||||
if not 0 <= audio_track <= _MAX_AUDIO_TRACK:
|
||||
raise ClipError("bad_audio")
|
||||
spec["audio_track"] = audio_track
|
||||
frame = _crop(crop)
|
||||
if frame is not None:
|
||||
spec["crop"] = frame
|
||||
return spec
|
||||
|
||||
|
||||
def _crop(raw: dict | None) -> dict | None:
|
||||
"""A crop frame, canonical — or None for no crop, which a frame over the whole box also is (the
|
||||
same clip as one cut without a frame, so the same cache key)."""
|
||||
if raw is None:
|
||||
return None
|
||||
try:
|
||||
x, y, w, h, aspect = (float(raw[k]) for k in ("x", "y", "w", "h", "aspect"))
|
||||
except (KeyError, TypeError, ValueError) as e:
|
||||
raise ClipError("bad_crop") from e
|
||||
if not all(math.isfinite(v) for v in (x, y, w, h, aspect)):
|
||||
raise ClipError("bad_crop")
|
||||
lo, hi = _CROP_ASPECTS
|
||||
if not lo <= aspect <= hi or x < 0 or y < 0:
|
||||
raise ClipError("bad_crop")
|
||||
if w < MIN_CROP - _EDGE_SLACK or h < MIN_CROP - _EDGE_SLACK:
|
||||
raise ClipError("bad_crop")
|
||||
if x + w > 1 + _EDGE_SLACK or y + h > 1 + _EDGE_SLACK:
|
||||
raise ClipError("bad_crop")
|
||||
frame = {"x": x, "y": y, "w": min(w, 1 - x), "h": min(h, 1 - y), "aspect": aspect}
|
||||
frame = {k: round(v, 4) for k, v in frame.items()}
|
||||
if (frame["x"], frame["y"], frame["w"], frame["h"]) == (0, 0, 1, 1):
|
||||
return None
|
||||
return frame
|
||||
|
||||
|
||||
def duration(spec: dict) -> float:
|
||||
return spec["end_s"] - spec["start_s"]
|
||||
|
||||
|
||||
def sig(spec: dict) -> str:
|
||||
"""The clip's cache signature: the spec itself, versioned."""
|
||||
body = json.dumps({**spec, "_v": _SIG_VERSION}, sort_keys=True, separators=(",", ":"))
|
||||
return hashlib.sha256(body.encode()).hexdigest()[:24]
|
||||
|
||||
|
||||
def asset_ref(user_id: int, spec: dict) -> str:
|
||||
"""The asset's `source_ref`. The user id makes a clip private to whoever cut it, like an edit:
|
||||
never dedup-shared, never cache-served to someone else."""
|
||||
return f"{user_id}:{spec['source']}:{spec['ref']}"
|
||||
|
||||
|
||||
def reference_url(spec: dict) -> str | None:
|
||||
"""Where a YouTube clip came from: its video at the clip's start. A Plex clip has no public page."""
|
||||
if spec.get("source") != "youtube" or not spec.get("ref"):
|
||||
return None
|
||||
return f"https://www.youtube.com/watch?v={spec['ref']}&t={int(spec.get('start_s') or 0)}s"
|
||||
|
||||
|
||||
def is_hdr(probe: dict) -> bool:
|
||||
"""Whether the first video stream of an ffprobe `-show_streams` JSON carries an HDR transfer."""
|
||||
for s in probe.get("streams") or []:
|
||||
if s.get("codec_type") == "video":
|
||||
# PQ / HLG: an 8-bit SDR encode must tonemap them (the one set the Plex stream uses).
|
||||
return s.get("color_transfer") in HDR_TRANSFERS
|
||||
return False
|
||||
|
||||
|
||||
def display_aspect(probe: dict) -> float | None:
|
||||
"""The picture's shape as a player shows it, from an ffprobe `-show_streams` JSON: the first video
|
||||
stream's width × its sample aspect ratio over its height, turned a quarter when the stream says
|
||||
it is rotated (ffmpeg turns such a stream upright before any filter of ours, so a crop is cut
|
||||
from the upright picture). None when the stream has no size."""
|
||||
for s in probe.get("streams") or []:
|
||||
if s.get("codec_type") != "video":
|
||||
continue
|
||||
w, h = s.get("width"), s.get("height")
|
||||
if not isinstance(w, int) or not isinstance(h, int) or w <= 0 or h <= 0:
|
||||
return None
|
||||
dar = w * (_ratio(s.get("sample_aspect_ratio")) or 1.0) / h
|
||||
return 1 / dar if _quarter_turned(s) else dar
|
||||
return None
|
||||
|
||||
|
||||
def _ratio(v: object) -> float | None:
|
||||
"""'32:27' → 1.185…; '0:1', 'N/A', a missing or malformed value → None."""
|
||||
if not isinstance(v, str) or ":" not in v:
|
||||
return None
|
||||
num, _, den = v.partition(":")
|
||||
try:
|
||||
n, d = float(num), float(den)
|
||||
except ValueError: # silent-ok: a malformed ratio reads as none given; the caller takes 1:1
|
||||
return None
|
||||
return n / d if n > 0 and d > 0 else None
|
||||
|
||||
|
||||
def _quarter_turned(stream: dict) -> bool:
|
||||
rotation: object = (stream.get("tags") or {}).get("rotate")
|
||||
for sd in stream.get("side_data_list") or []:
|
||||
if "rotation" in sd:
|
||||
rotation = sd["rotation"]
|
||||
try:
|
||||
return round(float(str(rotation))) % 180 == 90
|
||||
except ValueError: # silent-ok: no (None) or a malformed rotation: the stream is upright
|
||||
return False
|
||||
|
||||
|
||||
def crop_filter(crop: dict, dar: float) -> str | None:
|
||||
"""The ffmpeg `crop` for a frame drawn in a box of `crop["aspect"]`, over a picture of display
|
||||
aspect `dar` fitted into that box as a player fits it (whole, centred, bars on the short sides).
|
||||
The frame's part over a bar is dropped: "" when what is left is the whole picture, None when too
|
||||
little of the frame lies on the picture at all.
|
||||
|
||||
In fractions of the input's own width and height, so a sample aspect ratio other than 1:1 (an
|
||||
anamorphic DVD) needs nothing of its own, and floored to even pixels: an even 4:2:0 frame, and a
|
||||
window that never runs past the picture's far edge."""
|
||||
box = crop["aspect"]
|
||||
pic_w, pic_h = (1.0, box / dar) if dar > box else (dar / box, 1.0)
|
||||
|
||||
def span(start: float, size: float, extent: float) -> tuple[float, float]:
|
||||
origin = (1 - extent) / 2
|
||||
a = min(1.0, max(0.0, (start - origin) / extent))
|
||||
b = min(1.0, max(0.0, (start + size - origin) / extent))
|
||||
return a, b - a
|
||||
|
||||
x, w = span(crop["x"], crop["w"], pic_w)
|
||||
y, h = span(crop["y"], crop["h"], pic_h)
|
||||
if w < _MIN_VISIBLE or h < _MIN_VISIBLE:
|
||||
return None
|
||||
if w > 1 - 1e-4 and h > 1 - 1e-4:
|
||||
return ""
|
||||
|
||||
def px(axis: str, f: float) -> str:
|
||||
return f"trunc({axis}*{f:.6f}/2)*2"
|
||||
|
||||
return f"crop={px('iw', w)}:{px('ih', h)}:{px('iw', x)}:{px('ih', y)}"
|
||||
|
||||
|
||||
def audio_track_count(probe: dict) -> int:
|
||||
return sum(1 for s in probe.get("streams") or [] if s.get("codec_type") == "audio")
|
||||
|
||||
|
||||
def build_cmd(
|
||||
src: Path,
|
||||
dest: Path,
|
||||
start_s: float,
|
||||
dur_s: float,
|
||||
*,
|
||||
audio_track: int | None,
|
||||
hdr: bool,
|
||||
threads: int,
|
||||
crop: str = "",
|
||||
) -> list[str]:
|
||||
"""ffmpeg for one clip: an input seek (decodes from the keyframe before `start_s` and drops the
|
||||
frames up to it, so the clip starts on the marked frame), the first video stream, the chosen audio
|
||||
track (the first when none was chosen; none at all is fine), no subtitles, data or chapters.
|
||||
A `crop` (crop_filter) comes first: the tonemap and the scale then work on the frame it keeps."""
|
||||
vf = ",".join([*([crop] if crop else []), *([_TONEMAP] if hdr else []), _SCALE, EVEN_FRAME])
|
||||
audio = f"0:a:{audio_track}" if audio_track is not None else "0:a:0?"
|
||||
return [
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-nostdin",
|
||||
"-progress",
|
||||
"pipe:1",
|
||||
"-nostats",
|
||||
"-ss",
|
||||
f"{start_s:.3f}",
|
||||
*INPUT_GUARD,
|
||||
"-i",
|
||||
str(src),
|
||||
"-t",
|
||||
f"{dur_s:.3f}",
|
||||
"-map",
|
||||
"0:v:0",
|
||||
"-map",
|
||||
audio,
|
||||
"-sn",
|
||||
"-dn",
|
||||
# A source's chapters would ride into the .mp4 as a data track of their own (an mkv's
|
||||
# chapter list came out as `bin_data`); a clip of a moment has no chapters to keep.
|
||||
"-map_chapters",
|
||||
"-1",
|
||||
"-vf",
|
||||
vf,
|
||||
*X264_ARGS,
|
||||
"-threads",
|
||||
str(threads),
|
||||
*AAC_ARGS,
|
||||
"-ac",
|
||||
"2",
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
str(dest),
|
||||
]
|
||||
@@ -30,6 +30,7 @@ from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import logs
|
||||
from app.downloads import clip as clipmod
|
||||
from app.downloads import formats
|
||||
from app.downloads.adapters import _host_is
|
||||
from app.models import DownloadCredential, MediaAsset
|
||||
@@ -259,6 +260,28 @@ def usable_cookie(
|
||||
return None
|
||||
if not is_authed_asset(asset, spec, user_id, url):
|
||||
return None # shared asset — never attach a cookie to a non-private identity
|
||||
return _readable(cred, user_id, host_key)
|
||||
|
||||
|
||||
def usable_clip_cookie(
|
||||
db: Session, user_id: int, url: str | None, asset: MediaAsset, clip_spec: dict
|
||||
) -> str | None:
|
||||
"""`usable_cookie` for an ad-hoc clip's range fetch. The second gate is the clip's own identity:
|
||||
a clip asset is private by construction (`clip.asset_ref` names its user, see downloads/clip),
|
||||
so a cookie goes with it exactly when that asset is THIS user's clip of `clip_spec`. Without it a
|
||||
video YouTube gates signed out could never be clipped, while its download goes through."""
|
||||
host_key = host_key_for_url(url)
|
||||
if host_key is None:
|
||||
return None
|
||||
cred = get_credential(db, user_id, host_key)
|
||||
if cred is None:
|
||||
return None
|
||||
if asset.source_kind != "clip" or asset.source_ref != clipmod.asset_ref(user_id, clip_spec):
|
||||
return None
|
||||
return _readable(cred, user_id, host_key)
|
||||
|
||||
|
||||
def _readable(cred: DownloadCredential, user_id: int, host_key: str) -> str | None:
|
||||
raw = decrypt_optional(cred.cookies_enc)
|
||||
if not raw:
|
||||
log.warning(
|
||||
@@ -284,6 +307,14 @@ def resolve_cookiefile(
|
||||
return _write_cookiefile(raw, staging) if raw else None
|
||||
|
||||
|
||||
def resolve_clip_cookiefile(
|
||||
db: Session, user_id: int, url: str | None, asset: MediaAsset, clip_spec: dict, staging: Path
|
||||
) -> Path | None:
|
||||
"""`resolve_cookiefile` for an ad-hoc clip (`usable_clip_cookie`)."""
|
||||
raw = usable_clip_cookie(db, user_id, url, asset, clip_spec)
|
||||
return _write_cookiefile(raw, staging) if raw else None
|
||||
|
||||
|
||||
def _write_cookiefile(raw: str, staging: Path) -> Path:
|
||||
"""Write the cookie blob to a private 0600 file under `staging/.auth/` and return its path.
|
||||
|
||||
|
||||
@@ -37,9 +37,10 @@ from app.downloads.credentials import host_key_for_url
|
||||
|
||||
# Worker-authored strings that this module owns because each is coupled to `_RULES`.
|
||||
#
|
||||
# QUOTA_FULL_MESSAGE is raised by the worker and must classify back to `quota_exceeded`; its rule
|
||||
# below matches a fragment of it, and test_download_errors pins the pair, because a message and its
|
||||
# rule drifting apart is exactly how a category silently becomes unreachable.
|
||||
# QUOTA_FULL_MESSAGE and the three *_TOO_BIG_MESSAGEs are raised by the worker and must classify
|
||||
# back to `quota_exceeded`; their rules below match a fragment of each, and test_download_errors pins
|
||||
# each one, because a message and its rule drifting apart is exactly how a
|
||||
# category silently becomes unreachable.
|
||||
#
|
||||
# NETWORK_REDACTED_MESSAGE REPLACES a message after classification (`user_error_text`). It is written
|
||||
# to the shared asset too, and a second job that inherits that failed asset re-diagnoses the stored
|
||||
@@ -53,6 +54,19 @@ from app.downloads.credentials import host_key_for_url
|
||||
# constants, imported above; each has a rule below that gives its category back, and a catalog entry
|
||||
# that gives the site's sentence.
|
||||
QUOTA_FULL_MESSAGE = "Your download storage is full — free up space, then try again."
|
||||
# A download, a cut clip, or an upload's converted file, held to the space left, which is not zero:
|
||||
# "full" would be untrue. All three classify by the one phrase they share.
|
||||
DOWNLOAD_TOO_BIG_MESSAGE = (
|
||||
"The video is larger than the download storage you have left — free up space, then try again."
|
||||
)
|
||||
CLIP_TOO_BIG_MESSAGE = (
|
||||
"The clip is larger than the download storage you have left — "
|
||||
"free up space or cut a shorter clip, then try again."
|
||||
)
|
||||
CONVERTED_TOO_BIG_MESSAGE = (
|
||||
"The converted file is larger than the download storage you have left — "
|
||||
"free up space, then try again."
|
||||
)
|
||||
UPLOAD_MISSING_MESSAGE = "The uploaded file is missing."
|
||||
NETWORK_REDACTED_MESSAGE = "The server could not reach that address."
|
||||
|
||||
@@ -62,6 +76,7 @@ _RULES: tuple[tuple[str, str], ...] = (
|
||||
# Our own refusal: the worker found the user already at their storage cap (SEC-13). Placed
|
||||
# first — it is our wording, so it can never be a false match on an upstream message.
|
||||
("download storage is full", "quota_exceeded"),
|
||||
("larger than the download storage you have left", "quota_exceeded"),
|
||||
# Our own redaction of a transport failure (see NETWORK_REDACTED_MESSAGE) — our wording too.
|
||||
(NETWORK_REDACTED_MESSAGE.lower(), "network"),
|
||||
# Our own verdicts on a post link (adapters.py) — our wording too: its page holds no single video,
|
||||
|
||||
@@ -105,6 +105,28 @@ def log_safe(text: object) -> str:
|
||||
return _PATH_RE.sub("…", text)
|
||||
|
||||
|
||||
# yt-dlp's HTTP downloader, refusing a file announced larger than `max_filesize`, logs this line and
|
||||
# RETURNS — no exception and no file (measured; test_formats holds it against the pinned yt-dlp).
|
||||
_OVER_CAP_LINE = "File is larger than max-filesize"
|
||||
_OVER_CAP: contextvars.ContextVar[list[bool] | None] = contextvars.ContextVar(
|
||||
"formats_over_cap", default=None
|
||||
)
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def watching_size_cap() -> Iterator[list[bool]]:
|
||||
"""While yt-dlp works inside it: a one-slot flag, set when it refused a file over the size cap
|
||||
(`build_ydl_opts(max_bytes=…)`). It then returns as if it had downloaded, and all the caller
|
||||
would find is no file — a processing fault, to anyone reading it, for what is the owner's
|
||||
storage. A context variable, like `hiding`: yt-dlp's logger calls run on the extraction's thread."""
|
||||
flag = [False]
|
||||
token = _OVER_CAP.set(flag)
|
||||
try:
|
||||
yield flag
|
||||
finally:
|
||||
_OVER_CAP.reset(token)
|
||||
|
||||
|
||||
class _YdlLog:
|
||||
"""yt-dlp's logger protocol, pointed at ours.
|
||||
|
||||
@@ -124,6 +146,8 @@ class _YdlLog:
|
||||
is the one line whose level should say so."""
|
||||
|
||||
def _emit(self, level: int, msg: str) -> None:
|
||||
if _OVER_CAP_LINE in msg and (over := _OVER_CAP.get()) is not None:
|
||||
over[0] = True
|
||||
# The line goes as an ARGUMENT, not as the format string. yt-dlp writes progress as
|
||||
# `[download] 53.2% of 12.00MiB`, and while stdlib logging only applies `%` formatting to
|
||||
# a record that has args, this way the question never arises — and it is the shape ruff's
|
||||
@@ -281,8 +305,11 @@ def build_ydl_opts(
|
||||
after the host-scope + private-asset gate in `downloads.credentials`. It is set AFTER the `extra`
|
||||
merge so a source adapter can never clobber it.
|
||||
|
||||
`max_bytes` (SEC-13) is the caller's remaining storage budget; yt-dlp refuses a format larger
|
||||
than it rather than filling the disk and only then being caught by the quota accounting."""
|
||||
`max_bytes` (SEC-13) is the caller's remaining storage budget; yt-dlp's HTTP downloader refuses
|
||||
a file announced larger than it rather than filling the disk and only then being caught by the
|
||||
quota accounting. Its ffmpeg downloader (a `download_ranges` fetch) does not read it, nor does
|
||||
a fragmented download (YouTube's formats: measured, 718 MB fetched with 1 MB left) — so the
|
||||
pipeline also holds every produced file to what is left by its real size."""
|
||||
s = normalize(spec)
|
||||
h = s["max_height"]
|
||||
opts: dict = {
|
||||
|
||||
@@ -7,10 +7,12 @@ from datetime import date, datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import yt_dlp
|
||||
from yt_dlp.utils import download_range_func
|
||||
|
||||
from app import logs, sysconfig
|
||||
from app.config import settings
|
||||
from app.db import SessionLocal
|
||||
from app.downloads import clip as clipmod
|
||||
from app.downloads import edit as editmod
|
||||
from app.downloads import errors as dlerrors
|
||||
from app.downloads import adapters, credentials, formats, mediakind, playable, probe, quota
|
||||
@@ -26,7 +28,9 @@ from app.downloads.jobdb import (
|
||||
)
|
||||
from app.downloads.stated import StatedFailure
|
||||
from app.downloads.status import JobStatus
|
||||
from app.models import DISPLAY_TEXT_MAX, DownloadJob, MediaAsset
|
||||
from app.models import DISPLAY_TEXT_MAX, DownloadJob, MediaAsset, PlexItem
|
||||
from app.plex import lookups as plex_lookups
|
||||
from app.plex import paths as plex_paths
|
||||
from app.notifications import create_notification
|
||||
from app.titles import normalize_title
|
||||
|
||||
@@ -37,6 +41,15 @@ class _Aborted(Exception):
|
||||
"""Raised inside the progress hook when the job was paused/canceled mid-download."""
|
||||
|
||||
|
||||
class _ClipFetchFailed(Exception):
|
||||
"""A clip's YouTube range fetch failed: its message and the download catalog's diagnosis, so a
|
||||
clip on YouTube's wall says what a download of the same video would."""
|
||||
|
||||
def __init__(self, msg: str, diagnosis: dlerrors.Diagnosis):
|
||||
super().__init__(msg)
|
||||
self.diagnosis = diagnosis
|
||||
|
||||
|
||||
class _OwnerRefused(Exception):
|
||||
"""A refusal that belongs to THIS job's owner, not to the source.
|
||||
|
||||
@@ -213,7 +226,7 @@ def _cancel_poll(job_id: int, interval_s: float = _CANCEL_POLL_INTERVAL_S, *, no
|
||||
|
||||
def cancelled() -> bool:
|
||||
if not state["cancelled"] and allow():
|
||||
state["cancelled"] = _job_status(job_id) not in ("running", None)
|
||||
state["cancelled"] = _job_status(job_id) not in (JobStatus.RUNNING, None)
|
||||
return state["cancelled"]
|
||||
|
||||
return cancelled
|
||||
@@ -333,7 +346,7 @@ def _media_info(info: dict) -> dict:
|
||||
|
||||
|
||||
def _extract_with_fallback(
|
||||
job_id, asset_id, url, spec, staging, adapter, cookiefile=None, max_bytes=None
|
||||
job_id, asset_id, url, spec, staging, adapter, cookiefile=None, max_bytes=None, section=None
|
||||
):
|
||||
"""Download via yt-dlp using the source `adapter`'s client sets + options. For YouTube the adapter
|
||||
supplies the primary player-client set then a POT-backed fallback (retried on a bot/DRM/format
|
||||
@@ -347,9 +360,16 @@ def _extract_with_fallback(
|
||||
download as itself.
|
||||
|
||||
`cookiefile` is the acting user's per-host cookies.txt, already gated by
|
||||
`credentials.resolve_cookiefile`; passed to every attempt so the fallback stays authenticated.
|
||||
`credentials.resolve_cookiefile` (a clip: `resolve_clip_cookiefile`); passed to every attempt so
|
||||
the fallback stays authenticated.
|
||||
|
||||
`max_bytes` (SEC-13) is the owner's remaining storage budget, applied to every attempt."""
|
||||
`max_bytes` (SEC-13) is the owner's remaining storage budget, applied to every attempt. A
|
||||
`section` fetch goes through yt-dlp's ffmpeg downloader, which does not read it: a clip holds
|
||||
its own output to the cap (`_process_clip`).
|
||||
|
||||
`section` = (start_s, end_s) fetches only that range (an ad-hoc clip, see downloads/clip): the
|
||||
range is stream-copied, and its first frame is the marked one (measured), so the clip's own
|
||||
re-encode starts at 0 instead of re-fetching keyframes at the cut."""
|
||||
client_sets = adapter.client_sets(authed=cookiefile is not None)
|
||||
outtmpl = str(staging / "%(id)s.%(ext)s")
|
||||
# Resolving may read a page first — network time the stall watchdog must not see as a stall.
|
||||
@@ -375,6 +395,9 @@ def _extract_with_fallback(
|
||||
max_bytes=max_bytes,
|
||||
)
|
||||
opts["postprocessor_hooks"] = [_make_pp_hook(job_id)]
|
||||
if section is not None:
|
||||
opts["download_ranges"] = download_range_func([], [section])
|
||||
opts["force_keyframes_at_cuts"] = False
|
||||
_touch_job(job_id) # stall-watchdog heartbeat: extraction writes no progress, so a slow /
|
||||
# multi-client-set fallback would otherwise cross the stall timeout with a stale updated_at.
|
||||
# (Per-ATTEMPT only; a single extract_info that itself runs past the stall timeout is still
|
||||
@@ -502,8 +525,8 @@ def _download(
|
||||
# SEC-13: what this user has left. `check_enqueue` only compared the footprint as it
|
||||
# stood when the job was QUEUED — a download's size is unknown until it finishes, so a
|
||||
# single job could previously blow through the cap by any amount. Handing the remainder
|
||||
# to yt-dlp as `max_filesize` makes the format selector refuse an oversized file up
|
||||
# front instead.
|
||||
# to yt-dlp as `max_filesize` makes it refuse an oversized file (its HTTP downloader
|
||||
# checks the announced size) instead.
|
||||
remaining_bytes = quota.remaining_bytes(cdb, user_id)
|
||||
# …and zero is a REFUSAL, not "no limit". Earlier jobs of this user may have filled the cap
|
||||
# while this one sat in the queue, and 0 is not an expressible yt-dlp `max_filesize`, so
|
||||
@@ -512,8 +535,8 @@ def _download(
|
||||
# free space, which is what the `quota_exceeded` category tells them.
|
||||
if remaining_bytes == 0:
|
||||
raise _OwnerRefused(dlerrors.QUOTA_FULL_MESSAGE)
|
||||
info = _media_info(
|
||||
_extract_with_fallback(
|
||||
with formats.watching_size_cap() as over_cap:
|
||||
fetched = _extract_with_fallback(
|
||||
job_id,
|
||||
asset_id,
|
||||
url,
|
||||
@@ -523,7 +546,11 @@ def _download(
|
||||
cookiefile=str(cookiefile) if cookiefile else None,
|
||||
max_bytes=remaining_bytes,
|
||||
)
|
||||
)
|
||||
info = _media_info(dict(fetched))
|
||||
# yt-dlp refused a file larger than what is left: it returns with no file, which would
|
||||
# otherwise read as a processing fault. The owner's storage, so the owner's refusal.
|
||||
if over_cap[0]:
|
||||
raise _OwnerRefused(dlerrors.DOWNLOAD_TOO_BIG_MESSAGE)
|
||||
# Re-resolve now that yt-dlp's info reveals the true source (a bare/redirected url only
|
||||
# identifies post-extraction) so the .nfo uniqueid type + uploader_url agree on one source.
|
||||
adapter = adapters.resolve_adapter(source_kind, source_ref, info)
|
||||
@@ -536,6 +563,13 @@ def _download(
|
||||
CONVERTING,
|
||||
int(info["duration"]) if info.get("duration") else None,
|
||||
)
|
||||
# Held to the cap by its real size, as a clip is: yt-dlp's `max_filesize` only stops a plain
|
||||
# HTTP file that announces its size, and YouTube's fragmented formats never meet it
|
||||
# (measured: 718 MB fetched with 1 MB left). Read again — other jobs may have landed.
|
||||
with SessionLocal() as qdb:
|
||||
room = quota.remaining_bytes(qdb, user_id)
|
||||
if room is not None and produced.stat().st_size > room:
|
||||
raise _OwnerRefused(dlerrors.DOWNLOAD_TOO_BIG_MESSAGE)
|
||||
ext = produced.suffix.lstrip(".").lower()
|
||||
# Codecs/dims/duration come from a PROBE of the produced file — the ground truth — NOT yt-dlp's
|
||||
# `info` dict, whose fields are absent for some sources (Facebook among them): deriving
|
||||
@@ -736,11 +770,18 @@ def _log_download_failed(job_id: int, msg: str, code: str | None, exc: BaseExcep
|
||||
|
||||
|
||||
def _fail_edit(
|
||||
job_id: int, asset_id: int, msg: str, exc: BaseException | None = None, *, what: str = "edit"
|
||||
job_id: int,
|
||||
asset_id: int,
|
||||
msg: str,
|
||||
exc: BaseException | None = None,
|
||||
*,
|
||||
what: str = "edit",
|
||||
diagnosis: dlerrors.Diagnosis | None = None,
|
||||
) -> None:
|
||||
"""Fail a local ffmpeg job — an edit, or an upload's conversion (`what="upload"`) — and say so
|
||||
ONCE: every way one fails comes through here, so this is where its line and its event code
|
||||
(`<what>.job.failed`) live. `exc` is the exception when there was one.
|
||||
"""Fail a local ffmpeg job — an edit, a clip, or an upload's conversion (`what="upload"`) — and
|
||||
say so ONCE: every way one fails comes through here, so this is where its line and its event code
|
||||
(`<what>.job.failed`) live. `exc` is the exception when there was one; `diagnosis` replaces the
|
||||
plain category when the failure was a fetch the download catalog can name (a clip's).
|
||||
|
||||
Its traceback goes with the line unless the message already tells it all (`_failure_stack`).
|
||||
Logged after the write, as on the download error path."""
|
||||
@@ -755,9 +796,12 @@ def _fail_edit(
|
||||
job_id,
|
||||
status=JobStatus.ERROR,
|
||||
error=msg,
|
||||
error_code=dlerrors.classify_conversion(msg)
|
||||
error_code=diagnosis.code
|
||||
if diagnosis
|
||||
else dlerrors.classify_conversion(msg)
|
||||
if what == "upload"
|
||||
else dlerrors.classify(msg),
|
||||
error_detail=diagnosis.detail if diagnosis else None,
|
||||
phase=None,
|
||||
)
|
||||
db.commit()
|
||||
@@ -834,6 +878,13 @@ def _process_edit(job_id: int, asset_id: int) -> None:
|
||||
# playable as its source: hold it to the same rule as every other ready file.
|
||||
dest_staging = _ensure_phone_safe(job_id, dest_staging, staging, "editing", total_s)
|
||||
out_ext = dest_staging.suffix.lstrip(".").lower() or out_ext
|
||||
# New storage on the owner's footprint, held to what is left by its real size, as a
|
||||
# download and a player clip are.
|
||||
if user_id is not None:
|
||||
with SessionLocal() as qdb:
|
||||
room = quota.remaining_bytes(qdb, user_id)
|
||||
if room is not None and dest_staging.stat().st_size > room:
|
||||
raise StatedFailure(dlerrors.CLIP_TOO_BIG_MESSAGE)
|
||||
|
||||
rel = storage.edit_rel_path(user_id, asset_id, out_ext)
|
||||
storage.place_file(dest_staging, settings.download_root, rel)
|
||||
@@ -896,6 +947,233 @@ def _process_edit(job_id: int, asset_id: int) -> None:
|
||||
shutil.rmtree(staging, ignore_errors=True)
|
||||
|
||||
|
||||
def _clip_room(user_id: int) -> int | None:
|
||||
"""What the clip's owner may still store (`quota.remaining_bytes`), refusing at zero — see
|
||||
`_download`: zero is a refusal, not "no limit"."""
|
||||
with SessionLocal() as db:
|
||||
remaining = quota.remaining_bytes(db, user_id)
|
||||
if remaining == 0:
|
||||
raise _OwnerRefused(dlerrors.QUOTA_FULL_MESSAGE)
|
||||
return remaining
|
||||
|
||||
|
||||
def _clip_source(
|
||||
job_id: int, asset_id: int, spec: dict, staging: Path, remaining: int | None
|
||||
) -> tuple[Path, float, str | None]:
|
||||
"""The file a clip is cut from, where in it the clip starts, and the source's title when only
|
||||
the fetch could tell it. A YouTube clip fetches ONLY its range into `staging` (so it starts at
|
||||
0 there); a Plex clip reads the library file in place."""
|
||||
if spec["source"] == "youtube":
|
||||
url = service.source_url("youtube", spec["ref"])
|
||||
cookiefile: Path | None = None
|
||||
with SessionLocal() as db:
|
||||
job = db.get(DownloadJob, job_id)
|
||||
asset = db.get(MediaAsset, asset_id)
|
||||
# Signed in when the user has a YouTube cookie, as their downloads are: YouTube gates
|
||||
# single videos signed out ("not a bot"), and the clip is theirs alone.
|
||||
if job and asset:
|
||||
cookiefile = credentials.resolve_clip_cookiefile(
|
||||
db, job.user_id, url, asset, spec, staging
|
||||
)
|
||||
_set_job(job_id, phase="downloading", progress=0)
|
||||
try:
|
||||
with formats.watching_size_cap() as over_cap:
|
||||
info = _extract_with_fallback(
|
||||
job_id,
|
||||
asset_id,
|
||||
url,
|
||||
formats.normalize(clipmod.YOUTUBE_FETCH_SPEC),
|
||||
staging,
|
||||
adapters.resolve_adapter("youtube", spec["ref"]),
|
||||
cookiefile=str(cookiefile) if cookiefile else None,
|
||||
max_bytes=remaining,
|
||||
section=(spec["start_s"], spec["end_s"]),
|
||||
)
|
||||
# Should yt-dlp's own cap ever refuse the range (see `_download`): the owner's storage.
|
||||
if over_cap[0]:
|
||||
raise _OwnerRefused(dlerrors.CLIP_TOO_BIG_MESSAGE)
|
||||
except (_Aborted, _OwnerRefused):
|
||||
raise
|
||||
except Exception as exc: # re-raised below with the download catalog's diagnosis
|
||||
msg = str(exc)[:500]
|
||||
raise _ClipFetchFailed(
|
||||
msg, dlerrors.diagnose(msg, url=url, cookie_attached=cookiefile is not None)
|
||||
) from exc
|
||||
finally:
|
||||
# Never leave a plaintext session cookie on disk past the fetch (see `_download`).
|
||||
if cookiefile is not None:
|
||||
cookiefile.unlink(missing_ok=True)
|
||||
info = _media_info(dict(info))
|
||||
title = normalize_title(adapters.resolve_adapter("youtube", spec["ref"]).title(info))
|
||||
return _produced_file(info, staging), 0.0, title
|
||||
with SessionLocal() as db:
|
||||
item = plex_lookups.by_rating_key(db, PlexItem, spec["ref"])
|
||||
src = plex_paths.local_media_path(db, item.file_path) if item else None
|
||||
if src is None:
|
||||
raise StatedFailure("The library file is not reachable from here.")
|
||||
return src, spec["start_s"], None
|
||||
|
||||
|
||||
def _clip_crop(spec: dict, data: dict) -> str:
|
||||
"""The clip's crop filter, from its frame and the picture the file really holds ("" for none)."""
|
||||
frame = spec.get("crop")
|
||||
if not frame:
|
||||
return ""
|
||||
dar = clipmod.display_aspect(data)
|
||||
if dar is None:
|
||||
raise StatedFailure("The picture's size could not be read, so the crop cannot be placed.")
|
||||
crop = clipmod.crop_filter(frame, dar)
|
||||
if crop is None:
|
||||
raise StatedFailure("The crop frame lies outside the picture.")
|
||||
return crop
|
||||
|
||||
|
||||
def _process_clip(job_id: int, asset_id: int) -> None:
|
||||
"""Cut an ad-hoc clip (downloads/clip): fetch or open its source, ONE re-encode to the clip
|
||||
shape, then into the user's library beside their edits."""
|
||||
with SessionLocal() as db:
|
||||
job = db.get(DownloadJob, job_id)
|
||||
if job is None:
|
||||
return
|
||||
spec = dict(job.edit_spec or {})
|
||||
user_id = job.user_id
|
||||
staging = _edit_staging_dir(asset_id)
|
||||
try:
|
||||
if staging.exists():
|
||||
shutil.rmtree(staging, ignore_errors=True)
|
||||
staging.mkdir(parents=True, exist_ok=True)
|
||||
# Refused before the fetch and the encode when the owner's storage is already full.
|
||||
remaining = _clip_room(user_id)
|
||||
src, start, fetched_title = _clip_source(job_id, asset_id, spec, staging, remaining)
|
||||
data = probe.probe_json(src)
|
||||
if not data:
|
||||
raise StatedFailure("The source file could not be read.")
|
||||
audio_track = spec.get("audio_track")
|
||||
tracks = clipmod.audio_track_count(data)
|
||||
if tracks == 0:
|
||||
# A silent source: the player still names its track 0, and the clip is silent too.
|
||||
audio_track = None
|
||||
elif audio_track is not None and audio_track >= tracks:
|
||||
raise StatedFailure("The chosen audio track is not in the file.")
|
||||
crop = _clip_crop(spec, data)
|
||||
total_s = clipmod.duration(spec)
|
||||
out = staging / "clip.mp4"
|
||||
_set_job(job_id, phase="editing", progress=0, speed_bps=None, eta_s=None)
|
||||
editmod.run_ffmpeg(
|
||||
clipmod.build_cmd(
|
||||
src,
|
||||
out,
|
||||
start,
|
||||
total_s,
|
||||
audio_track=audio_track,
|
||||
hdr=clipmod.is_hdr(data),
|
||||
threads=editmod.encode_thread_cap(),
|
||||
crop=crop,
|
||||
),
|
||||
total_s,
|
||||
on_progress=_ffmpeg_progress_writer(job_id, "editing"),
|
||||
should_cancel=_cancel_poll(job_id),
|
||||
)
|
||||
if not out.exists():
|
||||
raise StatedFailure("ffmpeg produced no output file")
|
||||
# The phone-safe shape by construction; still held to the rule every ready file meets.
|
||||
out = _ensure_phone_safe(job_id, out, staging, "editing", round(total_s))
|
||||
# Held to the cap by its real size: a range fetch passes yt-dlp's `max_filesize` to its
|
||||
# ffmpeg downloader, which never reads it, and a Plex clip fetches nothing at all. Read
|
||||
# again, since other jobs of this owner may have landed during the encode.
|
||||
remaining = _clip_room(user_id)
|
||||
if remaining is not None and out.stat().st_size > remaining:
|
||||
raise _OwnerRefused(dlerrors.CLIP_TOO_BIG_MESSAGE)
|
||||
ext = out.suffix.lstrip(".").lower() or "mp4"
|
||||
rel = storage.edit_rel_path(user_id, asset_id, ext)
|
||||
storage.place_file(out, settings.download_root, rel)
|
||||
final = storage.abs_path(settings.download_root, rel)
|
||||
vcodec, acodec, width, height, _ = probe.probe_media(final)
|
||||
_, retention_days = _download_settings()
|
||||
|
||||
with SessionLocal() as db:
|
||||
asset = db.get(MediaAsset, asset_id)
|
||||
if asset:
|
||||
asset.status = "ready"
|
||||
asset.rel_path = rel
|
||||
asset.size_bytes = final.stat().st_size if final.exists() else None
|
||||
asset.container = ext
|
||||
asset.vcodec, asset.acodec, asset.width, asset.height = (
|
||||
vcodec,
|
||||
acodec,
|
||||
width,
|
||||
height,
|
||||
)
|
||||
asset.duration_s = round(total_s)
|
||||
asset.media_kind = mediakind.derive(vcodec, acodec)
|
||||
# The clip's own frame as its poster: the link preview shows the moment, and a Plex
|
||||
# item has no public thumbnail at all.
|
||||
asset.poster_path = editmod.ensure_poster(asset, settings.download_root)
|
||||
asset.error = None
|
||||
asset.last_access_at = datetime.now(timezone.utc)
|
||||
asset.expires_at = datetime.now(timezone.utc) + timedelta(days=retention_days)
|
||||
fields: dict = {"status": JobStatus.DONE, "progress": 100, "phase": None}
|
||||
# A video outside the catalog was queued as "Clip (clip)": the fetch has its title now.
|
||||
placeholder = service.clip_title(None, None)
|
||||
if asset and fetched_title and asset.title == placeholder:
|
||||
named = service.clip_title(fetched_title, None)
|
||||
asset.title = named
|
||||
if job_row := db.get(DownloadJob, job_id):
|
||||
if job_row.display_name == placeholder:
|
||||
fields["display_name"] = named[:DISPLAY_TEXT_MAX]
|
||||
title = asset.title if asset else None
|
||||
finalized = service.finalize_if_running(db, job_id, **fields)
|
||||
db.commit()
|
||||
if finalized:
|
||||
_notify_download_done(db, user_id, title, job_id, kind="edit")
|
||||
log.info(
|
||||
"clip job %s done: asset %s",
|
||||
job_id,
|
||||
asset_id,
|
||||
extra=logs.event(
|
||||
"clip.job.done", job_id=job_id, asset_id=asset_id, source=spec["source"]
|
||||
),
|
||||
)
|
||||
except (_Aborted, editmod.EditAborted):
|
||||
# Paused/canceled mid-fetch or mid-encode: free the asset for a later retry.
|
||||
_reset_asset_pending(asset_id)
|
||||
log.info(
|
||||
"clip job %s aborted (pause/cancel)",
|
||||
job_id,
|
||||
extra=logs.event("clip.job.aborted", job_id=job_id),
|
||||
)
|
||||
except _OwnerRefused as exc:
|
||||
# Quota full: the job fails, the asset goes back to pending for a Retry once there is room
|
||||
# (the download's handling; see `_download`).
|
||||
msg = str(exc)[:500]
|
||||
with SessionLocal() as db:
|
||||
service.finalize_if_running(
|
||||
db,
|
||||
job_id,
|
||||
status=JobStatus.ERROR,
|
||||
error=msg,
|
||||
error_code=dlerrors.classify(msg),
|
||||
error_detail=None,
|
||||
phase=None,
|
||||
)
|
||||
db.commit()
|
||||
_reset_asset_pending(asset_id)
|
||||
log.info(
|
||||
"clip job %s refused for its owner: %s",
|
||||
job_id,
|
||||
formats.log_safe(msg),
|
||||
extra=logs.event("clip.job.refused", job_id=job_id),
|
||||
)
|
||||
except _ClipFetchFailed as exc:
|
||||
# silent-ok: not silent — `_fail_edit` is the report, with the fetch's diagnosis.
|
||||
_fail_edit(job_id, asset_id, str(exc), exc, what="clip", diagnosis=exc.diagnosis)
|
||||
except Exception as exc: # noqa: BLE001 — surface the failure, keep the worker alive
|
||||
# silent-ok: not silent — `_fail_edit` is the report, and it takes `exc` for its stack.
|
||||
_fail_edit(job_id, asset_id, str(exc)[:500], exc, what="clip")
|
||||
finally:
|
||||
shutil.rmtree(staging, ignore_errors=True)
|
||||
|
||||
|
||||
def _check_converted_fits(owner_id: int | None, old_size: int, new_size: int) -> None:
|
||||
"""Refuse a converted upload that would push its owner over the storage cap: the upload was
|
||||
charged at its original size, and a re-encode can come out larger. The original stays, counted,
|
||||
@@ -904,7 +1182,7 @@ def _check_converted_fits(owner_id: int | None, old_size: int, new_size: int) ->
|
||||
return
|
||||
with SessionLocal() as db:
|
||||
if not quota.fits_after_conversion(db, owner_id, old_size, new_size):
|
||||
raise StatedFailure(dlerrors.QUOTA_FULL_MESSAGE)
|
||||
raise StatedFailure(dlerrors.CONVERTED_TOO_BIG_MESSAGE)
|
||||
|
||||
|
||||
def _unplace(placed: Path | None) -> None:
|
||||
|
||||
@@ -15,7 +15,8 @@ the footprint as it stood when the job was created and earlier jobs may have fil
|
||||
That is a per-job ceiling, not an aggregate ledger: concurrent jobs of one user each read the same
|
||||
remainder (in-flight bytes are not `ready`, so `footprint` cannot see them), so a wave of
|
||||
max_concurrent downloads can still land over the cap — the deliberate trade, since a download's size
|
||||
is unknown until it finishes. Edit jobs do not consult it at all (tracked as SEC-19).
|
||||
is unknown until it finishes. CLIP jobs consult it before their fetch or encode and again with
|
||||
their output's real size before it is placed. Edit jobs do not consult it at all (tracked as SEC-19).
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
@@ -24,7 +25,7 @@ from sqlalchemy import and_, func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import sysconfig
|
||||
from app.downloads.status import JobStatus
|
||||
from app.downloads.status import JobKind, JobStatus
|
||||
from app.models import DownloadJob, DownloadQuota, MediaAsset
|
||||
|
||||
# Jobs a user is considered to be "holding" (occupy a job slot / footprint). Terminal failures
|
||||
@@ -66,7 +67,8 @@ def resolve(db: Session, user_id: int) -> QuotaLimits:
|
||||
# "at limit" checks — the worker's claim-window exclusion vs at_concurrency_limit — since a
|
||||
# 0-limit user is "capped" with zero running jobs, which the SQL exclusion can't see. Keep
|
||||
# them consistent by never letting the effective limit drop below 1. (To BLOCK a user's
|
||||
# downloads, suspend them or set max_jobs=0, not max_concurrent=0.)
|
||||
# downloads, suspend them — the worker leaves a suspended user's queued jobs unclaimed — or
|
||||
# set max_jobs=0, not max_concurrent=0.)
|
||||
return QuotaLimits(row.max_bytes, max(1, row.max_concurrent), row.max_jobs, row.unlimited)
|
||||
return QuotaLimits(
|
||||
max_bytes=sysconfig.get_int(db, "download_default_max_bytes"),
|
||||
@@ -86,7 +88,7 @@ def footprint(db: Session, user_id: int) -> int:
|
||||
DownloadJob.user_id == user_id,
|
||||
or_(
|
||||
DownloadJob.status.in_(_HOLDING),
|
||||
and_(DownloadJob.job_kind == "upload", DownloadJob.status == JobStatus.ERROR),
|
||||
and_(DownloadJob.job_kind == JobKind.UPLOAD, DownloadJob.status == JobStatus.ERROR),
|
||||
),
|
||||
DownloadJob.asset_id.is_not(None),
|
||||
)
|
||||
|
||||
@@ -22,6 +22,7 @@ from app.config import settings
|
||||
from app.db import SessionLocal
|
||||
from app.downloads import edit as editmod
|
||||
from app.downloads import formats
|
||||
from app.downloads.status import JobKind
|
||||
from app.models import DownloadJob
|
||||
|
||||
log = logs.get_logger(__name__)
|
||||
@@ -95,14 +96,16 @@ def _run(job_id, asset_id, job_kind, source_kind, source_ref, spec, user_id) ->
|
||||
(`cause=child_exit`)."""
|
||||
# Imported here (not at module top) so the child pays the yt-dlp import cost only when it has
|
||||
# real work, and to keep the import graph obvious: runner is a thin entry over the pipeline.
|
||||
from app.downloads.pipeline import _convert_upload, _download, _process_edit
|
||||
from app.downloads.pipeline import _convert_upload, _download, _process_clip, _process_edit
|
||||
|
||||
with formats.hiding(source_ref):
|
||||
try:
|
||||
if job_kind == "edit":
|
||||
if job_kind == JobKind.EDIT:
|
||||
_process_edit(job_id, asset_id)
|
||||
elif job_kind == "upload":
|
||||
elif job_kind == JobKind.UPLOAD:
|
||||
_convert_upload(job_id, asset_id)
|
||||
elif job_kind == JobKind.CLIP:
|
||||
_process_clip(job_id, asset_id)
|
||||
else:
|
||||
_download(job_id, asset_id, source_kind, source_ref, spec, user_id)
|
||||
except Exception as exc: # noqa: BLE001 — whatever it is, it is reported here or raw
|
||||
|
||||
@@ -16,11 +16,22 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import settings
|
||||
from app.downloads import adapters
|
||||
from app.downloads import clip as clipmod
|
||||
from app.downloads import credentials
|
||||
from app.downloads import edit as editmod
|
||||
from app.downloads import formats, mediakind, quota, storage
|
||||
from app.downloads.status import HOLDING, JobStatus
|
||||
from app.models import DISPLAY_TEXT_MAX, Channel, DownloadJob, MediaAsset, Video
|
||||
from app.downloads.status import HOLDING, JobKind, JobStatus
|
||||
from app.models import (
|
||||
DISPLAY_TEXT_MAX,
|
||||
Channel,
|
||||
DownloadJob,
|
||||
MediaAsset,
|
||||
PlexItem,
|
||||
PlexShow,
|
||||
Video,
|
||||
)
|
||||
from app.plex import lookups as plex_lookups
|
||||
from app.plex import paths as plex_paths
|
||||
|
||||
|
||||
class EditError(Exception):
|
||||
@@ -40,10 +51,13 @@ def source_url(source_kind: str, source_ref: str) -> str:
|
||||
def reference_url(job, asset) -> str | None:
|
||||
"""The clean "downloaded from" URL for a job — shared by the private Downloads page and the public
|
||||
watch page so both surfaces always agree. None for edit derivatives (a clip's source is the user's
|
||||
own earlier download, not a web page); else the canonical page URL yt-dlp recorded; else one derived
|
||||
from source_kind+source_ref (covers queued/older rows before the worker fills it)."""
|
||||
if job is None or job.job_kind == "edit":
|
||||
own earlier download, not a web page) and for a Plex clip (the library is not a public page); a
|
||||
YouTube clip links its video at the clip's start; else the canonical page URL yt-dlp recorded; else
|
||||
one derived from source_kind+source_ref (covers queued/older rows before the worker fills it)."""
|
||||
if job is None or job.job_kind == JobKind.EDIT:
|
||||
return None
|
||||
if job.job_kind == JobKind.CLIP:
|
||||
return clipmod.reference_url(job.edit_spec or {})
|
||||
if asset is not None and asset.source_webpage_url:
|
||||
return asset.source_webpage_url
|
||||
if job.source_kind in ("youtube", "url"):
|
||||
@@ -180,7 +194,7 @@ def enqueue(
|
||||
profile_snapshot=spec,
|
||||
display_name=display_name
|
||||
or (asset.title[:DISPLAY_TEXT_MAX] if asset.status == "ready" and asset.title else None),
|
||||
status="queued",
|
||||
status=JobStatus.QUEUED,
|
||||
queue_pos=next_queue_pos(db),
|
||||
)
|
||||
db.add(job)
|
||||
@@ -188,7 +202,7 @@ def enqueue(
|
||||
|
||||
# Cache hit: the file already exists — no worker round-trip needed.
|
||||
if asset.status == "ready":
|
||||
job.status = "done"
|
||||
job.status = JobStatus.DONE
|
||||
job.progress = 100
|
||||
asset.last_access_at = datetime.now(timezone.utc)
|
||||
|
||||
@@ -197,7 +211,7 @@ def enqueue(
|
||||
return job
|
||||
|
||||
|
||||
def _clip_title(source_title: str | None, display_name: str | None) -> str:
|
||||
def clip_title(source_title: str | None, display_name: str | None) -> str:
|
||||
# Clipped to DISPLAY_TEXT_MAX, not to a literal: this feeds `DownloadJob.display_name` through
|
||||
# `enqueue_edit`, the same column `display_text` guards. A machine-made title, so it is clipped
|
||||
# rather than run through `display_text` — blank here means "no title", not None.
|
||||
@@ -239,7 +253,7 @@ def enqueue_edit(
|
||||
ref = f"{user_id}:{src_asset.id}"[:512]
|
||||
asset = get_or_create_asset(db, "edit", ref, sig)
|
||||
if not asset.title:
|
||||
asset.title = _clip_title(src_asset.title, display_name)
|
||||
asset.title = clip_title(src_asset.title, display_name)
|
||||
asset.uploader = src_asset.uploader
|
||||
asset.thumbnail_url = src_asset.thumbnail_url
|
||||
asset.upload_date = src_asset.upload_date
|
||||
@@ -251,7 +265,7 @@ def enqueue_edit(
|
||||
job = DownloadJob(
|
||||
user_id=user_id,
|
||||
asset_id=asset.id,
|
||||
job_kind="edit",
|
||||
job_kind=JobKind.EDIT,
|
||||
source_kind="edit",
|
||||
source_ref=ref,
|
||||
source_job_id=source_job.id,
|
||||
@@ -259,14 +273,101 @@ def enqueue_edit(
|
||||
edit_spec=spec,
|
||||
profile_snapshot={},
|
||||
display_name=display_name or asset.title[:DISPLAY_TEXT_MAX],
|
||||
status="queued",
|
||||
status=JobStatus.QUEUED,
|
||||
queue_pos=next_queue_pos(db),
|
||||
)
|
||||
db.add(job)
|
||||
incr_ref_count(db, asset.id)
|
||||
|
||||
if asset.status == "ready": # identical clip already produced for this user → instant
|
||||
job.status = "done"
|
||||
job.status = JobStatus.DONE
|
||||
job.progress = 100
|
||||
asset.last_access_at = datetime.now(timezone.utc)
|
||||
|
||||
db.commit()
|
||||
db.refresh(job)
|
||||
return job
|
||||
|
||||
|
||||
def _clip_source_facts(db: Session, spec: dict) -> dict:
|
||||
"""Title, uploader, thumbnail and length of what a clip is cut from, read from our own catalog.
|
||||
Raises ClipError when a Plex item is unknown or its file cannot be read here — said at enqueue,
|
||||
rather than as a job that fails in the queue a minute later."""
|
||||
if spec["source"] == "youtube":
|
||||
video = db.get(Video, spec["ref"])
|
||||
if video is None: # not in the catalog: titled "Clip" until yt-dlp names it (worker)
|
||||
return {"title": None, "uploader": None, "thumbnail_url": None, "length_s": None}
|
||||
channel = db.get(Channel, video.channel_id)
|
||||
return {
|
||||
"title": video.title,
|
||||
"uploader": channel.title if channel else None,
|
||||
"thumbnail_url": video.thumbnail_url,
|
||||
"length_s": video.duration_seconds,
|
||||
}
|
||||
item = plex_lookups.by_rating_key(db, PlexItem, spec["ref"])
|
||||
if item is None:
|
||||
raise clipmod.ClipError("not_found")
|
||||
if plex_paths.local_media_path(db, item.file_path) is None:
|
||||
raise clipmod.ClipError("media_missing")
|
||||
title = item.title
|
||||
show = db.get(PlexShow, item.show_id) if item.show_id else None
|
||||
if show is not None:
|
||||
episode = (
|
||||
f"S{item.season_number}·E{item.episode_number}"
|
||||
if item.season_number is not None and item.episode_number is not None
|
||||
else None
|
||||
)
|
||||
title = " — ".join(p for p in (show.title, episode, item.title) if p)
|
||||
return {
|
||||
"title": title,
|
||||
"uploader": None,
|
||||
# Plex art is served through our own authenticated routes, never a public URL; the worker
|
||||
# cuts the clip's poster from the clip itself.
|
||||
"thumbnail_url": None,
|
||||
"length_s": item.duration_s,
|
||||
}
|
||||
|
||||
|
||||
def enqueue_clip(
|
||||
db: Session, user_id: int, spec: dict, display_name: str | None = None
|
||||
) -> DownloadJob:
|
||||
"""Queue an ad-hoc clip (`clipmod.normalize`d spec) for `user_id`: a NEW per-user asset cut
|
||||
straight from the source — a YouTube range or a Plex library file (see downloads/clip).
|
||||
|
||||
Raises ClipError (unknown or unreachable source, a range past its end) or quota.QuotaExceeded."""
|
||||
facts = _clip_source_facts(db, spec)
|
||||
# One second of grace: a mark set on the last frame may round past the catalog's whole seconds.
|
||||
if facts["length_s"] and spec["end_s"] > facts["length_s"] + 1:
|
||||
raise clipmod.ClipError("past_end")
|
||||
|
||||
quota.check_enqueue(db, user_id)
|
||||
|
||||
ref = clipmod.asset_ref(user_id, spec)[:512]
|
||||
asset = get_or_create_asset(db, "clip", ref, clipmod.sig(spec))
|
||||
if not asset.title:
|
||||
asset.title = clip_title(facts["title"], display_name)
|
||||
asset.uploader = facts["uploader"]
|
||||
asset.thumbnail_url = facts["thumbnail_url"]
|
||||
asset.duration_s = round(clipmod.duration(spec))
|
||||
asset.media_kind = mediakind.VIDEO
|
||||
|
||||
job = DownloadJob(
|
||||
user_id=user_id,
|
||||
asset_id=asset.id,
|
||||
job_kind=JobKind.CLIP,
|
||||
source_kind="clip",
|
||||
source_ref=ref,
|
||||
edit_spec=spec,
|
||||
profile_snapshot={},
|
||||
display_name=display_name or asset.title[:DISPLAY_TEXT_MAX],
|
||||
status=JobStatus.QUEUED,
|
||||
queue_pos=next_queue_pos(db),
|
||||
)
|
||||
db.add(job)
|
||||
incr_ref_count(db, asset.id)
|
||||
|
||||
if asset.status == "ready": # the same range was already cut for this user → instant
|
||||
job.status = JobStatus.DONE
|
||||
job.progress = 100
|
||||
asset.last_access_at = datetime.now(timezone.utc)
|
||||
|
||||
|
||||
@@ -33,6 +33,23 @@ HOLDING = frozenset(
|
||||
{JobStatus.QUEUED, JobStatus.RUNNING, JobStatus.PAUSED, JobStatus.DONE, JobStatus.ERROR}
|
||||
)
|
||||
|
||||
|
||||
class JobKind(StrEnum):
|
||||
"""What a job produces, which decides the worker path that runs it and what the library may do
|
||||
with the result (only a DOWNLOAD has a web source to fetch again)."""
|
||||
|
||||
DOWNLOAD = "download" # yt-dlp fetches a source into a shared, deduplicated asset
|
||||
EDIT = "edit" # a per-user trim/crop of a file already in the library
|
||||
UPLOAD = "upload" # a file the user sent; the worker only converts it
|
||||
CLIP = (
|
||||
"clip" # a per-user range of a YouTube video or a Plex item, cut straight from the source
|
||||
)
|
||||
|
||||
|
||||
# A job kind with no web source to fetch again: Re-download refuses these.
|
||||
NOT_REFETCHABLE = frozenset({JobKind.EDIT, JobKind.UPLOAD, JobKind.CLIP})
|
||||
|
||||
|
||||
# States a route sets to STOP a running job. The worker's supervisor must step in (kill the child, free
|
||||
# the asset, leave the route's status intact) when it observes one of these on a job it is running.
|
||||
STOPPED_BY_ROUTE = frozenset({JobStatus.PAUSED, JobStatus.CANCELED})
|
||||
|
||||
@@ -34,7 +34,7 @@ from sqlalchemy.orm import Session
|
||||
from app.config import settings
|
||||
from app.downloads import edit as editmod
|
||||
from app.downloads import mediakind, playable, probe, quota, service, storage
|
||||
from app.downloads.status import JobStatus
|
||||
from app.downloads.status import JobKind, JobStatus
|
||||
from app.models import DownloadJob, MediaAsset, display_text
|
||||
|
||||
|
||||
@@ -113,7 +113,7 @@ def ingest_upload(
|
||||
job = DownloadJob(
|
||||
user_id=user_id,
|
||||
asset_id=asset.id,
|
||||
job_kind="upload",
|
||||
job_kind=JobKind.UPLOAD,
|
||||
source_kind="upload",
|
||||
source_ref=ref,
|
||||
profile_snapshot={},
|
||||
|
||||
@@ -123,7 +123,8 @@ class User(Base, TimestampMixin, CanonicalEmailMixin):
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true")
|
||||
# Admin-imposed access block, distinct from is_active (approval lifecycle). A suspended
|
||||
# account can't sign in by any method and its live sessions are rejected; the user is told
|
||||
# why and pointed at the operator's contact.
|
||||
# why and pointed at the operator's contact. The worker leaves its queued downloads unclaimed
|
||||
# until the block is lifted (`worker._claim_job`).
|
||||
is_suspended: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||
# Server-side session revocation (SA4): a monotonic counter bumped on password reset, password
|
||||
# change, or "log out everywhere". A signed session cookie records the epoch it was minted under;
|
||||
@@ -933,8 +934,9 @@ class DownloadJob(Base, TimestampMixin, UpdatedAtMixin):
|
||||
)
|
||||
source_kind: Mapped[str] = mapped_column(String(16)) # "youtube" | "url"
|
||||
source_ref: Mapped[str] = mapped_column(String(512))
|
||||
# "download" (default), "edit" (a per-user trim/crop derivative of another job) or "upload" (a
|
||||
# user's own file, converted by the worker when it is not phone-safe — see `downloads.upload`).
|
||||
# "download" (default), "edit" (a per-user trim/crop derivative of another job), "upload" (a
|
||||
# user's own file, converted by the worker when it is not phone-safe — see `downloads.upload`) or
|
||||
# "clip" (a range cut straight from a YouTube video or a Plex item — see `downloads.clip`).
|
||||
job_kind: Mapped[str] = mapped_column(String(16), default="download", server_default="download")
|
||||
# For edit jobs: the parent download this clip is cut from (SET NULL if the parent is deleted).
|
||||
source_job_id: Mapped[int | None] = mapped_column(
|
||||
@@ -943,7 +945,9 @@ class DownloadJob(Base, TimestampMixin, UpdatedAtMixin):
|
||||
source_asset_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("media_assets.id", ondelete="SET NULL")
|
||||
)
|
||||
# For edit jobs: the trim/crop recipe {trim?:{start_s,end_s}, crop?:{x,y,w,h}}.
|
||||
# For edit jobs: the trim/crop recipe {trim?:{start_s,end_s}, crop?:{x,y,w,h}} in pixels. For clip
|
||||
# jobs: the clip spec {source, ref, start_s, end_s, audio_track?, crop?:{x,y,w,h,aspect}}, its crop
|
||||
# in fractions of the box it was drawn in (`downloads.clip.normalize`).
|
||||
edit_spec: Mapped[dict | None] = mapped_column(JSON)
|
||||
profile_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("download_profiles.id", ondelete="SET NULL")
|
||||
|
||||
@@ -3,9 +3,24 @@ playlists, and detail views render. Extracted from routes/plex/browse.py so the
|
||||
lives beside the other Plex read-model modules (detail.py, query.py) rather than inside the
|
||||
router. Pure functions — no DB, no request state."""
|
||||
|
||||
from typing import NamedTuple
|
||||
|
||||
from app.models import PlexItem, PlexShow, PlexState
|
||||
|
||||
|
||||
class ShowState(NamedTuple):
|
||||
"""A show's watch state rolled up from its episodes, as its card carries it. `resumable`: an unwatched
|
||||
episode has a resume point, the one the card's Continue resumes and its Restart replays from 0. A
|
||||
show part watched with its next episode untouched has none: Continue and Restart would both play
|
||||
that episode from its start, so the card offers Play alone."""
|
||||
|
||||
status: str
|
||||
resumable: bool
|
||||
|
||||
|
||||
SHOW_NEW = ShowState("new", False)
|
||||
|
||||
|
||||
# A card's wide backdrop, for a rail's landscape tile or a crossfading hero — but ONLY when the item
|
||||
# actually has art. `thumb` is always emitted (its endpoint placeholders a missing poster), but an
|
||||
# `art` URL for an item with no `art_key` would resolve to a broken/blank image on every card that
|
||||
@@ -54,7 +69,7 @@ def movie_card(it: PlexItem, st: PlexState | None, *, lang: str) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def show_card(sh: PlexShow, status: str = "new", *, lang: str) -> dict:
|
||||
def show_card(sh: PlexShow, state: ShowState = SHOW_NEW, *, lang: str) -> dict:
|
||||
return {
|
||||
"id": sh.rating_key,
|
||||
"type": "show",
|
||||
@@ -66,7 +81,8 @@ def show_card(sh: PlexShow, status: str = "new", *, lang: str) -> dict:
|
||||
"art": _art_url(sh.rating_key, sh.art_key),
|
||||
"season_count": sh.child_count,
|
||||
# Aggregate watch-state across the show's episodes (new | in_progress | watched) → grid badge.
|
||||
"status": status,
|
||||
"status": state.status,
|
||||
"resumable": state.resumable,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -167,12 +167,16 @@ class PlexClient:
|
||||
keys = ",".join(str(k) for k in rating_keys)
|
||||
return f"server://{self.machine_id}/com.plexapp.plugins.library/library/metadata/{keys}"
|
||||
|
||||
def create_collection(self, section_key: str, title: str, item_rating_key: str) -> dict:
|
||||
"""Create a (dumb) collection seeded with one item; returns its Metadata (incl. ratingKey)."""
|
||||
def create_collection(
|
||||
self, section_key: str, title: str, item_rating_key: str, item_type: int = 1
|
||||
) -> dict:
|
||||
"""Create a (dumb) collection seeded with one item; returns its Metadata (incl. ratingKey).
|
||||
`item_type` is the Plex metadata type of its members — 1 movie, 2 show. Measured: a collection
|
||||
made as type 1 in a TV section came out EMPTY with a show seed, and refused the show after."""
|
||||
uri = quote(self._metadata_uri(item_rating_key), safe="")
|
||||
mc = self._write(
|
||||
"POST",
|
||||
f"/library/collections?type=1&smart=0§ionId={section_key}"
|
||||
f"/library/collections?type={item_type}&smart=0§ionId={section_key}"
|
||||
f"&title={quote(title, safe='')}&uri={uri}",
|
||||
)
|
||||
items = mc.get("Metadata", []) or []
|
||||
|
||||
@@ -7,6 +7,7 @@ the two never diverge — the same one-parser rule that keeps `people.py`'s cast
|
||||
(genres/directors/studio/content_rating already land from the cheap listing at `_apply_facet_fields`)."""
|
||||
|
||||
from app.plex import people
|
||||
from app.plex.plaintext import plain_text
|
||||
|
||||
|
||||
def rich_detail(meta: dict) -> dict:
|
||||
@@ -42,7 +43,7 @@ def rich_detail(meta: dict) -> dict:
|
||||
"genres": [g.get("tag") for g in (meta.get("Genre") or []) if g.get("tag")][:8],
|
||||
"directors": [d.get("tag") for d in (meta.get("Director") or []) if d.get("tag")][:4],
|
||||
"studio": meta.get("studio"),
|
||||
"tagline": meta.get("tagline"),
|
||||
"tagline": plain_text(meta.get("tagline")),
|
||||
"cast": people.cast_from_meta(meta),
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ from app.models import PlexItem, PlexLibrary, PlexShow
|
||||
from app.plex import sync as plex_sync
|
||||
from app.plex.client import PlexClient
|
||||
from app.plex.genres import hu_genres
|
||||
from app.plex.plaintext import plain_text
|
||||
|
||||
log = logs.get_logger(__name__)
|
||||
|
||||
@@ -109,8 +110,8 @@ def _overlay_pass(
|
||||
for g, meta in hu_by_guid.items():
|
||||
targets = by_guid[g]
|
||||
title_hu = (meta.get("title") or "").strip() or None
|
||||
summary_hu = meta.get("summary")
|
||||
tagline_hu = (meta.get("tagline") or "").strip() or None
|
||||
summary_hu = plain_text(meta.get("summary"))
|
||||
tagline_hu = plain_text(meta.get("tagline"))
|
||||
genres_hu = hu_genres(plex_sync._tags(meta, "Genre"))
|
||||
for row in targets:
|
||||
row.title_hu = title_hu
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Plex text fields as plain text.
|
||||
|
||||
Plex hands a summary over as the agent wrote it, and some agents (a Hungarian TV listing, for one)
|
||||
write HTML: `<p>…</p>` paragraphs and `á`-style entities. The app renders summaries as text,
|
||||
so the tags showed literally on the info page and the hover card, and a card's 300-character cut
|
||||
could land inside one. Every summary and tagline is stored through `plain_text`, and the migration
|
||||
that cleaned the existing rows calls it too, so the two cannot disagree.
|
||||
"""
|
||||
|
||||
import html
|
||||
import re
|
||||
from html.entities import html5
|
||||
|
||||
_BREAK = re.compile(r"<br\s*/?>", re.IGNORECASE)
|
||||
# A paragraph or block boundary: a blank line between the two. Only a tag name ends the match, so a
|
||||
# bare "<" in prose ("a < b", "<3") is left alone.
|
||||
_BLOCK = re.compile(r"</?(?:p|div|li|ul|ol|h[1-6]|blockquote)\b[^>]*>", re.IGNORECASE)
|
||||
_TAG = re.compile(r"</?[a-zA-Z][^>]*>")
|
||||
_BLANKS = re.compile(r"[ \t]*\n[ \t]*\n\s*")
|
||||
|
||||
# A well-formed character reference only. `html.unescape` alone also decodes the legacy forms that
|
||||
# have no semicolon, so prose like "AT¬ice" or "R©" came out as "AT¬ice" and "R©". A named
|
||||
# one counts only when its whole name is an entity: for "¬ice;" `html.unescape` still falls back
|
||||
# to the legacy "¬" inside it.
|
||||
_REF = re.compile(r"&(?:#[0-9]+|#[xX][0-9a-fA-F]+|[A-Za-z][A-Za-z0-9]*);")
|
||||
|
||||
|
||||
def _decode(m: re.Match[str]) -> str:
|
||||
ref = m.group()
|
||||
if ref[1] != "#":
|
||||
return html.unescape(ref) if ref[1:] in html5 else ref
|
||||
if ref[2] in "xX":
|
||||
return html.unescape(ref)
|
||||
# `int()` refuses a decimal run past 4300 digits, and any number past seven significant digits is
|
||||
# beyond U+10FFFF, which the parser turns into U+FFFD anyway.
|
||||
digits = ref[2:-1].lstrip("0") or "0"
|
||||
return "�" if len(digits) > 7 else html.unescape(f"&#{digits};")
|
||||
|
||||
|
||||
def plain_text(value: str | None) -> str | None:
|
||||
"""`value` with its markup gone: line breaks kept as newlines, paragraphs as a blank line,
|
||||
every other tag dropped and entities decoded. None or an all-blank result stays None."""
|
||||
if value is None:
|
||||
return None
|
||||
if "<" in value or "&" in value:
|
||||
value = _BREAK.sub("\n", value)
|
||||
value = _BLOCK.sub("\n\n", value)
|
||||
value = _TAG.sub("", value)
|
||||
value = _REF.sub(_decode, value)
|
||||
value = _BLANKS.sub("\n\n", value)
|
||||
return value.strip() or None
|
||||
@@ -9,6 +9,7 @@ from sqlalchemy import and_, case, func, or_, select
|
||||
from sqlalchemy.orm import Session, aliased
|
||||
|
||||
from app.models import PlexItem, PlexState
|
||||
from app.plex.cards import ShowState
|
||||
|
||||
# Sort sentinel for a rail row whose timestamp is missing, so `key=lambda r: r[0] or NO_TS` reads the
|
||||
# same at every rail that merges two type-homogeneous lists in Python.
|
||||
@@ -32,6 +33,11 @@ def show_status(total: int, watched: int, inprog: int) -> str:
|
||||
return "new"
|
||||
|
||||
|
||||
def show_state(total: int, watched: int, inprog: int) -> ShowState:
|
||||
"""`show_status` plus whether an episode holds a resume point (the card's `ShowState`)."""
|
||||
return ShowState(show_status(total, watched, inprog), (inprog or 0) > 0)
|
||||
|
||||
|
||||
def rollup(cards: list[dict]) -> dict:
|
||||
"""From ORDERED episode cards → the aggregate watch status, the "on deck" episode to Resume (the
|
||||
last in-progress one, else the first unwatched), the first episode (for Play from the start), and
|
||||
@@ -92,16 +98,16 @@ def agg_subquery(db: Session, user_id: int, include_resumed: bool = False):
|
||||
)
|
||||
|
||||
|
||||
def state_map(db: Session, user_id: int, show_ids: list[int]) -> dict[int, str]:
|
||||
def state_map(db: Session, user_id: int, show_ids: list[int]) -> dict[int, ShowState]:
|
||||
if not show_ids:
|
||||
return {}
|
||||
rows = db.execute(agg_subquery(db, user_id).where(PlexItem.show_id.in_(show_ids))).all()
|
||||
return {r.show_id: show_status(r.total, r.watched, r.inprog) for r in rows}
|
||||
return {r.show_id: show_state(r.total, r.watched, r.inprog) for r in rows}
|
||||
|
||||
|
||||
def item_state_map(db: Session, user_id: int, item_ids: list[int]) -> dict[int, PlexState]:
|
||||
"""A user's PlexState rows for a set of leaf items (movies or episodes), keyed by item_id — the
|
||||
leaf analogue of `state_map` (which rolls a show's episodes up to a status string). One batched
|
||||
leaf analogue of `state_map` (which rolls a show's episodes up to a `ShowState`). One batched
|
||||
fetch, so the caller serializes cards without a per-card query. Empty in → empty dict (no `IN ()`)."""
|
||||
if not item_ids:
|
||||
return {}
|
||||
|
||||
@@ -35,7 +35,7 @@ from app.config import settings
|
||||
from app.downloads.edit import encode_thread_cap
|
||||
from app.models import PlexItem
|
||||
from app.plex import paths
|
||||
from app.plex.sync import HW_DECODABLE_CODECS
|
||||
from app.plex.sync import HDR_TRANSFERS, HW_DECODABLE_CODECS
|
||||
|
||||
log = logs.get_logger(__name__)
|
||||
_HLS_ROOT = (
|
||||
@@ -203,7 +203,8 @@ _HW_DECODABLE = HW_DECODABLE_CODECS
|
||||
# plain scale_vaapi=format=nv12 path (Gen 9.5 VPP refuses it) — it needs the OpenCL tonemap graph
|
||||
# (_venc_opts tonemap branch) when the OpenCL runtime is present, else software libx264. A 10-bit source
|
||||
# WITHOUT one of these transfers is SDR, not HDR, and must not be tone-mapped. See _probe_video_kind.
|
||||
_HDR_TRANSFERS = {"smpte2084", "arib-std-b67"}
|
||||
# THE single source is plex.sync (a clip's tonemap reads it too).
|
||||
_HDR_TRANSFERS = HDR_TRANSFERS
|
||||
|
||||
|
||||
# The three source classes that drive the transcode-pipeline choice (see _probe_video_kind).
|
||||
|
||||
@@ -26,6 +26,7 @@ from app.plex import detail, overlay, people
|
||||
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
|
||||
from app.plex.genres import hu_genres
|
||||
from app.plex.lookups import by_rating_key
|
||||
from app.plex.plaintext import plain_text
|
||||
|
||||
log = logs.get_logger(__name__)
|
||||
# Codecs/containers a browser can play in a native <video> without any transcoding.
|
||||
@@ -63,9 +64,11 @@ def classify_playable(vcodec: str | None, acodec: str | None, container: str | N
|
||||
# outside it (AV1, MPEG-4 ASP/XviD…) is software-only even on the iGPU (it can't decode it to feed its
|
||||
# own encoder).
|
||||
HW_DECODABLE_CODECS = frozenset({"h264", "hevc", "h265", "vp9", "vc1", "mpeg2video"})
|
||||
# Color-transfer values marking an HDR source (PQ / HLG). A 10-bit/HDR source breaks the VAAPI
|
||||
# scale=nv12 path on Gen 9.5 (see plex.stream) → software transcode, so it counts as NOT HW-capable.
|
||||
_HDR_TRANSFERS = frozenset({"smpte2084", "arib-std-b67"})
|
||||
# Color-transfer values marking an HDR source (PQ / HLG). THE single source of this set: plex.stream
|
||||
# (the tonemap choice) and downloads.clip (a clip's tonemap) import it here, so a transfer added for
|
||||
# one is tonemapped by all. A 10-bit/HDR source breaks the VAAPI scale=nv12 path on Gen 9.5 (see
|
||||
# plex.stream) → software transcode, so it counts as NOT HW-capable.
|
||||
HDR_TRANSFERS = frozenset({"smpte2084", "arib-std-b67"})
|
||||
|
||||
|
||||
def transcode_hw_capable(
|
||||
@@ -106,7 +109,7 @@ def _video_stream_props(meta: dict) -> tuple[int | None, bool]:
|
||||
# whole library would drown the sync's own lines.
|
||||
except (TypeError, ValueError):
|
||||
depth = None
|
||||
hdr = str(s.get("colorTrc") or "").lower() in _HDR_TRANSFERS
|
||||
hdr = str(s.get("colorTrc") or "").lower() in HDR_TRANSFERS
|
||||
return depth, hdr
|
||||
return None, False
|
||||
|
||||
@@ -468,7 +471,7 @@ def _apply_item(row: PlexItem, lib_id: int, kind: str, meta: dict) -> None:
|
||||
# carries none (a server ignoring includeGuids mustn't wipe the overlay's only match key).
|
||||
row.guid = _canonical_guid(meta) or row.guid
|
||||
row.title = (meta.get("title") or "").strip() or "Untitled"
|
||||
row.summary = meta.get("summary")
|
||||
row.summary = plain_text(meta.get("summary"))
|
||||
row.year = meta.get("year")
|
||||
row.duration_s = _dur_s(meta)
|
||||
row.thumb_key = meta.get("thumb")
|
||||
@@ -576,7 +579,7 @@ def _sync_shows(db: Session, plex: PlexClient, lib: PlexLibrary, stats: dict) ->
|
||||
_canonical_guid(meta) or sh.guid
|
||||
) # EN<->HU pairing key; keep prior if listing has none
|
||||
sh.title = (meta.get("title") or "").strip() or "Untitled"
|
||||
sh.summary = meta.get("summary")
|
||||
sh.summary = plain_text(meta.get("summary"))
|
||||
sh.year = meta.get("year")
|
||||
sh.thumb_key = meta.get("thumb")
|
||||
sh.art_key = meta.get("art")
|
||||
@@ -659,7 +662,7 @@ def _upsert_collection(
|
||||
db.add(col)
|
||||
col.library_id = lib_id
|
||||
col.title = (meta.get("title") or "").strip() or "Untitled"
|
||||
col.summary = meta.get("summary")
|
||||
col.summary = plain_text(meta.get("summary"))
|
||||
col.thumb_key = meta.get("thumb")
|
||||
col.child_count = meta.get("childCount")
|
||||
col.smart = str(meta.get("smart")) == "1"
|
||||
|
||||
@@ -484,7 +484,8 @@ def set_user_suspended(
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Suspend or un-suspend an account. A suspended user can't sign in by any method and any
|
||||
live session is dropped on its next request. Guards: can't suspend yourself, and can't suspend the last active admin (that would lock admin out). On
|
||||
live session is dropped on its next request; their queued downloads wait (the worker skips them)
|
||||
until they are un-suspended. Guards: can't suspend yourself, and can't suspend the last active admin (that would lock admin out). On
|
||||
un-suspend the user is emailed they can return (suspend is announced reactively, on a blocked
|
||||
sign-in)."""
|
||||
suspended = body.suspended
|
||||
|
||||
@@ -8,6 +8,7 @@ state, serves finished files (range-aware, with the user's custom display name),
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
from typing import Literal
|
||||
from collections.abc import AsyncIterator, Sequence
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
@@ -16,7 +17,7 @@ from urllib.parse import parse_qs, unquote, urlparse
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import delete, func, select
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -27,13 +28,14 @@ from app import audit, sysconfig
|
||||
from app.auth import admin_user, current_user
|
||||
from app.config import settings
|
||||
from app.db import get_db, release_read_connection
|
||||
from app.downloads import clip as clipmod
|
||||
from app.downloads import credentials as dlcreds
|
||||
from app.downloads import edit as editmod
|
||||
from app.downloads import links as linksmod
|
||||
from app.downloads import quota, service, storage
|
||||
from app.downloads import upload as uploadmod
|
||||
from app.downloads import urlguard
|
||||
from app.downloads.status import JobStatus
|
||||
from app.downloads.status import NOT_REFETCHABLE, JobStatus
|
||||
from app.models import (
|
||||
DownloadJob,
|
||||
DownloadLink,
|
||||
@@ -576,6 +578,49 @@ def enqueue_edit(
|
||||
return _serialize_job(db, job)
|
||||
|
||||
|
||||
class ClipCropIn(BaseModel):
|
||||
x: float
|
||||
y: float
|
||||
w: float
|
||||
h: float
|
||||
aspect: float
|
||||
|
||||
|
||||
class EnqueueClipIn(BaseModel):
|
||||
source: Literal["youtube", "plex"]
|
||||
# The YouTube video id or the Plex rating key.
|
||||
ref: str = Field(max_length=32)
|
||||
start_s: float
|
||||
end_s: float
|
||||
# Plex only: which of the file's audio tracks (0-based, in file order) — the one being heard.
|
||||
audio_track: int | None = None
|
||||
# A crop frame (downloads/clip): fractions of the box it was drawn in, and that box's aspect.
|
||||
crop: ClipCropIn | None = None
|
||||
display_name: str | None = None
|
||||
|
||||
|
||||
@router.post("/clip")
|
||||
def enqueue_clip(
|
||||
body: EnqueueClipIn, user: User = Depends(current_user), db: Session = Depends(get_db)
|
||||
) -> dict:
|
||||
"""Queue an ad-hoc clip: the marked range of a YouTube video or a Plex item, cut straight from
|
||||
the source into a new asset in this user's library (downloads/clip)."""
|
||||
try:
|
||||
crop = body.crop.model_dump() if body.crop else None
|
||||
spec = clipmod.normalize(
|
||||
body.source, body.ref, body.start_s, body.end_s, body.audio_track, crop
|
||||
)
|
||||
display_name = display_text(body.display_name)
|
||||
job = service.enqueue_clip(db, user.id, spec, display_name=display_name)
|
||||
except clipmod.ClipError as e:
|
||||
status = 404 if e.reason == "not_found" else 422
|
||||
detail = {"code": "clip", "reason": e.reason, **e.params}
|
||||
raise HTTPException(status_code=status, detail=detail) from e
|
||||
except quota.QuotaExceeded as e:
|
||||
raise HTTPException(status_code=422, detail=_quota_detail(e)) from e
|
||||
return _serialize_job(db, job)
|
||||
|
||||
|
||||
def _quota_detail(e: quota.QuotaExceeded) -> dict:
|
||||
"""Structured error the trilingual client localizes (reason key + numbers), instead of a
|
||||
pre-baked English string with a dot-decimal GB value. See lib/api.ts localizeDetail()."""
|
||||
@@ -768,9 +813,9 @@ def redownload_download(
|
||||
never deletes a file another job/user still shares: the old asset is only removed if THIS job
|
||||
was its last holder."""
|
||||
job = _own_job_for_update(db, user, job_id)
|
||||
if job.job_kind in ("edit", "upload"):
|
||||
# Neither has a re-fetchable source: an editor clip is derived locally, an upload IS the
|
||||
# source. Re-running yt-dlp against source_kind='upload'/'edit' would just fail.
|
||||
if job.job_kind in NOT_REFETCHABLE:
|
||||
# None has a re-fetchable source: an editor clip is derived locally, an upload IS the
|
||||
# source, an ad-hoc clip is a range of a source the clip itself does not re-fetch.
|
||||
raise refused(409, "not_redownloadable")
|
||||
if job.status in (JobStatus.QUEUED, JobStatus.RUNNING, JobStatus.PAUSED):
|
||||
raise refused(409, "still_downloading")
|
||||
|
||||
@@ -164,6 +164,7 @@ def unified_library(
|
||||
cast(null(), Integer).label("season_count"),
|
||||
func.coalesce(st.position_seconds, 0).label("position_seconds"),
|
||||
m_status.label("status"),
|
||||
literal(False).label("resumable"),
|
||||
# "last watched" sort key: when the movie was last resumed, else when it was marked
|
||||
# watched. NULL for never-touched movies → nullslast sinks them (see the sort dict).
|
||||
func.coalesce(st.progress_updated_at, st.watched_at).label("last_watched"),
|
||||
@@ -211,6 +212,8 @@ def unified_library(
|
||||
PlexShow.child_count.label("season_count"),
|
||||
literal(0).label("position_seconds"),
|
||||
s_status.label("status"),
|
||||
# An episode holds a resume point (`ShowState.resumable`, the card's Continue + Restart).
|
||||
(inp > 0).label("resumable"),
|
||||
# Show leg's "last watched": max(resume/watched time) across the show's episodes (see
|
||||
# rollup.agg_subquery include_resumed). NULL for a never-watched show → nullslast.
|
||||
agg.c.resumed_at.label("last_watched"),
|
||||
@@ -276,6 +279,7 @@ def unified_library(
|
||||
"thumb": f"/api/plex/image/{r.rk}",
|
||||
"season_count": r.season_count,
|
||||
"status": r.status,
|
||||
"resumable": r.resumable,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ from app.models import (
|
||||
)
|
||||
from app.plex import rollup
|
||||
from app.plex import sync as plex_sync
|
||||
from app.plex.cards import display_title, movie_card, norm_lang, show_card
|
||||
from app.plex.cards import SHOW_NEW, display_title, movie_card, norm_lang, show_card
|
||||
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
|
||||
from app.plex.lookups import by_rating_key
|
||||
from app.plex.query import content_libs, runtime_by_key, title_col, top_art_keys
|
||||
@@ -176,6 +176,40 @@ def collections(
|
||||
}
|
||||
|
||||
|
||||
@router.get("/collections/for/{item_rating_key}")
|
||||
def collections_for_item(
|
||||
item_rating_key: str,
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""What a movie or show card's "Add to collection" lists: the editable collections of the title's
|
||||
library (a collection lives in one library, and only a manual one can take a title — the same
|
||||
`can_edit` the editor offers), each saying whether the title is in it already, by name."""
|
||||
movie = by_rating_key(db, PlexItem, item_rating_key)
|
||||
title: PlexItem | PlexShow | None = (
|
||||
movie if movie is not None and movie.kind == "movie" else None
|
||||
) or by_rating_key(db, PlexShow, item_rating_key)
|
||||
if title is None:
|
||||
raise not_found("item")
|
||||
member_of = set(title.collection_keys or [])
|
||||
rows = (
|
||||
db.execute(
|
||||
select(PlexCollection)
|
||||
.where(PlexCollection.library_id == title.library_id)
|
||||
.order_by(collated(PlexCollection.title, lang_for_user(user)))
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
return {
|
||||
"collections": [
|
||||
{**_collection_card(c), "contains": c.rating_key in member_of}
|
||||
for c in rows
|
||||
if _collection_editable(c)
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
class Member(NamedTuple):
|
||||
"""One collection member flattened to everything the Sort control can order by. The page's two
|
||||
legs are different tables (movies and shows), so there is no single query to ORDER BY — the
|
||||
@@ -284,7 +318,7 @@ def collection_detail(
|
||||
if shows
|
||||
else []
|
||||
)
|
||||
sstate = {r.show_id: rollup.show_status(r.total, r.watched, r.inprog) for r in show_rows}
|
||||
sstate = {r.show_id: rollup.show_state(r.total, r.watched, r.inprog) for r in show_rows}
|
||||
sresumed = {r.show_id: r.resumed_at for r in show_rows}
|
||||
|
||||
def _movie_last_watched(st: PlexState | None) -> datetime | None:
|
||||
@@ -325,7 +359,7 @@ def collection_detail(
|
||||
items = [
|
||||
movie_card(m.obj, mstate.get(m.obj.id), lang=lang)
|
||||
if m.kind == "movie"
|
||||
else show_card(m.obj, sstate.get(m.obj.id, "new"), lang=lang)
|
||||
else show_card(m.obj, sstate.get(m.obj.id, SHOW_NEW), lang=lang)
|
||||
for m in members
|
||||
]
|
||||
years = [m.year for m in members if m.year is not None]
|
||||
@@ -407,27 +441,47 @@ def create_collection(
|
||||
seed = body.item_rating_key
|
||||
if not title or not seed:
|
||||
raise refused(400, "collection_fields_required")
|
||||
seed_show = by_rating_key(db, PlexShow, seed)
|
||||
if library:
|
||||
lib = (
|
||||
db.execute(select(PlexLibrary).where(PlexLibrary.plex_key == library)).scalars().first()
|
||||
)
|
||||
else:
|
||||
# The "New collection" flow only knows the seed title → derive its library.
|
||||
seed_item = by_rating_key(db, PlexItem, seed) or by_rating_key(db, PlexShow, seed)
|
||||
seed_item = by_rating_key(db, PlexItem, seed) or seed_show
|
||||
lib = db.get(PlexLibrary, seed_item.library_id) if seed_item else None
|
||||
if lib is None:
|
||||
raise not_found("library")
|
||||
_reject_duplicate_name(db, lib.id, title)
|
||||
wrote = False
|
||||
new_rk = ""
|
||||
try:
|
||||
with PlexClient(db) as plex:
|
||||
new = plex.create_collection(lib.plex_key, title, seed)
|
||||
# A show collection is made as one (Plex type 2): as a movie one it stayed empty.
|
||||
new = plex.create_collection(lib.plex_key, title, seed, 2 if seed_show else 1)
|
||||
wrote = True
|
||||
col = plex_sync.resync_collection(db, plex, lib, str(new.get("ratingKey")))
|
||||
new_rk = str(new.get("ratingKey") or "")
|
||||
col = plex_sync.resync_collection(db, plex, lib, new_rk)
|
||||
col.editable = True
|
||||
db.commit()
|
||||
except (PlexError, PlexNotConfigured) as e:
|
||||
db.rollback()
|
||||
if wrote and new_rk:
|
||||
# It is on Plex now, and only the follow-up read failed. Its row is kept, editable: the
|
||||
# full sync preserves `editable` but creates a missing row read-only, so the collection
|
||||
# would drop out of every "add to" list; and the duplicate-name check reads these rows,
|
||||
# so a retry of the same name would make a second one on Plex. Plex made it with exactly
|
||||
# the seed in it, so the seed's row says so too — otherwise the title showed unticked in
|
||||
# the collection it was just added to, and the collection empty, until that sync.
|
||||
db.add(
|
||||
PlexCollection(
|
||||
rating_key=new_rk, library_id=lib.id, title=title, editable=True, child_count=1
|
||||
)
|
||||
)
|
||||
Model = PlexItem if lib.kind == "movie" else PlexShow
|
||||
if row := by_rating_key(db, Model, seed):
|
||||
row.collection_keys = list(dict.fromkeys([*(row.collection_keys or []), new_rk]))
|
||||
db.commit()
|
||||
raise _write_or_pending_error(wrote, e) from e
|
||||
return _collection_card(col)
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ from app.collation import collated, sorted_words
|
||||
from app import logs
|
||||
from app.auth import current_user
|
||||
from app.db import get_db
|
||||
from app.discover import queries
|
||||
from app.models import (
|
||||
PlexCollection,
|
||||
PlexItem,
|
||||
@@ -28,7 +29,7 @@ from app.plex import people as plex_people
|
||||
from app.plex import rollup, subtitles
|
||||
from app.plex.query import title_col
|
||||
from app.plex import tmdb as plex_tmdb
|
||||
from app.plex.cards import episode_card, movie_card, show_card
|
||||
from app.plex.cards import SHOW_NEW, episode_card, movie_card, show_card
|
||||
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
|
||||
from app.plex.lookups import by_rating_key
|
||||
from app.routes.plex._shared import _norm_lang, item_or_404
|
||||
@@ -221,6 +222,24 @@ def show_detail(
|
||||
}
|
||||
|
||||
|
||||
@router.get("/show/{rating_key}/next")
|
||||
def show_next(
|
||||
rating_key: str,
|
||||
lang: str = "en",
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""The episode a show's card plays: the one in progress, else the first unwatched, else the first
|
||||
(`discover.queries.next_episode`, the rule Home's shelves and the show page's Resume follow).
|
||||
Asked one show at a time, when a card is hovered or played, rather than carried on every card of a
|
||||
grid: three probes a show would cost a 60-card page 180 queries for the few cards anyone plays.
|
||||
`next` is null for a show with no episodes."""
|
||||
sh = by_rating_key(db, PlexShow, rating_key)
|
||||
if sh is None:
|
||||
raise not_found("show")
|
||||
return {"next": queries.next_episode(db, user.id, sh.id, lang=_norm_lang(lang))}
|
||||
|
||||
|
||||
def _season_block(se: PlexSeason, cards: list[dict]) -> dict:
|
||||
"""A season's card for the show page (with its aggregate rollup) plus its episodes (used by the
|
||||
season subpage, read from the same cached show-detail payload)."""
|
||||
@@ -260,7 +279,7 @@ def _relatedshow_cards(
|
||||
if sh is None or rk in seen:
|
||||
continue
|
||||
seen.add(rk)
|
||||
out.append(show_card(sh, statuses.get(sh.id, "new"), lang=lang))
|
||||
out.append(show_card(sh, statuses.get(sh.id, SHOW_NEW), lang=lang))
|
||||
if len(out) >= 20:
|
||||
break
|
||||
return out
|
||||
@@ -504,6 +523,7 @@ def item_detail(
|
||||
|
||||
prev_id, next_id = _episode_nav(db, it)
|
||||
show = db.get(PlexShow, it.show_id) if it.kind == "episode" and it.show_id else None
|
||||
season = db.get(PlexSeason, it.season_id) if show and it.season_id else None
|
||||
return {
|
||||
"id": it.rating_key,
|
||||
"kind": it.kind,
|
||||
@@ -537,6 +557,9 @@ def item_detail(
|
||||
"status": st.status if st else "new",
|
||||
"position_seconds": st.position_seconds if st else 0,
|
||||
"show_title": ((show.title_hu or show.title if hu else show.title) if show else None),
|
||||
# The episode page links up to its show and its season (their rating keys = the view keys).
|
||||
"show_id": show.rating_key if show else None,
|
||||
"season_id": season.rating_key if season else None,
|
||||
"season_number": it.season_number,
|
||||
"episode_number": it.episode_number,
|
||||
"prev_id": prev_id,
|
||||
|
||||
@@ -23,7 +23,13 @@ from app.auth import current_user
|
||||
from app.db import get_db
|
||||
from app.models import PlexItem, PlexShow, PlexState, User
|
||||
from app.plex import rollup
|
||||
from app.plex.cards import display_title, leaf_card, movie_card, show_card
|
||||
from app.plex.cards import (
|
||||
SHOW_NEW,
|
||||
display_title,
|
||||
leaf_card,
|
||||
movie_card,
|
||||
show_card,
|
||||
)
|
||||
from app.plex.query import (
|
||||
LibraryFilters,
|
||||
apply_meta_filters,
|
||||
@@ -69,7 +75,7 @@ def _movie_cards(db: Session, uid: int, items: list[PlexItem], lang: str) -> lis
|
||||
|
||||
def _show_cards(db: Session, uid: int, shows: list[PlexShow], lang: str) -> list[dict]:
|
||||
statuses = rollup.state_map(db, uid, [s.id for s in shows])
|
||||
return [show_card(s, statuses.get(s.id, "new"), lang=lang) for s in shows]
|
||||
return [show_card(s, statuses.get(s.id, SHOW_NEW), lang=lang) for s in shows]
|
||||
|
||||
|
||||
def _recently_added(
|
||||
@@ -232,7 +238,9 @@ def _continue_watching(
|
||||
# `apply_show_state_filter` the grid uses, so the rail can't drift from the grid's definition.
|
||||
prog = rollup.agg_subquery(db, uid, include_resumed=True).subquery()
|
||||
sq = apply_show_state_filter(
|
||||
select(PlexShow, prog.c.resumed_at).join(prog, prog.c.show_id == PlexShow.id),
|
||||
select(PlexShow, prog.c.resumed_at, prog.c.total, prog.c.watched, prog.c.inprog).join(
|
||||
prog, prog.c.show_id == PlexShow.id
|
||||
),
|
||||
prog.c.total,
|
||||
prog.c.watched,
|
||||
prog.c.inprog,
|
||||
@@ -243,8 +251,9 @@ def _continue_watching(
|
||||
.order_by(prog.c.resumed_at.desc().nullslast())
|
||||
.limit(RAIL_LIMIT)
|
||||
).all()
|
||||
for sh, resumed_at in srows:
|
||||
rows.append((resumed_at, show_card(sh, "in_progress", lang=lang)))
|
||||
for sh, resumed_at, total, watched, inprog in srows:
|
||||
state = rollup.show_state(total, watched, inprog)
|
||||
rows.append((resumed_at, show_card(sh, state, lang=lang)))
|
||||
|
||||
rows.sort(key=lambda r: r[0] or rollup.NO_TS, reverse=True)
|
||||
return [card for _, card in rows[:RAIL_LIMIT]]
|
||||
@@ -402,11 +411,71 @@ def discover(
|
||||
}
|
||||
|
||||
|
||||
def _draw(db: Session, stmt, key_col, per: int, exclude: list[str]) -> list:
|
||||
"""`per` random rows of `stmt`, the previous draw's (`exclude`) left out while the pool allows: a
|
||||
reshuffle over a small filtered pool kept dealing the same few titles. What the fresh rows leave
|
||||
short is topped up from the excluded ones, so a pool smaller than a draw still fills the rail."""
|
||||
fresh = (
|
||||
db.execute(stmt.where(key_col.not_in(exclude)).order_by(func.random()).limit(per))
|
||||
.scalars()
|
||||
.all()
|
||||
if exclude
|
||||
else []
|
||||
)
|
||||
if len(fresh) == per:
|
||||
return list(fresh)
|
||||
taken = [key_col.not_in([r.rating_key for r in fresh])] if fresh else []
|
||||
rest = db.execute(stmt.where(*taken).order_by(func.random()).limit(per - len(fresh))).scalars()
|
||||
return [*fresh, *rest.all()]
|
||||
|
||||
|
||||
def _kept(
|
||||
db: Session, uid: int, movie_ids: list[int], show_ids: list[int], keep: list[str], lang: str
|
||||
) -> list[dict]:
|
||||
"""The `keep` titles' cards in their given order, as they stand now: a title watched since the draw
|
||||
stays in it, marked watched, and one gone from the scope's libraries drops out."""
|
||||
movies = (
|
||||
db.execute(
|
||||
select(PlexItem).where(
|
||||
PlexItem.kind == "movie",
|
||||
PlexItem.library_id.in_(movie_ids),
|
||||
PlexItem.rating_key.in_(keep),
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
if movie_ids
|
||||
else []
|
||||
)
|
||||
shows = (
|
||||
db.execute(
|
||||
select(PlexShow).where(PlexShow.library_id.in_(show_ids), PlexShow.rating_key.in_(keep))
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
if show_ids
|
||||
else []
|
||||
)
|
||||
cards = {
|
||||
c["id"]: c
|
||||
for c in [
|
||||
*_movie_cards(db, uid, list(movies), lang),
|
||||
*_show_cards(db, uid, list(shows), lang),
|
||||
]
|
||||
}
|
||||
return [cards[k] for k in keep if k in cards]
|
||||
|
||||
|
||||
@router.get("/surprise")
|
||||
def surprise(
|
||||
scope: str = "both",
|
||||
lang: str = "en",
|
||||
limit: int = Query(default=2 * SURPRISE_PER_KIND, ge=1, le=40),
|
||||
# The previous draw's rating keys: a reshuffle deals other titles first.
|
||||
exclude: list[str] = Query(default=[], max_length=40),
|
||||
# The draw on screen, read again rather than redrawn: its titles' watch state moves (a play, a
|
||||
# toggle) while the draw itself stands until the ↻.
|
||||
keep: list[str] = Query(default=[], max_length=40),
|
||||
f: LibraryFilters = Depends(),
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
@@ -419,6 +488,8 @@ def surprise(
|
||||
lang = _norm_lang(lang)
|
||||
p = f.as_dict()
|
||||
movie_ids, show_ids = _scope_libs(db, scope)
|
||||
if keep:
|
||||
return {"scope": scope, "items": _kept(db, user.id, movie_ids, show_ids, keep, lang)}
|
||||
per = min(limit, SURPRISE_PER_KIND)
|
||||
movies = []
|
||||
if movie_ids:
|
||||
@@ -432,23 +503,21 @@ def surprise(
|
||||
)
|
||||
.exists()
|
||||
)
|
||||
movies = (
|
||||
db.execute(
|
||||
_movie_meta(
|
||||
select(PlexItem).where(
|
||||
PlexItem.kind == "movie",
|
||||
PlexItem.library_id.in_(movie_ids),
|
||||
~watched_movie,
|
||||
),
|
||||
p,
|
||||
lang,
|
||||
f,
|
||||
)
|
||||
.order_by(func.random())
|
||||
.limit(per)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
movies = _draw(
|
||||
db,
|
||||
_movie_meta(
|
||||
select(PlexItem).where(
|
||||
PlexItem.kind == "movie",
|
||||
PlexItem.library_id.in_(movie_ids),
|
||||
~watched_movie,
|
||||
),
|
||||
p,
|
||||
lang,
|
||||
f,
|
||||
),
|
||||
PlexItem.rating_key,
|
||||
per,
|
||||
exclude,
|
||||
)
|
||||
shows = []
|
||||
if show_ids:
|
||||
@@ -458,21 +527,19 @@ def surprise(
|
||||
not_fully_watched = or_(
|
||||
prog.c.total.is_(None), prog.c.total == 0, prog.c.watched < prog.c.total
|
||||
)
|
||||
shows = (
|
||||
db.execute(
|
||||
apply_meta_filters(
|
||||
select(PlexShow)
|
||||
.outerjoin(prog, prog.c.show_id == PlexShow.id)
|
||||
.where(PlexShow.library_id.in_(show_ids), not_fully_watched),
|
||||
PlexShow,
|
||||
p,
|
||||
lang,
|
||||
)
|
||||
.order_by(func.random())
|
||||
.limit(per)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
shows = _draw(
|
||||
db,
|
||||
apply_meta_filters(
|
||||
select(PlexShow)
|
||||
.outerjoin(prog, prog.c.show_id == PlexShow.id)
|
||||
.where(PlexShow.library_id.in_(show_ids), not_fully_watched),
|
||||
PlexShow,
|
||||
p,
|
||||
lang,
|
||||
),
|
||||
PlexShow.rating_key,
|
||||
per,
|
||||
exclude,
|
||||
)
|
||||
mcards = _movie_cards(db, user.id, movies, lang)
|
||||
scards = _show_cards(db, user.id, shows, lang)
|
||||
|
||||
@@ -19,7 +19,7 @@ from app.models import (
|
||||
)
|
||||
from app.plex import rollup
|
||||
from app.plex import tmdb as plex_tmdb
|
||||
from app.plex.cards import movie_card, norm_lang, show_card
|
||||
from app.plex.cards import SHOW_NEW, movie_card, norm_lang, show_card
|
||||
from app.plex.query import content_libs, split_csv, title_col
|
||||
|
||||
router = APIRouter(prefix="/api/plex", tags=["plex"])
|
||||
@@ -57,7 +57,7 @@ def _resolve_known_for(
|
||||
# A credit resolves against its OWN media_type's bucket only.
|
||||
if k.get("media_type") == "tv":
|
||||
if g in shows:
|
||||
out.append(show_card(shows[g], show_statuses.get(shows[g].id, "new"), lang=lang))
|
||||
out.append(show_card(shows[g], show_statuses.get(shows[g].id, SHOW_NEW), lang=lang))
|
||||
elif g in movies:
|
||||
out.append(movie_card(movies[g], m_states.get(movies[g].id), lang=lang))
|
||||
return out[:_KNOWN_FOR_SHOWN]
|
||||
@@ -245,7 +245,7 @@ def person_detail(
|
||||
.all()
|
||||
)
|
||||
statuses = rollup.state_map(db, user.id, [s.id for s in show_rows])
|
||||
shows = [show_card(sh, statuses.get(sh.id, "new"), lang=lang) for sh in show_rows]
|
||||
shows = [show_card(sh, statuses.get(sh.id, SHOW_NEW), lang=lang) for sh in show_rows]
|
||||
|
||||
known_for = _resolve_known_for(
|
||||
db, person.known_for if person else None, user.id, movie_lib_ids, show_lib_ids, lang
|
||||
|
||||
@@ -17,7 +17,7 @@ from app.db import get_db
|
||||
from app.downloads import links as linksmod
|
||||
from app.downloads import storage
|
||||
from app.downloads.status import JobStatus
|
||||
from app.models import DownloadJob, DownloadLink, MediaAsset
|
||||
from app.models import DownloadJob, DownloadLink, MediaAsset, User
|
||||
from app.ratelimit import RateLimiter, client_key
|
||||
from app.security import verify_password
|
||||
from app.refusal import refused
|
||||
@@ -44,6 +44,17 @@ def _link_or_404(db: Session, token: str) -> DownloadLink:
|
||||
return link
|
||||
|
||||
|
||||
def _being_made(db: Session, job: DownloadJob) -> bool:
|
||||
"""The job is running, or queued for a worker that will take it: the worker leaves a suspended
|
||||
owner's queued jobs where they are (`worker._claim_job`), as long as the suspension lasts."""
|
||||
if job.status == JobStatus.RUNNING:
|
||||
return True
|
||||
if job.status != JobStatus.QUEUED:
|
||||
return False
|
||||
owner = db.get(User, job.user_id)
|
||||
return owner is not None and not owner.is_suspended
|
||||
|
||||
|
||||
def _ready_target(db: Session, link: DownloadLink) -> tuple[DownloadJob, MediaAsset]:
|
||||
job = db.get(DownloadJob, link.job_id)
|
||||
asset = db.get(MediaAsset, job.asset_id) if job and job.asset_id else None
|
||||
@@ -51,7 +62,7 @@ def _ready_target(db: Session, link: DownloadLink) -> tuple[DownloadJob, MediaAs
|
||||
job is not None
|
||||
and asset is not None
|
||||
and asset.status in ("pending", "downloading")
|
||||
and job.status in (JobStatus.QUEUED, JobStatus.RUNNING)
|
||||
and _being_made(db, job)
|
||||
):
|
||||
# Being (re)made — a re-download, or a conversion — so the link works again once it is ready:
|
||||
# its own reason, so the page can say "try again shortly" rather than "this link is gone".
|
||||
|
||||
+39
-11
@@ -29,6 +29,7 @@ from datetime import datetime, timezone
|
||||
from functools import partial
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import logs
|
||||
from app import logsink
|
||||
@@ -44,7 +45,7 @@ from app.downloads.jobdb import (
|
||||
_reset_asset_pending,
|
||||
_staging_dir,
|
||||
)
|
||||
from app.downloads.status import STOPPED_BY_ROUTE, JobStatus
|
||||
from app.downloads.status import STOPPED_BY_ROUTE, JobKind, JobStatus
|
||||
from app.models import DISPLAY_TEXT_MAX, DownloadJob, MediaAsset
|
||||
|
||||
log = logs.get_logger(__name__)
|
||||
@@ -162,7 +163,12 @@ def _claim_job() -> int | None:
|
||||
seen.) The per-user limit is the quota row's `max_concurrent` (else the sysconfig default);
|
||||
`unlimited` users are never capped. A final Python `at_concurrency_limit` re-check still guards
|
||||
the tiny race between the exclusion subquery and the claim (it can only ever reject, never
|
||||
over-claim), so correctness never depends on the two observations agreeing."""
|
||||
over-claim), so correctness never depends on the two observations agreeing.
|
||||
|
||||
A suspended user's queued jobs are left where they are: suspension blocks their downloads (not
|
||||
only their sign-in), and lifting it lets them run in their old place. A job already running
|
||||
when the user is suspended finishes on that worker; one a worker restart puts back in the queue
|
||||
(`_requeue_if_running`, `_recover_orphans`) waits with the rest."""
|
||||
with SessionLocal() as db:
|
||||
default_mc = sysconfig.get_int(db, "download_default_max_concurrent")
|
||||
rows = db.execute(
|
||||
@@ -170,6 +176,10 @@ def _claim_job() -> int | None:
|
||||
"""
|
||||
SELECT id, user_id FROM download_jobs
|
||||
WHERE status='queued'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM users u
|
||||
WHERE u.id = download_jobs.user_id AND u.is_suspended
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM media_assets a
|
||||
WHERE a.id = download_jobs.asset_id AND a.status = 'downloading'
|
||||
@@ -437,7 +447,7 @@ def _run_supervised(job_id: int, asset_id: int) -> None:
|
||||
if status is None or status in STOPPED_BY_ROUTE:
|
||||
reason = "cancelled" # a route stopped it (or the row vanished)
|
||||
break
|
||||
if status == "running":
|
||||
if status == JobStatus.RUNNING:
|
||||
if stale is not None and stale > stall_s:
|
||||
reason = "stalled"
|
||||
break
|
||||
@@ -448,7 +458,7 @@ def _run_supervised(job_id: int, asset_id: int) -> None:
|
||||
# teardown. A still-running job over the cap is failed ("timeout"); a terminal one (already
|
||||
# done/error) just gets its lingering child killed, its DB status left intact ("abandon").
|
||||
if time.monotonic() - started > wall_s:
|
||||
reason = "timeout" if status == "running" else "abandon"
|
||||
reason = "timeout" if status == JobStatus.RUNNING else "abandon"
|
||||
break
|
||||
# else: a terminal status under the cap (done/error, or a re-queued contended asset) means
|
||||
# the child is exiting on its own — keep polling; NEVER kill a finishing child (that could
|
||||
@@ -479,7 +489,7 @@ def _run_supervised(job_id: int, asset_id: int) -> None:
|
||||
_reset_asset_pending(asset_id)
|
||||
if proc.returncode != _CHILD_OK:
|
||||
status, _ = _job_liveness(job_id)
|
||||
if status == "running":
|
||||
if status == JobStatus.RUNNING:
|
||||
_cleanup_staging(asset_id)
|
||||
_fail_running(
|
||||
job_id,
|
||||
@@ -560,15 +570,12 @@ def _process_job(job_id: int) -> None:
|
||||
asset_id = asset.id if asset else None
|
||||
asset_status = asset.status if asset else None
|
||||
asset_error = asset.error if asset else None
|
||||
if asset_status == "error":
|
||||
if asset is not None and asset_status == "error":
|
||||
# A job that meets an already-failed asset gets that failure's explanation — not a bare
|
||||
# "Download failed". Only the text is on the asset, so it is diagnosed again, for this
|
||||
# link, with whether a cookie goes with it asked of the same gate the download child
|
||||
# used (`credentials.usable_cookie`) — as it stands now, so the advice fits this job's Retry.
|
||||
url = service.source_url(job.source_kind, job.source_ref)
|
||||
cookie = credentials.usable_cookie(
|
||||
db, job.user_id, url, asset, formats.normalize(job.profile_snapshot)
|
||||
)
|
||||
url, cookie = _failed_link_and_cookie(db, job, asset)
|
||||
diagnosis = dlerrors.diagnose(asset_error, url=url, cookie_attached=cookie is not None)
|
||||
del cookie # only whether there is one matters here
|
||||
|
||||
@@ -605,13 +612,34 @@ def _process_job(job_id: int) -> None:
|
||||
_run_supervised(job_id, asset_id)
|
||||
|
||||
|
||||
def _failed_link_and_cookie(
|
||||
db: Session, job: DownloadJob, asset: MediaAsset
|
||||
) -> tuple[str | None, str | None]:
|
||||
"""The link a job's failed asset was fetched from, and the cookie that fetch would go with now.
|
||||
A clip's link is its source's (a `clip` ref names the user's range, no page), asked of the
|
||||
clip's own cookie gate, as its fetch was (`_clip_source`); a Plex clip fetches nothing."""
|
||||
if job.job_kind == JobKind.CLIP:
|
||||
spec = dict(job.edit_spec or {})
|
||||
if spec.get("source") != "youtube":
|
||||
return None, None
|
||||
url = service.source_url("youtube", spec["ref"])
|
||||
return url, credentials.usable_clip_cookie(db, job.user_id, url, asset, spec)
|
||||
url = service.source_url(job.source_kind, job.source_ref)
|
||||
spec = formats.normalize(job.profile_snapshot)
|
||||
return url, credentials.usable_cookie(db, job.user_id, url, asset, spec)
|
||||
|
||||
|
||||
def _finish_from_cache(job_id: int, asset_id: int) -> None:
|
||||
with SessionLocal() as db:
|
||||
job = db.get(DownloadJob, job_id)
|
||||
asset = db.get(MediaAsset, asset_id)
|
||||
if job and asset:
|
||||
fields = {"status": JobStatus.DONE, "progress": 100, "phase": None}
|
||||
if not job.display_name and asset.title:
|
||||
# A clip queued under the placeholder takes the title its sibling's fetch found.
|
||||
unnamed = not job.display_name or (
|
||||
job.job_kind == JobKind.CLIP and job.display_name == service.clip_title(None, None)
|
||||
)
|
||||
if unnamed and asset.title:
|
||||
# display_name is VARCHAR(255); a title can be longer (e.g. a recipe in the title).
|
||||
fields["display_name"] = asset.title[:DISPLAY_TEXT_MAX]
|
||||
asset.last_access_at = datetime.now(timezone.utc)
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
# Source of truth is requirements.txt (the floor ranges); regenerate after editing it with:
|
||||
# docker run --rm -v "$PWD":/b python:3.12-slim sh -c 'pip install -q -r /b/requirements.txt && pip freeze --exclude pip --exclude setuptools --exclude wheel' | sort
|
||||
# The prod Dockerfile + Dockerfile.test install THIS file; the gate's pip-audit (scripts/check.sh)
|
||||
# scans it, blocking. A security fix may be ONE pin bumped in place instead of a full
|
||||
# regeneration, which would move every other package too — then `pip install -r requirements.lock && pip check` must
|
||||
# pass. In place so far: urllib3 2.7.0 -> 2.8.0 (PYSEC-2026-4175/4176/4177).
|
||||
# scans it, blocking. A security fix, or a yt-dlp release YouTube's changes force, may be ONE pin
|
||||
# bumped in place instead of a full regeneration, which would move every other package too — then
|
||||
# `pip install -r requirements.lock && pip check` must pass. In place so far: urllib3 2.7.0 -> 2.8.0 (PYSEC-2026-4175/4176/4177); yt-dlp 2026.7.4 -> 2026.8.19
|
||||
# (it marks the clients that need a stream PO token, so the downloader stops fetching into a 403).
|
||||
APScheduler==3.11.3
|
||||
Authlib==1.7.2
|
||||
Mako==1.4.1
|
||||
@@ -62,4 +63,4 @@ uvloop==0.22.1
|
||||
watchfiles==1.2.0
|
||||
websockets==17.0.1
|
||||
yt-dlp-ejs==0.8.0
|
||||
yt-dlp==2026.7.4
|
||||
yt-dlp==2026.8.19
|
||||
|
||||
@@ -16,12 +16,14 @@ argon2-cffi>=23.1,<26
|
||||
# Download center: yt-dlp does the extraction/download; ffmpeg (OS-level in the Dockerfile)
|
||||
# does the merge + postprocessing. The [default] extra pulls yt-dlp-ejs (the challenge-solver
|
||||
# scripts Deno runs for YouTube's n-signature). yt-dlp is release-often (YouTube changes), so
|
||||
# keep the floor loose and bump it when extraction breaks.
|
||||
yt-dlp[default]>=2025.5.22
|
||||
# keep the floor loose and bump it when extraction breaks. 2026.8.19 marks the clients that need a
|
||||
# stream PO token, which the shipped player clients (config.download_player_clients) rely on.
|
||||
yt-dlp[default]>=2026.8.19
|
||||
# Browser impersonation (`impersonate`) for Facebook downloads made with the user's cookie: some
|
||||
# links answer a signed-in plain request with "Cannot parse data" and only parse as Chrome
|
||||
# (measured). The range is YT-DLP's, not ours — yt-dlp 2026.07.04 imports curl_cffi only in
|
||||
# 0.10.x–0.15.x and silently reports every target "unavailable" outside it. Move it when bumping
|
||||
# (measured). The range is YT-DLP's, not ours — yt-dlp 2026.07.04 imported curl_cffi only in
|
||||
# 0.10.x–0.15.x and silently reported every target "unavailable" outside it (2026.08.19 takes up to
|
||||
# 0.16.x; the cap stays on the measured 0.15 until a bump re-measures it). Move it when bumping
|
||||
# yt-dlp; `tests/test_facebook_impersonation.py::test_the_locked_curl_cffi_can_impersonate_chrome`
|
||||
# fails the gate if the locked pair can no longer impersonate.
|
||||
curl_cffi>=0.10,<0.16
|
||||
|
||||
@@ -175,6 +175,21 @@ def test_the_cookie_capable_clients_match_the_installed_ytdlp():
|
||||
assert set(YouTubeAdapter._AUTHED_DEFAULT) <= live
|
||||
|
||||
|
||||
def test_the_shipped_player_clients_exist_in_the_installed_ytdlp():
|
||||
# yt-dlp skips a client it does not know with a warning and falls back to its own defaults, so
|
||||
# a bump that renames or drops one (tv_simply, the one measured to deliver HD) would change
|
||||
# what every download is fetched with, silently. Read from the Settings defaults, not from
|
||||
# `settings`, which an environment may override.
|
||||
from yt_dlp.extractor.youtube._base import INNERTUBE_CLIENTS
|
||||
|
||||
from app.config import Settings
|
||||
|
||||
fields = Settings.model_fields
|
||||
for knob in ("download_player_clients", "download_player_clients_fallback"):
|
||||
names = Settings._clients(fields[knob].default)
|
||||
assert names and set(names) <= set(INNERTUBE_CLIENTS), (knob, names)
|
||||
|
||||
|
||||
def test_youtube_ydl_opts_extra_carries_player_client_and_pot():
|
||||
ea = YouTubeAdapter().ydl_opts_extra(["android_vr"])["extractor_args"]
|
||||
assert ea["youtube"]["player_client"] == ["android_vr"]
|
||||
|
||||
@@ -0,0 +1,810 @@
|
||||
"""Ad-hoc clips (downloads/clip): the spec, the one ffmpeg command every clip goes through, the route,
|
||||
and the worker path for both sources.
|
||||
|
||||
The ffmpeg command is judged at argv level: where the seek sits, which streams are mapped, what the
|
||||
video filter does to an HDR source. The worker runs with ffmpeg / yt-dlp replaced by stand-ins that
|
||||
write a file where they were told to, so what is checked is what the pipeline asks of them and what
|
||||
it stores afterwards.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app import worker as workermod
|
||||
from app.config import settings
|
||||
from app.downloads import clip as clipmod
|
||||
from app.downloads import errors as dlerrors
|
||||
from app.downloads import formats, pipeline, quota, runner, service
|
||||
from app.downloads import probe as probemod
|
||||
from app.downloads.edit import INPUT_GUARD
|
||||
from app.models import Channel, DownloadJob, MediaAsset, PlexItem, PlexLibrary, PlexShow, Video
|
||||
from app.plex import paths as plex_paths
|
||||
|
||||
# --- the spec -----------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_a_spec_is_canonical_so_the_same_marks_hit_the_same_clip():
|
||||
a = clipmod.normalize("youtube", "abcdefghijk", 10.00049, 40.0)
|
||||
b = clipmod.normalize("youtube", "abcdefghijk", 10.0, 40.0004)
|
||||
assert a == b == {"source": "youtube", "ref": "abcdefghijk", "start_s": 10.0, "end_s": 40.0}
|
||||
assert clipmod.sig(a) == clipmod.sig(b)
|
||||
assert clipmod.sig(a) != clipmod.sig({**a, "end_s": 41.0})
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("args", "reason"),
|
||||
[
|
||||
(("vimeo", "abcdefghijk", 0, 10), "bad_source"),
|
||||
(("youtube", "short", 0, 10), "bad_source"),
|
||||
(("youtube", "abcdefghij/", 0, 10), "bad_source"),
|
||||
(("plex", "12a", 0, 10), "bad_source"),
|
||||
(("youtube", "abcdefghijk", -1, 10), "bad_range"),
|
||||
(("youtube", "abcdefghijk", 0, float("nan")), "bad_range"),
|
||||
(("youtube", "abcdefghijk", 5, 5.5), "too_short"),
|
||||
(("youtube", "abcdefghijk", 30, 10), "too_short"),
|
||||
(("youtube", "abcdefghijk", 0, 600.001), "too_long"),
|
||||
],
|
||||
)
|
||||
def test_a_range_it_cannot_cut_is_refused_with_its_reason(args, reason):
|
||||
with pytest.raises(clipmod.ClipError) as e:
|
||||
clipmod.normalize(*args)
|
||||
assert e.value.reason == reason
|
||||
|
||||
|
||||
def test_the_ten_minute_cap_itself_is_allowed():
|
||||
assert clipmod.duration(clipmod.normalize("plex", "42", 0, 600)) == 600
|
||||
|
||||
|
||||
def test_an_audio_track_is_kept_for_plex_only_and_must_be_sane():
|
||||
assert clipmod.normalize("plex", "42", 0, 10, 2)["audio_track"] == 2
|
||||
assert "audio_track" not in clipmod.normalize("youtube", "abcdefghijk", 0, 10, 2)
|
||||
with pytest.raises(clipmod.ClipError, match="bad_audio"):
|
||||
clipmod.normalize("plex", "42", 0, 10, -1)
|
||||
|
||||
|
||||
def test_a_youtube_clip_links_its_video_at_the_clip_start_and_a_plex_clip_links_nothing():
|
||||
yt = clipmod.normalize("youtube", "abcdefghijk", 75.9, 90)
|
||||
assert clipmod.reference_url(yt) == "https://www.youtube.com/watch?v=abcdefghijk&t=75s"
|
||||
assert clipmod.reference_url(clipmod.normalize("plex", "42", 0, 10)) is None
|
||||
|
||||
|
||||
# --- the ffmpeg command ----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _cmd(**kw):
|
||||
args = {"audio_track": None, "hdr": False, "threads": 3, **kw}
|
||||
return clipmod.build_cmd(Path("/m/in.mkv"), Path("/s/out.mp4"), 61.5, 30.0, **args)
|
||||
|
||||
|
||||
def test_the_seek_is_on_the_input_so_the_clip_starts_on_the_marked_frame():
|
||||
cmd = _cmd()
|
||||
i = cmd.index("-i")
|
||||
assert cmd[cmd.index("-ss") + 1] == "61.500" and cmd.index("-ss") < i
|
||||
assert cmd[i + 1] == "/m/in.mkv"
|
||||
assert cmd[cmd.index("-t") + 1] == "30.000" and cmd.index("-t") > i
|
||||
# The single-file demuxer allowlist goes with every library read.
|
||||
g = cmd.index(INPUT_GUARD[0])
|
||||
assert cmd[g : g + len(INPUT_GUARD)] == INPUT_GUARD and g < i
|
||||
assert cmd[-1] == "/s/out.mp4"
|
||||
|
||||
|
||||
def test_it_maps_the_first_video_and_the_heard_audio_track_and_nothing_else():
|
||||
cmd = _cmd(audio_track=2)
|
||||
maps = [cmd[i + 1] for i, a in enumerate(cmd) if a == "-map"]
|
||||
assert maps == ["0:v:0", "0:a:2"]
|
||||
assert "-sn" in cmd and "-dn" in cmd
|
||||
# An mkv's chapters would ride into the .mp4 as a data track of their own (measured on a Blu-ray
|
||||
# rip: `bin_data`), which -dn does not drop.
|
||||
assert cmd[cmd.index("-map_chapters") + 1] == "-1"
|
||||
# No track chosen: the first one, and a source with no audio at all still cuts.
|
||||
maps = [c for i, c in enumerate(_cmd()) if i and _cmd()[i - 1] == "-map"]
|
||||
assert maps == ["0:v:0", "0:a:0?"]
|
||||
|
||||
|
||||
def test_the_output_is_phone_safe_stereo_h264_at_most_1080p():
|
||||
cmd = _cmd()
|
||||
assert cmd[cmd.index("-c:v") + 1] == "libx264"
|
||||
assert cmd[cmd.index("-pix_fmt") + 1] == "yuv420p"
|
||||
assert cmd[cmd.index("-c:a") + 1] == "aac" and cmd[cmd.index("-ac") + 1] == "2"
|
||||
assert cmd[cmd.index("-threads") + 1] == "3"
|
||||
vf = cmd[cmd.index("-vf") + 1]
|
||||
assert "min(1080,ih)" in vf and "tonemap" not in vf
|
||||
|
||||
|
||||
def test_an_hdr_source_is_tonemapped_to_sdr_before_it_is_scaled():
|
||||
vf = _cmd(hdr=True)[_cmd(hdr=True).index("-vf") + 1]
|
||||
assert vf.index("tonemap") < vf.index("zscale=t=bt709") < vf.index("min(1080,ih)")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("transfer", "hdr"),
|
||||
[("smpte2084", True), ("arib-std-b67", True), ("bt709", False), (None, False)],
|
||||
)
|
||||
def test_hdr_is_read_from_the_first_video_stream(transfer, hdr):
|
||||
data = {
|
||||
"streams": [{"codec_type": "audio"}, {"codec_type": "video", "color_transfer": transfer}]
|
||||
}
|
||||
assert clipmod.is_hdr(data) is hdr
|
||||
|
||||
|
||||
# --- the crop ----------------------------------------------------------------------------------------
|
||||
|
||||
_FRAME = {"x": 0.5, "y": 0.25, "w": 0.25, "h": 0.5, "aspect": 16 / 9}
|
||||
|
||||
|
||||
def test_a_crop_frame_is_canonical_and_a_frame_over_the_whole_box_is_no_crop():
|
||||
a = clipmod.normalize("plex", "42", 0, 10, crop={**_FRAME, "x": 0.50004})
|
||||
b = clipmod.normalize("plex", "42", 0, 10, crop=_FRAME)
|
||||
assert a["crop"] == b["crop"] == {"x": 0.5, "y": 0.25, "w": 0.25, "h": 0.5, "aspect": 1.7778}
|
||||
assert clipmod.sig(a) == clipmod.sig(b)
|
||||
whole = clipmod.normalize(
|
||||
"plex", "42", 0, 10, crop={"x": 0, "y": 0, "w": 1, "h": 1, "aspect": 1}
|
||||
)
|
||||
assert whole == clipmod.normalize("plex", "42", 0, 10)
|
||||
# A far edge a hair past the box (the client's rounding) is the box's edge.
|
||||
assert clipmod.normalize("plex", "42", 0, 10, crop={**_FRAME, "w": 0.5004})["crop"]["w"] == 0.5
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"frame",
|
||||
[
|
||||
{**_FRAME, "w": 0.05},
|
||||
{**_FRAME, "h": 0.09},
|
||||
{**_FRAME, "x": -0.1},
|
||||
{**_FRAME, "x": 0.8},
|
||||
{**_FRAME, "y": 0.6},
|
||||
{**_FRAME, "aspect": 0},
|
||||
{**_FRAME, "aspect": 9},
|
||||
{**_FRAME, "x": float("nan")},
|
||||
{"x": 0.1, "y": 0.1, "w": 0.5},
|
||||
{**_FRAME, "h": "half"},
|
||||
],
|
||||
)
|
||||
def test_a_crop_frame_it_cannot_cut_is_refused(frame):
|
||||
with pytest.raises(clipmod.ClipError) as e:
|
||||
clipmod.normalize("plex", "42", 0, 10, crop=frame)
|
||||
assert e.value.reason == "bad_crop"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("stream", "dar"),
|
||||
[
|
||||
({"width": 1920, "height": 1080}, 16 / 9),
|
||||
# An anamorphic DVD: 720×480 stored, shown 16:9.
|
||||
({"width": 720, "height": 480, "sample_aspect_ratio": "32:27"}, 16 / 9),
|
||||
({"width": 640, "height": 480, "sample_aspect_ratio": "0:1"}, 4 / 3),
|
||||
# A phone video stored landscape, shown upright.
|
||||
({"width": 1280, "height": 720, "side_data_list": [{"rotation": -90}]}, 9 / 16),
|
||||
({"width": 1280, "height": 720, "tags": {"rotate": "270"}}, 9 / 16),
|
||||
({"width": 1280, "height": 720, "side_data_list": [{"rotation": 180}]}, 16 / 9),
|
||||
({"width": None, "height": 720}, None),
|
||||
],
|
||||
)
|
||||
def test_the_display_aspect_is_the_picture_as_a_player_shows_it(stream, dar):
|
||||
got = clipmod.display_aspect(
|
||||
{"streams": [{"codec_type": "audio"}, {"codec_type": "video", **stream}]}
|
||||
)
|
||||
assert got == pytest.approx(dar) if dar else got is None
|
||||
|
||||
|
||||
def _crop_args(f: str) -> list[float]:
|
||||
# crop=trunc(iw*W/2)*2:trunc(ih*H/2)*2:trunc(iw*X/2)*2:trunc(ih*Y/2)*2 → [W, H, X, Y]
|
||||
assert f.startswith("crop=")
|
||||
return [float(p.split("*")[1].split("/")[0]) for p in f[5:].split(":")]
|
||||
|
||||
|
||||
def test_a_frame_on_a_picture_that_fills_its_box_is_cut_as_drawn():
|
||||
assert _crop_args(clipmod.crop_filter(_FRAME, 16 / 9)) == pytest.approx([0.25, 0.5, 0.5, 0.25])
|
||||
|
||||
|
||||
def test_a_frame_drawn_in_a_16_9_box_over_a_4_3_picture_maps_onto_the_picture():
|
||||
# The 4:3 picture fills 3/4 of the box's width from 1/8 in: a frame over its right half's middle.
|
||||
frame = {"x": 0.5, "y": 0.25, "w": 0.1875, "h": 0.5, "aspect": 16 / 9}
|
||||
assert _crop_args(clipmod.crop_filter(frame, 4 / 3)) == pytest.approx([0.25, 0.5, 0.5, 0.25])
|
||||
|
||||
|
||||
def test_the_part_of_a_frame_over_a_bar_is_dropped_and_a_frame_on_a_bar_alone_is_no_cut():
|
||||
over = {"x": 0.0, "y": 0.0, "w": 0.25, "h": 0.5, "aspect": 16 / 9}
|
||||
w, h, x, y = _crop_args(clipmod.crop_filter(over, 4 / 3))
|
||||
assert (w, h, x, y) == pytest.approx([1 / 6, 0.5, 0, 0], abs=1e-6)
|
||||
assert clipmod.crop_filter({**over, "w": 0.13}, 4 / 3) is None
|
||||
# A letterboxed film: the frame over the whole box is the whole picture.
|
||||
whole = {"x": 0.0, "y": 0.0, "w": 1.0, "h": 1.0, "aspect": 16 / 9}
|
||||
assert clipmod.crop_filter(whole, 2.39) == ""
|
||||
|
||||
|
||||
def test_the_crop_comes_first_so_the_tonemap_and_the_scale_work_on_what_it_keeps():
|
||||
f = clipmod.crop_filter(_FRAME, 16 / 9)
|
||||
vf = _cmd(hdr=True, crop=f)[_cmd(hdr=True, crop=f).index("-vf") + 1]
|
||||
assert vf.startswith(f + ",") and vf.index("tonemap") < vf.index("min(1080,ih)")
|
||||
assert "crop" not in _cmd()[_cmd().index("-vf") + 1]
|
||||
|
||||
|
||||
# --- the route ---------------------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def yt_video(db):
|
||||
db.add(Channel(id="UC_clip", title="The Channel"))
|
||||
db.flush()
|
||||
db.add(
|
||||
Video(
|
||||
id="abcdefghijk",
|
||||
channel_id="UC_clip",
|
||||
title="A Funny Video",
|
||||
duration_seconds=300,
|
||||
thumbnail_url="https://i.ytimg.com/vi/abcdefghijk/hq.jpg",
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
return "abcdefghijk"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def plex_episode(db, tmp_path, monkeypatch):
|
||||
lib = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True)
|
||||
db.add(lib)
|
||||
db.flush()
|
||||
show = PlexShow(rating_key="s9", library_id=lib.id, title="The Show")
|
||||
db.add(show)
|
||||
db.flush()
|
||||
db.add(
|
||||
PlexItem(
|
||||
rating_key="777",
|
||||
library_id=lib.id,
|
||||
kind="episode",
|
||||
title="Pilot",
|
||||
show_id=show.id,
|
||||
season_number=1,
|
||||
episode_number=2,
|
||||
duration_s=1800,
|
||||
file_path="/data/shows/pilot.mkv",
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
media = tmp_path / "pilot.mkv"
|
||||
media.write_bytes(b"m" * 8)
|
||||
monkeypatch.setattr(
|
||||
plex_paths,
|
||||
"local_media_path",
|
||||
lambda _db, p: media if p == "/data/shows/pilot.mkv" else None,
|
||||
)
|
||||
return media
|
||||
|
||||
|
||||
def _clip(client, **body):
|
||||
return client.post("/api/downloads/clip", json={"start_s": 10, "end_s": 40, **body})
|
||||
|
||||
|
||||
def test_a_youtube_clip_is_queued_as_its_own_job_titled_after_its_video(
|
||||
db, make_user, as_user, yt_video
|
||||
):
|
||||
r = _clip(as_user(make_user()), source="youtube", ref=yt_video)
|
||||
assert r.status_code == 200, r.text
|
||||
job = r.json()
|
||||
assert (job["job_kind"], job["status"], job["display_name"]) == (
|
||||
"clip",
|
||||
"queued",
|
||||
"A Funny Video (clip)",
|
||||
)
|
||||
assert job["edit_spec"] == {"source": "youtube", "ref": yt_video, "start_s": 10, "end_s": 40}
|
||||
assert job["source_url"] == f"https://www.youtube.com/watch?v={yt_video}&t=10s"
|
||||
asset = db.get(MediaAsset, job["asset"]["id"])
|
||||
assert (asset.duration_s, asset.uploader, asset.media_kind) == (30, "The Channel", "video")
|
||||
|
||||
|
||||
def test_the_same_range_again_is_the_same_clip_but_never_another_users(
|
||||
db, make_user, as_user, yt_video
|
||||
):
|
||||
a, b = make_user(), make_user()
|
||||
first = _clip(as_user(a), source="youtube", ref=yt_video).json()["asset"]["id"]
|
||||
again = _clip(as_user(a), source="youtube", ref=yt_video).json()["asset"]["id"]
|
||||
other = _clip(as_user(b), source="youtube", ref=yt_video).json()["asset"]["id"]
|
||||
longer = _clip(as_user(a), source="youtube", ref=yt_video, end_s=41).json()["asset"]["id"]
|
||||
assert first == again
|
||||
assert len({first, other, longer}) == 3
|
||||
|
||||
|
||||
def test_a_ready_clip_asked_for_again_is_done_at_once(db, make_user, as_user, yt_video):
|
||||
u = make_user()
|
||||
first = _clip(as_user(u), source="youtube", ref=yt_video).json()
|
||||
asset = db.get(MediaAsset, first["asset"]["id"])
|
||||
asset.status, asset.rel_path = "ready", ".edits/1/clip_1.mp4"
|
||||
db.commit()
|
||||
again = _clip(as_user(u), source="youtube", ref=yt_video).json()
|
||||
assert (again["status"], again["progress"]) == ("done", 100)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("body", "status", "reason", "params"),
|
||||
[
|
||||
# The cap travels with its refusal, so the client never states a stale one.
|
||||
({"end_s": 700}, 422, "too_long", {"limit_s": clipmod.MAX_CLIP_S}),
|
||||
({"start_s": 299.5, "end_s": 330}, 422, "past_end", {}),
|
||||
({"ref": "nope"}, 422, "bad_source", {}),
|
||||
],
|
||||
)
|
||||
def test_a_youtube_clip_it_cannot_cut_says_why(
|
||||
db, make_user, as_user, yt_video, body, status, reason, params
|
||||
):
|
||||
r = _clip(as_user(make_user()), **{"source": "youtube", "ref": yt_video, **body})
|
||||
assert r.status_code == status
|
||||
assert r.json()["detail"] == {"code": "clip", "reason": reason, **params}
|
||||
assert db.query(DownloadJob).count() == 0
|
||||
|
||||
|
||||
def test_a_plex_clip_is_titled_show_episode_title_and_keeps_its_audio_track(
|
||||
db, make_user, as_user, plex_episode
|
||||
):
|
||||
r = _clip(as_user(make_user()), source="plex", ref="777", audio_track=1)
|
||||
assert r.status_code == 200, r.text
|
||||
job = r.json()
|
||||
assert job["display_name"] == "The Show — S1·E2 — Pilot (clip)"
|
||||
assert job["edit_spec"]["audio_track"] == 1
|
||||
assert job["source_url"] is None
|
||||
|
||||
|
||||
def test_a_plex_clip_of_an_unknown_item_is_a_404_and_of_an_unreachable_file_a_422(
|
||||
db, make_user, as_user, plex_episode
|
||||
):
|
||||
c = as_user(make_user())
|
||||
assert _clip(c, source="plex", ref="999").json()["detail"]["reason"] == "not_found"
|
||||
assert _clip(c, source="plex", ref="999").status_code == 404
|
||||
item = db.query(PlexItem).one()
|
||||
item.file_path = "/data/elsewhere.mkv"
|
||||
db.commit()
|
||||
r = _clip(c, source="plex", ref="777")
|
||||
assert (r.status_code, r.json()["detail"]["reason"]) == (422, "media_missing")
|
||||
|
||||
|
||||
def test_a_clip_over_quota_is_refused_like_a_download(
|
||||
db, make_user, as_user, yt_video, monkeypatch
|
||||
):
|
||||
def full(_db, _uid):
|
||||
raise quota.QuotaExceeded("max_jobs", limit=5, current=5)
|
||||
|
||||
monkeypatch.setattr(quota, "check_enqueue", full)
|
||||
r = _clip(as_user(make_user()), source="youtube", ref=yt_video)
|
||||
assert r.status_code == 422
|
||||
assert db.query(DownloadJob).count() == 0
|
||||
|
||||
|
||||
def test_a_clip_is_not_redownloadable(db, make_user, as_user, yt_video):
|
||||
c = as_user(make_user())
|
||||
job = _clip(c, source="youtube", ref=yt_video).json()
|
||||
row = db.get(DownloadJob, job["id"])
|
||||
row.status = "done"
|
||||
db.commit()
|
||||
r = c.post(f"/api/downloads/{job['id']}/redownload")
|
||||
assert (r.status_code, r.json()["detail"]["reason"]) == (409, "not_redownloadable")
|
||||
|
||||
|
||||
# --- the worker ----------------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_a_clip_job_reaches_the_clip_entry(monkeypatch):
|
||||
called = []
|
||||
for name in ("_convert_upload", "_process_edit", "_download", "_process_clip"):
|
||||
monkeypatch.setattr(pipeline, name, lambda *a, _n=name: called.append(_n))
|
||||
runner._run(5, 7, "clip", "clip", "1:youtube:abcdefghijk", {}, 1)
|
||||
assert called == ["_process_clip"]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def worker(db, tmp_path, monkeypatch):
|
||||
"""The worker path with its external programs replaced: ffmpeg writes the file it was given, the
|
||||
probe answers what the test says, the phone-safe step and the poster pass through."""
|
||||
monkeypatch.setattr(settings, "download_root", str(tmp_path / "root"))
|
||||
seen: dict = {}
|
||||
|
||||
def ffmpeg(cmd, *_a, **_k):
|
||||
seen["cmd"] = cmd
|
||||
Path(cmd[-1]).write_bytes(b"clip")
|
||||
|
||||
monkeypatch.setattr(pipeline.editmod, "run_ffmpeg", ffmpeg)
|
||||
monkeypatch.setattr(pipeline, "_ensure_phone_safe", lambda _j, p, *_a, **_k: p)
|
||||
monkeypatch.setattr(pipeline.editmod, "ensure_poster", lambda *_a, **_k: None)
|
||||
monkeypatch.setattr(probemod, "probe_media", lambda _p: ("h264", "aac", 1920, 1080, 30))
|
||||
seen["probe"] = {"streams": [{"codec_type": "video"}, {"codec_type": "audio"}]}
|
||||
monkeypatch.setattr(probemod, "probe_json", lambda _p: seen["probe"])
|
||||
|
||||
def run(job_json):
|
||||
job = db.get(DownloadJob, job_json["id"])
|
||||
asset = db.get(MediaAsset, job.asset_id)
|
||||
job.status, asset.status = "running", "downloading"
|
||||
db.commit()
|
||||
pipeline._process_clip(job.id, asset.id)
|
||||
db.expire_all()
|
||||
return db.get(DownloadJob, job.id), db.get(MediaAsset, asset.id)
|
||||
|
||||
seen["run"] = run
|
||||
return seen
|
||||
|
||||
|
||||
def test_a_plex_clip_is_cut_from_the_library_file_with_its_audio_track(
|
||||
db, make_user, as_user, plex_episode, worker
|
||||
):
|
||||
worker["probe"] = {
|
||||
"streams": [
|
||||
{"codec_type": "video", "color_transfer": "smpte2084"},
|
||||
{"codec_type": "audio"},
|
||||
{"codec_type": "audio"},
|
||||
]
|
||||
}
|
||||
job = _clip(as_user(make_user()), source="plex", ref="777", audio_track=1).json()
|
||||
job, asset = worker["run"](job)
|
||||
assert (job.status, asset.status, asset.container, asset.height) == (
|
||||
"done",
|
||||
"ready",
|
||||
"mp4",
|
||||
1080,
|
||||
)
|
||||
cmd = worker["cmd"]
|
||||
assert cmd[cmd.index("-i") + 1] == str(plex_episode)
|
||||
assert cmd[cmd.index("-ss") + 1] == "10.000"
|
||||
assert "0:a:1" in cmd and "tonemap" in cmd[cmd.index("-vf") + 1]
|
||||
assert asset.rel_path.startswith(".edits/") and asset.rel_path.endswith(".mp4")
|
||||
|
||||
|
||||
def test_a_cropped_plex_clip_cuts_the_frame_from_the_picture_and_is_its_own_clip(
|
||||
db, make_user, as_user, plex_episode, worker
|
||||
):
|
||||
worker["probe"] = {
|
||||
"streams": [
|
||||
{"codec_type": "video", "width": 720, "height": 480, "sample_aspect_ratio": "32:27"}
|
||||
]
|
||||
}
|
||||
c = as_user(make_user())
|
||||
plain = _clip(c, source="plex", ref="777").json()
|
||||
job = _clip(c, source="plex", ref="777", crop=_FRAME).json()
|
||||
assert job["asset"]["id"] != plain["asset"]["id"]
|
||||
assert job["edit_spec"]["crop"]["w"] == 0.25
|
||||
job, asset = worker["run"](job)
|
||||
assert (job.status, asset.status) == ("done", "ready")
|
||||
vf = worker["cmd"][worker["cmd"].index("-vf") + 1]
|
||||
# Stored to four decimals, the box's 16:9 is a hair off the file's: well under a pixel.
|
||||
assert _crop_args(vf.split(",")[0]) == pytest.approx([0.25, 0.5, 0.5, 0.25], abs=1e-4)
|
||||
|
||||
|
||||
def test_a_crop_frame_that_misses_the_picture_fails_and_says_so(
|
||||
db, make_user, as_user, plex_episode, worker
|
||||
):
|
||||
# A 4:3 file, a frame drawn over its pillar bar in a 16:9 box.
|
||||
worker["probe"] = {"streams": [{"codec_type": "video", "width": 640, "height": 480}]}
|
||||
frame = {"x": 0.0, "y": 0.0, "w": 0.12, "h": 0.5, "aspect": 16 / 9}
|
||||
job = _clip(as_user(make_user()), source="plex", ref="777", crop=frame).json()
|
||||
job, asset = worker["run"](job)
|
||||
assert (job.status, asset.status) == ("error", "error")
|
||||
assert "crop" in job.error
|
||||
assert "cmd" not in worker
|
||||
|
||||
|
||||
def test_a_crop_frame_the_route_cannot_cut_says_why(db, make_user, as_user, yt_video):
|
||||
r = _clip(as_user(make_user()), source="youtube", ref=yt_video, crop={**_FRAME, "w": 0.01})
|
||||
assert r.status_code == 422
|
||||
assert r.json()["detail"] == {"code": "clip", "reason": "bad_crop"}
|
||||
|
||||
|
||||
def test_a_plex_clip_of_a_track_the_file_does_not_have_fails_and_says_so(
|
||||
db, make_user, as_user, plex_episode, worker
|
||||
):
|
||||
job = _clip(as_user(make_user()), source="plex", ref="777", audio_track=3).json()
|
||||
job, asset = worker["run"](job)
|
||||
assert (job.status, asset.status) == ("error", "error")
|
||||
assert "audio track" in job.error
|
||||
assert "cmd" not in worker
|
||||
|
||||
|
||||
def test_a_youtube_clip_fetches_only_its_range_and_cuts_it_from_zero(
|
||||
db, make_user, as_user, yt_video, worker, monkeypatch
|
||||
):
|
||||
asked: dict = {}
|
||||
|
||||
def extract(job_id, asset_id, url, spec, staging, adapter, **kw):
|
||||
asked.update(url=url, spec=spec, **kw)
|
||||
f = Path(staging) / "abcdefghijk.mp4"
|
||||
f.write_bytes(b"range")
|
||||
return {"requested_downloads": [{"filepath": str(f)}]}
|
||||
|
||||
monkeypatch.setattr(pipeline, "_extract_with_fallback", extract)
|
||||
job = _clip(as_user(make_user()), source="youtube", ref=yt_video).json()
|
||||
job, asset = worker["run"](job)
|
||||
assert (job.status, asset.status) == ("done", "ready")
|
||||
assert asked["section"] == (10, 40)
|
||||
assert asked["spec"]["max_height"] == 1080
|
||||
assert asked["url"].endswith(yt_video)
|
||||
cmd = worker["cmd"]
|
||||
assert cmd[cmd.index("-ss") + 1] == "0.000" and cmd[cmd.index("-t") + 1] == "30.000"
|
||||
assert cmd[cmd.index("-i") + 1].endswith("abcdefghijk.mp4")
|
||||
|
||||
|
||||
def test_a_youtube_clip_hands_the_range_to_yt_dlp(monkeypatch, tmp_path):
|
||||
seen = {}
|
||||
|
||||
class FakeYDL:
|
||||
def __init__(self, opts):
|
||||
seen.update(opts)
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_a):
|
||||
return False
|
||||
|
||||
def extract_info(self, *_a, **_k):
|
||||
return {}
|
||||
|
||||
monkeypatch.setattr(pipeline.yt_dlp, "YoutubeDL", FakeYDL)
|
||||
monkeypatch.setattr(pipeline, "_touch_job", lambda *_a: None)
|
||||
adapter = pipeline.adapters.resolve_adapter("youtube", "abcdefghijk")
|
||||
pipeline._extract_with_fallback(
|
||||
1,
|
||||
2,
|
||||
"https://www.youtube.com/watch?v=abcdefghijk",
|
||||
pipeline.formats.normalize({}),
|
||||
tmp_path,
|
||||
adapter,
|
||||
section=(10.0, 40.0),
|
||||
)
|
||||
ranges = list(seen["download_ranges"]({}, None))
|
||||
assert [(r["start_time"], r["end_time"]) for r in ranges] == [(10.0, 40.0)]
|
||||
assert seen["force_keyframes_at_cuts"] is False
|
||||
|
||||
|
||||
def test_service_reference_url_follows_the_clip_spec(db, make_user, as_user, yt_video):
|
||||
job = _clip(as_user(make_user()), source="youtube", ref=yt_video, start_s=65, end_s=90).json()
|
||||
row = db.get(DownloadJob, job["id"])
|
||||
assert service.reference_url(row, None) == f"https://www.youtube.com/watch?v={yt_video}&t=65s"
|
||||
|
||||
|
||||
def test_a_youtube_clip_outside_the_catalog_takes_its_title_from_the_fetch(
|
||||
db, make_user, as_user, worker, monkeypatch
|
||||
):
|
||||
def extract(job_id, asset_id, url, spec, staging, adapter, **kw):
|
||||
f = Path(staging) / "zyxwvutsrqp.mp4"
|
||||
f.write_bytes(b"range")
|
||||
return {"requested_downloads": [{"filepath": str(f)}], "title": "Found On YouTube"}
|
||||
|
||||
monkeypatch.setattr(pipeline, "_extract_with_fallback", extract)
|
||||
job = _clip(as_user(make_user()), source="youtube", ref="zyxwvutsrqp").json()
|
||||
assert job["display_name"] == "Clip (clip)"
|
||||
job, asset = worker["run"](job)
|
||||
assert (job.status, asset.title, job.display_name) == (
|
||||
"done",
|
||||
"Found On YouTube (clip)",
|
||||
"Found On YouTube (clip)",
|
||||
)
|
||||
|
||||
|
||||
# --- a YouTube clip goes with the user's own cookie, and its failure is diagnosed -----------------
|
||||
|
||||
_YT_COOKIES = "# Netscape HTTP Cookie File\n.youtube.com\tTRUE\t/\tTRUE\t0\tSID\tabc\n"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fernet(monkeypatch):
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
from app import security
|
||||
|
||||
monkeypatch.setattr(security, "_fernet", Fernet(Fernet.generate_key()))
|
||||
|
||||
|
||||
def _capture_extract(monkeypatch, asked: dict):
|
||||
def extract(job_id, asset_id, url, spec, staging, adapter, **kw):
|
||||
cookiefile = kw.get("cookiefile")
|
||||
asked["cookiefile"] = cookiefile
|
||||
asked["cookie_text"] = Path(cookiefile).read_text() if cookiefile else None
|
||||
f = Path(staging) / "abcdefghijk.mp4"
|
||||
f.write_bytes(b"range")
|
||||
return {"requested_downloads": [{"filepath": str(f)}]}
|
||||
|
||||
monkeypatch.setattr(pipeline, "_extract_with_fallback", extract)
|
||||
|
||||
|
||||
def test_a_youtube_clip_goes_signed_in_with_the_users_own_cookie_and_leaves_none_behind(
|
||||
db, make_user, as_user, yt_video, worker, monkeypatch, fernet
|
||||
):
|
||||
from app.downloads import credentials
|
||||
|
||||
user = make_user()
|
||||
credentials.upsert_credential(db, user.id, "youtube", _YT_COOKIES)
|
||||
db.commit()
|
||||
asked: dict = {}
|
||||
_capture_extract(monkeypatch, asked)
|
||||
job, _asset = worker["run"](_clip(as_user(user), source="youtube", ref=yt_video).json())
|
||||
assert job.status == "done"
|
||||
assert asked["cookie_text"] == _YT_COOKIES
|
||||
assert not Path(asked["cookiefile"]).exists()
|
||||
|
||||
|
||||
def test_a_youtube_clip_never_goes_with_another_users_cookie(
|
||||
db, make_user, as_user, yt_video, worker, monkeypatch, fernet
|
||||
):
|
||||
from app.downloads import credentials
|
||||
|
||||
other = make_user()
|
||||
credentials.upsert_credential(db, other.id, "youtube", _YT_COOKIES)
|
||||
db.commit()
|
||||
asked: dict = {}
|
||||
_capture_extract(monkeypatch, asked)
|
||||
job, _asset = worker["run"](_clip(as_user(make_user()), source="youtube", ref=yt_video).json())
|
||||
assert job.status == "done" and asked["cookiefile"] is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("with_cookie", "detail"), [(False, "youtube.bot_wall"), (True, "youtube.cookie_rejected")]
|
||||
)
|
||||
def test_a_youtube_clip_on_the_bot_wall_is_diagnosed_like_a_download(
|
||||
db, make_user, as_user, yt_video, worker, monkeypatch, fernet, with_cookie, detail
|
||||
):
|
||||
from app.downloads import credentials
|
||||
|
||||
user = make_user()
|
||||
if with_cookie:
|
||||
credentials.upsert_credential(db, user.id, "youtube", _YT_COOKIES)
|
||||
db.commit()
|
||||
|
||||
def walled(*_a, **_k):
|
||||
raise RuntimeError(f"ERROR: [youtube] {yt_video}: Sign in to confirm you’re not a bot.")
|
||||
|
||||
monkeypatch.setattr(pipeline, "_extract_with_fallback", walled)
|
||||
job, asset = worker["run"](_clip(as_user(user), source="youtube", ref=yt_video).json())
|
||||
assert (job.status, asset.status) == ("error", "error")
|
||||
assert (job.error_code, job.error_detail) == ("login_required", detail)
|
||||
|
||||
|
||||
# --- held to the owner's storage cap, and a clip that cannot or need not go on ---------------------
|
||||
|
||||
|
||||
def _fetch_range(monkeypatch):
|
||||
def extract(job_id, asset_id, url, spec, staging, adapter, **kw):
|
||||
f = Path(staging) / "abcdefghijk.mp4"
|
||||
f.write_bytes(b"range")
|
||||
return {"requested_downloads": [{"filepath": str(f)}]}
|
||||
|
||||
monkeypatch.setattr(pipeline, "_extract_with_fallback", extract)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("source", ["youtube", "plex"])
|
||||
def test_a_clip_for_an_owner_whose_storage_is_full_is_refused_before_it_is_cut(
|
||||
db, make_user, as_user, yt_video, plex_episode, worker, monkeypatch, source
|
||||
):
|
||||
_fetch_range(monkeypatch)
|
||||
ref = yt_video if source == "youtube" else "777"
|
||||
job = _clip(as_user(make_user()), source=source, ref=ref).json()
|
||||
monkeypatch.setattr(quota, "remaining_bytes", lambda _db, _uid: 0)
|
||||
job, asset = worker["run"](job)
|
||||
# Refused like a download at its cap: the job says why, the asset waits for a Retry.
|
||||
assert (job.status, job.error, asset.status) == (
|
||||
"error",
|
||||
dlerrors.QUOTA_FULL_MESSAGE,
|
||||
"pending",
|
||||
)
|
||||
assert "cmd" not in worker
|
||||
|
||||
|
||||
@pytest.mark.parametrize("source", ["youtube", "plex"])
|
||||
def test_a_clip_larger_than_what_its_owner_has_left_is_refused_and_not_placed(
|
||||
db, make_user, as_user, yt_video, plex_episode, worker, monkeypatch, source
|
||||
):
|
||||
# The range fetch's own ceiling never reaches yt-dlp's ffmpeg downloader, and a Plex clip
|
||||
# fetches nothing: the cut file itself is held to the cap.
|
||||
_fetch_range(monkeypatch)
|
||||
ref = yt_video if source == "youtube" else "777"
|
||||
job = _clip(as_user(make_user()), source=source, ref=ref).json()
|
||||
monkeypatch.setattr(quota, "remaining_bytes", lambda _db, _uid: 3) # the cut is 4 bytes
|
||||
job, asset = worker["run"](job)
|
||||
# Not "full": there is room left, just not this much.
|
||||
assert (job.status, job.error, asset.status, asset.rel_path) == (
|
||||
"error",
|
||||
dlerrors.CLIP_TOO_BIG_MESSAGE,
|
||||
"pending",
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
def test_a_youtube_clip_range_yt_dlp_refuses_for_its_cap_says_it_is_too_big(
|
||||
db, make_user, as_user, yt_video, worker, monkeypatch
|
||||
):
|
||||
# Should yt-dlp's own `max_filesize` refuse the range, it returns with no file (test_formats):
|
||||
# the owner's storage, not a processing fault, and nothing is cut.
|
||||
def refused(*_a, max_bytes=None, **_k):
|
||||
formats._YDL_LOG.debug(
|
||||
f"[download] File is larger than max-filesize (9000 bytes > {max_bytes} bytes). Aborting."
|
||||
)
|
||||
return {"requested_downloads": [{}]}
|
||||
|
||||
monkeypatch.setattr(pipeline, "_extract_with_fallback", refused)
|
||||
job = _clip(as_user(make_user()), source="youtube", ref=yt_video).json()
|
||||
monkeypatch.setattr(quota, "remaining_bytes", lambda _db, _uid: 500)
|
||||
job, asset = worker["run"](job)
|
||||
assert (job.status, job.error_code, job.error, asset.status) == (
|
||||
"error",
|
||||
"quota_exceeded",
|
||||
dlerrors.CLIP_TOO_BIG_MESSAGE,
|
||||
"pending",
|
||||
)
|
||||
assert "cmd" not in worker
|
||||
|
||||
|
||||
def test_a_plex_clip_of_a_silent_file_is_cut_silent(db, make_user, as_user, plex_episode, worker):
|
||||
# The player names the track it plays 0 even when the file has none.
|
||||
worker["probe"] = {"streams": [{"codec_type": "video"}]}
|
||||
job = _clip(as_user(make_user()), source="plex", ref="777", audio_track=0).json()
|
||||
job, asset = worker["run"](job)
|
||||
assert (job.status, asset.status) == ("done", "ready")
|
||||
assert "0:a:0?" in worker["cmd"]
|
||||
|
||||
|
||||
def test_a_plex_clip_of_a_file_the_probe_cannot_read_says_so(
|
||||
db, make_user, as_user, plex_episode, worker
|
||||
):
|
||||
worker["probe"] = None
|
||||
job = _clip(as_user(make_user()), source="plex", ref="777", audio_track=0).json()
|
||||
job, asset = worker["run"](job)
|
||||
assert (job.status, asset.status) == ("error", "error")
|
||||
assert job.error == "The source file could not be read."
|
||||
assert "cmd" not in worker
|
||||
|
||||
|
||||
@pytest.mark.parametrize("where", ["fetch", "encode"])
|
||||
def test_a_paused_clip_frees_its_asset_for_a_later_retry(
|
||||
db, make_user, as_user, yt_video, worker, monkeypatch, where
|
||||
):
|
||||
def stopped(*_a, **_k):
|
||||
raise pipeline._Aborted() if where == "fetch" else pipeline.editmod.EditAborted()
|
||||
|
||||
if where == "fetch":
|
||||
monkeypatch.setattr(pipeline, "_extract_with_fallback", stopped)
|
||||
else:
|
||||
_fetch_range(monkeypatch)
|
||||
monkeypatch.setattr(pipeline.editmod, "run_ffmpeg", stopped)
|
||||
job = _clip(as_user(make_user()), source="youtube", ref=yt_video).json()
|
||||
job, asset = worker["run"](job)
|
||||
# The route already wrote the job's own state (paused / canceled); the asset is free again.
|
||||
assert (job.status, job.error, asset.status) == ("running", None, "pending")
|
||||
|
||||
|
||||
# --- a second job on the same clip -----------------------------------------------------------------
|
||||
|
||||
|
||||
def _second_job(db, client, **body):
|
||||
"""Two jobs for one range: the second shares the first's asset."""
|
||||
first = _clip(client, **body).json()
|
||||
second = _clip(client, **body).json()
|
||||
assert first["asset"]["id"] == second["asset"]["id"]
|
||||
return db.get(DownloadJob, first["id"]), db.get(DownloadJob, second["id"])
|
||||
|
||||
|
||||
def test_a_second_clip_job_on_a_failed_clip_gets_the_same_youtube_diagnosis(
|
||||
db, make_user, as_user, yt_video
|
||||
):
|
||||
_first, second = _second_job(db, as_user(make_user()), source="youtube", ref=yt_video)
|
||||
asset = db.get(MediaAsset, second.asset_id)
|
||||
asset.status = "error"
|
||||
asset.error = f"ERROR: [youtube] {yt_video}: Sign in to confirm you’re not a bot."
|
||||
second.status = "running"
|
||||
db.commit()
|
||||
workermod._process_job(second.id)
|
||||
db.expire_all()
|
||||
job = db.get(DownloadJob, second.id)
|
||||
assert (job.status, job.error_code, job.error_detail) == (
|
||||
"error",
|
||||
"login_required",
|
||||
"youtube.bot_wall",
|
||||
)
|
||||
|
||||
|
||||
def test_a_second_clip_job_takes_the_title_its_sibling_found(db, make_user, as_user):
|
||||
_first, second = _second_job(db, as_user(make_user()), source="youtube", ref="zyxwvutsrqp")
|
||||
assert second.display_name == "Clip (clip)"
|
||||
asset = db.get(MediaAsset, second.asset_id)
|
||||
asset.status, asset.title = "ready", "Found On YouTube (clip)"
|
||||
second.status = "running"
|
||||
db.commit()
|
||||
workermod._finish_from_cache(second.id, asset.id)
|
||||
db.expire_all()
|
||||
job = db.get(DownloadJob, second.id)
|
||||
assert (job.status, job.display_name) == ("done", "Found On YouTube (clip)")
|
||||
@@ -63,6 +63,9 @@ def test_our_own_worker_refusals_classify_as_themselves():
|
||||
# fragment of the text. Pinning message and rule together is what stops an edit to the wording
|
||||
# from silently orphaning the category — the UI would then fall back to "Download failed".
|
||||
assert classify(dlerrors.QUOTA_FULL_MESSAGE) == "quota_exceeded"
|
||||
assert classify(dlerrors.CLIP_TOO_BIG_MESSAGE) == "quota_exceeded"
|
||||
assert classify(dlerrors.CONVERTED_TOO_BIG_MESSAGE) == "quota_exceeded"
|
||||
assert classify(dlerrors.DOWNLOAD_TOO_BIG_MESSAGE) == "quota_exceeded"
|
||||
|
||||
|
||||
def test_the_redacted_network_text_classifies_back_to_network():
|
||||
|
||||
@@ -11,7 +11,8 @@ its OWN session, so every test COMMITS its setup before calling it.
|
||||
"""
|
||||
|
||||
from app.config import settings
|
||||
from app.downloads import pipeline, quota
|
||||
from app.downloads import errors as dlerrors
|
||||
from app.downloads import formats, pipeline, quota
|
||||
from app.models import DownloadJob, DownloadQuota, MediaAsset, User
|
||||
from app.worker import _claim_job
|
||||
|
||||
@@ -155,6 +156,25 @@ def test_unlimited_user_is_never_excluded(db):
|
||||
assert _claim_job() == q.id
|
||||
|
||||
|
||||
def test_a_suspended_users_queued_jobs_wait(db):
|
||||
# Suspending a user blocks their downloads (quota.resolve's advice): a job they queued before
|
||||
# stays queued, unclaimed, and runs once the suspension is lifted.
|
||||
s = User(email="suspended@example.com", is_suspended=True)
|
||||
o = User(email="other@example.com")
|
||||
db.add_all([s, o])
|
||||
db.flush()
|
||||
waits = _job(db, s.id, queue_pos=1)
|
||||
served = _job(db, o.id, queue_pos=2)
|
||||
db.commit()
|
||||
assert _claim_job() == served.id
|
||||
assert _claim_job() is None
|
||||
db.expire_all()
|
||||
assert db.get(DownloadJob, waits.id).status == "queued"
|
||||
db.get(User, s.id).is_suspended = False
|
||||
db.commit()
|
||||
assert _claim_job() == waits.id
|
||||
|
||||
|
||||
# --- #3 · list cap --------------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -278,3 +298,97 @@ def test_a_refusal_for_one_user_does_not_poison_the_shared_asset(db, monkeypatch
|
||||
assert db.get(MediaAsset, shared.id).status == "pending"
|
||||
assert db.get(MediaAsset, shared.id).error is None
|
||||
assert db.get(DownloadJob, theirs.id).status == "queued"
|
||||
|
||||
|
||||
def test_a_download_over_the_space_left_says_so_and_releases_the_shared_asset(
|
||||
db, monkeypatch, tmp_path
|
||||
):
|
||||
# yt-dlp refuses a file over `max_filesize` by saying so and returning with no file (pinned in
|
||||
# test_formats). That is the owner's storage, not a processing fault: their refusal, in words
|
||||
# true of a cap with space still left, and the shared asset goes back for the next job.
|
||||
monkeypatch.setattr(settings, "download_root", str(tmp_path))
|
||||
u = User(email="nearcap@example.com")
|
||||
db.add(u)
|
||||
db.flush()
|
||||
db.add(
|
||||
DownloadQuota(user_id=u.id, max_bytes=1000, max_concurrent=2, max_jobs=100, unlimited=False)
|
||||
)
|
||||
held = _sized_asset(db, 400, "near-held")
|
||||
_job(db, u.id, queue_pos=0, status="done", asset_id=held.id)
|
||||
shared = MediaAsset(
|
||||
source_kind="youtube",
|
||||
source_ref="y" * 11,
|
||||
format_sig="s",
|
||||
status="downloading",
|
||||
ref_count=1,
|
||||
)
|
||||
db.add(shared)
|
||||
db.flush()
|
||||
mine = _job(db, u.id, queue_pos=1, status="running", asset_id=shared.id)
|
||||
db.commit()
|
||||
caps = []
|
||||
|
||||
def refuses_over_the_cap(*args, max_bytes=None, **kwargs):
|
||||
caps.append(max_bytes)
|
||||
formats._YDL_LOG.debug(
|
||||
f"[download] File is larger than max-filesize (2000 bytes > {max_bytes} bytes). Aborting."
|
||||
)
|
||||
return {"id": "y" * 11, "requested_downloads": [{}]}
|
||||
|
||||
monkeypatch.setattr(pipeline, "_extract_with_fallback", refuses_over_the_cap)
|
||||
pipeline._download(mine.id, shared.id, "youtube", "y" * 11, {}, u.id)
|
||||
|
||||
db.expire_all()
|
||||
job = db.get(DownloadJob, mine.id)
|
||||
assert caps == [600]
|
||||
assert (job.status, job.error_code) == ("error", "quota_exceeded")
|
||||
assert job.error == dlerrors.DOWNLOAD_TOO_BIG_MESSAGE
|
||||
assert db.get(MediaAsset, shared.id).status == "pending"
|
||||
|
||||
|
||||
def test_a_download_that_outgrows_the_space_left_unannounced_is_refused_and_not_placed(
|
||||
db, monkeypatch, tmp_path
|
||||
):
|
||||
# yt-dlp's `max_filesize` only stops a plain HTTP file that announces its size: YouTube's
|
||||
# fragmented formats never meet it (measured on localdev: 718 MB fetched with 1 MB left). The
|
||||
# file it produced is held to what is left by its real size, as a clip is.
|
||||
monkeypatch.setattr(settings, "download_root", str(tmp_path))
|
||||
u = User(email="unannounced@example.com")
|
||||
db.add(u)
|
||||
db.flush()
|
||||
db.add(
|
||||
DownloadQuota(user_id=u.id, max_bytes=1000, max_concurrent=2, max_jobs=100, unlimited=False)
|
||||
)
|
||||
held = _sized_asset(db, 400, "unannounced-held")
|
||||
_job(db, u.id, queue_pos=0, status="done", asset_id=held.id)
|
||||
shared = MediaAsset(
|
||||
source_kind="youtube",
|
||||
source_ref="z" * 11,
|
||||
format_sig="s",
|
||||
status="downloading",
|
||||
ref_count=1,
|
||||
)
|
||||
db.add(shared)
|
||||
db.flush()
|
||||
mine = _job(db, u.id, queue_pos=1, status="running", asset_id=shared.id)
|
||||
db.commit()
|
||||
|
||||
def fetched_past_the_cap(job_id, asset_id, url, spec, staging, adapter, **kw):
|
||||
f = staging / "zzzzzzzzzzz.mp4"
|
||||
f.write_bytes(b"v" * 2000) # 600 left
|
||||
return {"id": "z" * 11, "requested_downloads": [{"filepath": str(f)}]}
|
||||
|
||||
monkeypatch.setattr(pipeline, "_extract_with_fallback", fetched_past_the_cap)
|
||||
monkeypatch.setattr(pipeline, "_ensure_phone_safe", lambda _j, produced, *_a: produced)
|
||||
pipeline._download(mine.id, shared.id, "youtube", "z" * 11, {}, u.id)
|
||||
|
||||
db.expire_all()
|
||||
job = db.get(DownloadJob, mine.id)
|
||||
assert (job.status, job.error_code, job.error) == (
|
||||
"error",
|
||||
"quota_exceeded",
|
||||
dlerrors.DOWNLOAD_TOO_BIG_MESSAGE,
|
||||
)
|
||||
asset = db.get(MediaAsset, shared.id)
|
||||
assert (asset.status, asset.rel_path) == ("pending", None)
|
||||
assert not any(p.suffix == ".mp4" for p in tmp_path.rglob("*") if "staging" not in p.parts)
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
"""An editor clip is held to its owner's storage cap by its real size, as a download and a player
|
||||
clip are: its file is new storage on the owner's footprint, and nothing else checked it.
|
||||
|
||||
Real ffmpeg — the size the check reads is the encoder's.
|
||||
"""
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
from app.config import settings
|
||||
from app.downloads import errors as dlerrors
|
||||
from app.downloads import pipeline, quota, service
|
||||
from app.downloads import upload as uploadmod
|
||||
from app.models import DownloadJob, DownloadQuota, MediaAsset
|
||||
|
||||
pytestmark = pytest.mark.skipif(shutil.which("ffmpeg") is None, reason="ffmpeg not available")
|
||||
|
||||
|
||||
def test_an_editor_clip_larger_than_what_is_left_is_refused_and_not_placed(
|
||||
db, make_user, tmp_path, monkeypatch
|
||||
):
|
||||
monkeypatch.setattr(settings, "download_root", str(tmp_path))
|
||||
src = tmp_path / "in.mp4"
|
||||
subprocess.run(
|
||||
[
|
||||
"ffmpeg", "-y", "-loglevel", "error",
|
||||
"-f", "lavfi", "-i", "testsrc2=size=160x120:rate=10:duration=2",
|
||||
"-c:v", "libx264", "-pix_fmt", "yuv420p", str(src),
|
||||
],
|
||||
check=True,
|
||||
) # fmt: skip
|
||||
u = make_user()
|
||||
source = uploadmod.ingest_upload(
|
||||
db, u.id, filename="clip.mp4", src_path=src, size=src.stat().st_size
|
||||
)
|
||||
job = service.enqueue_edit(db, u.id, source, {"trim": {"start_s": 0, "end_s": 1.5}})
|
||||
asset = db.get(MediaAsset, job.asset_id)
|
||||
job.status, asset.status = "running", "downloading" # the worker's claim
|
||||
# Ten bytes left: no clip of a real video fits.
|
||||
db.add(
|
||||
DownloadQuota(
|
||||
user_id=u.id,
|
||||
max_bytes=quota.footprint(db, u.id) + 10,
|
||||
max_concurrent=2,
|
||||
max_jobs=100,
|
||||
unlimited=False,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
pipeline._process_edit(job.id, asset.id)
|
||||
|
||||
db.expire_all()
|
||||
job, asset = db.get(DownloadJob, job.id), db.get(MediaAsset, job.asset_id)
|
||||
assert (job.status, job.error_code, job.error) == (
|
||||
"error",
|
||||
"quota_exceeded",
|
||||
dlerrors.CLIP_TOO_BIG_MESSAGE,
|
||||
)
|
||||
assert asset.rel_path is None
|
||||
assert not list(tmp_path.glob(".edits/**/*.mp4"))
|
||||
@@ -3,6 +3,13 @@ playability guarantee) and must SELECT H.264 (not merely prefer it), so shared d
|
||||
every browser incl. iOS/Safari. Non-mp4 containers and non-H.264 vcodecs are coerced, not honoured.
|
||||
See the iPad AV1 broken-player fix + the format narrowing."""
|
||||
|
||||
import contextlib
|
||||
import functools
|
||||
import http.server
|
||||
import threading
|
||||
|
||||
import yt_dlp
|
||||
|
||||
from app.downloads import edit as editmod
|
||||
from app.downloads import formats
|
||||
|
||||
@@ -162,3 +169,40 @@ def test_the_mp3_fix_moved_the_audio_cache_identity(monkeypatch):
|
||||
current = formats.format_sig(audio)
|
||||
monkeypatch.setattr(formats, "_AUDIO_SIG_VERSION", 4)
|
||||
assert formats.format_sig(audio) != current
|
||||
|
||||
|
||||
class _QuietFiles(http.server.SimpleHTTPRequestHandler):
|
||||
def log_message(self, *args): # the test's output is yt-dlp's, not the server's
|
||||
pass
|
||||
|
||||
def handle(self):
|
||||
# yt-dlp hangs up on a file over the cap as soon as it reads its size.
|
||||
with contextlib.suppress(ConnectionError):
|
||||
super().handle()
|
||||
|
||||
|
||||
def test_yt_dlp_refuses_a_file_over_the_cap_without_raising_and_the_watch_sees_it(tmp_path):
|
||||
# The boundary `watching_size_cap` reads, held against the yt-dlp this image pins: over
|
||||
# `max_filesize` its HTTP downloader logs one line and RETURNS — no exception, no file — so
|
||||
# without the watch the pipeline would only find "no output file" and call it a processing fault.
|
||||
served = tmp_path / "served"
|
||||
served.mkdir()
|
||||
(served / "v.mp4").write_bytes(b"\0" * 200_000)
|
||||
srv = http.server.ThreadingHTTPServer(
|
||||
("127.0.0.1", 0), functools.partial(_QuietFiles, directory=str(served))
|
||||
)
|
||||
threading.Thread(target=srv.serve_forever, daemon=True).start()
|
||||
url = f"http://127.0.0.1:{srv.server_port}/v.mp4"
|
||||
try:
|
||||
for cap, refused in ((1_000, True), (10_000_000, False)):
|
||||
out = tmp_path / f"out-{cap}"
|
||||
opts = formats.build_ydl_opts(
|
||||
{}, str(out / "%(id)s.%(ext)s"), lambda d: None, max_bytes=cap
|
||||
)
|
||||
with formats.watching_size_cap() as over, yt_dlp.YoutubeDL(opts) as ydl:
|
||||
ydl.extract_info(url, download=True)
|
||||
assert over[0] is refused, cap
|
||||
files = [p for p in out.glob("*") if p.suffix == ".mp4"] if out.exists() else []
|
||||
assert bool(files) is not refused, cap
|
||||
finally:
|
||||
srv.shutdown()
|
||||
|
||||
@@ -73,10 +73,17 @@ def test_every_path_to_ready_runs_the_rule():
|
||||
found.append(where)
|
||||
if not runs_rule:
|
||||
skipped.append(where)
|
||||
# The paths known today: a download, an editor clip, an upload's conversion, an upload kept as is.
|
||||
# The paths known today: a download, an editor clip, an ad-hoc clip, an upload's conversion, an
|
||||
# upload kept as is.
|
||||
# Fewer means the scan went blind (a rename, a new status spelling) — not that a path is gone.
|
||||
names = {w.rsplit(" ", 1)[1] for w in found}
|
||||
assert {"_download", "_process_edit", "_convert_upload", "ingest_upload"} <= names, found
|
||||
assert {
|
||||
"_download",
|
||||
"_process_edit",
|
||||
"_process_clip",
|
||||
"_convert_upload",
|
||||
"ingest_upload",
|
||||
} <= names, found
|
||||
assert skipped == [], f"marks an asset ready without the phone-safe rule: {skipped}"
|
||||
|
||||
|
||||
|
||||
@@ -159,6 +159,10 @@ def test_a_collection_is_created_with_a_well_formed_uri(plex):
|
||||
assert plex.seen[-1].method == "POST"
|
||||
assert params["title"] == "Best of: 90s" and params["sectionId"] == "3"
|
||||
assert params["uri"] == "server://abc/com.plexapp.plugins.library/library/metadata/42"
|
||||
assert params["type"] == "1" # a movie collection unless told otherwise
|
||||
# A show collection is made as one: made as a movie one, it came out empty in a TV section.
|
||||
plex.create_collection("3", "Shows", "42", 2)
|
||||
assert plex.seen[-1].url.params["type"] == "2"
|
||||
|
||||
plex.routes["/library/collections"] = {}
|
||||
with pytest.raises(PlexError):
|
||||
|
||||
@@ -5,6 +5,7 @@ DB lane (`db` fixture) — skipped without Postgres.
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import select
|
||||
|
||||
@@ -14,6 +15,7 @@ from app.db import get_db
|
||||
from app.main import app
|
||||
from app.models import PlexCollection, PlexItem, PlexLibrary, PlexShow, User
|
||||
from app.plex.client import PlexError
|
||||
from app.plex.lookups import by_rating_key
|
||||
from app.routes.plex import collections as coll_routes
|
||||
|
||||
|
||||
@@ -224,8 +226,9 @@ class _CollStub:
|
||||
if self.fail_on == name:
|
||||
raise PlexError("boom")
|
||||
|
||||
def create_collection(self, section_key, title, item_rating_key):
|
||||
def create_collection(self, section_key, title, item_rating_key, item_type=1):
|
||||
self._maybe("create_collection")
|
||||
self.created_type = item_type
|
||||
return {"ratingKey": "NEW"}
|
||||
|
||||
def add_collection_item(self, rk, item):
|
||||
@@ -320,6 +323,22 @@ def test_create_writes_through_to_plex(db, monkeypatch, override_dep):
|
||||
assert body["editable"] is True # create marks the new collection editable
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("seed", "plex_type"), [("s1", 2), ("m1", 1)])
|
||||
def test_create_makes_a_show_collection_as_one(db, monkeypatch, override_dep, seed, plex_type):
|
||||
# Measured on a real Plex: a collection made as a movie one (type 1) in a TV section came out
|
||||
# empty with a show seed, and refused the show when it was added after.
|
||||
lib, user = _seed_editable(db)
|
||||
db.add(PlexShow(rating_key="s1", library_id=lib.id, title="Series"))
|
||||
db.commit()
|
||||
stub = _CollStub()
|
||||
client = _admin_client(db, user, monkeypatch, override_dep, stub, resync=_resync_create)
|
||||
r = client.post(
|
||||
"/api/plex/collections", json={"library": "", "title": "New", "item_rating_key": seed}
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert stub.created_type == plex_type
|
||||
|
||||
|
||||
def test_create_resync_failure_is_422(db, monkeypatch, override_dep):
|
||||
# The collection was created in Plex (wrote=True) but the follow-up resync read failed → 422 with
|
||||
# the honest "reconcile on the next sync" detail, not a false "write failed".
|
||||
@@ -337,6 +356,19 @@ def test_create_resync_failure_is_422(db, monkeypatch, override_dep):
|
||||
assert r.status_code == 422, r.text
|
||||
assert r.json()["detail"] == {"code": "refused", "reason": "collection_pending_sync"}
|
||||
assert "create_collection" in stub.calls
|
||||
# It exists on Plex: its row stays, editable, until the next sync fills it in — so it is offered
|
||||
# where collections are added to, and the same name is not made twice.
|
||||
row = by_rating_key(db, PlexCollection, "NEW")
|
||||
assert row is not None and row.editable and row.title == "Fresh Coll"
|
||||
# Plex made it with the seed in it: the seed's row says so, so the title shows ticked in it.
|
||||
assert row.child_count == 1
|
||||
assert "NEW" in (by_rating_key(db, PlexItem, "m1").collection_keys or [])
|
||||
again = client.post(
|
||||
"/api/plex/collections",
|
||||
json={"library": "1", "title": "Fresh Coll", "item_rating_key": "m1"},
|
||||
)
|
||||
assert again.status_code == 409, again.text
|
||||
assert stub.calls.count("create_collection") == 1
|
||||
|
||||
|
||||
def test_mutation_failure_returns_422_write_failed(db, monkeypatch, override_dep):
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
"""A movie or show card's "Add to collection": the collections it can go into, each saying whether the
|
||||
title is in it already. DB lane (`db` fixture)."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import app.state as state
|
||||
from app.auth import current_user
|
||||
from app.db import get_db
|
||||
from app.main import app
|
||||
from app.models import PlexCollection, PlexItem, PlexLibrary, PlexShow, User
|
||||
|
||||
|
||||
def _seed(db):
|
||||
movies = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
||||
shows = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True)
|
||||
db.add_all([movies, shows])
|
||||
db.flush()
|
||||
enriched = datetime(2024, 1, 1, tzinfo=timezone.utc)
|
||||
db.add_all(
|
||||
[
|
||||
PlexItem(
|
||||
rating_key="m1",
|
||||
title="Film",
|
||||
kind="movie",
|
||||
library_id=movies.id,
|
||||
collection_keys=["cA"],
|
||||
people_enriched_at=enriched,
|
||||
),
|
||||
PlexItem(
|
||||
rating_key="e1",
|
||||
title="An episode",
|
||||
kind="episode",
|
||||
library_id=shows.id,
|
||||
people_enriched_at=enriched,
|
||||
),
|
||||
PlexShow(rating_key="s1", title="Series", library_id=shows.id, collection_keys=[]),
|
||||
# The movie library's: two manual ones it can go into, and three it cannot.
|
||||
PlexCollection(rating_key="cA", library_id=movies.id, title="Alpha", editable=True),
|
||||
PlexCollection(rating_key="cB", library_id=movies.id, title="Beta", editable=True),
|
||||
PlexCollection(rating_key="cP", library_id=movies.id, title="Plain", editable=False),
|
||||
PlexCollection(
|
||||
rating_key="cS", library_id=movies.id, title="Smart", editable=True, smart=True
|
||||
),
|
||||
PlexCollection(
|
||||
rating_key="cI", library_id=movies.id, title="IMDb Top 250", editable=True
|
||||
),
|
||||
# The show library's own.
|
||||
PlexCollection(rating_key="cT", library_id=shows.id, title="Sci-fi", editable=True),
|
||||
]
|
||||
)
|
||||
user = User(email="collfor@example.com")
|
||||
db.add(user)
|
||||
db.commit()
|
||||
return user
|
||||
|
||||
|
||||
def _get(db, monkeypatch, override_dep, rk):
|
||||
user = _seed(db)
|
||||
monkeypatch.setattr(state, "_configured_cache", True)
|
||||
override_dep(get_db, db.info["request"])
|
||||
override_dep(current_user, lambda: user)
|
||||
return TestClient(app).get(f"/api/plex/collections/for/{rk}")
|
||||
|
||||
|
||||
def test_a_movie_lists_its_librarys_manual_collections_and_which_hold_it(
|
||||
db, monkeypatch, override_dep
|
||||
):
|
||||
resp = _get(db, monkeypatch, override_dep, "m1")
|
||||
assert resp.status_code == 200
|
||||
got = [(c["id"], c["contains"]) for c in resp.json()["collections"]]
|
||||
assert got == [("cA", True), ("cB", False)]
|
||||
|
||||
|
||||
def test_a_show_lists_its_own_librarys(db, monkeypatch, override_dep):
|
||||
resp = _get(db, monkeypatch, override_dep, "s1")
|
||||
assert [(c["id"], c["contains"]) for c in resp.json()["collections"]] == [("cT", False)]
|
||||
|
||||
|
||||
def test_an_episode_is_not_a_collection_member(db, monkeypatch, override_dep):
|
||||
assert _get(db, monkeypatch, override_dep, "e1").status_code == 404
|
||||
|
||||
|
||||
def test_an_unknown_key_is_not_found(db, monkeypatch, override_dep):
|
||||
assert _get(db, monkeypatch, override_dep, "nope").status_code == 404
|
||||
@@ -19,7 +19,7 @@ import app.state as state
|
||||
from app.auth import current_user
|
||||
from app.db import get_db
|
||||
from app.main import app
|
||||
from app.models import PlexItem, PlexLibrary, PlexShow, User
|
||||
from app.models import PlexItem, PlexLibrary, PlexSeason, PlexShow, PlexState, User
|
||||
from app.plex import detail
|
||||
from app.plex.sync import _apply_facet_fields, _enrich_people_batch
|
||||
|
||||
@@ -276,8 +276,16 @@ def test_item_detail_episode_serves_rich_from_live(db, monkeypatch, override_dep
|
||||
show = PlexShow(rating_key="s1", library_id=lib.id, title="A Show")
|
||||
db.add(show)
|
||||
db.flush()
|
||||
season = PlexSeason(rating_key="se1", show_id=show.id, season_number=1)
|
||||
db.add(season)
|
||||
db.flush()
|
||||
ep = PlexItem(
|
||||
rating_key="e1", library_id=lib.id, kind="episode", title="Pilot", show_id=show.id
|
||||
rating_key="e1",
|
||||
library_id=lib.id,
|
||||
kind="episode",
|
||||
title="Pilot",
|
||||
show_id=show.id,
|
||||
season_id=season.id,
|
||||
)
|
||||
db.add(ep)
|
||||
db.commit()
|
||||
@@ -289,6 +297,8 @@ def test_item_detail_episode_serves_rich_from_live(db, monkeypatch, override_dep
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["imdb_id"] == "tt0068646" # from the live meta, not the (NULL) row
|
||||
# The page's links up to the show and the season.
|
||||
assert (body["show_id"], body["season_id"]) == ("s1", "se1")
|
||||
assert [c["name"] for c in body["cast"]] == ["Marlon Brando", "Al Pacino"]
|
||||
db.refresh(ep)
|
||||
assert ep.full_cast is None # episodes are not persisted/enriched
|
||||
@@ -436,3 +446,63 @@ def test_item_detail_markers_carry_plex_final_flag(db, monkeypatch, override_dep
|
||||
{"type": "credits", "start_s": 6811.0, "end_s": 6831.0, "final": False},
|
||||
{"type": "credits", "start_s": 7549.0, "end_s": 7559.0, "final": True},
|
||||
]
|
||||
|
||||
|
||||
def _show_with_episodes(db, n=3):
|
||||
lib = PlexLibrary(plex_key="3", title="Shows", kind="show", enabled=True)
|
||||
db.add(lib)
|
||||
db.flush()
|
||||
show = PlexShow(rating_key="sn1", library_id=lib.id, title="Next Show")
|
||||
db.add(show)
|
||||
db.flush()
|
||||
eps = [
|
||||
PlexItem(
|
||||
rating_key=f"sn1e{i}",
|
||||
library_id=lib.id,
|
||||
kind="episode",
|
||||
title=f"Episode {i}",
|
||||
show_id=show.id,
|
||||
season_number=1,
|
||||
episode_number=i,
|
||||
)
|
||||
for i in range(1, n + 1)
|
||||
]
|
||||
db.add_all(eps)
|
||||
db.flush()
|
||||
return eps
|
||||
|
||||
|
||||
def test_show_next_plays_the_first_episode_of_an_untouched_show(db, monkeypatch, override_dep):
|
||||
"""A show card's picture plays: never started → its first episode."""
|
||||
_show_with_episodes(db)
|
||||
user = _user(db, "next1@example.com")
|
||||
db.commit()
|
||||
resp = _get(db, user, monkeypatch, override_dep, "/api/plex/show/sn1/next")
|
||||
assert resp.status_code == 200, resp.text
|
||||
nxt = resp.json()["next"]
|
||||
assert nxt["id"] == "sn1e1"
|
||||
assert nxt["show_title"] == "Next Show"
|
||||
|
||||
|
||||
def test_show_next_continues_the_episode_in_progress(db, monkeypatch, override_dep):
|
||||
"""Started → the episode in progress (the show page's Resume), not the first unwatched."""
|
||||
eps = _show_with_episodes(db)
|
||||
user = _user(db, "next2@example.com")
|
||||
db.add(PlexState(user_id=user.id, item_id=eps[0].id, status="watched"))
|
||||
db.add(PlexState(user_id=user.id, item_id=eps[2].id, status="new", position_seconds=120))
|
||||
db.commit()
|
||||
resp = _get(db, user, monkeypatch, override_dep, "/api/plex/show/sn1/next")
|
||||
assert resp.json()["next"]["id"] == "sn1e3"
|
||||
assert resp.json()["next"]["position_seconds"] == 120
|
||||
|
||||
|
||||
def test_show_next_is_null_for_an_empty_show_and_404_for_an_unknown_one(
|
||||
db, monkeypatch, override_dep
|
||||
):
|
||||
_show_with_episodes(db, n=0)
|
||||
user = _user(db, "next3@example.com")
|
||||
db.commit()
|
||||
assert _get(db, user, monkeypatch, override_dep, "/api/plex/show/sn1/next").json() == {
|
||||
"next": None
|
||||
}
|
||||
assert _get(db, user, monkeypatch, override_dep, "/api/plex/show/nope/next").status_code == 404
|
||||
|
||||
@@ -165,6 +165,30 @@ def test_continue_watching_movies_and_shows(db, monkeypatch, override_dep):
|
||||
assert "m1" not in ids
|
||||
|
||||
|
||||
def test_a_continue_watching_show_says_whether_it_has_an_episode_to_resume(
|
||||
db, monkeypatch, override_dep
|
||||
):
|
||||
# Both in-progress rails (Discover's and Home's) agree with the one rollup: a show part watched
|
||||
# with nothing to resume offers Play alone, not Continue + Restart.
|
||||
from app.discover.queries import plex_in_progress
|
||||
|
||||
user = _seed(db)
|
||||
|
||||
def rails():
|
||||
d = _get(db, user, monkeypatch, override_dep, "/api/plex/discover?scope=both").json()
|
||||
home = {c["id"]: c for _, c in plex_in_progress(db, user.id, lang="en")}
|
||||
return {c["id"]: c for c in d["continue_watching"]}["sh1"], home["sh1"]
|
||||
|
||||
assert [c["resumable"] for c in rails()] == [True, True]
|
||||
ep1 = db.execute(select(PlexItem).where(PlexItem.rating_key == "sh1e1")).scalar_one()
|
||||
st = db.execute(
|
||||
select(PlexState).where(PlexState.user_id == user.id, PlexState.item_id == ep1.id)
|
||||
).scalar_one()
|
||||
st.status, st.position_seconds = "watched", 0
|
||||
db.commit()
|
||||
assert [(c["status"], c["resumable"]) for c in rails()] == [("in_progress", False)] * 2
|
||||
|
||||
|
||||
def test_continue_watching_null_status_in_hidden_out(db, monkeypatch, override_dep):
|
||||
# A started movie whose PlexState.status is NULL is still "in progress": `NULL NOT IN (...)` is NULL
|
||||
# (not TRUE), so a bare notin_ would wrongly drop it — the or_(is_(None), notin_) branch keeps it.
|
||||
@@ -316,6 +340,35 @@ def test_surprise_excludes_watched_and_caps_per_kind(db, monkeypatch, override_d
|
||||
assert len([c for c in items if c["type"] == "show"]) <= 3
|
||||
|
||||
|
||||
def test_a_reshuffle_deals_other_titles_first(db, monkeypatch, override_dep):
|
||||
# The ↻ sends the previous draw: what is left of the pool comes first, and a pool smaller than a
|
||||
# draw is topped up from the excluded titles rather than leaving the rail short.
|
||||
user = _seed(db)
|
||||
|
||||
def movies(url: str) -> list[str]:
|
||||
items = _get(db, user, monkeypatch, override_dep, url).json()["items"]
|
||||
return [c["id"] for c in items if c["type"] == "movie"]
|
||||
|
||||
for _ in range(5): # random order: one lucky pass would prove nothing
|
||||
got = movies("/api/plex/surprise?scope=movie&exclude=m2")
|
||||
assert got[0] == "m3" and set(got) == {"m2", "m3"}
|
||||
assert set(movies("/api/plex/surprise?scope=movie&exclude=m2&exclude=m3")) == {"m2", "m3"}
|
||||
|
||||
|
||||
def test_a_kept_draw_is_read_again_not_redrawn(db, monkeypatch, override_dep):
|
||||
# The rail's draw stands until the ↻, but its cards' watch state moves: `keep` answers the titles on
|
||||
# screen, in their order and as they stand now, a watched one included; the scope still bounds it.
|
||||
user = _seed(db)
|
||||
url = "/api/plex/surprise?scope=both&keep=m3&keep=sh1&keep=m1&keep=nope"
|
||||
items = _get(db, user, monkeypatch, override_dep, url).json()["items"]
|
||||
assert _ids(items) == ["m3", "sh1", "m1"]
|
||||
by_id = {c["id"]: c for c in items}
|
||||
assert by_id["m1"]["status"] == "watched"
|
||||
assert by_id["m3"]["position_seconds"] > 0
|
||||
movie_only = "/api/plex/surprise?scope=movie&keep=sh1&keep=m2"
|
||||
assert _ids(_get(db, user, monkeypatch, override_dep, movie_only).json()["items"]) == ["m2"]
|
||||
|
||||
|
||||
def test_discover_scope_movie_excludes_shows(db, monkeypatch, override_dep):
|
||||
user = _seed(db)
|
||||
d = _get(db, user, monkeypatch, override_dep, "/api/plex/discover?scope=movie").json()
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
"""Plex summaries and taglines are stored as plain text.
|
||||
|
||||
A Hungarian TV agent hands summaries over as HTML (`<p>A műsorban … párosnak …</p>`), and
|
||||
the info page and the hover card showed the tags. `plain_text` is the one cleaner: the sync's every
|
||||
summary path and the HU overlay store through it, and migration 0101 cleaned the rows written before.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db import engine
|
||||
from app.models import PlexCollection, PlexItem, PlexLibrary, PlexShow
|
||||
from app.plex.detail import rich_detail
|
||||
from app.plex.overlay import sync_overlay
|
||||
from app.plex.plaintext import plain_text
|
||||
from app.plex import sync
|
||||
from app.plex.sync import _apply_item, _upsert_collection
|
||||
|
||||
HU_SUMMARY = "<p>A műsorban minden párosnak stoppal kell eljutnia.</p>"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"raw, clean",
|
||||
[
|
||||
(HU_SUMMARY, "A műsorban minden párosnak stoppal kell eljutnia."),
|
||||
("<p>One.</p><p>Two.</p>", "One.\n\nTwo."),
|
||||
("Line one<br>line two<BR/>three", "Line one\nline two\nthree"),
|
||||
("<b>Bold</b> & <i>more</i>", "Bold & more"),
|
||||
# Prose that only looks like markup stays as written.
|
||||
("a < b and 1 <3 you", "a < b and 1 <3 you"),
|
||||
# Only a reference with its semicolon is one: an ampersand in prose stays as written.
|
||||
("AT¬ice, R© and H&M", "AT¬ice, R© and H&M"),
|
||||
# ...and only a whole entity name: a legacy one at its start ("¬", "&") is not.
|
||||
("AT¬ice; and &x;", "AT¬ice; and &x;"),
|
||||
("été ©", "été ©"),
|
||||
# A decimal reference too long for int() to parse is out of range, like �.
|
||||
("a &#" + "9" * 5000 + "; b", "a � b"),
|
||||
("&#" + "0" * 5000 + "65;", "A"),
|
||||
("Plain summary.", "Plain summary."),
|
||||
(" padded ", "padded"),
|
||||
("<p></p>", None),
|
||||
("", None),
|
||||
(None, None),
|
||||
],
|
||||
)
|
||||
def test_plain_text(raw, clean):
|
||||
assert plain_text(raw) == clean
|
||||
|
||||
|
||||
def test_the_item_sync_stores_a_summary_as_plain_text():
|
||||
row = PlexItem()
|
||||
_apply_item(row, lib_id=1, kind="episode", meta={"title": "Ep", "summary": HU_SUMMARY})
|
||||
assert row.summary == "A műsorban minden párosnak stoppal kell eljutnia."
|
||||
|
||||
|
||||
def test_the_show_sync_stores_a_summary_as_plain_text(db, monkeypatch):
|
||||
lib = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True)
|
||||
db.add(lib)
|
||||
db.commit()
|
||||
meta = {"ratingKey": "s1", "title": "Show", "summary": HU_SUMMARY}
|
||||
|
||||
def listing(_plex, _key, typ, progress=None):
|
||||
if progress is not None:
|
||||
progress["complete"] = True
|
||||
return [meta] if typ == sync._TYPE_SHOW else []
|
||||
|
||||
monkeypatch.setattr(sync, "_paginate", listing)
|
||||
sync._sync_shows(db, object(), lib, {"shows": 0, "seasons": 0, "episodes": 0})
|
||||
db.commit()
|
||||
show = db.execute(select(PlexShow).where(PlexShow.rating_key == "s1")).scalar_one()
|
||||
assert show.summary == "A műsorban minden párosnak stoppal kell eljutnia."
|
||||
|
||||
|
||||
def test_a_collection_stores_its_summary_as_plain_text(db):
|
||||
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
||||
db.add(lib)
|
||||
db.flush()
|
||||
col = _upsert_collection(db, None, "c1", lib.id, {"title": "C", "summary": "<p>A & B</p>"})
|
||||
assert col.summary == "A & B"
|
||||
|
||||
|
||||
def test_a_fetched_tagline_is_plain_text():
|
||||
assert rich_detail({"tagline": "<i>An offer</i> & more"})["tagline"] == "An offer & more"
|
||||
|
||||
|
||||
class _StubPlex:
|
||||
def __init__(self, items: list[dict]):
|
||||
self._items = items
|
||||
|
||||
def section_items(self, key, item_type, start, size):
|
||||
return self._items[start : start + size], len(self._items)
|
||||
|
||||
|
||||
def test_the_hungarian_overlay_stores_plain_text(db):
|
||||
en_lib = PlexLibrary(plex_key="4", title="Movies", kind="movie")
|
||||
hu_lib = PlexLibrary(plex_key="9", title="Filmek", kind="movie", metadata_lang="hu")
|
||||
db.add_all([en_lib, hu_lib])
|
||||
db.flush()
|
||||
hu_lib.overlay_of = en_lib.id
|
||||
row = PlexItem(rating_key="m1", library_id=en_lib.id, kind="movie", title="Heat", guid="tmdb:1")
|
||||
db.add(row)
|
||||
db.commit()
|
||||
|
||||
meta = {
|
||||
"ratingKey": "h1",
|
||||
"title": "Szemtől szemben",
|
||||
"summary": HU_SUMMARY,
|
||||
"tagline": "<i>Egy élet</i>",
|
||||
"Guid": [{"id": "tmdb://1"}],
|
||||
}
|
||||
sync_overlay(db, _StubPlex([meta]), hu_lib, en_lib.id, {"overlaid": 0})
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
assert row.summary_hu == "A műsorban minden párosnak stoppal kell eljutnia."
|
||||
assert row.tagline_hu == "Egy élet"
|
||||
|
||||
|
||||
def test_0101_cleans_the_stored_rows(db):
|
||||
cfg = Config("alembic.ini")
|
||||
command.downgrade(cfg, "0100_drop_plex_item_markers")
|
||||
upgraded = False
|
||||
try:
|
||||
with Session(engine) as s:
|
||||
lib = PlexLibrary(plex_key="1", title="Shows", kind="show", enabled=True)
|
||||
s.add(lib)
|
||||
s.flush()
|
||||
ep = PlexItem(
|
||||
rating_key="e1",
|
||||
library_id=lib.id,
|
||||
kind="episode",
|
||||
title="Ep",
|
||||
summary=HU_SUMMARY,
|
||||
tagline_hu="<i>x</i>",
|
||||
)
|
||||
plain = PlexItem(
|
||||
rating_key="m1", library_id=lib.id, kind="movie", title="M", summary="a < b"
|
||||
)
|
||||
show = PlexShow(rating_key="s1", library_id=lib.id, title="S", summary_hu="<p>Hu</p>")
|
||||
col = PlexCollection(rating_key="c1", library_id=lib.id, title="C", summary="A & B")
|
||||
s.add_all([ep, plain, show, col])
|
||||
s.commit()
|
||||
ids = (ep.id, plain.id, show.id, col.id)
|
||||
command.upgrade(cfg, "head")
|
||||
upgraded = True
|
||||
with Session(engine) as s:
|
||||
ep2, plain2 = s.get(PlexItem, ids[0]), s.get(PlexItem, ids[1])
|
||||
show2, col2 = s.get(PlexShow, ids[2]), s.get(PlexCollection, ids[3])
|
||||
assert ep2 and plain2 and show2 and col2
|
||||
assert ep2.summary == "A műsorban minden párosnak stoppal kell eljutnia."
|
||||
assert ep2.tagline_hu == "x"
|
||||
assert plain2.summary == "a < b"
|
||||
assert show2.summary_hu == "Hu"
|
||||
assert col2.summary == "A & B"
|
||||
finally:
|
||||
if not upgraded:
|
||||
command.upgrade(cfg, "head")
|
||||
@@ -18,6 +18,7 @@ from app.db import get_db
|
||||
from app.main import app
|
||||
from app.models import PlexItem, PlexLibrary, PlexSeason, PlexShow, PlexState, User
|
||||
from app.plex import rollup, sync
|
||||
from app.plex.cards import ShowState
|
||||
|
||||
|
||||
def _dt(day: int) -> datetime:
|
||||
@@ -184,21 +185,53 @@ class TestRollup:
|
||||
def test_state_map_new_inprogress_watched(self, db):
|
||||
s = _seed(db)
|
||||
show, user = s["show"], s["user"]
|
||||
# e1 in-progress, e2 new -> in_progress
|
||||
assert rollup.state_map(db, user.id, [show.id]) == {show.id: "in_progress"}
|
||||
# e1 in-progress, e2 new -> in_progress, and e1's resume point makes it resumable
|
||||
assert rollup.state_map(db, user.id, [show.id]) == {show.id: ShowState("in_progress", True)}
|
||||
# empty input short-circuits
|
||||
assert rollup.state_map(db, user.id, []) == {}
|
||||
# a fresh user with no state -> new
|
||||
other = User(email="rollup-other@example.com")
|
||||
db.add(other)
|
||||
db.commit()
|
||||
assert rollup.state_map(db, other.id, [show.id]) == {show.id: "new"}
|
||||
# mark every episode watched for `user` -> watched
|
||||
assert rollup.state_map(db, other.id, [show.id]) == {show.id: ShowState("new", False)}
|
||||
# e1 finished, e2 untouched: part watched, but no episode to resume — Continue and Restart
|
||||
# would both play e2 from its start
|
||||
db.execute(delete(PlexState).where(PlexState.user_id == user.id))
|
||||
for e in db.execute(select(PlexItem).where(PlexItem.kind == "episode")).scalars():
|
||||
db.add(PlexState(user_id=user.id, item_id=e.id, status="watched"))
|
||||
e1, e2 = db.execute(
|
||||
select(PlexItem).where(PlexItem.kind == "episode").order_by(PlexItem.episode_number)
|
||||
).scalars()
|
||||
db.add(PlexState(user_id=user.id, item_id=e1.id, status="watched"))
|
||||
db.commit()
|
||||
assert rollup.state_map(db, user.id, [show.id]) == {show.id: "watched"}
|
||||
assert rollup.state_map(db, user.id, [show.id]) == {
|
||||
show.id: ShowState("in_progress", False)
|
||||
}
|
||||
# mark every episode watched for `user` -> watched
|
||||
db.add(PlexState(user_id=user.id, item_id=e2.id, status="watched"))
|
||||
db.commit()
|
||||
assert rollup.state_map(db, user.id, [show.id]) == {show.id: ShowState("watched", False)}
|
||||
|
||||
def test_the_grid_card_says_whether_a_show_has_an_episode_to_resume(
|
||||
self, db, monkeypatch, override_dep
|
||||
):
|
||||
s = _seed(db)
|
||||
user = s["user"]
|
||||
cards = {
|
||||
i["id"]: i
|
||||
for i in _get(db, user, monkeypatch, override_dep, "/api/plex/library").json()["items"]
|
||||
}
|
||||
assert cards["s1"]["resumable"] is True
|
||||
# e1 finished, e2 untouched: in progress, but nothing to resume
|
||||
e1 = db.execute(select(PlexItem).where(PlexItem.rating_key == "e1")).scalar_one()
|
||||
db.execute(
|
||||
delete(PlexState).where(PlexState.user_id == user.id, PlexState.item_id == e1.id)
|
||||
)
|
||||
db.add(PlexState(user_id=user.id, item_id=e1.id, status="watched"))
|
||||
db.commit()
|
||||
cards = {
|
||||
i["id"]: i
|
||||
for i in _get(db, user, monkeypatch, override_dep, "/api/plex/library").json()["items"]
|
||||
}
|
||||
assert (cards["s1"]["status"], cards["s1"]["resumable"]) == ("in_progress", False)
|
||||
|
||||
|
||||
def _fake_paginate(by_type):
|
||||
|
||||
@@ -451,7 +451,7 @@ def test_a_non_ascii_filename_survives_the_header_round_trip(
|
||||
def test_a_long_display_name_is_truncated_on_EVERY_route_that_takes_one(
|
||||
db, as_user, make_user, _root, monkeypatch, upload_headers
|
||||
):
|
||||
"""The sweep, held end to end rather than at one site.
|
||||
"""The sweep, held end to end rather than at one site (the clip route joined it later).
|
||||
|
||||
`display_text` was introduced for the upload path and applied to three of the five writers of
|
||||
this varchar(255) column; `POST /api/downloads` and `POST /api/downloads/edit` kept the old
|
||||
@@ -503,15 +503,32 @@ def test_a_long_display_name_is_truncated_on_EVERY_route_that_takes_one(
|
||||
assert patched.json()["display_name"] == bound
|
||||
assert patched.json()["display_uploader"] == bound
|
||||
|
||||
# 6. the clip route — the clip panel's name field.
|
||||
clip = c.post(
|
||||
"/api/downloads/clip",
|
||||
json={
|
||||
"source": "youtube",
|
||||
"ref": "dQw4w9WgXcQ",
|
||||
"start_s": 0,
|
||||
"end_s": 5,
|
||||
"display_name": long_name,
|
||||
},
|
||||
)
|
||||
assert clip.status_code == 200, clip.text
|
||||
assert clip.json()["display_name"] == bound
|
||||
|
||||
|
||||
def _display_write_sites() -> dict[tuple[str, str], set[str]]:
|
||||
"""Every place under `backend/app/` that puts a value into a display field, as
|
||||
(module relative to `app/`, enclosing function) -> the set of value expressions written there.
|
||||
|
||||
TWO SHAPES, because the first version of this guard walked only `ast.Assign` and was therefore
|
||||
THREE SHAPES, because the first version of this guard walked only `ast.Assign` and was therefore
|
||||
blind to the shape the upload route itself uses — `SpooledUpload(..., display_name=...)` is an
|
||||
`ast.keyword`, not an assignment, so the one site with an unbounded REQUEST HEADER behind it was
|
||||
the site the guard could not see.
|
||||
the site the guard could not see. The third, `fields["display_name"] = …`, is a dict a finalize
|
||||
call is spread from (the worker's), which the first two missed as well. A value passed
|
||||
POSITIONALLY is still unseen: a writer binds it to a name or a keyword first, as every route here
|
||||
does.
|
||||
|
||||
ANNOTATED assignments are excluded on purpose: `display_name: Mapped[str | None] =
|
||||
mapped_column(...)` in `models.py` and `display_name: str | None = None` in a Pydantic model are
|
||||
@@ -537,6 +554,13 @@ def _display_write_sites() -> dict[tuple[str, str], set[str]]:
|
||||
t.id if isinstance(t, ast.Name) else t.attr
|
||||
for t in node.targets
|
||||
if isinstance(t, (ast.Name, ast.Attribute))
|
||||
} | {
|
||||
# `fields["display_name"] = …` — a dict the write is then spread from.
|
||||
t.slice.value
|
||||
for t in node.targets
|
||||
if isinstance(t, ast.Subscript)
|
||||
and isinstance(t.slice, ast.Constant)
|
||||
and isinstance(t.slice.value, str)
|
||||
}
|
||||
value = node.value
|
||||
elif isinstance(node, ast.keyword):
|
||||
@@ -558,9 +582,12 @@ def _display_write_sites() -> dict[tuple[str, str], set[str]]:
|
||||
# Bounded by `display_text` unless noted:
|
||||
# service.redownload CLEARS the field so the fresh metadata wins — `None` is the
|
||||
# one value that needs no bound, and a clear is still a write.
|
||||
# service.enqueue / enqueue_edit `display_name` is already bounded by its caller; the fallback
|
||||
# slices the YouTube title to the same width.
|
||||
# service.enqueue / enqueue_edit / `display_name` is already bounded by its caller; the fallback
|
||||
# enqueue_clip slices the source's title to the same width.
|
||||
# downloads.upload_download passes through `spool_upload`'s value, bounded there.
|
||||
# routes enqueue_clip the `display_name=` keyword passes on the name bound above it.
|
||||
# pipeline._download / _process_clip / worker._finish_from_cache
|
||||
# slice a fetched or stored title to the column's width.
|
||||
#
|
||||
# Held as EXACT EQUALITY rather than as a list of offenders. The offender form could only fail
|
||||
# upward: refactoring the writers into a shape the walker did not recognise left it asserting
|
||||
@@ -573,11 +600,16 @@ DISPLAY_WRITE_SITES = {
|
||||
"if asset.status == 'ready' and asset.title else None)"
|
||||
},
|
||||
("downloads/service.py", "enqueue_edit"): {"display_name or asset.title[:DISPLAY_TEXT_MAX]"},
|
||||
("downloads/service.py", "enqueue_clip"): {"display_name or asset.title[:DISPLAY_TEXT_MAX]"},
|
||||
("downloads/pipeline.py", "_download"): {"meta.title[:DISPLAY_TEXT_MAX]"},
|
||||
("downloads/pipeline.py", "_process_clip"): {"named[:DISPLAY_TEXT_MAX]"},
|
||||
("worker.py", "_finish_from_cache"): {"asset.title[:DISPLAY_TEXT_MAX]"},
|
||||
("downloads/upload.py", "ingest_upload"): {"display_text(display_name)"},
|
||||
("routes/auth.py", "callback"): {"display_text(userinfo.get('name'))"},
|
||||
("routes/auth.py", "_complete_link"): {"display_text(userinfo.get('name'))"},
|
||||
("routes/downloads.py", "enqueue_download"): {"display_text(body.display_name)"},
|
||||
("routes/downloads.py", "enqueue_edit"): {"display_text(body.display_name)"},
|
||||
("routes/downloads.py", "enqueue_clip"): {"display_text(body.display_name)", "display_name"},
|
||||
("routes/downloads.py", "update_download_meta"): {
|
||||
"display_text(body.display_name)",
|
||||
"display_text(body.display_uploader)",
|
||||
|
||||
@@ -15,6 +15,7 @@ import pytest
|
||||
|
||||
from app import sysconfig
|
||||
from app.config import settings
|
||||
from app.downloads import errors as dlerrors
|
||||
from app.downloads import gc as gcmod
|
||||
from app.downloads import pipeline, quota, storage
|
||||
from app.downloads import probe as probemod
|
||||
@@ -124,6 +125,8 @@ def test_a_conversion_that_outgrows_the_cap_is_refused_and_keeps_the_original(
|
||||
db.expire_all()
|
||||
job = db.get(DownloadJob, job.id)
|
||||
assert (job.status, job.error_code) == ("error", "quota_exceeded")
|
||||
# 90 of the 100 are still free: the converted file is too big, the storage is not full.
|
||||
assert job.error == dlerrors.CONVERTED_TOO_BIG_MESSAGE
|
||||
assert original.exists()
|
||||
assert [p.name for p in _uploads_dir(_root, u.id).iterdir()] == [original.name]
|
||||
|
||||
@@ -558,6 +561,33 @@ def test_a_link_says_preparing_only_while_its_video_is_being_made(
|
||||
assert r.json()["detail"]["reason"] == reason
|
||||
|
||||
|
||||
def test_a_link_to_a_suspended_owners_waiting_rebuild_says_it_is_gone(
|
||||
db, as_user, make_user, _root, monkeypatch, upload_headers
|
||||
):
|
||||
# The worker leaves a suspended user's queued jobs where they are, maybe for good: like a paused
|
||||
# rebuild, nothing is making the video, so "try again shortly" would never come true.
|
||||
_probe(monkeypatch, vcodec="h264")
|
||||
owner = make_user()
|
||||
c = as_user(owner)
|
||||
job = c.post(
|
||||
"/api/downloads/upload", content=b"h264 bytes", headers=upload_headers("clip.mp4")
|
||||
).json()
|
||||
link = c.post(f"/api/downloads/{job['id']}/links", json={"allow_download": False}).json()
|
||||
db.get(MediaAsset, job["asset"]["id"]).status = "pending"
|
||||
db.get(DownloadJob, job["id"]).status = "queued"
|
||||
owner.is_suspended = True
|
||||
db.commit()
|
||||
r = c.get(f"/api/public/watch/{link['token']}")
|
||||
assert r.status_code == 404
|
||||
assert r.json()["detail"]["reason"] == "video_gone"
|
||||
# A rebuild already running when the owner was suspended still finishes.
|
||||
db.get(DownloadJob, job["id"]).status = "running"
|
||||
db.commit()
|
||||
assert c.get(f"/api/public/watch/{link['token']}").json()["detail"]["reason"] == (
|
||||
"video_preparing"
|
||||
)
|
||||
|
||||
|
||||
def test_the_admin_storage_view_counts_a_waiting_upload(db, as_user, make_user, _root, monkeypatch):
|
||||
# The same disk the owner's footprint and the GC's cap count (`quota.ON_DISK`).
|
||||
_probe(monkeypatch)
|
||||
|
||||
+174
-111
@@ -112,7 +112,18 @@ style guide.
|
||||
own chip), never `--warn`: a score is not a warning. What sits on a picture looks the same in both themes,
|
||||
because the picture does: the broadcast red `--live` under `--live-fg` (never `--danger`: "live" is not an
|
||||
error), `--rating-media` on a poster's scrim, and `--media-chrome` for the always-dark player's menus
|
||||
(owner decision F16). Black and white are the media scrims and their text. Five schemes × dark
|
||||
(owner decision F16). On that chrome `--media-mark` is a marked place (the playhead, the crop frame, a
|
||||
Plex marker), the focus ring and what is chosen, and `--media-danger` an error, as a light theme's
|
||||
accent and danger are dark on the chrome: `media-marks` re-points `--focus` and `--chosen` on the
|
||||
player (the Plex player, the YouTube stage) and on every surface painted `--media-chrome` (its menus,
|
||||
panels and bars), and a form there (`media-scope`) re-points `--danger` too. A chosen item there is
|
||||
white like the rest, marked by an amber tick (a track) or underline (a tab, `ui/mediaChrome`), never
|
||||
the accent as text; a choice filled with the accent keeps it, its own text holds on it. A fill that
|
||||
must be seen there — a native checkbox or slider (`accent-chosen`), the skip button's countdown — is
|
||||
the amber too; the Plex progress bar plays white with an amber head, so an amber marker ahead of
|
||||
the head never reads as already played.
|
||||
`theme.contract.test.ts` measures the red on the chrome and holds every class string that paints it
|
||||
to name one of the two. Black and white are the media scrims and their text. Five schemes × dark
|
||||
and light (B2): **Midnight** (blue), **Forest** (cyan), **Slate** (fuchsia), **Matrix** (moss) each earn their
|
||||
place with their own hue family; **Fjord** (Midnight's navy with a calm steel-blue accent) is the one kept
|
||||
exception, the calm twin of the vivid Midnight. A new scheme still needs its own hue family. Normal text ≥ 4.5:1 and a control's edge ≥ 3:1 in **every** scheme and mode.
|
||||
@@ -135,9 +146,11 @@ style guide.
|
||||
- **State layers (B3):** hover and pressed are a layer, not a colour swap: the text colour mixed at **10%**
|
||||
(hover) / **16%** (pressed) over whatever the control sits on, so it reads on the page, a card and glass
|
||||
alike. A filled control mixes its own text colour into its fill at 14% / 24%. Opacity dimming and
|
||||
`bg-card` hovers are violations (invisible on a light card). Selected stays the solid accent. The one
|
||||
exception is an edged pill (a chip, a toolbar or form trigger, a segment): its hover is the accent edge
|
||||
(`.pill:hover`, `glass-hover`), which reads on every surface too (owner decision F14).
|
||||
`bg-card` hovers are violations (invisible on a light card). Selected stays the solid accent. The
|
||||
exceptions are an edged pill (a chip, a toolbar or form trigger, a segment): its hover is the accent edge
|
||||
(`.pill:hover`, `glass-hover`), which reads on every surface too (owner decision F14); and a dark
|
||||
translucent button on a picture (the `media` variant: a card's hover row, a rail's scroll arrows, Restart),
|
||||
whose hover is a 2px accent ring over a darker body, since a layer is lost in the picture.
|
||||
A card that is one row with actions answers as a whole: the layer is the card's, under its badges and
|
||||
buttons, and the row's open button spans the card beneath the actions (a hover that stopped at the first
|
||||
badge read as broken; owner, E28 — `AdminUsers` user rows).
|
||||
@@ -398,7 +411,9 @@ pick a density step; it never changes the anatomy or loses an action. [Nielsen #
|
||||
`mediaTitleClass`, `mediaPictureBox`).
|
||||
- **Play only where the click plays.** The hover layer dims the picture and shows `MediaPlayGlyph`, the
|
||||
one round media Play (the button family's `media` variant), only on a card whose click starts
|
||||
playback; a show without a next episode opens instead and shows none. The hover row (watched, save or
|
||||
playback. Every Plex picture plays, a show too: the episode in progress (Continue + Restart, offered only
|
||||
when an episode holds a resume point), else the first unwatched, else the first, which the click asks the server for (`/show/{id}/next`); its title
|
||||
and an ⓘ open the show's page, and one with no episodes opens there. The hover row (watched, save or
|
||||
add, "…") docks at the picture's bottom-right; the length badge steps aside while it shows.
|
||||
- **Density.** A grid card: a two-line title and the full meta (Plex: year · ★ score; a video: the
|
||||
channel, then views · age). On a shelf (any horizontal strip) the title is one line, cut with an
|
||||
@@ -411,7 +426,8 @@ pick a density step; it never changes the anatomy or loses an action. [Nielsen #
|
||||
had become the strip's.
|
||||
- **The preview (the owner's pick C).** Resting the mouse on a Plex card for ~0.9 s opens a panel beside
|
||||
it: the whole title, year · length · ★, and the synopsis in short lines (`ui/MediaPreview` +
|
||||
`useMediaPreview`). Mouse only (a keyboard walk or a tap never opens it; "…" → Media info has the same),
|
||||
`useMediaPreview`); a show's adds the episode it would play ("Next: S6·E16 — Title"), asked as the
|
||||
panel opens. Mouse only (a keyboard walk or a tap never opens it; "…" → Media info has the same),
|
||||
passive (it never takes a click), and any press on the card closes it. Long text never goes in a
|
||||
native `title` tooltip.
|
||||
- **Home's hero is a card** (the owner's pick A): the same hover row and Play on its picture, the same
|
||||
@@ -531,9 +547,45 @@ of a full-width panel and nothing on the right.
|
||||
applies to the item the player opened on (`fromStart`); every item it moves on to resumes. A library
|
||||
row is not a card and keeps its one play button: started, that button continues, its "…" has Continue +
|
||||
"Play from the beginning", and its picture carries the cards' progress bar.
|
||||
- **The YouTube modal:** its chrome uses the `Button` rungs (44 on touch), the side arrows stay inside the
|
||||
screen, and the description opens from an ⓘ button, because a hover popover never opens on a tap (measured
|
||||
live). [WCAG 1.4.13]
|
||||
- **The YouTube modal:** its chrome uses the `Button` rungs (44 on touch); Previous / Next live in the panel
|
||||
(its transport row, or the shut panel's strip) and on Shift+← / →, with no strips beside the card. Close is
|
||||
an icon at the top right of the panel (wide: its head, or its strip) and at the end of the title row
|
||||
under the video (stacked). Wide, the panel shows the description outright, clamped to three
|
||||
lines with a More (`ClampedText`; a Tab onto a link below the cut opens it), and collapses to a 2.75rem
|
||||
strip of close, expand and Previous / Next, kept shut as the queue steps on and open again on a new
|
||||
open. Stacked, the description opens from an ⓘ, because a hover popover never opens on a tap (measured
|
||||
live). The wheel: over the video it sets the volume; over a panel with something to scroll it scrolls
|
||||
only, never the volume, even at its end; over one with nothing to scroll it sets the volume. [WCAG 1.4.13]
|
||||
- **Clip mode (both players, owner's picks A + P1 + a):** a scissors in the control row (Plex: the C5 row,
|
||||
before the ⋯; YouTube: the actions beside Download, and the docked bar) or `C` opens a clip panel IN the
|
||||
player; `[` / `]` mark the start / end at the playhead and open it when shut. The video being watched is the
|
||||
preview — no second stream. Plex: the panel takes the seek bar's and the row's place, and the picture stands
|
||||
above it. YouTube: it sits under the video (the card's fit counts it, so the video shrinks to make room)
|
||||
and the wide side panel shuts to its strip while the clip panel is open (owner; it opens again after, unless
|
||||
the user opened it meanwhile); maximised, the panel takes the docked bar's place; the browser's fullscreen
|
||||
steps back to maximised. The panel: an overview of the whole item (the selection and the zoomed window on it), a zoomed strip
|
||||
around the selection with the two ends (`ui/RangeTrack`: each end a slider — arrows step a tenth,
|
||||
Shift a second; drawn 4px, hit 1.5rem), Start / End fields with a tenth back / forward and "to the
|
||||
playhead", the length, Play selection (loops), a name, Cancel / Cut clip. On the always-dark chrome the
|
||||
ends are white, every focus ring in the panel and the playhead the media amber `--media-mark` (the same
|
||||
in both themes; a light theme's accent and warn all but vanished there). A typed time that cannot be
|
||||
read stays in its field once offered (Enter, leaving it), marked invalid with a line under the fields'
|
||||
row saying how to type one (in the flow: the row below moves down rather than being covered); a time typed over a mark that then moves (a nudge, another segment) is dropped; one
|
||||
past the end shows where it landed (decimal point or comma).
|
||||
The strip's window follows the selection when it settles, never under a dragging pointer. Plex seeks once a drag is
|
||||
released (a seek may restart the transcode); YouTube follows the drag; a mark on the very end seeks a
|
||||
moment short of it, and while a clip is marked the end of the item never closes the player or steps the
|
||||
queue, and Plex's auto-skip waits. Escape closes the panel before the player, but after anything over
|
||||
the picture (Plex: the info, the up-next card, the countdown); ⌫ under a dialog is the dialog's. The
|
||||
scissors the user can see gets the focus back (maximised: the docked bar's), else the player. The panel
|
||||
keeps its name, Loop and focus when the view is maximised or restored under it. **Crop (owner's picks K1 + a):** a Crop toggle in the panel puts a
|
||||
frame over the picture (`ui/CropFrame`: on the picture, not its bars; the outside dimmed; drag to move, edges
|
||||
and corners to resize, thirds while dragging; the arrows move it, a focused corner's resize it, Shift
|
||||
further; focus ring amber) with a shape — Free, 16:9, 9:16, 1:1 (the largest of it the picture holds) — and
|
||||
Whole picture; off, the clip is the whole picture. Escape turns the crop off before it closes the panel. A
|
||||
press outside the frame is still the video's. A cut is a toast with a Downloads link and the usual
|
||||
"Ready to watch" notification. Limits are the server's: 1 s to 10 min (`lib/clipRange`, held to
|
||||
`downloads/clip.py` by `clipLimits.contract.test.ts`; the "too long" message names the server's cap).
|
||||
|
||||
## Dialogs — _Decided_ (C4, built in S3f)
|
||||
|
||||
@@ -609,105 +661,116 @@ of a full-width panel and nothing on the right.
|
||||
|
||||
## Decision log
|
||||
|
||||
| Date | Decision | Chosen | Over | Why |
|
||||
| ---------- | ------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 2026-09-24 | Floating button | Back to top: a round 48 glass FAB; "round" = floats over something (media or the page) | A Material 3 rounded square; a labelled pill; the old 64 circle | The common web convention for back to top; round tells it apart from every toolbar button; 64 was oversized |
|
||||
| 2026-09-24 | Device contract | Tablet portrait + landscape and up; phones out | A 1024px floor with a "too small" message | Portrait tablet use is real, and the app already renders at 820 portrait |
|
||||
| 2026-09-24 | Card actions on touch | Fewer visible, the rest in a "…" menu | 44px buttons that no longer fit the card | Six 44px targets need more width than a card has |
|
||||
| 2026-09-24 | Action placement | One menu with several triggers, never gesture-only, a desktop background menu that mirrors the toolbar | Visible buttons for everything | Fewer non-critical buttons on screen, without hiding any function |
|
||||
| 2026-09-24 | Text selection | Text stays selectable; a click that ends a selection does nothing | Click-to-play on titles | The user must be able to copy any text the app shows |
|
||||
| 2026-09-24 | A1 Button shape | r8 + a rung per context; glass is a Button variant | r12; a pill | Nested corners (r8 in r12 in r16); the pill already means "a filter or a choice" |
|
||||
| 2026-09-24 | A2 Icon buttons | Two sizes, r8; a round button only on media | One size; all round | A lone toolbar icon at the row size looks lost; round rows read as tags |
|
||||
| 2026-09-24 | A3 Touch size | 44 for every control primitive | 36 + gaps; Button only | One rule (HIG 44) instead of two sizes nobody remembers |
|
||||
| 2026-09-24 | A4 Input | Bordered, a 55% fg-into-bg edge (≥ 3:1) | Filled; today's look as a primitive | The only option that clears WCAG 1.4.11 without a heavy fill on glass |
|
||||
| 2026-09-24 | A5 Dropdown trigger | Pill in a toolbar, r8 in a form | Pill everywhere; r8 everywhere | Matches the neighbours in each context |
|
||||
| 2026-09-24 | A6 Checkbox, switch, slider | Native, accent-tinted, the row is the target | Drawn primitives | Free keyboard / screen-reader / forced-colours support; only 3 sites |
|
||||
| 2026-09-24 | B1 Density | Compact 32 / 28 / 24 by explicit height | Standard 38 / 32 / 26 | The owner's read of the lab; touch is covered separately (A3), and one toolbar row lines up at 28 |
|
||||
| 2026-09-24 | B2 Scheme set | Midnight, Forest→cyan, Slate→fuchsia, Matrix→lime | Today's six | The accent collided with danger (YouTube), warn (Slate) and success (Forest, Matrix); Midnight and Starship were one scheme twice; 4 of 6 light accents failed 4.5:1 |
|
||||
| 2026-09-24 | B3 State layers | Text colour at 10% / 16% over anything | Material's 8 / 12%; today's opacity + bg-card | Visible in every scheme and mode (≥ 1.2:1); today's row hover was 1.00:1 on a card |
|
||||
| 2026-09-24 | B4 Page chrome | One sticky band + fade; filters fold when short | A floating glass bar; everything scrolls | Tabs and filters always in reach, in one place; a keyboard-up tablet keeps its content |
|
||||
| 2026-09-24 | B5 Surfaces | One class + radius per role | Today's per-site mix | Nested corners and no per-site alpha drift |
|
||||
| 2026-09-24 | B6 /watch | The app's surfaces, native video controls | Flat and minimal | The page strangers see should look like the product; native controls work on any device |
|
||||
| 2026-09-24 | B7 Nav rail | Soft tint + edge bar, optional labels (a user setting), slim | Solid accent (recommended); a Material rail | The owner's combination: calmer than a solid fill, labels for who wants them, no width cost |
|
||||
| 2026-09-24 | B8 Loading | Skeleton by shape, spinner in a control | A spinner everywhere | Nothing jumps when data lands; a long wait doesn't look stuck |
|
||||
| 2026-09-24 | B9 Fade | Toolbars / headers, panel tabs, dialog edges, chip rows | Thumbnails; buttons | The north-star depth where content scrolls under chrome; not on content or small controls |
|
||||
| 2026-09-24 | C1 Action classification | The table (lab C1): state + primary at rest, everyday toggles on hover, "…" on touch, destructive into the menu | Visible buttons per site, as today | One action list per object, nothing gesture-only, fewer buttons without losing a function |
|
||||
| 2026-09-24 | C2 Side panels | A tap opens an overlay drawer; pin is a separate button ≥ 1024 | A tap pins and the content reflows | Below 768 the filters were unreachable; on touch a tap pinned and pushed the grid |
|
||||
| 2026-09-24 | C3 Players on touch | Tap = show the controls; 44px seek hit area; 44 buttons; the YT description behind ⓘ | Today (tap also pauses; 7.5px seek bar) | Measured live: you couldn't reach the seek bar without pausing, and the hover-only description never opened on a tap |
|
||||
| 2026-09-24 | C4 Dialogs | Focus by purpose; a pinned footer | Focus on Close; the footer scrolls | A form starts in its first field, and Save stays on screen with the keyboard up |
|
||||
| 2026-09-24 | C5 Plex player controls | The owner's lean set: no Stop / Restart / ±10 s buttons, Back by position, Next only with a next item, Resume + From the start on the card, touch double-tap ±10 s | Every control in one row (it ran off-screen below 1024); Back by a press counter; ±10 s keyboard-only | The row fits a portrait tablet; the decision to restart is made on the card; a tablet keeps the 10 s skip |
|
||||
| 2026-09-24 | Glass tuner | Retired; the schemes are the only colour choice | Keep it with ranges clamped to the rules | Free tuning is the drift the constitution exists to stop (owner) |
|
||||
| 2026-09-24 | Matrix toned down | Moss #86a05a at Midnight's lightness, near-neutral greys; every white-on-accent part → `--accent-fg` | The B2 lime #a3e635 | The owner found the lime neon and tiring; it was the loudest accent (L 0.85, C 0.21), and fixed white parts sat on it at 1.5:1 |
|
||||
| 2026-09-24 | Field primitives | `Input` / `Textarea` / `InnerInput` + `fieldClass`, `SelectTrigger`, the native controls; the lint rule "no raw `<input>`" | `inputCls` and ~40 hand-styled boxes | Six edges, four radii and two focus looks for one control; the ≥ 3:1 edge held nowhere |
|
||||
| 2026-09-25 | Surface roles | `SURFACE` roles in `ui/surfaces.ts`; the lint rule "no bare glass class"; menus p-1 with r8 rows | Three tiers paired ad hoc with five radii and three menu paddings | ~140 sites drifted; nested corners only hold if the radius comes with the role |
|
||||
| 2026-09-25 | Static labels | Flat tonal `Badge`, no edge; a filtering tag is an edged chip | A glass pill with an edge, like the chips | Edged = pressable at a glance; a static tag that looks like a chip gets clicked and does nothing |
|
||||
| 2026-09-25 | Header search | A 40 glass pill with the field edge (`lg`); what sits inside it is round | 44, r16, a glass box with a faint edge | The one rounded box among pills, and its edge under 3:1; the hero search is the YouTube/Gmail pill |
|
||||
| 2026-09-25 | Switch row (F12) | The whole settings row toggles (`SwitchRow`; a hand-laid row points a label at the switch) | Only the 36×24 switch toggles | The iOS / Material settings row; the text was a dead click beside the control it names |
|
||||
| 2026-09-25 | Edges and hovers (F14) | The ≥ 3:1 edge on the field family (fields, the form trigger); labelled pills keep the faint edge and the accent-edge hover | The ≥ 3:1 edge and a state-layer hover on every control | 1.4.11 asks an edge only where nothing else identifies the control; a heavy edge on every chip and tab weighs the whole UI down |
|
||||
| 2026-09-25 | Rating and live (F16) | Own tokens: `--rating` (theme-scoped gold), `--live`/`--live-fg`, `--rating-media`, `--media-chrome`; palette classes lint-refused | Map them to `--warn` / `--danger` | A gold star and a red LIVE are what the eye expects; as warn/danger they would read as a status. The old amber score was 2:1 on light |
|
||||
| 2026-09-25 | Fjord trial (B2 exception) | A fifth scheme, temporary: Midnight's neutrals, Playnite's steel accent (#337599 lifted to #5aa1cb) and steel rules; exit = fold into Midnight or remove | Retune Midnight straight away; borrow only the qualities | The owner wants to live with the calmer palette app-wide before it replaces the default; it breaks "own hue family" on purpose, and only until that call |
|
||||
| 2026-09-25 | Whole-card row (owner) | A card row's B3 layer is the card's `::before` under its content; the open button's hit layer spans the card, the actions sit above it | The name button's own hover, ending at the first badge or button | A row that half-answers the pointer reads as broken; a badge click did nothing |
|
||||
| 2026-09-25 | Badge tones (F29) | `Badge tone` neutral / accent / warn at 6%; light `--warn` #b45309 → #a14a07; edged label spans lint-refused | Keep edged status pills (a private Badge in Users, Settings "Granted", the suggestions count) | Edged = pressable at a glance; amber-700 was 4.58:1 as text and fell under 4.5 on its own tint |
|
||||
| 2026-09-26 | Page band built (B4) | Controls only in the band (intros scroll); the fold shows the same row in place; one `h1` per page (`PageHeading` steps the hidden one aside); `SectionLabel` headings; Skip to content | A popover of the folded filters; the page title kept visible | A folded row is still the same controls, nothing duplicated; the rail and the tab title already name the page |
|
||||
| 2026-09-26 | Fade built (B9) | `fade-below` on sticky toolbars and table headers; the scroller mask where the band sits on the backdrop | A painted band over the backdrop | The backdrop is the colour under the band, so fading the content into it is the same edge |
|
||||
| 2026-09-26 | Viewport units | `dvh` everywhere (the Plex player stage with S3f) | `vh` / `h-screen` | `100vh` hides the last rows under a tablet browser's toolbar |
|
||||
| 2026-09-26 | Video actions built (C1) | One list per video (menu, right-click, long-press, "…"); the card keeps watched / save / add to playlist on its picture on hover; download, details, copy, hide, reset in the menu | The six-button row under the card and the hover ⓘ | The menu is the one door every device has; the card loses a line of height and the touch card its row of 28px buttons |
|
||||
| 2026-09-26 | Plex actions built (C1) | One list per Plex title (play/open, watched, add to playlist, media info, copy title, pin); the poster keeps watched / add / "…" on its picture on hover; seasons get open / watched / add / copy | The hover ⓘ, the pin-only menu, an episode's add button at rest, a collection's "Remove" X on the card | The same door as a video's; the collection grid goes to two columns on a phone so the touch "…" leaves the title its line |
|
||||
| 2026-09-26 | Playlist actions built (C1) | A playlist row's "…" (play, pin, copy title / link, move, order, remove), Delete + an undo toast; one list per playlist (rename, pin, YouTube open / push / revert, delete) from its rail row and its header's "…" | The row's X column and its confirm; the header's pencil, YouTube link, push, revert and trash buttons | Remove is reversible, so it needs no confirm; the header keeps Play all; a narrow table drops "#" and Channel so the title keeps a width |
|
||||
| 2026-09-26 | Channel + Plex list actions (C1) | A channel row's "…" (open, pin, YouTube, copy, full history, hide + undo, unsubscribe); a Plex playlist's title / show / season "…", Delete + undo; its header's "…" (rename, delete); an action toast lasts ≥ 8 s | The channel row's full-history, hide and unsubscribe buttons; the Plex list's red X on every row, show and season; the header's pencil and trash | Hiding and removing are reversible, so they need no confirm and the undo needs time; unsubscribe and deleting a playlist keep theirs |
|
||||
| 2026-09-26 | Download / notification / Scheduler actions (C1) | A download row's "…" (play, share, save, edit, details, re-download, manage sharing, copies, cancel / delete / remove); a library row's Share stays in sight, ahead of save; a notification row's "…" and hover dismiss; the inbox band's and the Scheduler strip's "…" | The download row's five rare buttons and its cancel / delete / remove; the source link's copy button; an activity notice's Find / Undo links; the band's Clear everything; the strip's Purge discovery | The destructive rows keep their confirm, and Clear everything gains one; a row link keeps the browser's own menu; Find in feed drops the tag / search / age filters that could hide its video |
|
||||
| 2026-09-26 | Channel menu: Tags + Priority (C1) | Tags A–Z as in-place checks; Priority as fixed steps 10 · 5 · 3 · 2 · 1 · 0, the current one ticked; a pick re-sorts at once and the page + focus follow the row; one drawn `MenuCheck` for every checkable row that cannot be an `<input>` | Relative steps (+1 / +5); straight to the top; the stepper only; seven hand-drawn check marks (three box sizes, a tinted box, a × for "on", a trailing tick) | Bubbling a channel up is one pick; fixed levels keep the scale the reader already uses; the list moving where the reader can see it is the feedback |
|
||||
| 2026-09-26 | Reorder without a drag (WCAG 2.5.7) | Row menus: Top / Up / Down / Bottom (YouTube + Plex playlists); saved views: four buttons with the place beside the selected view (owner: A); side panels in edit mode: an Up / Down pair per group (owner: A), the name wrapping there | A "…" on every saved-view row; a "…" on every panel group; drag only | A visible, one-click path where the list is edited; the edge moves shown off so the buttons never shift |
|
||||
| 2026-09-26 | Titles in click surfaces | The surface stays a button, flagged `selectable`: its text selects, and the click that ends a selection does nothing; every plain `Pressable` pinned with its reason | Rebuilding each card and row so the title sits outside the button; exceptions for master-detail lists | Chrome selects inside a button once it is `user-select: text` (measured), so one flag on the primitive keeps the keyboard, focus and list walking every surface already has |
|
||||
| 2026-09-26 | Right-click on a selection | The browser's own menu opens on selected words; beside them the object's menu | A "Copy selection" row in the app menu | The reader selected the words to do something with them; the native menu copies, searches and translates, and changes nothing else |
|
||||
| 2026-09-26 | Keyboard ring on a flush row | Inside the row (a table row by its tag, a log line, a show header in its clipping card, with the card's corners) | The ring outside, as on a card | Outside it ran under the table head and past the table, and a clipping card cut it away entirely (invisible focus) |
|
||||
| 2026-09-26 | Fade at rest | The sticky header's fade appears with the first 1rem of scroll (a scroll-driven animation; no fade where unsupported) | A fade that is always drawn | At rest it dimmed the first row and hid its ring's top edge; nothing is under the header yet |
|
||||
| 2026-09-26 | Narrow playlist table | "#" and Channel hide by the table's own width (under 42rem), not the window's | Hiding below the `sm` window width | Beside the playlist rail a desktop window left the table 432 px and the title 0 px |
|
||||
| 2026-09-26 | Background menu built | Five list pages (feed, Plex grid, channels, playlist, notifications); the toolbar's sort / view / filters and the band's actions; no destructive row | Every page with a toolbar; the "…" menus copied whole, delete included | The tab-only pages have nothing a menu would add; a blank-area right-click must not be one click from delete |
|
||||
| 2026-09-26 | Media card anatomy (C) | One frame; the meta line pinned to its bottom; the length a badge on the picture; the Plex card on the same primitive as the video card | A: the year as an overline over the title; B: the year inline after it; a fixed two-line title slot | A one-line title left a hole the eye reads as a missing line; C puts nothing under a variable-height title |
|
||||
| 2026-09-26 | One Play | `MediaPlayGlyph` (the media button variant) and only where the click plays | The Plex card's hand-painted accent circle; a playable dot on every card | One Play for every card; a dot that only a colour and a tooltip explain says nothing |
|
||||
| 2026-09-26 | Filtering values on the info page | A value that filters (year, certificate, studio, director) is an edged chip; a fact is flat text | Everything flat, the clickable ones only hover-tinted | The S3d rule: edged = pressable, so the reader sees which values do something |
|
||||
| 2026-09-26 | Shelf density | On any strip: one title line over one meta line | The grid's two lines and full meta on a strip | The tallest card set the strip's height and left holes in the cards with less to say |
|
||||
| 2026-09-26 | Compact list row (B) | The title leads; a faint "…" at the end; the toggles float over the date on hover | A leading 128px action column | That column sat empty at rest, a hole at the start of every row |
|
||||
| 2026-09-26 | Media-info layout (B) | A main column (collections first, crew, cast) beside a facts sidebar; an empty column is not drawn | Every section full width, stacked | The collection is the most used; the keywords' column left a void beside a short details box |
|
||||
| 2026-09-26 | Mixed Home shelf (A) | Plex titles as wide art beside videos | Posters, with the videos not stretched | Stretching a video card to a poster's height left an ~80px gap |
|
||||
| 2026-09-27 | Home hero | A card (hover row + Play on the picture, the whole menu, a touch "…"; owner: A); three quarters wide and centred on a wide window; one fact line; the summary at the measure | The hero's pin row alone; full width; an 18rem picture column | The one answer stands in focus, and it offers what the same card offers anywhere |
|
||||
| 2026-09-27 | Home shelf tiles | A channel / playlist tile: Play, ↗ open, "…" with Open; a show acts on its next episode, ⓘ opens it; the shelf slot 232px | The tile's pin row alone; 200px | A tile without its object's actions read as broken; at 200 the Play touched the hover row |
|
||||
| 2026-09-27 | Synopsis preview (C) | A ~0.9 s mouse rest opens a passive panel beside the card | The synopsis as the picture's `title` tooltip; an ⓘ to hover; none | One long tooltip line fired anywhere on the art and clashed with the cut title's own tooltip |
|
||||
| 2026-09-27 | Reading measure | Prose capped at ~65 characters a line (`measure`) | The container's full width | At 110–130 characters a line the eye loses the next line's start |
|
||||
| 2026-09-24 | Chip family | One `Chip` (filter / token / tag / suggest / ghost) + `DismissibleChip`; nothing under 24; the lint rule "no raw `<button>` outside ui/" | Five chip geometries in five files | The applied-filter row, the tag cells, the year picker and the saved-view editor each drew their own chip; a one-letter tag was 21px wide |
|
||||
| 2026-09-24 | Button families | `Button` / `IconButton` / `Pressable` / chips / `DragHandle`; `lg` 40 for a hero CTA; pills and a 48 Play on media (from C5) | Every button hand-painted | 268 hand-rolled buttons in 79 files drifted into as many looks; the ladder only holds if every button stands on it |
|
||||
| 2026-09-27 | C4 as built | `initialFocus` required on every `Modal`; the commit row in `ModalFooter`, portalled under the body from any depth; the fades driven by the body's scroll timeline; `interactive-widget=resizes-content` | An optional prop defaulting to the ✕; a `footer` prop (the issue composer's buttons live three components down); always-on fades | A default is how 21 dialogs came to open on the ✕; lifting the composer's state to reach a prop is the larger change; a resting fade dimmed the first line |
|
||||
| 2026-09-27 | C3 + C5 as built | A tap toggles the bar through `pointerType` from `pointerdown`; a double-tap takes back its first tap's toggle; the card's pair is the video card's `MediaResumeButtons` (Continue / Restart), a sibling over the picture; the YT description is an ⓘ click popover | A delayed single tap (wait out the double-tap window); new "Resume / From the start" words for Plex; the pair inside the picture's button | A delayed tap feels dead; one media card says one pair of words; a button inside a button is invalid and the hover row already sits beside the picture |
|
||||
| 2026-09-27 | C2 as built | The side panel's drawer is `ui/Modal` `drawer-start`; drawer = a coarse pointer or below 768; the pin offered in its header ≥ 1024 | A drawer of its own inside `SidePanel` | Esc, Back, the scrim, the trap and the scroll lock are the dialog's; a second implementation is how two shells drift |
|
||||
| 2026-09-30 | Composition and the reader (E30 S6) | A chapter and a Conformance step: the space is used, one focal point, worst real data, where am I, a click answers at once, zoom and text size first-class; the person page as two columns (facts and bio left, "Known for" right) | Rules for pieces only; the bio in a full-width panel; a narrower panel | The person page passed every piece rule with half its panel empty; the owner reads zoomed in, so zoom is a requirement, not a nicety |
|
||||
| 2026-09-30 | Hungarian words (E30 S5) | The D3 list: Feed, Shorts, Admin, Token, Port stay; stream, Prio, Job, Audit, Demo, Auto, Info, Frontend/Backend, Backfill, transcode get the Hungarian word; one name per thing (kérelem, behúzás, one "Test connection") | Keep every loanword; translate Feed/Shorts too | The owner chose the list (D3); the final sweep found the same feature under two names in three places |
|
||||
| 2026-09-29 | Numbers and order in the UI language (E30 S3) | Every number through `lib/format.ts` in the UI language (decimal mark, grouping, compact, units); A→Z by the shown name in the UI language's alphabet, client and server alike; the language comes off `useTranslation`, never the singleton | `toFixed`, hand-built "1.2K", `localeCompare` in the browser's locale, byte-order server sorts | A Hungarian reader saw "8.3", "1.2K", "Életrajzi" after "Z"; the singleton read also left values in the old language after a switch (React Compiler) |
|
||||
| 2026-09-27 | Narrow rows by container | The download row, the playlist table and the Channels tag row stack by a container query; a fact line never wraps | Breakpoints on the window | The iPad zoomed in, split screen and a column beside a rail are narrow while the window is not; the zoomed download row broke "18.9 / MB" apart |
|
||||
| 2026-09-27 | Phones deferred | A phone-only layout (the hamburger nav, a folded feed band) waits for its own epic; E28 stays desktop + tablet | Fix the phone views inside E28 | Principle 2; the first phone attempts made the feed worse, and the epic's review grows with every sprint |
|
||||
| 2026-09-27 | Conformance check (S3g) | A four-step check before a UI change is done: primitive at a named rung, the chapter re-read, the rendered page measured by kind and look, every state seen | A reviewer's eye per sprint | The S1 probe found 69 button looks; after S3 every control kind is drawn by its primitive, and the check keeps a new look from arriving unnoticed |
|
||||
| 2026-09-27 | Segmented rung | `SegmentedToggle` defaults to the toolbar rung (28, `text-xs`); 24 only for a panel's captioned match toggle | The small default | Two toolbars had inherited 10px type from the default beside 28px buttons |
|
||||
| 2026-09-27 | Copy rule (S3g) | One name per feature (the rail may shorten it); four verbs for four meanings; sentence case with the app's places capitalised; "…" when a command asks first; one-sentence messages without a stop; curly quotes; dates relative in lists with the exact minute on hover, three absolute forms; units from Intl, short | Word-for-word names everywhere; the verbs as they were; all lower case; no "…"; always a stop; always absolute dates; long or compact units | The copy survey found 7 EN and 6 HU double names, 22 Delete against 27 Remove, five date shapes and three spellings of a minute; the owner picked every recommended option |
|
||||
| 2026-09-27 | One glyph, one meaning (S4) | Play only for playback or resuming a paused transfer; Retry = `RefreshCw`; reset / revert = `RotateCcw` | Retry drawn with Play | The first UI-review run (Downloads) found a failed row whose Retry showed ▷ in the row and its menu |
|
||||
| 2026-09-27 | Fjord kept | Fjord stays a fifth scheme beside Midnight; the trial wording goes | Fold it into Midnight; remove it | The owner lived with it and likes it; the vivid Midnight stays for those who want it. B2's own-hue rule still binds any new scheme |
|
||||
| 2026-09-27 | Tablet columns | A table hides `tabletHidden` columns below 64rem of its own width; Channels drops Prio, About, Length | Hide About only; give the name a bigger share | At 1024 the channel name had 60 px; the owner chose to drop all three there |
|
||||
| 2026-09-27 | Glyph pager | First / previous / next / last as glyph buttons round the page box, names as tooltips | Words beside the arrows (hidden only on a phone) | The owner's call: the words widened the row; four arrows are the shared convention |
|
||||
| 2026-09-27 | Match switch on the panel | `ui/MatchToggle` above the picker from two picks, everywhere; one wording (Any / All) | The picker's popover footer (Feed, Plex collection) | A closed picker hid an active "all"; six pickers had drawn it two ways |
|
||||
| 2026-09-27 | Missing picture, bare link | A glyph of the kind for no picture; a download with no name is titled by its short link, once | An empty box; the URL as title, channel and link | The owner's picks 4A and 3A: an empty box read as loading, the URL showed three times |
|
||||
| 2026-09-27 | Copy on a selection | Right-click on selected words keeps the browser's menu; no app Copy row | An app menu with Copy first | A real browser's menu has copy, search and translate; only the desktop pane's is thin |
|
||||
| 2026-10-01 | Zoom target 125–133% | Screens are held at 125% and 133% browser zoom (1536 × 729, 1444 × 685 on a 1080p window) and the largest text size | 200% browser zoom | The owner: 200% was a guess; 125–133% is what they read Siftlode and most sites at, so 200% would be an upper bound nobody uses |
|
||||
| 2026-10-01 | E31 Nav rail at short heights | One "Admin" rail item opening a panel of the admin pages | A denser rail when short; a visible "+4" hint | The rail fits at 133–150% without a hidden scroll; the admin pages are rare and admin-only |
|
||||
| 2026-10-01 | E31 Configuration sections | A vertical, grouped section list, as Settings has | Group tabs + sub-tabs; one long page with a contents list | Sixteen tabs wrapped to three rows at 133%; one pattern for both settings pages |
|
||||
| 2026-10-01 | E31 Statistics overview | Number tiles, and the per-operation counts as horizontal bars | Three narrow cards; a 30-day chart | The label sits by its value; what spends the quota is seen at a glance |
|
||||
| 2026-10-01 | E31 Channel page header | The full banner, folding to one row once the grid scrolls | Always compact; no banner | The header is fixed and took 40% of a 685px window; the channel keeps its face at first sight |
|
||||
| 2026-10-01 | E31 Channel page watch state | A fresh channel page shows the unwatched videos; a picked state is remembered | Show all, forgotten on reload | The owner: open a channel to see what is new; a choice made there should stick |
|
||||
| 2026-10-01 | E31 Audit log runs | A job's successful runs fold into one row that opens, whatever ran between; only that job's failure (never folded, danger colour) breaks it | A filter defaulting to admin actions; both | Nothing hidden, the noise collapsed, the failure seen |
|
||||
| 2026-10-01 | E31 Users row | Role and state as badges, the actions in the row's one menu | An inline role toggle; icon-only buttons | Action placement: the row shows state, rare and destructive actions live in the menu |
|
||||
| 2026-10-01 | E31 YouTube player | Video beside an info panel at 1280px and wider (the video as large as the height allows); stacked below | Today's stack with a one-line bar; full-window like Plex | The owner's eyesight: about 1460px of video on 1080p instead of 1008, the actions still visible |
|
||||
| 2026-10-01 | E31 Plex info and show pages | A second column for the facts, keywords and crew, as the person page; a collection's hero sized to its content | Full-width text; a larger poster and fanart | The right ~40% was empty; one composition for every media page |
|
||||
| 2026-10-01 | E31 Nav rail rows are links | Each page row is a real link: a middle-click or Ctrl-click opens the page in a new tab | A button | The owner: open a page in a tab of its own, as on any site |
|
||||
| 2026-10-02 | E31 Plex detail second column (refined) | The facts over the key crew beside the text; the keywords, chips that filter like the genres, under the text; the show page gets all three too | All three in the column, as the mockup drew it | At 20 keywords and 12 crew the column ran to twice the text's height (849 vs ~400 px at 133%): a hole under the text instead of beside it |
|
||||
| 2026-10-02 | E31 Plex detail poster and crew | The movie and show poster keeps its 2:3 and grows a rung in a wide hero (w-56 at a 64rem panel); the crew shows its first 6 names and "Show N more" (a list of 8 or fewer shows whole) | Poster stretched to the row; a "More" on the keywords | The row is as tall as the crew column, so the poster drew ~175×590 and cropped; the crew, not the keywords, set the hero's height |
|
||||
| 2026-10-04 | E32 `field` focus skips a mode switch | A form opens on its first field; a control above it that is not a field (the composer's Edit / Preview) is passed over by marking the field `data-initial-focus` | The first text field everywhere | That moved the focus in the download dialog off its profile choice and in the upload dialog off its drop zone; the mark changes only the form that needs it |
|
||||
| 2026-10-04 | E35 Lint for z-levels and edged labels | Both rules stay; the two eslint selectors (`Z_RAW`, `EDGED_LABEL`) that refused them go | Keep the selectors | 0 hits since the E28 sweep that fixed the sites: a check that never fires costs a probe test and a snapshot each, and the UI review holds both rules |
|
||||
| 2026-10-06 | `player` focus | A dialog that is a player opens focused on its panel (the YouTube player's rule, now `Modal`'s too); on the media element itself, Space and the arrows stay the element's own | `close`, or the media element | On the ✕ Space closed the player; on the element the browser's own controls acted on the same key as the player's (one Space played and paused, measured in Chrome) |
|
||||
| Date | Decision | Chosen | Over | Why |
|
||||
| ---------- | ------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 2026-09-24 | Floating button | Back to top: a round 48 glass FAB; "round" = floats over something (media or the page) | A Material 3 rounded square; a labelled pill; the old 64 circle | The common web convention for back to top; round tells it apart from every toolbar button; 64 was oversized |
|
||||
| 2026-09-24 | Device contract | Tablet portrait + landscape and up; phones out | A 1024px floor with a "too small" message | Portrait tablet use is real, and the app already renders at 820 portrait |
|
||||
| 2026-09-24 | Card actions on touch | Fewer visible, the rest in a "…" menu | 44px buttons that no longer fit the card | Six 44px targets need more width than a card has |
|
||||
| 2026-09-24 | Action placement | One menu with several triggers, never gesture-only, a desktop background menu that mirrors the toolbar | Visible buttons for everything | Fewer non-critical buttons on screen, without hiding any function |
|
||||
| 2026-09-24 | Text selection | Text stays selectable; a click that ends a selection does nothing | Click-to-play on titles | The user must be able to copy any text the app shows |
|
||||
| 2026-09-24 | A1 Button shape | r8 + a rung per context; glass is a Button variant | r12; a pill | Nested corners (r8 in r12 in r16); the pill already means "a filter or a choice" |
|
||||
| 2026-09-24 | A2 Icon buttons | Two sizes, r8; a round button only on media | One size; all round | A lone toolbar icon at the row size looks lost; round rows read as tags |
|
||||
| 2026-09-24 | A3 Touch size | 44 for every control primitive | 36 + gaps; Button only | One rule (HIG 44) instead of two sizes nobody remembers |
|
||||
| 2026-09-24 | A4 Input | Bordered, a 55% fg-into-bg edge (≥ 3:1) | Filled; today's look as a primitive | The only option that clears WCAG 1.4.11 without a heavy fill on glass |
|
||||
| 2026-09-24 | A5 Dropdown trigger | Pill in a toolbar, r8 in a form | Pill everywhere; r8 everywhere | Matches the neighbours in each context |
|
||||
| 2026-09-24 | A6 Checkbox, switch, slider | Native, accent-tinted, the row is the target | Drawn primitives | Free keyboard / screen-reader / forced-colours support; only 3 sites |
|
||||
| 2026-09-24 | B1 Density | Compact 32 / 28 / 24 by explicit height | Standard 38 / 32 / 26 | The owner's read of the lab; touch is covered separately (A3), and one toolbar row lines up at 28 |
|
||||
| 2026-09-24 | B2 Scheme set | Midnight, Forest→cyan, Slate→fuchsia, Matrix→lime | Today's six | The accent collided with danger (YouTube), warn (Slate) and success (Forest, Matrix); Midnight and Starship were one scheme twice; 4 of 6 light accents failed 4.5:1 |
|
||||
| 2026-09-24 | B3 State layers | Text colour at 10% / 16% over anything | Material's 8 / 12%; today's opacity + bg-card | Visible in every scheme and mode (≥ 1.2:1); today's row hover was 1.00:1 on a card |
|
||||
| 2026-09-24 | B4 Page chrome | One sticky band + fade; filters fold when short | A floating glass bar; everything scrolls | Tabs and filters always in reach, in one place; a keyboard-up tablet keeps its content |
|
||||
| 2026-09-24 | B5 Surfaces | One class + radius per role | Today's per-site mix | Nested corners and no per-site alpha drift |
|
||||
| 2026-09-24 | B6 /watch | The app's surfaces, native video controls | Flat and minimal | The page strangers see should look like the product; native controls work on any device |
|
||||
| 2026-09-24 | B7 Nav rail | Soft tint + edge bar, optional labels (a user setting), slim | Solid accent (recommended); a Material rail | The owner's combination: calmer than a solid fill, labels for who wants them, no width cost |
|
||||
| 2026-09-24 | B8 Loading | Skeleton by shape, spinner in a control | A spinner everywhere | Nothing jumps when data lands; a long wait doesn't look stuck |
|
||||
| 2026-09-24 | B9 Fade | Toolbars / headers, panel tabs, dialog edges, chip rows | Thumbnails; buttons | The north-star depth where content scrolls under chrome; not on content or small controls |
|
||||
| 2026-09-24 | C1 Action classification | The table (lab C1): state + primary at rest, everyday toggles on hover, "…" on touch, destructive into the menu | Visible buttons per site, as today | One action list per object, nothing gesture-only, fewer buttons without losing a function |
|
||||
| 2026-09-24 | C2 Side panels | A tap opens an overlay drawer; pin is a separate button ≥ 1024 | A tap pins and the content reflows | Below 768 the filters were unreachable; on touch a tap pinned and pushed the grid |
|
||||
| 2026-09-24 | C3 Players on touch | Tap = show the controls; 44px seek hit area; 44 buttons; the YT description behind ⓘ | Today (tap also pauses; 7.5px seek bar) | Measured live: you couldn't reach the seek bar without pausing, and the hover-only description never opened on a tap |
|
||||
| 2026-09-24 | C4 Dialogs | Focus by purpose; a pinned footer | Focus on Close; the footer scrolls | A form starts in its first field, and Save stays on screen with the keyboard up |
|
||||
| 2026-09-24 | C5 Plex player controls | The owner's lean set: no Stop / Restart / ±10 s buttons, Back by position, Next only with a next item, Resume + From the start on the card, touch double-tap ±10 s | Every control in one row (it ran off-screen below 1024); Back by a press counter; ±10 s keyboard-only | The row fits a portrait tablet; the decision to restart is made on the card; a tablet keeps the 10 s skip |
|
||||
| 2026-09-24 | Glass tuner | Retired; the schemes are the only colour choice | Keep it with ranges clamped to the rules | Free tuning is the drift the constitution exists to stop (owner) |
|
||||
| 2026-09-24 | Matrix toned down | Moss #86a05a at Midnight's lightness, near-neutral greys; every white-on-accent part → `--accent-fg` | The B2 lime #a3e635 | The owner found the lime neon and tiring; it was the loudest accent (L 0.85, C 0.21), and fixed white parts sat on it at 1.5:1 |
|
||||
| 2026-09-24 | Field primitives | `Input` / `Textarea` / `InnerInput` + `fieldClass`, `SelectTrigger`, the native controls; the lint rule "no raw `<input>`" | `inputCls` and ~40 hand-styled boxes | Six edges, four radii and two focus looks for one control; the ≥ 3:1 edge held nowhere |
|
||||
| 2026-09-25 | Surface roles | `SURFACE` roles in `ui/surfaces.ts`; the lint rule "no bare glass class"; menus p-1 with r8 rows | Three tiers paired ad hoc with five radii and three menu paddings | ~140 sites drifted; nested corners only hold if the radius comes with the role |
|
||||
| 2026-09-25 | Static labels | Flat tonal `Badge`, no edge; a filtering tag is an edged chip | A glass pill with an edge, like the chips | Edged = pressable at a glance; a static tag that looks like a chip gets clicked and does nothing |
|
||||
| 2026-09-25 | Header search | A 40 glass pill with the field edge (`lg`); what sits inside it is round | 44, r16, a glass box with a faint edge | The one rounded box among pills, and its edge under 3:1; the hero search is the YouTube/Gmail pill |
|
||||
| 2026-09-25 | Switch row (F12) | The whole settings row toggles (`SwitchRow`; a hand-laid row points a label at the switch) | Only the 36×24 switch toggles | The iOS / Material settings row; the text was a dead click beside the control it names |
|
||||
| 2026-09-25 | Edges and hovers (F14) | The ≥ 3:1 edge on the field family (fields, the form trigger); labelled pills keep the faint edge and the accent-edge hover | The ≥ 3:1 edge and a state-layer hover on every control | 1.4.11 asks an edge only where nothing else identifies the control; a heavy edge on every chip and tab weighs the whole UI down |
|
||||
| 2026-09-25 | Rating and live (F16) | Own tokens: `--rating` (theme-scoped gold), `--live`/`--live-fg`, `--rating-media`, `--media-chrome`; palette classes lint-refused | Map them to `--warn` / `--danger` | A gold star and a red LIVE are what the eye expects; as warn/danger they would read as a status. The old amber score was 2:1 on light |
|
||||
| 2026-09-25 | Fjord trial (B2 exception) | A fifth scheme, temporary: Midnight's neutrals, Playnite's steel accent (#337599 lifted to #5aa1cb) and steel rules; exit = fold into Midnight or remove | Retune Midnight straight away; borrow only the qualities | The owner wants to live with the calmer palette app-wide before it replaces the default; it breaks "own hue family" on purpose, and only until that call |
|
||||
| 2026-09-25 | Whole-card row (owner) | A card row's B3 layer is the card's `::before` under its content; the open button's hit layer spans the card, the actions sit above it | The name button's own hover, ending at the first badge or button | A row that half-answers the pointer reads as broken; a badge click did nothing |
|
||||
| 2026-09-25 | Badge tones (F29) | `Badge tone` neutral / accent / warn at 6%; light `--warn` #b45309 → #a14a07; edged label spans lint-refused | Keep edged status pills (a private Badge in Users, Settings "Granted", the suggestions count) | Edged = pressable at a glance; amber-700 was 4.58:1 as text and fell under 4.5 on its own tint |
|
||||
| 2026-09-26 | Page band built (B4) | Controls only in the band (intros scroll); the fold shows the same row in place; one `h1` per page (`PageHeading` steps the hidden one aside); `SectionLabel` headings; Skip to content | A popover of the folded filters; the page title kept visible | A folded row is still the same controls, nothing duplicated; the rail and the tab title already name the page |
|
||||
| 2026-09-26 | Fade built (B9) | `fade-below` on sticky toolbars and table headers; the scroller mask where the band sits on the backdrop | A painted band over the backdrop | The backdrop is the colour under the band, so fading the content into it is the same edge |
|
||||
| 2026-09-26 | Viewport units | `dvh` everywhere (the Plex player stage with S3f) | `vh` / `h-screen` | `100vh` hides the last rows under a tablet browser's toolbar |
|
||||
| 2026-09-26 | Video actions built (C1) | One list per video (menu, right-click, long-press, "…"); the card keeps watched / save / add to playlist on its picture on hover; download, details, copy, hide, reset in the menu | The six-button row under the card and the hover ⓘ | The menu is the one door every device has; the card loses a line of height and the touch card its row of 28px buttons |
|
||||
| 2026-09-26 | Plex actions built (C1) | One list per Plex title (play/open, watched, add to playlist, media info, copy title, pin); the poster keeps watched / add / "…" on its picture on hover; seasons get open / watched / add / copy | The hover ⓘ, the pin-only menu, an episode's add button at rest, a collection's "Remove" X on the card | The same door as a video's; the collection grid goes to two columns on a phone so the touch "…" leaves the title its line |
|
||||
| 2026-09-26 | Playlist actions built (C1) | A playlist row's "…" (play, pin, copy title / link, move, order, remove), Delete + an undo toast; one list per playlist (rename, pin, YouTube open / push / revert, delete) from its rail row and its header's "…" | The row's X column and its confirm; the header's pencil, YouTube link, push, revert and trash buttons | Remove is reversible, so it needs no confirm; the header keeps Play all; a narrow table drops "#" and Channel so the title keeps a width |
|
||||
| 2026-09-26 | Channel + Plex list actions (C1) | A channel row's "…" (open, pin, YouTube, copy, full history, hide + undo, unsubscribe); a Plex playlist's title / show / season "…", Delete + undo; its header's "…" (rename, delete); an action toast lasts ≥ 8 s | The channel row's full-history, hide and unsubscribe buttons; the Plex list's red X on every row, show and season; the header's pencil and trash | Hiding and removing are reversible, so they need no confirm and the undo needs time; unsubscribe and deleting a playlist keep theirs |
|
||||
| 2026-09-26 | Download / notification / Scheduler actions (C1) | A download row's "…" (play, share, save, edit, details, re-download, manage sharing, copies, cancel / delete / remove); a library row's Share stays in sight, ahead of save; a notification row's "…" and hover dismiss; the inbox band's and the Scheduler strip's "…" | The download row's five rare buttons and its cancel / delete / remove; the source link's copy button; an activity notice's Find / Undo links; the band's Clear everything; the strip's Purge discovery | The destructive rows keep their confirm, and Clear everything gains one; a row link keeps the browser's own menu; Find in feed drops the tag / search / age filters that could hide its video |
|
||||
| 2026-09-26 | Channel menu: Tags + Priority (C1) | Tags A–Z as in-place checks; Priority as fixed steps 10 · 5 · 3 · 2 · 1 · 0, the current one ticked; a pick re-sorts at once and the page + focus follow the row; one drawn `MenuCheck` for every checkable row that cannot be an `<input>` | Relative steps (+1 / +5); straight to the top; the stepper only; seven hand-drawn check marks (three box sizes, a tinted box, a × for "on", a trailing tick) | Bubbling a channel up is one pick; fixed levels keep the scale the reader already uses; the list moving where the reader can see it is the feedback |
|
||||
| 2026-09-26 | Reorder without a drag (WCAG 2.5.7) | Row menus: Top / Up / Down / Bottom (YouTube + Plex playlists); saved views: four buttons with the place beside the selected view (owner: A); side panels in edit mode: an Up / Down pair per group (owner: A), the name wrapping there | A "…" on every saved-view row; a "…" on every panel group; drag only | A visible, one-click path where the list is edited; the edge moves shown off so the buttons never shift |
|
||||
| 2026-09-26 | Titles in click surfaces | The surface stays a button, flagged `selectable`: its text selects, and the click that ends a selection does nothing; every plain `Pressable` pinned with its reason | Rebuilding each card and row so the title sits outside the button; exceptions for master-detail lists | Chrome selects inside a button once it is `user-select: text` (measured), so one flag on the primitive keeps the keyboard, focus and list walking every surface already has |
|
||||
| 2026-09-26 | Right-click on a selection | The browser's own menu opens on selected words; beside them the object's menu | A "Copy selection" row in the app menu | The reader selected the words to do something with them; the native menu copies, searches and translates, and changes nothing else |
|
||||
| 2026-09-26 | Keyboard ring on a flush row | Inside the row (a table row by its tag, a log line, a show header in its clipping card, with the card's corners) | The ring outside, as on a card | Outside it ran under the table head and past the table, and a clipping card cut it away entirely (invisible focus) |
|
||||
| 2026-09-26 | Fade at rest | The sticky header's fade appears with the first 1rem of scroll (a scroll-driven animation; no fade where unsupported) | A fade that is always drawn | At rest it dimmed the first row and hid its ring's top edge; nothing is under the header yet |
|
||||
| 2026-09-26 | Narrow playlist table | "#" and Channel hide by the table's own width (under 42rem), not the window's | Hiding below the `sm` window width | Beside the playlist rail a desktop window left the table 432 px and the title 0 px |
|
||||
| 2026-09-26 | Background menu built | Five list pages (feed, Plex grid, channels, playlist, notifications); the toolbar's sort / view / filters and the band's actions; no destructive row | Every page with a toolbar; the "…" menus copied whole, delete included | The tab-only pages have nothing a menu would add; a blank-area right-click must not be one click from delete |
|
||||
| 2026-09-26 | Media card anatomy (C) | One frame; the meta line pinned to its bottom; the length a badge on the picture; the Plex card on the same primitive as the video card | A: the year as an overline over the title; B: the year inline after it; a fixed two-line title slot | A one-line title left a hole the eye reads as a missing line; C puts nothing under a variable-height title |
|
||||
| 2026-09-26 | One Play | `MediaPlayGlyph` (the media button variant) and only where the click plays | The Plex card's hand-painted accent circle; a playable dot on every card | One Play for every card; a dot that only a colour and a tooltip explain says nothing |
|
||||
| 2026-09-26 | Filtering values on the info page | A value that filters (year, certificate, studio, director) is an edged chip; a fact is flat text | Everything flat, the clickable ones only hover-tinted | The S3d rule: edged = pressable, so the reader sees which values do something |
|
||||
| 2026-09-26 | Shelf density | On any strip: one title line over one meta line | The grid's two lines and full meta on a strip | The tallest card set the strip's height and left holes in the cards with less to say |
|
||||
| 2026-09-26 | Compact list row (B) | The title leads; a faint "…" at the end; the toggles float over the date on hover | A leading 128px action column | That column sat empty at rest, a hole at the start of every row |
|
||||
| 2026-09-26 | Media-info layout (B) | A main column (collections first, crew, cast) beside a facts sidebar; an empty column is not drawn | Every section full width, stacked | The collection is the most used; the keywords' column left a void beside a short details box |
|
||||
| 2026-09-26 | Mixed Home shelf (A) | Plex titles as wide art beside videos | Posters, with the videos not stretched | Stretching a video card to a poster's height left an ~80px gap |
|
||||
| 2026-09-27 | Home hero | A card (hover row + Play on the picture, the whole menu, a touch "…"; owner: A); three quarters wide and centred on a wide window; one fact line; the summary at the measure | The hero's pin row alone; full width; an 18rem picture column | The one answer stands in focus, and it offers what the same card offers anywhere |
|
||||
| 2026-09-27 | Home shelf tiles | A channel / playlist tile: Play, ↗ open, "…" with Open; a show acts on its next episode, ⓘ opens it; the shelf slot 232px | The tile's pin row alone; 200px | A tile without its object's actions read as broken; at 200 the Play touched the hover row |
|
||||
| 2026-09-27 | Synopsis preview (C) | A ~0.9 s mouse rest opens a passive panel beside the card | The synopsis as the picture's `title` tooltip; an ⓘ to hover; none | One long tooltip line fired anywhere on the art and clashed with the cut title's own tooltip |
|
||||
| 2026-09-27 | Reading measure | Prose capped at ~65 characters a line (`measure`) | The container's full width | At 110–130 characters a line the eye loses the next line's start |
|
||||
| 2026-09-24 | Chip family | One `Chip` (filter / token / tag / suggest / ghost) + `DismissibleChip`; nothing under 24; the lint rule "no raw `<button>` outside ui/" | Five chip geometries in five files | The applied-filter row, the tag cells, the year picker and the saved-view editor each drew their own chip; a one-letter tag was 21px wide |
|
||||
| 2026-09-24 | Button families | `Button` / `IconButton` / `Pressable` / chips / `DragHandle`; `lg` 40 for a hero CTA; pills and a 48 Play on media (from C5) | Every button hand-painted | 268 hand-rolled buttons in 79 files drifted into as many looks; the ladder only holds if every button stands on it |
|
||||
| 2026-09-27 | C4 as built | `initialFocus` required on every `Modal`; the commit row in `ModalFooter`, portalled under the body from any depth; the fades driven by the body's scroll timeline; `interactive-widget=resizes-content` | An optional prop defaulting to the ✕; a `footer` prop (the issue composer's buttons live three components down); always-on fades | A default is how 21 dialogs came to open on the ✕; lifting the composer's state to reach a prop is the larger change; a resting fade dimmed the first line |
|
||||
| 2026-09-27 | C3 + C5 as built | A tap toggles the bar through `pointerType` from `pointerdown`; a double-tap takes back its first tap's toggle; the card's pair is the video card's `MediaResumeButtons` (Continue / Restart), a sibling over the picture; the YT description is an ⓘ click popover | A delayed single tap (wait out the double-tap window); new "Resume / From the start" words for Plex; the pair inside the picture's button | A delayed tap feels dead; one media card says one pair of words; a button inside a button is invalid and the hover row already sits beside the picture |
|
||||
| 2026-09-27 | C2 as built | The side panel's drawer is `ui/Modal` `drawer-start`; drawer = a coarse pointer or below 768; the pin offered in its header ≥ 1024 | A drawer of its own inside `SidePanel` | Esc, Back, the scrim, the trap and the scroll lock are the dialog's; a second implementation is how two shells drift |
|
||||
| 2026-09-30 | Composition and the reader (E30 S6) | A chapter and a Conformance step: the space is used, one focal point, worst real data, where am I, a click answers at once, zoom and text size first-class; the person page as two columns (facts and bio left, "Known for" right) | Rules for pieces only; the bio in a full-width panel; a narrower panel | The person page passed every piece rule with half its panel empty; the owner reads zoomed in, so zoom is a requirement, not a nicety |
|
||||
| 2026-09-30 | Hungarian words (E30 S5) | The D3 list: Feed, Shorts, Admin, Token, Port stay; stream, Prio, Job, Audit, Demo, Auto, Info, Frontend/Backend, Backfill, transcode get the Hungarian word; one name per thing (kérelem, behúzás, one "Test connection") | Keep every loanword; translate Feed/Shorts too | The owner chose the list (D3); the final sweep found the same feature under two names in three places |
|
||||
| 2026-09-29 | Numbers and order in the UI language (E30 S3) | Every number through `lib/format.ts` in the UI language (decimal mark, grouping, compact, units); A→Z by the shown name in the UI language's alphabet, client and server alike; the language comes off `useTranslation`, never the singleton | `toFixed`, hand-built "1.2K", `localeCompare` in the browser's locale, byte-order server sorts | A Hungarian reader saw "8.3", "1.2K", "Életrajzi" after "Z"; the singleton read also left values in the old language after a switch (React Compiler) |
|
||||
| 2026-09-27 | Narrow rows by container | The download row, the playlist table and the Channels tag row stack by a container query; a fact line never wraps | Breakpoints on the window | The iPad zoomed in, split screen and a column beside a rail are narrow while the window is not; the zoomed download row broke "18.9 / MB" apart |
|
||||
| 2026-09-27 | Phones deferred | A phone-only layout (the hamburger nav, a folded feed band) waits for its own epic; E28 stays desktop + tablet | Fix the phone views inside E28 | Principle 2; the first phone attempts made the feed worse, and the epic's review grows with every sprint |
|
||||
| 2026-09-27 | Conformance check (S3g) | A four-step check before a UI change is done: primitive at a named rung, the chapter re-read, the rendered page measured by kind and look, every state seen | A reviewer's eye per sprint | The S1 probe found 69 button looks; after S3 every control kind is drawn by its primitive, and the check keeps a new look from arriving unnoticed |
|
||||
| 2026-09-27 | Segmented rung | `SegmentedToggle` defaults to the toolbar rung (28, `text-xs`); 24 only for a panel's captioned match toggle | The small default | Two toolbars had inherited 10px type from the default beside 28px buttons |
|
||||
| 2026-09-27 | Copy rule (S3g) | One name per feature (the rail may shorten it); four verbs for four meanings; sentence case with the app's places capitalised; "…" when a command asks first; one-sentence messages without a stop; curly quotes; dates relative in lists with the exact minute on hover, three absolute forms; units from Intl, short | Word-for-word names everywhere; the verbs as they were; all lower case; no "…"; always a stop; always absolute dates; long or compact units | The copy survey found 7 EN and 6 HU double names, 22 Delete against 27 Remove, five date shapes and three spellings of a minute; the owner picked every recommended option |
|
||||
| 2026-09-27 | One glyph, one meaning (S4) | Play only for playback or resuming a paused transfer; Retry = `RefreshCw`; reset / revert = `RotateCcw` | Retry drawn with Play | The first UI-review run (Downloads) found a failed row whose Retry showed ▷ in the row and its menu |
|
||||
| 2026-09-27 | Fjord kept | Fjord stays a fifth scheme beside Midnight; the trial wording goes | Fold it into Midnight; remove it | The owner lived with it and likes it; the vivid Midnight stays for those who want it. B2's own-hue rule still binds any new scheme |
|
||||
| 2026-09-27 | Tablet columns | A table hides `tabletHidden` columns below 64rem of its own width; Channels drops Prio, About, Length | Hide About only; give the name a bigger share | At 1024 the channel name had 60 px; the owner chose to drop all three there |
|
||||
| 2026-09-27 | Glyph pager | First / previous / next / last as glyph buttons round the page box, names as tooltips | Words beside the arrows (hidden only on a phone) | The owner's call: the words widened the row; four arrows are the shared convention |
|
||||
| 2026-09-27 | Match switch on the panel | `ui/MatchToggle` above the picker from two picks, everywhere; one wording (Any / All) | The picker's popover footer (Feed, Plex collection) | A closed picker hid an active "all"; six pickers had drawn it two ways |
|
||||
| 2026-09-27 | Missing picture, bare link | A glyph of the kind for no picture; a download with no name is titled by its short link, once | An empty box; the URL as title, channel and link | The owner's picks 4A and 3A: an empty box read as loading, the URL showed three times |
|
||||
| 2026-09-27 | Copy on a selection | Right-click on selected words keeps the browser's menu; no app Copy row | An app menu with Copy first | A real browser's menu has copy, search and translate; only the desktop pane's is thin |
|
||||
| 2026-10-01 | Zoom target 125–133% | Screens are held at 125% and 133% browser zoom (1536 × 729, 1444 × 685 on a 1080p window) and the largest text size | 200% browser zoom | The owner: 200% was a guess; 125–133% is what they read Siftlode and most sites at, so 200% would be an upper bound nobody uses |
|
||||
| 2026-10-01 | E31 Nav rail at short heights | One "Admin" rail item opening a panel of the admin pages | A denser rail when short; a visible "+4" hint | The rail fits at 133–150% without a hidden scroll; the admin pages are rare and admin-only |
|
||||
| 2026-10-01 | E31 Configuration sections | A vertical, grouped section list, as Settings has | Group tabs + sub-tabs; one long page with a contents list | Sixteen tabs wrapped to three rows at 133%; one pattern for both settings pages |
|
||||
| 2026-10-01 | E31 Statistics overview | Number tiles, and the per-operation counts as horizontal bars | Three narrow cards; a 30-day chart | The label sits by its value; what spends the quota is seen at a glance |
|
||||
| 2026-10-01 | E31 Channel page header | The full banner, folding to one row once the grid scrolls | Always compact; no banner | The header is fixed and took 40% of a 685px window; the channel keeps its face at first sight |
|
||||
| 2026-10-01 | E31 Channel page watch state | A fresh channel page shows the unwatched videos; a picked state is remembered | Show all, forgotten on reload | The owner: open a channel to see what is new; a choice made there should stick |
|
||||
| 2026-10-01 | E31 Audit log runs | A job's successful runs fold into one row that opens, whatever ran between; only that job's failure (never folded, danger colour) breaks it | A filter defaulting to admin actions; both | Nothing hidden, the noise collapsed, the failure seen |
|
||||
| 2026-10-01 | E31 Users row | Role and state as badges, the actions in the row's one menu | An inline role toggle; icon-only buttons | Action placement: the row shows state, rare and destructive actions live in the menu |
|
||||
| 2026-10-01 | E31 YouTube player | Video beside an info panel at 1280px and wider (the video as large as the height allows); stacked below | Today's stack with a one-line bar; full-window like Plex | The owner's eyesight: about 1460px of video on 1080p instead of 1008, the actions still visible |
|
||||
| 2026-10-01 | E31 Plex info and show pages | A second column for the facts, keywords and crew, as the person page; a collection's hero sized to its content | Full-width text; a larger poster and fanart | The right ~40% was empty; one composition for every media page |
|
||||
| 2026-10-01 | E31 Nav rail rows are links | Each page row is a real link: a middle-click or Ctrl-click opens the page in a new tab | A button | The owner: open a page in a tab of its own, as on any site |
|
||||
| 2026-10-02 | E31 Plex detail second column (refined) | The facts over the key crew beside the text; the keywords, chips that filter like the genres, under the text; the show page gets all three too | All three in the column, as the mockup drew it | At 20 keywords and 12 crew the column ran to twice the text's height (849 vs ~400 px at 133%): a hole under the text instead of beside it |
|
||||
| 2026-10-02 | E31 Plex detail poster and crew | The movie and show poster keeps its 2:3 and grows a rung in a wide hero (w-56 at a 64rem panel); the crew shows its first 6 names and "Show N more" (a list of 8 or fewer shows whole) | Poster stretched to the row; a "More" on the keywords | The row is as tall as the crew column, so the poster drew ~175×590 and cropped; the crew, not the keywords, set the hero's height |
|
||||
| 2026-10-04 | E32 `field` focus skips a mode switch | A form opens on its first field; a control above it that is not a field (the composer's Edit / Preview) is passed over by marking the field `data-initial-focus` | The first text field everywhere | That moved the focus in the download dialog off its profile choice and in the upload dialog off its drop zone; the mark changes only the form that needs it |
|
||||
| 2026-10-04 | E35 Lint for z-levels and edged labels | Both rules stay; the two eslint selectors (`Z_RAW`, `EDGED_LABEL`) that refused them go | Keep the selectors | 0 hits since the E28 sweep that fixed the sites: a check that never fires costs a probe test and a snapshot each, and the UI review holds both rules |
|
||||
| 2026-10-06 | `player` focus | A dialog that is a player opens focused on its panel (the YouTube player's rule, now `Modal`'s too); on the media element itself, Space and the arrows stay the element's own | `close`, or the media element | On the ✕ Space closed the player; on the element the browser's own controls acted on the same key as the player's (one Space played and paused, measured in Chrome) |
|
||||
| 2026-10-08 | Captions on touch | A `Tooltip` opens for a mouse and a key's focus only, never for a finger | A caption on tap; a long-press caption | On an iPad the tap that showed the caption was taken as a hover and lost its click (every rail button took two taps), and no finger sends the mouseleave that hides it; the rail's labels setting names the buttons on touch |
|
||||
| 2026-10-08 | Header search on touch | No key cap on a touch screen; the pill grows to 52 px so a ring shows round the 44 px Go; Go drops to its icon by the column's width | Keep the key cap; a 44 px Go overhanging a 40 px pill | On a tablet the 44 px key cap and Go sat on the pill's edge and cut the placeholder; an overhanging Go breaks the nested radii |
|
||||
| 2026-10-08 | Plex subtitles and the control bar | The player draws the cues itself; while the bar shows they rise above it (200 ms), then settle back | The browser's own cues, moved by `line` | On an iPad the bar covered the text; a native cue cannot be animated or measured, and our layer behaves the same on every engine |
|
||||
| 2026-10-08 | Hover on a button over a picture | The `media` variant's hover is a 2px accent ring over a darker body (Plex + YT cards, rail arrows; option B) | An accent fill (reads as "selected"); a white wash (weak on a bright poster) | The 60 → 75% black step was invisible, and over a card a rail arrow could not be told from the card's own Play under it |
|
||||
| 2026-10-08 | Plex "Add to collection" | A movie or show card's menu row opens a panel beside the card: its library's manual collections tick in place, a name + Enter creates one with the title in it (admin; option A) | An Add submenu with a "New…" dialog; the per-title editor modal from the menu | A collection took a trip to the Collections page, a name, a title search, then a second search per title; the panel is the video card's "Add to playlist" |
|
||||
| 2026-10-08 | YouTube modal chrome | No prev/next strips beside the card; Close an icon (the panel's top right; stacked, the title row's end); the description clamped to three lines + More; the panel shuts to a 2.75rem strip of close, expand, prev/next (option D1) | Keep the strips; a "Close" text button; the full description in a scroller; a panel that hides outright | The strips cost the video 7rem of width for a second copy of the panel's buttons; a long description pushed the channel and the actions out of sight; a hidden panel left no way to step the queue without the keyboard |
|
||||
| 2026-10-08 | The wheel over the YouTube modal | Over the video: volume. Over a panel with something to scroll: scroll only, never volume, not even at its end. Over a panel with nothing to scroll: volume (owner) | Volume everywhere; a scroll that hands over to the volume at its end | Reading a long description turned the volume down once it hit the end |
|
||||
| 2026-10-09 | A Plex show card plays | Its picture plays the show's next episode everywhere (an episode with a resume point → Continue + Restart, else the first unwatched, else S1·E1), resolved on the click; the preview names it once up; title and ⓘ open the show (option A + hover ask) | The picture opens the show page (as before); every card carrying `next` | A show card on Discover had no Play while Home's had one; carrying `next` costs a grid page up to 180 queries for the few cards anyone plays |
|
||||
| 2026-10-10 | Clip mode in the players (E38) | Scissors in the control row + `C`, `[` `]`; the panel in the player with the watched video as preview; overview + zoomed strip on `ui/RangeTrack` (ends are sliders); a toast + the Ready notification after Cut | A ⋯ menu entry; permanent In / Out buttons; a big dialog with its own preview (a second Plex transcode / YouTube iframe) | Owner's picks A, P1, a; a film is hours and a clip seconds, so only a zoomed strip gives a hand the precision. The Downloads editor's cuts moved onto the same track (keyboard-steppable now) |
|
||||
| 2026-10-10 | Crop in clip mode (E38) | A Crop toggle in the clip panel: a frame over the playing picture (`ui/CropFrame`, the editor's crop too), Free / 16:9 / 9:16 / 1:1, Whole picture; maximised YouTube keeps the panel in the bar's place; the wide side panel shuts while clipping | A frame always on the picture in clip mode (K2); a free shape only | Owner's K1 + a: a crop is rare, so it never covers the picture unasked; a phone or square clip needs a held shape. The iframe hides its picture's size: drawn on the 16:9 box, the cut fits it to the real picture |
|
||||
| 2026-10-10 | Marks on the player chrome | `--media-mark` (amber) and `--media-danger` (light red), the same in both themes; `media-marks` points the focus ring (`--focus`) and the chosen mark (`--chosen`: a track's tick, a tab's underline; the label stays white) at the amber on the player and every chrome surface; `media-scope` (a form) also the errors at the red | The theme's accent ring, chosen text, warn and danger | On the chrome (#171717) a light theme's accent ring (Slate 2.84:1), warn (2.99:1) and danger (2.77:1) all fall under 3:1; the accent as chosen text under 4.5:1 |
|
||||
|
||||
@@ -2,23 +2,19 @@ import { useLayoutEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { ListPlus, Plus } from "lucide-react";
|
||||
import { ListPlus } from "lucide-react";
|
||||
import { YouTubeIcon } from "../lib/brandIcons";
|
||||
import { MenuCheck } from "./ui/MenuCheck";
|
||||
import { api, type Playlist } from "../lib/api";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { useScrollFade } from "../lib/useScrollFade";
|
||||
import { playlistName } from "../lib/playlistName";
|
||||
import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover";
|
||||
import { PopoverSurface } from "./ui/AnchoredPopover";
|
||||
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
|
||||
import { IconButton } from "./ui/IconButton";
|
||||
import { Input } from "./ui/Input";
|
||||
import { MembershipMenu } from "./ui/MembershipMenu";
|
||||
import { SURFACE } from "./ui/surfaces";
|
||||
|
||||
// A small anchored menu for toggling a video's membership in the user's playlists, with inline
|
||||
// "new playlist" creation. Same shape as the channel tag editor (the popover pilot): radio-style
|
||||
// toggles plus a free-text creator, so it follows that file's two conventions — typeahead OFF and
|
||||
// the input swallowing non-Escape keys, since character and arrow keys belong to the text field.
|
||||
// "new playlist" creation. Its body is the shared MembershipMenu (as the channel tag editor's).
|
||||
export default function AddToPlaylist({
|
||||
videoId,
|
||||
variant = "ghost",
|
||||
@@ -94,9 +90,7 @@ function MembershipPanel({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const { ref: fadeRef, style: fadeStyle } = useScrollFade();
|
||||
const open = pop.open;
|
||||
const [newName, setNewName] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const membership = useQuery({
|
||||
@@ -115,13 +109,17 @@ function MembershipPanel({
|
||||
|
||||
// A failure is said, and the list refreshed either way. With no catch, a playlist deleted on
|
||||
// another device (a 404 `not_found/playlist` — still offered, from a cache up to 30 s old) was an
|
||||
// unhandled rejection: the tick did not change, nothing was said, the gone row stayed.
|
||||
// unhandled rejection: the tick did not change, nothing was said, the gone row stayed. A write that
|
||||
// landed flips the tick at once: a second tap before the refetch returned would otherwise repeat it.
|
||||
async function toggle(pl: Playlist) {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
if (pl.has_video) await api.removeFromPlaylist(pl.id, videoId);
|
||||
else await api.addToPlaylist(pl.id, videoId);
|
||||
qc.setQueryData<Playlist[]>(qk.playlistMembership(videoId), (old) =>
|
||||
old?.map((p) => (p.id === pl.id ? { ...p, has_video: !p.has_video } : p)),
|
||||
);
|
||||
} catch (e) {
|
||||
notifyActionError(e, t(pl.has_video ? "playlists.removeFailed" : "playlists.addFailed"));
|
||||
}
|
||||
@@ -129,19 +127,20 @@ function MembershipPanel({
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
async function createAndAdd() {
|
||||
const name = newName.trim();
|
||||
if (!name || busy) return;
|
||||
async function createAndAdd(name: string) {
|
||||
if (busy) return false;
|
||||
setBusy(true);
|
||||
let made = false;
|
||||
try {
|
||||
const pl = await api.createPlaylist(name);
|
||||
await api.addToPlaylist(pl.id, videoId);
|
||||
setNewName("");
|
||||
made = true;
|
||||
} catch (e) {
|
||||
notifyActionError(e, t("playlists.addFailed"));
|
||||
}
|
||||
refresh();
|
||||
setBusy(false);
|
||||
return made;
|
||||
}
|
||||
|
||||
// All of the user's playlists are add targets, including YouTube-linked ones — adding
|
||||
@@ -149,67 +148,40 @@ function MembershipPanel({
|
||||
const lists = membership.data ?? [];
|
||||
|
||||
return (
|
||||
<>
|
||||
<PopoverSurface popover={pop} className={`${SURFACE.popover} w-60 p-2`}>
|
||||
{/* A React portal still propagates events up the REACT tree, so a click inside this panel
|
||||
would otherwise reach the feed card that hosts the trigger and start playback. */}
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<div className="text-xs text-muted px-1.5 pb-1.5">
|
||||
{t("playlists.addToPlaylistTitle")}
|
||||
</div>
|
||||
<div
|
||||
ref={fadeRef}
|
||||
style={fadeStyle}
|
||||
aria-busy={busy}
|
||||
className="max-h-56 overflow-y-auto no-scrollbar"
|
||||
>
|
||||
{lists.length === 0 && !membership.isLoading && (
|
||||
<div className="text-xs text-muted px-1.5 py-2">{t("playlists.noneYet")}</div>
|
||||
)}
|
||||
{lists.map((pl) => (
|
||||
<PopoverItem
|
||||
key={pl.id}
|
||||
// A video can sit in several playlists at once — checkboxes, not one choice.
|
||||
role="menuitemcheckbox"
|
||||
checked={pl.has_video}
|
||||
onSelect={() => toggle(pl)}
|
||||
// NOT `disabled={busy}`: the native disabled attribute would blur the focused item to
|
||||
// <body> mid-request and permanently kill roving focus (useListNavigation listens on
|
||||
// the floating element only). `toggle()` already no-ops while busy, so re-entrancy is
|
||||
// guarded without touching focus; `aria-busy` on the list conveys the pending state.
|
||||
label={playlistName(pl, t)}
|
||||
className="w-full flex items-center gap-2 text-sm px-1.5 py-1.5 rounded-lg text-fg outline-hidden transition"
|
||||
>
|
||||
<MenuCheck kind="many" on={!!pl.has_video} />
|
||||
<span className="truncate flex-1 text-left">{playlistName(pl, t)}</span>
|
||||
{(pl.source === "youtube" || pl.yt_playlist_id) && (
|
||||
<YouTubeIcon className="w-3 h-3 shrink-0 text-muted" />
|
||||
)}
|
||||
</PopoverItem>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 border-t border-border mt-1.5 pt-1.5">
|
||||
<Plus className="w-3.5 h-3.5 text-muted shrink-0" />
|
||||
<Input
|
||||
className="flex-1"
|
||||
size="sm"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
// Escape bubbles to the popover's dismiss; every other key is text editing here and
|
||||
// must not reach the surrounding menu's list navigation.
|
||||
if (e.key === "Escape") return;
|
||||
e.stopPropagation();
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
void createAndAdd();
|
||||
}
|
||||
}}
|
||||
placeholder={t("playlists.newPlaylist")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverSurface>
|
||||
</>
|
||||
<PopoverSurface
|
||||
popover={pop}
|
||||
className={`${SURFACE.popover} w-60 p-2`}
|
||||
ariaLabel={t("playlists.addToPlaylistTitle")}
|
||||
listInside
|
||||
>
|
||||
<MembershipMenu
|
||||
heading={t("playlists.addToPlaylistTitle")}
|
||||
rows={lists.map((pl) => ({
|
||||
id: pl.id,
|
||||
label: playlistName(pl, t),
|
||||
checked: !!pl.has_video,
|
||||
extra:
|
||||
pl.source === "youtube" || pl.yt_playlist_id ? (
|
||||
<YouTubeIcon className="w-3 h-3 shrink-0 text-muted" />
|
||||
) : undefined,
|
||||
}))}
|
||||
onToggle={(row) => {
|
||||
const pl = lists.find((p) => p.id === row.id);
|
||||
if (pl) void toggle(pl);
|
||||
}}
|
||||
status={
|
||||
membership.isError && !membership.data
|
||||
? "error"
|
||||
: membership.isLoading
|
||||
? "loading"
|
||||
: "ready"
|
||||
}
|
||||
onRetry={() => void membership.refetch()}
|
||||
emptyText={t("playlists.noneYet")}
|
||||
errorText={t("common.loadError")}
|
||||
busy={busy}
|
||||
create={{ placeholder: t("playlists.newPlaylist"), onCreate: createAndAdd }}
|
||||
/>
|
||||
</PopoverSurface>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The channel tag editor says a failed load of the tag catalog as one, with a retry — not as "no tags
|
||||
// yet, create one below", which invited a duplicate of a tag that exists. i18n is key-passthrough.
|
||||
import { describe, it, expect, afterEach, beforeAll, vi } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
import { FloatingTree } from "@floating-ui/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
||||
};
|
||||
});
|
||||
|
||||
import { ChannelTagsControl } from "./ChannelTags";
|
||||
import { api } from "../lib/api";
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("the channel tag editor", () => {
|
||||
it("says a failed load of the tags as one, with a retry — not as no tags yet", async () => {
|
||||
vi.spyOn(api, "tags").mockRejectedValue(new Error("down"));
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<FloatingTree>
|
||||
<ChannelTagsControl
|
||||
userTags={[]}
|
||||
attachedIds={[]}
|
||||
onToggle={() => {}}
|
||||
onCreate={() => {}}
|
||||
/>
|
||||
</FloatingTree>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
fireEvent.click(screen.getByLabelText("channels.row.editTags"));
|
||||
const alert = await screen.findByRole("alert", { hidden: true });
|
||||
expect(alert.textContent).toContain("common.loadError");
|
||||
expect(screen.queryByText("channels.tags.noneYet")).toBeNull();
|
||||
expect(alert.closest("[role=menu]")).toBeNull();
|
||||
expect(await screen.findByText("common.retry")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,17 +1,15 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { Plus, Sparkles } from "lucide-react";
|
||||
import { MenuCheck } from "./ui/MenuCheck";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type Tag } from "../lib/api";
|
||||
import { PopoverSurface, PopoverItem } from "./ui/AnchoredPopover";
|
||||
import { PopoverSurface } from "./ui/AnchoredPopover";
|
||||
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
|
||||
import { useScrollFade } from "../lib/useScrollFade";
|
||||
import { fitChipsWithPill } from "../lib/chipOverflow";
|
||||
import { chipLaneClass, useChipOverflowFit } from "../lib/useChipOverflowFit";
|
||||
import { IconButton } from "./ui/IconButton";
|
||||
import { Chip, DismissibleChip } from "./ui/Chip";
|
||||
import { chipClass } from "./ui/chipStyles";
|
||||
import { Input } from "./ui/Input";
|
||||
import { MembershipMenu } from "./ui/MembershipMenu";
|
||||
import { useTags } from "../lib/useTags";
|
||||
import { SURFACE } from "./ui/surfaces";
|
||||
|
||||
// The channel tag surface, shared by the channel manager (table + cards) and the channel detail
|
||||
@@ -194,8 +192,9 @@ function SuggestedChipsLane({
|
||||
}
|
||||
|
||||
/** The editor body: toggle the user's existing tags on/off for this channel, plus an inline
|
||||
* "create + attach" input. Rendered inside an anchored `menu` popover. (Exported for the channel
|
||||
* detail page in Part 2; internal for now.) */
|
||||
* "create + attach" input. Rendered inside an anchored `menu` popover. The rows are the caller's
|
||||
* `userTags`; whether that list has loaded is read from the catalog query itself (mounted only while
|
||||
* the editor is open), so a failed or pending load is said as such, not as "no tags yet". */
|
||||
function TagEditorMenu({
|
||||
userTags,
|
||||
attachedIds,
|
||||
@@ -208,71 +207,27 @@ function TagEditorMenu({
|
||||
onCreate: (name: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [draft, setDraft] = useState("");
|
||||
const { ref: fadeRef, style: fadeStyle } = useScrollFade();
|
||||
const tags = useTags();
|
||||
const attached = new Set(attachedIds);
|
||||
const submit = () => {
|
||||
const name = draft.trim();
|
||||
if (!name) return;
|
||||
onCreate(name);
|
||||
setDraft("");
|
||||
};
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<div ref={fadeRef} style={fadeStyle} className="max-h-60 overflow-y-auto no-scrollbar">
|
||||
{userTags.map((tg) => {
|
||||
const on = attached.has(tg.id);
|
||||
return (
|
||||
<PopoverItem
|
||||
key={tg.id}
|
||||
// Several tags can be on at once — a checkbox set, not one exclusive choice.
|
||||
role="menuitemcheckbox"
|
||||
checked={on}
|
||||
onSelect={() => onToggle(tg.id)}
|
||||
label={tg.name}
|
||||
className={`w-full flex items-center gap-2 text-left text-xs px-2 py-1.5 rounded-lg outline-hidden transition ${
|
||||
on ? "text-fg" : "text-muted"
|
||||
}`}
|
||||
>
|
||||
<MenuCheck kind="many" on={on} />
|
||||
<span className="truncate">{tg.name}</span>
|
||||
</PopoverItem>
|
||||
);
|
||||
})}
|
||||
{userTags.length === 0 && (
|
||||
<p className="text-2xs text-muted px-2 py-1.5">{t("channels.tags.noneYet")}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 pt-1.5 border-t border-border flex items-center gap-1">
|
||||
<Input
|
||||
className="flex-1"
|
||||
size="sm"
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
// Escape bubbles to the popover's dismiss (close + refocus the trigger); every other key —
|
||||
// arrows, Home/End, typed characters — is text editing here, so keep it out of the menu's
|
||||
// list-navigation/typeahead handlers on the surrounding surface.
|
||||
if (e.key === "Escape") return;
|
||||
e.stopPropagation();
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
}
|
||||
}}
|
||||
maxLength={64}
|
||||
placeholder={t("channels.tags.createPlaceholder")}
|
||||
/>
|
||||
<IconButton
|
||||
variant="outline"
|
||||
onClick={submit}
|
||||
disabled={!draft.trim()}
|
||||
label={t("channels.tags.create")}
|
||||
>
|
||||
<Plus />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
<MembershipMenu
|
||||
dense
|
||||
rows={userTags.map((tg) => ({ id: tg.id, label: tg.name, checked: attached.has(tg.id) }))}
|
||||
onToggle={(row) => onToggle(row.id as number)}
|
||||
status={tags.isError && !tags.data ? "error" : tags.isPending ? "loading" : "ready"}
|
||||
onRetry={() => void tags.refetch()}
|
||||
emptyText={t("channels.tags.noneYet")}
|
||||
errorText={t("common.loadError")}
|
||||
create={{
|
||||
placeholder: t("channels.tags.createPlaceholder"),
|
||||
maxLength: 64,
|
||||
submitLabel: t("channels.tags.create"),
|
||||
onCreate: (name) => {
|
||||
onCreate(name);
|
||||
return true;
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -473,6 +428,7 @@ export function ChannelTagsControl({
|
||||
<PopoverSurface
|
||||
popover={editPop}
|
||||
className={`${SURFACE.menu} w-56 animate-[popIn_0.16s_ease]`}
|
||||
listInside
|
||||
>
|
||||
<TagEditorMenu
|
||||
userTags={userTags}
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The clip panel: it opens on its In handle, every way of marking keeps a range the server takes,
|
||||
// Cut sends the range (and a Plex clip's audio track) and closes, a refused Cut stays open and says
|
||||
// so, and "Play selection" loops back to In. The range is the caller's state, so the test holds it.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import type { ClipRequest } from "../lib/api";
|
||||
import type { ClipRange } from "../lib/clipRange";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return { ...actual, useTranslation: () => ({ t: (k: string) => k }) };
|
||||
});
|
||||
|
||||
const enqueueClip = vi.fn<(body: ClipRequest) => Promise<unknown>>();
|
||||
vi.mock("../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../lib/api")>();
|
||||
return { ...actual, api: { ...actual.api, enqueueClip: (b: ClipRequest) => enqueueClip(b) } };
|
||||
});
|
||||
const notify = vi.fn();
|
||||
vi.mock("../lib/notifications", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../lib/notifications")>();
|
||||
return { ...actual, notify: (n: unknown) => notify(n) };
|
||||
});
|
||||
const notifyActionError = vi.fn();
|
||||
vi.mock("../lib/actionErrors", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../lib/actionErrors")>();
|
||||
return { ...actual, notifyActionError: (e: unknown, t: string) => notifyActionError(e, t) };
|
||||
});
|
||||
|
||||
import ClipPanel, { type ClipCrop, type ClipSource } from "./ClipPanel";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
beforeEach(() => {
|
||||
// A block, not an expression: a function returned from beforeEach is run as its teardown.
|
||||
enqueueClip.mockResolvedValue({ id: 1 });
|
||||
});
|
||||
|
||||
function Harness({
|
||||
src = { source: "plex", ref: "777", audioTrack: 1 },
|
||||
start = { start: 100, end: 130 },
|
||||
current = 100,
|
||||
onClose = () => {},
|
||||
onPlayFrom = () => {},
|
||||
onSeek = () => {},
|
||||
duration = 3600,
|
||||
}: {
|
||||
src?: ClipSource;
|
||||
start?: ClipRange;
|
||||
current?: number;
|
||||
onClose?: () => void;
|
||||
onPlayFrom?: (t: number) => void;
|
||||
onSeek?: (t: number, final: boolean) => void;
|
||||
duration?: number;
|
||||
}) {
|
||||
const [clip, setClip] = useState(start);
|
||||
const [crop, setCrop] = useState<ClipCrop | null>(null);
|
||||
return (
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<output data-testid="range">{`${clip.start}-${clip.end}`}</output>
|
||||
<output data-testid="crop">{crop ? JSON.stringify(crop) : "none"}</output>
|
||||
<ClipPanel
|
||||
src={src}
|
||||
clip={clip}
|
||||
onChange={setClip}
|
||||
duration={duration}
|
||||
current={current}
|
||||
picture={16 / 9}
|
||||
crop={crop}
|
||||
onCrop={setCrop}
|
||||
onSeek={onSeek}
|
||||
onPlayFrom={onPlayFrom}
|
||||
onClose={onClose}
|
||||
/>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const range = () => screen.getByTestId("range").textContent;
|
||||
|
||||
describe("ClipPanel", () => {
|
||||
it("opens with the focus on its In handle, so the arrows move the mark at once", () => {
|
||||
render(<Harness />);
|
||||
const handle = screen.getByRole("slider", { name: "clip.in" });
|
||||
expect(document.activeElement).toBe(handle);
|
||||
fireEvent.keyDown(handle, { key: "ArrowLeft", shiftKey: true });
|
||||
expect(range()).toBe("99-130");
|
||||
});
|
||||
|
||||
it("takes a typed time on Enter and carries the other end along past it", () => {
|
||||
render(<Harness />);
|
||||
const [inField] = screen.getAllByRole("textbox", { name: "clip.in" });
|
||||
fireEvent.change(inField!, { target: { value: "2:20" } });
|
||||
fireEvent.keyDown(inField!, { key: "Enter" });
|
||||
expect(range()).toBe("140-170");
|
||||
});
|
||||
|
||||
it("keeps the focus in a time field that took a typed time on Enter", () => {
|
||||
render(<Harness />);
|
||||
const field = () => screen.getAllByRole("textbox", { name: "clip.in" })[0]!;
|
||||
field().focus();
|
||||
fireEvent.change(field(), { target: { value: "2:20" } });
|
||||
fireEvent.keyDown(field(), { key: "Enter" });
|
||||
expect(range()).toBe("140-170");
|
||||
expect(document.activeElement).toBe(field());
|
||||
expect((field() as HTMLInputElement).value).toBe("0:02:20.0");
|
||||
});
|
||||
|
||||
it("shows the mark it holds, not a time it could not take", () => {
|
||||
render(<Harness start={{ start: 3500, end: 3600 }} />);
|
||||
const out = () => screen.getAllByRole("textbox", { name: "clip.out" })[0] as HTMLInputElement;
|
||||
// Past the end: clamped back to the end, and the field says so.
|
||||
fireEvent.change(out(), { target: { value: "1:15:00" } });
|
||||
fireEvent.blur(out());
|
||||
expect(range()).toBe("3500-3600");
|
||||
expect(out().value).toBe("1:00:00.0");
|
||||
expect(out().getAttribute("aria-invalid")).toBeNull();
|
||||
// Not a time at all: kept for fixing, and marked as not taken.
|
||||
fireEvent.change(out(), { target: { value: "abc" } });
|
||||
fireEvent.blur(out());
|
||||
expect(out().value).toBe("abc");
|
||||
expect(out().getAttribute("aria-invalid")).toBe("true");
|
||||
expect(range()).toBe("3500-3600");
|
||||
});
|
||||
|
||||
it("says an unreadable time on a line of its own under the fields, read out, over nothing", () => {
|
||||
render(<Harness />);
|
||||
const out = () => screen.getAllByRole("textbox", { name: "clip.out" })[0] as HTMLInputElement;
|
||||
fireEvent.change(out(), { target: { value: "abc" } });
|
||||
fireEvent.blur(out());
|
||||
const line = screen.getByRole("alert");
|
||||
expect(line.textContent).toBe("common.timecodeInvalidHours");
|
||||
expect(out().getAttribute("aria-describedby")).toBe(line.id);
|
||||
// In the panel's flow, after the fields' row: it pushes the crop row down rather than lying over
|
||||
// its buttons and taking their clicks.
|
||||
expect(line.closest('[role="group"]')).toBeNull();
|
||||
expect(line.className).not.toMatch(/\babsolute\b/);
|
||||
expect(line.parentElement?.className).toMatch(/\bmedia-scope\b/);
|
||||
});
|
||||
|
||||
it("takes a decimal comma, as a Hungarian hand types it", () => {
|
||||
render(<Harness />);
|
||||
const field = screen.getAllByRole("textbox", { name: "clip.in" })[0]!;
|
||||
fireEvent.change(field, { target: { value: "1:42,5" } });
|
||||
fireEvent.keyDown(field, { key: "Enter" });
|
||||
expect(range()).toBe("102.5-130");
|
||||
});
|
||||
|
||||
it("never seeks the player onto the item's very end (that would finish the item)", () => {
|
||||
const onSeek = vi.fn();
|
||||
render(<Harness onSeek={onSeek} duration={600} start={{ start: 590, end: 595 }} />);
|
||||
fireEvent.keyDown(screen.getByRole("slider", { name: "clip.out" }), { key: "End" });
|
||||
expect(range()).toBe("590-600");
|
||||
const [time, final] = onSeek.mock.lastCall!;
|
||||
expect(final).toBe(true);
|
||||
expect(time).toBeLessThan(600);
|
||||
expect(time).toBeGreaterThan(599);
|
||||
});
|
||||
|
||||
it("marks an end at the playhead", () => {
|
||||
render(<Harness current={115.5} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "clip.outHere" }));
|
||||
expect(range()).toBe("100-115.5");
|
||||
});
|
||||
|
||||
it("cuts the range with the Plex audio track and closes", async () => {
|
||||
const onClose = vi.fn();
|
||||
render(<Harness onClose={onClose} start={{ start: 100.12345, end: 130 }} />);
|
||||
fireEvent.change(screen.getByRole("textbox", { name: "clip.name" }), {
|
||||
target: { value: " The bit " },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "clip.cut" }));
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalled());
|
||||
expect(enqueueClip).toHaveBeenCalledWith({
|
||||
source: "plex",
|
||||
ref: "777",
|
||||
start_s: 100.123,
|
||||
end_s: 130,
|
||||
audio_track: 1,
|
||||
display_name: "The bit",
|
||||
});
|
||||
expect(notify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ level: "success", message: "clip.queued" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("sends no audio track for a YouTube clip", async () => {
|
||||
render(<Harness src={{ source: "youtube", ref: "abcdefghijk" }} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "clip.cut" }));
|
||||
await waitFor(() => expect(enqueueClip).toHaveBeenCalled());
|
||||
const body = enqueueClip.mock.calls[0]![0];
|
||||
expect(body.source).toBe("youtube");
|
||||
expect(Object.keys(body)).not.toContain("audio_track");
|
||||
});
|
||||
|
||||
it("stays open and says so when the cut is refused", async () => {
|
||||
const onClose = vi.fn();
|
||||
const err = new Error("boom");
|
||||
enqueueClip.mockRejectedValue(err);
|
||||
render(<Harness onClose={onClose} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "clip.cut" }));
|
||||
await waitFor(() => expect(notifyActionError).toHaveBeenCalledTimes(1));
|
||||
expect(notifyActionError.mock.calls[0]![0]).toBe(err);
|
||||
expect(notifyActionError.mock.calls[0]![1]).toBe("clip.failed");
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
expect((screen.getByRole("button", { name: "clip.cut" }) as HTMLButtonElement).disabled).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("loops the selection: playing past End goes back to Start", () => {
|
||||
const onPlayFrom = vi.fn();
|
||||
const { rerender } = render(<Harness onPlayFrom={onPlayFrom} current={110} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "clip.loop" }));
|
||||
expect(onPlayFrom).toHaveBeenLastCalledWith(100);
|
||||
onPlayFrom.mockClear();
|
||||
act(() => rerender(<Harness onPlayFrom={onPlayFrom} current={125} />));
|
||||
expect(onPlayFrom).not.toHaveBeenCalled();
|
||||
act(() => rerender(<Harness onPlayFrom={onPlayFrom} current={130.2} />));
|
||||
expect(onPlayFrom).toHaveBeenLastCalledWith(100);
|
||||
});
|
||||
|
||||
it("cuts the whole picture until the crop is turned on, then the frame with its box's shape", async () => {
|
||||
render(<Harness />);
|
||||
expect(screen.queryByRole("group", { name: "clip.shape" })).toBeNull();
|
||||
fireEvent.click(screen.getByRole("button", { name: "clip.crop" }));
|
||||
expect(screen.getByRole("button", { name: "clip.crop" }).getAttribute("aria-pressed")).toBe(
|
||||
"true",
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "clip.cut" }));
|
||||
await waitFor(() => expect(enqueueClip).toHaveBeenCalled());
|
||||
expect(enqueueClip.mock.calls[0]![0].crop).toEqual({
|
||||
x: 0.1,
|
||||
y: 0.1,
|
||||
w: 0.8,
|
||||
h: 0.8,
|
||||
aspect: 1.7778,
|
||||
});
|
||||
});
|
||||
|
||||
it("holds the frame to a shape without cutting, and goes back to the whole picture", () => {
|
||||
render(<Harness />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "clip.crop" }));
|
||||
// A segmented choice inside the panel's form: never a submit.
|
||||
fireEvent.click(screen.getByRole("button", { name: "9:16" }));
|
||||
expect(enqueueClip).not.toHaveBeenCalled();
|
||||
const tall = JSON.parse(screen.getByTestId("crop").textContent!) as ClipCrop;
|
||||
expect(tall.shape).toBe("9:16");
|
||||
expect(tall.rect.h).toBeCloseTo(1, 6);
|
||||
expect((tall.rect.w * 16) / 9 / tall.rect.h).toBeCloseTo(9 / 16, 6);
|
||||
fireEvent.click(screen.getByRole("button", { name: "clip.whole" }));
|
||||
expect(JSON.parse(screen.getByTestId("crop").textContent!)).toEqual({
|
||||
rect: { x: 0, y: 0, w: 1, h: 1 },
|
||||
shape: "free",
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "clip.crop" }));
|
||||
expect(screen.getByTestId("crop").textContent).toBe("none");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,468 @@
|
||||
import { useEffect, useEffectEvent, useId, useRef, useState, type SubmitEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Crop, Crosshair, Maximize, Minus, Plus, Repeat, Scissors, X } from "lucide-react";
|
||||
import { api, type ClipRequest } from "../lib/api";
|
||||
import { DISPLAY_NAME_MAX } from "../lib/api/downloads";
|
||||
import { invalidateDownloads } from "../lib/downloads";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { navigateTo } from "../lib/nav";
|
||||
import { log } from "../lib/log";
|
||||
import { formatTimecode } from "../lib/timecode";
|
||||
import { handleBounds, markIn, markOut, zoomWindow, type ClipRange } from "../lib/clipRange";
|
||||
import { fitRatio, fracRatio, WHOLE, type Frac } from "../lib/cropGeometry";
|
||||
import { RangeTrack, RulerTicks, type RangeWindow } from "./ui/RangeTrack";
|
||||
import { CropFrame } from "./ui/CropFrame";
|
||||
import SegmentedToggle from "./ui/SegmentedToggle";
|
||||
import { Button } from "./ui/Button";
|
||||
import { IconButton } from "./ui/IconButton";
|
||||
import { Input } from "./ui/Input";
|
||||
import { TimecodeError, TimecodeField } from "./ui/TimecodeField";
|
||||
import { useTimecodeField, type TimecodeDraft } from "./ui/useTimecodeField";
|
||||
|
||||
// The clip panel: marks a range of what is playing and cuts it into a download of its own. It lives
|
||||
// IN the player (the YouTube card under the video, the Plex player's control band), so the video
|
||||
// being watched is the preview: dragging a handle or pressing a timeline moves the player there.
|
||||
//
|
||||
// Two tracks: the whole item, with the selection and the zoomed window on it, and a zoomed strip
|
||||
// around the selection where the handles are. The strip's window follows the selection when it
|
||||
// settles, not while a handle is being dragged — a window that moved under the pointer would run
|
||||
// away from the drag. A film is hours long and a clip seconds: only the strip gives a hand the
|
||||
// precision, and the fields and their tenth-of-a-second steps give the rest.
|
||||
//
|
||||
// The range itself is the caller's state (`clip`), so the player's `[` / `]` keys and this panel
|
||||
// mark the same range by the same rules (lib/clipRange).
|
||||
//
|
||||
// A clip is the whole picture unless the user turns the crop on: then a frame over the video
|
||||
// (`ClipCropFrame`, which the player places over its picture) marks the part to keep, free or held
|
||||
// to an output shape. The crop is the caller's state too, so the player's Escape can turn it off
|
||||
// before it closes the panel.
|
||||
|
||||
/** The output shapes a crop frame can be held to (width / height of the clip), or a free frame. */
|
||||
type ClipShape = "free" | "16:9" | "9:16" | "1:1";
|
||||
const SHAPES: Record<Exclude<ClipShape, "free">, number> = {
|
||||
"16:9": 16 / 9,
|
||||
"9:16": 9 / 16,
|
||||
"1:1": 1,
|
||||
};
|
||||
|
||||
/** A clip's crop: the frame, in fractions of the picture, and the shape it is held to. */
|
||||
export interface ClipCrop {
|
||||
rect: Frac;
|
||||
shape: ClipShape;
|
||||
}
|
||||
|
||||
const CROP_START: ClipCrop = { rect: { x: 0.1, y: 0.1, w: 0.8, h: 0.8 }, shape: "free" };
|
||||
const round4 = (v: number) => Math.round(v * 10_000) / 10_000;
|
||||
const lockOf = (crop: ClipCrop, picture: number) =>
|
||||
crop.shape === "free" ? null : fracRatio(SHAPES[crop.shape], picture);
|
||||
|
||||
export interface ClipSource {
|
||||
source: ClipRequest["source"];
|
||||
ref: string;
|
||||
/** Plex: the audio track that is playing, which the clip carries. */
|
||||
audioTrack?: number | null;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
src: ClipSource;
|
||||
clip: ClipRange;
|
||||
onChange: (r: ClipRange) => void;
|
||||
duration: number;
|
||||
/** The player's position, live. */
|
||||
current: number;
|
||||
/** The shape (width / height) of the picture the crop frame is drawn on. */
|
||||
picture: number;
|
||||
crop: ClipCrop | null;
|
||||
onCrop: (c: ClipCrop | null) => void;
|
||||
/** Move the player; `final` false while a pointer still drags (a player whose seek is costly waits). */
|
||||
onSeek: (time: number, final: boolean) => void;
|
||||
/** Play from `time` (the loop's restart, and "Play selection"). */
|
||||
onPlayFrom: (time: number) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const NUDGE_S = 0.1;
|
||||
// How far short of the item's end a seek stops: a mark on the very end moves the player to its last
|
||||
// moment, not past it. A player put at its end ENDS, and the item counts as finished — YouTube marks
|
||||
// it watched and shows its end screen, a Plex position reads as the whole — while a mark there is only
|
||||
// a place. (The players keep themselves and the clip open at an end while one is marked; this keeps
|
||||
// the item unfinished.)
|
||||
const LAST_MOMENT_S = 0.25;
|
||||
// On the always-dark chrome the ends are white (a light theme's accent is a dark blue there, which
|
||||
// all but vanished on the bar) and the playhead amber, so an end and the playhead are never confused.
|
||||
// The media amber (`--media-mark`), not the theme's warn: a light theme's is dark on this bar. Every
|
||||
// focus ring in the panel is that amber as well (media-scope re-points --focus), as the crop frame's
|
||||
// is: the theme accent's ring was under 3:1 on the chrome.
|
||||
const PLAYHEAD = "bg-media-mark";
|
||||
|
||||
export default function ClipPanel({
|
||||
src,
|
||||
clip,
|
||||
onChange,
|
||||
duration,
|
||||
current,
|
||||
picture,
|
||||
crop,
|
||||
onCrop,
|
||||
onSeek,
|
||||
onPlayFrom,
|
||||
onClose,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const rootRef = useRef<HTMLFormElement>(null);
|
||||
const [name, setName] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [looping, setLooping] = useState(false);
|
||||
// The strip's window while a handle drags (null at rest: it follows the selection).
|
||||
const [frozen, setFrozen] = useState<RangeWindow | null>(null);
|
||||
const hours = duration >= 3600;
|
||||
const tc = (s: number) => formatTimecode(s, hours);
|
||||
const view = frozen ?? zoomWindow(clip, duration);
|
||||
const bounds = handleBounds(clip, duration);
|
||||
const length = clip.end - clip.start;
|
||||
|
||||
// Opened from a button or a key, the panel takes the focus to its In handle: the button that
|
||||
// opened it is gone (its row is this panel now), and the arrows then move the mark at once. Without
|
||||
// scrolling there: the player's card is still fitting itself around the new panel at this point,
|
||||
// and a card that has to scroll (a short window) scrolled the top of the video out of sight.
|
||||
useEffect(() => {
|
||||
rootRef.current
|
||||
?.querySelector<HTMLElement>('[data-handle="in"]')
|
||||
?.focus({ preventScroll: true });
|
||||
}, []);
|
||||
|
||||
// The crop turned off from its frame (Escape there): the frame took the focus with it, so it goes to
|
||||
// the Crop toggle rather than to <body>, where the player's dialog no longer holds it.
|
||||
const cropButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const cropOn = crop != null;
|
||||
useEffect(() => {
|
||||
if (cropOn) return;
|
||||
const lost = document.activeElement == null || document.activeElement === document.body;
|
||||
if (lost) cropButtonRef.current?.focus();
|
||||
}, [cropOn]);
|
||||
|
||||
// "Play selection" loops: past Out, back to In.
|
||||
const restart = useEffectEvent(() => onPlayFrom(clip.start));
|
||||
useEffect(() => {
|
||||
if (looping && current >= clip.end) restart();
|
||||
}, [looping, current, clip.end]);
|
||||
|
||||
const seek = (time: number, final: boolean) =>
|
||||
onSeek(Math.max(0, Math.min(time, duration - LAST_MOMENT_S)), final);
|
||||
const moveIn = (time: number, final: boolean) => {
|
||||
setFrozen(final ? null : (frozen ?? view));
|
||||
const next = markIn(clip, time, duration);
|
||||
onChange(next);
|
||||
seek(next.start, final);
|
||||
};
|
||||
const moveOut = (time: number, final: boolean) => {
|
||||
setFrozen(final ? null : (frozen ?? view));
|
||||
const next = markOut(clip, time, duration);
|
||||
onChange(next);
|
||||
seek(next.end, final);
|
||||
};
|
||||
|
||||
const inField = useTimecodeField(clip.start, hours, (v) => moveIn(v, true));
|
||||
const outField = useTimecodeField(clip.end, hours, (v) => moveOut(v, true));
|
||||
const timeErrorId = useId();
|
||||
const body: ClipRequest = {
|
||||
source: src.source,
|
||||
ref: src.ref,
|
||||
start_s: Math.round(clip.start * 1000) / 1000,
|
||||
end_s: Math.round(clip.end * 1000) / 1000,
|
||||
...(src.audioTrack != null ? { audio_track: src.audioTrack } : {}),
|
||||
...(crop
|
||||
? {
|
||||
crop: {
|
||||
x: round4(crop.rect.x),
|
||||
y: round4(crop.rect.y),
|
||||
w: round4(crop.rect.w),
|
||||
h: round4(crop.rect.h),
|
||||
aspect: round4(picture),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(name.trim() ? { display_name: name.trim() } : {}),
|
||||
};
|
||||
const submit = async (e: SubmitEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.enqueueClip(body);
|
||||
invalidateDownloads(qc);
|
||||
log.info("clip.queued", "clip queued", { source: src.source, seconds: Math.round(length) });
|
||||
notify({
|
||||
level: "success",
|
||||
message: t("clip.queued"),
|
||||
action: { label: t("clip.view"), onClick: () => navigateTo("downloads") },
|
||||
});
|
||||
onClose();
|
||||
} catch (err) {
|
||||
// A refusal the server explains (too long, quota) is already in the error dialog; a lost
|
||||
// connection or a 404 is not, and must not leave the panel looking as if nothing happened.
|
||||
notifyActionError(err, t("clip.failed"));
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
ref={rootRef}
|
||||
onSubmit={submit}
|
||||
aria-label={t("clip.title")}
|
||||
data-testid="clip-panel"
|
||||
className="media-scope flex flex-col gap-2 bg-media-chrome/95 px-3 py-2.5 text-sm"
|
||||
// The panel's own presses are not the video's (play/pause) nor the backdrop's (close).
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="flex shrink-0 items-center gap-1.5 font-medium">
|
||||
<Scissors className="h-4 w-4" aria-hidden="true" />
|
||||
{t("clip.title")}
|
||||
</span>
|
||||
<RangeTrack
|
||||
label={t("clip.overview")}
|
||||
duration={duration}
|
||||
playhead={current}
|
||||
playheadClassName={PLAYHEAD}
|
||||
onSeek={(time, final) => final && seek(time, true)}
|
||||
className="h-5 flex-1 cursor-pointer rounded-sm bg-white/15"
|
||||
regions={[
|
||||
{
|
||||
key: "window",
|
||||
start: view.from,
|
||||
end: view.to,
|
||||
className: "border-x border-white/50",
|
||||
},
|
||||
{ key: "sel", start: clip.start, end: clip.end, className: "bg-accent/70" },
|
||||
]}
|
||||
/>
|
||||
<IconButton variant="overlay" size="sm" onClick={onClose} label={t("clip.close")}>
|
||||
<X />
|
||||
</IconButton>
|
||||
</div>
|
||||
|
||||
<RangeTrack
|
||||
label={t("clip.strip")}
|
||||
duration={duration}
|
||||
view={view}
|
||||
playhead={current}
|
||||
playheadClassName={PLAYHEAD}
|
||||
onSeek={seek}
|
||||
className="h-14 cursor-pointer rounded-md bg-white/10 text-white/45"
|
||||
background={
|
||||
<RulerTicks view={view} format={(s) => formatTimecode(s, hours).replace(/\.\d$/, "")} />
|
||||
}
|
||||
regions={[
|
||||
{
|
||||
key: "sel",
|
||||
start: clip.start,
|
||||
end: clip.end,
|
||||
className: "bg-accent/25 border-y-2 border-accent",
|
||||
},
|
||||
]}
|
||||
handles={[
|
||||
{
|
||||
id: "in",
|
||||
label: t("clip.in"),
|
||||
time: clip.start,
|
||||
...bounds.in,
|
||||
valueText: tc(clip.start),
|
||||
step: NUDGE_S,
|
||||
bigStep: 1,
|
||||
onChange: moveIn,
|
||||
barClassName: "bg-white",
|
||||
},
|
||||
{
|
||||
id: "out",
|
||||
label: t("clip.out"),
|
||||
time: clip.end,
|
||||
...bounds.out,
|
||||
valueText: tc(clip.end),
|
||||
step: NUDGE_S,
|
||||
bigStep: 1,
|
||||
onChange: moveOut,
|
||||
barClassName: "bg-white",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
|
||||
<MarkField
|
||||
label={t("clip.in")}
|
||||
value={clip.start}
|
||||
field={inField}
|
||||
errorId={timeErrorId}
|
||||
onSet={(v) => moveIn(v, true)}
|
||||
nudge={NUDGE_S}
|
||||
hereLabel={t("clip.inHere")}
|
||||
onHere={() => moveIn(current, true)}
|
||||
/>
|
||||
<MarkField
|
||||
label={t("clip.out")}
|
||||
value={clip.end}
|
||||
field={outField}
|
||||
errorId={timeErrorId}
|
||||
onSet={(v) => moveOut(v, true)}
|
||||
nudge={NUDGE_S}
|
||||
hereLabel={t("clip.outHere")}
|
||||
onHere={() => moveOut(current, true)}
|
||||
/>
|
||||
<span className="tabular-nums text-muted" aria-live="polite">
|
||||
{t("clip.length", { length: formatTimecode(length) })}
|
||||
</span>
|
||||
<Button
|
||||
variant="overlay"
|
||||
size="sm"
|
||||
pressed={looping}
|
||||
icon={<Repeat />}
|
||||
onClick={() => {
|
||||
if (!looping) onPlayFrom(clip.start);
|
||||
setLooping(!looping);
|
||||
}}
|
||||
>
|
||||
{t("clip.loop")}
|
||||
</Button>
|
||||
</div>
|
||||
{(inField.invalid || outField.invalid) && <TimecodeError id={timeErrorId} hours={hours} />}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
ref={cropButtonRef}
|
||||
variant="overlay"
|
||||
size="sm"
|
||||
pressed={crop != null}
|
||||
icon={<Crop />}
|
||||
onClick={() => onCrop(crop ? null : CROP_START)}
|
||||
>
|
||||
{t("clip.crop")}
|
||||
</Button>
|
||||
{crop && (
|
||||
<>
|
||||
<SegmentedToggle
|
||||
ariaLabel={t("clip.shape")}
|
||||
value={crop.shape}
|
||||
onChange={(shape) => {
|
||||
const next = { ...crop, shape };
|
||||
const lock = lockOf(next, picture);
|
||||
onCrop(lock == null ? next : { ...next, rect: fitRatio(crop.rect, lock) });
|
||||
}}
|
||||
options={[
|
||||
{ value: "free", label: t("clip.shapeFree") },
|
||||
{ value: "16:9", label: "16:9" },
|
||||
{ value: "9:16", label: "9:16" },
|
||||
{ value: "1:1", label: "1:1" },
|
||||
]}
|
||||
/>
|
||||
<Button
|
||||
variant="overlay"
|
||||
size="sm"
|
||||
icon={<Maximize />}
|
||||
onClick={() => onCrop({ rect: WHOLE, shape: "free" })}
|
||||
>
|
||||
{t("clip.whole")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Input
|
||||
size="sm"
|
||||
className="min-w-48 flex-1"
|
||||
value={name}
|
||||
maxLength={DISPLAY_NAME_MAX}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={t("clip.namePlaceholder")}
|
||||
aria-label={t("clip.name")}
|
||||
/>
|
||||
<Button variant="overlay" size="sm" onClick={onClose}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" variant="primary" size="sm" loading={busy} icon={<Scissors />}>
|
||||
{t("clip.cut")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
/** One end's field: the time typed (Enter or leaving the field sets it), a tenth back and forward,
|
||||
* and "to the playhead". */
|
||||
function MarkField({
|
||||
label,
|
||||
value,
|
||||
field,
|
||||
errorId,
|
||||
onSet,
|
||||
nudge,
|
||||
hereLabel,
|
||||
onHere,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
field: TimecodeDraft;
|
||||
errorId: string;
|
||||
onSet: (v: number) => void;
|
||||
nudge: number;
|
||||
hereLabel: string;
|
||||
onHere: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex items-center gap-1" role="group" aria-label={label}>
|
||||
<span className="mr-1 text-muted">{label}</span>
|
||||
<IconButton
|
||||
variant="overlay"
|
||||
size="sm"
|
||||
onClick={() => onSet(value - nudge)}
|
||||
label={t("clip.earlier", { label })}
|
||||
>
|
||||
<Minus />
|
||||
</IconButton>
|
||||
<TimecodeField
|
||||
size="sm"
|
||||
className="w-24 text-center tabular-nums"
|
||||
field={field}
|
||||
errorId={errorId}
|
||||
aria-label={label}
|
||||
/>
|
||||
<IconButton
|
||||
variant="overlay"
|
||||
size="sm"
|
||||
onClick={() => onSet(value + nudge)}
|
||||
label={t("clip.later", { label })}
|
||||
>
|
||||
<Plus />
|
||||
</IconButton>
|
||||
<IconButton variant="overlay" size="sm" onClick={onHere} label={hereLabel}>
|
||||
<Crosshair />
|
||||
</IconButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The clip's crop frame, over the player's picture box (the caller positions this layer over it). */
|
||||
export function ClipCropFrame({
|
||||
picture,
|
||||
crop,
|
||||
onCrop,
|
||||
}: {
|
||||
picture: number;
|
||||
crop: ClipCrop;
|
||||
onCrop: (c: ClipCrop) => void;
|
||||
}) {
|
||||
return (
|
||||
<CropFrame
|
||||
aspect={picture}
|
||||
rect={crop.rect}
|
||||
ratio={lockOf(crop, picture)}
|
||||
onChange={(rect) => onCrop({ ...crop, rect })}
|
||||
className="z-base"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The feed's optimistic layer: a card shows what was just written to it until the server's own answer
|
||||
// lands. Which answers count is the rule pinned here: a refetch that LANDS (for what was written
|
||||
// before it set out), a filter change; never an append of the next page. A mark made while a refetch
|
||||
// was in flight is newer than its answer, and dropping it put a card marked watched back into an
|
||||
// Unwatched view. The grid is a stub that shows what Feed hands it.
|
||||
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor, cleanup, act } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("react-i18next")>()),
|
||||
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
||||
}));
|
||||
vi.mock("./wizardContext", () => ({ useWizard: () => ({ openWizard: vi.fn() }) }));
|
||||
vi.mock("./VirtualFeed", () => ({
|
||||
default: ({
|
||||
items,
|
||||
onState,
|
||||
fetchNextPage,
|
||||
}: {
|
||||
items: { id: string; status: string }[];
|
||||
onState: (id: string, status: string) => void;
|
||||
fetchNextPage: (o: object) => unknown;
|
||||
}) => (
|
||||
<ol aria-label="results">
|
||||
{items.map((v) => (
|
||||
<li key={v.id}>
|
||||
<span>{`${v.id}:${v.status}`}</span>
|
||||
<button onClick={() => onState(v.id, "watched")}>watch {v.id}</button>
|
||||
<button onClick={() => onState(v.id, "hidden")}>hide {v.id}</button>
|
||||
</li>
|
||||
))}
|
||||
<button onClick={() => void fetchNextPage({})}>more</button>
|
||||
</ol>
|
||||
),
|
||||
}));
|
||||
|
||||
import Feed from "./Feed";
|
||||
import { api, DEFAULT_TAG_MODES, type FeedFilters, type Video } from "../lib/api";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const FILTERS: FeedFilters = {
|
||||
tags: [],
|
||||
tagModes: DEFAULT_TAG_MODES,
|
||||
channelIds: [],
|
||||
q: "",
|
||||
sort: "newest",
|
||||
scope: "my",
|
||||
includeNormal: true,
|
||||
includeShorts: false,
|
||||
includeLive: false,
|
||||
show: "all",
|
||||
};
|
||||
|
||||
const video = (id: string) =>
|
||||
({
|
||||
id,
|
||||
title: id,
|
||||
channel_id: "UCx",
|
||||
channel_title: "Channel",
|
||||
status: "new",
|
||||
position_seconds: 0,
|
||||
duration_seconds: 600,
|
||||
published_at: "2026-01-01T00:00:00Z",
|
||||
watch_url: `https://www.youtube.com/watch?v=${id}`,
|
||||
}) as unknown as Video;
|
||||
|
||||
// The server never takes the mark in these tests: whatever a list shows as "watched" is the override.
|
||||
const page = (ids: string[], next: string | null = null) =>
|
||||
({ items: ids.map(video), next_cursor: next }) as never;
|
||||
|
||||
function mount(feed: ReturnType<typeof vi.fn>, setState?: () => Promise<unknown>) {
|
||||
vi.spyOn(api, "feed").mockImplementation(feed as never);
|
||||
vi.spyOn(api, "feedSummary").mockResolvedValue({ count: 1, facets: {} } as never);
|
||||
if (setState) vi.spyOn(api, "setState").mockImplementation(setState as never);
|
||||
else vi.spyOn(api, "setState").mockResolvedValue({ ok: true } as never);
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
qc.setQueryData(qk.me(), { id: 1, can_write: true, prefs: {} });
|
||||
const ui = (filters: FeedFilters) => (
|
||||
<QueryClientProvider client={qc}>
|
||||
<Feed
|
||||
filters={filters}
|
||||
setFilters={() => {}}
|
||||
view="cards"
|
||||
setView={() => {}}
|
||||
canRead
|
||||
ytSearch={null}
|
||||
onYtSearch={() => {}}
|
||||
onExitYtSearch={() => {}}
|
||||
/>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
const r = render(ui(FILTERS));
|
||||
return { qc, rerender: (f: FeedFilters) => r.rerender(ui(f)) };
|
||||
}
|
||||
|
||||
const settle = () => act(() => new Promise((r) => setTimeout(r, 20)));
|
||||
|
||||
describe("Feed — optimistic overrides", () => {
|
||||
it("give way to a refetch that lands after them", async () => {
|
||||
let answer: (v: unknown) => void = () => {};
|
||||
const feed = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(page(["v1"]))
|
||||
.mockImplementationOnce(() => new Promise((r) => (answer = r)));
|
||||
const { qc } = mount(feed);
|
||||
fireEvent.click(await screen.findByText("watch v1"));
|
||||
await screen.findByText("v1:watched");
|
||||
act(() => void qc.invalidateQueries({ queryKey: qk.feed() }));
|
||||
await waitFor(() => expect(feed).toHaveBeenCalledTimes(2));
|
||||
await settle();
|
||||
await act(async () => answer(page(["v1"])));
|
||||
await waitFor(() => expect(screen.getByText("v1:new")).toBeTruthy());
|
||||
});
|
||||
|
||||
it("keep a mark made while the refetch was in flight", async () => {
|
||||
let answer: (v: unknown) => void = () => {};
|
||||
const feed = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(page(["v1"]))
|
||||
.mockImplementationOnce(() => new Promise((r) => (answer = r)));
|
||||
const { qc } = mount(feed);
|
||||
await screen.findByText("v1:new");
|
||||
act(() => void qc.invalidateQueries({ queryKey: qk.feed() }));
|
||||
await waitFor(() => expect(feed).toHaveBeenCalledTimes(2));
|
||||
await settle();
|
||||
fireEvent.click(screen.getByText("watch v1"));
|
||||
await screen.findByText("v1:watched");
|
||||
// The answer the server gave before it heard of the mark.
|
||||
await act(async () => answer(page(["v1"])));
|
||||
await settle();
|
||||
expect(screen.getByText("v1:watched")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("keep a mark sent before the refetch set out but not answered by then", async () => {
|
||||
// The server may read the list before it commits the mark: that answer is older than the mark.
|
||||
let answer: (v: unknown) => void = () => {};
|
||||
let commit: (v: unknown) => void = () => {};
|
||||
const feed = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(page(["v1"]))
|
||||
.mockImplementationOnce(() => new Promise((r) => (answer = r)));
|
||||
const { qc } = mount(feed, () => new Promise((r) => (commit = r)));
|
||||
fireEvent.click(await screen.findByText("watch v1"));
|
||||
await screen.findByText("v1:watched");
|
||||
act(() => void qc.invalidateQueries({ queryKey: qk.feed() }));
|
||||
await waitFor(() => expect(feed).toHaveBeenCalledTimes(2));
|
||||
await settle();
|
||||
await act(async () => answer(page(["v1"])));
|
||||
await settle();
|
||||
expect(screen.getByText("v1:watched")).toBeTruthy();
|
||||
await act(async () => commit({ ok: true }));
|
||||
});
|
||||
|
||||
it("are taken back by a failed write only while no later write stands", async () => {
|
||||
// Marked watched, then hidden before the first answer came back: the refused mark must not take
|
||||
// the hide with it and put the card back in the view.
|
||||
const writes: { resolve: (v: unknown) => void; reject: (e: unknown) => void }[] = [];
|
||||
mount(
|
||||
vi.fn().mockResolvedValue(page(["v1", "v2"])),
|
||||
() => new Promise((resolve, reject) => writes.push({ resolve, reject })),
|
||||
);
|
||||
fireEvent.click(await screen.findByText("watch v1"));
|
||||
fireEvent.click(await screen.findByText("hide v1"));
|
||||
await waitFor(() => expect(screen.queryByText(/^v1:/)).toBeNull());
|
||||
await act(async () => writes[0]?.reject(new Error("refused")));
|
||||
await settle();
|
||||
expect(screen.queryByText(/^v1:/)).toBeNull();
|
||||
await act(async () => writes[1]?.reject(new Error("refused")));
|
||||
await waitFor(() => expect(screen.getByText("v1:new")).toBeTruthy());
|
||||
});
|
||||
|
||||
it("survive an append of the next page", async () => {
|
||||
let answer: (v: unknown) => void = () => {};
|
||||
const feed = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(page(["v1"], "c1"))
|
||||
.mockImplementationOnce(() => new Promise((r) => (answer = r)));
|
||||
mount(feed);
|
||||
fireEvent.click(await screen.findByText("watch v1"));
|
||||
await screen.findByText("v1:watched");
|
||||
fireEvent.click(screen.getByText("more"));
|
||||
await waitFor(() => expect(feed).toHaveBeenCalledTimes(2));
|
||||
await settle();
|
||||
await act(async () => answer(page(["v2"])));
|
||||
await screen.findByText("v2:new");
|
||||
await settle();
|
||||
expect(screen.getByText("v1:watched")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("are dropped by a change of the filters", async () => {
|
||||
const { rerender } = mount(vi.fn().mockResolvedValue(page(["v1"])));
|
||||
fireEvent.click(await screen.findByText("watch v1"));
|
||||
await screen.findByText("v1:watched");
|
||||
rerender({ ...FILTERS });
|
||||
await waitFor(() => expect(screen.getByText("v1:new")).toBeTruthy());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,253 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// A video marked watched in the player (by hand, or by the player at its end) left an Unwatched view
|
||||
// at once, then came back as "new" when the player closed, until a reload (owner, on a channel page).
|
||||
// The player's closing position patch writes into the cached feed, which bumped `dataUpdatedAt`; the
|
||||
// feed took that for fresh server data and dropped its optimistic overrides, and nothing refetched.
|
||||
// The grid and the player are stubs that show what Feed hands them.
|
||||
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor, cleanup, act } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("react-i18next")>()),
|
||||
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
||||
}));
|
||||
vi.mock("./wizardContext", () => ({ useWizard: () => ({ openWizard: vi.fn() }) }));
|
||||
// What the grid was handed for its card menu's channel-filter row.
|
||||
const grid: { channelFilter?: { ids: string[]; set: (ids: string[]) => void } } = {};
|
||||
vi.mock("./VirtualFeed", () => ({
|
||||
default: ({
|
||||
items,
|
||||
onOpen,
|
||||
channelFilter,
|
||||
}: {
|
||||
items: { id: string }[];
|
||||
onOpen: (v: unknown) => void;
|
||||
channelFilter?: { ids: string[]; set: (ids: string[]) => void };
|
||||
}) => (
|
||||
(grid.channelFilter = channelFilter),
|
||||
(
|
||||
<ol aria-label="results">
|
||||
{items.map((v) => (
|
||||
<li key={v.id}>
|
||||
{v.id}
|
||||
<button onClick={() => onOpen(v)}>open {v.id}</button>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)
|
||||
),
|
||||
}));
|
||||
// The player's progress hook marks a clip closed near its end watched from its unmount cleanup:
|
||||
// `watchOnUnmount` stands in for that, and `flush` for its closing checkpoint, which it tracks.
|
||||
const player = vi.hoisted(() => ({
|
||||
watchOnUnmount: false,
|
||||
flush: null as Promise<unknown> | null,
|
||||
}));
|
||||
vi.mock("./PlayerModal", async () => {
|
||||
const { useEffect } = await import("react");
|
||||
const { trackProgressFlush } = await import("../lib/progressCache");
|
||||
return {
|
||||
default: function Player({
|
||||
video,
|
||||
onState,
|
||||
onClose,
|
||||
}: {
|
||||
video: { id: string };
|
||||
onState: (id: string, status: string) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (player.watchOnUnmount) onState(video.id, "watched");
|
||||
if (player.flush) trackProgressFlush(player.flush);
|
||||
},
|
||||
[video.id, onState],
|
||||
);
|
||||
return (
|
||||
<div>
|
||||
<button onClick={() => onState(video.id, "watched")}>mark</button>
|
||||
<button onClick={onClose}>close</button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import Feed from "./Feed";
|
||||
import { api, DEFAULT_TAG_MODES, type FeedFilters, type Video } from "../lib/api";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { patchCachedProgress } from "../lib/progressCache";
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
player.watchOnUnmount = false;
|
||||
player.flush = null;
|
||||
});
|
||||
|
||||
const FILTERS: FeedFilters = {
|
||||
tags: [],
|
||||
tagModes: DEFAULT_TAG_MODES,
|
||||
channelIds: ["UCx"],
|
||||
q: "",
|
||||
sort: "newest",
|
||||
scope: "my",
|
||||
includeNormal: true,
|
||||
includeShorts: false,
|
||||
includeLive: false,
|
||||
show: "unwatched",
|
||||
};
|
||||
|
||||
const video = (status: string, position = 0) =>
|
||||
({
|
||||
id: "v1",
|
||||
title: "v1",
|
||||
channel_id: "UCx",
|
||||
channel_title: "Channel",
|
||||
status,
|
||||
position_seconds: position,
|
||||
duration_seconds: 600,
|
||||
published_at: "2026-01-01T00:00:00Z",
|
||||
watch_url: "https://www.youtube.com/watch?v=v1",
|
||||
}) as unknown as Video;
|
||||
|
||||
function mount({
|
||||
channelScoped = true,
|
||||
setFilters = () => {},
|
||||
}: { channelScoped?: boolean; setFilters?: (f: FeedFilters) => void } = {}) {
|
||||
const feed = vi
|
||||
.spyOn(api, "feed")
|
||||
.mockResolvedValueOnce({ items: [video("new")], next_cursor: null } as never)
|
||||
// The server after the mark: watched, so an Unwatched view leaves it out.
|
||||
.mockResolvedValue({ items: [], next_cursor: null } as never);
|
||||
const summary = vi.spyOn(api, "feedSummary").mockResolvedValue({ count: 1, facets: {} } as never);
|
||||
vi.spyOn(api, "setState").mockResolvedValue({ ok: true } as never);
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
qc.setQueryData(qk.me(), { id: 1, can_write: true, prefs: {} });
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<Feed
|
||||
filters={FILTERS}
|
||||
setFilters={setFilters}
|
||||
view="cards"
|
||||
setView={() => {}}
|
||||
canRead
|
||||
ytSearch={null}
|
||||
onYtSearch={() => {}}
|
||||
onExitYtSearch={() => {}}
|
||||
channelScoped={channelScoped}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
return { qc, feed, summary };
|
||||
}
|
||||
|
||||
describe("Feed — a video marked watched in the player", () => {
|
||||
it("stays out of an Unwatched view when the player writes its position into the cache", async () => {
|
||||
const { qc } = mount();
|
||||
fireEvent.click(await screen.findByText("open v1"));
|
||||
fireEvent.click(await screen.findByText("mark"));
|
||||
await waitFor(() => expect(screen.queryByText("open v1")).toBeNull());
|
||||
// What the player does as it closes: the server's stored position, written into the cached list.
|
||||
act(() => patchCachedProgress(qc, new Map([["v1", 10]])));
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
expect(screen.queryByText("open v1")).toBeNull();
|
||||
});
|
||||
|
||||
it("refetches the list and its count by the view's filters when the player closes", async () => {
|
||||
const { feed, summary } = mount();
|
||||
fireEvent.click(await screen.findByText("open v1"));
|
||||
fireEvent.click(await screen.findByText("mark"));
|
||||
const before = { feed: feed.mock.calls.length, summary: summary.mock.calls.length };
|
||||
fireEvent.click(screen.getByText("close"));
|
||||
await waitFor(() => expect(feed.mock.calls.length).toBeGreaterThan(before.feed));
|
||||
await waitFor(() => expect(summary.mock.calls.length).toBeGreaterThan(before.summary));
|
||||
// The refetch is the channel's Unwatched list, and the server's answer keeps the card out.
|
||||
expect(feed.mock.calls[feed.mock.calls.length - 1]?.[0]).toMatchObject({
|
||||
channelIds: ["UCx"],
|
||||
show: "unwatched",
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
expect(screen.queryByText("open v1")).toBeNull();
|
||||
});
|
||||
|
||||
it("refetches when the player marks the clip watched as it closes", async () => {
|
||||
const { feed } = mount();
|
||||
fireEvent.click(await screen.findByText("open v1"));
|
||||
player.watchOnUnmount = true;
|
||||
const before = feed.mock.calls.length;
|
||||
fireEvent.click(await screen.findByText("close"));
|
||||
await waitFor(() => expect(feed.mock.calls.length).toBeGreaterThan(before));
|
||||
});
|
||||
|
||||
it("waits for the session's writes to land before it refetches", async () => {
|
||||
const { feed } = mount();
|
||||
let land: (v: unknown) => void = () => {};
|
||||
vi.mocked(api.setState).mockReturnValue(new Promise((r) => (land = r)) as never);
|
||||
fireEvent.click(await screen.findByText("open v1"));
|
||||
fireEvent.click(await screen.findByText("mark"));
|
||||
const before = feed.mock.calls.length;
|
||||
fireEvent.click(screen.getByText("close"));
|
||||
await act(() => new Promise((r) => setTimeout(r, 30)));
|
||||
// A refetch now would ask a server that has not taken the mark yet.
|
||||
expect(feed.mock.calls.length).toBe(before);
|
||||
await act(async () => land({ ok: true }));
|
||||
await waitFor(() => expect(feed.mock.calls.length).toBeGreaterThan(before));
|
||||
});
|
||||
|
||||
it("waits for the player's closing checkpoint before it refetches", async () => {
|
||||
// Refetched before that write landed, the list read the older position and, landing after the
|
||||
// player's patch, put it back: a reopen resumed early.
|
||||
const { feed } = mount();
|
||||
let land: (v: unknown) => void = () => {};
|
||||
player.flush = new Promise((r) => (land = r));
|
||||
fireEvent.click(await screen.findByText("open v1"));
|
||||
fireEvent.click(await screen.findByText("mark"));
|
||||
const before = feed.mock.calls.length;
|
||||
fireEvent.click(screen.getByText("close"));
|
||||
await act(() => new Promise((r) => setTimeout(r, 30)));
|
||||
expect(feed.mock.calls.length).toBe(before);
|
||||
await act(async () => land(undefined));
|
||||
await waitFor(() => expect(feed.mock.calls.length).toBeGreaterThan(before));
|
||||
});
|
||||
|
||||
it("does not refetch on a close with nothing changed", async () => {
|
||||
const { feed } = mount();
|
||||
fireEvent.click(await screen.findByText("open v1"));
|
||||
const before = feed.mock.calls.length;
|
||||
fireEvent.click(await screen.findByText("close"));
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
expect(feed.mock.calls.length).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Feed — the card menu's channel filter", () => {
|
||||
it("is offered on the feed, as its own channel list, and sets it", async () => {
|
||||
const setFilters = vi.fn();
|
||||
mount({ channelScoped: false, setFilters });
|
||||
await screen.findByText("open v1");
|
||||
expect(grid.channelFilter?.ids).toEqual(["UCx"]);
|
||||
grid.channelFilter?.set(["UCx", "UCy"]);
|
||||
expect(setFilters).toHaveBeenCalledWith({ ...FILTERS, channelIds: ["UCx", "UCy"] });
|
||||
});
|
||||
|
||||
it("is not offered on a channel page, whose view is that channel already", async () => {
|
||||
mount({ channelScoped: true });
|
||||
await screen.findByText("open v1");
|
||||
expect(grid.channelFilter).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,7 @@
|
||||
// the next refetch (found in a UAT). The grid is replaced by a list that shows what Feed
|
||||
// hands it: each video's position, and a button that fires the reset.
|
||||
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react";
|
||||
import { render, screen, fireEvent, waitFor, cleanup, act } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => ({
|
||||
@@ -35,6 +35,7 @@ vi.mock("./VirtualFeed", () => ({
|
||||
import Feed from "./Feed";
|
||||
import { api, DEFAULT_TAG_MODES, type FeedFilters, type Video } from "../lib/api";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { patchCachedProgress } from "../lib/progressCache";
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
@@ -78,30 +79,69 @@ const halfWatched = {
|
||||
watch_url: "https://www.youtube.com/watch?v=v1",
|
||||
} as unknown as Video;
|
||||
|
||||
function mount(clear: () => Promise<unknown>) {
|
||||
vi.spyOn(api, "feed").mockResolvedValue({ items: [halfWatched], next_cursor: null } as never);
|
||||
vi.spyOn(api, "feedSummary").mockResolvedValue({ count: 1, facets: {} } as never);
|
||||
vi.spyOn(api, "clearState").mockImplementation(clear as never);
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
qc.setQueryData(qk.me(), { id: 1, can_write: true, prefs: {} });
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<Feed
|
||||
filters={FILTERS}
|
||||
setFilters={() => {}}
|
||||
view="cards"
|
||||
setView={() => {}}
|
||||
canRead
|
||||
ytSearch=""
|
||||
onYtSearch={() => {}}
|
||||
onExitYtSearch={() => {}}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
return qc;
|
||||
}
|
||||
|
||||
describe("Feed — resetting a half-watched video", () => {
|
||||
it("clears its resume position at once, not only its status", async () => {
|
||||
vi.spyOn(api, "feed").mockResolvedValue({ items: [halfWatched], next_cursor: null } as never);
|
||||
vi.spyOn(api, "feedSummary").mockResolvedValue({ count: 1, facets: {} } as never);
|
||||
// Never settles: what the card shows before the server answers is the optimistic layer alone.
|
||||
vi.spyOn(api, "clearState").mockReturnValue(new Promise(() => {}) as never);
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
qc.setQueryData(qk.me(), { id: 1, can_write: true, prefs: {} });
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<Feed
|
||||
filters={FILTERS}
|
||||
setFilters={() => {}}
|
||||
view="cards"
|
||||
setView={() => {}}
|
||||
canRead
|
||||
ytSearch=""
|
||||
onYtSearch={() => {}}
|
||||
onExitYtSearch={() => {}}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
mount(() => new Promise(() => {}));
|
||||
await waitFor(() => expect(screen.getByText("v1@95")).toBeTruthy());
|
||||
fireEvent.click(screen.getByText("reset v1"));
|
||||
await waitFor(() => expect(screen.getByText("v1@0")).toBeTruthy());
|
||||
});
|
||||
|
||||
it("shows the position a replay after it saved, so a reopen resumes there", async () => {
|
||||
// Reset, played to 40 s and closed: the player writes what the server stored into the cache.
|
||||
// A reset mask over the cache kept showing 0, a reopen resumed at 0, and its first checkpoint
|
||||
// wrote over the 40.
|
||||
const qc = mount(() => Promise.resolve({ ok: true }));
|
||||
fireEvent.click(await screen.findByText("reset v1"));
|
||||
await screen.findByText("v1@0");
|
||||
act(() => patchCachedProgress(qc, new Map([["v1", 40]])));
|
||||
await waitFor(() => expect(screen.getByText("v1@40")).toBeTruthy());
|
||||
});
|
||||
|
||||
it("keeps the position at 0 through a refetch that set out before the reset", async () => {
|
||||
// That refetch read the row the reset deletes: its answer put the old position back under a
|
||||
// card the reset had made new, and a reopen resumed there.
|
||||
let answer: (v: unknown) => void = () => {};
|
||||
const qc = mount(() => new Promise(() => {}));
|
||||
await screen.findByText("v1@95");
|
||||
vi.mocked(api.feed).mockImplementationOnce(() => new Promise((r) => (answer = r)) as never);
|
||||
act(() => void qc.invalidateQueries({ queryKey: qk.feed() }));
|
||||
await waitFor(() => expect(api.feed).toHaveBeenCalledTimes(2));
|
||||
await act(() => new Promise((r) => setTimeout(r, 20)));
|
||||
fireEvent.click(screen.getByText("reset v1"));
|
||||
await screen.findByText("v1@0");
|
||||
await act(async () => answer({ items: [halfWatched], next_cursor: null }));
|
||||
await act(() => new Promise((r) => setTimeout(r, 20)));
|
||||
expect(screen.getByText("v1@0")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("puts the position back when the server refuses the reset", async () => {
|
||||
mount(() => Promise.reject(new Error("down")));
|
||||
fireEvent.click(await screen.findByText("reset v1"));
|
||||
await waitFor(() => expect(screen.getByText("v1@95")).toBeTruthy());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,6 +27,7 @@ import i18n from "../i18n";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { notify, resolveVideo } from "../lib/notifications";
|
||||
import { useDebounced } from "../lib/useDebounced";
|
||||
import { patchCachedProgress, progressFlushes } from "../lib/progressCache";
|
||||
import {
|
||||
buildSort,
|
||||
feedSortOptions,
|
||||
@@ -102,6 +103,22 @@ function matchesView(status: VideoStatus, show: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
// An optimistic value and the write that made it. A refetch that lands is the server's word only on
|
||||
// the writes it ANSWERED before that refetch set out: a mark made while it was in flight is newer
|
||||
// than its answer, and so is one sent just before it whose answer had not come back (the server may
|
||||
// read the list first). Dropping either put a card marked watched back into an Unwatched view.
|
||||
type Stamped<T> = { value: T; seq: number };
|
||||
type Overrides<T> = Record<string, Stamped<T>>;
|
||||
const unconfirmed = <T,>(o: Overrides<T>, answered: ReadonlySet<number>): Overrides<T> =>
|
||||
Object.fromEntries(Object.entries(o).filter(([, s]) => !answered.has(s.seq)));
|
||||
// A failed write takes back only its OWN value: a later write for the same video stands.
|
||||
const withoutWrite = <T,>(o: Overrides<T>, id: string, seq: number): Overrides<T> => {
|
||||
if (o[id]?.seq !== seq) return o;
|
||||
const next = { ...o };
|
||||
delete next[id];
|
||||
return next;
|
||||
};
|
||||
|
||||
// How many live-search results one fetch asks for; `Load more` pulls another batch of the same size.
|
||||
const YT_COUNTS = [20, 40, 60, 100] as const;
|
||||
|
||||
@@ -146,26 +163,59 @@ export default function Feed({
|
||||
const sortKeys = channelScoped
|
||||
? SORT_KEYS.filter((k) => k !== "subscribers" && k !== "priority")
|
||||
: SORT_KEYS;
|
||||
const [overrides, setOverrides] = useState<Record<string, VideoStatus>>({});
|
||||
const [savedOverrides, setSavedOverrides] = useState<Record<string, boolean>>({});
|
||||
// A reset drops the resume position too, which a status override cannot say: without this the card
|
||||
// kept its progress bar and its Continue button until the next refetch.
|
||||
const [resetIds, setResetIds] = useState<Record<string, true>>({});
|
||||
const [overrides, setOverrides] = useState<Overrides<VideoStatus>>({});
|
||||
const [savedOverrides, setSavedOverrides] = useState<Overrides<boolean>>({});
|
||||
// The write order the stamps above count in, the writes the server has answered, and which of
|
||||
// them it had answered when the running refetch set out.
|
||||
const writeSeq = useRef(0);
|
||||
const answered = useRef(new Set<number>());
|
||||
const answeredAtRefetch = useRef<ReadonlySet<number>>(new Set());
|
||||
// A reset's position 0 lives in the cached lists (see onResetState), where a refetch that set out
|
||||
// before the server took the reset puts the old position back: written again on its landing.
|
||||
const resets = useRef(new Map<string, number>());
|
||||
// The open player: which video and where to start (null = resume from saved position).
|
||||
const [activeVideo, setActiveVideo] = useState<{ video: Video; startAt: number | null } | null>(
|
||||
null,
|
||||
);
|
||||
const qc = useQueryClient();
|
||||
|
||||
const openVideo = useCallback(
|
||||
(video: Video, startAt: number | null = null) => setActiveVideo({ video, startAt }),
|
||||
[],
|
||||
);
|
||||
// What changed state while the player was open, and the writes still in flight: closing it
|
||||
// refetches the list by its filters once those land. Marked watched (by hand, or by the player at
|
||||
// the end) a card leaves an Unwatched view through the override at once, but the list, its count
|
||||
// and the cache stayed the pre-session server's until a reload (owner).
|
||||
const playerSession = useRef({
|
||||
open: false,
|
||||
changed: false,
|
||||
pending: new Set<Promise<unknown>>(),
|
||||
});
|
||||
const openVideo = useCallback((video: Video, startAt: number | null = null) => {
|
||||
playerSession.current.open = true;
|
||||
// Its player writes the next position: a reset no longer speaks for it.
|
||||
resets.current.delete(video.id);
|
||||
setActiveVideo({ video, startAt });
|
||||
}, []);
|
||||
// Stable identity, like Playlists' `closePlayer`: PlayerModal's keydown effect
|
||||
// depends on `onClose`, so a fresh closure every Feed render re-subscribed those listeners,
|
||||
// re-ran focusModal() under the open player, dropped its in-flight timers and flushed the
|
||||
// debounced volume write on each pass.
|
||||
const closePlayer = useCallback(() => setActiveVideo(null), []);
|
||||
// The session ends once the player has UNMOUNTED, not on the close click: its progress hook's
|
||||
// cleanup marks a clip closed near its end watched, a write that lands after the click. React runs
|
||||
// every unmount cleanup before any effect's setup, so here that write is already in the session,
|
||||
// and so is the player's closing checkpoint (`progressFlushes`): refetched before it lands, the
|
||||
// list read the older position and, landing after the player's patch, put it back.
|
||||
const playerOpen = activeVideo !== null;
|
||||
useEffect(() => {
|
||||
const session = playerSession.current;
|
||||
if (playerOpen || !session.open) return;
|
||||
session.open = false;
|
||||
if (!session.changed) return;
|
||||
session.changed = false;
|
||||
void Promise.allSettled([...session.pending, ...progressFlushes()]).then(() => {
|
||||
void qc.invalidateQueries({ queryKey: qk.feed() });
|
||||
void qc.invalidateQueries({ queryKey: qk.feedSummary() });
|
||||
});
|
||||
}, [playerOpen, qc]);
|
||||
|
||||
const ytActive = !!ytSearch;
|
||||
// How many live-search results to gather (the count selector replaces a manual "load more";
|
||||
@@ -244,22 +294,31 @@ export default function Feed({
|
||||
setOverridesFor(filters);
|
||||
setOverrides({});
|
||||
setSavedOverrides({});
|
||||
setResetIds({});
|
||||
}
|
||||
// ...but NOT on infinite-scroll appends: fetchNextPage also bumps dataUpdatedAt while page 1's
|
||||
// rows are unchanged, so clearing overrides there would make a just-hidden card flash back.
|
||||
// Only a real refetch (page count doesn't grow) is authoritative.
|
||||
const pagesLenRef = useRef(0);
|
||||
const pagesLen = query.data?.pages?.length ?? 0;
|
||||
// ...but only when a refetch LANDS, the server's own answer. Not on an infinite-scroll append (its
|
||||
// page 1 rows are unchanged, so a just-hidden card would flash back), and not on a local write into
|
||||
// the cache: the player's closing position patch bumped `dataUpdatedAt` too, cleared the overrides,
|
||||
// and put a card marked watched back into an Unwatched view as "new", with nothing to refetch it.
|
||||
// And only the overrides whose write it had answered when it set out (`Stamped`).
|
||||
const refetching = query.isFetching && !query.isFetchingNextPage;
|
||||
const wasRefetching = useRef(false);
|
||||
useEffect(() => {
|
||||
const appended = pagesLen > pagesLenRef.current;
|
||||
pagesLenRef.current = pagesLen;
|
||||
if (!appended) {
|
||||
setOverrides({});
|
||||
setSavedOverrides({});
|
||||
setResetIds({});
|
||||
const started = !wasRefetching.current && refetching;
|
||||
const landed = wasRefetching.current && !refetching && query.status === "success";
|
||||
wasRefetching.current = refetching;
|
||||
if (started) answeredAtRefetch.current = new Set(answered.current);
|
||||
if (!landed) return;
|
||||
const known = answeredAtRefetch.current;
|
||||
setOverrides((o) => unconfirmed(o, known));
|
||||
setSavedOverrides((o) => unconfirmed(o, known));
|
||||
for (const seq of known) answered.current.delete(seq);
|
||||
const zero = new Map<string, number>();
|
||||
for (const [id, seq] of resets.current) {
|
||||
if (known.has(seq)) resets.current.delete(id);
|
||||
else zero.set(id, 0);
|
||||
}
|
||||
}, [query.dataUpdatedAt, pagesLen]);
|
||||
patchCachedProgress(qc, zero);
|
||||
}, [refetching, query.status, qc]);
|
||||
|
||||
// One shared query with the sidebar (same key + filters -> React Query dedupes to a single request):
|
||||
// the feed reads `.count`, the sidebar reads `.facets` (PERF-13, replaces feedCount + facets).
|
||||
@@ -282,19 +341,28 @@ export default function Feed({
|
||||
const onStateRef = useRef<(id: string, status: VideoStatus) => void>(() => {});
|
||||
const onState = useCallback(
|
||||
(id: string, status: VideoStatus) => {
|
||||
setOverrides((o) => ({ ...o, [id]: status }));
|
||||
const seq = ++writeSeq.current;
|
||||
setOverrides((o) => ({ ...o, [id]: { value: status, seq } }));
|
||||
const write = api.setState(id, status);
|
||||
const session = playerSession.current;
|
||||
if (session.open) {
|
||||
session.changed = true;
|
||||
session.pending.add(write);
|
||||
const settled = () => void session.pending.delete(write);
|
||||
write.then(settled, settled);
|
||||
}
|
||||
// Refetch once the server has the change so other views (e.g. Hidden) are in sync.
|
||||
// Announce the change only AFTER the server confirms it: a "Marked watched"/"Hidden"
|
||||
// notice (or resolving a stale one) is a claim of success, so firing it optimistically
|
||||
// would falsely report a change that the .catch below is about to roll back (e.g. when
|
||||
// the API is unreachable).
|
||||
api
|
||||
.setState(id, status)
|
||||
write
|
||||
.then(() => {
|
||||
// Mark stale WITHOUT refetching: the optimistic `overrides` layer + the matchesView filter
|
||||
// already show the correct state (a hidden/watched card drops out immediately), so refetching
|
||||
// every loaded infinite-query page here was pure waste — 10 pages x rapid marks = a refetch
|
||||
// storm (PERF-2). The next filter change / remount refetches fresh from the server.
|
||||
answered.current.add(seq);
|
||||
qc.invalidateQueries({ queryKey: qk.feed(), refetchType: "none" });
|
||||
if (status === "hidden") {
|
||||
const v = loadedRef.current.find((x) => x.id === id);
|
||||
@@ -340,11 +408,7 @@ export default function Feed({
|
||||
// reverts to the real (unchanged) state instead of staying phantom-hidden, and say so:
|
||||
// a card that silently snaps back reads as a misclick.
|
||||
notifyActionError(e, i18n.t("errors.writeFailed.videoState"));
|
||||
setOverrides((o) => {
|
||||
const next = { ...o };
|
||||
delete next[id];
|
||||
return next;
|
||||
});
|
||||
setOverrides((o) => withoutWrite(o, id, seq));
|
||||
});
|
||||
},
|
||||
[qc],
|
||||
@@ -354,24 +418,27 @@ export default function Feed({
|
||||
(id: string) => {
|
||||
// Reset to pristine (drop the whole VideoState row, incl. resume position) — the
|
||||
// un-watch toggle can't clear an in-progress position, this can.
|
||||
setOverrides((o) => ({ ...o, [id]: "new" }));
|
||||
setResetIds((r) => ({ ...r, [id]: true }));
|
||||
const seq = ++writeSeq.current;
|
||||
setOverrides((o) => ({ ...o, [id]: { value: "new", seq } }));
|
||||
// The position goes into the cached lists, where the player's own closing patch writes the
|
||||
// next one: as a mask over them it outlived a replay, so a reopen resumed at 0 and its first
|
||||
// checkpoint wrote over the progress just made.
|
||||
const before = loadedRef.current.find((v) => v.id === id)?.position_seconds;
|
||||
patchCachedProgress(qc, new Map([[id, 0]]));
|
||||
resets.current.set(id, seq);
|
||||
api
|
||||
.clearState(id)
|
||||
// Stale-mark only (see setState above): the optimistic override already reset the card.
|
||||
.then(() => qc.invalidateQueries({ queryKey: qk.feed(), refetchType: "none" }))
|
||||
.then(() => {
|
||||
answered.current.add(seq);
|
||||
qc.invalidateQueries({ queryKey: qk.feed(), refetchType: "none" });
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
notifyActionError(e, i18n.t("errors.writeFailed.videoReset"));
|
||||
setOverrides((o) => {
|
||||
const next = { ...o };
|
||||
delete next[id];
|
||||
return next;
|
||||
});
|
||||
setResetIds((r) => {
|
||||
const next = { ...r };
|
||||
delete next[id];
|
||||
return next;
|
||||
});
|
||||
setOverrides((o) => withoutWrite(o, id, seq));
|
||||
if (resets.current.get(id) !== seq) return;
|
||||
resets.current.delete(id);
|
||||
if (before !== undefined) patchCachedProgress(qc, new Map([[id, before]]));
|
||||
});
|
||||
},
|
||||
[qc],
|
||||
@@ -379,9 +446,11 @@ export default function Feed({
|
||||
|
||||
const onToggleSave = useCallback(
|
||||
(id: string, saved: boolean) => {
|
||||
setSavedOverrides((o) => ({ ...o, [id]: saved }));
|
||||
const seq = ++writeSeq.current;
|
||||
setSavedOverrides((o) => ({ ...o, [id]: { value: saved, seq } }));
|
||||
(saved ? api.watchLaterAdd(id) : api.watchLaterRemove(id))
|
||||
.then(() => {
|
||||
answered.current.add(seq);
|
||||
qc.invalidateQueries({ queryKey: qk.playlists() });
|
||||
qc.invalidateQueries({ queryKey: qk.playlist() });
|
||||
})
|
||||
@@ -392,7 +461,7 @@ export default function Feed({
|
||||
saved ? "errors.writeFailed.watchLaterAdd" : "errors.writeFailed.watchLaterRemove",
|
||||
),
|
||||
);
|
||||
setSavedOverrides((o) => ({ ...o, [id]: !saved }));
|
||||
setSavedOverrides((o) => withoutWrite(o, id, seq));
|
||||
});
|
||||
},
|
||||
[qc],
|
||||
@@ -409,12 +478,11 @@ export default function Feed({
|
||||
list
|
||||
.map((v) => {
|
||||
const status = overrides[v.id]; // read once so the narrowing sticks
|
||||
return status ? { ...v, status } : v;
|
||||
return status ? { ...v, status: status.value } : v;
|
||||
})
|
||||
.map((v) => (resetIds[v.id] ? { ...v, position_seconds: 0 } : v))
|
||||
.map((v) => {
|
||||
const saved = savedOverrides[v.id];
|
||||
return saved !== undefined ? { ...v, saved } : v;
|
||||
return saved ? { ...v, saved: saved.value } : v;
|
||||
});
|
||||
const merged: Video[] = withOverrides(loaded);
|
||||
const items: Video[] = merged.filter((v) => matchesView(v.status, filters.show));
|
||||
@@ -878,6 +946,15 @@ export default function Feed({
|
||||
onToggleSave={onToggleSave}
|
||||
onResetState={onResetState}
|
||||
onOpen={openVideo}
|
||||
// A channel page's view is the channel already: no row there to narrow it to one.
|
||||
channelFilter={
|
||||
channelScoped
|
||||
? undefined
|
||||
: {
|
||||
ids: filters.channelIds,
|
||||
set: (channelIds) => setFilters({ ...filters, channelIds }),
|
||||
}
|
||||
}
|
||||
// A failed next page stops the read-ahead: retry is off, and re-running on every settle would
|
||||
// re-request it once a round-trip for as long as the end is in view. The row
|
||||
// below says so and retries on a click.
|
||||
|
||||
@@ -663,7 +663,7 @@ describe("Home", () => {
|
||||
comfort: [],
|
||||
});
|
||||
mount();
|
||||
fireEvent.click(await screen.findByText("A Show"));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "A Show" }));
|
||||
expect(setJumpTo).toHaveBeenCalledWith(expect.objectContaining({ id: "ep-7", type: "play" }));
|
||||
});
|
||||
|
||||
@@ -678,11 +678,24 @@ describe("Home", () => {
|
||||
comfort: [],
|
||||
});
|
||||
mount();
|
||||
fireEvent.click(await screen.findByText("A Show"));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "A Show" }));
|
||||
expect(setJumpTo).toHaveBeenCalledWith(expect.objectContaining({ replace: true }));
|
||||
expect(setPage).toHaveBeenCalledWith("plex");
|
||||
});
|
||||
|
||||
it("a show tile's title opens the show page; its picture plays on", async () => {
|
||||
discover.mockResolvedValue({
|
||||
budget: "any",
|
||||
hero: null,
|
||||
continue_watching: [showCard()],
|
||||
comfort: [],
|
||||
});
|
||||
mount();
|
||||
fireEvent.click(await screen.findByText("A Show"), { detail: 1 });
|
||||
expect(setJumpTo).toHaveBeenCalledWith(expect.objectContaining({ type: "show" }));
|
||||
expect(setJumpTo).not.toHaveBeenCalledWith(expect.objectContaining({ type: "play" }));
|
||||
});
|
||||
|
||||
it("sends the ⓘ affordance to the item's page, not to the player", async () => {
|
||||
// `onInfo` and `onPlay` differ by one ternary arm, and confusing them is the defect the play
|
||||
// test above was written for — but only `onPlay` was pinned, so swapping ⓘ's "movie" for "play"
|
||||
|
||||
@@ -199,7 +199,7 @@ function useHomeCardMenu({
|
||||
.map((it, i) => (i === 0 ? { ...it, separatorBefore: true } : it))
|
||||
: target.plex
|
||||
? plexActions
|
||||
.items(target.plex, { play: false, pin: false })
|
||||
.items(target.plex, { play: false, pin: false, anchor: menu.anchor })
|
||||
.map((it, i) => (i === 0 ? { ...it, separatorBefore: true } : it))
|
||||
: target.kind === "channel" || target.kind === "playlist"
|
||||
? [{ ...openPage.item(target), separatorBefore: true }]
|
||||
@@ -371,6 +371,7 @@ function Rail({
|
||||
onAdd={plexAdd}
|
||||
next={c.next?.plex}
|
||||
onInfo={() => onInfo(c)}
|
||||
onPage={() => onInfo(c)}
|
||||
menuProps={menuFor[i]}
|
||||
menuButtonProps={buttonFor[i]}
|
||||
menuOpen={menu.open && menu.target === c}
|
||||
|
||||
@@ -28,7 +28,8 @@ function useCap(): string {
|
||||
// words matched nothing.
|
||||
|
||||
/** The key cap at the end of a search field. A button, so the pointer and a screen reader reach it
|
||||
* too; left out of a phone's narrow header, where there is no keyboard to press it on. */
|
||||
* too; left out of a phone's narrow header and of every touch screen, where there is (usually) no
|
||||
* keyboard to press it on — on a tablet it was a 44 px box crowding the field. ⌘K still works. */
|
||||
export function PaletteKeycap() {
|
||||
const { t } = useTranslation();
|
||||
const cap = useCap();
|
||||
@@ -37,7 +38,7 @@ export function PaletteKeycap() {
|
||||
onClick={openPalette}
|
||||
aria-label={`${t("palette.open")} (${cap})`}
|
||||
title={`${t("palette.open")} (${cap})`}
|
||||
className="inline-flex h-6 shrink-0 items-center justify-center rounded-md border border-border px-1.5 font-mono text-3xs touch-target text-muted transition hover:border-accent/50 hover:text-fg @max-[640px]/appmain:hidden"
|
||||
className="inline-flex h-6 shrink-0 items-center justify-center rounded-md border border-border px-1.5 font-mono text-3xs touch-target text-muted transition hover:border-accent/50 hover:text-fg pointer-coarse:hidden @max-[640px]/appmain:hidden"
|
||||
>
|
||||
{cap}
|
||||
</Pressable>
|
||||
|
||||
@@ -50,6 +50,8 @@ beforeAll(() => {
|
||||
// --- the fake IFrame API ------------------------------------------------------------------------
|
||||
const clock = { current: 0, duration: 3000 };
|
||||
const started: { videoId: string; start: number | undefined }[] = [];
|
||||
// The last player's event handlers, so a test can say "the player is ready" when it wants to.
|
||||
let playerEvents: { onReady?: () => void; onStateChange?: (e: { data: number }) => void } = {};
|
||||
const base = {
|
||||
getCurrentTime: () => clock.current,
|
||||
getDuration: () => clock.duration,
|
||||
@@ -61,8 +63,12 @@ const fakePlayer = new Proxy(base, {
|
||||
get: (t, k) => (k in t ? t[k as keyof typeof t] : k === "then" ? undefined : () => undefined),
|
||||
});
|
||||
window.YT = {
|
||||
Player: function (_el: unknown, opts: { videoId: string; playerVars: { start?: number } }) {
|
||||
Player: function (
|
||||
_el: unknown,
|
||||
opts: { videoId: string; playerVars: { start?: number }; events?: typeof playerEvents },
|
||||
) {
|
||||
started.push({ videoId: opts.videoId, start: opts.playerVars.start });
|
||||
playerEvents = opts.events ?? {};
|
||||
return fakePlayer;
|
||||
} as never,
|
||||
};
|
||||
@@ -331,6 +337,40 @@ describe("PlayerModal — the dialog contract", () => {
|
||||
}
|
||||
});
|
||||
|
||||
// The card takes the focus back on its own when it may have slipped into the iframe: the player
|
||||
// turning ready, the pointer leaving the video, the window regaining focus. None of those may take
|
||||
// it off a control the viewer tabbed to, or their next Tab starts over from the first button.
|
||||
it.each([
|
||||
["the player turns ready", () => playerEvents.onReady?.()],
|
||||
[
|
||||
"the pointer leaves the video",
|
||||
() => {
|
||||
fireEvent.mouseLeave(document.querySelector(".player-stage")!);
|
||||
},
|
||||
],
|
||||
[
|
||||
"the window regains focus",
|
||||
() => {
|
||||
fireEvent.focus(window);
|
||||
},
|
||||
],
|
||||
])("leaves the focus on a control the viewer tabbed to when %s", async (_why, happen) => {
|
||||
open();
|
||||
await waitFor(() => expect(playerEvents.onReady).toBeTypeOf("function"));
|
||||
const control = within(screen.getByTestId("player-modal")).getByTestId("player-close");
|
||||
control.focus();
|
||||
act(happen);
|
||||
expect(document.activeElement).toBe(control);
|
||||
});
|
||||
|
||||
it("takes the focus back from the iframe when the player turns ready", async () => {
|
||||
open();
|
||||
await waitFor(() => expect(playerEvents.onReady).toBeTypeOf("function"));
|
||||
(document.activeElement as HTMLElement).blur();
|
||||
act(() => playerEvents.onReady?.());
|
||||
expect(screen.getByTestId("player-modal").contains(document.activeElement)).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps Tab inside the player", () => {
|
||||
open();
|
||||
const dialog = screen.getByTestId("player-modal");
|
||||
@@ -424,23 +464,103 @@ describe("PlayerModal — the description", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// C3: the queue's side arrows sit in a gutter of the card's row, not outside it, so no window cuts
|
||||
// them off (outside the card, a 1024 window cut them and a portrait tablet lost them).
|
||||
describe("PlayerModal — the side arrows", () => {
|
||||
it("stand inside the row's gutter", () => {
|
||||
// The full-height strips beside the card were a second Previous / Next that cost the video 7rem of
|
||||
// width (owner): the panel's transport row and Shift+← / → step on their own.
|
||||
describe("PlayerModal — no side strips", () => {
|
||||
it("steps from the panel only, and the card keeps no gutter for strips", () => {
|
||||
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
|
||||
const A = vid("A", 0);
|
||||
const B = vid("B", 0);
|
||||
renderModal(
|
||||
<PlayerModal video={A} queue={[A, B]} startIndex={0} onClose={() => {}} onState={() => {}} />,
|
||||
);
|
||||
const arrow = [...document.querySelectorAll<HTMLElement>("[aria-label='player.next']")].find(
|
||||
(b) => b.className.includes("w-12"),
|
||||
)!;
|
||||
expect(arrow).toBeTruthy();
|
||||
expect(arrow.className).toMatch(/\bright-0\b/);
|
||||
expect(arrow.className).not.toMatch(/left-full/);
|
||||
expect(arrow.parentElement!.className).toMatch(/sm:px-14/);
|
||||
expect(screen.getAllByRole("button", { name: "player.next" })).toHaveLength(1);
|
||||
expect(screen.getAllByRole("button", { name: "player.previous" })).toHaveLength(1);
|
||||
// stage → its wrapper (with the clip panel) → the card → the row
|
||||
const row =
|
||||
document.querySelector<HTMLElement>(".player-stage")!.parentElement!.parentElement!
|
||||
.parentElement!;
|
||||
expect(row.className).not.toMatch(/px-14/);
|
||||
});
|
||||
|
||||
it("closes from one icon-only button", () => {
|
||||
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
|
||||
const onClose = vi.fn();
|
||||
const A = vid("A", 0);
|
||||
renderModal(
|
||||
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={onClose} onState={() => {}} />,
|
||||
);
|
||||
const close = screen.getByTestId("player-close");
|
||||
expect(close.getAttribute("aria-label")).toBe("player.close");
|
||||
expect(close.textContent).toBe("");
|
||||
fireEvent.click(close);
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
// YouTube's embed keeps its controls at fixed px from the iframe's edges: the seek bar across the bottom
|
||||
// 52–96 px up, the fullscreen button above its right end 80–128 px up, the gear and volume along the
|
||||
// top. A cutout given only as a share of the height lay over that button on a short player (a small
|
||||
// window, a browser zoomed to 150 %): the click paused instead.
|
||||
describe("PlayerModal — the click layer over the video", () => {
|
||||
const layers = () => {
|
||||
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
|
||||
const A = vid("A", 0);
|
||||
renderModal(
|
||||
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={() => {}} onState={() => {}} />,
|
||||
);
|
||||
return screen.getAllByTestId("yt-click-layer");
|
||||
};
|
||||
const box = (el: HTMLElement) => {
|
||||
const { left, right, width, top, bottom } = el.style;
|
||||
return { left, right, width, top, bottom };
|
||||
};
|
||||
|
||||
it("clears the seek bar across the width and the fullscreen button in its right-hand column", () => {
|
||||
const [picture, column] = layers();
|
||||
expect(box(picture!)).toEqual({
|
||||
left: "0px",
|
||||
right: "88px",
|
||||
width: "",
|
||||
top: "max(12%, 56px)",
|
||||
bottom: "max(22%, 104px)",
|
||||
});
|
||||
expect(box(column!)).toEqual({
|
||||
left: "",
|
||||
right: "0px",
|
||||
width: "88px",
|
||||
top: "max(12%, 56px)",
|
||||
bottom: "max(22%, 140px)",
|
||||
});
|
||||
});
|
||||
|
||||
// In the big views our docked bar sits under the iframe; YouTube's px are counted from the iframe's
|
||||
// bottom, so a box measured from the stage's would start that bar's height too low again.
|
||||
it.each([
|
||||
["the windowed card", false, /(^|\s)relative(\s|$)/],
|
||||
["true fullscreen", true, /(^|\s)bottom-14(\s|$)/],
|
||||
])("measures those edges against the iframe's own box in %s", (_view, fullscreen, inset) => {
|
||||
const els = layers();
|
||||
const stage = document.querySelector(".player-stage")!;
|
||||
if (fullscreen) {
|
||||
Object.defineProperty(document, "fullscreenElement", {
|
||||
configurable: true,
|
||||
get: () => stage,
|
||||
});
|
||||
act(() => void document.dispatchEvent(new Event("fullscreenchange")));
|
||||
}
|
||||
try {
|
||||
for (const el of screen.getAllByTestId("yt-click-layer")) {
|
||||
const wrapper = el.parentElement!;
|
||||
expect(wrapper).not.toBe(stage);
|
||||
expect(wrapper.parentElement).toBe(stage);
|
||||
expect(wrapper.className).toMatch(inset);
|
||||
expect(wrapper.className).not.toMatch(/(^|\s)p[byt]?-/);
|
||||
}
|
||||
expect(els).toHaveLength(2);
|
||||
} finally {
|
||||
delete (document as { fullscreenElement?: unknown }).fullscreenElement;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -457,17 +577,18 @@ describe("PlayerModal — fits the window's height", () => {
|
||||
vi.spyOn(HTMLElement.prototype, "scrollHeight", "get").mockImplementation(function (
|
||||
this: HTMLElement,
|
||||
) {
|
||||
return this.querySelector(":scope > .player-stage") ? 630 : 0;
|
||||
return this.querySelector(":scope > * > .player-stage") ? 630 : 0;
|
||||
});
|
||||
const A = vid("A", 0);
|
||||
const B = vid("B", 0);
|
||||
renderModal(
|
||||
<PlayerModal video={A} queue={[A, B]} startIndex={0} onClose={() => {}} onState={() => {}} />,
|
||||
);
|
||||
const row = document.querySelector<HTMLElement>(".player-stage")!.parentElement!.parentElement!;
|
||||
expect(row.style.maxWidth).toBe(
|
||||
"min(63rem, calc(max(36rem, (100dvh - 3rem - 160px) * 16 / 9) + 7rem))",
|
||||
);
|
||||
// stage → its wrapper (with the clip panel) → the card → the row
|
||||
const row =
|
||||
document.querySelector<HTMLElement>(".player-stage")!.parentElement!.parentElement!
|
||||
.parentElement!;
|
||||
expect(row.style.maxWidth).toBe("min(56rem, max(36rem, (100dvh - 3rem - 160px) * 16 / 9))");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -485,8 +606,19 @@ describe("PlayerModal — the side panel on a wide window", () => {
|
||||
removeEventListener: () => {},
|
||||
}) as unknown as MediaQueryList,
|
||||
);
|
||||
// jsdom has no matchMedia to spy on: a bare one is put in for these tests and taken out after, or
|
||||
// every later test's media query would read that bare stub.
|
||||
let added = false;
|
||||
beforeEach(() => {
|
||||
window.matchMedia ??= (() => ({})) as unknown as typeof window.matchMedia;
|
||||
if (typeof window.matchMedia === "function") return;
|
||||
window.matchMedia = (() => ({})) as unknown as typeof window.matchMedia;
|
||||
added = true;
|
||||
});
|
||||
afterEach(() => {
|
||||
// The spy goes first: restoring it later would put the bare stub back.
|
||||
vi.restoreAllMocks();
|
||||
if (added) delete (window as { matchMedia?: unknown }).matchMedia;
|
||||
added = false;
|
||||
});
|
||||
|
||||
it("puts the details beside the video, the description open, and sizes the card for both", async () => {
|
||||
@@ -500,7 +632,7 @@ describe("PlayerModal — the side panel on a wide window", () => {
|
||||
<PlayerModal video={A} queue={[A, B]} startIndex={0} onClose={() => {}} onState={() => {}} />,
|
||||
);
|
||||
const stage = document.querySelector<HTMLElement>(".player-stage")!;
|
||||
const card = stage.parentElement!;
|
||||
const card = stage.parentElement!.parentElement!; // the stage sits in a wrapper (+ clip panel)
|
||||
const info = screen.getByTestId("player-info");
|
||||
// The card is a row: the video, then the panel holding the title and the channel.
|
||||
expect(card.className).toMatch(/(^|\s)flex(\s|$)/);
|
||||
@@ -510,13 +642,110 @@ describe("PlayerModal — the side panel on a wide window", () => {
|
||||
// The description is shown outright; there is no ⓘ to open it.
|
||||
expect(await screen.findByText("A long description")).toBeTruthy();
|
||||
expect(screen.queryByRole("button", { name: "player.description" })).toBeNull();
|
||||
// The row's width: the video at the window's height, plus the panel and the side strips.
|
||||
expect(card.parentElement!.style.maxWidth).toBe(
|
||||
"calc((100dvh - 3rem - 0px) * 16 / 9 + 20rem + 7rem)",
|
||||
);
|
||||
// The row's width: the video at the window's height, plus the panel.
|
||||
expect(card.parentElement!.style.maxWidth).toBe("calc((100dvh - 3rem - 0px) * 16 / 9 + 20rem)");
|
||||
// A long channel name is cut on the panel's line, not run past its 20rem into a sideways scroll.
|
||||
const name = within(info).getByText("Channel").closest("button, a")!.parentElement!;
|
||||
expect(name.className).toMatch(/\bmin-w-0\b/);
|
||||
expect(name.className).toMatch(/\bflex-1\b/);
|
||||
expect(name.className).not.toMatch(/\bshrink-0\b/);
|
||||
// The description is cut to three lines, with a More once it is cut.
|
||||
expect((await screen.findByText("A long description")).closest(".line-clamp-3")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("lets the wheel scroll the panel while it can, and turn the volume everywhere else", async () => {
|
||||
it("shuts the panel to a strip for a bigger video, and keeps it shut as the queue steps on", () => {
|
||||
wideWindow(true);
|
||||
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
|
||||
const A = vid("A", 0);
|
||||
const B = vid("B", 0);
|
||||
renderModal(
|
||||
<PlayerModal video={A} queue={[A, B]} startIndex={0} onClose={() => {}} onState={() => {}} />,
|
||||
);
|
||||
const row = () =>
|
||||
document.querySelector<HTMLElement>(".player-stage")!.parentElement!.parentElement!
|
||||
.parentElement!;
|
||||
fireEvent.click(screen.getByRole("button", { name: "player.collapsePanel" }));
|
||||
expect(screen.queryByTestId("player-info")).toBeNull();
|
||||
const strip = screen.getByTestId("player-strip");
|
||||
// The pressed toggle is gone: focus is on its counterpart, not on <body>.
|
||||
expect(document.activeElement).toBe(
|
||||
within(strip).getByRole("button", { name: "player.expandPanel" }),
|
||||
);
|
||||
expect(row().style.maxWidth).toBe("calc((100dvh - 3rem - 0px) * 16 / 9 + 2.75rem)");
|
||||
// The strip keeps the way out and the stepping.
|
||||
expect(within(strip).getByTestId("player-close")).toBeTruthy();
|
||||
// The dialog keeps its name with the title out of sight: it is named by that heading's id.
|
||||
expect(screen.getByRole("dialog", { name: "Video A" })).toBeTruthy();
|
||||
fireEvent.click(within(strip).getByRole("button", { name: "player.next" }));
|
||||
expect(screen.getByTestId("player-strip")).toBeTruthy(); // still shut on the next video
|
||||
expect(screen.getByRole("dialog", { name: "Video B" })).toBeTruthy();
|
||||
fireEvent.click(
|
||||
within(screen.getByTestId("player-strip")).getByRole("button", {
|
||||
name: "player.expandPanel",
|
||||
}),
|
||||
);
|
||||
expect(screen.getByTestId("player-info")).toBeTruthy();
|
||||
expect(document.activeElement).toBe(
|
||||
screen.getByRole("button", { name: "player.collapsePanel" }),
|
||||
);
|
||||
expect(row().style.maxWidth).toBe("calc((100dvh - 3rem - 0px) * 16 / 9 + 20rem)");
|
||||
});
|
||||
|
||||
it("gives the focus to the player when the scissors that opened the clip is not there to take it", async () => {
|
||||
wideWindow(true);
|
||||
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
|
||||
const A = vid("A", 0);
|
||||
renderModal(
|
||||
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={() => {}} onState={() => {}} />,
|
||||
);
|
||||
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
|
||||
// Shut by the user: closing the clip leaves it shut, and its strip has no scissors.
|
||||
fireEvent.click(screen.getByRole("button", { name: "player.collapsePanel" }));
|
||||
act(
|
||||
() =>
|
||||
void window.dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key: "c", bubbles: true, cancelable: true }),
|
||||
),
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "clip.close" }));
|
||||
await waitFor(() => expect(document.activeElement).not.toBe(document.body));
|
||||
expect(screen.getByRole("dialog").contains(document.activeElement)).toBe(true);
|
||||
});
|
||||
|
||||
it("shuts while a clip is marked and opens again after it, unless the user opened it meanwhile", async () => {
|
||||
wideWindow(true);
|
||||
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
|
||||
const A = vid("A", 0);
|
||||
renderModal(
|
||||
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={() => {}} onState={() => {}} />,
|
||||
);
|
||||
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "clip.open" }));
|
||||
expect(screen.getByTestId("player-strip")).toBeTruthy();
|
||||
// The clip panel sits under the video here too: the stacked card's floor, and its scroll past it
|
||||
// (without them the fit ran the card down to a sliver at 133% with the largest text).
|
||||
const stage = document.querySelector<HTMLElement>(".player-stage")!;
|
||||
const cardEl = stage.parentElement!.parentElement!;
|
||||
expect(cardEl.parentElement!.style.maxWidth).toBe(
|
||||
"calc(max(36rem, (100dvh - 3rem - 0px) * 16 / 9) + 2.75rem)",
|
||||
);
|
||||
expect(cardEl.className).toContain("overflow-y-auto");
|
||||
fireEvent.click(screen.getByRole("button", { name: "clip.close" }));
|
||||
expect(screen.getByTestId("player-info")).toBeTruthy();
|
||||
expect(cardEl.className).toContain("overflow-hidden");
|
||||
// Opened again by hand while clipping: it stays open, and closing the clip leaves it so.
|
||||
fireEvent.click(screen.getByRole("button", { name: "clip.open" }));
|
||||
fireEvent.click(
|
||||
within(screen.getByTestId("player-strip")).getByRole("button", {
|
||||
name: "player.expandPanel",
|
||||
}),
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "player.collapsePanel" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "clip.close" }));
|
||||
expect(screen.getByTestId("player-strip")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("gives a panel with something to scroll the wheel for that alone, the volume elsewhere", async () => {
|
||||
wideWindow(true);
|
||||
vi.spyOn(api, "videoDetail").mockResolvedValue({
|
||||
description: "A long description",
|
||||
@@ -537,11 +766,33 @@ describe("PlayerModal — the side panel on a wide window", () => {
|
||||
return e.defaultPrevented; // prevented = the wheel went to the volume
|
||||
};
|
||||
expect(spin(text, 100)).toBe(false); // room below: the panel scrolls
|
||||
expect(spin(text, -100)).toBe(true); // at its top: nothing to scroll up, so the volume
|
||||
expect(spin(text, -100)).toBe(false); // at its top too: one gesture, one meaning (owner)
|
||||
top = 400;
|
||||
expect(spin(text, 100)).toBe(true); // at its end: the volume
|
||||
expect(spin(text, -100)).toBe(false);
|
||||
expect(spin(text, 100)).toBe(false); // and at its end, where it once fell through to the volume
|
||||
expect(spin(document.querySelector(".player-stage")!, 100)).toBe(true); // over the video
|
||||
// A panel that fits has nothing to scroll: the wheel is the volume there.
|
||||
Object.defineProperty(panel, "scrollHeight", { value: 500, configurable: true });
|
||||
expect(spin(text, 100)).toBe(true);
|
||||
});
|
||||
|
||||
it("stacked: the card scrolls under the wheel, the video in it is still the volume", () => {
|
||||
wideWindow(false);
|
||||
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
|
||||
const A = vid("A", 0);
|
||||
renderModal(
|
||||
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={() => {}} onState={() => {}} />,
|
||||
);
|
||||
const stage = document.querySelector<HTMLElement>(".player-stage")!;
|
||||
const card = stage.parentElement!.parentElement!; // the stage sits in a wrapper (+ clip panel)
|
||||
Object.defineProperty(card, "scrollHeight", { value: 900, configurable: true });
|
||||
Object.defineProperty(card, "clientHeight", { value: 500, configurable: true });
|
||||
const spin = (el: Element) => {
|
||||
const e = new WheelEvent("wheel", { deltaY: 100, bubbles: true, cancelable: true });
|
||||
el.dispatchEvent(e);
|
||||
return e.defaultPrevented;
|
||||
};
|
||||
expect(spin(screen.getByRole("heading", { name: "Video A" }))).toBe(false);
|
||||
expect(spin(stage)).toBe(true);
|
||||
});
|
||||
|
||||
// A failed load read "No description." in the always-open panel — an error drawn as empty.
|
||||
@@ -591,3 +842,193 @@ describe("PlayerModal — the side panel on a wide window", () => {
|
||||
expect(screen.queryByTestId("player-description")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// Brave on an iPad cannot fullscreen an element (it fakes `fullscreenEnabled`): the docked bar's
|
||||
// true-fullscreen button did nothing there. The window-filling view is then the biggest one.
|
||||
describe("PlayerModal — the docked bar's fullscreen", () => {
|
||||
const maximised = (api_: boolean) => {
|
||||
if (api_)
|
||||
Object.defineProperty(Element.prototype, "requestFullscreen", {
|
||||
configurable: true,
|
||||
value: () => Promise.resolve(),
|
||||
});
|
||||
// Brave's stand-in otherwise: a <video>-only requestFullscreen on HTMLElement.prototype.
|
||||
else
|
||||
Object.defineProperty(HTMLElement.prototype, "requestFullscreen", {
|
||||
configurable: true,
|
||||
value: () => true,
|
||||
});
|
||||
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
|
||||
const A = vid("A", 0);
|
||||
renderModal(
|
||||
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={() => {}} onState={() => {}} />,
|
||||
);
|
||||
fireEvent.click(screen.getByLabelText("player.fullscreen"));
|
||||
return screen.getAllByLabelText("player.fullscreen").length;
|
||||
};
|
||||
afterEach(() => {
|
||||
delete (Element.prototype as { requestFullscreen?: unknown }).requestFullscreen;
|
||||
delete (HTMLElement.prototype as { requestFullscreen?: unknown }).requestFullscreen;
|
||||
});
|
||||
|
||||
it("is offered where the browser can fullscreen an element", () => {
|
||||
expect(maximised(true)).toBe(2);
|
||||
});
|
||||
|
||||
it("is left out where it cannot", () => {
|
||||
expect(maximised(false)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// Clip mode: the panel sits under the video (the video is its preview), Escape closes the panel and
|
||||
// not the player, the scissors gets the focus back; in the maximised view the panel takes the docked
|
||||
// bar's place; a crop frame lies over the video and Escape turns it off before the panel closes.
|
||||
describe("PlayerModal — clip mode", () => {
|
||||
const key = (k: string) =>
|
||||
act(
|
||||
() =>
|
||||
void window.dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key: k, bubbles: true, cancelable: true }),
|
||||
),
|
||||
);
|
||||
const open = (onClose = () => {}) => {
|
||||
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
|
||||
const A = vid("A", 0);
|
||||
renderModal(
|
||||
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={onClose} onState={() => {}} />,
|
||||
);
|
||||
};
|
||||
|
||||
it("opens under the video on C, and Escape closes the panel, not the player", async () => {
|
||||
const onClose = vi.fn();
|
||||
clock.current = 42;
|
||||
open(onClose);
|
||||
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
|
||||
key("c");
|
||||
const panel = screen.getByTestId("clip-panel");
|
||||
const stage = document.querySelector(".player-stage")!;
|
||||
expect(stage.compareDocumentPosition(panel) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
expect(screen.getByRole("slider", { name: "clip.in" }).getAttribute("aria-valuenow")).toBe(
|
||||
"42",
|
||||
);
|
||||
key("Escape");
|
||||
expect(screen.queryByTestId("clip-panel")).toBeNull();
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
await waitFor(() =>
|
||||
expect(document.activeElement).toBe(screen.getByRole("button", { name: "clip.open" })),
|
||||
);
|
||||
});
|
||||
|
||||
it("marks in the maximised view without leaving it: the panel takes the docked bar's place", async () => {
|
||||
clock.current = 10;
|
||||
open();
|
||||
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
|
||||
key("f");
|
||||
const stage = document.querySelector(".player-stage--max")!;
|
||||
expect(stage).toBeTruthy();
|
||||
key("]");
|
||||
expect(document.querySelector(".player-stage--max")).toBe(stage);
|
||||
expect(stage.contains(screen.getByTestId("clip-panel"))).toBe(true);
|
||||
expect(screen.queryByRole("button", { name: "player.exitMaximize" })).toBeNull();
|
||||
expect(screen.getByRole("slider", { name: "clip.out" }).getAttribute("aria-valuenow")).toBe(
|
||||
"10",
|
||||
);
|
||||
// Escape: the panel first, then the maximised view, then (not here) the player.
|
||||
key("Escape");
|
||||
expect(screen.queryByTestId("clip-panel")).toBeNull();
|
||||
expect(document.querySelector(".player-stage--max")).toBe(stage);
|
||||
expect(screen.getByRole("button", { name: "player.exitMaximize" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("draws a short's frame on its 9:16 picture and tells the cut so", async () => {
|
||||
const enqueue = vi.spyOn(api, "enqueueClip").mockResolvedValue({ id: 9 } as never);
|
||||
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
|
||||
const S = { ...vid("S", 0), is_short: true } as Video;
|
||||
renderModal(
|
||||
<PlayerModal video={S} queue={[S]} startIndex={0} onClose={() => {}} onState={() => {}} />,
|
||||
);
|
||||
await waitFor(() => expect(lastStart()?.videoId).toBe("S"));
|
||||
key("c");
|
||||
fireEvent.click(screen.getByRole("button", { name: "clip.crop" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "clip.cut" }));
|
||||
await waitFor(() => expect(enqueue).toHaveBeenCalled());
|
||||
expect(enqueue.mock.calls[0]![0].crop?.aspect).toBe(0.5625);
|
||||
});
|
||||
|
||||
it("lays the crop frame over the video, and Escape turns it off before closing the panel", async () => {
|
||||
// jsdom lays nothing out: the frame's layer measures a 16:9 box.
|
||||
vi.spyOn(Element.prototype, "clientWidth", "get").mockReturnValue(1600);
|
||||
vi.spyOn(Element.prototype, "clientHeight", "get").mockReturnValue(900);
|
||||
open();
|
||||
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
|
||||
key("c");
|
||||
fireEvent.click(screen.getByRole("button", { name: "clip.crop" }));
|
||||
const frame = screen.getByTestId("crop-frame");
|
||||
expect(document.querySelector(".player-stage")!.contains(frame)).toBe(true);
|
||||
act(() => screen.getByRole("group", { name: "common.cropFrame.frame" }).focus());
|
||||
key("Escape");
|
||||
expect(screen.queryByTestId("crop-frame")).toBeNull();
|
||||
expect(screen.getByTestId("clip-panel")).toBeTruthy();
|
||||
// The frame took the focus with it: it is on the Crop toggle, not on <body>.
|
||||
expect(document.activeElement).toBe(screen.getByRole("button", { name: "clip.crop" }));
|
||||
key("Escape");
|
||||
expect(screen.queryByTestId("clip-panel")).toBeNull();
|
||||
});
|
||||
|
||||
it("gives the focus back to the scissors the user can see: in the maximised view, the bar's", async () => {
|
||||
open();
|
||||
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
|
||||
key("f");
|
||||
key("c");
|
||||
key("Escape");
|
||||
const stage = document.querySelector(".player-stage--max")!;
|
||||
await waitFor(() =>
|
||||
expect(document.activeElement?.getAttribute("aria-label")).toBe("clip.open"),
|
||||
);
|
||||
// The card's own scissors lies under the maximised stage: a Space there reopened the panel.
|
||||
expect(stage.contains(document.activeElement)).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps what was typed in the panel when the view is maximised under it", async () => {
|
||||
open();
|
||||
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
|
||||
key("c");
|
||||
fireEvent.change(screen.getByRole("textbox", { name: "clip.name" }), {
|
||||
target: { value: "The bit" },
|
||||
});
|
||||
const loop = screen.getByRole("button", { name: "clip.loop" });
|
||||
fireEvent.click(loop);
|
||||
act(() => loop.focus());
|
||||
const panel = screen.getByTestId("clip-panel");
|
||||
key("f");
|
||||
expect(document.querySelector(".player-stage--max")!.contains(panel)).toBe(true);
|
||||
expect(screen.getByTestId("clip-panel")).toBe(panel);
|
||||
expect((screen.getByRole("textbox", { name: "clip.name" }) as HTMLInputElement).value).toBe(
|
||||
"The bit",
|
||||
);
|
||||
expect(loop.getAttribute("aria-pressed")).toBe("true");
|
||||
expect(document.activeElement).toBe(loop);
|
||||
// And back: the dock the panel sat in goes, the panel and its focus stay.
|
||||
key("f");
|
||||
expect(document.querySelector(".player-stage--max")).toBeNull();
|
||||
expect(screen.getByTestId("clip-panel")).toBe(panel);
|
||||
expect(document.activeElement).toBe(loop);
|
||||
});
|
||||
|
||||
it("does not step on to the next video when one ends while a clip is marked", async () => {
|
||||
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
|
||||
const A = vid("A", 0);
|
||||
const B = vid("B", 0);
|
||||
renderModal(
|
||||
<PlayerModal video={A} queue={[A, B]} startIndex={0} onClose={() => {}} onState={() => {}} />,
|
||||
);
|
||||
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
|
||||
// Auto-advance on: an ending video steps on to B.
|
||||
fireEvent.click(screen.getAllByRole("button", { name: /^player\.autoAdvance\.label/ })[0]!);
|
||||
key("c");
|
||||
act(() => playerEvents.onStateChange?.({ data: 0 }));
|
||||
await settle();
|
||||
expect(lastStart()?.videoId).toBe("A");
|
||||
expect(screen.getByTestId("clip-panel")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -30,7 +30,7 @@ vi.mock("../lib/notifications", async (importOriginal) => ({
|
||||
}));
|
||||
|
||||
import PlexPlaylistView from "./PlexPlaylistView";
|
||||
import AddToPlaylist from "./AddToPlaylist";
|
||||
import AddToPlaylist, { AddToPlaylistAt } from "./AddToPlaylist";
|
||||
import PlexPlaylistAdd from "./PlexPlaylistAdd";
|
||||
import TagManager from "./TagManager";
|
||||
import { api, HttpError, type PlexCard } from "../lib/api";
|
||||
@@ -157,6 +157,64 @@ describe("the add-to-playlist popover", () => {
|
||||
await waitFor(() => expect(lists.mock.calls.length).toBeGreaterThan(1));
|
||||
});
|
||||
|
||||
it("says a failed load of the playlists as one, not as 'no playlists yet'", async () => {
|
||||
vi.spyOn(api, "playlists").mockRejectedValue(new Error("down"));
|
||||
render(
|
||||
<QueryClientProvider client={client()}>
|
||||
<AddToPlaylist videoId="v1" />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
fireEvent.click(screen.getByLabelText("playlists.addToPlaylist"));
|
||||
const alert = await screen.findByRole("alert", { hidden: true });
|
||||
expect(alert.textContent).toContain("common.loadError");
|
||||
expect(screen.queryByText("playlists.noneYet")).toBeNull();
|
||||
});
|
||||
|
||||
it("opened from a card's menu, is a menu named for itself, its lines beside the list", async () => {
|
||||
vi.spyOn(api, "playlists").mockRejectedValue(new Error("down"));
|
||||
const anchor = document.createElement("button");
|
||||
document.body.append(anchor);
|
||||
render(
|
||||
<QueryClientProvider client={client()}>
|
||||
<AddToPlaylistAt videoId="v1" anchor={anchor} onClose={() => {}} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
// The menu row that opened it is gone: its own name, not a label pointing at that row. jsdom
|
||||
// computes no name for the unpositioned surface, so read what names it.
|
||||
const menu = await waitFor(() => {
|
||||
const found = document.querySelector(
|
||||
'[role="menu"][aria-label="playlists.addToPlaylistTitle"]',
|
||||
);
|
||||
expect(found).not.toBeNull();
|
||||
return found!;
|
||||
});
|
||||
expect(menu.hasAttribute("aria-labelledby")).toBe(false);
|
||||
const alert = await screen.findByRole("alert", { hidden: true });
|
||||
expect(alert.closest("[role=menu]")).toBeNull();
|
||||
anchor.remove();
|
||||
});
|
||||
|
||||
it("flips the tick as soon as an add lands, so the next tap takes it out again", async () => {
|
||||
const lists = vi.spyOn(api, "playlists");
|
||||
lists.mockResolvedValueOnce([{ id: 3, name: "Mix", kind: "user", has_video: false }] as never);
|
||||
// The refetch after the write never answers: the tick must not wait for it.
|
||||
lists.mockImplementation(() => new Promise(() => {}));
|
||||
const add = vi.spyOn(api, "addToPlaylist").mockResolvedValue({} as never);
|
||||
const remove = vi.spyOn(api, "removeFromPlaylist").mockResolvedValue({} as never);
|
||||
render(
|
||||
<QueryClientProvider client={client()}>
|
||||
<AddToPlaylist videoId="v1" />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
fireEvent.click(screen.getByLabelText("playlists.addToPlaylist"));
|
||||
const row = await screen.findByRole("menuitemcheckbox", { hidden: true });
|
||||
fireEvent.click(row);
|
||||
await waitFor(() => expect(row.getAttribute("aria-checked")).toBe("true"));
|
||||
fireEvent.click(row);
|
||||
await waitFor(() => expect(remove).toHaveBeenCalledWith(3, "v1"));
|
||||
expect(add).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("says so on the PLEX side too, where the same sweep had missed the dialog", async () => {
|
||||
// Same shape, same 404, same silence — `req` leaves 403/404/429 to the caller, and this one had
|
||||
// no catch: the tick did not flip, nothing was said, the gone row stayed, and the floating
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// A show's card in the Browse grid plays (the owner, 2026-10-09): its picture asks the server which
|
||||
// episode and opens the player on it; a started show's Restart opens it from the start; its title
|
||||
// opens the show's page. The hook is tested on its own (usePlayShow); this is the grid wiring it.
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("react-i18next")>()),
|
||||
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
||||
}));
|
||||
vi.mock("./PlexPlayer", () => ({
|
||||
default: ({ itemId, fromStart }: { itemId: string; fromStart?: boolean }) => (
|
||||
<div>
|
||||
player {itemId} {fromStart ? "from the start" : "resumed"}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
vi.mock("./plexViews", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("./plexViews")>()),
|
||||
PlexShowView: ({ showId }: { showId: string }) => <div>show page {showId}</div>,
|
||||
PlexInfoView: ({ id }: { id: string }) => <div>info page {id}</div>,
|
||||
}));
|
||||
|
||||
import PlexBrowse from "./PlexBrowse";
|
||||
import { api, type PlexCard } from "../lib/api";
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
||||
});
|
||||
beforeEach(() => window.history.replaceState({ _sub: { kind: "grid" } }, ""));
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const show: PlexCard = {
|
||||
id: "sh",
|
||||
type: "show",
|
||||
title: "Grimm",
|
||||
thumb: "",
|
||||
status: "in_progress",
|
||||
resumable: true,
|
||||
};
|
||||
|
||||
function mount(episodes: PlexCard[] = []) {
|
||||
vi.spyOn(api, "plexLibrary").mockResolvedValue({
|
||||
items: [show],
|
||||
total: 1,
|
||||
offset: 0,
|
||||
episodes,
|
||||
} as never);
|
||||
const next = vi.spyOn(api, "plexShowNext").mockResolvedValue({
|
||||
next: { id: "e7", type: "episode", title: "Ep 7", thumb: "" },
|
||||
});
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<PlexBrowse />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
return { next };
|
||||
}
|
||||
|
||||
describe("PlexBrowse — a show's card in the grid", () => {
|
||||
it("plays the episode the server names", async () => {
|
||||
const { next } = mount();
|
||||
fireEvent.click(await screen.findByRole("button", { name: "card.continue" }));
|
||||
expect(await screen.findByText("player e7 resumed")).toBeTruthy();
|
||||
expect(next).toHaveBeenCalledWith("sh", "en");
|
||||
});
|
||||
|
||||
it("starts that episode over from its Restart", async () => {
|
||||
mount();
|
||||
fireEvent.click(await screen.findByRole("button", { name: "card.restart" }));
|
||||
expect(await screen.findByText("player e7 from the start")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("opens the show's page from its title, asking nothing", async () => {
|
||||
const { next } = mount();
|
||||
fireEvent.click(await screen.findByText("Grimm"), { detail: 1 });
|
||||
expect(await screen.findByText("show page sh")).toBeTruthy();
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// A matched episode's title opens its page, a pointer's door: its menu offers the same to the keyboard.
|
||||
it("opens a matched episode's page from its menu, as its title does", async () => {
|
||||
mount([{ id: "ep1", type: "episode", title: "Pilot", thumb: "", show_title: "Grimm" }]);
|
||||
const section = (await screen.findByText("plex.unified.episodes")).parentElement!;
|
||||
fireEvent.click(section.querySelector<HTMLButtonElement>("button[aria-haspopup]")!);
|
||||
const rows = await screen.findAllByRole("menuitem", { hidden: true });
|
||||
fireEvent.click(rows.find((r) => r.textContent === "plex.info.title")!);
|
||||
expect(await screen.findByText("info page ep1")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("opens the show's page from its ⓘ, asking nothing", async () => {
|
||||
const { next } = mount();
|
||||
fireEvent.click(await screen.findByRole("button", { name: "plex.openShow" }));
|
||||
expect(await screen.findByText("show page sh")).toBeTruthy();
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -26,6 +26,7 @@ import { useDebounced } from "../lib/useDebounced";
|
||||
import { useHistorySubview } from "../lib/history";
|
||||
import { PLEX_SUB_URL, type Sub } from "./plexSubUrl";
|
||||
import { toggleWatchState } from "../lib/plexWatch";
|
||||
import { usePlayShow } from "./usePlayShow";
|
||||
import SortControl from "./ui/SortControl";
|
||||
import FilterSelect from "./ui/FilterSelect";
|
||||
import PlexDiscover from "./PlexDiscover";
|
||||
@@ -328,18 +329,32 @@ export default function PlexBrowse() {
|
||||
return () => io.disconnect();
|
||||
}, [sentinelEl, hasNextPage, isFetching, fetchNextPage]);
|
||||
|
||||
const resolveShow = usePlayShow(
|
||||
(id, fromStart) => sub.open({ kind: "player", id, fromStart }),
|
||||
(id) => sub.open({ kind: "show", id }),
|
||||
sub.view,
|
||||
);
|
||||
// A show's card plays the episode it resolves to (usePlayShow); the grid's place is kept first.
|
||||
function playShow(card: PlexCard, fromStart: boolean) {
|
||||
scrollRef.current = scroller()?.scrollTop ?? 0;
|
||||
void resolveShow(card, fromStart);
|
||||
}
|
||||
function onCard(card: PlexCard) {
|
||||
if (card.type === "show") return playShow(card, false);
|
||||
scrollRef.current = scroller()?.scrollTop ?? 0; // remember grid position for the trip back
|
||||
if (card.type === "show") sub.open({ kind: "show", id: card.id });
|
||||
else sub.open({ kind: "player", id: card.id });
|
||||
sub.open({ kind: "player", id: card.id });
|
||||
}
|
||||
// The started card's Restart (C5): the same trip, from 0 instead of the resume point.
|
||||
function onRestartCard(card: PlexCard) {
|
||||
if (card.type === "show") return playShow(card, true);
|
||||
scrollRef.current = scroller()?.scrollTop ?? 0;
|
||||
sub.open({ kind: "player", id: card.id, fromStart: true });
|
||||
}
|
||||
// A card's page: a show's own page, a movie's or an episode's info page (the card menu's "Details",
|
||||
// and a card title's link).
|
||||
function onInfo(card: PlexCard) {
|
||||
scrollRef.current = scroller()?.scrollTop ?? 0;
|
||||
if (card.type === "show") return void sub.open({ kind: "show", id: card.id });
|
||||
rememberInfoScroll(0); // fresh info page starts at the top
|
||||
sub.open({ kind: "info", id: card.id });
|
||||
}
|
||||
@@ -377,8 +392,9 @@ export default function PlexBrowse() {
|
||||
{ onOpen: onCard, onRestart: onRestartCard, onInfo, onToggleWatched: toggleWatched },
|
||||
menu.open,
|
||||
);
|
||||
// Details too: an episode's title opens its page, and the menu is that door for the keyboard.
|
||||
const epActions = usePlexCardActions(
|
||||
{ onOpen: onCard, onRestart: onRestartCard, onToggleWatched: toggleWatched },
|
||||
{ onOpen: onCard, onRestart: onRestartCard, onInfo, onToggleWatched: toggleWatched },
|
||||
epMenu.open,
|
||||
);
|
||||
// Apply a metadata filter (clicked on an info page / show hero / cast) then show the unified grid.
|
||||
@@ -461,6 +477,20 @@ export default function PlexBrowse() {
|
||||
onPlayItem={(id, fromStart) => sub.open({ kind: "player", id, fromStart })}
|
||||
onFilter={applyFilter}
|
||||
onOpenPerson={onOpenPerson}
|
||||
// Up to the show or season: Back returns to this page where it was (as from a cast row).
|
||||
onOpenShow={(showId) => {
|
||||
rememberInfoScroll(scroller()?.scrollTop ?? 0);
|
||||
sub.open({ kind: "show", id: showId });
|
||||
}}
|
||||
onOpenSeason={(showId, seasonId) => {
|
||||
rememberInfoScroll(scroller()?.scrollTop ?? 0);
|
||||
sub.open({ kind: "season", showId, seasonId });
|
||||
}}
|
||||
// A collection strip's title: another info page, from its top.
|
||||
onOpenInfo={(id) => {
|
||||
rememberInfoScroll(0);
|
||||
sub.open({ kind: "info", id });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -492,6 +522,7 @@ export default function PlexBrowse() {
|
||||
}
|
||||
onOpenSeason={(seasonId) => sub.open({ kind: "season", showId, seasonId })}
|
||||
onOpenShow={(id) => sub.open({ kind: "show", id })}
|
||||
onPlayShow={playShow}
|
||||
onFilter={applyFilter}
|
||||
onOpenPerson={onOpenPerson}
|
||||
/>
|
||||
@@ -504,6 +535,7 @@ export default function PlexBrowse() {
|
||||
showId={showId}
|
||||
seasonId={seasonId}
|
||||
onBack={sub.back}
|
||||
onInfoCard={onInfo}
|
||||
onPlay={(epRk, queue, fromStart) =>
|
||||
sub.open({ kind: "player", id: epRk, queue, fromStart })
|
||||
}
|
||||
@@ -707,6 +739,7 @@ export default function PlexBrowse() {
|
||||
navIndex={i}
|
||||
card={c}
|
||||
onOpen={() => onCard(c)}
|
||||
onPage={onInfo}
|
||||
onRestart={() => onRestartCard(c)}
|
||||
onToggleWatched={toggleWatched}
|
||||
onAdd={actions.add}
|
||||
@@ -730,6 +763,7 @@ export default function PlexBrowse() {
|
||||
ep={ep}
|
||||
withShowTitle
|
||||
onPlay={() => onCard(ep)}
|
||||
onPage={() => onInfo(ep)}
|
||||
onRestart={() => onRestartCard(ep)}
|
||||
onToggleWatched={() => toggleWatched(ep)}
|
||||
onAdd={() => epActions.add(ep)}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { useLayoutEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { api, HttpError, type PlexCollection } from "../lib/api";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { PopoverSurface } from "./ui/AnchoredPopover";
|
||||
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
|
||||
import { MembershipMenu } from "./ui/MembershipMenu";
|
||||
import { SURFACE } from "./ui/surfaces";
|
||||
|
||||
// "Add to collection…" from a movie or show card's menu: a panel beside the card that ticks the title
|
||||
// into or out of its library's manual collections, and names a new one — created with this title in
|
||||
// it, in one step (a collection cannot be empty on Plex). It was a trip to the Collections page, a new
|
||||
// collection, its name, a search for the title, then another search to add the next one. Its body is
|
||||
// the same MembershipMenu as the video card's "Add to playlist".
|
||||
|
||||
export interface PlexCollectionTarget {
|
||||
ratingKey: string;
|
||||
anchor: Element;
|
||||
}
|
||||
|
||||
export default function PlexCollectionAddAt({
|
||||
target,
|
||||
onClose,
|
||||
}: {
|
||||
target: PlexCollectionTarget;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const pop = useAnchoredPopover({
|
||||
open: true,
|
||||
onOpenChange: (next) => {
|
||||
if (!next) onClose();
|
||||
},
|
||||
offset: 6,
|
||||
hostsInput: true,
|
||||
});
|
||||
const { setPositionReference } = pop.refs;
|
||||
useLayoutEffect(() => setPositionReference(target.anchor), [target.anchor, setPositionReference]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const { ratingKey } = target;
|
||||
const forKey = qk.plexCollections("for", ratingKey);
|
||||
|
||||
const list = useQuery({
|
||||
queryKey: forKey,
|
||||
queryFn: () => api.plexCollectionsFor(ratingKey),
|
||||
});
|
||||
|
||||
// The title's own page (a movie's item, a show's show), the collections' pages and the library grid
|
||||
// all show membership.
|
||||
function refresh() {
|
||||
void qc.invalidateQueries({ queryKey: qk.plexCollections() });
|
||||
void qc.invalidateQueries({ queryKey: qk.plexItem(ratingKey) });
|
||||
void qc.invalidateQueries({ queryKey: qk.plexShow(ratingKey) });
|
||||
void qc.invalidateQueries({ queryKey: qk.plexLibrary() });
|
||||
}
|
||||
|
||||
// A write that landed flips the tick at once: the row's state comes from the list, and a second tap
|
||||
// before its refetch returned would otherwise repeat the write instead of undoing it.
|
||||
async function toggle(c: PlexCollection & { contains: boolean }) {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
if (c.contains) await api.plexCollectionRemoveItem(c.id, ratingKey);
|
||||
else await api.plexCollectionAddItem(c.id, ratingKey);
|
||||
qc.setQueryData<typeof list.data>(forKey, (old) => flipContains(old, c.id));
|
||||
} catch (e) {
|
||||
notifyActionError(e, t("plex.collection.editFailed"));
|
||||
}
|
||||
refresh();
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
async function create(name: string) {
|
||||
if (busy) return false;
|
||||
setBusy(true);
|
||||
let made = false;
|
||||
try {
|
||||
// No library: the server takes the seed title's own.
|
||||
await api.plexCreateCollection("", name, ratingKey);
|
||||
made = true;
|
||||
} catch (e) {
|
||||
made = madeOnPlex(e);
|
||||
notifyActionError(e, t("plex.collection.editFailed"));
|
||||
}
|
||||
refresh();
|
||||
setBusy(false);
|
||||
return made;
|
||||
}
|
||||
|
||||
const collections = list.data?.collections ?? [];
|
||||
return (
|
||||
<PopoverSurface
|
||||
popover={pop}
|
||||
className={`${SURFACE.popover} w-64 p-2`}
|
||||
ariaLabel={t("plex.collectionAdd.title")}
|
||||
listInside
|
||||
>
|
||||
<MembershipMenu
|
||||
heading={t("plex.collectionAdd.title")}
|
||||
rows={collections.map((c) => ({ id: c.id, label: c.title, checked: c.contains }))}
|
||||
onToggle={(row) => {
|
||||
const c = collections.find((x) => x.id === row.id);
|
||||
if (c) void toggle(c);
|
||||
}}
|
||||
status={list.isError && !list.data ? "error" : list.isLoading ? "loading" : "ready"}
|
||||
onRetry={() => void list.refetch()}
|
||||
emptyText={t("plex.collectionAdd.none")}
|
||||
errorText={t("plex.collectionAdd.loadFailed")}
|
||||
busy={busy}
|
||||
create={{
|
||||
placeholder: t("plex.collection.newCollection"),
|
||||
ariaLabel: t("plex.collection.namePlaceholder"),
|
||||
onCreate: create,
|
||||
}}
|
||||
/>
|
||||
</PopoverSurface>
|
||||
);
|
||||
}
|
||||
|
||||
/** A create refused only because the read after it failed: the collection is on Plex, with this
|
||||
* title in it, and its row is kept — the name is taken now, so the field clears as on success. */
|
||||
function madeOnPlex(e: unknown): boolean {
|
||||
const d = e instanceof HttpError ? (e.detailData as { reason?: string } | undefined) : undefined;
|
||||
return d?.reason === "collection_pending_sync";
|
||||
}
|
||||
|
||||
function flipContains<T extends { collections: { id: string; contains: boolean }[] }>(
|
||||
old: T | undefined,
|
||||
id: string,
|
||||
): T | undefined {
|
||||
if (!old) return old;
|
||||
return {
|
||||
...old,
|
||||
collections: old.collections.map((c) => (c.id === id ? { ...c, contains: !c.contains } : c)),
|
||||
};
|
||||
}
|
||||
@@ -431,6 +431,7 @@ export default function PlexCollectionView({
|
||||
card={c}
|
||||
navIndex={i}
|
||||
onOpen={() => onOpenCard(c)}
|
||||
onPage={onInfoCard}
|
||||
onRestart={() => onRestartCard(c)}
|
||||
onToggleWatched={toggleWatched}
|
||||
onAdd={actions.add}
|
||||
|
||||
@@ -57,6 +57,8 @@ vi.mock("../lib/api", () => ({
|
||||
}));
|
||||
|
||||
import PlexDiscover from "./PlexDiscover";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import type { PlexCard } from "../lib/api";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
@@ -157,7 +159,8 @@ describe("PlexDiscover", () => {
|
||||
expect.objectContaining({ genres: [] }),
|
||||
]);
|
||||
expect(keys).toContainEqual([
|
||||
"plex-surprise",
|
||||
"plex-discover",
|
||||
"surprise",
|
||||
"both",
|
||||
"en",
|
||||
expect.objectContaining({ genres: [] }),
|
||||
@@ -218,6 +221,103 @@ describe("PlexDiscover", () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "plex.discover.reshuffle" }));
|
||||
await waitFor(() => expect(plexSurprise).toHaveBeenCalledTimes(2));
|
||||
expect(plexDiscover).toHaveBeenCalledTimes(1); // the whole surface was NOT refetched
|
||||
// The first draw asks for anything; the redraw hands over the one on screen to deal it last.
|
||||
expect(plexSurprise.mock.calls[0]?.[4]).toEqual({ exclude: [] });
|
||||
expect(plexSurprise.mock.calls[1]?.[4]).toEqual({ exclude: ["sp1"] });
|
||||
});
|
||||
|
||||
// Back from a card's player or page remounts the surface: the draw it left must still be there, with
|
||||
// its cards' watch state as it is now. A refetch reads the same titles again; only ↻ deals.
|
||||
it("keeps its draw across a remount, a refresh and a long absence; only ↻ redraws", async () => {
|
||||
plexDiscover.mockResolvedValue(fullPayload);
|
||||
let status: PlexCard["status"] = "new";
|
||||
plexSurprise.mockImplementation(
|
||||
(_s: string, _l: unknown, _f: unknown, _lang: string, o: { keep?: string[] }) =>
|
||||
Promise.resolve({
|
||||
scope: "both",
|
||||
items: o.keep?.length
|
||||
? [{ ...card("sp1", "Random Pick"), status }]
|
||||
: [card(plexSurprise.mock.calls.length > 1 ? "sp2" : "sp1", "Pick")],
|
||||
}),
|
||||
);
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const ui = (
|
||||
<QueryClientProvider client={qc}>
|
||||
<PlexDiscover
|
||||
scope="both"
|
||||
onOpenCard={noop}
|
||||
onRestartCard={() => {}}
|
||||
onInfoCard={noop}
|
||||
onGenre={noop}
|
||||
onSeeAllRecent={noop}
|
||||
/>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
const first = render(ui);
|
||||
await screen.findByText("Pick");
|
||||
first.unmount();
|
||||
render(ui);
|
||||
await screen.findByText("Random Pick");
|
||||
// Watched from somewhere else: whatever refreshes the rails refreshes the draw's cards too.
|
||||
status = "watched";
|
||||
await act(() => qc.invalidateQueries({ queryKey: qk.plexDiscover() }));
|
||||
const [entry] = qc.getQueryCache().findAll({ queryKey: qk.plexSurprise() });
|
||||
expect(entry?.state.data).toMatchObject({ items: [{ id: "sp1", status: "watched" }] });
|
||||
// No draw is ever dealt behind the viewer's back: every call after the first reads it again.
|
||||
for (const call of plexSurprise.mock.calls.slice(1)) expect(call[4]).toEqual({ keep: ["sp1"] });
|
||||
// Unobserved past the default five minutes (a film watched from the rail) it is still kept.
|
||||
expect(entry?.options.gcTime).toBe(Infinity);
|
||||
fireEvent.click(screen.getByRole("button", { name: "plex.discover.reshuffle" }));
|
||||
await screen.findByText("Pick");
|
||||
expect(plexSurprise.mock.calls[plexSurprise.mock.calls.length - 1]?.[4]).toEqual({
|
||||
exclude: ["sp1"],
|
||||
});
|
||||
});
|
||||
|
||||
// A watched toggle's resync refreshes every rail, the Surprise draw included. Landing while a ↻
|
||||
// deal was in flight, it cancelled that fetch and re-ran it as a re-read: the old titles came back
|
||||
// and the ↻ did nothing. And the ↻ spins for a deal only, never for a re-read of the same titles.
|
||||
it("a refresh landing mid-deal deals again; only a deal spins the ↻", async () => {
|
||||
plexDiscover.mockResolvedValue(fullPayload);
|
||||
const answers: ((v: unknown) => void)[] = [];
|
||||
const rereads: ((v: unknown) => void)[] = [];
|
||||
plexSurprise.mockImplementation(
|
||||
(_s: string, _l: unknown, _f: unknown, _lang: string, o: { keep?: string[] }) =>
|
||||
plexSurprise.mock.calls.length === 1
|
||||
? Promise.resolve({ scope: "both", items: [card("sp1", "Old Pick")] })
|
||||
: new Promise((r) => (o.keep ? rereads : answers).push(r)),
|
||||
);
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<PlexDiscover
|
||||
scope="both"
|
||||
onOpenCard={noop}
|
||||
onRestartCard={() => {}}
|
||||
onInfoCard={noop}
|
||||
onGenre={noop}
|
||||
onSeeAllRecent={noop}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
await screen.findByText("Old Pick");
|
||||
const button = () => screen.getByRole("button", { name: "plex.discover.reshuffle" });
|
||||
act(() => void qc.invalidateQueries({ queryKey: qk.plexDiscover() }));
|
||||
await waitFor(() => expect(rereads).toHaveLength(1));
|
||||
expect(button().getAttribute("aria-busy")).toBeNull();
|
||||
await act(async () => rereads[0]?.({ scope: "both", items: [card("sp1", "Old Pick")] }));
|
||||
fireEvent.click(button());
|
||||
expect(button().getAttribute("aria-busy")).toBe("true");
|
||||
act(() => void qc.invalidateQueries({ queryKey: qk.plexDiscover() }));
|
||||
await waitFor(() => expect(answers).toHaveLength(2));
|
||||
const last = plexSurprise.mock.calls[plexSurprise.mock.calls.length - 1];
|
||||
expect(last?.[4]).toEqual({ exclude: ["sp1"] });
|
||||
// The cancelled deal's late answer neither lands nor stops the spinner of the one replacing it.
|
||||
await act(async () => answers[0]?.({ scope: "both", items: [card("sp9", "Stale Pick")] }));
|
||||
expect(button().getAttribute("aria-busy")).toBe("true");
|
||||
await act(async () => answers[1]?.({ scope: "both", items: [card("sp2", "New Pick")] }));
|
||||
await screen.findByText("New Pick");
|
||||
expect(button().getAttribute("aria-busy")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows the empty message when every rail is empty", async () => {
|
||||
@@ -244,8 +344,8 @@ describe("PlexDiscover", () => {
|
||||
});
|
||||
|
||||
it("puts a Surprise card back when its watched toggle fails", async () => {
|
||||
// The resync refetches the rails but not the Surprise draw (that would reshuffle it), so a
|
||||
// refused toggle stayed flipped there — the sibling of the collection page's fix.
|
||||
// The resync re-reads the Surprise draw too, but a refused toggle is put back at once rather than
|
||||
// left flipped until that read lands — the sibling of the collection page's fix.
|
||||
plexDiscover.mockResolvedValue({
|
||||
...fullPayload,
|
||||
continue_watching: [],
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type ReactNode } from "react";
|
||||
import { useRef, useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { RotateCcw } from "lucide-react";
|
||||
@@ -78,6 +78,7 @@ function CardRail({
|
||||
card={c}
|
||||
navIndex={i}
|
||||
onOpen={() => onOpenCard(c)}
|
||||
onPage={onInfoCard}
|
||||
onRestart={() => onRestartCard(c)}
|
||||
onToggleWatched={onToggleWatched}
|
||||
onAdd={actions.add}
|
||||
@@ -119,11 +120,46 @@ export default function PlexDiscover({
|
||||
queryFn: () => api.plexDiscover(scope, lang, filters),
|
||||
});
|
||||
// Surprise-me is its own query so the ↻ button can redraw it without refetching the whole surface.
|
||||
// A draw stands until the ↻ (or another scope, language or filter) asks for a new one: redrawn on
|
||||
// every refetch, the card just looked at was gone from its rail on Back from the player or a show
|
||||
// page. So a refetch reads the draw on screen again (`keep`: fresh watch state, same titles), and
|
||||
// only the ↻ deals — handing over that draw, so a small filtered pool deals its other titles first.
|
||||
const surpriseKey = qk.plexSurprise(scope, lang, filters);
|
||||
// The ↻ stands until ITS deal answers, not until the fetch sets out: a refresh of the rails landing
|
||||
// mid-deal (a watched toggle's resync) cancels that fetch and runs this one again, which read the
|
||||
// spent flag and handed back the old draw. Only the latest deal ends it, so the cancelled one's late
|
||||
// answer does not stop the spinner of the deal that replaced it.
|
||||
const redraw = useRef(false);
|
||||
const lastDeal = useRef<Promise<unknown> | null>(null);
|
||||
const [dealing, setDealing] = useState(false);
|
||||
const surpriseQ = useQuery({
|
||||
queryKey: qk.plexSurprise(scope, lang, filters),
|
||||
queryFn: () => api.plexSurprise(scope, undefined, filters, lang),
|
||||
staleTime: 0,
|
||||
queryKey: surpriseKey,
|
||||
queryFn: () => {
|
||||
const shown = (qc.getQueryData<{ items: PlexCard[] }>(surpriseKey)?.items ?? []).map(
|
||||
(c) => c.id,
|
||||
);
|
||||
if (!redraw.current && shown.length > 0) {
|
||||
return api.plexSurprise(scope, undefined, filters, lang, { keep: shown });
|
||||
}
|
||||
const deal = api.plexSurprise(scope, undefined, filters, lang, { exclude: shown });
|
||||
lastDeal.current = deal;
|
||||
const done = () => {
|
||||
if (lastDeal.current !== deal) return;
|
||||
redraw.current = false;
|
||||
setDealing(false);
|
||||
};
|
||||
void deal.then(done, done);
|
||||
return deal;
|
||||
},
|
||||
// Kept past the default five minutes unobserved: a film watched from the rail outlasts that, and
|
||||
// a collected draw is a new one.
|
||||
gcTime: Infinity,
|
||||
});
|
||||
const reshuffle = () => {
|
||||
redraw.current = true;
|
||||
setDealing(true);
|
||||
void surpriseQ.refetch();
|
||||
};
|
||||
|
||||
// Watched toggle over BOTH this surface's cache (so a rail card flips instantly) and the surprise
|
||||
// list, then persist + resync via the shared helper — invalidating the grid too so it agrees.
|
||||
@@ -154,8 +190,8 @@ export default function PlexDiscover({
|
||||
);
|
||||
setSurpriseStatus(card, next);
|
||||
},
|
||||
// The resync below leaves the Surprise rail alone (a refetch would reshuffle it), so a failed
|
||||
// toggle was left flipped there — put back in place instead.
|
||||
// The resync below re-reads the Surprise draw too, but only once the write has answered: a
|
||||
// refused toggle is put back at once rather than left flipped until that read lands.
|
||||
revert: () => setSurpriseStatus(card, card.status),
|
||||
resync: () => {
|
||||
qc.invalidateQueries({ queryKey: qk.plexDiscover() });
|
||||
@@ -228,8 +264,10 @@ export default function PlexDiscover({
|
||||
cards={surprise}
|
||||
action={
|
||||
<IconButton
|
||||
onClick={() => surpriseQ.refetch()}
|
||||
loading={surpriseQ.isFetching}
|
||||
onClick={reshuffle}
|
||||
// A deal, not any fetch: a refresh re-reads the same titles, and a spinning ↻ promised a
|
||||
// new draw that never came.
|
||||
loading={dealing}
|
||||
label={t("plex.discover.reshuffle")}
|
||||
>
|
||||
<RotateCcw />
|
||||
|
||||
@@ -173,6 +173,55 @@ describe("PlexInfo enriched detail (page)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("an episode page links up to its show and season", () => {
|
||||
const episode: PlexItemDetail = {
|
||||
...detail,
|
||||
id: "e1",
|
||||
kind: "episode",
|
||||
title: "Pilot",
|
||||
show_title: "A Show",
|
||||
show_id: "s1",
|
||||
season_id: "se1",
|
||||
season_number: 1,
|
||||
episode_number: 25,
|
||||
tmdb: null,
|
||||
};
|
||||
|
||||
it("the show title opens the show, the episode number its season", () => {
|
||||
const onOpenShow = vi.fn();
|
||||
const onOpenSeason = vi.fn();
|
||||
wrap(
|
||||
<PlexInfo
|
||||
onPlayItem={noop}
|
||||
detail={episode}
|
||||
variant="page"
|
||||
onOpenShow={onOpenShow}
|
||||
onOpenSeason={onOpenSeason}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "A Show" }));
|
||||
expect(onOpenShow).toHaveBeenCalledWith("s1");
|
||||
fireEvent.click(screen.getByRole("button", { name: "S1·E25" }));
|
||||
expect(onOpenSeason).toHaveBeenCalledWith("s1", "se1");
|
||||
expect(screen.getByText(/— Pilot/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("over the running player the header stays plain text", () => {
|
||||
wrap(
|
||||
<PlexInfo
|
||||
onPlayItem={noop}
|
||||
detail={episode}
|
||||
variant="overlay"
|
||||
onOpenShow={noop}
|
||||
onOpenSeason={noop}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByRole("button", { name: "A Show" })).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "S1·E25" })).toBeNull();
|
||||
expect(screen.getByText("S1·E25 — Pilot")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// The owner's layout: a main column (collection strips → crew → cast) beside a sidebar of
|
||||
// facts (details, keywords); a column with nothing in it is not rendered and the other takes the width.
|
||||
// The owner's pick: the hero's text stopped at ~55% of the panel. The facts and the key crew are
|
||||
@@ -362,4 +411,18 @@ describe("a collection strip's card", () => {
|
||||
fireEvent.click(restart.find((r) => r.textContent === "card.restartTitle")!);
|
||||
await vi.waitFor(() => expect(onPlayItem).toHaveBeenCalledWith("m2", true));
|
||||
});
|
||||
|
||||
// Its title opens the member's page, a pointer's door: the menu offers the same to the keyboard.
|
||||
it("opens the member's page from its menu, as its title does", async () => {
|
||||
const onOpenInfo = vi.fn();
|
||||
wrap(
|
||||
<PlexInfo detail={withColl} variant="page" onPlayItem={vi.fn()} onOpenInfo={onOpenInfo} />,
|
||||
);
|
||||
const strip = screen.getByRole("heading", { name: "The Collection" }).parentElement!
|
||||
.parentElement!;
|
||||
fireEvent.click(strip.querySelector<HTMLButtonElement>("button[aria-haspopup]")!);
|
||||
const rows = await screen.findAllByRole("menuitem", { hidden: true });
|
||||
fireEvent.click(rows.find((r) => r.textContent === "plex.info.title")!);
|
||||
await vi.waitFor(() => expect(onOpenInfo).toHaveBeenCalledWith("m2"));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,8 @@ import { DetailAside, DetailAsideToggles, DetailKeywords } from "./plexDetailAsi
|
||||
import {
|
||||
DETAIL_POSTER,
|
||||
episodeLine,
|
||||
episodeName,
|
||||
episodeNumbers,
|
||||
tmdbBackdropUrl,
|
||||
useCrossfadeBackdrop,
|
||||
useDetailAside,
|
||||
@@ -186,6 +188,11 @@ type Props = {
|
||||
onPlayItem: (id: string, fromStart?: boolean) => void;
|
||||
// The item's library key — enables the admin "Manage collections" editor (page variant, movies).
|
||||
library?: string;
|
||||
// Page variant, an episode: its show title opens the show page, its "S1·E25" the season page.
|
||||
onOpenShow?: (showId: string) => void;
|
||||
onOpenSeason?: (showId: string, seasonId: string) => void;
|
||||
// Page variant: a collection strip's title opens that title's info page (its picture plays it).
|
||||
onOpenInfo?: (id: string) => void;
|
||||
};
|
||||
|
||||
export default function PlexInfo({
|
||||
@@ -198,6 +205,9 @@ export default function PlexInfo({
|
||||
onOpenPerson,
|
||||
onPlayItem,
|
||||
library,
|
||||
onOpenShow,
|
||||
onOpenSeason,
|
||||
onOpenInfo,
|
||||
}: Props) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
@@ -275,10 +285,13 @@ export default function PlexInfo({
|
||||
qc.invalidateQueries({ queryKey: qk.plexLibrary() });
|
||||
},
|
||||
});
|
||||
// Details wherever the member's title opens its page (not in the overlay): the menu is that door
|
||||
// for the keyboard.
|
||||
const colActions = usePlexCardActions(
|
||||
{
|
||||
onOpen: (c) => onPlayItem(c.id),
|
||||
onRestart: (c) => onPlayItem(c.id, true),
|
||||
onInfo: variant !== "overlay" && onOpenInfo ? (c) => onOpenInfo(c.id) : undefined,
|
||||
onToggleWatched: toggleColWatched,
|
||||
},
|
||||
colMenu.open,
|
||||
@@ -299,6 +312,12 @@ export default function PlexInfo({
|
||||
const asideShown = !overlay && aside.column;
|
||||
const infoTitle =
|
||||
detail.kind === "episode" && detail.show_title ? detail.show_title : detail.title;
|
||||
// Up from an episode: the show's title and the episode's season are links on the page (not over
|
||||
// the running player, where leaving would stop it).
|
||||
const showId = detail.kind === "episode" && !overlay ? detail.show_id : null;
|
||||
const seasonId = detail.season_id;
|
||||
const numbers = episodeNumbers(detail);
|
||||
const upLink = "hover:text-accent hover:underline";
|
||||
|
||||
return (
|
||||
<div className={overlay ? "relative w-full max-w-3xl" : "relative glass-media"}>
|
||||
@@ -312,11 +331,32 @@ export default function PlexInfo({
|
||||
{overlay ? (
|
||||
<h2 className="text-lg font-bold text-white">{infoTitle}</h2>
|
||||
) : (
|
||||
<PageHeading className="text-2xl font-bold">{infoTitle}</PageHeading>
|
||||
<PageHeading className="text-2xl font-bold">
|
||||
{showId && onOpenShow && detail.show_title ? (
|
||||
<Pressable selectable className={upLink} onClick={() => onOpenShow(showId)}>
|
||||
{infoTitle}
|
||||
</Pressable>
|
||||
) : (
|
||||
infoTitle
|
||||
)}
|
||||
</PageHeading>
|
||||
)}
|
||||
{detail.kind === "episode" && (
|
||||
<p className={`text-sm ${overlay ? "text-white/70" : "text-muted"}`}>
|
||||
{episodeLine(detail)}
|
||||
{showId && seasonId && onOpenSeason && numbers ? (
|
||||
<>
|
||||
<Pressable
|
||||
selectable
|
||||
className={upLink}
|
||||
onClick={() => onOpenSeason(showId, seasonId)}
|
||||
>
|
||||
{episodeName(detail)}
|
||||
</Pressable>
|
||||
{` — ${detail.title}`}
|
||||
</>
|
||||
) : (
|
||||
episodeLine(detail)
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -468,7 +508,7 @@ export default function PlexInfo({
|
||||
|
||||
{detail.summary && (
|
||||
<p
|
||||
className={`measure mt-3 text-sm leading-relaxed ${overlay ? "text-white/85 line-clamp-5" : ""}`}
|
||||
className={`measure mt-3 whitespace-pre-line text-sm leading-relaxed ${overlay ? "text-white/85 line-clamp-5" : ""}`}
|
||||
>
|
||||
{detail.summary}
|
||||
</p>
|
||||
@@ -576,6 +616,7 @@ export default function PlexInfo({
|
||||
shelf
|
||||
navIndex={i}
|
||||
onOpen={() => onPlayItem(m.id)}
|
||||
onPage={!overlay && onOpenInfo ? (c) => onOpenInfo(c.id) : undefined}
|
||||
onRestart={() => onPlayItem(m.id, true)}
|
||||
onToggleWatched={toggleColWatched}
|
||||
onAdd={colActions.add}
|
||||
|
||||
@@ -8,7 +8,8 @@ import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { FloatingTree } from "@floating-ui/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import type { PlexCard } from "../lib/api";
|
||||
import { api, HttpError, type PlexCard, type PlexCollection } from "../lib/api";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
@@ -101,21 +102,27 @@ function Grid({
|
||||
);
|
||||
}
|
||||
|
||||
function setup(c: PlexCard, opts: Omit<Parameters<typeof Grid>[0], "c" | "handlers"> = {}) {
|
||||
function setup(
|
||||
c: PlexCard,
|
||||
opts: Omit<Parameters<typeof Grid>[0], "c" | "handlers"> = {},
|
||||
{ admin = false }: { admin?: boolean } = {},
|
||||
) {
|
||||
const handlers = {
|
||||
onOpen: vi.fn(),
|
||||
onInfo: vi.fn(),
|
||||
onToggleWatched: vi.fn(),
|
||||
onRestart: vi.fn(),
|
||||
};
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
if (admin) qc.setQueryData(qk.me(), { role: "admin" });
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<QueryClientProvider client={qc}>
|
||||
<FloatingTree>
|
||||
<Grid c={c} handlers={handlers} {...opts} />
|
||||
</FloatingTree>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
return { handlers };
|
||||
return { handlers, qc };
|
||||
}
|
||||
// The card root holds the menu's trigger props (right-click anywhere on it).
|
||||
const rightClick = () =>
|
||||
@@ -154,10 +161,34 @@ describe("a Plex title's menu", () => {
|
||||
expect(labels()).not.toContain("card.restartTitle");
|
||||
});
|
||||
|
||||
it("a show opens its page and has no watched, add or details rows", () => {
|
||||
setup(card({ type: "show" }));
|
||||
// A show plays like any title (the surface resolves the episode); its page is a row of its own.
|
||||
// The watched toggle and the add belong to an episode, so a show has none.
|
||||
it("a show plays, opens its page from a row of its own, and has no watched or add rows", async () => {
|
||||
const { handlers } = setup(card({ type: "show" }));
|
||||
rightClick();
|
||||
expect(labels()).toEqual(["plex.openShow", "common.copy.title", "home.pin.add"]);
|
||||
expect(labels()).toEqual(["plex.play", "plex.openShow", "common.copy.title", "home.pin.add"]);
|
||||
fireEvent.click(item("plex.play"));
|
||||
await vi.waitFor(() =>
|
||||
expect(handlers.onOpen).toHaveBeenCalledWith(expect.objectContaining({ type: "show" })),
|
||||
);
|
||||
rightClick();
|
||||
fireEvent.click(item("plex.openShow"));
|
||||
await vi.waitFor(() =>
|
||||
expect(handlers.onInfo).toHaveBeenCalledWith(expect.objectContaining({ type: "show" })),
|
||||
);
|
||||
});
|
||||
|
||||
it("a started show continues, and starts its episode over from the next row", () => {
|
||||
setup(card({ type: "show", status: "in_progress", resumable: true }));
|
||||
rightClick();
|
||||
expect(labels().slice(0, 2)).toEqual(["card.continue", "card.restartTitle"]);
|
||||
});
|
||||
|
||||
it("a part-watched show with no episode to resume plays, with nothing to start over", () => {
|
||||
setup(card({ type: "show", status: "in_progress", resumable: false }));
|
||||
rightClick();
|
||||
expect(labels()[0]).toBe("plex.play");
|
||||
expect(labels()).not.toContain("card.restartTitle");
|
||||
});
|
||||
|
||||
it("the card's '…' opens the same menu, and says so", () => {
|
||||
@@ -223,3 +254,189 @@ describe("a Plex title's menu", () => {
|
||||
await vi.waitFor(() => expect(remove).toHaveBeenCalledWith("m1"));
|
||||
});
|
||||
});
|
||||
|
||||
// Adding a title to a collection was a trip to the Collections page, a new collection, its name and a
|
||||
// search for the title. From the card's menu it is a tick, or a name and Enter.
|
||||
describe("a Plex title's menu — Add to collection", () => {
|
||||
const coll = (id: string, title: string, contains: boolean) =>
|
||||
({ id, title, contains }) as PlexCollection & { contains: boolean };
|
||||
|
||||
it("an admin gets the row on a movie and a show; an episode and a viewer do not", () => {
|
||||
setup(card({}), {}, { admin: true });
|
||||
rightClick();
|
||||
expect(labels()).toContain("plex.collectionAdd.row");
|
||||
cleanup();
|
||||
setup(card({ type: "show" }), {}, { admin: true });
|
||||
rightClick();
|
||||
expect(labels()).toContain("plex.collectionAdd.row");
|
||||
cleanup();
|
||||
setup(card({ type: "episode" }), {}, { admin: true });
|
||||
rightClick();
|
||||
expect(labels()).not.toContain("plex.collectionAdd.row");
|
||||
cleanup();
|
||||
setup(card({}));
|
||||
rightClick();
|
||||
expect(labels()).not.toContain("plex.collectionAdd.row");
|
||||
});
|
||||
|
||||
it("ticks the title into a collection, out of one, and names a new one with it inside", async () => {
|
||||
vi.spyOn(api, "plexCollectionsFor").mockResolvedValue({
|
||||
collections: [coll("cA", "Alpha", true), coll("cB", "Beta", false)],
|
||||
});
|
||||
const add = vi.spyOn(api, "plexCollectionAddItem").mockResolvedValue({} as PlexCollection);
|
||||
const remove = vi
|
||||
.spyOn(api, "plexCollectionRemoveItem")
|
||||
.mockResolvedValue({} as PlexCollection);
|
||||
const create = vi.spyOn(api, "plexCreateCollection").mockResolvedValue({} as PlexCollection);
|
||||
setup(card({}), {}, { admin: true });
|
||||
rightClick();
|
||||
fireEvent.click(item("plex.collectionAdd.row"));
|
||||
const row = async (name: string) =>
|
||||
vi.waitFor(() => {
|
||||
const found = screen
|
||||
.queryAllByRole("menuitemcheckbox", { hidden: true })
|
||||
.find((b) => b.textContent === name);
|
||||
expect(found).toBeDefined();
|
||||
return found!;
|
||||
});
|
||||
fireEvent.click(await row("Beta"));
|
||||
await vi.waitFor(() => expect(add).toHaveBeenCalledWith("cB", "m1"));
|
||||
// One write at a time: the next tick waits for this one to settle.
|
||||
await vi.waitFor(() =>
|
||||
expect(document.querySelector("[aria-busy]")?.getAttribute("aria-busy")).toBe("false"),
|
||||
);
|
||||
fireEvent.click(await row("Alpha"));
|
||||
await vi.waitFor(() => expect(remove).toHaveBeenCalledWith("cA", "m1"));
|
||||
const name = screen.getByLabelText("plex.collection.namePlaceholder");
|
||||
fireEvent.change(name, { target: { value: " Rainy days " } });
|
||||
fireEvent.keyDown(name, { key: "Enter" });
|
||||
await vi.waitFor(() => expect(create).toHaveBeenCalledWith("", "Rainy days", "m1"));
|
||||
});
|
||||
|
||||
const checkRow = async (name: string) =>
|
||||
vi.waitFor(() => {
|
||||
const found = screen
|
||||
.queryAllByRole("menuitemcheckbox", { hidden: true })
|
||||
.find((b) => b.textContent === name);
|
||||
expect(found).toBeDefined();
|
||||
return found!;
|
||||
});
|
||||
const idle = () =>
|
||||
vi.waitFor(() =>
|
||||
expect(document.querySelector("[aria-busy]")?.getAttribute("aria-busy")).toBe("false"),
|
||||
);
|
||||
|
||||
it("flips the tick as soon as a write lands, so the next tap undoes it before the list refetches", async () => {
|
||||
const list = vi.spyOn(api, "plexCollectionsFor");
|
||||
list.mockResolvedValueOnce({ collections: [coll("cB", "Beta", false)] });
|
||||
// The refetch after the write never answers: the tick must not wait for it.
|
||||
list.mockImplementation(() => new Promise(() => {}));
|
||||
const add = vi.spyOn(api, "plexCollectionAddItem").mockResolvedValue({} as PlexCollection);
|
||||
const remove = vi
|
||||
.spyOn(api, "plexCollectionRemoveItem")
|
||||
.mockResolvedValue({} as PlexCollection);
|
||||
setup(card({}), {}, { admin: true });
|
||||
rightClick();
|
||||
fireEvent.click(item("plex.collectionAdd.row"));
|
||||
fireEvent.click(await checkRow("Beta"));
|
||||
await vi.waitFor(() => expect(add).toHaveBeenCalledWith("cB", "m1"));
|
||||
await idle();
|
||||
expect((await checkRow("Beta")).getAttribute("aria-checked")).toBe("true");
|
||||
fireEvent.click(await checkRow("Beta"));
|
||||
await vi.waitFor(() => expect(remove).toHaveBeenCalledWith("cB", "m1"));
|
||||
expect(add).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("refreshes a show's own page too, whose collections come with the show", async () => {
|
||||
vi.spyOn(api, "plexCollectionsFor").mockResolvedValue({
|
||||
collections: [coll("cB", "Beta", false)],
|
||||
});
|
||||
vi.spyOn(api, "plexCollectionAddItem").mockResolvedValue({} as PlexCollection);
|
||||
const { qc } = setup(card({ type: "show" }), {}, { admin: true });
|
||||
const invalidate = vi.spyOn(qc, "invalidateQueries");
|
||||
rightClick();
|
||||
fireEvent.click(item("plex.collectionAdd.row"));
|
||||
fireEvent.click(await checkRow("Beta"));
|
||||
await idle();
|
||||
expect(invalidate).toHaveBeenCalledWith({ queryKey: qk.plexShow("m1") });
|
||||
});
|
||||
|
||||
it("is a named menu that says it is loading, not that there are no collections", async () => {
|
||||
vi.spyOn(api, "plexCollectionsFor").mockImplementation(() => new Promise(() => {}));
|
||||
setup(card({}), {}, { admin: true });
|
||||
rightClick();
|
||||
fireEvent.click(item("plex.collectionAdd.row"));
|
||||
// jsdom computes no name for the unpositioned (visibility: hidden) surface: read what names it.
|
||||
const menu = await vi.waitFor(() => {
|
||||
const found = document.querySelector('[role="menu"][aria-label="plex.collectionAdd.title"]');
|
||||
expect(found).not.toBeNull();
|
||||
return found!;
|
||||
});
|
||||
expect(menu.hasAttribute("aria-labelledby")).toBe(false);
|
||||
expect(screen.getByText("common.loading")).toBeTruthy();
|
||||
expect(screen.queryByText("plex.collectionAdd.none")).toBeNull();
|
||||
});
|
||||
|
||||
it("says a failed load as one, with a retry — not as no collections", async () => {
|
||||
const list = vi.spyOn(api, "plexCollectionsFor").mockRejectedValue(new Error("down"));
|
||||
setup(card({}), {}, { admin: true });
|
||||
rightClick();
|
||||
fireEvent.click(item("plex.collectionAdd.row"));
|
||||
const alert = await screen.findByRole("alert", { hidden: true });
|
||||
expect(alert.textContent).toContain("plex.collectionAdd.loadFailed");
|
||||
expect(screen.queryByText("plex.collectionAdd.none")).toBeNull();
|
||||
// A menu owns its items only: the line about the list sits beside it, not in it.
|
||||
expect(document.querySelector("[role=menu]")).not.toBeNull();
|
||||
expect(alert.closest("[role=menu]")).toBeNull();
|
||||
list.mockResolvedValue({
|
||||
collections: [coll("cB", "Beta", false), coll("cC", "Gamma", false)],
|
||||
});
|
||||
// Retry from the keyboard: the focused button goes as the list loads, and focus must not be left
|
||||
// on <body> with the menu open — it lands on the first row that came back.
|
||||
const retry = (await screen.findByText("common.retry")).closest("button")!;
|
||||
retry.focus();
|
||||
fireEvent.click(retry);
|
||||
const beta = await checkRow("Beta");
|
||||
await vi.waitFor(() => expect(document.activeElement).toBe(beta));
|
||||
// …and the menu's own arrows carry on from there.
|
||||
fireEvent.keyDown(beta, { key: "ArrowDown" });
|
||||
expect(document.activeElement).toBe(await checkRow("Gamma"));
|
||||
});
|
||||
|
||||
it("clears the name when Plex made the collection and only the read after it failed", async () => {
|
||||
vi.spyOn(api, "plexCollectionsFor").mockResolvedValue({ collections: [] });
|
||||
const create = vi
|
||||
.spyOn(api, "plexCreateCollection")
|
||||
.mockRejectedValue(
|
||||
new HttpError(422, "pending", { code: "refused", reason: "collection_pending_sync" }),
|
||||
);
|
||||
setup(card({}), {}, { admin: true });
|
||||
rightClick();
|
||||
fireEvent.click(item("plex.collectionAdd.row"));
|
||||
const name = await screen.findByLabelText<HTMLInputElement>("plex.collection.namePlaceholder");
|
||||
fireEvent.change(name, { target: { value: "Noir" } });
|
||||
fireEvent.keyDown(name, { key: "Enter" });
|
||||
await vi.waitFor(() => expect(create).toHaveBeenCalledTimes(1));
|
||||
// It exists now: a second Enter on the kept name could only be refused as taken.
|
||||
await vi.waitFor(() => expect(name.value).toBe(""));
|
||||
});
|
||||
|
||||
it("steps from the name field into the rows with the arrows — rows that arrived after it opened", async () => {
|
||||
let answer: (v: { collections: (PlexCollection & { contains: boolean })[] }) => void = () => {};
|
||||
vi.spyOn(api, "plexCollectionsFor").mockImplementation(
|
||||
() => new Promise((resolve) => (answer = resolve)),
|
||||
);
|
||||
setup(card({}), {}, { admin: true });
|
||||
rightClick();
|
||||
fireEvent.click(item("plex.collectionAdd.row"));
|
||||
const name = await screen.findByLabelText("plex.collection.namePlaceholder");
|
||||
name.focus();
|
||||
answer({ collections: [coll("cA", "Alpha", true), coll("cB", "Beta", false)] });
|
||||
const alpha = await checkRow("Alpha");
|
||||
fireEvent.keyDown(name, { key: "ArrowDown" });
|
||||
expect(document.activeElement).toBe(alpha);
|
||||
name.focus();
|
||||
fireEvent.keyDown(name, { key: "ArrowUp" });
|
||||
expect(document.activeElement).toBe(await checkRow("Beta"));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,7 +28,7 @@ export function PlexMenu({
|
||||
<ContextMenu
|
||||
menu={menu}
|
||||
ariaLabel={t("plex.menu")}
|
||||
items={c ? [...actions.items(c, { pin }), ...(extra?.(c) ?? [])] : []}
|
||||
items={c ? [...actions.items(c, { pin, anchor: menu.anchor }), ...(extra?.(c) ?? [])] : []}
|
||||
/>
|
||||
{actions.panel}
|
||||
</>
|
||||
|
||||
@@ -24,6 +24,7 @@ vi.mock("react-i18next", async (importOriginal) => {
|
||||
import PlexPlayer from "./PlexPlayer";
|
||||
import { api, type PlexItemDetail } from "../lib/api";
|
||||
import { ACTIVE_ACCOUNT_KEY } from "../lib/activeAccount";
|
||||
import { pushLayer, removeLayer } from "../lib/layerStack";
|
||||
|
||||
type Media = HTMLMediaElement & { _t?: number; _paused?: boolean };
|
||||
|
||||
@@ -128,6 +129,7 @@ function mount(opts: {
|
||||
queue?: string[];
|
||||
first?: string;
|
||||
fromStart?: boolean;
|
||||
onClose?: () => void;
|
||||
}) {
|
||||
vi.spyOn(api, "plexItem").mockImplementation((id: string) => Promise.resolve(opts.details[id]!));
|
||||
const session = vi
|
||||
@@ -139,7 +141,12 @@ function mount(opts: {
|
||||
const first = opts.first ?? opts.queue?.[0] ?? Object.keys(opts.details)[0]!;
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<PlexPlayer itemId={first} onClose={() => {}} queue={opts.queue} fromStart={opts.fromStart} />
|
||||
<PlexPlayer
|
||||
itemId={first}
|
||||
onClose={opts.onClose ?? (() => {})}
|
||||
queue={opts.queue}
|
||||
fromStart={opts.fromStart}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
return { session };
|
||||
@@ -857,3 +864,237 @@ describe("the seek bar (C3)", () => {
|
||||
expect(head?.className).toContain("[@media(hover:none)]:opacity-100");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the time in the row", () => {
|
||||
it("reads the position and the length, and the clock it ends at — no countdown", async () => {
|
||||
mount({ details: { A: item("A") } });
|
||||
await playingAt("/media/A", 100);
|
||||
const row = screen.getByText(/1:40 \/ 50:00/).closest("div")!;
|
||||
expect(row.textContent).not.toMatch(/-\s*48:20/);
|
||||
expect(row.textContent).toMatch(/\(.+\)/);
|
||||
});
|
||||
});
|
||||
|
||||
// On an iPad a drag on the video moved the page behind it, and the strip it moved showed the page's
|
||||
// own background under the player.
|
||||
describe("the page under the player", () => {
|
||||
it("is locked, without overscroll, on black, while the player is open — and given back after", async () => {
|
||||
const root = document.documentElement.style;
|
||||
mount({ details: { A: item("A") } });
|
||||
await playingAt("/media/A", 100);
|
||||
expect(document.body.style.overflow).toBe("hidden");
|
||||
expect(root.overscrollBehavior).toBe("none");
|
||||
expect(root.backgroundColor).toBe("black");
|
||||
cleanup();
|
||||
expect(document.body.style.overflow).toBe("");
|
||||
expect(root.overscrollBehavior).toBe("");
|
||||
expect(root.backgroundColor).toBe("");
|
||||
});
|
||||
|
||||
it("is covered by a player sized from its fixed box, not from dvh", async () => {
|
||||
mount({ details: { A: item("A") } });
|
||||
await playingAt("/media/A", 100);
|
||||
const wrap = video().closest<HTMLElement>("[class*='fixed']")!;
|
||||
expect([wrap.style.width, wrap.style.height]).toEqual(["80%", "80%"]);
|
||||
});
|
||||
});
|
||||
|
||||
// Brave on an iPad cannot fullscreen an element (it fakes `fullscreenEnabled`), and the button did
|
||||
// nothing there. It is shown only where the browser has the API on Element.prototype.
|
||||
describe("the fullscreen button", () => {
|
||||
const withApi = (on: boolean) => {
|
||||
if (on)
|
||||
Object.defineProperty(Element.prototype, "requestFullscreen", {
|
||||
configurable: true,
|
||||
value: () => Promise.resolve(),
|
||||
});
|
||||
else delete (Element.prototype as { requestFullscreen?: unknown }).requestFullscreen;
|
||||
};
|
||||
afterEach(() => withApi(false));
|
||||
|
||||
it("is there where the browser can fullscreen an element", async () => {
|
||||
withApi(true);
|
||||
mount({ details: { A: item("A") } });
|
||||
await playingAt("/media/A", 100);
|
||||
expect(screen.queryByLabelText("plex.player.fullscreen")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("is left out where it cannot, whatever Brave's stand-ins claim", async () => {
|
||||
withApi(false);
|
||||
// Brave's: a <video>-only requestFullscreen on HTMLElement.prototype, and fullscreenEnabled true.
|
||||
Object.defineProperty(HTMLElement.prototype, "requestFullscreen", {
|
||||
configurable: true,
|
||||
value: () => true,
|
||||
});
|
||||
Object.defineProperty(document, "fullscreenEnabled", { configurable: true, value: true });
|
||||
mount({ details: { A: item("A") } });
|
||||
await playingAt("/media/A", 100);
|
||||
expect(screen.queryByLabelText("plex.player.fullscreen")).toBeNull();
|
||||
delete (document as { fullscreenEnabled?: unknown }).fullscreenEnabled;
|
||||
delete (HTMLElement.prototype as { requestFullscreen?: unknown }).requestFullscreen;
|
||||
});
|
||||
});
|
||||
|
||||
// Clip mode: `[` / `]` mark at the playhead (opening the panel), the panel takes the control row's
|
||||
// place, Escape closes the panel before it would close the player, and the scissors gets the focus
|
||||
// back. Cut sends this item and the audio track that is playing.
|
||||
describe("PlexPlayer — clip mode", () => {
|
||||
const key = (k: string, init: KeyboardEventInit = {}) =>
|
||||
act(
|
||||
() =>
|
||||
void window.dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key: k, bubbles: true, cancelable: true, ...init }),
|
||||
),
|
||||
);
|
||||
|
||||
it("opens on [ with Start at the playhead, and Escape closes only the panel", async () => {
|
||||
mount({ details: { a: item("a") } });
|
||||
await playingAt("/media/a", 120);
|
||||
expect(screen.queryByTestId("clip-panel")).toBeNull();
|
||||
key("[");
|
||||
expect(screen.getByTestId("clip-panel")).toBeTruthy();
|
||||
expect(screen.getByRole("slider", { name: "clip.in" }).getAttribute("aria-valuenow")).toBe(
|
||||
"120",
|
||||
);
|
||||
// The seek bar and the control row make room for it.
|
||||
expect(screen.queryByLabelText("plex.player.playPause")).toBeNull();
|
||||
key("Escape");
|
||||
expect(screen.queryByTestId("clip-panel")).toBeNull();
|
||||
expect(screen.getByLabelText("plex.player.playPause")).toBeTruthy();
|
||||
await waitFor(() =>
|
||||
expect(document.activeElement).toBe(screen.getByRole("button", { name: "clip.open" })),
|
||||
);
|
||||
});
|
||||
|
||||
it("marks End on ] typed through AltGr (the HU layout's ])", async () => {
|
||||
mount({ details: { a: item("a") } });
|
||||
await playingAt("/media/a", 200);
|
||||
key("]", { ctrlKey: true, altKey: true, modifierAltGraph: true } as KeyboardEventInit);
|
||||
expect(screen.getByRole("slider", { name: "clip.out" }).getAttribute("aria-valuenow")).toBe(
|
||||
"200",
|
||||
);
|
||||
});
|
||||
|
||||
it("cuts this item with the audio track that is playing", async () => {
|
||||
const enqueue = vi.spyOn(api, "enqueueClip").mockResolvedValue({ id: 9 } as never);
|
||||
mount({ details: { a: item("a") } });
|
||||
await playingAt("/media/a", 60);
|
||||
fireEvent.click(screen.getByRole("button", { name: "clip.open" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "clip.cut" }));
|
||||
await waitFor(() => expect(enqueue).toHaveBeenCalled());
|
||||
expect(enqueue.mock.calls[0]![0]).toMatchObject({
|
||||
source: "plex",
|
||||
ref: "a",
|
||||
start_s: 60,
|
||||
end_s: 90,
|
||||
audio_track: 0,
|
||||
});
|
||||
});
|
||||
it("crops on the picture's own shape, and Escape turns the crop off before the panel", async () => {
|
||||
const enqueue = vi.spyOn(api, "enqueueClip").mockResolvedValue({ id: 9 } as never);
|
||||
// A 720×480 stream: the frame is drawn on a 3:2 picture, and the cut is told so.
|
||||
const proto = HTMLVideoElement.prototype;
|
||||
const own = ["videoWidth", "videoHeight"].map(
|
||||
(k) => [k, Object.getOwnPropertyDescriptor(proto, k)] as const,
|
||||
);
|
||||
Object.defineProperty(proto, "videoWidth", { configurable: true, get: () => 720 });
|
||||
Object.defineProperty(proto, "videoHeight", { configurable: true, get: () => 480 });
|
||||
try {
|
||||
mount({ details: { a: item("a") } });
|
||||
await playingAt("/media/a", 60);
|
||||
key("c");
|
||||
fireEvent.click(screen.getByRole("button", { name: "clip.crop" }));
|
||||
// The frame's layer is the video's box: both end above the panel, both at the player's top
|
||||
// (its flex box centred the shortened video, and the frame sat 155 px above the picture).
|
||||
const video = document.querySelector("video")!;
|
||||
const layer = screen.getByTestId("crop-frame").parentElement!;
|
||||
expect(video.style.alignSelf).toBe("flex-start");
|
||||
expect(layer.className).toContain("top-0");
|
||||
expect(layer.style.height).toBe(video.style.height);
|
||||
key("Escape");
|
||||
expect(screen.queryByTestId("crop-frame")).toBeNull();
|
||||
expect(screen.getByTestId("clip-panel")).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: "clip.crop" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "clip.cut" }));
|
||||
await waitFor(() => expect(enqueue).toHaveBeenCalled());
|
||||
expect(enqueue.mock.calls[0]![0].crop).toEqual({
|
||||
x: 0.1,
|
||||
y: 0.1,
|
||||
w: 0.8,
|
||||
h: 0.8,
|
||||
aspect: 1.5,
|
||||
});
|
||||
} finally {
|
||||
for (const [k, d] of own)
|
||||
if (d) Object.defineProperty(proto, k, d);
|
||||
else delete (proto as unknown as Record<string, unknown>)[k];
|
||||
}
|
||||
});
|
||||
|
||||
const prefs = (p: object) =>
|
||||
localStorage.setItem("siftlode.plexPlayerPrefs.7", JSON.stringify({ subLang: "en", ...p }));
|
||||
const infoOverlay = () => document.querySelector("[class*='bg-black/70']");
|
||||
|
||||
it("Escape closes what is over the picture first, then the crop and the panel", async () => {
|
||||
const full = { ...item("a"), cast: [], genres: [], directors: [], collections: [] };
|
||||
mount({ details: { a: full as PlexItemDetail } });
|
||||
await playingAt("/media/a", 120);
|
||||
key("c");
|
||||
key("i");
|
||||
expect(infoOverlay()).toBeTruthy();
|
||||
key("Escape");
|
||||
expect(infoOverlay()).toBeNull();
|
||||
expect(screen.getByTestId("clip-panel")).toBeTruthy();
|
||||
key("Escape");
|
||||
expect(screen.queryByTestId("clip-panel")).toBeNull();
|
||||
});
|
||||
|
||||
it("⌫ under a dialog over the player is the dialog's, not the clip's", async () => {
|
||||
mount({ details: { a: item("a") } });
|
||||
await playingAt("/media/a", 120);
|
||||
key("c");
|
||||
const layer = pushLayer();
|
||||
try {
|
||||
key("Backspace");
|
||||
expect(screen.getByTestId("clip-panel")).toBeTruthy();
|
||||
} finally {
|
||||
removeLayer(layer);
|
||||
}
|
||||
key("Backspace");
|
||||
expect(screen.queryByTestId("clip-panel")).toBeNull();
|
||||
});
|
||||
|
||||
it("opens no range while the item's length is unknown", async () => {
|
||||
mount({
|
||||
details: { a: { ...item("a"), duration_seconds: null } as unknown as PlexItemDetail },
|
||||
});
|
||||
await playingAt("/media/a", 120);
|
||||
for (const k of ["c", "[", "]"]) key(k);
|
||||
expect(screen.queryByTestId("clip-panel")).toBeNull();
|
||||
});
|
||||
|
||||
it("an item played to its end while a clip is marked stays, with the clip", async () => {
|
||||
const onClose = vi.fn();
|
||||
mount({ details: { a: item("a") }, onClose });
|
||||
await playingAt("/media/a", 2990);
|
||||
key("c");
|
||||
act(() => void video().dispatchEvent(new Event("ended")));
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
expect(screen.getByTestId("clip-panel")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does not auto-skip while a clip is marked: the skip would carry the player off it", async () => {
|
||||
prefs({ autoSkipCredits: true, autoSkipDelay: 0 });
|
||||
const withCredits = {
|
||||
...item("a", 0, "b"),
|
||||
markers: [{ type: "credits", start_s: 2900, end_s: 2995, final: true }],
|
||||
} as unknown as PlexItemDetail;
|
||||
const { session } = mount({ details: { a: withCredits, b: item("b") }, queue: ["a", "b"] });
|
||||
await playingAt("/media/a", 2800);
|
||||
key("c");
|
||||
await playingAt("/media/a", 2910);
|
||||
await act(() => new Promise((r) => setTimeout(r, 50)));
|
||||
expect(lastStart(session)?.[0]).toBe("a");
|
||||
expect(screen.getByTestId("clip-panel")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -232,6 +232,20 @@ it("closing the player detaches the <video> it was playing", async () => {
|
||||
expect(v.getAttribute("src")).toBeNull();
|
||||
});
|
||||
|
||||
it("closing the player refreshes every surface that shows watch state, the Discover rails too", async () => {
|
||||
const { unmount, qc } = mount({
|
||||
details: { A: item("A") },
|
||||
sessions: { A: () => Promise.resolve(direct("A")) },
|
||||
});
|
||||
await waitFor(() => expect(video()?.getAttribute("src")).toBe("/media/A"));
|
||||
const refreshed = vi.spyOn(qc, "invalidateQueries");
|
||||
unmount();
|
||||
const keys = refreshed.mock.calls.map(([f]) => f?.queryKey);
|
||||
expect(keys).toEqual(
|
||||
expect.arrayContaining([qk.plexLibrary(), qk.plexShow(), qk.plexDiscover()]),
|
||||
);
|
||||
});
|
||||
|
||||
describe("the subtitle poll and notice", () => {
|
||||
it("asks about where a resumed session was asked to start, before playback has ticked", async () => {
|
||||
const { status } = mount({
|
||||
|
||||
@@ -21,6 +21,12 @@ import { api, type PlexItemDetail } from "../lib/api";
|
||||
import { pushLayer, removeLayer } from "../lib/layerStack";
|
||||
|
||||
beforeAll(() => {
|
||||
// A browser that can fullscreen an element, as every desktop one can: the row's last control is
|
||||
// the fullscreen button there (jsdom has no Fullscreen API, so the button would be left out).
|
||||
Object.defineProperty(Element.prototype, "requestFullscreen", {
|
||||
configurable: true,
|
||||
value: () => Promise.resolve(),
|
||||
});
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,7 @@ import { Pressable } from "./ui/Pressable";
|
||||
import { ColorInput, CheckRow } from "./ui/NativeControls";
|
||||
import Tabs from "./ui/Tabs";
|
||||
import { useTabPanel } from "./ui/tabPanel";
|
||||
import { MEDIA_CHOSEN } from "./ui/mediaChrome";
|
||||
|
||||
type SettingsTab = "sync" | "playback" | "subtitle" | "clock";
|
||||
|
||||
@@ -65,7 +66,7 @@ export function PlexPlayerSettings(props: {
|
||||
aria-label={t("plex.player.settings")}
|
||||
onWheel={(e) => e.stopPropagation()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="border border-white/15 bg-media-chrome/95 absolute bottom-full right-0 z-menu mb-2 w-80 rounded-xl p-2 text-sm text-white shadow-2xl"
|
||||
className="border border-white/15 bg-media-chrome/95 media-marks absolute bottom-full right-0 z-menu mb-2 w-80 rounded-xl p-2 text-sm text-white shadow-2xl"
|
||||
>
|
||||
<Tabs
|
||||
tone="media"
|
||||
@@ -273,9 +274,7 @@ export function PlexPlayerSettings(props: {
|
||||
aria-pressed={prefs.clockDateStyle === st}
|
||||
onClick={() => patchPrefs({ clockDateStyle: st })}
|
||||
className={`touch-target flex-1 rounded px-2 py-1 text-2xs ${
|
||||
prefs.clockDateStyle === st
|
||||
? "bg-white/15 text-accent"
|
||||
: "text-white/70 hover:bg-white/10"
|
||||
prefs.clockDateStyle === st ? MEDIA_CHOSEN : "text-white/70 hover:bg-white/10"
|
||||
}`}
|
||||
>
|
||||
{clockParts(now, { ...prefs, clockDate: true, clockDateStyle: st }).date}
|
||||
|
||||
@@ -28,6 +28,7 @@ vi.mock("./confirmContext", () => ({ useConfirm: () => h.confirm }));
|
||||
|
||||
import PlexPlaylistView from "./PlexPlaylistView";
|
||||
import { api, type PlexCard } from "../lib/api";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
@@ -63,15 +64,15 @@ const ep = (id: string, season: number, n: number) =>
|
||||
thumb: "",
|
||||
}) as unknown as PlexCard;
|
||||
|
||||
function mount(items: PlexCard[], onInfoCard?: (c: PlexCard) => void) {
|
||||
function mount(items: PlexCard[], onInfoCard?: (c: PlexCard) => void, { admin = false } = {}) {
|
||||
vi.spyOn(api, "plexPlaylist").mockResolvedValue({ id: 5, title: "Films", items } as never);
|
||||
vi.spyOn(api, "comfortPins").mockResolvedValue([] as never);
|
||||
const onPlay = vi.fn();
|
||||
const onBack = vi.fn();
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
if (admin) client.setQueryData(qk.me(), { role: "admin" });
|
||||
render(
|
||||
<QueryClientProvider
|
||||
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
|
||||
>
|
||||
<QueryClientProvider client={client}>
|
||||
<PlexPlaylistView playlistId={5} onBack={onBack} onPlay={onPlay} onInfoCard={onInfoCard} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
@@ -112,6 +113,13 @@ const pressKey = (key: string) =>
|
||||
});
|
||||
|
||||
describe("PlexPlaylistView · a row's menu", () => {
|
||||
it("offers an admin a movie's 'Add to collection…', as its card's menu does", async () => {
|
||||
mount([movie("1")], undefined, { admin: true });
|
||||
await screen.findByText("Movie 1");
|
||||
await openMenu(() => fireEvent.click(moreOf("Movie 1")));
|
||||
expect(menuRows()).toContain("plex.collectionAdd.row");
|
||||
});
|
||||
|
||||
it("has no X at rest: each title ends in its '…', which hides until hover or focus", async () => {
|
||||
mount([movie("1"), ep("e1", 1, 1)]);
|
||||
await screen.findByText("Movie 1");
|
||||
|
||||
@@ -626,7 +626,7 @@ export default function PlexPlaylistView({
|
||||
? []
|
||||
: menuTarget.kind === "item"
|
||||
? [
|
||||
...titleActions.items(menuTarget.item),
|
||||
...titleActions.items(menuTarget.item, { anchor: rowMenu.anchor }),
|
||||
...orderRows(menuTarget),
|
||||
removeRow(menuTarget, t("plex.playlist.removeFromPlaylist")),
|
||||
]
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The Plex player draws its subtitles itself. With the browser drawing them, the control bar covered
|
||||
// the text on an iPad whenever it was up: a native cue is placed by `line`, which nothing animates.
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { act, cleanup, render, screen } from "@testing-library/react";
|
||||
import { createRef } from "react";
|
||||
import PlexSubtitles from "./PlexSubtitles";
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
type FakeTrack = EventTarget & { kind: string; mode: TextTrackMode; activeCues: unknown[] };
|
||||
|
||||
/** A <video> 1000 px tall with one hidden track of the player's own <track>, whose active cues the
|
||||
* test sets; `addTrack` adds another as a browser would — the player's own, or one of the media's. */
|
||||
function fakeVideo() {
|
||||
const video = document.createElement("video");
|
||||
const list: FakeTrack[] = [];
|
||||
const tracks = Object.assign(new EventTarget(), {
|
||||
get length() {
|
||||
return list.length;
|
||||
},
|
||||
[Symbol.iterator]: () => list[Symbol.iterator](),
|
||||
});
|
||||
Object.defineProperty(video, "textTracks", { value: tracks });
|
||||
Object.defineProperty(video, "clientHeight", { value: 1000 });
|
||||
const make = (own: boolean) => {
|
||||
const track: FakeTrack = Object.assign(new EventTarget(), {
|
||||
kind: "subtitles",
|
||||
mode: "hidden" as TextTrackMode,
|
||||
activeCues: [] as unknown[],
|
||||
});
|
||||
if (own) {
|
||||
const el = document.createElement("track");
|
||||
Object.defineProperty(el, "track", { value: track });
|
||||
video.appendChild(el);
|
||||
}
|
||||
list.push(track);
|
||||
return track;
|
||||
};
|
||||
const cuesOn = (track: FakeTrack, texts: string[]) =>
|
||||
act(() => {
|
||||
track.activeCues = texts.map((text, i) => ({ startTime: i, text }));
|
||||
track.dispatchEvent(new Event("cuechange"));
|
||||
});
|
||||
const track = make(true);
|
||||
const show = (...texts: string[]) => cuesOn(track, texts);
|
||||
const addTrack = (own: boolean) => {
|
||||
const added = make(own);
|
||||
act(() => {
|
||||
tracks.dispatchEvent(new Event("addtrack"));
|
||||
});
|
||||
return (...texts: string[]) => cuesOn(added, texts);
|
||||
};
|
||||
return { video, show, addTrack };
|
||||
}
|
||||
|
||||
/** Lay the boxes out: the text's box ends at `textBottom`, the bar starts at `barTop`. With a `scale`,
|
||||
* the player is drawn that much larger than its own pixels, as the windowed player is. */
|
||||
function layout(
|
||||
video: HTMLVideoElement,
|
||||
bar: HTMLElement,
|
||||
textBottom: number,
|
||||
barTop: number,
|
||||
scale = 1,
|
||||
) {
|
||||
const rect = (top: number, bottom: number) =>
|
||||
({ top, bottom, left: 0, right: 0, width: 0, height: bottom - top }) as DOMRect;
|
||||
const isBox = (el: Element) => (el as HTMLElement).dataset?.testid === "plex-subtitles";
|
||||
vi.spyOn(Element.prototype, "getBoundingClientRect").mockImplementation(function (this: Element) {
|
||||
if (this === video) return rect(0, 1000);
|
||||
if (this === bar) return rect(barTop, 1000);
|
||||
// The outer box: the lift moves only the text inside it.
|
||||
if (isBox(this)) return rect(textBottom - 80, textBottom);
|
||||
return rect(0, 0);
|
||||
});
|
||||
vi.spyOn(HTMLElement.prototype, "offsetHeight", "get").mockImplementation(function (
|
||||
this: HTMLElement,
|
||||
) {
|
||||
return isBox(this) ? 80 / scale : 0;
|
||||
});
|
||||
}
|
||||
|
||||
const lifted = () => screen.getByTestId("plex-subtitles-text");
|
||||
|
||||
function mount(video: HTMLVideoElement, avoid: boolean) {
|
||||
const bar = document.createElement("div");
|
||||
const barRef = createRef<HTMLElement>() as { current: HTMLElement | null };
|
||||
barRef.current = bar;
|
||||
const props = {
|
||||
video,
|
||||
pos: 88,
|
||||
size: 100,
|
||||
color: "#ffffff",
|
||||
background: "transparent",
|
||||
shadowPx: 0,
|
||||
shadowColor: "#000000",
|
||||
avoidRef: barRef,
|
||||
};
|
||||
const view = render(<PlexSubtitles {...props} avoid={avoid} />);
|
||||
return { bar, rerender: (a: boolean) => view.rerender(<PlexSubtitles {...props} avoid={a} />) };
|
||||
}
|
||||
|
||||
describe("PlexSubtitles", () => {
|
||||
it("shows the cues the browser reports active, at the cue type size of the video box", () => {
|
||||
const { video, show } = fakeVideo();
|
||||
mount(video, false);
|
||||
show("Half full", "or half empty?");
|
||||
const box = screen.getByTestId("plex-subtitles");
|
||||
expect(box.textContent).toBe("Half fullor half empty?");
|
||||
expect(box.style.fontSize).toBe("50px");
|
||||
expect(box.style.top).toBe("88%");
|
||||
});
|
||||
|
||||
it("rises above the control bar while it shows, and settles back when it hides", () => {
|
||||
const { video, show } = fakeVideo();
|
||||
const { bar, rerender } = mount(video, false);
|
||||
layout(video, bar, 950, 900);
|
||||
show("Half full");
|
||||
expect(lifted().style.transform).toBe("translateY(0px)");
|
||||
rerender(true);
|
||||
// 950 − (900 − 8): the text's bottom clears the bar's top by the 8 px of air.
|
||||
expect(lifted().style.transform).toBe("translateY(-58px)");
|
||||
rerender(false);
|
||||
expect(lifted().style.transform).toBe("translateY(0px)");
|
||||
});
|
||||
|
||||
it("lifts in the player's own pixels when the player is drawn scaled", () => {
|
||||
const { video, show } = fakeVideo();
|
||||
const { bar } = mount(video, true);
|
||||
layout(video, bar, 950, 900, 1.25);
|
||||
show("Half full");
|
||||
// 58 screen pixels are 46.4 of the player's own, which the browser then draws ×1.25.
|
||||
expect(lifted().style.transform).toBe("translateY(-46px)");
|
||||
});
|
||||
|
||||
it("places a cue that just appeared, and slides only one already on screen", () => {
|
||||
const { video, show } = fakeVideo();
|
||||
const { bar, rerender } = mount(video, true);
|
||||
layout(video, bar, 950, 900);
|
||||
show("Half full");
|
||||
expect(lifted().style.transform).toBe("translateY(-58px)");
|
||||
expect(lifted().className).not.toContain("transition-transform");
|
||||
rerender(false);
|
||||
expect(lifted().className).toContain("transition-transform");
|
||||
// The bar comes back while no cue is up: the next one is placed above it, not slid there.
|
||||
show();
|
||||
rerender(true);
|
||||
show("or half empty?");
|
||||
expect(lifted().style.transform).toBe("translateY(-58px)");
|
||||
expect(lifted().className).not.toContain("transition-transform");
|
||||
});
|
||||
|
||||
it("draws the cues of a track the player mounts later — a remounted <track> is a new one", () => {
|
||||
const { video, addTrack } = fakeVideo();
|
||||
mount(video, false);
|
||||
const showOnNew = addTrack(true);
|
||||
showOnNew("After the seek");
|
||||
expect(screen.getByTestId("plex-subtitles").textContent).toBe("After the seek");
|
||||
});
|
||||
|
||||
it("never draws a track that is not the player's own, such as a direct-played file's", () => {
|
||||
const { video, addTrack } = fakeVideo();
|
||||
mount(video, false);
|
||||
const showInBand = addTrack(false);
|
||||
showInBand("Embedded");
|
||||
expect(screen.queryByTestId("plex-subtitles")).toBeNull();
|
||||
});
|
||||
|
||||
it("draws nothing while no cue is active", () => {
|
||||
const { video } = fakeVideo();
|
||||
mount(video, true);
|
||||
expect(screen.queryByTestId("plex-subtitles")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState, type RefObject } from "react";
|
||||
import { subShadow } from "../lib/plexPlayerPrefs";
|
||||
import { ownTextTracks } from "../lib/plexSubTracks";
|
||||
|
||||
// The Plex player's subtitles, drawn by us instead of the browser. The track stays mounted (hls.js and
|
||||
// the VTT fetch need it) but `hidden`: the browser parses the cues and reports the active ones, and
|
||||
// this layer shows them. The browser's own rendering could not move out of the way of the control
|
||||
// bar — a cue is placed by `line`, which neither WebKit nor Chromium animates, and on an iPad the bar
|
||||
// covered the text whenever it was up. Here the text rises above the bar while it shows and slides
|
||||
// back when it hides, the same on every engine, and the viewer's size / colour / background / outline
|
||||
// settings style it directly.
|
||||
|
||||
type Cue = TextTrackCue & { text?: string; getCueAsHTML?: () => DocumentFragment };
|
||||
|
||||
// A cue's default type size, as both engines draw it: 5 % of the video box's height.
|
||||
const CUE_EM_OF_HEIGHT = 0.05;
|
||||
// The air left between the text and what it avoids (the bar's top, or the video's bottom edge).
|
||||
const CLEARANCE_PX = 8;
|
||||
|
||||
/** The cues showing now, from the player's own track(s) of `video` that are not switched off. */
|
||||
function useActiveCues(video: HTMLVideoElement | null): Cue[] {
|
||||
const [cues, setCues] = useState<Cue[]>([]);
|
||||
useEffect(() => {
|
||||
if (!video) return;
|
||||
const tracks = video.textTracks;
|
||||
const read = () => {
|
||||
const now: Cue[] = [];
|
||||
const own = ownTextTracks(video);
|
||||
for (const tr of Array.from(tracks)) {
|
||||
if (!own.has(tr) || tr.mode === "disabled") continue;
|
||||
for (const c of Array.from(tr.activeCues ?? [])) now.push(c as Cue);
|
||||
}
|
||||
setCues((prev) =>
|
||||
prev.length === now.length && prev.every((c, i) => c === now[i]) ? prev : now,
|
||||
);
|
||||
};
|
||||
const bound = new Set<TextTrack>();
|
||||
const bind = (tr: TextTrack) => {
|
||||
if (bound.has(tr)) return;
|
||||
bound.add(tr);
|
||||
tr.addEventListener("cuechange", read);
|
||||
};
|
||||
const onAdd = () => {
|
||||
for (const tr of Array.from(tracks)) bind(tr);
|
||||
read();
|
||||
};
|
||||
for (const tr of Array.from(tracks)) bind(tr);
|
||||
tracks.addEventListener("addtrack", onAdd);
|
||||
tracks.addEventListener("removetrack", read);
|
||||
read();
|
||||
return () => {
|
||||
tracks.removeEventListener("addtrack", onAdd);
|
||||
tracks.removeEventListener("removetrack", read);
|
||||
for (const tr of bound) tr.removeEventListener("cuechange", read);
|
||||
};
|
||||
}, [video]);
|
||||
return cues;
|
||||
}
|
||||
|
||||
/** The video box's height, followed as the window or fullscreen changes it. */
|
||||
function useHeight(el: HTMLElement | null): number {
|
||||
const [height, setHeight] = useState(0);
|
||||
useEffect(() => {
|
||||
if (!el) return;
|
||||
const measure = () => setHeight(el.clientHeight);
|
||||
measure();
|
||||
const ro = new ResizeObserver(measure);
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, [el]);
|
||||
return height;
|
||||
}
|
||||
|
||||
/** One cue's text. `getCueAsHTML` keeps its <i>/<b>; it builds DOM nodes from the parsed cue, never
|
||||
* markup from a string, so nothing in a subtitle file can run. */
|
||||
function CueText({ cue, style }: { cue: Cue; style: React.CSSProperties }) {
|
||||
const ref = useRef<HTMLSpanElement>(null);
|
||||
useLayoutEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
if (typeof cue.getCueAsHTML === "function") el.replaceChildren(cue.getCueAsHTML());
|
||||
else el.textContent = cue.text ?? "";
|
||||
}, [cue]);
|
||||
return (
|
||||
<span
|
||||
ref={ref}
|
||||
style={style}
|
||||
className="whitespace-pre-line px-[0.25em] leading-[1.3] box-decoration-clone"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PlexSubtitles({
|
||||
video,
|
||||
pos,
|
||||
size,
|
||||
color,
|
||||
background,
|
||||
shadowPx,
|
||||
shadowColor,
|
||||
avoid,
|
||||
avoidRef,
|
||||
}: {
|
||||
video: HTMLVideoElement | null;
|
||||
/** Where the text's top sits, in % of the video box's height (the setting's "cue line"). */
|
||||
pos: number;
|
||||
size: number;
|
||||
color: string;
|
||||
background: string;
|
||||
shadowPx: number;
|
||||
shadowColor: string;
|
||||
/** True while the control bar shows: the text then keeps above `avoidRef`'s top. */
|
||||
avoid: boolean;
|
||||
avoidRef: RefObject<HTMLElement | null>;
|
||||
}) {
|
||||
const cues = useActiveCues(video);
|
||||
const height = useHeight(video);
|
||||
const boxRef = useRef<HTMLDivElement>(null);
|
||||
// `slide` is false for the first placement of a box that just appeared: it is put where it belongs,
|
||||
// not slid there from where the previous cue was left.
|
||||
const [lift, setLift] = useState({ px: 0, slide: false });
|
||||
const shownRef = useRef(false);
|
||||
|
||||
// How far up the text has to move to clear the bar (or, without it, the video's bottom edge). The
|
||||
// outer box is never transformed — the lift moves the inner one — so its rect is where the text sits
|
||||
// with no lift, even mid-slide. The rects are screen pixels and the windowed player is scaled
|
||||
// (PlexPlayer's wrapper), while the lift applies in the box's own pixels: the distance is divided
|
||||
// by that scale.
|
||||
useLayoutEffect(() => {
|
||||
const box = boxRef.current;
|
||||
if (!box || !video || cues.length === 0) {
|
||||
shownRef.current = false;
|
||||
return;
|
||||
}
|
||||
const rect = box.getBoundingClientRect();
|
||||
const scale = box.offsetHeight > 0 ? rect.height / box.offsetHeight : 1;
|
||||
const bar = avoid ? avoidRef.current?.getBoundingClientRect().top : undefined;
|
||||
const limit = (bar ?? video.getBoundingClientRect().bottom) - CLEARANCE_PX;
|
||||
const px = Math.max(0, Math.round((rect.bottom - limit) / scale));
|
||||
setLift({ px, slide: shownRef.current });
|
||||
shownRef.current = true;
|
||||
}, [avoid, avoidRef, cues, height, pos, size, video]);
|
||||
|
||||
if (cues.length === 0 || height === 0) return null;
|
||||
const text: React.CSSProperties = {
|
||||
color,
|
||||
backgroundColor: background,
|
||||
textShadow: subShadow(shadowPx, shadowColor),
|
||||
};
|
||||
return (
|
||||
<div
|
||||
ref={boxRef}
|
||||
data-testid="plex-subtitles"
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-x-0 px-[5%] text-center"
|
||||
style={{ top: `${pos}%`, fontSize: `${(height * CUE_EM_OF_HEIGHT * size) / 100}px` }}
|
||||
>
|
||||
<div
|
||||
data-testid="plex-subtitles-text"
|
||||
className={`flex flex-col items-center gap-[0.15em] ${lift.slide ? "transition-transform duration-200 ease-out motion-reduce:transition-none" : ""}`}
|
||||
style={{ transform: `translateY(${-lift.px}px)` }}
|
||||
>
|
||||
{cues.map((cue, i) => (
|
||||
<CueText key={`${cue.startTime}-${i}`} cue={cue} style={text} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,8 +4,8 @@
|
||||
// collapse — the box must WRAP to its own line (not shrink the input to ~0) when the content column
|
||||
// (@container/appmain) is narrow. jsdom does no layout, but the class presence is the contract these
|
||||
// rules depend on, so a refactor that drops them fails here instead of only on a hand-resized window.
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, render } from "@testing-library/react";
|
||||
import SearchBar from "./SearchBar";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
@@ -13,14 +13,46 @@ vi.mock("react-i18next", async (importOriginal) => {
|
||||
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
|
||||
});
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("SearchBar responsive wrapper", () => {
|
||||
it("carries the container-query classes that make it wrap instead of collapsing", () => {
|
||||
const { container } = render(<SearchBar value="" onChange={() => {}} placeholder="Search…" />);
|
||||
const cls = (container.firstElementChild as HTMLElement).className;
|
||||
// A usable min-width floor once the column has room → the box wraps via flex-wrap, never ~0:
|
||||
expect(cls).toContain("@min-[420px]/appmain:min-w-[15rem]");
|
||||
expect(cls).toContain("@min-[420px]/appmain:min-w-[18rem]");
|
||||
// On a narrow column it takes a full line of its own and sorts last (small pills share the row above):
|
||||
expect(cls).toContain("@max-[640px]/appmain:basis-full");
|
||||
expect(cls).toContain("@max-[640px]/appmain:order-last");
|
||||
});
|
||||
});
|
||||
|
||||
// On a tablet the 44 px key cap and Go sat on the edge of a 44 px pill and crowded the text out.
|
||||
describe("SearchBar on a touch screen", () => {
|
||||
it("drops the key cap, and grows the pill past the 44 px Go so a ring shows round it", () => {
|
||||
const { container, getByRole } = render(
|
||||
<SearchBar value="" onChange={() => {}} placeholder="Search…" onGo={() => {}} />,
|
||||
);
|
||||
expect(getByRole("button", { name: /palette.open/ }).className).toContain(
|
||||
"pointer-coarse:hidden",
|
||||
);
|
||||
const pill = container.querySelector("input")!.closest<HTMLElement>("[class*='h-10']")!;
|
||||
expect(pill.className).toContain("pointer-coarse:h-13");
|
||||
});
|
||||
|
||||
it("shortens Go to its icon by the column's width, not the screen's", () => {
|
||||
const { getByText } = render(
|
||||
<SearchBar
|
||||
value=""
|
||||
onChange={() => {}}
|
||||
placeholder="Search…"
|
||||
onGo={() => {}}
|
||||
goIcon={<svg />}
|
||||
goLabel="Go far"
|
||||
/>,
|
||||
);
|
||||
const label = getByText("Go far");
|
||||
expect(label.className).toContain("@min-[900px]/appmain:inline");
|
||||
expect(label.className).not.toMatch(/(^|\s)md:inline/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -72,14 +72,18 @@ export default function SearchBar({
|
||||
// overflow on a truly tiny column; once the column has room (≥420px) a `min-w-[15rem]` floor makes
|
||||
// the box WRAP to its own line via the header's flex-wrap instead of shrinking the input to ~0; on
|
||||
// a narrow column (≤640px) it also takes a full line of its own (basis-full) and sorts LAST, so the
|
||||
// small pills (module name + Plex scope toggle / feed sync chip) share the line above it.
|
||||
// small pills (module name + Plex scope toggle / feed sync chip) share the line above it. The floor
|
||||
// is the input's room plus what sits beside it (the icon, the key cap, Go): at 15rem the box kept
|
||||
// only ~8rem for the text and a tablet cut the placeholder ("Search your subscriptior").
|
||||
return (
|
||||
<div className="pointer-events-auto relative flex-1 min-w-0 max-w-xl @min-[420px]/appmain:min-w-[15rem] @max-[640px]/appmain:order-last @max-[640px]/appmain:basis-full">
|
||||
{/* `free` + its own padding: the Go button sits 6px in from the pill's end, not a field's 12. */}
|
||||
<div className="pointer-events-auto relative flex-1 min-w-0 max-w-xl @min-[420px]/appmain:min-w-[18rem] @max-[640px]/appmain:order-last @max-[640px]/appmain:basis-full">
|
||||
{/* `free` + its own padding: the Go button sits 6px in from the pill's end, not a field's 12.
|
||||
On touch the pill grows past the 44 px Go so a ring of the pill shows round it — a 44 Go in a
|
||||
44 pill sat on its edge. */}
|
||||
<div
|
||||
className={clsx(
|
||||
fieldClass({ size: "free", pill: true, floating: true }),
|
||||
"flex h-10 items-center gap-2 pr-1.5 pl-3 text-sm pointer-coarse:text-base",
|
||||
"flex h-10 items-center gap-2 pr-1.5 pl-3 text-sm pointer-coarse:h-13 pointer-coarse:pr-1 pointer-coarse:text-base",
|
||||
)}
|
||||
>
|
||||
{leading ?? <Search className="w-4 h-4 shrink-0 text-muted" />}
|
||||
@@ -116,7 +120,8 @@ export default function SearchBar({
|
||||
title={goTitle ?? goLabel ?? t("header.goTip")}
|
||||
>
|
||||
{goIcon}
|
||||
<span className={goIcon ? "hidden md:inline" : undefined}>
|
||||
{/* By the column, not the screen: a tablet's 768–1024 px window has a tight column. */}
|
||||
<span className={goIcon ? "hidden @min-[900px]/appmain:inline" : undefined}>
|
||||
{goLabel ?? t("header.go")}
|
||||
</span>
|
||||
</Button>
|
||||
|
||||
@@ -72,7 +72,7 @@ export function UpNextCard({
|
||||
role="region"
|
||||
aria-label={t("plex.player.upNext.title")}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="absolute bottom-24 right-6 z-menu flex w-[22rem] max-w-[calc(100vw-3rem)] gap-3 rounded-2xl border border-white/15 bg-media-chrome/95 p-3.5 text-white shadow-2xl"
|
||||
className="absolute bottom-24 right-6 z-menu flex w-[22rem] max-w-[calc(100vw-3rem)] gap-3 rounded-2xl border border-white/15 bg-media-chrome/95 media-marks p-3.5 text-white shadow-2xl"
|
||||
>
|
||||
<div className="h-[6.5rem] w-[4.6rem] flex-none overflow-hidden rounded-lg bg-white/10">
|
||||
{next && (
|
||||
|
||||
@@ -295,7 +295,7 @@ function Thumb({
|
||||
</span>
|
||||
)}
|
||||
{/* Hover overlay: Play everywhere; Continue + Restart once the video is in progress. Mouse-only:
|
||||
reveal on hover AND on keyboard focus-within (else a Tab-focused button is invisible), and
|
||||
reveal on hover AND on keyboard focus (else a Tab-focused button is invisible), and
|
||||
`reveal-hover` drops it entirely on touch — the card tap already plays/resumes there, so a
|
||||
permanent dark scrim over every mobile thumbnail (and an invisible tap target) is not wanted. */}
|
||||
{onOpen && (
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
// now says how many made it (also when `req` has already shown the refusal in its dialog), deselects
|
||||
// them, holds the cut list still while it queues, and a retry goes on with the same "(i/N)" series.
|
||||
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor, cleanup, act } from "@testing-library/react";
|
||||
import { render, screen, fireEvent, waitFor, cleanup, act, within } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => ({
|
||||
@@ -101,3 +101,175 @@ describe("VideoEditor — a split export that fails part-way", () => {
|
||||
expect(enqueue.mock.calls[2]![0].display_name).toBe("Talk (2/2)");
|
||||
});
|
||||
});
|
||||
|
||||
// A time typed into the selected segment's field belongs to that segment: selecting another one
|
||||
// drops it, and never commits it to the new selection's cut.
|
||||
describe("VideoEditor — a typed time and another segment", () => {
|
||||
it("does not move the newly selected segment's cut with a time typed for the old one", () => {
|
||||
vi.spyOn(api, "downloadStoryboard").mockResolvedValue({ available: false } as never);
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<VideoEditor job={job} onClose={() => {}} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
const video = document.querySelector("video")!;
|
||||
for (const at of [30, 60]) {
|
||||
Object.defineProperty(video, "currentTime", {
|
||||
value: at,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
fireEvent.timeUpdate(video);
|
||||
fireEvent.click(screen.getByText("editor.splitHere"));
|
||||
}
|
||||
const segs = () => screen.getAllByTitle("editor.keptSeg");
|
||||
fireEvent.pointerDown(segs()[1]!);
|
||||
const inField = screen.getByRole("textbox", { name: "editor.in" });
|
||||
act(() => inField.focus());
|
||||
fireEvent.change(inField, { target: { value: "0:40" } });
|
||||
// Another segment is selected before the field loses the focus (a press on it).
|
||||
fireEvent.pointerDown(segs()[2]!);
|
||||
fireEvent.blur(inField);
|
||||
const cuts = screen
|
||||
.getAllByRole("slider", { name: "editor.cutN" })
|
||||
.map((c) => c.getAttribute("aria-valuenow"));
|
||||
expect(cuts).toEqual(["30", "60"]);
|
||||
});
|
||||
});
|
||||
|
||||
// An unreadable time is said on a line under the segment's row: outside the field's label, so the
|
||||
// field's name stays "Out" and the line is read once, as its description.
|
||||
describe("VideoEditor — an unreadable time", () => {
|
||||
it("is said under the row, outside the field's label", () => {
|
||||
vi.spyOn(api, "downloadStoryboard").mockResolvedValue({ available: false } as never);
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<VideoEditor job={job} onClose={() => {}} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
const video = document.querySelector("video")!;
|
||||
Object.defineProperty(video, "currentTime", { value: 50, configurable: true, writable: true });
|
||||
fireEvent.timeUpdate(video);
|
||||
fireEvent.click(screen.getByText("editor.splitHere"));
|
||||
fireEvent.pointerDown(screen.getAllByTitle("editor.keptSeg")[0]!);
|
||||
const out = screen.getByRole("textbox", { name: "editor.out" });
|
||||
fireEvent.change(out, { target: { value: "abc" } });
|
||||
fireEvent.keyDown(out, { key: "Enter" });
|
||||
const line = screen.getByRole("alert");
|
||||
expect(line.textContent).toBe("common.timecodeInvalid");
|
||||
expect(line.closest("label")).toBeNull();
|
||||
expect(screen.getByRole("textbox", { name: "editor.out" })).toBe(out);
|
||||
expect(out.getAttribute("aria-describedby")).toBe(line.id);
|
||||
});
|
||||
});
|
||||
|
||||
// The cuts sit on the shared time track: each is a slider the keyboard steps, and a step stops a
|
||||
// tenth short of the next cut, so no segment ever closes up.
|
||||
describe("VideoEditor — a cut on the keyboard", () => {
|
||||
it("steps a focused cut with the arrows and stops it before its neighbours", () => {
|
||||
vi.spyOn(api, "downloadStoryboard").mockResolvedValue({ available: false } as never);
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<VideoEditor job={job} onClose={() => {}} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
const video = document.querySelector("video")!;
|
||||
Object.defineProperty(video, "currentTime", { value: 50, configurable: true, writable: true });
|
||||
fireEvent.timeUpdate(video);
|
||||
fireEvent.click(screen.getByText("editor.splitHere"));
|
||||
const cut = screen.getByRole("slider", { name: "editor.cutN" });
|
||||
expect(cut.getAttribute("aria-valuenow")).toBe("50");
|
||||
fireEvent.keyDown(cut, { key: "ArrowRight", shiftKey: true });
|
||||
expect(cut.getAttribute("aria-valuenow")).toBe("51");
|
||||
fireEvent.keyDown(cut, { key: "End" });
|
||||
expect(cut.getAttribute("aria-valuenow")).toBe("99.9");
|
||||
});
|
||||
});
|
||||
|
||||
// A cut's remove button and the keep toggle of the segment after it: the button is a button a click
|
||||
// or a key works (it sat inside the cut's slider, pointer-only, its arrows moving the cut), and it
|
||||
// sits at the cut's foot, clear of the toggle at the segment's head; the toggle sits over the cut's
|
||||
// hit area that reaches into its segment, so a press on it is the toggle's.
|
||||
describe("VideoEditor — a cut's remove button", () => {
|
||||
const split = () => {
|
||||
vi.spyOn(api, "downloadStoryboard").mockResolvedValue({ available: false } as never);
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<VideoEditor job={job} onClose={() => {}} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
const video = document.querySelector("video")!;
|
||||
Object.defineProperty(video, "currentTime", { value: 50, configurable: true, writable: true });
|
||||
fireEvent.timeUpdate(video);
|
||||
fireEvent.click(screen.getByText("editor.splitHere"));
|
||||
};
|
||||
|
||||
it("removes the cut on a click (Enter on a focused button is one), and is not part of the slider", () => {
|
||||
split();
|
||||
const remove = screen.getByRole("button", { name: "editor.removeCut" });
|
||||
expect(screen.getByRole("slider", { name: "editor.cutN" }).contains(remove)).toBe(false);
|
||||
act(() => remove.focus());
|
||||
fireEvent.click(remove);
|
||||
expect(screen.queryByRole("slider", { name: "editor.cutN" })).toBeNull();
|
||||
});
|
||||
|
||||
it("hands the focus on to a cut beside it, or to Split here, never to <body>", async () => {
|
||||
split();
|
||||
const video = document.querySelector("video")!;
|
||||
Object.defineProperty(video, "currentTime", { value: 70, configurable: true, writable: true });
|
||||
fireEvent.timeUpdate(video);
|
||||
fireEvent.click(screen.getByText("editor.splitHere"));
|
||||
const [second] = screen.getAllByRole("button", { name: "editor.removeCut" }).slice(1);
|
||||
act(() => second!.focus());
|
||||
fireEvent.click(second!);
|
||||
await waitFor(() =>
|
||||
expect(document.activeElement).toBe(screen.getByRole("slider", { name: "editor.cutN" })),
|
||||
);
|
||||
const last = screen.getByRole("button", { name: "editor.removeCut" });
|
||||
act(() => last.focus());
|
||||
fireEvent.click(last);
|
||||
await waitFor(() =>
|
||||
expect(document.activeElement).toBe(screen.getByText("editor.splitHere").closest("button")),
|
||||
);
|
||||
});
|
||||
|
||||
it("a remove refused while the clips are queued leaves no focus to take later", async () => {
|
||||
let refuse: (e: unknown) => void = () => {};
|
||||
vi.spyOn(api, "enqueueEdit").mockImplementation(
|
||||
() =>
|
||||
new Promise((_, reject) => {
|
||||
refuse = reject;
|
||||
}) as never,
|
||||
);
|
||||
split();
|
||||
fireEvent.click(screen.getByText("editor.createN"));
|
||||
await waitFor(() => expect(api.enqueueEdit).toHaveBeenCalled());
|
||||
// The cut list holds still while the clips are queued: the remove is refused.
|
||||
const remove = screen.getByRole("button", { name: "editor.removeCut" });
|
||||
act(() => remove.focus());
|
||||
fireEvent.click(remove);
|
||||
expect(screen.getByRole("slider", { name: "editor.cutN" })).toBeTruthy();
|
||||
await act(async () => refuse(new Error("refused")));
|
||||
// The create failed and the editor stays; a later split keeps the focus where it was.
|
||||
const video = document.querySelector("video")!;
|
||||
Object.defineProperty(video, "currentTime", { value: 70, configurable: true, writable: true });
|
||||
fireEvent.timeUpdate(video);
|
||||
const splitButton = screen.getByText("editor.splitHere").closest("button")!;
|
||||
act(() => splitButton.focus());
|
||||
fireEvent.click(splitButton);
|
||||
expect(screen.getAllByRole("slider", { name: "editor.cutN" })).toHaveLength(2);
|
||||
expect(document.activeElement).toBe(splitButton);
|
||||
});
|
||||
|
||||
it("sits under the track at its cut, off the cut's grab area; the keep toggle sits over it", () => {
|
||||
split();
|
||||
const remove = screen.getByRole("button", { name: "editor.removeCut" });
|
||||
const track = screen.getByRole("group", { name: "editor.timeline" });
|
||||
// A press anywhere on the cut drags it; the X that deletes it (no undo) is below the track.
|
||||
expect(track.contains(remove)).toBe(false);
|
||||
const cut = screen.getByRole("slider", { name: "editor.cutN" });
|
||||
expect(remove.closest<HTMLElement>("[style]")?.style.left).toBe(cut.style.left);
|
||||
const toggles = within(track).getAllByRole("button", { pressed: true });
|
||||
for (const toggle of toggles) expect(toggle.className).toMatch(/\bz-menu\b/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
@@ -7,37 +7,22 @@ import clsx from "clsx";
|
||||
import Modal, { ModalFooter } from "./ui/Modal";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { api, type DownloadJob, type EditSpec } from "../lib/api";
|
||||
import { contentRect, cropToSourcePx, type Frac } from "../lib/cropGeometry";
|
||||
import { cropToSourcePx, type Frac } from "../lib/cropGeometry";
|
||||
import { CropFrame } from "./ui/CropFrame";
|
||||
import { Button } from "./ui/Button";
|
||||
import { failureReason, notifyActionError } from "../lib/actionErrors";
|
||||
import { markReported } from "../lib/api/core";
|
||||
import { IconButton } from "./ui/IconButton";
|
||||
import { Pressable } from "./ui/Pressable";
|
||||
import { Input } from "./ui/Input";
|
||||
import { TimecodeError, TimecodeField } from "./ui/TimecodeField";
|
||||
import { useTimecodeField } from "./ui/useTimecodeField";
|
||||
import { SURFACE } from "./ui/surfaces";
|
||||
|
||||
// mm:ss.d timecode <-> seconds.
|
||||
function tc(s: number): string {
|
||||
if (!isFinite(s) || s < 0) s = 0;
|
||||
const m = Math.floor(s / 60);
|
||||
return `${m}:${(s - m * 60).toFixed(1).padStart(4, "0")}`;
|
||||
}
|
||||
function parseTc(v: string): number | null {
|
||||
v = v.trim();
|
||||
if (!v) return null;
|
||||
if (v.includes(":")) {
|
||||
const [m, s] = v.split(":");
|
||||
const mm = Number(m),
|
||||
ss = Number(s);
|
||||
if (!isFinite(mm) || !isFinite(ss)) return null;
|
||||
return mm * 60 + ss;
|
||||
}
|
||||
const n = Number(v);
|
||||
return isFinite(n) ? n : null;
|
||||
}
|
||||
import { RangeTrack } from "./ui/RangeTrack";
|
||||
import { trackLeft } from "./ui/trackPosition";
|
||||
import { formatTimecode } from "../lib/timecode";
|
||||
|
||||
type Seg = { id: number; start: number; end: number; keep: boolean };
|
||||
type Drag = { type: "seek" } | { type: "boundary"; bi: number };
|
||||
|
||||
const MIN_SEG = 0.1;
|
||||
|
||||
@@ -59,6 +44,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
||||
const qc = useQueryClient();
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const trackRef = useRef<HTMLDivElement>(null);
|
||||
const splitRef = useRef<HTMLButtonElement>(null);
|
||||
const sid = useRef(1);
|
||||
const nid = () => sid.current++;
|
||||
|
||||
@@ -67,11 +53,9 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
||||
const [current, setCurrent] = useState(0);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [trackW, setTrackW] = useState(0);
|
||||
// Intrinsic (source) video dims and the video element's rendered box, both needed to map the
|
||||
// crop marquee onto the letterboxed picture. `vdim` starts from asset metadata so the overlay
|
||||
// can size before the media decodes; `box` is measured off the element (max-h can clamp it).
|
||||
// Intrinsic (source) video dims: the crop frame's picture shape, and the crop's source pixels.
|
||||
// From the asset's metadata, so the frame sizes before the media decodes.
|
||||
const [vdim, setVdim] = useState({ w: job.asset?.width ?? 0, h: job.asset?.height ?? 0 });
|
||||
const [box, setBox] = useState({ w: 0, h: 0 });
|
||||
|
||||
// The cut-list: contiguous segments covering [0, duration]. Starts as the whole video, kept.
|
||||
const [segments, setSegments] = useState<Seg[]>([{ id: 0, start: 0, end: srcDur, keep: true }]);
|
||||
@@ -90,6 +74,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
||||
const [name, setName] = useState("");
|
||||
|
||||
const fileUrl = useMemo(() => api.downloadFileUrl(job.id), [job.id]);
|
||||
const tc = (sec: number) => formatTimecode(sec, duration >= 3600);
|
||||
const aspect =
|
||||
(job.asset?.width && job.asset?.height ? job.asset.width / job.asset.height : 0) || 16 / 9;
|
||||
const sb = useQuery({
|
||||
@@ -111,21 +96,9 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
// Measure the video element's rendered box (max-h can shrink it below its intrinsic size, which
|
||||
// then pillarboxes the picture). The crop overlay is anchored to the picture, not this box.
|
||||
useEffect(() => {
|
||||
const v = videoRef.current;
|
||||
if (!v || typeof ResizeObserver === "undefined") return;
|
||||
const ro = new ResizeObserver(() => setBox({ w: v.clientWidth, h: v.clientHeight }));
|
||||
ro.observe(v);
|
||||
setBox({ w: v.clientWidth, h: v.clientHeight });
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
// The picture's rendered rectangle inside the (possibly letterboxed) video box — the overlay's
|
||||
// frame, so a crop fraction is a fraction of the real picture, not of the black bars.
|
||||
// The picture's shape, which the crop frame is drawn on (it finds the picture inside the video's
|
||||
// box itself: max-h can pillarbox it).
|
||||
const va = vdim.w && vdim.h ? vdim.w / vdim.h : aspect;
|
||||
const picRect = contentRect(box.w, box.h, va);
|
||||
|
||||
const onLoaded = () => {
|
||||
const v = videoRef.current;
|
||||
@@ -162,6 +135,18 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
||||
|
||||
// --- cut-list mutations ---
|
||||
const boundaries = useMemo(() => segments.slice(0, -1).map((s) => s.end), [segments]);
|
||||
// A cut removed by its button takes the button with it: once it is gone, the focus moves on to the
|
||||
// cut before it (or the one now first), else to Split here — not to <body>, out of the dialog.
|
||||
const refocusAfterRemove = useRef<number | null>(null);
|
||||
useEffect(() => {
|
||||
const bi = refocusAfterRemove.current;
|
||||
if (bi == null) return;
|
||||
refocusAfterRemove.current = null;
|
||||
const cut = trackRef.current?.querySelector<HTMLElement>(
|
||||
`[data-handle="cut-${Math.max(0, bi - 1)}"]`,
|
||||
);
|
||||
(cut ?? splitRef.current)?.focus();
|
||||
}, [boundaries.length]);
|
||||
const splitAtPlayhead = () => {
|
||||
if (creating.current) return;
|
||||
const time = current;
|
||||
@@ -200,81 +185,12 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
||||
creating.current ? segs : segs.map((s) => (s.id === id ? { ...s, keep: !s.keep } : s)),
|
||||
);
|
||||
|
||||
// --- timeline drag (seek / boundary) + hover preview ---
|
||||
const [drag, setDrag] = useState<Drag | null>(null);
|
||||
// The hover point, measured in the pointer handler (track-relative x and its time), so render never
|
||||
// reads the track's geometry (a DOM read in render made the React Compiler skip the editor).
|
||||
// --- timeline hover preview ---
|
||||
// The hover point, measured in the track's pointer handler (track-relative x and its time), so
|
||||
// render never reads the track's geometry (a DOM read in render made the React Compiler skip it).
|
||||
const [hover, setHover] = useState<{ x: number; time: number } | null>(null);
|
||||
const timeAt = useCallback(
|
||||
(clientX: number) => {
|
||||
const el = trackRef.current;
|
||||
if (!el) return 0;
|
||||
const r = el.getBoundingClientRect();
|
||||
return Math.max(0, Math.min(1, (clientX - r.left) / r.width)) * duration;
|
||||
},
|
||||
[duration],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!drag) return;
|
||||
const move = (e: PointerEvent) => {
|
||||
const tt = timeAt(e.clientX);
|
||||
if (drag.type === "seek") {
|
||||
setCurrent(tt);
|
||||
seek(tt);
|
||||
} else moveBoundary(drag.bi, tt);
|
||||
};
|
||||
const up = () => setDrag(null);
|
||||
window.addEventListener("pointermove", move);
|
||||
window.addEventListener("pointerup", up);
|
||||
return () => {
|
||||
window.removeEventListener("pointermove", move);
|
||||
window.removeEventListener("pointerup", up);
|
||||
};
|
||||
}, [drag, timeAt, seek, moveBoundary]);
|
||||
|
||||
// --- crop drag ---
|
||||
const overlayRef = useRef<HTMLDivElement>(null);
|
||||
const cropDrag = useRef<null | { mode: "move" | "resize"; px: number; py: number; orig: Frac }>(
|
||||
null,
|
||||
);
|
||||
const onCropDown = (e: React.PointerEvent, mode: "move" | "resize") => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
(e.target as Element).setPointerCapture?.(e.pointerId);
|
||||
cropDrag.current = { mode, px: e.clientX, py: e.clientY, orig: { ...crop } };
|
||||
};
|
||||
useEffect(() => {
|
||||
const move = (e: PointerEvent) => {
|
||||
const d = cropDrag.current;
|
||||
const el = overlayRef.current;
|
||||
if (!d || !el) return;
|
||||
const r = el.getBoundingClientRect();
|
||||
const dx = (e.clientX - d.px) / r.width;
|
||||
const dy = (e.clientY - d.py) / r.height;
|
||||
if (d.mode === "move")
|
||||
setCrop({
|
||||
...d.orig,
|
||||
x: Math.max(0, Math.min(1 - d.orig.w, d.orig.x + dx)),
|
||||
y: Math.max(0, Math.min(1 - d.orig.h, d.orig.y + dy)),
|
||||
});
|
||||
else
|
||||
setCrop({
|
||||
...d.orig,
|
||||
w: Math.max(0.05, Math.min(1 - d.orig.x, d.orig.w + dx)),
|
||||
h: Math.max(0.05, Math.min(1 - d.orig.y, d.orig.h + dy)),
|
||||
});
|
||||
};
|
||||
const up = () => (cropDrag.current = null);
|
||||
window.addEventListener("pointermove", move);
|
||||
window.addEventListener("pointerup", up);
|
||||
return () => {
|
||||
window.removeEventListener("pointermove", move);
|
||||
window.removeEventListener("pointerup", up);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// --- filmstrip tile helpers (percentage sprite positioning; aspect-correct cells) ---
|
||||
const pct = (v: number) => `${(duration ? (v / duration) * 100 : 0).toFixed(3)}%`;
|
||||
const tileStyle = (idx: number): React.CSSProperties => {
|
||||
const cols = strip?.cols ?? 12;
|
||||
const rows = strip?.rows ?? 1;
|
||||
@@ -395,6 +311,18 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
||||
|
||||
const sel = segments.find((s) => s.id === selId) ?? segments[0];
|
||||
const selIdx = segments.findIndex((s) => s.id === sel?.id);
|
||||
const hours = duration >= 3600;
|
||||
const inField = useTimecodeField(
|
||||
sel?.start ?? 0,
|
||||
hours,
|
||||
(v) => selIdx > 0 && moveBoundary(selIdx - 1, v),
|
||||
);
|
||||
const outField = useTimecodeField(
|
||||
sel?.end ?? 0,
|
||||
hours,
|
||||
(v) => selIdx < segments.length - 1 && moveBoundary(selIdx, v),
|
||||
);
|
||||
const timeErrorId = useId();
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -416,35 +344,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
||||
className="w-full max-h-[42dvh] mx-auto block object-contain"
|
||||
playsInline
|
||||
/>
|
||||
{cropOn && picRect && (
|
||||
<div
|
||||
ref={overlayRef}
|
||||
className="absolute"
|
||||
style={{
|
||||
left: picRect.left,
|
||||
top: picRect.top,
|
||||
width: picRect.width,
|
||||
height: picRect.height,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute border-2 border-accent cursor-move"
|
||||
style={{
|
||||
left: `${crop.x * 100}%`,
|
||||
top: `${crop.y * 100}%`,
|
||||
width: `${crop.w * 100}%`,
|
||||
height: `${crop.h * 100}%`,
|
||||
boxShadow: "0 0 0 9999px rgba(0,0,0,0.5)",
|
||||
}}
|
||||
onPointerDown={(e) => onCropDown(e, "move")}
|
||||
>
|
||||
<div
|
||||
className="absolute -right-1.5 -bottom-1.5 w-4 h-4 rounded-xs bg-accent cursor-nwse-resize"
|
||||
onPointerDown={(e) => onCropDown(e, "resize")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{cropOn && <CropFrame aspect={va} rect={crop} ratio={null} onChange={setCrop} />}
|
||||
</div>
|
||||
|
||||
{/* transport */}
|
||||
@@ -459,6 +359,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
ref={splitRef}
|
||||
onClick={splitAtPlayhead}
|
||||
icon={<SplitSquareHorizontal />}
|
||||
>
|
||||
@@ -468,106 +369,112 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
||||
|
||||
{/* timeline: filmstrip + segments (keep/drop tint) + markers + playhead + hover preview */}
|
||||
<div className="relative">
|
||||
<div
|
||||
ref={trackRef}
|
||||
className="relative h-14 rounded-lg overflow-hidden bg-surface border border-border cursor-pointer"
|
||||
onPointerDown={(e) => {
|
||||
setDrag({ type: "seek" });
|
||||
seek(timeAt(e.clientX));
|
||||
}}
|
||||
onPointerMove={(e) =>
|
||||
setHover({
|
||||
x: e.clientX - e.currentTarget.getBoundingClientRect().left,
|
||||
time: timeAt(e.clientX),
|
||||
})
|
||||
}
|
||||
onPointerLeave={() => setHover(null)}
|
||||
>
|
||||
{/* aspect-correct filmstrip */}
|
||||
{stripCells > 0 && (
|
||||
<div className="absolute inset-0 flex pointer-events-none">
|
||||
{Array.from({ length: stripCells }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-full flex-1"
|
||||
style={tileStyle(
|
||||
Math.round(((i + 0.5) / stripCells) * ((strip!.count ?? 1) - 1)),
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* segment tint: dropped = dimmed + hatched */}
|
||||
{segments.map((s) => (
|
||||
<div
|
||||
key={s.id}
|
||||
className={clsx(
|
||||
"absolute inset-y-0 border-r border-black/40 last:border-r-0",
|
||||
<div ref={trackRef}>
|
||||
<RangeTrack
|
||||
label={t("editor.timeline")}
|
||||
duration={duration}
|
||||
playhead={current}
|
||||
onSeek={(time) => {
|
||||
setCurrent(time);
|
||||
seek(time);
|
||||
}}
|
||||
onHover={setHover}
|
||||
className="h-14 rounded-lg bg-surface border border-border cursor-pointer"
|
||||
background={
|
||||
stripCells > 0 && (
|
||||
<div className="absolute inset-0 flex pointer-events-none">
|
||||
{Array.from({ length: stripCells }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-full flex-1"
|
||||
style={tileStyle(
|
||||
Math.round(((i + 0.5) / stripCells) * ((strip!.count ?? 1) - 1)),
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
regions={segments.map((s) => ({
|
||||
key: s.id,
|
||||
start: s.start,
|
||||
end: s.end,
|
||||
// segment tint: dropped = dimmed + hatched
|
||||
className: clsx(
|
||||
s.end < duration && "border-r border-black/40",
|
||||
s.keep ? "bg-accent/10" : "bg-black/60",
|
||||
s.id === selId && "ring-2 ring-inset ring-white/70",
|
||||
)}
|
||||
style={{
|
||||
left: pct(s.start),
|
||||
width: pct(s.end - s.start),
|
||||
...(s.keep
|
||||
? {}
|
||||
: {
|
||||
backgroundImage:
|
||||
"repeating-linear-gradient(45deg, rgba(0,0,0,0.55) 0 6px, rgba(0,0,0,0.25) 6px 12px)",
|
||||
}),
|
||||
}}
|
||||
onPointerDown={() => setSelId(s.id)}
|
||||
title={s.keep ? t("editor.keptSeg") : t("editor.droppedSeg")}
|
||||
>
|
||||
{/* keep/drop toggle */}
|
||||
<Pressable
|
||||
aria-pressed={s.keep}
|
||||
onPointerDown={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleKeep(s.id);
|
||||
}}
|
||||
className={clsx(
|
||||
"absolute top-1 left-1 p-0.5 rounded-sm bg-black/50 backdrop-blur-xs",
|
||||
s.keep ? "text-accent" : "text-muted",
|
||||
)}
|
||||
title={s.keep ? t("editor.drop") : t("editor.keep")}
|
||||
>
|
||||
{s.keep ? <Eye className="w-3.5 h-3.5" /> : <EyeOff className="w-3.5 h-3.5" />}
|
||||
</Pressable>
|
||||
</div>
|
||||
))}
|
||||
{/* draggable boundary markers */}
|
||||
{boundaries.map((b, bi) => (
|
||||
<div
|
||||
key={bi}
|
||||
className="absolute inset-y-0 -ml-1.5 w-3 flex items-center justify-center z-base"
|
||||
style={{ left: pct(b) }}
|
||||
>
|
||||
<div
|
||||
className="w-1 h-full bg-white/90 cursor-ew-resize rounded-sm"
|
||||
onPointerDown={(e) => {
|
||||
e.stopPropagation();
|
||||
setDrag({ type: "boundary", bi });
|
||||
}}
|
||||
/>
|
||||
<Pressable
|
||||
onPointerDown={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteBoundary(bi);
|
||||
}}
|
||||
className="absolute top-0 -right-2 p-0.5 rounded-full bg-black/70 text-muted hover:text-danger"
|
||||
title={t("editor.removeCut")}
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</Pressable>
|
||||
</div>
|
||||
))}
|
||||
{/* playhead */}
|
||||
<div
|
||||
className="absolute inset-y-0 w-0.5 bg-white pointer-events-none z-menu"
|
||||
style={{ left: pct(current) }}
|
||||
),
|
||||
style: s.keep
|
||||
? undefined
|
||||
: {
|
||||
backgroundImage:
|
||||
"repeating-linear-gradient(45deg, rgba(0,0,0,0.55) 0 6px, rgba(0,0,0,0.25) 6px 12px)",
|
||||
},
|
||||
title: s.keep ? t("editor.keptSeg") : t("editor.droppedSeg"),
|
||||
onPointerDown: () => setSelId(s.id),
|
||||
children: (
|
||||
// keep/drop toggle, over the cut's hit area beside it (that reaches 12 px into the
|
||||
// segment): a press on the eye is the eye's, never a drag of the cut
|
||||
<Pressable
|
||||
aria-pressed={s.keep}
|
||||
onPointerDown={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleKeep(s.id);
|
||||
}}
|
||||
className={clsx(
|
||||
"absolute top-1 left-1 z-menu p-0.5 rounded-sm bg-black/50 backdrop-blur-xs",
|
||||
s.keep ? "text-accent" : "text-muted",
|
||||
)}
|
||||
title={s.keep ? t("editor.drop") : t("editor.keep")}
|
||||
>
|
||||
{s.keep ? <Eye className="w-3.5 h-3.5" /> : <EyeOff className="w-3.5 h-3.5" />}
|
||||
</Pressable>
|
||||
),
|
||||
}))}
|
||||
// The cuts between segments: dragged, or stepped with the arrows once focused.
|
||||
handles={boundaries.map((b, bi) => ({
|
||||
id: `cut-${bi}`,
|
||||
label: t("editor.cutN", { n: bi + 1 }),
|
||||
time: b,
|
||||
min: segments[bi]!.start + MIN_SEG,
|
||||
max: segments[bi + 1]!.end - MIN_SEG,
|
||||
valueText: tc(b),
|
||||
step: 0.1,
|
||||
bigStep: 1,
|
||||
onChange: (time: number) => moveBoundary(bi, time),
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
{/* Each cut's remove button, under the track at the cut: a real button a click or Enter
|
||||
works, off the cut's grab area, where a press drags it (a removal has no undo). */}
|
||||
{boundaries.length > 0 && (
|
||||
<div className="relative mt-1 h-6">
|
||||
{boundaries.map((b, bi) => (
|
||||
<div
|
||||
key={`cut-${bi}`}
|
||||
className="absolute top-0 -translate-x-1/2"
|
||||
style={{ left: trackLeft(b, { from: 0, to: duration }) }}
|
||||
>
|
||||
<Pressable
|
||||
onClick={() => {
|
||||
// Refused while the clips are queued (the list holds still): then nothing is
|
||||
// removed, and no focus is left waiting to be taken by a later change.
|
||||
if (creating.current) return;
|
||||
refocusAfterRemove.current = bi;
|
||||
deleteBoundary(bi);
|
||||
}}
|
||||
// 24px, the target floor; white on its black disc in either theme.
|
||||
className="flex p-1.5 rounded-full bg-black/70 text-white/80 hover:text-white"
|
||||
aria-label={t("editor.removeCut", { n: bi + 1 })}
|
||||
title={t("editor.removeCut", { n: bi + 1 })}
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</Pressable>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* hover-scrub thumbnail */}
|
||||
{strip && hoverTime != null && (
|
||||
@@ -605,35 +512,32 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
||||
<div className="flex-1" />
|
||||
<label className="flex items-center gap-1 text-muted">
|
||||
{t("editor.in")}
|
||||
<Input
|
||||
<TimecodeField
|
||||
className="w-24 tabular-nums"
|
||||
size="sm"
|
||||
defaultValue={tc(sel.start)}
|
||||
key={`s-${sel.id}-${sel.start}`}
|
||||
field={inField}
|
||||
errorId={timeErrorId}
|
||||
disabled={selIdx === 0}
|
||||
onBlur={(e) => {
|
||||
const v = parseTc(e.target.value);
|
||||
if (v != null && selIdx > 0) moveBoundary(selIdx - 1, v);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center gap-1 text-muted">
|
||||
{t("editor.out")}
|
||||
<Input
|
||||
<TimecodeField
|
||||
className="w-24 tabular-nums"
|
||||
size="sm"
|
||||
defaultValue={tc(sel.end)}
|
||||
key={`e-${sel.id}-${sel.end}`}
|
||||
field={outField}
|
||||
errorId={timeErrorId}
|
||||
disabled={selIdx === segments.length - 1}
|
||||
onBlur={(e) => {
|
||||
const v = parseTc(e.target.value);
|
||||
if (v != null && selIdx < segments.length - 1) moveBoundary(selIdx, v);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<span className="text-xs text-muted tabular-nums w-16 text-right">
|
||||
{tc(sel.end - sel.start)}
|
||||
</span>
|
||||
{(inField.invalid || outField.invalid) && (
|
||||
<div className="basis-full">
|
||||
<TimecodeError id={timeErrorId} hours={hours} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { FEED_VIEW_SPEC, type FeedView } from "../lib/feedView";
|
||||
import type { NEXT_PAGE } from "../lib/nextPage";
|
||||
import VideoCard from "./VideoCard";
|
||||
import { VideoMenu } from "./VideoMenu";
|
||||
import type { VideoActionHandlers } from "./useVideoActions";
|
||||
import { useContextMenu } from "./ui/useContextMenu";
|
||||
import VirtualGrid from "./ui/VirtualGrid";
|
||||
|
||||
@@ -28,6 +29,8 @@ export interface VirtualFeedProps {
|
||||
onToggleSave: (id: string, saved: boolean) => void;
|
||||
onResetState: (id: string) => void;
|
||||
onOpen: (v: Video, startAt?: number | null) => void;
|
||||
/** The card menu's channel-filter row (see `useVideoActions`); omitted where it has no place. */
|
||||
channelFilter?: VideoActionHandlers["channelFilter"];
|
||||
hasNextPage: boolean;
|
||||
isFetching: boolean;
|
||||
fetchNextPage: (options: typeof NEXT_PAGE) => unknown;
|
||||
@@ -43,6 +46,7 @@ export default function VirtualFeed({
|
||||
onToggleSave,
|
||||
onResetState,
|
||||
onOpen,
|
||||
channelFilter,
|
||||
hasNextPage,
|
||||
isFetching,
|
||||
fetchNextPage,
|
||||
@@ -86,7 +90,10 @@ export default function VirtualFeed({
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<VideoMenu menu={menu} handlers={{ onState, onToggleSave, onResetState, onOpen }} />
|
||||
<VideoMenu
|
||||
menu={menu}
|
||||
handlers={{ onState, onToggleSave, onResetState, onOpen, channelFilter }}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -268,7 +268,7 @@ export function DownloadRow({
|
||||
<div
|
||||
className={`font-medium leading-snug break-words line-clamp-1 @max-[40rem]/dlrow:line-clamp-2`}
|
||||
>
|
||||
{job.job_kind === "edit" && (
|
||||
{(job.job_kind === "edit" || job.job_kind === "clip") && (
|
||||
<span className="mr-1.5 inline-flex items-center gap-1 px-1.5 py-0.5 align-middle rounded-sm text-2xs font-semibold uppercase tracking-wide bg-accent/15 text-accent">
|
||||
<Scissors className="w-3 h-3" /> {t("downloads.clipBadge")}
|
||||
</span>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import videoCard from "./VideoCard.tsx?raw";
|
||||
import { HOVER_REVEAL, HOVER_YIELD } from "./ui/reveal";
|
||||
|
||||
import {
|
||||
MEDIA_BAR,
|
||||
@@ -38,3 +40,28 @@ describe("a media picture", () => {
|
||||
expect(layer).not.toMatch(/pic:pb-\d/);
|
||||
});
|
||||
});
|
||||
|
||||
const indexCss = readFileSync(new URL("../index.css", import.meta.url), "utf8");
|
||||
|
||||
// Back from a channel page hands the card its focus again, after a mouse trip: with `focus-within`
|
||||
// its hover row stayed up under no pointer until a reload (owner). The chrome answers the pointer and
|
||||
// KEYBOARD focus only (`:focus-visible` on the card or inside it); jsdom cannot evaluate either, so
|
||||
// the classes are the contract, and the global `.reveal` rule says the same.
|
||||
describe("a card's hover chrome", () => {
|
||||
it.each([
|
||||
["the hover row", HOVER_REVEAL],
|
||||
["what yields to it", HOVER_YIELD],
|
||||
["the Play layer", MEDIA_HOVER_LAYER],
|
||||
["the Continue + Restart layer", MEDIA_RESUME_LAYER],
|
||||
])("shows on keyboard focus, never on a focus handed back: %s", (_, cls) => {
|
||||
const parts = cls.split(" ");
|
||||
expect(parts.some((c) => c.startsWith("group-focus-within:"))).toBe(false);
|
||||
expect(parts.some((c) => c.startsWith("group-focus-visible:"))).toBe(true);
|
||||
expect(parts.some((c) => c.startsWith("group-has-focus-visible:"))).toBe(true);
|
||||
});
|
||||
|
||||
it("is the same in the global .reveal rule", () => {
|
||||
expect(indexCss).not.toMatch(/\.group:focus-within \.reveal/);
|
||||
expect(indexCss).toMatch(/\.group:has\(:focus-visible\) \.reveal/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { VideoDetail } from "../lib/api";
|
||||
import type { AutoMode, LoopMode } from "../lib/playerModes";
|
||||
import { Button } from "./ui/Button";
|
||||
import { StateMessage } from "./ui/QueryState";
|
||||
import { ClampedText } from "./ui/ClampedText";
|
||||
import { renderDescription } from "./descriptionLinks";
|
||||
|
||||
// Presentational controls for the YouTube modal player (PlayerModal), split out of the god component —
|
||||
@@ -109,7 +110,8 @@ export function PlayerTransportControls({
|
||||
/** The description, said the same way in its two places (the popover under 1280 px, the panel beside
|
||||
* the video above it): loading, failed with a retry, the text in the caller's box, or none. One
|
||||
* ladder, so the two cannot drift (the failed state was once tested in the panel only).
|
||||
* `ref` / `style` / `className` dress the text's box only. */
|
||||
* `ref` / `style` / `className` dress the text's box only. `clamp` cuts it to three lines with a
|
||||
* More / Less (the panel, where the description sits under the actions and is rarely read whole). */
|
||||
export function PlayerDescription({
|
||||
detail,
|
||||
currentId,
|
||||
@@ -118,6 +120,7 @@ export function PlayerDescription({
|
||||
className,
|
||||
style,
|
||||
ref,
|
||||
clamp = false,
|
||||
}: {
|
||||
detail: { isLoading: boolean; isError: boolean; data?: VideoDetail; refetch: () => unknown };
|
||||
currentId: string;
|
||||
@@ -126,6 +129,7 @@ export function PlayerDescription({
|
||||
className: string;
|
||||
style?: CSSProperties;
|
||||
ref?: Ref<HTMLDivElement>;
|
||||
clamp?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (detail.isLoading) return <div className="text-sm text-muted">{t("player.loading")}</div>;
|
||||
@@ -137,6 +141,18 @@ export function PlayerDescription({
|
||||
);
|
||||
const text = detail.data?.description;
|
||||
if (!text) return <div className="text-sm text-muted">{t("player.noDescription")}</div>;
|
||||
if (clamp)
|
||||
return (
|
||||
<ClampedText
|
||||
text={text}
|
||||
lines={3}
|
||||
moreLabel={t("player.descriptionMore")}
|
||||
lessLabel={t("player.descriptionLess")}
|
||||
textClassName={className}
|
||||
>
|
||||
{renderDescription(text, { currentId, onSeek, onLoadVideo, t })}
|
||||
</ClampedText>
|
||||
);
|
||||
return (
|
||||
<div ref={ref} style={style} className={className}>
|
||||
{renderDescription(text, { currentId, onSeek, onLoadVideo, t })}
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
// aria-label — a dropped label silently reintroduces an unnamed button for screen-reader users. i18n is
|
||||
// key-passthrough, so the asserted names are the i18n keys.
|
||||
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||
import { render, screen, cleanup, fireEvent, act } from "@testing-library/react";
|
||||
import { render as rtlRender, screen, cleanup, fireEvent, act } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { ReactElement } from "react";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
@@ -21,7 +23,15 @@ vi.mock("react-i18next", async (importOriginal) => {
|
||||
|
||||
import { PlexPosterCard, SeasonCard, EpisodeCard } from "./plexCards";
|
||||
import { iconButtonClass } from "./ui/buttonStyles";
|
||||
import type { PlexCard, PlexSeasonDetail } from "../lib/api";
|
||||
import { api, type PlexCard, type PlexSeasonDetail } from "../lib/api";
|
||||
|
||||
// A show card asks the server which episode it would play once its preview is up: a query client.
|
||||
const render = (ui: ReactElement) =>
|
||||
rtlRender(ui, {
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={new QueryClient()}>{children}</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
|
||||
const movie = (status?: string): PlexCard => ({
|
||||
id: "1",
|
||||
@@ -169,14 +179,70 @@ describe("PlexPosterCard with a next episode", () => {
|
||||
expect(onInfo).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stays a plain show card without one: no Play, no toggles, no ⓘ", () => {
|
||||
// Every show card plays (the owner, 2026-10-09): without its episode in hand the surface's onOpen
|
||||
// asks which one. The toggles act on an episode, so they wait for one; the ⓘ is the show's page.
|
||||
it("plays without one too, opens its page from the ⓘ, and offers no toggles", () => {
|
||||
const onOpen = vi.fn();
|
||||
const onPage = vi.fn();
|
||||
const { container } = render(
|
||||
<PlexPosterCard card={show} onOpen={vi.fn()} onToggleWatched={vi.fn()} onInfo={vi.fn()} />,
|
||||
<PlexPosterCard card={show} onOpen={onOpen} onPage={onPage} onToggleWatched={vi.fn()} />,
|
||||
);
|
||||
expect(playGlyph(container)).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "plex.openShow" })).toBeNull();
|
||||
expect(playGlyph(container)).not.toBeNull();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Show" }));
|
||||
expect(onOpen).toHaveBeenCalledTimes(1);
|
||||
fireEvent.click(screen.getByRole("button", { name: "plex.openShow" }));
|
||||
expect(onPage).toHaveBeenCalledWith(show);
|
||||
expect(screen.queryByRole("button", { name: "plex.markWatched" })).toBeNull();
|
||||
});
|
||||
|
||||
it("continues a started show and starts its episode over, as a started movie does", () => {
|
||||
const onOpen = vi.fn();
|
||||
const onRestart = vi.fn();
|
||||
render(
|
||||
<PlexPosterCard
|
||||
card={{ ...show, status: "in_progress", resumable: true }}
|
||||
onOpen={onOpen}
|
||||
onRestart={onRestart}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "card.restart" }));
|
||||
expect(onRestart).toHaveBeenCalledTimes(1);
|
||||
fireEvent.click(screen.getByRole("button", { name: "card.continue" }));
|
||||
expect(onOpen).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("plays a part-watched show with no episode to resume, with no Restart beside it", () => {
|
||||
// Its next episode is untouched: Continue and Restart would both play it from its start.
|
||||
render(
|
||||
<PlexPosterCard
|
||||
card={{ ...show, status: "in_progress", resumable: false }}
|
||||
onOpen={vi.fn()}
|
||||
onRestart={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByRole("button", { name: "card.restart" })).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "card.continue" })).toBeNull();
|
||||
});
|
||||
|
||||
it("names the episode it would play once its preview is up, and not asked before", async () => {
|
||||
vi.useFakeTimers();
|
||||
const ask = vi
|
||||
.spyOn(api, "plexShowNext")
|
||||
.mockResolvedValue({ next: { ...next, title: "Pilot" } });
|
||||
render(<PlexPosterCard card={show} onOpen={vi.fn()} />);
|
||||
fireEvent.mouseEnter(screen.getByRole("button", { name: "Show" }).closest(".cv-poster")!);
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(600);
|
||||
});
|
||||
expect(ask).not.toHaveBeenCalled(); // a sweep across a grid asks nothing
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(400);
|
||||
});
|
||||
vi.useRealTimers();
|
||||
expect(ask).toHaveBeenCalledWith("sh", "en");
|
||||
expect(await screen.findByText("plex.nextUp:S6·E16 — Pilot")).toBeTruthy();
|
||||
ask.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
// C5: a started item's picture offers Continue and Restart (the video card's pair) instead of a Play;
|
||||
@@ -313,7 +379,7 @@ describe("Plex cards in a keyboard-walkable list", () => {
|
||||
describe("a Plex card's title stays selectable", () => {
|
||||
it("opens from its caption, but not on the click that ends selecting the title", () => {
|
||||
const onOpen = vi.fn();
|
||||
render(<PlexPosterCard card={movie()} onOpen={onOpen} />);
|
||||
render(<PlexPosterCard card={movie()} onOpen={vi.fn()} onPage={onOpen} />);
|
||||
const title = screen.getByText("M");
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(title);
|
||||
@@ -327,10 +393,47 @@ describe("a Plex card's title stays selectable", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// The picture plays; the title, lit like a link, opens the page of what it names (owner).
|
||||
describe("a Plex card's title is a link to its page", () => {
|
||||
it("a movie's title opens its page and never plays it", () => {
|
||||
const onOpen = vi.fn();
|
||||
const onPage = vi.fn();
|
||||
render(<PlexPosterCard card={movie()} onOpen={onOpen} onPage={onPage} />);
|
||||
fireEvent.click(screen.getByText("M"), { detail: 1 });
|
||||
expect(onPage).toHaveBeenCalledWith(expect.objectContaining({ id: "1", type: "movie" }));
|
||||
expect(onOpen).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("an episode standing as its show opens the show", () => {
|
||||
const onPage = vi.fn();
|
||||
const ep: PlexCard = { ...episode(), show_id: "s9", show_title: "The Show" };
|
||||
render(<PlexPosterCard card={ep} onOpen={vi.fn()} onPage={onPage} />);
|
||||
fireEvent.click(screen.getByText("The Show"), { detail: 1 });
|
||||
expect(onPage).toHaveBeenCalledWith(expect.objectContaining({ id: "s9", type: "show" }));
|
||||
});
|
||||
|
||||
it("an episode card's title opens the episode's page, its picture plays", () => {
|
||||
const onPlay = vi.fn();
|
||||
const onPage = vi.fn();
|
||||
render(<EpisodeCard ep={episode()} onPlay={onPlay} onPage={onPage} />);
|
||||
fireEvent.click(screen.getByText(episode().title), { detail: 1 });
|
||||
expect(onPage).toHaveBeenCalledTimes(1);
|
||||
expect(onPlay).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("without a page the title is plain text: a click does nothing", () => {
|
||||
const onOpen = vi.fn();
|
||||
render(<PlexPosterCard card={movie()} onOpen={onOpen} />);
|
||||
fireEvent.click(screen.getByText("M"), { detail: 1 });
|
||||
expect(onOpen).not.toHaveBeenCalled();
|
||||
expect(screen.getByText("M").className).not.toContain("cursor-pointer");
|
||||
});
|
||||
});
|
||||
|
||||
describe("a Plex card's caption and a drag", () => {
|
||||
it("does not open on a press dragged past the slop", () => {
|
||||
const onOpen = vi.fn();
|
||||
render(<PlexPosterCard card={movie()} onOpen={onOpen} />);
|
||||
render(<PlexPosterCard card={movie()} onOpen={vi.fn()} onPage={onOpen} />);
|
||||
const title = screen.getByText("M");
|
||||
fireEvent.mouseDown(title, { clientX: 10, clientY: 10 });
|
||||
fireEvent.click(title, { detail: 1, clientX: 60, clientY: 10 });
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user