Compare commits
60
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75f4efde98 | ||
|
|
c0059a0c4b | ||
|
|
a29cadf03e | ||
|
|
83886494ce | ||
|
|
4eaa55385f | ||
|
|
7541b1b016 | ||
|
|
24ad17fc7d | ||
|
|
48bd038c5f | ||
|
|
0fce732497 | ||
|
|
7e94da72b9 | ||
|
|
fadb4f17a9 | ||
|
|
57a81c5643 | ||
|
|
dae0b80f6a | ||
|
|
75093458c9 | ||
|
|
5db6dc8ee6 | ||
|
|
45a9ceae25 | ||
|
|
5249c41edc | ||
|
|
c3664826fe | ||
|
|
c9b8fe15a3 | ||
|
|
1044eaf8a9 | ||
|
|
ca949d22de | ||
|
|
20178f0ec4 | ||
|
|
49a9319f84 | ||
|
|
70d56ab939 | ||
|
|
3865689c26 | ||
|
|
c32429ba69 | ||
|
|
a504550196 | ||
|
|
5095047c81 | ||
|
|
4e70f168b1 | ||
|
|
7f17f488ab | ||
|
|
d1704a5519 | ||
|
|
121ebeebc1 | ||
|
|
ee5290ba01 | ||
|
|
dd6a666ad1 | ||
|
|
392099770a | ||
|
|
2aa3ac6e85 | ||
|
|
891dc628f3 | ||
|
|
0a35f2a3b1 | ||
|
|
d25a132495 | ||
|
|
61d3a1a189 | ||
|
|
d482a5e8e2 | ||
|
|
2064373ac2 | ||
|
|
0cda5b6dd4 | ||
|
|
f3c94641a7 | ||
|
|
59b27467ab | ||
|
|
02feaa9a1c | ||
|
|
c1058f1d5a | ||
|
|
23f16b69df | ||
|
|
c813814c3c | ||
|
|
ed6facb703 | ||
|
|
d44d8d6dda | ||
|
|
b20e9a3947 | ||
|
|
6073a006b8 | ||
|
|
dba4758c59 | ||
|
|
68cfc7e25d | ||
|
|
00886d92fe | ||
|
|
e7d2eb8cf6 | ||
|
|
e886890d6e | ||
|
|
5561d9a379 | ||
|
|
2768ab5bdd |
@@ -7,6 +7,39 @@ 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.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
|
||||
|
||||
- The library and shared-link players answer the keyboard's and the system's media keys, and resume where you stopped.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Plex player: the browser Back closes the panels over the video one at a time, and only the credits at the very end move on to the next episode. Plus smaller fixes around resume points and stopping a stream.
|
||||
|
||||
## 0.101.0 — 2026-10-06
|
||||
|
||||
### New
|
||||
|
||||
- A shared link plays on any phone, iPhone and iPad included: downloads, clips and uploads are kept in a format every phone plays, and an upload in another format is converted after it arrives.
|
||||
- A shared link opens much faster: its page no longer loads the whole app.
|
||||
|
||||
### Fixed
|
||||
|
||||
- The YouTube player keeps its size on a short window and when maximised — before, it could shrink to a thin strip. Plus smaller fixes around uploads and clips.
|
||||
|
||||
## 0.100.0 — 2026-10-05
|
||||
|
||||
### New
|
||||
|
||||
@@ -65,7 +65,8 @@ how it gets built. Concretely:
|
||||
`downloads/`, `youtube/`, `issues/` (shared by routes + scheduler + worker) → flat core (`auth`, `quota`,
|
||||
`sysconfig`, `state`, `audit`, `notifications`, `userscope`, `ratelimit`, `bodylimit`, `progress`, `collation`,
|
||||
`security`, `logs`, `logsink`, `diagnostics`, `debugsession`) → `models/db/config`. **Dependency direction is one-way: core never imports routes.**
|
||||
Frontend: `main.tsx` provider tree → `App.tsx` shell → `moduleRegistry` route-chunks → module
|
||||
Frontend: `main.tsx` provider tree → `App.tsx` shell (a shared `/watch` link boots from its own
|
||||
entry, `watch.html` → `watchMain.tsx`, held app-free by a contract test) → `moduleRegistry` route-chunks → module
|
||||
components → the **platform layer `components/ui/`** (domain-free primitives: the popover engine and
|
||||
its menus, `Modal`/`Overlay`, `Button`/`form`, `DataTable`/`VirtualGrid`, `QueryState`, …). That
|
||||
boundary is one-way and **lint-enforced**, not just described: a platform component may not import a
|
||||
@@ -101,6 +102,7 @@ CQRS, a repository/DI framework, a router library, a generic CRUD-router factory
|
||||
| FE static label / button-like link | `Badge` (flat, no edge) · `LinkButton` / `IconLink` | a glass pill that reads as a chip; an `<a>` hand-painted like a button |
|
||||
| FE loading/empty/error | `QueryState` / `StateMessage` | ad-hoc conditionals |
|
||||
| FE failed write | `meta.failTitle`, else `notifyActionError(err, title)` | a bare `catch {}` or `notify()` with no error attached |
|
||||
| FE player on a native media element | `lib/mediaSession` (`useMediaSession`) + `lib/mediaKeys` + `lib/resume` (`useResume`), the element held as STATE | a private `navigator.mediaSession` / `keydown` / position-saving effect per player |
|
||||
| FE diagnostics | `lib/log.ts` (`log.info("event.code", msg, attrs)`); a filter change → `lib/filterLog.ts`, a player → `lib/mediaLog.ts` | `console.*` (lint-enforced); a value only a higher level may carry, logged unguarded |
|
||||
| FE state that MIRRORS a prop/query | derive it, or hold a draft (`draft ?? server`) | a copy an effect re-syncs — that paints the stale value first |
|
||||
| FE state reset on a key change | adjust it DURING render against a remembered key | `useEffect(() => setX(init), [key])` |
|
||||
|
||||
@@ -23,8 +23,9 @@ blocker and SponsorBlock keep working.
|
||||
- **Playlists with two-way YouTube sync** — build them locally, keep them in sync in both directions.
|
||||
- **In-app player** with resume, plus keyboard/scroll controls.
|
||||
- **Download Center** — save videos to the server with yt-dlp in a Plex-friendly layout (format
|
||||
presets, per-user storage quota), trim / crop / split & join them in a built-in editor, then save
|
||||
to your device, share with another user, or hand out a public watch link.
|
||||
presets, per-user storage quota), trim / crop / split & join them in a built-in editor, play them
|
||||
where you left off, then save to your device, share with another user, or hand out a public watch
|
||||
link.
|
||||
- **Plex library** *(optional)* — point Siftlode at your own Plex server to browse and play its
|
||||
movies and shows alongside your feed, with watch state kept in sync.
|
||||
- **Multi-user** with per-user private state, a shared catalog, and a fair daily API-quota guard.
|
||||
|
||||
@@ -743,14 +743,6 @@
|
||||
"lineCount": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": "reportArgumentType",
|
||||
"range": {
|
||||
"startColumn": 37,
|
||||
"endColumn": 47,
|
||||
"lineCount": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": "reportArgumentType",
|
||||
"range": {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
"""library: a resume point per user and library item
|
||||
|
||||
The library player opened every item at 0:00. `library_states` keeps where a user stopped, per job
|
||||
(their own download, or one shared with them) — the same shape as `video_states`' position, without a
|
||||
status: the library has no "watched". It starts empty; the downgrade drops it.
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0099_library_states"
|
||||
down_revision: str | None = "0098_drop_demo_account"
|
||||
branch_labels: str | None = None
|
||||
depends_on: str | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"library_states",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column(
|
||||
"user_id",
|
||||
sa.Integer(),
|
||||
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"job_id",
|
||||
sa.Integer(),
|
||||
sa.ForeignKey("download_jobs.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("position_seconds", sa.Integer(), nullable=False),
|
||||
sa.Column("progress_updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.UniqueConstraint("user_id", "job_id", name="uq_library_user_job"),
|
||||
)
|
||||
op.create_index("ix_library_states_job_id", "library_states", ["job_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_library_states_job_id", table_name="library_states")
|
||||
op.drop_table("library_states")
|
||||
@@ -0,0 +1,22 @@
|
||||
"""plex_items: drop `markers`, a column nothing writes or reads
|
||||
|
||||
Intro/credits markers are read live from Plex for the item the player opens (with Plex's `final` flag
|
||||
on the block that ends it); this cached copy was never filled — every row is NULL. The downgrade
|
||||
restores the empty column.
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0100_drop_plex_item_markers"
|
||||
down_revision: str | None = "0099_library_states"
|
||||
branch_labels: str | None = None
|
||||
depends_on: str | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.drop_column("plex_items", "markers")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.add_column("plex_items", sa.Column("markers", sa.JSON(), nullable=True))
|
||||
+71
-113
@@ -188,6 +188,51 @@ def clip_duration(spec: dict, source_duration: int | None) -> int | None:
|
||||
|
||||
# --- ffmpeg: trim / crop --------------------------------------------------------------------
|
||||
|
||||
# Every libx264 / aac re-encode in this package uses these, so a re-encoded file is always the
|
||||
# phone-safe shape (`playable`): `-pix_fmt yuv420p` pins 8-bit 4:2:0 — without it libx264 keeps a
|
||||
# 10-bit or 4:4:4 source's pixel format and writes H.264 High 10 / High 4:4:4, which browsers decode
|
||||
# only patchily (the one safe H.264 is 8-bit 4:2:0).
|
||||
X264_ARGS = ["-c:v", "libx264", "-preset", "veryfast", "-crf", "20", "-pix_fmt", "yuv420p"]
|
||||
AAC_ARGS = ["-c:a", "aac", "-b:a", "192k"]
|
||||
# The last step of every video filter chain a libx264 re-encode runs: 4:2:0 halves the chroma, so
|
||||
# libx264 refuses an odd width or height ("width not divisible by 2") — measured on an odd source
|
||||
# that is not 4:2:0 already (a 4:4:4 or RGB screen capture, 1365x767). A crop of a 4:2:0 source is
|
||||
# rounded to its chroma grid by ffmpeg itself; this holds every other path to the encoder to the same
|
||||
# even frame. Rounding down to even costs at most one pixel row/column.
|
||||
EVEN_FRAME = "scale=trunc(iw/2)*2:trunc(ih/2)*2"
|
||||
|
||||
# The demuxers ffmpeg/ffprobe may use to open a library file — single-file containers only. Without
|
||||
# it ffmpeg picks the demuxer from the file's CONTENT and NAME, and an upload's name is the user's:
|
||||
# a playlist-type demuxer (HLS, DASH, concat) opens whatever local paths its entries name, so a
|
||||
# crafted upload could copy another user's clip or a Plex file into its own converted asset. A file
|
||||
# no demuxer here can open fails the probe, and an unprobeable upload is a download-only 'file'.
|
||||
MEDIA_DEMUXERS = (
|
||||
"mov", # mp4, m4a, mov, 3gp
|
||||
"matroska", # mkv, webm
|
||||
"mpegts",
|
||||
"mpeg",
|
||||
"avi",
|
||||
"asf",
|
||||
"flv",
|
||||
"ogg",
|
||||
"mp3",
|
||||
"aac",
|
||||
"ac3",
|
||||
"eac3",
|
||||
"flac",
|
||||
"wav",
|
||||
"w64",
|
||||
"aiff",
|
||||
"caf",
|
||||
"amr",
|
||||
)
|
||||
INPUT_GUARD = ["-format_whitelist", ",".join(MEDIA_DEMUXERS)]
|
||||
# A downloaded thumbnail is the remote site's bytes under the suffix yt-dlp gave it (`.png`, `.webp`,
|
||||
# `.jpg` — `pipeline._THUMB_SUFFIXES`): only the single-image demuxers those open, so a "thumbnail"
|
||||
# that is really a playlist cannot pull a local file into the poster. Anything else fails, and the
|
||||
# poster falls back to a cut frame (`ensure_poster`).
|
||||
THUMB_GUARD = ["-format_whitelist", "png_pipe,jpeg_pipe,webp_pipe"]
|
||||
|
||||
|
||||
def build_edit_cmd(src: Path, dest: Path, spec: dict, out_ext: str) -> list[str]:
|
||||
"""ffmpeg command for a trim/crop edit. `-progress pipe:1` streams machine-readable progress.
|
||||
@@ -214,7 +259,7 @@ def build_edit_cmd(src: Path, dest: Path, spec: dict, out_ext: str) -> list[str]
|
||||
]
|
||||
if start:
|
||||
cmd += ["-ss", f"{start:.3f}"]
|
||||
cmd += ["-i", str(src)]
|
||||
cmd += [*INPUT_GUARD, "-i", str(src)]
|
||||
if end is not None:
|
||||
dur = end - start
|
||||
if dur > 0:
|
||||
@@ -222,21 +267,9 @@ def build_edit_cmd(src: Path, dest: Path, spec: dict, out_ext: str) -> list[str]
|
||||
|
||||
if reencode:
|
||||
# Input -ss + re-encode is frame-accurate (decodes from the prior keyframe, discards up to
|
||||
# the exact cut). A crop adds the filter; an accurate trim re-encodes with no filter.
|
||||
if crop:
|
||||
cmd += ["-vf", f"crop={crop['w']}:{crop['h']}:{crop['x']}:{crop['y']}"]
|
||||
cmd += [
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-preset",
|
||||
"veryfast",
|
||||
"-crf",
|
||||
"20",
|
||||
"-c:a",
|
||||
"aac",
|
||||
"-b:a",
|
||||
"192k",
|
||||
]
|
||||
# the exact cut). A crop adds the filter; an accurate trim only evens the frame.
|
||||
cropf = f"crop={crop['w']}:{crop['h']}:{crop['x']}:{crop['y']}," if crop else ""
|
||||
cmd += ["-vf", cropf + EVEN_FRAME, *X264_ARGS, *AAC_ARGS]
|
||||
else:
|
||||
cmd += ["-c", "copy", "-avoid_negative_ts", "make_zero"]
|
||||
|
||||
@@ -284,11 +317,14 @@ def build_concat_plan(src: Path, dest: Path, spec: dict, out_ext: str, staging:
|
||||
parts, labels = [], []
|
||||
for i, s in enumerate(segs):
|
||||
st, en = s["start_s"], s["end_s"]
|
||||
parts.append(f"[0:v]trim=start={st}:end={en},setpts=PTS-STARTPTS{cropf}[v{i}]")
|
||||
parts.append(
|
||||
f"[0:v]trim=start={st}:end={en},setpts=PTS-STARTPTS{cropf},{EVEN_FRAME}[v{i}]"
|
||||
)
|
||||
parts.append(f"[0:a]atrim=start={st}:end={en},asetpts=PTS-STARTPTS[a{i}]")
|
||||
labels.append(f"[v{i}][a{i}]")
|
||||
parts.append(f"{''.join(labels)}concat=n={len(segs)}:v=1:a=1[v][a]")
|
||||
cmd = head + [
|
||||
*INPUT_GUARD,
|
||||
"-i",
|
||||
str(src),
|
||||
"-filter_complex",
|
||||
@@ -297,26 +333,23 @@ def build_concat_plan(src: Path, dest: Path, spec: dict, out_ext: str, staging:
|
||||
"[v]",
|
||||
"-map",
|
||||
"[a]",
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-preset",
|
||||
"veryfast",
|
||||
"-crf",
|
||||
"20",
|
||||
"-c:a",
|
||||
"aac",
|
||||
"-b:a",
|
||||
"192k",
|
||||
*X264_ARGS,
|
||||
*AAC_ARGS,
|
||||
]
|
||||
else:
|
||||
listp = staging / "concat.txt"
|
||||
lines = []
|
||||
lines = ["ffconcat version 1.0"]
|
||||
for s in segs:
|
||||
lines.append(f"file '{_concat_quote(src)}'")
|
||||
# ffmpeg hands the list's whitelist (which must name `concat` to read the list) down to
|
||||
# the file it names, so a library file whose bytes are a concat list would be read as one
|
||||
# and open the paths inside it. Each entry's own whitelist replaces the inherited one.
|
||||
lines.append(f"option format_whitelist {INPUT_GUARD[1]}")
|
||||
lines.append(f"inpoint {s['start_s']}")
|
||||
lines.append(f"outpoint {s['end_s']}")
|
||||
prep[listp] = "\n".join(lines) + "\n"
|
||||
cmd = head + ["-f", "concat", "-safe", "0", "-i", str(listp), "-c", "copy"]
|
||||
guard = ["-format_whitelist", "concat," + INPUT_GUARD[1]]
|
||||
cmd = head + ["-f", "concat", "-safe", "0", *guard, "-i", str(listp), "-c", "copy"]
|
||||
|
||||
if out_ext in ("mp4", "m4a", "mov"):
|
||||
cmd += ["-movflags", "+faststart"]
|
||||
@@ -474,14 +507,7 @@ def run_ffmpeg(
|
||||
raise StatedFailure(f"ffmpeg failed ({ret}): {''.join(err_tail)[-300:]}")
|
||||
|
||||
|
||||
# --- ffmpeg: universal browser playability --------------------------------------------------
|
||||
# A downloaded file must play in EVERY browser, whatever the source shipped. Universal = H.264
|
||||
# video + AAC/MP3 audio in an mp4 container: H.264 is the only video codec all browsers decode
|
||||
# (VP9/AV1 fail on Safari/iOS; HEVC fails on Chrome/Firefox), and mp4/AAC is the safe pairing.
|
||||
# Anything else is fixed post-download: re-encode the offending stream(s), or just remux when the
|
||||
# codecs are already fine but the container isn't.
|
||||
_BROWSER_SAFE_VCODEC = ("h264",)
|
||||
_BROWSER_SAFE_ACODEC = ("aac", "mp3")
|
||||
# --- ffprobe ---------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def run_ffprobe_json(
|
||||
@@ -493,10 +519,10 @@ def run_ffprobe_json(
|
||||
) -> dict | None:
|
||||
"""One ffprobe call → its parsed JSON dict, or None on any failure (timeout, non-zero exit,
|
||||
missing binary, unparseable output). The SINGLE ffprobe-JSON-invocation point in the downloads
|
||||
package: the browser-playability codec probe here and the shared media probe (`probe.probe_media`,
|
||||
used by both the download worker's finalize and the upload ingest) both go through it, so the
|
||||
package: the shared media probe (`probe.probe_json`: the stored facts and the phone-safe rule) goes
|
||||
through it, so the
|
||||
subprocess flags/timeout/error handling live in one place instead of drifting copies."""
|
||||
args = ["ffprobe", "-v", "error"]
|
||||
args = ["ffprobe", "-v", "error", *INPUT_GUARD]
|
||||
if show_streams:
|
||||
args.append("-show_streams")
|
||||
if show_format:
|
||||
@@ -517,35 +543,6 @@ def run_ffprobe_json(
|
||||
return None
|
||||
|
||||
|
||||
def _codecs_from_streams(streams: list) -> tuple[str | None, str | None]:
|
||||
"""(video_codec, audio_codec) lowercased from an ffprobe JSON stream list — the first of each kind.
|
||||
|
||||
Parsed from the JSON `streams` list, NOT ffprobe's flat `default=nw=1` output: there the fields
|
||||
arrive in ffprobe's own struct order, which puts `codec_name` BEFORE `codec_type`, so a line-by-line
|
||||
"remember the last codec_type" parser attributes every name to the PREVIOUS stream. Measured on real
|
||||
downloads: an ordinary H.264+AAC mp4 probed as `v=aac a=None` — the browser-playability check then
|
||||
believed the video stream was AAC and re-encoded a perfectly fine file on every download. Reading each
|
||||
stream dict by its own `codec_type` is immune to that ordering."""
|
||||
vcodec = acodec = None
|
||||
for s in streams:
|
||||
name = (s.get("codec_name") or "").lower() or None
|
||||
ctype = (s.get("codec_type") or "").lower()
|
||||
if ctype == "video" and vcodec is None:
|
||||
vcodec = name
|
||||
elif ctype == "audio" and acodec is None:
|
||||
acodec = name
|
||||
return vcodec, acodec
|
||||
|
||||
|
||||
def probe_codecs(path: Path) -> tuple[str | None, str | None]:
|
||||
"""Return (video_codec, audio_codec) lowercased via one ffprobe call; None for a missing
|
||||
stream or if ffprobe fails (a failed probe is treated as 'leave it alone' upstream)."""
|
||||
data = run_ffprobe_json(path, entries="stream=codec_type,codec_name")
|
||||
if data is None:
|
||||
return None, None
|
||||
return _codecs_from_streams(data.get("streams") or [])
|
||||
|
||||
|
||||
def probe_duration(path: Path) -> int:
|
||||
"""Media duration in whole seconds via ffprobe, or 0 if unknown — a progress denominator for a
|
||||
re-encode when the yt-dlp info dict carried no `duration` (some URL/live extractions)."""
|
||||
@@ -555,6 +552,7 @@ def probe_duration(path: Path) -> int:
|
||||
"ffprobe",
|
||||
"-v",
|
||||
"error",
|
||||
*INPUT_GUARD,
|
||||
"-show_entries",
|
||||
"format=duration",
|
||||
"-of",
|
||||
@@ -577,49 +575,6 @@ def probe_duration(path: Path) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
def browser_transcode_flags(
|
||||
vcodec: str | None, acodec: str | None, is_mp4: bool
|
||||
) -> tuple[bool, bool, bool]:
|
||||
"""Decide what (if anything) makes a file universally browser-playable. Returns
|
||||
(needs_change, reencode_video, reencode_audio). needs_change=False => already H.264 + AAC/MP3
|
||||
in mp4. A None codec (no such stream, or a failed probe) is never re-encoded."""
|
||||
rv = bool(vcodec) and vcodec not in _BROWSER_SAFE_VCODEC
|
||||
ra = bool(acodec) and acodec not in _BROWSER_SAFE_ACODEC
|
||||
return (rv or ra or not is_mp4), rv, ra
|
||||
|
||||
|
||||
def build_browser_safe_cmd(
|
||||
src: Path, dest: Path, reencode_video: bool, reencode_audio: bool, threads: int | None = None
|
||||
) -> list[str]:
|
||||
"""ffmpeg command that lands `src` as a browser-safe mp4 (faststart): re-encode only the
|
||||
stream(s) that need it (libx264 / aac), stream-copy the rest, always into mp4. `-progress
|
||||
pipe:1` streams machine-readable progress for run_ffmpeg. `threads` caps the encoder so a
|
||||
re-encode can't claim every core on the shared box (see the worker-child de-prioritization)."""
|
||||
cmd = [
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-nostdin",
|
||||
"-progress",
|
||||
"pipe:1",
|
||||
"-nostats",
|
||||
"-i",
|
||||
str(src),
|
||||
]
|
||||
cmd += (
|
||||
["-c:v", "libx264", "-preset", "veryfast", "-crf", "20"]
|
||||
if reencode_video
|
||||
else ["-c:v", "copy"]
|
||||
)
|
||||
cmd += ["-c:a", "aac", "-b:a", "192k"] if reencode_audio else ["-c:a", "copy"]
|
||||
if threads and threads > 0:
|
||||
cmd += ["-threads", str(threads)]
|
||||
cmd += ["-movflags", "+faststart", str(dest)]
|
||||
return cmd
|
||||
|
||||
|
||||
# --- filmstrip (editor scrub track) ---------------------------------------------------------
|
||||
|
||||
_SB_COLS = 12
|
||||
@@ -655,6 +610,7 @@ def build_storyboard_cmd(src: Path, dest: Path, duration_s: int | None, geom: di
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-nostdin",
|
||||
*INPUT_GUARD,
|
||||
"-i",
|
||||
str(src),
|
||||
"-frames:v",
|
||||
@@ -678,6 +634,7 @@ def build_poster_cmd(src: Path, dest: Path, at_s: float) -> list[str]:
|
||||
"-nostdin",
|
||||
"-ss",
|
||||
f"{max(0.0, at_s):.3f}",
|
||||
*INPUT_GUARD,
|
||||
"-i",
|
||||
str(src),
|
||||
"-frames:v",
|
||||
@@ -699,6 +656,7 @@ def build_thumbnail_jpg_cmd(src: Path, dest: Path) -> list[str]:
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-nostdin",
|
||||
*THUMB_GUARD,
|
||||
"-i",
|
||||
str(src),
|
||||
"-frames:v",
|
||||
@@ -734,7 +692,7 @@ def _step_failure(err: BaseException) -> str:
|
||||
|
||||
|
||||
def _is_jpeg(path: Path) -> bool:
|
||||
"""True if the file starts with the JPEG SOI marker (FF D8 FF)."""
|
||||
"""True if the file starts with the JPEG SOI marker (0xFF 0xD8 0xFF)."""
|
||||
try:
|
||||
with path.open("rb") as f:
|
||||
return f.read(3) == b"\xff\xd8\xff"
|
||||
|
||||
@@ -53,6 +53,7 @@ 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."
|
||||
UPLOAD_MISSING_MESSAGE = "The uploaded file is missing."
|
||||
NETWORK_REDACTED_MESSAGE = "The server could not reach that address."
|
||||
|
||||
# (marker, category) — first match wins, so order most-specific → most-general. Matching is
|
||||
@@ -98,6 +99,8 @@ _RULES: tuple[tuple[str, str], ...] = (
|
||||
# An edit's source download went away — our own wording, and MORE specific than the generic
|
||||
# "no longer available" below, so it must be matched first.
|
||||
("source download is no longer available", "source_missing"),
|
||||
# An upload's original went away before its conversion ran — our wording (UPLOAD_MISSING_MESSAGE).
|
||||
(UPLOAD_MISSING_MESSAGE.lower().rstrip("."), "upload_missing"),
|
||||
("source file is missing", "source_missing"),
|
||||
# Private / deleted / removed / terminated.
|
||||
("private video", "unavailable"),
|
||||
@@ -263,6 +266,15 @@ def classify(msg: str | None) -> str:
|
||||
return "unknown"
|
||||
|
||||
|
||||
def classify_conversion(msg: str | None) -> str:
|
||||
"""The category of an upload's failed conversion. The generic ffmpeg categories speak of a
|
||||
download ("the video downloaded but…"), which an upload never was: a failed encode or an
|
||||
unrecognised failure of one is `convert_failed`; a category of its own (a full quota, a missing
|
||||
original) stays as it is."""
|
||||
code = classify(msg)
|
||||
return "convert_failed" if code in ("postprocess", "unknown") else code
|
||||
|
||||
|
||||
def diagnose(
|
||||
msg: str | None, *, url: str | None = None, cookie_attached: bool = False
|
||||
) -> Diagnosis:
|
||||
|
||||
@@ -182,7 +182,9 @@ _DEFAULTS = {
|
||||
# v4: EVERY download is clamped to the mp4/H.264/AAC compat profile (no more mkv/webm/VP9/AV1/Opus
|
||||
# opt-out), so any asset cached under an old non-compat spec — or a stale non-H.264 mp4 that slipped
|
||||
# the v3 net — must re-derive through the current browser-safe guarantee.
|
||||
_SIG_VERSION = 4
|
||||
# v5: the phone-safe rule (`playable`) also judges the pixel format, so a cached 10-bit / 4:4:4 H.264
|
||||
# asset (which v4 let through as "H.264") re-derives as 8-bit 4:2:0 instead of being reused.
|
||||
_SIG_VERSION = 5
|
||||
# Audio-only downloads version on their own, so a change to the audio bytes doesn't re-download every
|
||||
# cached video. v5: an extracted .mp3/.m4a is stored as itself instead of remuxed into an .mp4 that
|
||||
# iPad's WebKit refuses to play, so a cached remuxed audio asset must re-derive.
|
||||
@@ -219,12 +221,13 @@ def normalize(spec: dict) -> dict:
|
||||
s["embed_subs"] = False
|
||||
# Browser-playability guarantee: the Download module exists to download → share → PLAY
|
||||
# in-browser, so every output must play in EVERY browser, iOS/Safari/WebKit included. The only
|
||||
# universally-safe combo is mp4 + H.264 + AAC/MP3, so we CLAMP the spec to it HERE — the single
|
||||
# point every path (picker, stored profile, raw API) flows through — instead of only hiding
|
||||
# options in the UI. Non-mp4 containers (mkv doesn't play at all; webm/VP9/AV1 fail iOS) and any
|
||||
# explicit vcodec are coerced to the compat profile; Opus audio (Safari-flaky as a bare file)
|
||||
# falls back to AAC/m4a. A source that exposes no H.264 is still caught by the worker's
|
||||
# browser-safe re-encode (`_ensure_browser_playable`), so playback is guaranteed end-to-end.
|
||||
# universally-safe combo is mp4 + H.264 + AAC (an .mp3/.m4a for audio), so we CLAMP the spec to
|
||||
# it HERE — the single point every path (picker, stored profile, raw API) flows through — instead
|
||||
# of only hiding options in the UI. Non-mp4 containers (mkv plays nowhere on iOS; AV1 fails there,
|
||||
# and webm/VP9 plays only on recent devices) and any explicit vcodec are coerced to the compat
|
||||
# profile; Opus audio falls back to AAC/m4a. A source that exposes no H.264 (or a 10-bit one) is
|
||||
# still caught by the worker's phone-safe step (`downloads.playable`), so playback is guaranteed
|
||||
# end-to-end.
|
||||
s["container"] = "mp4"
|
||||
s["vcodec"] = None
|
||||
if s["audio_format"] not in ("m4a", "mp3"):
|
||||
@@ -351,7 +354,7 @@ def build_ydl_opts(
|
||||
# inside mp4 — a VP9/AV1-in-mp4 file shows a broken player there — so H.264 is selected at the
|
||||
# SELECTION level, not merely preferred: a sort key can't conjure an H.264 stream a source
|
||||
# doesn't expose as video-only. A source that ships only non-H.264 is caught after download by
|
||||
# the worker's browser-safe re-encode (`_ensure_browser_playable`).
|
||||
# the worker's phone-safe step (`downloads.playable`).
|
||||
if s["mode"] == "v":
|
||||
# Video-only: keep it audio-free. Prefer an H.264 video-only stream, then any video-only,
|
||||
# and only fall to a progressive (audio-bearing) `best` as a last resort.
|
||||
|
||||
+48
-17
@@ -6,12 +6,13 @@ Five passes each run:
|
||||
2. Expiry deletion — delete assets past their TTL (files + row). The FK SET NULL nulls the
|
||||
holders' jobs' asset_id, so those jobs surface as "expired" (re-downloadable) in the UI.
|
||||
3. LRU eviction — if a total cache cap is set and exceeded, delete least-recently-accessed
|
||||
ready assets until back under it.
|
||||
ready assets (never an upload) until back under it.
|
||||
4. Orphan reap — delete errored, fileless, unreferenced asset rows (they carry no TTL, so
|
||||
passes 1-3 skip them; benign row-count hygiene).
|
||||
5. Orphan FILE sweep — delete on-disk files/dirs no surviving asset owns (cruft left when a
|
||||
re-download renames an asset, e.g. old accented channel dirs), skipping system trees and
|
||||
anything newer than an hour so an in-flight download is never touched.
|
||||
re-download renames an asset, e.g. old accented channel dirs, or an upload's stranded file).
|
||||
It skips the dot-prefixed system trees (all but `.uploads`) and anything newer than an hour,
|
||||
so an in-flight download is never touched.
|
||||
|
||||
Runs in the API process (which mounts DOWNLOAD_ROOT). Pure disk/DB work — no YouTube quota.
|
||||
"""
|
||||
@@ -20,19 +21,19 @@ import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import logs, sysconfig
|
||||
from app.config import settings
|
||||
from app.downloads import storage
|
||||
from app.downloads import quota, storage
|
||||
from app.models import DownloadJob, MediaAsset
|
||||
from app.notifications import create_notification
|
||||
|
||||
log = logs.get_logger(__name__)
|
||||
# Dot-prefixed top-level trees that are NOT part of the Plex/flat asset layout — the orphan sweep
|
||||
# never touches them (user clips, Plex HLS/img caches, in-progress staging, editor storyboards).
|
||||
_SYSTEM_TREE = {".edits", ".plex-hls", ".plex-img-cache", ".staging", ".storyboards"}
|
||||
# The one dot-prefixed top-level tree the orphan sweep enters. The others are not part of the asset
|
||||
# layout (user clips, Plex HLS/img caches, in-progress staging, editor storyboards) and are skipped.
|
||||
_UPLOAD_TREE = ".uploads"
|
||||
_ORPHAN_MIN_AGE_S = 3600 # only sweep files older than this, so an in-flight download is never hit
|
||||
|
||||
|
||||
@@ -67,16 +68,20 @@ def _delete_asset(db: Session, asset: MediaAsset, ntype: str) -> None:
|
||||
def _sweep_orphan_files(db: Session, download_root: str) -> int:
|
||||
"""Delete media/sidecar files (and now-empty dirs) under DOWNLOAD_ROOT that no `ready` asset
|
||||
owns — cruft left when a re-download changes an asset's on-disk name (e.g. an old accented
|
||||
channel dir, or a poster from a superseded naming scheme). Skips the dot-prefixed system trees
|
||||
and only removes files older than `_ORPHAN_MIN_AGE_S`, so a just-completed or in-flight download
|
||||
is never deleted, and only files NOT owned by a surviving asset."""
|
||||
channel dir, or a poster from a superseded naming scheme) — or, under `.uploads`, that no upload
|
||||
row names at all. Skips the other dot-prefixed system trees and only removes files older than
|
||||
`_ORPHAN_MIN_AGE_S`, so a just-completed or in-flight download is never deleted, and only files
|
||||
NOT owned by a surviving asset."""
|
||||
root = Path(download_root)
|
||||
if not root.is_dir():
|
||||
return 0
|
||||
keep: set[Path] = set()
|
||||
# An upload's file is on disk from ingest on, whatever its state (a queued conversion's original,
|
||||
# a failed one kept for Retry), so every upload row keeps its file — not only a ready one.
|
||||
for rel in db.execute(
|
||||
select(MediaAsset.rel_path).where(
|
||||
MediaAsset.status == "ready", MediaAsset.rel_path.is_not(None)
|
||||
or_(MediaAsset.status == "ready", MediaAsset.source_kind == "upload"),
|
||||
MediaAsset.rel_path.is_not(None),
|
||||
)
|
||||
).scalars():
|
||||
media = (root / rel).resolve()
|
||||
@@ -99,7 +104,10 @@ def _sweep_orphan_files(db: Session, download_root: str) -> int:
|
||||
|
||||
unreclaimed = 0
|
||||
for entry in root.iterdir():
|
||||
if entry.name.startswith("."): # .edits / .plex-* / .staging / .storyboards + any dotfile
|
||||
# .edits / .plex-* / .staging / .storyboards + any dotfile — but `.uploads` is swept: a
|
||||
# conversion stopped between placing the new file and the commit that points at it (or the
|
||||
# unlink of the original after it) leaves a file no row names, and nothing else finds it.
|
||||
if entry.name.startswith(".") and entry.name != _UPLOAD_TREE:
|
||||
continue
|
||||
files = [entry] if entry.is_file() else [p for p in entry.rglob("*") if p.is_file()]
|
||||
for f in files:
|
||||
@@ -188,17 +196,17 @@ def run_download_gc(db: Session) -> dict:
|
||||
if cap > 0:
|
||||
total = (
|
||||
db.execute(
|
||||
select(func.coalesce(func.sum(MediaAsset.size_bytes), 0)).where(
|
||||
MediaAsset.status == "ready"
|
||||
)
|
||||
select(func.coalesce(func.sum(MediaAsset.size_bytes), 0)).where(quota.ON_DISK)
|
||||
).scalar()
|
||||
or 0
|
||||
)
|
||||
if total > cap:
|
||||
# An upload counts toward the total (it is disk like any other) but is never evicted:
|
||||
# it has no source to fetch again, and it lives until its owner deletes it.
|
||||
candidates = (
|
||||
db.execute(
|
||||
select(MediaAsset)
|
||||
.where(MediaAsset.status == "ready")
|
||||
.where(MediaAsset.status == "ready", MediaAsset.source_kind != "upload")
|
||||
.order_by(MediaAsset.last_access_at.asc().nulls_first())
|
||||
)
|
||||
.scalars()
|
||||
@@ -232,6 +240,29 @@ def run_download_gc(db: Session) -> dict:
|
||||
if orphans:
|
||||
db.commit()
|
||||
|
||||
# 4b. Reap unheld uploads. An upload carries no expires_at, is never evicted and never re-enqueued,
|
||||
# so the passes above never free one that lost its last holder outside `release_asset` — a
|
||||
# conversion that finished after its job was cancelled, or one cancelled mid-run (the asset
|
||||
# was `downloading` then). Its file, ready or still the unconverted original, is at `rel_path`.
|
||||
unheld = (
|
||||
db.execute(
|
||||
select(MediaAsset).where(
|
||||
MediaAsset.source_kind == "upload",
|
||||
MediaAsset.ref_count == 0,
|
||||
MediaAsset.status != "downloading",
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
for asset in unheld:
|
||||
if asset.rel_path: # no holder is left to tell, so no notice — just the file and the row
|
||||
storage.delete_asset_files(settings.download_root, asset.rel_path)
|
||||
db.delete(asset)
|
||||
reaped += 1
|
||||
if unheld:
|
||||
db.commit()
|
||||
|
||||
# 5. Orphan FILE sweep — remove on-disk cruft no surviving asset owns (old accented dirs, stale
|
||||
# posters). Runs last so the keep-set reflects the assets that survived passes 2-4.
|
||||
swept = _sweep_orphan_files(db, settings.download_root)
|
||||
|
||||
@@ -147,13 +147,14 @@ def _watch_tags(token: str, db: Session) -> str | None:
|
||||
return "\n ".join(tags)
|
||||
|
||||
|
||||
def render_watch_html(token: str, index_html: Path, db: Session) -> str | None:
|
||||
"""Return index.html with the OG block replaced for this watch token, or None to serve the
|
||||
generic page unchanged (invalid/expired/not-ready link, or a template without the markers)."""
|
||||
def render_watch_html(token: str, page_html: Path, db: Session) -> str | None:
|
||||
"""Return the watch page (watch.html) with the OG block replaced for this token, or None to
|
||||
serve the generic page unchanged (invalid/expired/not-ready link, or a template without the
|
||||
markers)."""
|
||||
tags = _watch_tags(token, db)
|
||||
if tags is None:
|
||||
return None
|
||||
text = index_html.read_text(encoding="utf-8")
|
||||
text = page_html.read_text(encoding="utf-8")
|
||||
if "<!--OG:START-->" not in text:
|
||||
return None
|
||||
# A function replacement avoids re.sub interpreting backslashes/group refs in `tags`.
|
||||
|
||||
@@ -13,7 +13,8 @@ from app.config import settings
|
||||
from app.db import SessionLocal
|
||||
from app.downloads import edit as editmod
|
||||
from app.downloads import errors as dlerrors
|
||||
from app.downloads import adapters, credentials, formats, mediakind, probe, quota, service, storage
|
||||
from app.downloads import adapters, credentials, formats, mediakind, playable, probe, quota
|
||||
from app.downloads import service, storage
|
||||
from app.downloads import urlguard
|
||||
from app.downloads.jobdb import (
|
||||
_edit_staging_dir,
|
||||
@@ -58,6 +59,10 @@ _DB_WRITE_INTERVAL_S = 1.0
|
||||
_CANCEL_POLL_INTERVAL_S = 1.0
|
||||
|
||||
_THUMB_SUFFIXES = (".jpg", ".jpeg", ".png", ".webp")
|
||||
# The job phase while a file is brought into the phone-safe shape (`_ensure_phone_safe`) for a
|
||||
# download or an upload. Its own word, not yt-dlp's "processing": it reports ffmpeg's real progress,
|
||||
# so the queue row shows a % for it, where yt-dlp's post-steps only get an indeterminate pulse.
|
||||
CONVERTING = "converting"
|
||||
# yt-dlp's working files in the staging dir. None of them is ever the finished media, but several
|
||||
# CAN be the biggest file there (a video-only per-format stream, an abandoned fragment run), so the
|
||||
# staging fallback must exclude them by name — picking one would store e.g. a silent video-only
|
||||
@@ -75,7 +80,7 @@ def _notify_download_done(db, user_id: int | None, title: str | None, job_id, *,
|
||||
"""Post a best-effort 'ready' notice to the owner's inbox — downloads/edits used to finish
|
||||
silently while scheduler jobs and playlist-push already notified (feedback symmetry). A
|
||||
notification failure must never mask the completed job, so it is isolated + rolled back (mirrors
|
||||
scheduler._notify_done). `kind` ("download"/"edit") lets the inbox pick accurate copy; the
|
||||
scheduler._notify_done). `kind` ("download"/"edit"/"upload") lets the inbox pick accurate copy; the
|
||||
English title here is only a fallback for an unknown type. `user_id`/`title` are captured by the
|
||||
caller BEFORE its commit, so reading them here needs no post-commit refresh of an expired row."""
|
||||
if user_id is None:
|
||||
@@ -416,48 +421,50 @@ def _download_settings() -> tuple[str, int]:
|
||||
)
|
||||
|
||||
|
||||
def _ensure_browser_playable(job_id: int, produced: Path, spec: dict, info: dict) -> Path:
|
||||
"""Make an mp4-compat-profile download play in every browser: H.264 + AAC/MP3 in mp4. If the
|
||||
download (whatever the source shipped) isn't already that, re-encode the offending stream(s) —
|
||||
or just remux when the codecs are fine but the container isn't. **Scope:** only the mp4 compat
|
||||
profile is normalized; an explicit `vcodec` or a non-mp4 custom profile is the user opting out
|
||||
of the guarantee and is left as-is (so a `webm` profile can still ship VP9/Opus). Returns the
|
||||
file to store (converted, or the original) and corrects `info`'s codec fields to match disk.
|
||||
def _ensure_phone_safe(
|
||||
job_id: int,
|
||||
produced: Path,
|
||||
out_dir: Path,
|
||||
phase: str,
|
||||
total_s: int | None = None,
|
||||
*,
|
||||
keep_source: bool = False,
|
||||
) -> Path:
|
||||
"""Bring a file that is about to become a ready library asset into the phone-safe shape
|
||||
(`playable`), writing any converted copy into `out_dir`. Returns the file to store — `produced`
|
||||
itself when it already is that shape, or isn't media at all — and removes `produced` once a
|
||||
converted copy exists, unless `keep_source` (an upload's original, which must outlive the
|
||||
conversion until the database points at the new file). The job shows `phase` with real ffmpeg
|
||||
progress while it converts.
|
||||
|
||||
An audio download (mode "a") is never touched: yt-dlp already extracted it to .mp3 / .m4a, which
|
||||
every browser plays as audio/mpeg / audio/mp4. Remuxing it into .mp4 served an mp3 as video/mp4,
|
||||
which iPad's WebKit refuses to play (prod job 268)."""
|
||||
if spec.get("mode") == "a" or spec.get("container") != "mp4" or spec.get("vcodec"):
|
||||
Every ready path calls this — a download, an upload's worker conversion and an editor clip — so
|
||||
a share link can only ever serve the one shape every phone and browser plays."""
|
||||
p = playable.plan(probe.probe_json(produced), produced.suffix)
|
||||
if p is None or not p.needs_change:
|
||||
return produced
|
||||
vcodec, acodec = editmod.probe_codecs(produced)
|
||||
is_mp4 = produced.suffix.lower() == ".mp4"
|
||||
needs, reenc_v, reenc_a = editmod.browser_transcode_flags(vcodec, acodec, is_mp4)
|
||||
if not needs:
|
||||
return produced
|
||||
|
||||
dest = produced.with_name(produced.stem + ".browser.mp4")
|
||||
action = "re-encode" if (reenc_v or reenc_a) else "remux"
|
||||
dest = out_dir / f"{produced.stem}.safe.{p.ext}"
|
||||
action = "re-encode" if (p.reencode_video or p.reencode_audio) else "remux"
|
||||
log.info(
|
||||
"job %s: %s (v=%s a=%s mp4=%s) -> browser-safe mp4", job_id, action, vcodec, acodec, is_mp4
|
||||
"job %s: %s (video=%s audio=%s) -> phone-safe .%s",
|
||||
job_id,
|
||||
action,
|
||||
p.reencode_video,
|
||||
p.reencode_audio,
|
||||
p.ext,
|
||||
extra=logs.event("download.phone_safe", job_id=job_id, action=action, ext=p.ext),
|
||||
)
|
||||
_set_job(job_id, phase="processing", progress=0, speed_bps=None, eta_s=None)
|
||||
# Prefer the info duration; probe the file when the extractor didn't provide one, so a long
|
||||
# re-encode still shows real progress instead of a frozen 0%.
|
||||
total_s = int(info["duration"]) if info.get("duration") else editmod.probe_duration(produced)
|
||||
_set_job(job_id, phase=phase, progress=0, speed_bps=None, eta_s=None)
|
||||
# A re-encode with no known length would hold a frozen 0%; probe the file for a denominator.
|
||||
editmod.run_ffmpeg(
|
||||
editmod.build_browser_safe_cmd(
|
||||
produced, dest, reenc_v, reenc_a, threads=editmod.encode_thread_cap()
|
||||
),
|
||||
total_s=total_s,
|
||||
on_progress=_ffmpeg_progress_writer(job_id, "processing"),
|
||||
playable.build_cmd(produced, dest, p, threads=editmod.encode_thread_cap()),
|
||||
total_s=total_s or editmod.probe_duration(produced),
|
||||
on_progress=_ffmpeg_progress_writer(job_id, phase),
|
||||
should_cancel=_cancel_poll(job_id),
|
||||
)
|
||||
produced.unlink(missing_ok=True)
|
||||
# Keep the stored asset metadata truthful about what's now on disk.
|
||||
if reenc_v:
|
||||
info["vcodec"] = "h264"
|
||||
if reenc_a:
|
||||
info["acodec"] = "aac"
|
||||
if not dest.exists():
|
||||
raise StatedFailure("ffmpeg produced no output file")
|
||||
if not keep_source:
|
||||
produced.unlink(missing_ok=True)
|
||||
return dest
|
||||
|
||||
|
||||
@@ -522,7 +529,13 @@ def _download(
|
||||
adapter = adapters.resolve_adapter(source_kind, source_ref, info)
|
||||
|
||||
produced = _produced_file(info, staging)
|
||||
produced = _ensure_browser_playable(job_id, produced, spec, info)
|
||||
produced = _ensure_phone_safe(
|
||||
job_id,
|
||||
produced,
|
||||
staging,
|
||||
CONVERTING,
|
||||
int(info["duration"]) if info.get("duration") else None,
|
||||
)
|
||||
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
|
||||
@@ -722,9 +735,12 @@ 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) -> None:
|
||||
"""Fail an edit job, and say so ONCE — every way an edit fails comes through here, so this is
|
||||
where its line and its event code live. `exc` is the exception when there was one.
|
||||
def _fail_edit(
|
||||
job_id: int, asset_id: int, msg: str, exc: BaseException | None = None, *, what: str = "edit"
|
||||
) -> 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.
|
||||
|
||||
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."""
|
||||
@@ -739,19 +755,22 @@ def _fail_edit(job_id: int, asset_id: int, msg: str, exc: BaseException | None =
|
||||
job_id,
|
||||
status=JobStatus.ERROR,
|
||||
error=msg,
|
||||
error_code=dlerrors.classify(msg),
|
||||
error_code=dlerrors.classify_conversion(msg)
|
||||
if what == "upload"
|
||||
else dlerrors.classify(msg),
|
||||
phase=None,
|
||||
)
|
||||
db.commit()
|
||||
# `msg` is the user's copy; ffmpeg's stderr tail in it can name the source file, whose path is
|
||||
# built from the channel and the title.
|
||||
log.warning(
|
||||
"edit job %s failed: %s",
|
||||
"%s job %s failed: %s",
|
||||
what,
|
||||
job_id,
|
||||
formats.log_safe(msg),
|
||||
extra={
|
||||
**logs.event(
|
||||
"edit.job.failed",
|
||||
f"{what}.job.failed",
|
||||
job_id=job_id,
|
||||
asset_id=asset_id,
|
||||
**({"error_type": type(exc).__name__} if exc is not None else {}),
|
||||
@@ -811,11 +830,17 @@ def _process_edit(job_id: int, asset_id: int) -> None:
|
||||
)
|
||||
if not dest_staging.exists():
|
||||
raise StatedFailure("ffmpeg produced no output file")
|
||||
# A fast trim stream-copies the source's codecs and container, so the clip is only as
|
||||
# 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
|
||||
|
||||
rel = storage.edit_rel_path(user_id, asset_id, out_ext)
|
||||
storage.place_file(dest_staging, settings.download_root, rel)
|
||||
final = storage.abs_path(settings.download_root, rel)
|
||||
size = final.stat().st_size if final.exists() else None
|
||||
# The clip's real frame: a re-encode evens an odd crop or source (`editmod.EVEN_FRAME`).
|
||||
_, _, out_w, out_h, _ = probe.probe_media(final)
|
||||
_, retention_days = _download_settings()
|
||||
expires = datetime.now(timezone.utc) + timedelta(days=retention_days)
|
||||
|
||||
@@ -827,7 +852,9 @@ def _process_edit(job_id: int, asset_id: int) -> None:
|
||||
asset.size_bytes = size
|
||||
asset.container = out_ext
|
||||
asset.duration_s = total_s or asset.duration_s
|
||||
if crop:
|
||||
if out_w and out_h:
|
||||
asset.width, asset.height = out_w, out_h
|
||||
elif crop:
|
||||
asset.width = edit_spec["crop"]["w"]
|
||||
asset.height = edit_spec["crop"]["h"]
|
||||
else:
|
||||
@@ -867,3 +894,163 @@ def _process_edit(job_id: int, asset_id: int) -> None:
|
||||
_fail_edit(job_id, asset_id, str(exc)[:500], exc)
|
||||
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,
|
||||
for the owner to delete or keep."""
|
||||
if owner_id is None:
|
||||
return
|
||||
with SessionLocal() as db:
|
||||
if not quota.fits_after_conversion(db, owner_id, old_size, new_size):
|
||||
raise StatedFailure(dlerrors.QUOTA_FULL_MESSAGE)
|
||||
|
||||
|
||||
def _unplace(placed: Path | None) -> None:
|
||||
"""Remove a converted upload that was placed at its new rel but never committed: the row still
|
||||
names the original, so nothing else would ever find this file."""
|
||||
if placed is not None:
|
||||
placed.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _conversion_landed(asset_id: int, new_rel: str) -> bool | None:
|
||||
"""After the commit that makes a converted upload ready RAISED: did it land anyway? A commit can
|
||||
reach the server and lose its answer (the connection drops after COMMIT). True/False by what the
|
||||
row now says; None when the row cannot be read either."""
|
||||
try:
|
||||
with SessionLocal() as db:
|
||||
asset = db.get(MediaAsset, asset_id)
|
||||
return asset is not None and asset.status == "ready" and asset.rel_path == new_rel
|
||||
except Exception: # noqa: BLE001 — "cannot tell" is the answer, the caller acts on it
|
||||
# silent-ok: the caller re-raises the commit's own error, which `_fail_edit` reports.
|
||||
return None
|
||||
|
||||
|
||||
def _drop_original(job_id: int, src: Path) -> None:
|
||||
"""Remove an upload's original once the converted file is committed in its place. Best effort:
|
||||
the job is done and its file is ready, so a failure here must not fail it — the original is
|
||||
named by no row now, and the GC's `.uploads` sweep takes it."""
|
||||
try:
|
||||
src.unlink(missing_ok=True)
|
||||
except OSError as exc:
|
||||
log.warning(
|
||||
"upload job %s: original not removed, left to the GC sweep: %s",
|
||||
job_id,
|
||||
exc,
|
||||
extra=logs.event("upload.original.unlink_failed", job_id=job_id),
|
||||
)
|
||||
|
||||
|
||||
def _free_unheld_upload(asset_id: int) -> bool:
|
||||
"""Free an upload no job holds any more — cancelled or deleted while its conversion ran, when
|
||||
`release_asset` had to leave it because the conversion still read its file. Once that has
|
||||
stopped, free it as the release would have; left alone, its file would sit on disk counted by no
|
||||
owner's footprint until the GC's upload pass. False when a job still holds it (paused)."""
|
||||
with SessionLocal() as db:
|
||||
asset = db.get(MediaAsset, asset_id)
|
||||
if asset is None or asset.ref_count != 0:
|
||||
return False
|
||||
if asset.rel_path:
|
||||
storage.delete_asset_files(settings.download_root, asset.rel_path)
|
||||
db.delete(asset)
|
||||
db.commit()
|
||||
return True
|
||||
|
||||
|
||||
def _convert_upload(job_id: int, asset_id: int) -> None:
|
||||
"""Convert a queued upload into the phone-safe shape (`playable`) and make it ready.
|
||||
|
||||
The original sits at the asset's `rel_path` (see `upload`); the converted file replaces it there
|
||||
under the new extension, and only then is the original removed — so a failed or stopped
|
||||
conversion leaves the original in place for Retry, and a delete still finds it by `rel_path`.
|
||||
A converted file placed but never committed is removed again on the way out. When the commit's
|
||||
own outcome is unknown neither file is removed: the row names one of them, and the GC's `.uploads`
|
||||
sweep takes the one it does not — as it does an original whose unlink after the commit failed."""
|
||||
with SessionLocal() as db:
|
||||
asset = db.get(MediaAsset, asset_id)
|
||||
old_rel = asset.rel_path if asset else None
|
||||
old_ext = asset.container if asset else None
|
||||
old_size = (asset.size_bytes or 0) if asset else 0
|
||||
duration = asset.duration_s if asset else None
|
||||
job = db.get(DownloadJob, job_id)
|
||||
owner_id = job.user_id if job else None
|
||||
src = storage.safe_abs_path(settings.download_root, old_rel) if old_rel else None
|
||||
if old_rel is None or src is None or not src.exists():
|
||||
_fail_edit(job_id, asset_id, dlerrors.UPLOAD_MISSING_MESSAGE, what="upload")
|
||||
return
|
||||
staging = _staging_dir(asset_id)
|
||||
placed: Path | None = None # the converted file while it is at its new rel, uncommitted
|
||||
try:
|
||||
shutil.rmtree(staging, ignore_errors=True)
|
||||
staging.mkdir(parents=True, exist_ok=True)
|
||||
safe = _ensure_phone_safe(job_id, src, staging, CONVERTING, duration, keep_source=True)
|
||||
ext = safe.suffix.lstrip(".").lower()
|
||||
new_rel = storage.with_ext(old_rel, old_ext, ext)
|
||||
if safe != src:
|
||||
_check_converted_fits(owner_id, old_size, safe.stat().st_size)
|
||||
storage.place_file(safe, settings.download_root, new_rel)
|
||||
if new_rel != old_rel:
|
||||
placed = storage.abs_path(settings.download_root, new_rel)
|
||||
final = storage.abs_path(settings.download_root, new_rel)
|
||||
vcodec, acodec, width, height, dur = probe.probe_media(final)
|
||||
with SessionLocal() as db:
|
||||
asset = db.get(MediaAsset, asset_id)
|
||||
if asset:
|
||||
asset.status = "ready"
|
||||
asset.rel_path = new_rel
|
||||
asset.container = ext
|
||||
asset.size_bytes = final.stat().st_size
|
||||
asset.vcodec, asset.acodec = vcodec, acodec
|
||||
asset.width, asset.height = width or asset.width, height or asset.height
|
||||
asset.duration_s = dur or asset.duration_s
|
||||
asset.error = None
|
||||
asset.last_access_at = datetime.now(timezone.utc)
|
||||
title = asset.title if asset else None
|
||||
finalized = service.finalize_if_running(
|
||||
db, job_id, status=JobStatus.DONE, progress=100, phase=None
|
||||
)
|
||||
try:
|
||||
db.commit()
|
||||
except Exception:
|
||||
landed = _conversion_landed(asset_id, new_rel)
|
||||
if landed is False:
|
||||
raise # rolled back: the original is still the asset's file — fail as any step
|
||||
# Landed, or cannot tell: the converted file may be the asset's file now, so it is
|
||||
# never removed blind (the sweep takes whichever file no row names).
|
||||
placed = None
|
||||
if landed is None:
|
||||
raise
|
||||
db.rollback()
|
||||
placed = None
|
||||
if finalized:
|
||||
_notify_download_done(db, owner_id, title, job_id, kind="upload")
|
||||
if new_rel != old_rel:
|
||||
_drop_original(job_id, src)
|
||||
if not finalized:
|
||||
_free_unheld_upload(asset_id) # finished after its job was cancelled or deleted
|
||||
if finalized:
|
||||
log.info(
|
||||
"upload job %s converted: asset %s",
|
||||
job_id,
|
||||
asset_id,
|
||||
extra=logs.event("upload.job.done", job_id=job_id, asset_id=asset_id, ext=ext),
|
||||
)
|
||||
except editmod.EditAborted:
|
||||
# Paused/canceled mid-encode: the original is untouched. A paused job keeps it for its
|
||||
# resume; one cancelled or deleted holds it no more, so it is freed now.
|
||||
_unplace(placed)
|
||||
if not _free_unheld_upload(asset_id):
|
||||
_reset_asset_pending(asset_id)
|
||||
log.info(
|
||||
"upload job %s conversion aborted (pause/cancel)",
|
||||
job_id,
|
||||
extra=logs.event("upload.job.aborted", job_id=job_id),
|
||||
)
|
||||
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.
|
||||
_unplace(placed)
|
||||
_fail_edit(job_id, asset_id, str(exc)[:500], exc, what="upload")
|
||||
|
||||
finally:
|
||||
shutil.rmtree(staging, ignore_errors=True)
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
"""The phone-safe rule: what a library file must be so it plays wherever a share link lands.
|
||||
|
||||
A shared `/watch` link is opened by whoever got it, on whatever they hold: an iPhone's WebKit, an
|
||||
Android Chrome, a desktop Firefox. The one shape every one of them plays:
|
||||
|
||||
* video → H.264, 8-bit 4:2:0 (`yuv420p`, or its full-range twin `yuvj420p`), AAC audio, in .mp4
|
||||
* audio only → MP3 in .mp3, or AAC in .m4a
|
||||
|
||||
Measured on the owner's iPhone + iPad (2026-10-05): AV1, Matroska, HEVC tagged `hev1` and MP3
|
||||
inside an .mp4 all fail there. VP9/WebM, HEVC `hvc1` and 10-bit H.264 played on those devices, but
|
||||
Chrome and Firefox do not decode HEVC and browsers decode 10-bit / 4:4:4 H.264 only patchily, so the
|
||||
rule is the shape every browser plays, not the shape one device plays.
|
||||
|
||||
Every file that becomes a ready library asset passes through here — a download, an upload (the
|
||||
worker converts it, see `upload`), and an editor clip — so a link can only ever serve this shape.
|
||||
`plan` says what (if anything) must change; `build_cmd` is the one ffmpeg command that changes it:
|
||||
re-encode only the stream that fails, copy the rest, or just remux when only the container is wrong.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from app.downloads import edit as editmod
|
||||
from app.downloads import probe
|
||||
|
||||
_SAFE_VCODEC = "h264"
|
||||
_SAFE_PIX_FMTS = frozenset({"yuv420p", "yuvj420p"})
|
||||
_VIDEO_EXT = "mp4"
|
||||
# An audio-only file: the codec, and the extension it plays in. Anything else becomes AAC in .m4a.
|
||||
_AUDIO_HOME = {"mp3": "mp3", "aac": "m4a"}
|
||||
_AUDIO_FALLBACK_EXT = "m4a"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Plan:
|
||||
"""What makes one file phone-safe. `ext` is the extension the safe file has (no dot)."""
|
||||
|
||||
ext: str
|
||||
has_video: bool
|
||||
reencode_video: bool
|
||||
reencode_audio: bool
|
||||
needs_change: bool
|
||||
|
||||
|
||||
def plan(data: dict | None, ext: str) -> Plan | None:
|
||||
"""The phone-safe plan for a file whose `probe.probe_json` is `data` and whose extension is
|
||||
`ext`; None when it is not media at all (an unreadable probe, a still image, no audio or video
|
||||
stream) — such a file is a download-only 'file' and never plays inline, so there is nothing to
|
||||
make safe."""
|
||||
if data is None:
|
||||
return None
|
||||
video, audio = probe.primary_streams(data)
|
||||
ext = ext.lower().lstrip(".")
|
||||
if video is not None:
|
||||
rv = (video.get("codec_name") or "").lower() != _SAFE_VCODEC or (
|
||||
video.get("pix_fmt") or ""
|
||||
).lower() not in _SAFE_PIX_FMTS
|
||||
ra = audio is not None and (audio.get("codec_name") or "").lower() != "aac"
|
||||
return Plan(_VIDEO_EXT, True, rv, ra, rv or ra or ext != _VIDEO_EXT)
|
||||
if audio is not None:
|
||||
home = _AUDIO_HOME.get((audio.get("codec_name") or "").lower())
|
||||
target = home or _AUDIO_FALLBACK_EXT
|
||||
return Plan(target, False, False, home is None, home is None or ext != target)
|
||||
return None
|
||||
|
||||
|
||||
def build_cmd(src: Path, dest: Path, p: Plan, threads: int | None = None) -> list[str]:
|
||||
"""The ffmpeg command that writes `src` to `dest` (whose suffix is `p.ext`) in the phone-safe
|
||||
shape: the failing stream(s) re-encoded with the package's shared encoder settings, the rest
|
||||
stream-copied. An audio-only result drops any cover-art stream. `-progress pipe:1`
|
||||
streams machine-readable progress; `threads` caps the encoder so a re-encode cannot claim every
|
||||
core on the shared box (see the worker-child de-prioritization)."""
|
||||
cmd = [
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-nostdin",
|
||||
"-progress",
|
||||
"pipe:1",
|
||||
"-nostats",
|
||||
*editmod.INPUT_GUARD,
|
||||
"-i",
|
||||
str(src),
|
||||
]
|
||||
# Exactly the two streams `plan` judged: `V` is a video stream that is not cover art. Nothing
|
||||
# else rides along — a subtitle or data track the .mp4 muxer cannot take would fail the run.
|
||||
if p.has_video:
|
||||
cmd += ["-map", "0:V:0", "-map", "0:a:0?"]
|
||||
if p.reencode_video:
|
||||
cmd += ["-vf", editmod.EVEN_FRAME, *editmod.X264_ARGS]
|
||||
else:
|
||||
cmd += ["-c:v", "copy"]
|
||||
else:
|
||||
cmd += ["-map", "0:a:0"]
|
||||
cmd += editmod.AAC_ARGS if p.reencode_audio else ["-c:a", "copy"]
|
||||
if threads and threads > 0:
|
||||
cmd += ["-threads", str(threads)]
|
||||
if p.ext in ("mp4", "m4a"):
|
||||
cmd += ["-movflags", "+faststart"]
|
||||
cmd += [str(dest)]
|
||||
return cmd
|
||||
@@ -34,6 +34,28 @@ _IMAGE_FORMATS = {
|
||||
}
|
||||
|
||||
|
||||
def primary_streams(data: dict) -> tuple[dict | None, dict | None]:
|
||||
"""(video stream, audio stream) — the first of each kind — from a NON-None ffprobe JSON dict, by
|
||||
the same reading the stored facts use: a still image / image-sequence container has neither (it
|
||||
is a generic 'file'), and a video stream flagged `attached_pic` (an mp3/m4a's cover art) is not a
|
||||
video track. The phone-safe rule (`playable`) judges exactly these two streams."""
|
||||
fmt_names = {
|
||||
n.strip() for n in ((data.get("format") or {}).get("format_name") or "").split(",")
|
||||
}
|
||||
if fmt_names & _IMAGE_FORMATS:
|
||||
return None, None
|
||||
video = audio = None
|
||||
for stream in data.get("streams", []):
|
||||
kind = stream.get("codec_type")
|
||||
if kind == "video" and video is None:
|
||||
if (stream.get("disposition") or {}).get("attached_pic"):
|
||||
continue # cover art, not a video track
|
||||
video = stream
|
||||
elif kind == "audio" and audio is None:
|
||||
audio = stream
|
||||
return video, audio
|
||||
|
||||
|
||||
def _facts_from_probe(
|
||||
data: dict,
|
||||
) -> tuple[str | None, str | None, int | None, int | None, int | None]:
|
||||
@@ -45,19 +67,11 @@ def _facts_from_probe(
|
||||
fmt_names = {n.strip() for n in (fmt.get("format_name") or "").split(",")}
|
||||
if fmt_names & _IMAGE_FORMATS:
|
||||
return None, None, None, None, None
|
||||
|
||||
vcodec = acodec = None
|
||||
width = height = None
|
||||
for stream in data.get("streams", []):
|
||||
kind = stream.get("codec_type")
|
||||
if kind == "video" and vcodec is None:
|
||||
if (stream.get("disposition") or {}).get("attached_pic"):
|
||||
continue # cover art, not a video track
|
||||
vcodec = stream.get("codec_name")
|
||||
width = stream.get("width")
|
||||
height = stream.get("height")
|
||||
elif kind == "audio" and acodec is None:
|
||||
acodec = stream.get("codec_name")
|
||||
video, audio = primary_streams(data)
|
||||
vcodec = video.get("codec_name") if video else None
|
||||
width = video.get("width") if video else None
|
||||
height = video.get("height") if video else None
|
||||
acodec = audio.get("codec_name") if audio else None
|
||||
|
||||
duration = None
|
||||
try:
|
||||
@@ -71,16 +85,29 @@ def _facts_from_probe(
|
||||
return vcodec, acodec, width, height, duration
|
||||
|
||||
|
||||
def probe_media(path: Path) -> tuple[str | None, str | None, int | None, int | None, int | None]:
|
||||
"""(vcodec, acodec, width, height, duration_s) from one ffprobe call; all None if ffprobe fails
|
||||
or the file isn't playable media (an unprobeable file — or a still image — is treated as a generic
|
||||
non-media file, so it becomes a download-only 'file' rather than a broken 'video')."""
|
||||
data = editmod.run_ffprobe_json(path, show_streams=True, show_format=True)
|
||||
def probe_json(path: Path) -> dict | None:
|
||||
"""The full ffprobe JSON (streams + format) of `path`, or None when ffprobe could not read it —
|
||||
the one call both the stored facts (`facts`) and the phone-safe rule read from."""
|
||||
return editmod.run_ffprobe_json(path, show_streams=True, show_format=True)
|
||||
|
||||
|
||||
def facts(
|
||||
data: dict | None,
|
||||
) -> tuple[str | None, str | None, int | None, int | None, int | None]:
|
||||
"""(vcodec, acodec, width, height, duration_s) of a `probe_json` result; all None when it is None
|
||||
(an unprobeable file is treated as a generic non-media file, so it becomes a download-only 'file'
|
||||
rather than a broken 'video')."""
|
||||
if data is None:
|
||||
return None, None, None, None, None
|
||||
return _facts_from_probe(data)
|
||||
|
||||
|
||||
def probe_media(path: Path) -> tuple[str | None, str | None, int | None, int | None, int | None]:
|
||||
"""(vcodec, acodec, width, height, duration_s) from one ffprobe call; all None if ffprobe fails
|
||||
or the file isn't playable media (a still image is a generic 'file', see `primary_streams`)."""
|
||||
return facts(probe_json(path))
|
||||
|
||||
|
||||
def resolve_media_facts(
|
||||
path: Path, info: dict
|
||||
) -> tuple[str | None, str | None, int | None, int | None, int | None]:
|
||||
@@ -94,18 +121,18 @@ def resolve_media_facts(
|
||||
is authoritative — INCLUDING a deliberate None (cover-art-only audio, a still image). A per-field
|
||||
`probe or info` fallback would let `info`'s stale vcodec resurrect a video track the file doesn't
|
||||
have (yt-dlp reports a progressive `vcodec` for an audio-mode download whose video it then strips),
|
||||
mislabelling an audio download as a broken in-app 'video'. Only when ffprobe could NOT read the
|
||||
file at all do we fall back to `info` (the pre-probe behaviour, a safety net); the cosmetic
|
||||
dimensions and the duration still gap-fill from `info` in either case."""
|
||||
mislabelling an audio download as a broken in-app 'video'. When ffprobe could NOT read the file
|
||||
at all, no codec is reported: the probe runs with the media demuxer whitelist, so a file it
|
||||
cannot open is one the player and the editor cannot open either — and taking `info`'s codecs
|
||||
for it would make it an editable 'video' whose bytes are whatever the remote site served. The
|
||||
cosmetic dimensions and the duration still gap-fill from `info` in either case."""
|
||||
data = editmod.run_ffprobe_json(path, show_streams=True, show_format=True)
|
||||
info_v = info.get("vcodec") if info.get("vcodec") not in (None, "none") else None
|
||||
info_a = info.get("acodec") if info.get("acodec") not in (None, "none") else None
|
||||
info_w = int(info["width"]) if info.get("width") else None
|
||||
info_h = int(info["height"]) if info.get("height") else None
|
||||
info_d = int(info["duration"]) if info.get("duration") else None
|
||||
if data is None:
|
||||
# ffprobe could not read the file — fall back to the extractor's info entirely.
|
||||
return info_v, info_a, info_w, info_h, info_d
|
||||
# Unreadable to the guarded probe: a download-only 'file' (no codecs → media_kind FILE).
|
||||
return None, None, info_w, info_h, info_d
|
||||
pv, pa, pw, ph, pd = _facts_from_probe(data)
|
||||
# Probe ran → its codecs win outright (a None means "no such track", not "unknown"); info only
|
||||
# gap-fills the width/height/duration the probe couldn't read.
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"""Per-user download limits + footprint accounting.
|
||||
|
||||
A user's *footprint* is the total size of the ready assets their library references (cache hits
|
||||
count — it's "how much disk you're responsible for", not "how much you personally downloaded").
|
||||
count — it's "how much disk you're responsible for", not "how much you personally downloaded"). An
|
||||
upload counts from the moment it lands, ready or not: its original is on disk while its conversion
|
||||
waits, and stays there when the conversion fails (for Retry) — bytes a ready-only sum never saw.
|
||||
Limits come from a per-user DownloadQuota row if the admin set one, else the sysconfig defaults.
|
||||
`unlimited` (e.g. the admin's own account) bypasses the byte cap.
|
||||
|
||||
@@ -18,16 +20,24 @@ is unknown until it finishes. Edit jobs do not consult it at all (tracked as SEC
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy import and_, func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import sysconfig
|
||||
from app.downloads.status import JobStatus
|
||||
from app.models import DownloadJob, DownloadQuota, MediaAsset
|
||||
|
||||
# Jobs a user is considered to be "holding" (occupy a job slot / footprint). Terminal failures
|
||||
# (error, canceled) don't count.
|
||||
_HOLDING = ("queued", "running", "paused", "done")
|
||||
_RUNNING = ("queued", "running")
|
||||
_HOLDING = (JobStatus.QUEUED, JobStatus.RUNNING, JobStatus.PAUSED, JobStatus.DONE)
|
||||
_RUNNING = (JobStatus.QUEUED, JobStatus.RUNNING)
|
||||
# An asset whose file is on disk: a ready one, or an upload in any state (its original lands at
|
||||
# ingest, see the module docstring). Every byte sum — a user's footprint, the GC's total-cap pass,
|
||||
# the admin's storage view — reads it, so they all count the same disk.
|
||||
ON_DISK = or_(
|
||||
MediaAsset.status == "ready",
|
||||
and_(MediaAsset.source_kind == "upload", MediaAsset.rel_path.is_not(None)),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -67,12 +77,17 @@ def resolve(db: Session, user_id: int) -> QuotaLimits:
|
||||
|
||||
|
||||
def footprint(db: Session, user_id: int) -> int:
|
||||
"""Total bytes of the distinct ready assets this user's active jobs reference."""
|
||||
"""Total bytes of the distinct assets this user's jobs hold on disk: the ready ones their active
|
||||
jobs reference, and every upload of theirs — including one whose conversion failed, which keeps
|
||||
its original (see the module docstring)."""
|
||||
held_assets = (
|
||||
select(DownloadJob.asset_id)
|
||||
.where(
|
||||
DownloadJob.user_id == user_id,
|
||||
DownloadJob.status.in_(_HOLDING),
|
||||
or_(
|
||||
DownloadJob.status.in_(_HOLDING),
|
||||
and_(DownloadJob.job_kind == "upload", DownloadJob.status == JobStatus.ERROR),
|
||||
),
|
||||
DownloadJob.asset_id.is_not(None),
|
||||
)
|
||||
.distinct()
|
||||
@@ -80,7 +95,7 @@ def footprint(db: Session, user_id: int) -> int:
|
||||
return int(
|
||||
db.execute(
|
||||
select(func.coalesce(func.sum(MediaAsset.size_bytes), 0)).where(
|
||||
MediaAsset.id.in_(held_assets), MediaAsset.status == "ready"
|
||||
MediaAsset.id.in_(held_assets), ON_DISK
|
||||
)
|
||||
).scalar()
|
||||
or 0
|
||||
@@ -111,7 +126,7 @@ def at_concurrency_limit(db: Session, user_id: int) -> bool:
|
||||
lim = resolve(db, user_id)
|
||||
if lim.unlimited:
|
||||
return False
|
||||
running = _count(db, user_id, ("running",))
|
||||
running = _count(db, user_id, (JobStatus.RUNNING,))
|
||||
return running >= lim.max_concurrent
|
||||
|
||||
|
||||
@@ -160,6 +175,18 @@ def check_upload(db: Session, user_id: int, add_bytes: int) -> None:
|
||||
raise QuotaExceeded("max_bytes", lim.max_bytes, projected)
|
||||
|
||||
|
||||
def fits_after_conversion(db: Session, user_id: int, old_bytes: int, new_bytes: int) -> bool:
|
||||
"""Whether an upload converted from `old_bytes` (what it is charged at now) to `new_bytes` keeps
|
||||
its owner within the cap. A re-encode can come out larger than the original, and nothing else
|
||||
re-checks it once the upload was let in."""
|
||||
if new_bytes <= old_bytes: # a smaller file never breaks a cap the larger one was let in under
|
||||
return True
|
||||
lim = resolve(db, user_id)
|
||||
if lim.unlimited:
|
||||
return True
|
||||
return footprint(db, user_id) - old_bytes + new_bytes <= lim.max_bytes
|
||||
|
||||
|
||||
def usage(db: Session, user_id: int) -> dict:
|
||||
"""Compact usage snapshot for the UI (footprint + counts vs limits)."""
|
||||
lim = resolve(db, user_id)
|
||||
|
||||
@@ -95,12 +95,14 @@ 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 _download, _process_edit
|
||||
from app.downloads.pipeline import _convert_upload, _download, _process_edit
|
||||
|
||||
with formats.hiding(source_ref):
|
||||
try:
|
||||
if job_kind == "edit":
|
||||
_process_edit(job_id, asset_id)
|
||||
elif job_kind == "upload":
|
||||
_convert_upload(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
|
||||
|
||||
@@ -318,14 +318,22 @@ def release_asset(db: Session, job: DownloadJob) -> None:
|
||||
re-enqueuing the same (source,format) — get_or_create_asset would reset that row to `pending` and
|
||||
+1 it, and deleting it under that would FK-null the new job's asset (a lost update on ref_count).
|
||||
A ready asset is still freed at ref==0 (its file is the point); an orphaned errored row is cheap
|
||||
(no file) and gets reused+reset by the next enqueue, or reclaimed by a later GC pass."""
|
||||
(no file) and gets reused+reset by the next enqueue, or reclaimed by a later GC pass.
|
||||
|
||||
An UPLOAD is the exception: it is never shared or re-enqueued (its ref is unique), and while it
|
||||
waits for or failed its phone-safe conversion the original file already sits at `rel_path` — so
|
||||
it is freed at ref==0 whatever its state, except mid-conversion (`downloading`: the worker child
|
||||
still reads it; the GC upload pass takes it once the child has stopped)."""
|
||||
if not job.asset_id or job.status not in HOLDING:
|
||||
return
|
||||
asset = db.get(MediaAsset, job.asset_id)
|
||||
if asset is None:
|
||||
return
|
||||
new_count = decr_ref_count(db, asset.id)
|
||||
if new_count == 0 and asset.status == "ready":
|
||||
freeable = asset.status == "ready" or (
|
||||
asset.source_kind == "upload" and asset.status != "downloading"
|
||||
)
|
||||
if new_count == 0 and freeable:
|
||||
if asset.rel_path:
|
||||
storage.delete_asset_files(settings.download_root, asset.rel_path)
|
||||
db.delete(asset)
|
||||
|
||||
@@ -168,13 +168,25 @@ def upload_rel_path(user_id: int, asset_id: int, filename: str) -> str:
|
||||
episode sidecars). The name is system-decided and bound to the asset id (`_a{id}` keeps two
|
||||
same-named uploads apart); the original filename is display metadata only. The extension is
|
||||
preserved (shell-safe, bounded) so the served file + its Content-Disposition carry a correct
|
||||
suffix; an extensionless upload stays extensionless."""
|
||||
stem = sanitize(Path(filename).stem, 80)
|
||||
suffix; an extensionless upload stays extensionless.
|
||||
|
||||
The stem holds no dot, so the last dot of the name is always the extension's: every sibling
|
||||
derived by swapping it (`with_suffix` — the poster, the converted file) keeps `_a{id}`. A dotted
|
||||
stem with no extension ('Holiday.2024.ä') once lost it, and two such uploads shared one file."""
|
||||
stem = sanitize(Path(filename).stem, 80).replace(".", "_")
|
||||
ext = _clean_upload_ext(filename)
|
||||
suffix = f".{ext}" if ext else ""
|
||||
return f".uploads/{user_id}/{stem}_a{asset_id}{suffix}"
|
||||
|
||||
|
||||
def with_ext(rel: str, old_ext: str | None, ext: str) -> str:
|
||||
"""`rel` with its extension — `old_ext`, the stored `container`, None for a bare name — swapped
|
||||
for `ext`. Not `Path.with_suffix`: on a bare name whose stem holds a dot (an upload placed before
|
||||
the stems lost their dots) that would take the stem's tail for the extension, and `_a{id}` with it."""
|
||||
base = rel[: -len(old_ext) - 1] if old_ext and rel.endswith(f".{old_ext}") else rel
|
||||
return f"{base}.{ext}"
|
||||
|
||||
|
||||
def upload_ext(filename: str) -> str | None:
|
||||
"""The stored `container` for an uploaded file: its shell-safe lowercased extension, or None when
|
||||
the upload has no extension (a bare name). Shares `_clean_upload_ext` with `upload_rel_path` so the
|
||||
|
||||
@@ -2,14 +2,23 @@
|
||||
|
||||
A user-uploaded file joins the SAME library as a downloaded one, so it inherits the whole function
|
||||
set already built (metadata chips, share links, and — for media — in-app play/edit). Unlike a
|
||||
download it bypasses yt-dlp AND the worker entirely: the file is already on the host, so ingest
|
||||
probes it, places it on disk, and creates an already-`ready` MediaAsset + `done` DownloadJob in one
|
||||
step — the same "the file exists, mark it done now" shortcut `service.enqueue` takes on a cache hit.
|
||||
download it bypasses yt-dlp: the file is already on the host, so ingest probes it and places it on
|
||||
disk. A file that already is the phone-safe shape (`playable`) — or is not media at all — becomes an
|
||||
already-`ready` MediaAsset + `done` DownloadJob in one step, the same "the file exists, mark it done
|
||||
now" shortcut `service.enqueue` takes on a cache hit.
|
||||
|
||||
Media that is NOT phone-safe (an iPhone's HEVC .mov, a VP9 .webm, an .mkv) is placed as it came and
|
||||
queued: the asset stays `pending` and the job `queued`, and the download worker converts it
|
||||
(`pipeline._convert_upload`) — so a share link never hands a friend a file their phone cannot play.
|
||||
Only the converted file is kept (the owner's choice: one file per upload). A 10-bit HDR original
|
||||
becomes 8-bit H.264 with its colour tags kept — still tagged HDR (HLG/PQ, BT.2020), not tone-mapped
|
||||
to SDR (the owner's choice too). Until then the original sits at the asset's `rel_path`, which is
|
||||
what a failed conversion's Retry starts from again, and what a delete removes
|
||||
(`service.release_asset`).
|
||||
|
||||
Kinds (the invariant lives in `mediakind`): a probe with a real video stream -> video, audio-only
|
||||
-> audio, neither -> a generic 'file' (pdf/zip/docx/…) that is download-only and never rendered
|
||||
inline (see the force-download serving in the file/watch routes). This module is the ingest seam a
|
||||
future sub-function (e.g. convert) plugs a new adapter into, mirroring how it sidesteps the worker.
|
||||
inline (see the force-download serving in the file/watch routes).
|
||||
|
||||
An upload does NOT get an `expires_at`: it's user-provided and may be irreplaceable (there's no
|
||||
source to re-download), so it doesn't age out on the retention clock the way a re-fetchable download
|
||||
@@ -24,7 +33,8 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import settings
|
||||
from app.downloads import edit as editmod
|
||||
from app.downloads import mediakind, probe, quota, service, storage
|
||||
from app.downloads import mediakind, playable, probe, quota, service, storage
|
||||
from app.downloads.status import JobStatus
|
||||
from app.models import DownloadJob, MediaAsset, display_text
|
||||
|
||||
|
||||
@@ -44,15 +54,22 @@ def ingest_upload(
|
||||
size: int,
|
||||
display_name: str | None = None,
|
||||
) -> DownloadJob:
|
||||
"""Ingest an already-uploaded file at `src_path` into `user_id`'s library and return the `done`
|
||||
job. `src_path` is MOVED into place on success (the caller stages it to a temp file first).
|
||||
"""Ingest an already-uploaded file at `src_path` into `user_id`'s library and return its job —
|
||||
`done`, or `queued` for the worker's phone-safe conversion (see the module docstring). `src_path`
|
||||
is MOVED into place on success (the caller stages it to a temp file first).
|
||||
|
||||
Raises quota.QuotaExceeded (holdings/footprint cap) — checked BEFORE any disk write, so a
|
||||
rejected upload leaves nothing behind and the caller just cleans up its temp file."""
|
||||
quota.check_upload(db, user_id, size) # the file's size is known → charge it up front
|
||||
|
||||
vcodec, acodec, width, height, duration = probe.probe_media(src_path)
|
||||
data = probe.probe_json(src_path)
|
||||
vcodec, acodec, width, height, duration = probe.facts(data)
|
||||
kind = mediakind.derive(vcodec, acodec)
|
||||
# Judged by the extension the file is PLACED under (and the worker re-judges), not the raw one:
|
||||
# 'clip.mp4~' is stored as .mp4, and judging 'mp4~' queued a conversion with nothing to convert.
|
||||
ext = storage.upload_ext(filename) or ""
|
||||
safe = playable.plan(data, ext) if kind != mediakind.FILE else None
|
||||
convert = safe is not None and safe.needs_change
|
||||
|
||||
# An upload never dedups (each file is its own asset): a random ref makes the cache key unique,
|
||||
# so `format_sig` is a constant marker rather than a format hash.
|
||||
@@ -66,17 +83,18 @@ def ingest_upload(
|
||||
rel = storage.upload_rel_path(user_id, asset.id, filename)
|
||||
storage.place_file(src_path, settings.download_root, rel)
|
||||
# From here the file is on disk under `.uploads/`. If the transaction fails before it commits, the
|
||||
# asset/job rows roll back but the placed file would be ORPHANED — nothing references it, the
|
||||
# per-user delete path keys off asset.rel_path (which is gone), and the GC orphan sweep skips the
|
||||
# dot-prefixed `.uploads/` tree. So roll back the on-disk side too on any failure.
|
||||
# asset/job rows roll back and nothing names the placed file: the per-user delete path keys off
|
||||
# asset.rel_path (which is gone), and the GC's `.uploads` sweep reaches it only an hour later. So
|
||||
# roll back the on-disk side too — unless the commit itself raised after it landed (see below).
|
||||
asset_id = asset.id
|
||||
try:
|
||||
final = storage.abs_path(settings.download_root, rel)
|
||||
actual_size = final.stat().st_size if final.exists() else size
|
||||
|
||||
asset.status = "ready"
|
||||
asset.status = "pending" if convert else "ready"
|
||||
asset.rel_path = rel
|
||||
asset.size_bytes = actual_size
|
||||
asset.container = storage.upload_ext(filename)
|
||||
asset.container = ext or None
|
||||
asset.vcodec = vcodec
|
||||
asset.acodec = acodec
|
||||
asset.width = width
|
||||
@@ -100,8 +118,8 @@ def ingest_upload(
|
||||
source_ref=ref,
|
||||
profile_snapshot={},
|
||||
display_name=display_text(display_name),
|
||||
status="done",
|
||||
progress=100,
|
||||
status=JobStatus.QUEUED if convert else JobStatus.DONE,
|
||||
progress=0 if convert else 100,
|
||||
queue_pos=service.next_queue_pos(db),
|
||||
)
|
||||
db.add(job)
|
||||
@@ -109,6 +127,11 @@ def ingest_upload(
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
if _row_landed(db, asset_id) is not False:
|
||||
# The commit reached the server and lost its answer, or the row cannot be read: the file
|
||||
# may be the asset's now, so it is never removed blind — the sweep takes it if no row
|
||||
# names it.
|
||||
raise
|
||||
# The rows are gone; remove the now-unreferenced file so no bytes leak. Clean the poster by its
|
||||
# DETERMINISTIC path (rel with a .jpg suffix, the same path ensure_poster writes to) rather than
|
||||
# ensure_poster's return value: it writes the frame directly to that path and returns None on an
|
||||
@@ -120,3 +143,13 @@ def ingest_upload(
|
||||
raise
|
||||
db.refresh(job)
|
||||
return job
|
||||
|
||||
|
||||
def _row_landed(db: Session, asset_id: int) -> bool | None:
|
||||
"""After the ingest transaction raised: is the asset row there anyway? A commit can reach the
|
||||
server and lose its answer (the connection drops after COMMIT). None when it cannot be read."""
|
||||
try:
|
||||
return db.get(MediaAsset, asset_id) is not None
|
||||
except Exception: # noqa: BLE001 — "cannot tell" is the answer, the caller acts on it
|
||||
# silent-ok: the caller re-raises the ingest's own error.
|
||||
return None
|
||||
|
||||
+11
-5
@@ -339,6 +339,8 @@ STATIC_DIR = Path(__file__).parent / "static_spa"
|
||||
# stay cached but forces revalidation (cheap 304 via the FileResponse ETag) on every load. The
|
||||
# hashed bundles under /assets can cache forever (a new build changes their filename).
|
||||
INDEX_HTML = STATIC_DIR / "index.html"
|
||||
# A public share link's own page: the player alone, without the app's bundle (frontend/watch.html).
|
||||
WATCH_HTML = STATIC_DIR / "watch.html"
|
||||
INDEX_HEADERS = {"Cache-Control": "no-cache"}
|
||||
app.mount(
|
||||
"/assets",
|
||||
@@ -354,16 +356,16 @@ async def index() -> FileResponse:
|
||||
|
||||
@app.get("/watch/{token}")
|
||||
async def watch_page(token: str):
|
||||
"""Serve the SPA for a public share link, but with per-video Open Graph tags injected so the
|
||||
"""Serve the public player page for a share link, with per-video Open Graph tags injected so the
|
||||
link unfurls richly (title/channel/thumbnail) in Messenger/social crawlers. A real browser
|
||||
ignores the extra tags and hydrates WatchPage as usual; invalid/expired/password links fall
|
||||
back to the generic card."""
|
||||
ignores the extra tags and boots WatchPage; invalid/expired/password links fall back to the
|
||||
generic card."""
|
||||
from app.downloads import og
|
||||
|
||||
with SessionLocal() as db:
|
||||
rendered = og.render_watch_html(token, INDEX_HTML, db)
|
||||
rendered = og.render_watch_html(token, WATCH_HTML, db)
|
||||
if rendered is None:
|
||||
return FileResponse(INDEX_HTML, headers=INDEX_HEADERS)
|
||||
return FileResponse(WATCH_HTML, headers=INDEX_HEADERS)
|
||||
return HTMLResponse(rendered, headers=INDEX_HEADERS)
|
||||
|
||||
|
||||
@@ -381,4 +383,8 @@ async def spa_fallback(full_path: str) -> FileResponse:
|
||||
# Real SPA-root assets (welcome images, favicon, robots.txt) rarely change and have
|
||||
# stable names, so a moderate cache is safe and satisfies the cache-lifetime audit.
|
||||
return FileResponse(candidate, headers={"Cache-Control": "public, max-age=604800"})
|
||||
# A /watch/ path the route above does not match (a trailing segment, no token) is still the
|
||||
# player's: WatchPage reads the token off the path and says when a link is gone.
|
||||
if full_path.startswith("watch/"):
|
||||
return FileResponse(WATCH_HTML, headers=INDEX_HEADERS)
|
||||
return FileResponse(INDEX_HTML, headers=INDEX_HEADERS)
|
||||
|
||||
+23
-3
@@ -462,6 +462,27 @@ class VideoState(Base, UpdatedAtMixin):
|
||||
progress_updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
|
||||
class LibraryState(Base):
|
||||
"""Where a user stopped in a library item (their own download, or one shared with them) — the
|
||||
library player's resume point. Keyed by the JOB, not the file: a job is the user's library entry,
|
||||
a re-download keeps it, and deleting the job deletes the row; the shared file under it may outlive
|
||||
both. A revoked share leaves its recipient's row behind, unread — only a job the user can still
|
||||
reach is ever read or written — and it is back if the item is shared again. Rows exist only for a
|
||||
position worth keeping (see `routes._common.kept_position`); no row means "from the start"."""
|
||||
|
||||
__tablename__ = "library_states"
|
||||
__table_args__ = (UniqueConstraint("user_id", "job_id", name="uq_library_user_job"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
|
||||
job_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("download_jobs.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
# int4: a checkpoint body must stay under POSITION_MAX_S, or the flush is a DataError (a 500).
|
||||
position_seconds: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
progress_updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
|
||||
|
||||
class SearchFind(Base, TimestampMixin):
|
||||
"""Per-user record of a video the user surfaced via their own live YouTube search.
|
||||
|
||||
@@ -912,7 +933,8 @@ 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) or "edit" (a per-user trim/crop derivative of another job).
|
||||
# "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`).
|
||||
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(
|
||||
@@ -1252,8 +1274,6 @@ class PlexItem(Base, TimestampMixin, UpdatedAtMixin):
|
||||
# bit-depth/HDR. NULL = never (the backfill's work-list). Only HW-decodable transcode items are
|
||||
# probed (the rest are SW-only by codec and need no bit-depth); see `_probe_media_batch`.
|
||||
media_probed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
# Intro/credit markers: [{"type": "intro"|"credits", "start_s": int, "end_s": int}, ...]
|
||||
markers: Mapped[list | None] = mapped_column(JSON)
|
||||
added_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
|
||||
# Filterable / orderable metadata mirrored from the Plex section listing (no extra API calls;
|
||||
# `rating` = Plex audienceRating, which is ~the IMDb score). The arrays back genre/person/studio
|
||||
|
||||
@@ -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 []
|
||||
|
||||
@@ -744,8 +744,9 @@ def start_session(
|
||||
# `replace_only` makes the supersede identity-aware for the runtime fallback: the whole
|
||||
# check-and-replace happens under ONE _lock, so a newer same-key session (a concurrent
|
||||
# seek-restart) installed between the fallback's decision and here is never clobbered. When the
|
||||
# registered session is no longer `replace_only`, we abort and return None (the caller yields to
|
||||
# whoever is registered now). The INITIAL spawn never gets None — see its call site.
|
||||
# registered session is no longer `replace_only`, we abort and return None, and the caller
|
||||
# answers with its own dead session — never the newer one, whose id a stale requester would
|
||||
# stop. The INITIAL spawn never gets None — see its call site.
|
||||
# Software-fallback encode tuning — read only on the SW transcode path (the VAAPI path and
|
||||
# remux ignore preset/crf/max_height, and remux must not touch the DB for them).
|
||||
preset, crf, max_height = "veryfast", 20, 0
|
||||
@@ -840,12 +841,13 @@ def start_session(
|
||||
)
|
||||
respawned = _spawn(False, False, replace_only=s)
|
||||
if respawned is None:
|
||||
# Superseded/culled mid-fallback — yield whoever is registered now (a newer seek-restart), or
|
||||
# our own now-dead session if the key was cap-culled. Never None here: the POST /session route
|
||||
# turns a None return into a "Media file not found on disk" 404 — dishonest for a file that
|
||||
# exists. A (dead) session instead degrades to a retryable 404 ("No active playback session" /
|
||||
# "Not available yet") the client re-requests.
|
||||
return current_session(user_id, key) or s
|
||||
# Superseded/culled mid-fallback — answer with our own now-dead session. Never the newer one
|
||||
# registered meanwhile: this answer goes to the request that LOST, whose player drops it as
|
||||
# stale and stops the session it names (`stop_session`), which stopped the playback that won.
|
||||
# Never None either: the POST /session route turns a None return into a "Media file not found
|
||||
# on disk" 404 — dishonest for a file that exists. A dead session instead degrades to a
|
||||
# retryable 404 ("No active playback session" / "Not available yet") the client re-requests.
|
||||
return s
|
||||
s = respawned
|
||||
ready = wait_for(_seg(0), timeout=25.0, proc=s.proc)
|
||||
if ready:
|
||||
@@ -1117,6 +1119,23 @@ def reap_idle(finished_idle_s: float = _FINISHED_IDLE_S) -> int:
|
||||
return dropped
|
||||
|
||||
|
||||
def stop_session(user_id: int, key: str, session_id: str) -> bool:
|
||||
"""Stop this viewer's session for `key` now, because the player that asked for it is gone (left
|
||||
the item, closed, unloaded the page). Left to `reap_idle`, a transcode ran on to the end of the
|
||||
film first — measured: ~4 cores for a minute after leaving a resumed 480p film.
|
||||
|
||||
Only while `session_id` is still the registered one: the player names the session it played, and
|
||||
a seek-restart or a second tab may have replaced it meanwhile — that newer session is somebody's
|
||||
current playback, not this request's to end."""
|
||||
with _lock:
|
||||
s = _sessions.get((user_id, key))
|
||||
if s is None or s.id != session_id:
|
||||
return False
|
||||
_kill(s)
|
||||
_sessions.pop((user_id, key), None)
|
||||
return True
|
||||
|
||||
|
||||
def touch_session(user_id: int, key: str) -> None:
|
||||
"""Say this viewer is still there for `key`, without fetching anything. `last_access` used to move
|
||||
only on an HLS file fetch, which a paused player does not do — so the only way to keep a paused
|
||||
|
||||
@@ -9,16 +9,52 @@ one existence-leak policy.
|
||||
from typing import IO, Any, TypeVar
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db import Base
|
||||
from app.models import User
|
||||
from app.models import POSITION_MAX_S, User
|
||||
from app.issues import gitea
|
||||
from app.refusal import refused
|
||||
from app.youtube.client import YouTubeError, refusal_reason
|
||||
|
||||
M = TypeVar("M", bound=Base)
|
||||
|
||||
# Resume-position thresholds of a player that only keeps a resume point (the YouTube player, the
|
||||
# library's): positions below this are "didn't really start" and within this of the end are
|
||||
# "basically finished" — neither is worth storing, so the position is cleared instead (no progress
|
||||
# bar). The client's local resume (`lib/resume.ts`) keeps the same two numbers, held equal by
|
||||
# `resume.contract.test.ts` beside it. NB: the Plex player intentionally uses a LARGER finish margin
|
||||
# (30s) because it marks long-form items watched, not just clears a resume bar — see
|
||||
# routes/plex/playback.py.
|
||||
PROGRESS_MIN_SECONDS = 5
|
||||
FINISH_MARGIN_SECONDS = 10
|
||||
|
||||
|
||||
class ProgressIn(BaseModel):
|
||||
"""A player's resume checkpoint (sent every few seconds while it plays, and as a beacon when the
|
||||
page goes away)."""
|
||||
|
||||
# Finite only: JSON parsing here accepts `Infinity`/`NaN`, and the handler's `int()` turned
|
||||
# one into a 500.
|
||||
model_config = ConfigDict(allow_inf_nan=False)
|
||||
|
||||
# Accept a float and truncate (`kept_position`) — the player may post a raw currentTime (a float),
|
||||
# which the old `int(payload.get(...))` silently truncated. A plain `int` field would 422 that on
|
||||
# the progress hot path, losing the resume position; `float` keeps the old tolerance.
|
||||
# Bounded above by the int4 column (see POSITION_MAX_S): past it the flush was a 500.
|
||||
position_seconds: float = Field(0, le=POSITION_MAX_S)
|
||||
duration_seconds: float = Field(0, le=POSITION_MAX_S)
|
||||
|
||||
|
||||
def kept_position(body: ProgressIn) -> int:
|
||||
"""The whole-second resume point worth storing for `body`, or 0 for "clear it" (too early to
|
||||
count as started, or close enough to the end to count as finished)."""
|
||||
position = max(0, int(body.position_seconds))
|
||||
duration = int(body.duration_seconds)
|
||||
near_end = duration > 0 and position > duration - FINISH_MARGIN_SECONDS
|
||||
return position if position >= PROGRESS_MIN_SECONDS and not near_end else 0
|
||||
|
||||
|
||||
def not_found(what: str = "generic") -> HTTPException:
|
||||
"""The 404 for a row that is gone — structured, so the bilingual UI says it in the reader's
|
||||
|
||||
@@ -8,7 +8,7 @@ state, serves finished files (range-aware, with the user's custom display name),
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
from collections.abc import AsyncIterator
|
||||
from collections.abc import AsyncIterator, Sequence
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
@@ -17,7 +17,8 @@ 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 sqlalchemy import func, select
|
||||
from sqlalchemy import delete, func, select
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.mailtext import lang_for_user
|
||||
@@ -32,17 +33,26 @@ 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.models import (
|
||||
DownloadJob,
|
||||
DownloadLink,
|
||||
DownloadProfile,
|
||||
DownloadQuota,
|
||||
DownloadShare,
|
||||
LibraryState,
|
||||
MediaAsset,
|
||||
User,
|
||||
display_text,
|
||||
)
|
||||
from app.routes._common import capped_stream_to, get_or_404, not_found, owned_or_404
|
||||
from app.routes._common import (
|
||||
ProgressIn,
|
||||
capped_stream_to,
|
||||
get_or_404,
|
||||
kept_position,
|
||||
not_found,
|
||||
owned_or_404,
|
||||
)
|
||||
from app.security import hash_password
|
||||
from app.userscope import is_messageable_user, messageable_clauses
|
||||
from app.utils import iso
|
||||
@@ -131,8 +141,8 @@ def _serialize(job: DownloadJob, asset: MediaAsset | None) -> dict:
|
||||
"job_kind": job.job_kind,
|
||||
"source_job_id": job.source_job_id,
|
||||
"edit_spec": job.edit_spec,
|
||||
"can_download": ready and job.status == "done",
|
||||
"expired": job.asset_id is None and job.status == "done",
|
||||
"can_download": ready and job.status == JobStatus.DONE,
|
||||
"expired": job.asset_id is None and job.status == JobStatus.DONE,
|
||||
"asset": None
|
||||
if asset is None
|
||||
else {
|
||||
@@ -164,6 +174,19 @@ def _assets_for(db: Session, jobs: list[DownloadJob]) -> dict[int, MediaAsset]:
|
||||
return {a.id: a for a in rows}
|
||||
|
||||
|
||||
def _positions_for(db: Session, user: User, jobs: Sequence[DownloadJob]) -> dict[int, int]:
|
||||
"""`user`'s resume point in each of `jobs` that has one (the library player's `library_states`)."""
|
||||
ids = [j.id for j in jobs]
|
||||
if not ids:
|
||||
return {}
|
||||
rows = db.execute(
|
||||
select(LibraryState.job_id, LibraryState.position_seconds).where(
|
||||
LibraryState.user_id == user.id, LibraryState.job_id.in_(ids)
|
||||
)
|
||||
).all()
|
||||
return {job_id: pos for job_id, pos in rows}
|
||||
|
||||
|
||||
def _serialize_job(db: Session, job: DownloadJob) -> dict:
|
||||
"""Resolve a job's cache asset (if any) and serialize the pair — the resolve-then-serialize step
|
||||
every single-job handler shares."""
|
||||
@@ -502,7 +525,8 @@ def upload_download(
|
||||
(video/audio) gets the full function set, a generic file is download-only. The body IS the file
|
||||
(see the header note above); `spool_upload` has already authenticated the caller and streamed it
|
||||
to a temp path under the download volume, so what is left here is the non-yt-dlp ingest which
|
||||
probes + places it and returns an already-`done` job.
|
||||
probes + places it and returns its job — `done`, or `queued` for the worker's phone-safe
|
||||
conversion (`downloads.upload`).
|
||||
|
||||
A sync (threadpool) handler on purpose: the ingest does blocking work — an ffprobe of up to 30s
|
||||
plus sync DB writes — so it must NOT run on the event loop. The streaming half is async because
|
||||
@@ -539,7 +563,7 @@ def enqueue_edit(
|
||||
clip is a NEW per-user derivative in the editor's own library (counts against their quota); the
|
||||
source file is only read, never modified, so editing a shared video is safe."""
|
||||
source_job = _accessible_job(db, user, body.source_job_id)
|
||||
if source_job.status != "done" or source_job.asset_id is None:
|
||||
if source_job.status != JobStatus.DONE or source_job.asset_id is None:
|
||||
raise refused(409, "edit_unfinished")
|
||||
spec = body.edit_spec
|
||||
display_name = display_text(body.display_name)
|
||||
@@ -571,7 +595,14 @@ def list_downloads(user: User = Depends(current_user), db: Session = Depends(get
|
||||
.all()
|
||||
)
|
||||
assets = _assets_for(db, jobs)
|
||||
return [_serialize(j, assets.get(j.asset_id)) for j in jobs]
|
||||
positions = _positions_for(db, user, jobs)
|
||||
return [
|
||||
{
|
||||
**_serialize(j, assets.get(j.asset_id) if j.asset_id is not None else None),
|
||||
"position_seconds": positions.get(j.id, 0),
|
||||
}
|
||||
for j in jobs
|
||||
]
|
||||
|
||||
|
||||
@router.get("/usage")
|
||||
@@ -593,7 +624,12 @@ def my_usage(user: User = Depends(current_user), db: Session = Depends(get_db))
|
||||
|
||||
|
||||
# Priority when a source has several jobs (e.g. a done one plus a fresh queued one).
|
||||
_INDEX_RANK = {"done": 4, "running": 3, "paused": 2, "queued": 1}
|
||||
_INDEX_RANK: dict[str, int] = {
|
||||
JobStatus.DONE: 4,
|
||||
JobStatus.RUNNING: 3,
|
||||
JobStatus.PAUSED: 2,
|
||||
JobStatus.QUEUED: 1,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/index")
|
||||
@@ -603,7 +639,7 @@ def my_download_index(user: User = Depends(current_user), db: Session = Depends(
|
||||
rows = db.execute(
|
||||
select(DownloadJob.source_ref, DownloadJob.status).where(
|
||||
DownloadJob.user_id == user.id,
|
||||
DownloadJob.status.in_(("queued", "running", "paused", "done")),
|
||||
DownloadJob.status.in_(tuple(_INDEX_RANK)),
|
||||
)
|
||||
).all()
|
||||
out: dict[str, str] = {}
|
||||
@@ -627,10 +663,12 @@ def shared_with_me(user: User = Depends(current_user), db: Session = Depends(get
|
||||
.all()
|
||||
)
|
||||
assets = _assets_for(db, jobs)
|
||||
positions = _positions_for(db, user, jobs)
|
||||
out = []
|
||||
for j in jobs:
|
||||
data = _serialize(j, assets.get(j.asset_id))
|
||||
data["shared"] = True
|
||||
data["position_seconds"] = positions.get(j.id, 0)
|
||||
out.append(data)
|
||||
return out
|
||||
|
||||
@@ -734,7 +772,7 @@ def redownload_download(
|
||||
# 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.
|
||||
raise refused(409, "not_redownloadable")
|
||||
if job.status in ("queued", "running", "paused"):
|
||||
if job.status in (JobStatus.QUEUED, JobStatus.RUNNING, JobStatus.PAUSED):
|
||||
raise refused(409, "still_downloading")
|
||||
service.redownload(db, job)
|
||||
return _serialize_job(db, job)
|
||||
@@ -818,6 +856,39 @@ def download_file(
|
||||
# --- editor filmstrip (scrub track) --------------------------------------------------------
|
||||
|
||||
|
||||
@router.post("/{job_id}/progress")
|
||||
def set_library_progress(
|
||||
job_id: int,
|
||||
body: ProgressIn,
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Checkpoint the library player's resume point in a download the caller can play (their own, or
|
||||
one shared with them). A position too early or too close to the end clears it instead."""
|
||||
_accessible_job(db, user, job_id)
|
||||
position = kept_position(body)
|
||||
mine = (LibraryState.user_id == user.id) & (LibraryState.job_id == job_id)
|
||||
if not position:
|
||||
# A statement, not a loaded row's delete: the periodic checkpoint and the pagehide beacon of
|
||||
# one stop may both clear it, and the second then deletes nothing rather than failing.
|
||||
db.execute(delete(LibraryState).where(mine))
|
||||
db.commit()
|
||||
return {"ok": True, "job_id": job_id, "position_seconds": 0}
|
||||
now = datetime.now(timezone.utc)
|
||||
# One row per (user, job) even when two checkpoints race (the same pair): an upsert, not a
|
||||
# read-then-insert that the second would fail on the unique key.
|
||||
db.execute(
|
||||
pg_insert(LibraryState)
|
||||
.values(user_id=user.id, job_id=job_id, position_seconds=position, progress_updated_at=now)
|
||||
.on_conflict_do_update(
|
||||
constraint="uq_library_user_job",
|
||||
set_={"position_seconds": position, "progress_updated_at": now},
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
return {"ok": True, "job_id": job_id, "position_seconds": position}
|
||||
|
||||
|
||||
@router.get("/{job_id}/storyboard")
|
||||
def storyboard_meta(
|
||||
job_id: int, user: User = Depends(current_user), db: Session = Depends(get_db)
|
||||
@@ -886,7 +957,7 @@ def share_download(
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
job = owned_or_404(db, DownloadJob, job_id, user, what="download")
|
||||
if job.status != "done" or job.asset_id is None:
|
||||
if job.status != JobStatus.DONE or job.asset_id is None:
|
||||
raise refused(409, "share_unfinished")
|
||||
target = body.email.strip().lower()
|
||||
if not target:
|
||||
@@ -1033,7 +1104,7 @@ def list_links(
|
||||
|
||||
class CreateLinkIn(BaseModel):
|
||||
password: str = ""
|
||||
# D1: a newly shared download is downloadable by default — the common intent is "let the
|
||||
# A newly shared download is downloadable by default — the common intent is "let the
|
||||
# recipient have the file", not stream-only. Forward-only: this only sets the default for links
|
||||
# minted from now on; existing links keep whatever `allow_download` they were created with (the
|
||||
# column default stays False, so no back-catalogue link silently flips). The owner can still
|
||||
@@ -1050,7 +1121,7 @@ def create_link(
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
job = owned_or_404(db, DownloadJob, job_id, user, what="download")
|
||||
if job.status != "done" or job.asset_id is None:
|
||||
if job.status != JobStatus.DONE or job.asset_id is None:
|
||||
raise refused(409, "share_unfinished")
|
||||
pw = body.password.strip()
|
||||
link = DownloadLink(
|
||||
@@ -1137,10 +1208,10 @@ def admin_list_downloads(
|
||||
|
||||
@admin_router.get("/storage")
|
||||
def admin_storage(admin: User = Depends(admin_user), db: Session = Depends(get_db)) -> dict:
|
||||
ready = db.execute(
|
||||
select(func.count(), func.coalesce(func.sum(MediaAsset.size_bytes), 0)).where(
|
||||
MediaAsset.status == "ready"
|
||||
)
|
||||
# Every file on disk (`quota.ON_DISK`): ready ones, and uploads waiting for or failed in their
|
||||
# conversion — the same disk the footprint and the GC cap count.
|
||||
on_disk = db.execute(
|
||||
select(func.count(), func.coalesce(func.sum(MediaAsset.size_bytes), 0)).where(quota.ON_DISK)
|
||||
).one()
|
||||
total_assets = db.execute(select(func.count()).select_from(MediaAsset)).scalar()
|
||||
per_user = db.execute(
|
||||
@@ -1153,8 +1224,8 @@ def admin_storage(admin: User = Depends(admin_user), db: Session = Depends(get_d
|
||||
for u in db.execute(select(User).where(User.id.in_([uid for uid, _ in per_user]))).scalars()
|
||||
}
|
||||
return {
|
||||
"ready_files": ready[0],
|
||||
"total_bytes": int(ready[1]),
|
||||
"files_on_disk": on_disk[0],
|
||||
"total_bytes": int(on_disk[1]),
|
||||
"total_assets": total_assets,
|
||||
"total_cap_bytes": sysconfig.get_int(db, "download_total_max_bytes"),
|
||||
"per_user": [
|
||||
|
||||
+11
-34
@@ -1,7 +1,7 @@
|
||||
from datetime import date, datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import and_, func, or_, select
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -26,14 +26,19 @@ from app.feedquery import (
|
||||
windowed_relevance_rows,
|
||||
)
|
||||
from app.models import (
|
||||
POSITION_MAX_S,
|
||||
ChannelTag,
|
||||
Tag,
|
||||
User,
|
||||
Video,
|
||||
VideoState,
|
||||
)
|
||||
from app.routes._common import get_or_404, youtube_refusal, not_found
|
||||
from app.routes._common import (
|
||||
ProgressIn,
|
||||
get_or_404,
|
||||
kept_position,
|
||||
not_found,
|
||||
youtube_refusal,
|
||||
)
|
||||
from app.sync.videos import parse_iso8601_duration
|
||||
from app.utils import iso
|
||||
from app.youtube.client import YouTubeClient, YouTubeError
|
||||
@@ -44,14 +49,6 @@ router = APIRouter(prefix="/api", tags=["feed"])
|
||||
# "saved" used to be a status; it's now membership in the built-in Watch later playlist.
|
||||
VALID_STATES = {"new", "watched", "hidden"}
|
||||
|
||||
# Resume-position thresholds (mirror the client): positions below this are "didn't
|
||||
# really start" and within this of the end are "basically finished" — neither is worth
|
||||
# storing, so we clear the position instead (the video shows no progress bar).
|
||||
# NB: the Plex player intentionally uses a LARGER finish margin (30s) because it marks
|
||||
# long-form items watched, not just clears a resume bar — see routes/plex/playback.py.
|
||||
PROGRESS_MIN_SECONDS = 5
|
||||
FINISH_MARGIN_SECONDS = 10
|
||||
|
||||
|
||||
# Shared query parameters for /feed and /feed/count.
|
||||
def _feed_params(
|
||||
@@ -326,23 +323,10 @@ def clear_video_state(
|
||||
return {"ok": True, "video_id": video_id, "status": "new", "position_seconds": 0}
|
||||
|
||||
|
||||
class VideoProgressIn(BaseModel):
|
||||
# Finite only: JSON parsing here accepts `Infinity`/`NaN`, and the handler's `int()` turned
|
||||
# one into a 500.
|
||||
model_config = ConfigDict(allow_inf_nan=False)
|
||||
|
||||
# Accept a float and truncate (below) — the player may post a raw currentTime (a float), which
|
||||
# the old `int(payload.get(...))` silently truncated. A plain `int` field would 422 that on the
|
||||
# 10s progress hot path, losing the resume position; `float` keeps the old tolerance.
|
||||
# Bounded above by the int4 column (see POSITION_MAX_S): past it the flush was a 500.
|
||||
position_seconds: float = Field(0, le=POSITION_MAX_S)
|
||||
duration_seconds: float = Field(0, le=POSITION_MAX_S)
|
||||
|
||||
|
||||
@router.post("/videos/{video_id}/progress")
|
||||
def set_video_progress(
|
||||
video_id: str,
|
||||
body: VideoProgressIn,
|
||||
body: ProgressIn,
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
@@ -350,22 +334,15 @@ def set_video_progress(
|
||||
Stores a per-user position on the video_states row without touching watch status, so a
|
||||
partially-watched video can render a progress bar and match the "in progress" filter.
|
||||
Trivially-early and near-finished positions clear the position rather than store it."""
|
||||
position = int(body.position_seconds)
|
||||
duration = int(body.duration_seconds)
|
||||
if position < 0:
|
||||
position = 0
|
||||
position = kept_position(body)
|
||||
|
||||
get_or_404(db, Video, video_id, what="video")
|
||||
|
||||
# Decide whether this position is worth keeping (else clear it).
|
||||
near_end = duration > 0 and position > duration - FINISH_MARGIN_SECONDS
|
||||
keep = position >= PROGRESS_MIN_SECONDS and not near_end
|
||||
|
||||
row = db.execute(
|
||||
select(VideoState).where(VideoState.user_id == user.id, VideoState.video_id == video_id)
|
||||
).scalar_one_or_none()
|
||||
|
||||
if not keep:
|
||||
if not position:
|
||||
# Nothing meaningful to store: drop a status-less row entirely, otherwise just
|
||||
# zero the position (a saved/hidden video keeps its status).
|
||||
if row is not None:
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -404,6 +404,10 @@ def item_detail(
|
||||
"type": m.get("type"),
|
||||
"start_s": round((m.get("startTimeOffset") or 0) / 1000, 1),
|
||||
"end_s": round((m.get("endTimeOffset") or 0) / 1000, 1),
|
||||
# An item can carry several credits blocks with scenes between them; Plex
|
||||
# flags the one that really ends it. Which credits may leave the item is the
|
||||
# player's rule (`lib/plexMarkers`), so the flag goes over as Plex sent it.
|
||||
"final": bool(m.get("final")),
|
||||
}
|
||||
)
|
||||
# Audio + subtitle tracks (ordinals among their stream type — used to -map the selection).
|
||||
|
||||
@@ -15,7 +15,7 @@ from fastapi import (
|
||||
Response,
|
||||
)
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -23,7 +23,6 @@ from sqlalchemy.orm import Session
|
||||
from app.auth import current_user, resolved_user_id
|
||||
from app.db import SessionLocal, get_db
|
||||
from app.models import (
|
||||
POSITION_MAX_S,
|
||||
PlexItem,
|
||||
PlexSeason,
|
||||
PlexShow,
|
||||
@@ -39,7 +38,7 @@ from app.plex import watch_sync as plex_watch
|
||||
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
|
||||
from app.routes.plex._shared import item_or_404
|
||||
from app.refusal import refused
|
||||
from app.routes._common import not_found
|
||||
from app.routes._common import ProgressIn, not_found
|
||||
|
||||
router = APIRouter(prefix="/api/plex", tags=["plex"])
|
||||
|
||||
@@ -160,11 +159,11 @@ def subtitle(
|
||||
return Response(subtitles.shift_vtt(hit, offset), media_type="text/vtt")
|
||||
|
||||
|
||||
# Resume/watched thresholds. _PROGRESS_MIN_S matches the feed's PROGRESS_MIN_SECONDS (5), but
|
||||
# _FINISH_MARGIN_S is DELIBERATELY larger than the feed's FINISH_MARGIN_SECONDS (30 vs 10): this is
|
||||
# long-form content (movies/episodes with credits) and crossing it MARKS THE ITEM WATCHED, whereas
|
||||
# the feed's margin only clears a short video's resume bar — different domains, intentional divergence,
|
||||
# not drift. Keep them separate on purpose (see routes/feed.py).
|
||||
# Resume/watched thresholds. _PROGRESS_MIN_S matches PROGRESS_MIN_SECONDS (5), but _FINISH_MARGIN_S is
|
||||
# DELIBERATELY larger than FINISH_MARGIN_SECONDS (30 vs 10): this is long-form content
|
||||
# (movies/episodes with credits) and crossing it MARKS THE ITEM WATCHED, whereas the feed's and the
|
||||
# library's margin only clears a resume bar — different domains, intentional divergence, not drift.
|
||||
# Keep them separate on purpose (see routes/_common.py).
|
||||
_PROGRESS_MIN_S = 5
|
||||
|
||||
_FINISH_MARGIN_S = 30
|
||||
@@ -337,16 +336,9 @@ def _mark_finished(db: Session, user_id: int, item_id: int, now: datetime) -> bo
|
||||
return False
|
||||
|
||||
|
||||
class ItemProgressIn(BaseModel):
|
||||
# Finite only: JSON parsing here accepts `Infinity`/`NaN`, and the handler's `int()` turned
|
||||
# one into a 500.
|
||||
model_config = ConfigDict(allow_inf_nan=False)
|
||||
class ItemProgressIn(ProgressIn):
|
||||
"""The shared checkpoint (its bounds included), plus whether the player is leaving the item."""
|
||||
|
||||
# float, truncated in the handler — the player may post a raw currentTime (a float); a plain
|
||||
# `int` field would 422 it on the periodic progress checkpoint (the old `int(...)` truncated).
|
||||
# Bounded above by the int4 column (see POSITION_MAX_S): past it the flush was a 500.
|
||||
position_seconds: float = Field(0, le=POSITION_MAX_S)
|
||||
duration_seconds: float = Field(0, le=POSITION_MAX_S)
|
||||
final: bool = False
|
||||
|
||||
|
||||
@@ -637,12 +629,27 @@ def stream_session(
|
||||
# The session id is part of the path (not a query param) so the playlist's RELATIVE segment
|
||||
# names resolve back into the same session — see `stream_hls_file` for why it has to.
|
||||
"url": f"/api/plex/stream/{it.rating_key}/hls/{s.id}/{s.entry}",
|
||||
# What the player names when it no longer wants the session (POST …/session/{id}/stop).
|
||||
"id": s.id,
|
||||
"transcode": s.transcode,
|
||||
"hw": s.hw,
|
||||
"tonemap": s.tonemap,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/stream/{rating_key}/session/{session_id}/stop")
|
||||
def stream_session_stop(
|
||||
rating_key: str,
|
||||
session_id: str,
|
||||
user: User = Depends(current_user),
|
||||
) -> dict:
|
||||
"""The player no longer wants this session (it left the item, closed, or the page unloaded):
|
||||
its ffmpeg stops now instead of at the idle reaper. A POST, so the player can send it as a
|
||||
keepalive beacon from a page being unloaded. Stops nothing but the caller's own session, and
|
||||
only while it is still the one registered for the item (see `stop_session`)."""
|
||||
return {"ok": True, "stopped": plex_stream.stop_session(user.id, rating_key, session_id)}
|
||||
|
||||
|
||||
@router.get("/stream/{rating_key}/file")
|
||||
def stream_file(
|
||||
rating_key: str,
|
||||
|
||||
@@ -16,6 +16,7 @@ from app.config import settings
|
||||
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.ratelimit import RateLimiter, client_key
|
||||
from app.security import verify_password
|
||||
@@ -46,6 +47,17 @@ def _link_or_404(db: Session, token: str) -> DownloadLink:
|
||||
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
|
||||
if (
|
||||
job is not None
|
||||
and asset is not None
|
||||
and asset.status in ("pending", "downloading")
|
||||
and job.status in (JobStatus.QUEUED, JobStatus.RUNNING)
|
||||
):
|
||||
# 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".
|
||||
# Only while the job is still going: a cancelled or paused rebuild leaves the asset pending
|
||||
# with nothing working on it, and "try again in a few minutes" would never come true.
|
||||
raise refused(404, "video_preparing")
|
||||
if job is None or asset is None or asset.status != "ready" or not asset.rel_path:
|
||||
raise refused(404, "video_gone")
|
||||
# Verify the file is actually on disk here (not just flagged ready), so the watch page never
|
||||
|
||||
@@ -12,7 +12,7 @@ result (measured on real data: every untagged channel got a suggestion, most on
|
||||
alone). Topic overlap is what makes "you tagged similar channels this way" meaningful.
|
||||
|
||||
Purely read-only and deterministic — the Channel Manager surfaces the result as one-tap-accept
|
||||
"ghost" chips; nothing is written here (D4 = suggest-and-confirm, no silent auto-apply). The
|
||||
"ghost" chips; nothing is written here (suggest-and-confirm, never a silent auto-apply). The
|
||||
thresholds are tuning constants rather than admin knobs on purpose: they mirror how ``autotag.py``
|
||||
keeps its mappings inline, and sysconfig only stores int/str/bool (no float), so a knob would mean
|
||||
storing percentages — not worth the surface for values an operator has no reason to tune per-install.
|
||||
|
||||
@@ -8,6 +8,7 @@ import pytest
|
||||
|
||||
from app.config import settings
|
||||
from app.downloads import edit as editmod
|
||||
from app.downloads import playable
|
||||
from app.downloads import runner
|
||||
|
||||
|
||||
@@ -32,11 +33,12 @@ class TestEncodeThreadCap:
|
||||
assert editmod.encode_thread_cap() == 1 # max(1, 2 - 1)
|
||||
|
||||
|
||||
class TestBrowserSafeThreads:
|
||||
_REENCODE_VIDEO = playable.Plan("mp4", True, True, False, True)
|
||||
|
||||
|
||||
class TestPhoneSafeThreads:
|
||||
def test_threads_flag_added_when_capped(self):
|
||||
cmd = editmod.build_browser_safe_cmd(
|
||||
"/in.mkv", "/out.mp4", reencode_video=True, reencode_audio=False, threads=2
|
||||
)
|
||||
cmd = playable.build_cmd("/in.mkv", "/out.mp4", _REENCODE_VIDEO, threads=2)
|
||||
assert "-threads" in cmd
|
||||
assert cmd[cmd.index("-threads") + 1] == "2"
|
||||
# The cap precedes the output file, so it binds the encoder (not input decoding).
|
||||
@@ -44,9 +46,7 @@ class TestBrowserSafeThreads:
|
||||
|
||||
@pytest.mark.parametrize("threads", [0, None])
|
||||
def test_no_threads_flag_when_uncapped(self, threads):
|
||||
cmd = editmod.build_browser_safe_cmd(
|
||||
"/in.mkv", "/out.mp4", reencode_video=True, reencode_audio=False, threads=threads
|
||||
)
|
||||
cmd = playable.build_cmd("/in.mkv", "/out.mp4", _REENCODE_VIDEO, threads=threads)
|
||||
assert "-threads" not in cmd
|
||||
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ with "PosixPath('.') has an empty name" instead of downloading."""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.downloads.edit import _codecs_from_streams, _concat_quote, run_ffprobe_json
|
||||
from app.downloads.edit import _concat_quote, run_ffprobe_json
|
||||
from app.downloads.pipeline import _media_info, _produced_file
|
||||
from pathlib import Path, PurePosixPath
|
||||
|
||||
@@ -105,46 +105,9 @@ class TestMediaInfo:
|
||||
assert _media_info(info)["id"] == "vid"
|
||||
|
||||
|
||||
class TestCodecsFromStreams:
|
||||
# ffprobe emits codec_name BEFORE codec_type, so the old line parser shifted every name onto
|
||||
# the previous stream and an H.264+AAC mp4 came back as (aac, None) → a needless re-encode.
|
||||
# _codecs_from_streams reads each stream dict by its own codec_type, immune to that ordering.
|
||||
def test_reads_each_stream_by_its_own_type(self):
|
||||
streams = [
|
||||
{"codec_name": "h264", "codec_type": "video"},
|
||||
{"codec_name": "aac", "codec_type": "audio"},
|
||||
]
|
||||
assert _codecs_from_streams(streams) == ("h264", "aac")
|
||||
|
||||
def test_audio_first_ordering_is_handled(self):
|
||||
streams = [
|
||||
{"codec_name": "AAC", "codec_type": "audio"},
|
||||
{"codec_name": "H264", "codec_type": "video"},
|
||||
]
|
||||
assert _codecs_from_streams(streams) == ("h264", "aac")
|
||||
|
||||
def test_extra_streams_do_not_win_over_the_first_of_their_kind(self):
|
||||
streams = [
|
||||
{"codec_name": "h264", "codec_type": "video"},
|
||||
{"codec_name": "vp9", "codec_type": "video"},
|
||||
{"codec_name": "mov_text", "codec_type": "subtitle"},
|
||||
{"codec_name": "opus", "codec_type": "audio"},
|
||||
]
|
||||
assert _codecs_from_streams(streams) == ("h264", "opus")
|
||||
|
||||
def test_audio_only_and_video_only(self):
|
||||
assert _codecs_from_streams([{"codec_name": "opus", "codec_type": "audio"}]) == (
|
||||
None,
|
||||
"opus",
|
||||
)
|
||||
assert _codecs_from_streams([{"codec_name": "av1", "codec_type": "video"}]) == ("av1", None)
|
||||
|
||||
def test_empty_stream_list(self):
|
||||
assert _codecs_from_streams([]) == (None, None)
|
||||
|
||||
|
||||
class TestRunFfprobeJson:
|
||||
"""The single ffprobe-JSON invocation point (shared by probe_codecs + probe.probe_media). A failed
|
||||
"""The single ffprobe-JSON invocation point (`probe.probe_json`, which the stored facts and the
|
||||
phone-safe rule both read). A failed
|
||||
probe or unparseable output must degrade to None, not raise — the malformed-input safety the old
|
||||
parse_probe_streams string parser used to own now lives here."""
|
||||
|
||||
@@ -174,7 +137,7 @@ class TestRunFfprobeJson:
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", lambda *a, **k: _R())
|
||||
data = run_ffprobe_json(Path("x"), entries="stream=codec_type,codec_name")
|
||||
assert _codecs_from_streams(data["streams"]) == ("h264", None)
|
||||
assert data["streams"] == [{"codec_name": "h264", "codec_type": "video"}]
|
||||
|
||||
|
||||
class TestConcatQuote:
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
"""Every ffmpeg/ffprobe the downloads package runs over a library file names the demuxers it may use
|
||||
(`edit.INPUT_GUARD`, a downloaded thumbnail's `edit.THUMB_GUARD`).
|
||||
|
||||
Without the list, ffmpeg picks a demuxer from the file's content and name, and both are the user's
|
||||
for an upload (the remote site's for a thumbnail): a playlist-type demuxer opens whatever local paths
|
||||
its entries name. These tests read the argv each builder hands to ffmpeg — the guard is a flag, so
|
||||
the flag is what is held — and scan the package for an ffmpeg/ffprobe call written without one.
|
||||
|
||||
Not in scope: `app/plex`, whose ffmpeg reads the admin's own Plex library, not user-supplied bytes.
|
||||
"""
|
||||
|
||||
import ast
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.downloads import edit, playable
|
||||
|
||||
DOWNLOADS = Path(__file__).resolve().parents[1] / "app" / "downloads"
|
||||
# The demuxers that read paths named inside a file. `concat` is allowed only where the list is ours.
|
||||
PLAYLIST_DEMUXERS = {"hls", "dash", "concat", "image2", "ffconcat", "tee"}
|
||||
MEDIA = set(edit.MEDIA_DEMUXERS)
|
||||
|
||||
|
||||
def _whitelists(cmd: list[str]) -> list[set[str] | None]:
|
||||
"""For each `-i` in `cmd`, the demuxers the `-format_whitelist` given since the previous input
|
||||
allows — None when that input has none."""
|
||||
out, current = [], None
|
||||
for i, tok in enumerate(cmd):
|
||||
if tok == "-format_whitelist":
|
||||
current = set(cmd[i + 1].split(","))
|
||||
elif tok == "-i":
|
||||
out.append(current)
|
||||
current = None
|
||||
return out
|
||||
|
||||
|
||||
def _assert_media_guarded(cmd: list[str], extra: set[str] = frozenset()) -> None:
|
||||
lists = _whitelists(cmd)
|
||||
assert lists, cmd # a builder that opens no input is not what this test is about
|
||||
for allowed in lists:
|
||||
assert allowed is not None, f"an input with no -format_whitelist: {cmd}"
|
||||
assert allowed <= MEDIA | extra, allowed - MEDIA - extra
|
||||
assert not (allowed - extra) & PLAYLIST_DEMUXERS
|
||||
|
||||
|
||||
def test_the_media_list_names_no_demuxer_that_reads_other_paths():
|
||||
assert not MEDIA & PLAYLIST_DEMUXERS
|
||||
assert edit.INPUT_GUARD == ["-format_whitelist", ",".join(edit.MEDIA_DEMUXERS)]
|
||||
|
||||
|
||||
def test_the_phone_safe_conversion_is_guarded():
|
||||
p = playable.Plan("mp4", True, True, True, True)
|
||||
_assert_media_guarded(playable.build_cmd(Path("in.mov"), Path("out.mp4"), p))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"spec",
|
||||
[
|
||||
{"trim": {"start_s": 1, "end_s": 3}, "accurate": False},
|
||||
{"trim": {"start_s": 1, "end_s": 3}, "accurate": True},
|
||||
{"crop": {"x": 0, "y": 0, "w": 101, "h": 51}},
|
||||
],
|
||||
ids=["fast trim", "accurate trim", "crop"],
|
||||
)
|
||||
def test_a_clip_is_guarded(spec):
|
||||
spec = edit.normalize_edit_spec(spec)
|
||||
_assert_media_guarded(edit.build_edit_cmd(Path("in.mp4"), Path("out.mp4"), spec, "mp4"))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("accurate", [True, False], ids=["accurate join", "fast join"])
|
||||
def test_a_join_is_guarded(accurate, tmp_path):
|
||||
segs = [{"start_s": 0, "end_s": 1}, {"start_s": 2, "end_s": 3}]
|
||||
spec = edit.normalize_edit_spec({"segments": segs, "accurate": accurate})
|
||||
cmd, _ = edit.build_concat_plan(Path("in.mp4"), Path("out.mp4"), spec, "mp4", tmp_path)
|
||||
# The fast join reads a concat list WE write, so the list itself may be opened as concat.
|
||||
_assert_media_guarded(cmd, extra=set() if accurate else {"concat"})
|
||||
|
||||
|
||||
def test_the_fast_join_list_gives_each_entry_the_media_list_without_concat(tmp_path):
|
||||
# ffmpeg hands the list's whitelist (with `concat`) down to the library file each entry names;
|
||||
# the entry's own `option format_whitelist` must replace it, or that file may open as a list.
|
||||
segs = [{"start_s": 0, "end_s": 1}, {"start_s": 2, "end_s": 3}]
|
||||
spec = edit.normalize_edit_spec({"segments": segs, "accurate": False})
|
||||
_, prep = edit.build_concat_plan(Path("in.mp4"), Path("out.mp4"), spec, "mp4", tmp_path)
|
||||
(text,) = prep.values()
|
||||
lines = text.splitlines()
|
||||
entries = [i for i, ln in enumerate(lines) if ln.startswith("file ")]
|
||||
assert len(entries) == 2
|
||||
for i in entries:
|
||||
key, _, allowed = lines[i + 1].partition(" format_whitelist ")
|
||||
assert key == "option", lines[i + 1]
|
||||
assert set(allowed.split(",")) == MEDIA
|
||||
|
||||
|
||||
@pytest.mark.skipif(shutil.which("ffmpeg") is None, reason="ffmpeg not available")
|
||||
def test_the_fast_join_still_joins_a_real_file(tmp_path):
|
||||
src = tmp_path / "in.mp4"
|
||||
subprocess.run(
|
||||
["ffmpeg", "-y", "-loglevel", "error", "-f", "lavfi", "-i", "testsrc=size=64x48:rate=10",
|
||||
"-f", "lavfi", "-i", "sine=frequency=440", "-t", "4", "-c:v", "libx264",
|
||||
"-pix_fmt", "yuv420p", "-c:a", "aac", str(src)],
|
||||
check=True,
|
||||
) # fmt: skip
|
||||
segs = [{"start_s": 0, "end_s": 1}, {"start_s": 2, "end_s": 3}]
|
||||
spec = edit.normalize_edit_spec({"segments": segs, "accurate": False})
|
||||
out = tmp_path / "out.mp4"
|
||||
cmd, prep = edit.build_concat_plan(src, out, spec, "mp4", tmp_path)
|
||||
for p, content in prep.items():
|
||||
p.write_text(content, encoding="utf-8")
|
||||
subprocess.run(cmd, check=True, capture_output=True)
|
||||
assert (edit.probe_duration(out) or 0) > 0
|
||||
|
||||
|
||||
def test_the_poster_and_the_filmstrip_are_guarded():
|
||||
_assert_media_guarded(edit.build_poster_cmd(Path("in.mp4"), Path("p.jpg"), 2.0))
|
||||
geom = edit.storyboard_geometry(60)
|
||||
_assert_media_guarded(edit.build_storyboard_cmd(Path("in.mp4"), Path("s.jpg"), 60, geom))
|
||||
|
||||
|
||||
def test_a_downloaded_thumbnail_opens_as_a_single_image_only():
|
||||
(allowed,) = _whitelists(edit.build_thumbnail_jpg_cmd(Path("t.webp"), Path("t.jpg")))
|
||||
assert allowed == {"png_pipe", "jpeg_pipe", "webp_pipe"}
|
||||
|
||||
|
||||
def test_every_ffprobe_is_guarded(monkeypatch):
|
||||
seen = []
|
||||
|
||||
class _Done:
|
||||
stdout = "{}"
|
||||
|
||||
def _run(args, **_k):
|
||||
seen.append(args)
|
||||
return _Done()
|
||||
|
||||
monkeypatch.setattr(edit.subprocess, "run", _run)
|
||||
edit.run_ffprobe_json(Path("x.mp4"), show_streams=True)
|
||||
edit.probe_duration(Path("x.mp4"))
|
||||
assert len(seen) == 2
|
||||
for args in seen:
|
||||
i = args.index("-format_whitelist")
|
||||
assert set(args[i + 1].split(",")) == MEDIA
|
||||
|
||||
|
||||
def _unguarded_calls(source: str) -> list[int]:
|
||||
"""Lines of a list literal that starts an ffmpeg/ffprobe command or hands one an input (`-i`)
|
||||
and carries no `*…GUARD` / `*guard` splat and no "-format_whitelist" of its own. A builder that
|
||||
adds its input in a later `+=` must guard that list — that is where the input is."""
|
||||
bad = []
|
||||
for node in ast.walk(ast.parse(source)):
|
||||
if not isinstance(node, ast.List):
|
||||
continue
|
||||
consts = {e.value for e in node.elts if isinstance(e, ast.Constant)}
|
||||
if not consts & {"-i", "ffprobe"}:
|
||||
continue
|
||||
guarded = "-format_whitelist" in consts or any(
|
||||
isinstance(e, ast.Starred)
|
||||
and isinstance(v := e.value, ast.Name | ast.Attribute)
|
||||
and (v.id if isinstance(v, ast.Name) else v.attr).upper().endswith("GUARD")
|
||||
for e in node.elts
|
||||
)
|
||||
if not guarded:
|
||||
bad.append(node.lineno)
|
||||
return bad
|
||||
|
||||
|
||||
def test_no_ffmpeg_call_in_the_package_opens_an_input_unguarded():
|
||||
found = {
|
||||
f"{p.name}:{line}"
|
||||
for p in sorted(DOWNLOADS.rglob("*.py"))
|
||||
for line in _unguarded_calls(p.read_text(encoding="utf-8"))
|
||||
}
|
||||
assert found == set()
|
||||
|
||||
|
||||
def test_the_scan_sees_an_unguarded_call():
|
||||
# The scan above is green over nothing if it cannot see one: both shapes it must catch.
|
||||
assert _unguarded_calls('cmd = ["ffmpeg", "-y", "-i", src]') == [1]
|
||||
assert _unguarded_calls('a = ["ffprobe", "-v", "error", str(p)]') == [1]
|
||||
assert _unguarded_calls('cmd = [*INPUT_GUARD, "-i", src]') == []
|
||||
|
||||
|
||||
@pytest.mark.skipif(shutil.which("ffmpeg") is None, reason="ffmpeg not available")
|
||||
@pytest.mark.parametrize("ext", ["png", "webp", "jpg"])
|
||||
def test_the_thumbnail_guard_still_opens_every_thumbnail_a_download_keeps(ext, tmp_path):
|
||||
# `pipeline._THUMB_SUFFIXES`: what the guard must still let through (a .jpg that is really a
|
||||
# PNG goes the ffmpeg route too — `thumbnail_to_jpg` copies only true JPEG bytes).
|
||||
src = tmp_path / f"t.{ext}"
|
||||
fmt = ["-f", "image2", "-c:v", "png"] if ext == "jpg" else []
|
||||
subprocess.run(
|
||||
["ffmpeg", "-y", "-loglevel", "error", "-f", "lavfi", "-i", "testsrc2=size=64x48",
|
||||
"-frames:v", "1", *fmt, str(src)],
|
||||
check=True,
|
||||
) # fmt: skip
|
||||
out = edit.thumbnail_to_jpg(src, tmp_path / "poster.jpg")
|
||||
assert out is not None and edit._is_jpeg(out)
|
||||
@@ -94,23 +94,6 @@ def test_audio_only_unaffected():
|
||||
assert _opts({"mode": "a", "audio_format": "m4a"})["format"] == "bestaudio/best"
|
||||
|
||||
|
||||
def test_browser_transcode_flags_universal_playability():
|
||||
F = editmod.browser_transcode_flags
|
||||
# Already universal: H.264 + AAC in mp4 → no change.
|
||||
assert F("h264", "aac", True) == (False, False, False)
|
||||
assert F("h264", "mp3", True) == (False, False, False)
|
||||
# Non-H.264 video → full video re-encode (AV1/VP9 fail iOS; HEVC fails Chrome/FF).
|
||||
assert F("av1", "aac", True) == (True, True, False)
|
||||
assert F("vp9", "opus", True) == (True, True, True)
|
||||
assert F("hevc", "aac", True) == (True, True, False)
|
||||
# H.264 but bad audio → audio-only transcode (video copied).
|
||||
assert F("h264", "opus", True) == (True, False, True)
|
||||
# Codecs fine but wrong container → remux only (no re-encode).
|
||||
assert F("h264", "aac", False) == (True, False, False)
|
||||
# A failed probe / missing stream is never re-encoded.
|
||||
assert F(None, None, True) == (False, False, False)
|
||||
|
||||
|
||||
# --- SEC-13 · one enqueue is one bounded download -------------------------------------------
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
"""The library player's resume point (`library_states`).
|
||||
|
||||
A checkpoint stores where the caller stopped in a library item they can play — their own download,
|
||||
or one shared with them — and the two library listings hand it back per item, so the player opens
|
||||
there. Too early or too close to the end clears it. Nobody else's checkpoint, and no item the caller
|
||||
cannot reach, is ever read or written. DB-backed (real Postgres).
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models import DownloadJob, DownloadShare, LibraryState
|
||||
from app.routes._common import ProgressIn, kept_position
|
||||
|
||||
|
||||
def _done_job(db, user) -> DownloadJob:
|
||||
job = DownloadJob(
|
||||
user_id=user.id,
|
||||
source_kind="url",
|
||||
source_ref="https://example.com/a.mp4",
|
||||
status="done",
|
||||
profile_snapshot={},
|
||||
)
|
||||
db.add(job)
|
||||
db.commit()
|
||||
return job
|
||||
|
||||
|
||||
def _checkpoint(client, job_id: int, position: float, duration: float = 3000) -> int:
|
||||
"""Send a checkpoint the caller may make; the point the server kept (0 = cleared)."""
|
||||
r = client.post(
|
||||
f"/api/downloads/{job_id}/progress",
|
||||
json={"position_seconds": position, "duration_seconds": duration},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()["position_seconds"]
|
||||
|
||||
|
||||
def _states(db) -> list[tuple[int, int, int]]:
|
||||
db.expire_all()
|
||||
rows = db.execute(
|
||||
select(LibraryState.user_id, LibraryState.job_id, LibraryState.position_seconds)
|
||||
).all()
|
||||
return [tuple(r) for r in rows]
|
||||
|
||||
|
||||
def test_a_checkpoint_is_listed_back_on_the_item(db, make_user, as_user):
|
||||
me = make_user()
|
||||
job = _done_job(db, me)
|
||||
c = as_user(me)
|
||||
|
||||
r = c.post(
|
||||
f"/api/downloads/{job.id}/progress",
|
||||
json={"position_seconds": 754.9, "duration_seconds": 3000},
|
||||
)
|
||||
assert r.status_code == 200 and r.json() == {
|
||||
"ok": True,
|
||||
"job_id": job.id,
|
||||
"position_seconds": 754,
|
||||
}
|
||||
|
||||
[item] = c.get("/api/downloads").json()
|
||||
assert item["position_seconds"] == 754
|
||||
# A second checkpoint moves it rather than adding a row.
|
||||
assert _checkpoint(c, job.id, 900) == 900
|
||||
assert _states(db) == [(me.id, job.id, 900)]
|
||||
|
||||
|
||||
def test_an_item_never_played_lists_zero(db, make_user, as_user):
|
||||
me = make_user()
|
||||
_done_job(db, me)
|
||||
[item] = as_user(me).get("/api/downloads").json()
|
||||
assert item["position_seconds"] == 0
|
||||
|
||||
|
||||
def test_too_early_or_near_the_end_clears_it(db, make_user, as_user):
|
||||
me = make_user()
|
||||
job = _done_job(db, me)
|
||||
c = as_user(me)
|
||||
|
||||
assert _checkpoint(c, job.id, 600) == 600
|
||||
assert _checkpoint(c, job.id, 2995) == 0
|
||||
assert _states(db) == []
|
||||
|
||||
assert _checkpoint(c, job.id, 600) == 600
|
||||
assert _checkpoint(c, job.id, 3) == 0
|
||||
assert _states(db) == []
|
||||
# Clearing what is not there is not an error (the beacon and the last checkpoint of one stop).
|
||||
assert _checkpoint(c, job.id, 0) == 0
|
||||
|
||||
|
||||
# The edges themselves, one second either side — the same rows as the client's
|
||||
# frontend/src/lib/resume.dom.test.ts: the rule must be applied alike on both sides, and the shared
|
||||
# constants alone (resume.contract.test.ts) would not see a `>=` that became `>` on one side.
|
||||
@pytest.mark.parametrize(
|
||||
("position", "duration", "kept"),
|
||||
[(4, 3000, 0), (5, 3000, 5), (2990, 3000, 2990), (2991, 3000, 0)],
|
||||
)
|
||||
def test_the_kept_point_at_its_exact_edges(position, duration, kept):
|
||||
body = ProgressIn(position_seconds=position, duration_seconds=duration)
|
||||
assert kept_position(body) == kept
|
||||
|
||||
|
||||
def test_a_recipient_keeps_their_own_place_in_a_shared_item(db, make_user, as_user):
|
||||
owner, friend = make_user(), make_user()
|
||||
job = _done_job(db, owner)
|
||||
db.add(DownloadShare(job_id=job.id, from_user_id=owner.id, to_user_id=friend.id))
|
||||
db.commit()
|
||||
|
||||
assert _checkpoint(as_user(owner), job.id, 100) == 100
|
||||
assert _checkpoint(as_user(friend), job.id, 2000) == 2000
|
||||
|
||||
assert as_user(owner).get("/api/downloads").json()[0]["position_seconds"] == 100
|
||||
assert as_user(friend).get("/api/downloads/shared").json()[0]["position_seconds"] == 2000
|
||||
|
||||
|
||||
def test_an_item_the_caller_cannot_play_is_a_404_and_writes_nothing(db, make_user, as_user):
|
||||
owner, stranger = make_user(), make_user()
|
||||
job = _done_job(db, owner)
|
||||
|
||||
r = as_user(stranger).post(
|
||||
f"/api/downloads/{job.id}/progress",
|
||||
json={"position_seconds": 600, "duration_seconds": 3000},
|
||||
)
|
||||
assert r.status_code == 404
|
||||
assert _states(db) == []
|
||||
|
||||
|
||||
def test_deleting_the_job_deletes_its_places(db, make_user, as_user):
|
||||
me = make_user()
|
||||
job = _done_job(db, me)
|
||||
assert _checkpoint(as_user(me), job.id, 600) == 600
|
||||
db.delete(db.get(DownloadJob, job.id))
|
||||
db.commit()
|
||||
assert _states(db) == []
|
||||
@@ -99,7 +99,7 @@ class TestPublicMetaAudioFlag:
|
||||
|
||||
|
||||
class TestCreateLinkDefault:
|
||||
"""D1: a newly minted share link is downloadable by default. The one-click Share relies on
|
||||
"""A newly minted share link is downloadable by default. The one-click Share relies on
|
||||
this — it POSTs no `allow_download`, so the API default decides. Forward-only lives here (the API
|
||||
default), NOT in a migration: the DownloadLink column default stays False, so no existing back-
|
||||
catalogue link silently flips to downloadable."""
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
"""A libx264 re-encode of an odd-sized frame: 4:2:0 halves the chroma, so libx264 refuses an odd
|
||||
width or height ("width not divisible by 2"). Measured: a source that is odd AND not 4:2:0 already
|
||||
(a 4:4:4 or RGB screen capture, 1365x767) failed its conversion outright; a 4:2:0 source never
|
||||
reaches the encoder odd (ffmpeg's crop rounds to its chroma grid), but the clip then stored the
|
||||
size the user dragged, not the one the file has. Every re-encode now ends its filter chain on
|
||||
`edit.EVEN_FRAME` (at most one pixel row or column), and a clip stores its probed frame.
|
||||
|
||||
Real ffmpeg on both paths — the refusal is the encoder's, so only the encoder can show it is gone.
|
||||
"""
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.config import settings
|
||||
from app.downloads import pipeline, playable, probe, service, storage
|
||||
from app.downloads import upload as uploadmod
|
||||
from app.models import DownloadJob, MediaAsset
|
||||
|
||||
pytestmark = pytest.mark.skipif(shutil.which("ffmpeg") is None, reason="ffmpeg not available")
|
||||
|
||||
|
||||
def _ffmpeg(*args: str) -> None:
|
||||
subprocess.run(["ffmpeg", "-y", "-loglevel", "error", *args], check=True)
|
||||
|
||||
|
||||
def _frame(path: Path) -> tuple[int | None, int | None]:
|
||||
_, _, w, h, _ = probe.probe_media(path)
|
||||
return w, h
|
||||
|
||||
|
||||
def test_an_odd_sized_4_4_4_video_converts_to_the_even_frame_below_it(tmp_path):
|
||||
src = tmp_path / "in.mkv"
|
||||
_ffmpeg(
|
||||
"-f", "lavfi", "-i", "testsrc=size=161x121:rate=10:duration=1",
|
||||
"-c:v", "libx264", "-pix_fmt", "yuv444p", str(src),
|
||||
) # fmt: skip
|
||||
assert _frame(src) == (161, 121) # odd for real (`testsrc2` rounds its own size)
|
||||
p = playable.plan(probe.probe_json(src), "mkv")
|
||||
assert p is not None and p.reencode_video
|
||||
dest = tmp_path / "out.mp4"
|
||||
subprocess.run(playable.build_cmd(src, dest, p), check=True, capture_output=True)
|
||||
assert _frame(dest) == (160, 120)
|
||||
|
||||
|
||||
def test_an_odd_crop_makes_a_clip_and_stores_the_frame_it_has(db, make_user, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(settings, "download_root", str(tmp_path))
|
||||
src = tmp_path / "in.mp4"
|
||||
_ffmpeg(
|
||||
"-f", "lavfi", "-i", "testsrc2=size=160x120:rate=10:duration=2",
|
||||
"-f", "lavfi", "-i", "sine=duration=2",
|
||||
"-c:v", "libx264", "-pix_fmt", "yuv420p", "-c:a", "aac", str(src),
|
||||
) # fmt: skip
|
||||
u = make_user()
|
||||
source = uploadmod.ingest_upload(
|
||||
db, u.id, filename="clip.mp4", src_path=src, size=src.stat().st_size
|
||||
)
|
||||
assert source.status == "done" # already phone-safe: the clip's source as it came
|
||||
job = service.enqueue_edit(db, u.id, source, {"crop": {"x": 3, "y": 5, "w": 101, "h": 51}})
|
||||
asset = db.get(MediaAsset, job.asset_id)
|
||||
job.status, asset.status = "running", "downloading" # the worker's claim
|
||||
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, asset.status) == ("done", None, "ready")
|
||||
final = storage.abs_path(str(tmp_path), asset.rel_path)
|
||||
assert _frame(final) == (100, 50)
|
||||
assert (asset.width, asset.height) == (100, 50) # what the file is, not what was asked
|
||||
@@ -0,0 +1,163 @@
|
||||
"""The share-link contract: every media file a /watch link can serve passes the phone-safe rule
|
||||
(`downloads.playable`).
|
||||
|
||||
Two halves. The structural one reads the code: a link serves an asset once it is `ready`, so every
|
||||
function that marks one ready must run the rule — a new path to `ready` that skips it is named here,
|
||||
before a friend's phone meets the file. The end-to-end one sends the files measured broken on an
|
||||
iPhone + iPad (and the ones browsers other than Safari cannot be trusted with) through upload, the
|
||||
worker's conversion and the public file route, and judges the bytes the link actually hands out.
|
||||
"""
|
||||
|
||||
import ast
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.config import settings
|
||||
from app.downloads import pipeline, playable, probe
|
||||
from app.models import DownloadJob, MediaAsset
|
||||
|
||||
APP = Path(__file__).resolve().parents[1] / "app"
|
||||
|
||||
# The calls that ARE the rule: the worker's step (download, clip, upload conversion) and the upload's
|
||||
# judgement of a file it keeps as it came.
|
||||
_RULE_CALLS = {"_ensure_phone_safe", "plan"}
|
||||
|
||||
|
||||
def _marks_ready(node: ast.AST) -> bool:
|
||||
"""`x.status = "ready"`, `status="ready"`, or `"…" if … else "ready"` handed to either."""
|
||||
|
||||
def is_ready(v: ast.AST | None) -> bool:
|
||||
if isinstance(v, ast.Constant):
|
||||
return v.value == "ready"
|
||||
if isinstance(v, ast.IfExp):
|
||||
return is_ready(v.body) or is_ready(v.orelse)
|
||||
return False
|
||||
|
||||
if isinstance(node, ast.Assign):
|
||||
return is_ready(node.value) and any(
|
||||
isinstance(t, ast.Attribute) and t.attr == "status" for t in node.targets
|
||||
)
|
||||
return isinstance(node, ast.keyword) and node.arg == "status" and is_ready(node.value)
|
||||
|
||||
|
||||
def _calls(fn: ast.AST) -> set[str]:
|
||||
names = set()
|
||||
for n in ast.walk(fn):
|
||||
if isinstance(n, ast.Call):
|
||||
f = n.func
|
||||
names.add(f.attr if isinstance(f, ast.Attribute) else getattr(f, "id", ""))
|
||||
return names
|
||||
|
||||
|
||||
def ready_sites(source: str) -> list[tuple[str, int, bool]]:
|
||||
"""(function, line, runs the rule) for every place `source` marks something ready."""
|
||||
sites = []
|
||||
for fn in ast.walk(ast.parse(source)):
|
||||
if not isinstance(fn, ast.FunctionDef | ast.AsyncFunctionDef):
|
||||
continue
|
||||
runs_rule = bool(_calls(fn) & _RULE_CALLS)
|
||||
for n in ast.walk(fn):
|
||||
if _marks_ready(n):
|
||||
sites.append((fn.name, n.lineno, runs_rule))
|
||||
return sites
|
||||
|
||||
|
||||
def test_every_path_to_ready_runs_the_rule():
|
||||
found, skipped = [], []
|
||||
for path in sorted(APP.rglob("*.py")):
|
||||
for name, line, runs_rule in ready_sites(path.read_text(encoding="utf-8")):
|
||||
where = f"{path.relative_to(APP.parent).as_posix()}:{line} {name}"
|
||||
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.
|
||||
# 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 skipped == [], f"marks an asset ready without the phone-safe rule: {skipped}"
|
||||
|
||||
|
||||
def test_the_scan_names_a_path_that_skips_the_rule():
|
||||
planted = 'def restore(asset):\n asset.status = "ready"\n'
|
||||
guarded = (
|
||||
'def restore(asset, p):\n _ensure_phone_safe(1, p, p, "x")\n asset.status = "ready"\n'
|
||||
)
|
||||
assert ready_sites(planted) == [("restore", 2, False)]
|
||||
assert ready_sites(guarded) == [("restore", 3, True)]
|
||||
assert (
|
||||
ready_sites('def f(a, x):\n a.update(status="pending" if x else "ready")\n')[0][2]
|
||||
is False
|
||||
)
|
||||
|
||||
|
||||
# --- end to end: upload → worker → the bytes a link serves ---------------------------------------
|
||||
|
||||
_V = ["-f", "lavfi", "-i", "testsrc2=size=160x120:rate=10:duration=1"]
|
||||
_A = ["-f", "lavfi", "-i", "sine=duration=1"]
|
||||
|
||||
# name -> (ffmpeg args after the inputs, filename the user uploads)
|
||||
_FILES = {
|
||||
"hevc tagged hev1 (iOS: broken)": (
|
||||
[*_V, *_A, "-c:v", "libx265", "-tag:v", "hev1", "-c:a", "aac"],
|
||||
"phone.mov",
|
||||
),
|
||||
"av1 (iOS: broken)": ([*_V, *_A, "-c:v", "libsvtav1", "-c:a", "aac"], "clip.mp4"),
|
||||
"h264 in matroska (iOS: broken)": ([*_V, *_A, "-c:v", "libx264", "-c:a", "aac"], "clip.mkv"),
|
||||
"mp3 inside an mp4 (iOS: broken)": ([*_A, "-c:a", "libmp3lame", "-f", "mp4"], "song.mp4"),
|
||||
"vp9 + opus webm": ([*_V, *_A, "-c:v", "libvpx-vp9", "-c:a", "libopus"], "clip.webm"),
|
||||
"10-bit h264": (
|
||||
[*_V, *_A, "-c:v", "libx264", "-pix_fmt", "yuv420p10le", "-c:a", "aac"],
|
||||
"clip.mp4",
|
||||
),
|
||||
"opus in ogg": ([*_A, "-c:a", "libopus"], "voice.ogg"),
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def media_root(tmp_path, monkeypatch):
|
||||
root = tmp_path / "media"
|
||||
root.mkdir()
|
||||
monkeypatch.setattr(settings, "download_root", str(root))
|
||||
return root
|
||||
|
||||
|
||||
@pytest.mark.skipif(shutil.which("ffmpeg") is None, reason="ffmpeg not available")
|
||||
@pytest.mark.parametrize("name", _FILES)
|
||||
def test_what_a_link_serves_passes_the_rule(
|
||||
name, db, as_user, make_user, client, media_root, tmp_path, upload_headers
|
||||
):
|
||||
args, filename = _FILES[name]
|
||||
src = tmp_path / f"src{Path(filename).suffix}"
|
||||
subprocess.run(["ffmpeg", "-y", "-loglevel", "error", *args, str(src)], check=True)
|
||||
|
||||
owner = as_user(make_user())
|
||||
r = owner.post(
|
||||
"/api/downloads/upload", content=src.read_bytes(), headers=upload_headers(filename)
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
job_id = r.json()["id"]
|
||||
assert r.json()["status"] == "queued" # every one of these fails the rule as it came
|
||||
job = db.get(DownloadJob, job_id)
|
||||
asset = db.get(MediaAsset, job.asset_id)
|
||||
job.status, asset.status = "running", "downloading" # what the worker's claim does
|
||||
db.commit()
|
||||
pipeline._convert_upload(job.id, asset.id)
|
||||
db.expire_all()
|
||||
assert db.get(DownloadJob, job_id).status == "done"
|
||||
|
||||
link = owner.post(f"/api/downloads/{job_id}/links", json={})
|
||||
assert link.status_code == 200, link.text
|
||||
served = client.get(f"/api/public/watch/{link.json()['token']}/file")
|
||||
assert served.status_code == 200
|
||||
got = tmp_path / "served.bin"
|
||||
got.write_bytes(served.content)
|
||||
data = probe.probe_json(got)
|
||||
ext = {"video/mp4": "mp4", "audio/mp4": "m4a", "audio/mpeg": "mp3"}.get(
|
||||
served.headers["content-type"].split(";")[0]
|
||||
)
|
||||
assert ext is not None, served.headers["content-type"] # served as a type every phone plays
|
||||
verdict = playable.plan(data, ext)
|
||||
assert verdict is not None and not verdict.needs_change, (name, data["streams"])
|
||||
@@ -0,0 +1,157 @@
|
||||
"""The phone-safe rule (`downloads.playable`): what a library file must be so a share link plays
|
||||
on any phone or browser, and the one ffmpeg command that makes it so.
|
||||
|
||||
The plan table is the measured device verdict, class by class: what failed on a real iPhone + iPad
|
||||
(AV1, Matroska, HEVC, MP3 inside an .mp4) must change, and so must what browsers other than Safari
|
||||
cannot be trusted with (10-bit / 4:4:4 H.264, VP9, Opus). The ffmpeg cases run the real encoder and
|
||||
probe its output, because the rule is about the bytes, not the flags.
|
||||
"""
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.downloads import playable, probe
|
||||
|
||||
# conftest turns a missing ffmpeg into a hard error in the gate (SIFTLODE_TEST_REQUIRE_DB), so this
|
||||
# only ever skips on an explicit pure-logic run.
|
||||
needs_ffmpeg = pytest.mark.skipif(shutil.which("ffmpeg") is None, reason="ffmpeg not available")
|
||||
|
||||
|
||||
def _data(video=None, audio=None, fmt="mov,mp4,m4a,3gp,3g2,mj2", cover=False):
|
||||
streams = []
|
||||
if cover:
|
||||
streams.append(
|
||||
{"codec_type": "video", "codec_name": "mjpeg", "disposition": {"attached_pic": 1}}
|
||||
)
|
||||
if video:
|
||||
codec, pix = video
|
||||
streams.append({"codec_type": "video", "codec_name": codec, "pix_fmt": pix})
|
||||
if audio:
|
||||
streams.append({"codec_type": "audio", "codec_name": audio})
|
||||
return {"streams": streams, "format": {"format_name": fmt}}
|
||||
|
||||
|
||||
# (probe, extension) -> (ext, reencode_video, reencode_audio, needs_change)
|
||||
CASES = {
|
||||
"h264 8-bit + aac in mp4": (_data(("h264", "yuv420p"), "aac"), "mp4", ("mp4", 0, 0, 0)),
|
||||
"full-range h264 is still 8-bit 4:2:0": (
|
||||
_data(("h264", "yuvj420p"), "aac"),
|
||||
"mp4",
|
||||
("mp4", 0, 0, 0),
|
||||
),
|
||||
"10-bit h264": (_data(("h264", "yuv420p10le"), "aac"), "mp4", ("mp4", 1, 0, 1)),
|
||||
"4:4:4 h264": (_data(("h264", "yuv444p"), "aac"), "mp4", ("mp4", 1, 0, 1)),
|
||||
"hevc (an iPhone's .mov)": (_data(("hevc", "yuv420p10le"), "aac"), "mov", ("mp4", 1, 0, 1)),
|
||||
"vp9 + opus webm": (_data(("vp9", "yuv420p"), "opus"), "webm", ("mp4", 1, 1, 1)),
|
||||
"av1": (_data(("av1", "yuv420p"), "aac"), "mp4", ("mp4", 1, 0, 1)),
|
||||
"h264 + aac in mkv: remux only": (_data(("h264", "yuv420p"), "aac"), "mkv", ("mp4", 0, 0, 1)),
|
||||
"h264 + opus in mp4": (_data(("h264", "yuv420p"), "opus"), "mp4", ("mp4", 0, 1, 1)),
|
||||
"mp3 beside video in mp4": (_data(("h264", "yuv420p"), "mp3"), "mp4", ("mp4", 0, 1, 1)),
|
||||
"a silent h264 video": (_data(("h264", "yuv420p")), "mp4", ("mp4", 0, 0, 0)),
|
||||
"mp3": (_data(audio="mp3", fmt="mp3"), "mp3", ("mp3", 0, 0, 0)),
|
||||
"mp3 with cover art": (_data(audio="mp3", fmt="mp3", cover=True), "mp3", ("mp3", 0, 0, 0)),
|
||||
"mp3 inside an mp4: remux to .mp3": (_data(audio="mp3"), "mp4", ("mp3", 0, 0, 1)),
|
||||
"aac in m4a": (_data(audio="aac"), "m4a", ("m4a", 0, 0, 0)),
|
||||
"aac audio-only in mp4: remux to .m4a": (_data(audio="aac"), "mp4", ("m4a", 0, 0, 1)),
|
||||
"opus in ogg": (_data(audio="opus", fmt="ogg"), "ogg", ("m4a", 0, 1, 1)),
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", CASES)
|
||||
def test_the_plan_for_each_class(name):
|
||||
data, ext, (want_ext, rv, ra, needs) = CASES[name]
|
||||
p = playable.plan(data, ext)
|
||||
assert p is not None
|
||||
assert (p.ext, p.reencode_video, p.reencode_audio, p.needs_change) == (
|
||||
want_ext,
|
||||
bool(rv),
|
||||
bool(ra),
|
||||
bool(needs),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"data",
|
||||
[
|
||||
None, # ffprobe could not read it
|
||||
{"streams": [], "format": {"format_name": "pdf"}},
|
||||
_data(("png", "rgb24"), fmt="png_pipe"), # a still image is a download-only file
|
||||
],
|
||||
)
|
||||
def test_a_file_that_is_not_media_has_no_plan(data):
|
||||
assert playable.plan(data, "bin") is None
|
||||
|
||||
|
||||
def test_the_extension_is_read_with_or_without_its_dot():
|
||||
data = _data(("h264", "yuv420p"), "aac")
|
||||
assert playable.plan(data, ".MP4") == playable.plan(data, "mp4")
|
||||
assert not playable.plan(data, ".MP4").needs_change
|
||||
|
||||
|
||||
def test_a_video_re_encode_pins_8_bit_420_and_maps_only_the_judged_streams():
|
||||
p = playable.Plan("mp4", True, True, False, True)
|
||||
cmd = playable.build_cmd(Path("/in.mov"), Path("/out.mp4"), p)
|
||||
assert cmd[cmd.index("-pix_fmt") + 1] == "yuv420p"
|
||||
assert cmd[cmd.index("-c:v") + 1] == "libx264"
|
||||
assert cmd[cmd.index("-c:a") + 1] == "copy"
|
||||
maps = [cmd[i + 1] for i, a in enumerate(cmd) if a == "-map"]
|
||||
assert maps == ["0:V:0", "0:a:0?"] # never cover art, never a subtitle the muxer refuses
|
||||
assert cmd[-3:] == ["-movflags", "+faststart", "/out.mp4"]
|
||||
|
||||
|
||||
def test_an_audio_remux_copies_the_audio_alone():
|
||||
p = playable.Plan("mp3", False, False, False, True)
|
||||
cmd = playable.build_cmd(Path("/in.mp4"), Path("/out.mp3"), p)
|
||||
assert [cmd[i + 1] for i, a in enumerate(cmd) if a == "-map"] == ["0:a:0"]
|
||||
assert "-c:v" not in cmd and "-pix_fmt" not in cmd
|
||||
assert cmd[cmd.index("-c:a") + 1] == "copy"
|
||||
assert "-movflags" not in cmd # an .mp3 has no moov atom to move
|
||||
|
||||
|
||||
# --- the real encoder ------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _ffmpeg(*args: str) -> None:
|
||||
subprocess.run(["ffmpeg", "-y", "-loglevel", "error", *args], check=True)
|
||||
|
||||
|
||||
def _convert(src: Path, tmp_path: Path) -> Path:
|
||||
p = playable.plan(probe.probe_json(src), src.suffix)
|
||||
assert p is not None and p.needs_change
|
||||
dest = tmp_path / f"out.{p.ext}"
|
||||
subprocess.run(playable.build_cmd(src, dest, p), check=True, capture_output=True)
|
||||
return dest
|
||||
|
||||
|
||||
@needs_ffmpeg
|
||||
def test_a_10_bit_matroska_with_opus_comes_out_phone_safe(tmp_path):
|
||||
src = tmp_path / "in.mkv"
|
||||
_ffmpeg(
|
||||
"-f", "lavfi", "-i", "testsrc2=size=160x120:rate=10:duration=1",
|
||||
"-f", "lavfi", "-i", "sine=duration=1",
|
||||
"-c:v", "libx264", "-pix_fmt", "yuv420p10le", "-c:a", "libopus",
|
||||
str(src),
|
||||
) # fmt: skip
|
||||
out = _convert(src, tmp_path)
|
||||
data = probe.probe_json(out)
|
||||
video, audio = probe.primary_streams(data)
|
||||
assert (video["codec_name"], video["pix_fmt"], video["profile"]) == ("h264", "yuv420p", "High")
|
||||
assert audio["codec_name"] == "aac"
|
||||
assert out.suffix == ".mp4"
|
||||
assert not playable.plan(data, out.suffix).needs_change # the rule accepts its own output
|
||||
|
||||
|
||||
@needs_ffmpeg
|
||||
def test_mp3_inside_an_mp4_comes_out_as_an_mp3(tmp_path):
|
||||
# The one unsafe file prod held: an audio download remuxed into .mp4, which iPhone and iPad
|
||||
# both refused to play.
|
||||
src = tmp_path / "in.mp4"
|
||||
_ffmpeg("-f", "lavfi", "-i", "sine=duration=1", "-c:a", "libmp3lame", "-f", "mp4", str(src))
|
||||
out = _convert(src, tmp_path)
|
||||
data = probe.probe_json(out)
|
||||
assert out.suffix == ".mp3"
|
||||
assert probe.primary_streams(data)[1]["codec_name"] == "mp3"
|
||||
assert not playable.plan(data, out.suffix).needs_change
|
||||
@@ -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
|
||||
@@ -401,3 +401,38 @@ def test_item_detail_names_tracks_from_their_language_not_plexs_title(
|
||||
("magyar Forced", "hu", True, False, None),
|
||||
("English SDH", "en", False, True, None),
|
||||
]
|
||||
|
||||
|
||||
def test_item_detail_markers_carry_plex_final_flag(db, monkeypatch, override_dep):
|
||||
"""An item can carry several credits markers — mid- and post-credit scenes sit between them —
|
||||
and only the one Plex flags `final` is the real end; the player needs that flag to know which
|
||||
credits may move on to the next item."""
|
||||
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
||||
db.add(lib)
|
||||
db.flush()
|
||||
db.add(PlexItem(rating_key="m2", library_id=lib.id, kind="movie", title="Iron Man"))
|
||||
db.commit()
|
||||
user = _user(db, "markers@example.com")
|
||||
db.commit()
|
||||
|
||||
meta = {
|
||||
"Marker": [
|
||||
{"type": "intro", "startTimeOffset": 1000, "endTimeOffset": 60000},
|
||||
{"type": "credits", "startTimeOffset": 6811000, "endTimeOffset": 6831000},
|
||||
{
|
||||
"type": "credits",
|
||||
"startTimeOffset": 7549000,
|
||||
"endTimeOffset": 7559000,
|
||||
"final": True,
|
||||
},
|
||||
{"type": "commercial", "startTimeOffset": 1, "endTimeOffset": 2},
|
||||
]
|
||||
}
|
||||
monkeypatch.setattr(detail_routes, "PlexClient", lambda db: _StubClient(meta))
|
||||
resp = _get(db, user, monkeypatch, override_dep, "/api/plex/item/m2")
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["markers"] == [
|
||||
{"type": "intro", "start_s": 1.0, "end_s": 60.0, "final": False},
|
||||
{"type": "credits", "start_s": 6811.0, "end_s": 6831.0, "final": False},
|
||||
{"type": "credits", "start_s": 7549.0, "end_s": 7559.0, "final": True},
|
||||
]
|
||||
|
||||
@@ -96,6 +96,8 @@ def test_remux_item_stays_a_stream_copy_session(db, monkeypatch, override_dep):
|
||||
assert body["mode"] == "hls"
|
||||
assert body["transcode"] is False
|
||||
assert captured["transcode"] is False
|
||||
# The id the player names when it stops the session (POST …/session/{id}/stop).
|
||||
assert body["id"] == "sid"
|
||||
|
||||
|
||||
def test_direct_item_never_opens_a_session(db, monkeypatch, override_dep):
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
"""A player that no longer wants its HLS session stops it, instead of leaving ffmpeg to run on.
|
||||
|
||||
Left to the idle reaper, a transcode ran to the end of the film after the viewer had gone (measured on
|
||||
localdev: ~4 cores for a minute after leaving a resumed 480p film). The player names the session it
|
||||
played; the server stops it only while it is still that viewer's registered session for the item, so
|
||||
a stop that arrives after a seek-restart (or from a second tab) cannot end the newer playback.
|
||||
|
||||
No `db` fixture: the route reads nothing from the database (`current_user` is overridden wholesale).
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import app.state as state
|
||||
from app.auth import current_user
|
||||
from app.main import app
|
||||
from app.models import User
|
||||
from app.plex import stream as plex_stream
|
||||
|
||||
VIEWER = 41
|
||||
|
||||
|
||||
class _Proc:
|
||||
def __init__(self):
|
||||
self.terminated = False
|
||||
|
||||
def poll(self):
|
||||
return None
|
||||
|
||||
def terminate(self):
|
||||
self.terminated = True
|
||||
|
||||
def wait(self, timeout=None):
|
||||
return 0
|
||||
|
||||
|
||||
class _Session:
|
||||
def __init__(self, session_id: str, directory):
|
||||
self.id = session_id
|
||||
self.dir = directory
|
||||
self.proc = _Proc()
|
||||
self.last_access = time.time()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sessions(monkeypatch):
|
||||
live: dict = {}
|
||||
monkeypatch.setattr(plex_stream, "_sessions", live)
|
||||
return live
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(monkeypatch, override_dep):
|
||||
monkeypatch.setattr(state, "_configured_cache", True)
|
||||
user = User(id=VIEWER, email="stop@example.com")
|
||||
override_dep(current_user, lambda: user)
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def _session(tmp_path, sid: str) -> _Session:
|
||||
d = tmp_path / sid
|
||||
d.mkdir()
|
||||
return _Session(sid, d)
|
||||
|
||||
|
||||
def test_the_named_session_is_stopped_and_its_directory_goes(client, tmp_path, sessions):
|
||||
s = _session(tmp_path, "sid-a")
|
||||
sessions[(VIEWER, "m1")] = s
|
||||
r = client.post("/api/plex/stream/m1/session/sid-a/stop", json={})
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json() == {"ok": True, "stopped": True}
|
||||
assert s.proc.terminated
|
||||
assert sessions == {}
|
||||
assert not s.dir.exists()
|
||||
|
||||
|
||||
def test_a_session_that_replaced_the_named_one_plays_on(client, tmp_path, sessions):
|
||||
"""The stop of the session a seek-restart replaced arrives after the new one registered."""
|
||||
newer = _session(tmp_path, "sid-b")
|
||||
sessions[(VIEWER, "m1")] = newer
|
||||
r = client.post("/api/plex/stream/m1/session/sid-a/stop", json={})
|
||||
assert r.json() == {"ok": True, "stopped": False}
|
||||
assert not newer.proc.terminated
|
||||
assert sessions == {(VIEWER, "m1"): newer}
|
||||
|
||||
|
||||
def test_another_viewers_session_is_not_this_viewers_to_stop(client, tmp_path, sessions):
|
||||
theirs = _session(tmp_path, "sid-a")
|
||||
sessions[(7, "m1")] = theirs
|
||||
r = client.post("/api/plex/stream/m1/session/sid-a/stop", json={})
|
||||
assert r.json() == {"ok": True, "stopped": False}
|
||||
assert not theirs.proc.terminated
|
||||
|
||||
|
||||
def test_a_stop_with_no_session_left_is_not_an_error(client, sessions):
|
||||
"""The reaper or a cap may have dropped it first; the beacon must not fail loudly for that."""
|
||||
r = client.post("/api/plex/stream/m1/session/sid-a/stop", json={})
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json() == {"ok": True, "stopped": False}
|
||||
@@ -695,7 +695,9 @@ class TestTranscodeStartSession:
|
||||
# F2/race: start_session waits on seg_0 OUTSIDE _lock. If a concurrent same-key start (a rapid
|
||||
# seek-restart) or _enforce_cap replaces _sessions[key] while we wait, our proc dying must NOT
|
||||
# trigger the SW fallback — that would tear down the NEWER session and breach the transcode cap.
|
||||
# The identity guard must detect it's been superseded and yield the currently-registered session.
|
||||
# The identity guard must detect it's been superseded. It answers with its OWN (dead) session,
|
||||
# never the newer one: the answer goes to the request that lost, whose player drops it as stale
|
||||
# and stops the session it names — naming the newer one stopped the playback that won.
|
||||
monkeypatch.setattr(
|
||||
stream, "_select_encode", lambda db, item, transcode, src: (True, False)
|
||||
)
|
||||
@@ -708,9 +710,12 @@ class TestTranscodeStartSession:
|
||||
|
||||
newer = _Newer()
|
||||
|
||||
captured = {}
|
||||
|
||||
def wait_then_supersede(path, timeout=20.0, proc=None):
|
||||
# Simulate the concurrent replacement happening during our out-of-lock wait, then report the
|
||||
# (now-killed) HW proc produced nothing.
|
||||
captured["s"] = stream._sessions[(7, "12345")]
|
||||
stream._sessions[(7, "12345")] = newer
|
||||
return False
|
||||
|
||||
@@ -720,7 +725,7 @@ class TestTranscodeStartSession:
|
||||
)
|
||||
# No SW respawn — only the initial HW _spawn ran; the newer session was left intact.
|
||||
assert len(_CapturingProc.calls) == 1
|
||||
assert s is newer # yielded the currently-registered session, did not clobber it
|
||||
assert s is captured["s"] # its own session, not the newer one
|
||||
assert stream._sessions[(7, "12345")] is newer
|
||||
|
||||
def test_a_cap_culled_hw_session_does_not_respawn_and_is_not_reported_missing(
|
||||
|
||||
@@ -23,7 +23,10 @@ def client(monkeypatch, override_dep) -> TestClient:
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", ["/api/videos/v1/progress", "/api/plex/item/98614/progress"])
|
||||
@pytest.mark.parametrize(
|
||||
"path",
|
||||
["/api/videos/v1/progress", "/api/plex/item/98614/progress", "/api/downloads/1/progress"],
|
||||
)
|
||||
@pytest.mark.parametrize("raw", ["Infinity", "-Infinity", "NaN"])
|
||||
def test_a_non_finite_position_is_refused(client, path, raw):
|
||||
r = client.post(
|
||||
@@ -34,7 +37,10 @@ def test_a_non_finite_position_is_refused(client, path, raw):
|
||||
assert r.status_code == 422, r.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", ["/api/videos/v1/progress", "/api/plex/item/98614/progress"])
|
||||
@pytest.mark.parametrize(
|
||||
"path",
|
||||
["/api/videos/v1/progress", "/api/plex/item/98614/progress", "/api/downloads/1/progress"],
|
||||
)
|
||||
@pytest.mark.parametrize("field", ["position_seconds", "duration_seconds"])
|
||||
def test_a_position_past_the_int4_column_is_refused_not_a_500(client, path, field):
|
||||
"""Finite but past the int4 column: the flush was a DataError, i.e. a 500 with a traceback."""
|
||||
|
||||
@@ -20,7 +20,10 @@ _PW = "open sesame"
|
||||
@pytest.fixture
|
||||
def unlock_url(as_user, make_user, tmp_path, monkeypatch, upload_headers):
|
||||
monkeypatch.setattr(settings, "download_root", str(tmp_path))
|
||||
monkeypatch.setattr(probemod, "probe_media", lambda _p: ("h264", "aac", 320, 240, 5))
|
||||
video = {"codec_type": "video", "codec_name": "h264", "pix_fmt": "yuv420p"}
|
||||
audio = {"codec_type": "audio", "codec_name": "aac"}
|
||||
data = {"streams": [video, audio], "format": {"format_name": "mov,mp4", "duration": "5"}}
|
||||
monkeypatch.setattr(probemod, "probe_json", lambda _p: data)
|
||||
owner = as_user(make_user())
|
||||
job = owner.post(
|
||||
"/api/downloads/upload", content=b"fake-video", headers=upload_headers("clip.mp4")
|
||||
|
||||
@@ -7,7 +7,7 @@ from pydantic import ValidationError
|
||||
|
||||
from app.routes.channels import AttachTagIn, ChannelUpdateIn
|
||||
from app.routes.downloads import AdminSetQuotaIn
|
||||
from app.routes.feed import VideoProgressIn
|
||||
from app.routes._common import ProgressIn
|
||||
from app.routes.setup import SetupAdminIn
|
||||
from app.routes.tags import TagUpdateIn
|
||||
|
||||
@@ -52,10 +52,10 @@ def test_admin_quota_rejects_non_numeric_limit():
|
||||
def test_progress_accepts_fractional_seconds():
|
||||
# The player may post a raw currentTime (a float). The field must accept it (truncated in the
|
||||
# handler) — a plain `int` field would 422 on the progress hot path.
|
||||
assert VideoProgressIn(position_seconds=12.7).position_seconds == 12.7
|
||||
assert int(VideoProgressIn(position_seconds=12.7).position_seconds) == 12
|
||||
assert ProgressIn(position_seconds=12.7).position_seconds == 12.7
|
||||
assert int(ProgressIn(position_seconds=12.7).position_seconds) == 12
|
||||
with pytest.raises(ValidationError):
|
||||
VideoProgressIn(position_seconds="not-a-number")
|
||||
ProgressIn(position_seconds="not-a-number")
|
||||
|
||||
|
||||
# --- PATCH models: model_fields_set separates "absent" from "explicit null". ---
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Which HTML page the server hands a path (main.py), DB-lane.
|
||||
|
||||
The frontend builds two entries: the app (index.html) and a shared link's player alone (watch.html), so
|
||||
the friend a /watch link was sent to never downloads the app. Every /watch/ path must get the player's
|
||||
page — with the link's Open Graph tags when the token names a playable link — and nothing else may.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from app import main
|
||||
from app.config import settings
|
||||
from app.downloads import probe as probemod
|
||||
|
||||
_PAGE = """<!doctype html><html><head>
|
||||
<!--OG:START--><meta property="og:title" content="Siftlode" /><!--OG:END-->
|
||||
</head><body><script src="/assets/{entry}.js"></script></body></html>"""
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pages(tmp_path, monkeypatch):
|
||||
index, watch = tmp_path / "index.html", tmp_path / "watch.html"
|
||||
index.write_text(_PAGE.format(entry="app"), encoding="utf-8")
|
||||
watch.write_text(_PAGE.format(entry="watch"), encoding="utf-8")
|
||||
monkeypatch.setattr(main, "INDEX_HTML", index)
|
||||
monkeypatch.setattr(main, "WATCH_HTML", watch)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def link_token(as_user, make_user, tmp_path, monkeypatch, upload_headers):
|
||||
monkeypatch.setattr(settings, "download_root", str(tmp_path / "media"))
|
||||
video = {"codec_type": "video", "codec_name": "h264", "pix_fmt": "yuv420p"}
|
||||
audio = {"codec_type": "audio", "codec_name": "aac"}
|
||||
data = {"streams": [video, audio], "format": {"format_name": "mov,mp4", "duration": "5"}}
|
||||
monkeypatch.setattr(probemod, "probe_json", lambda _p: data)
|
||||
owner = as_user(make_user())
|
||||
job = owner.post(
|
||||
"/api/downloads/upload",
|
||||
content=b"fake-video",
|
||||
headers=upload_headers("clip.mp4", display_name="Holiday clip"),
|
||||
)
|
||||
assert job.status_code == 200, job.text
|
||||
link = owner.post(f"/api/downloads/{job.json()['id']}/links", json={})
|
||||
assert link.status_code == 200, link.text
|
||||
return link.json()["token"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", ["/", "/feed", "/downloads/42"])
|
||||
def test_an_app_path_gets_the_app(pages, client, path):
|
||||
r = client.get(path)
|
||||
assert r.status_code == 200
|
||||
assert "/assets/app.js" in r.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", ["/watch/no-such-token", "/watch/no-such-token/extra"])
|
||||
def test_every_watch_path_gets_the_player_alone(pages, client, path):
|
||||
r = client.get(path)
|
||||
assert r.status_code == 200
|
||||
assert "/assets/watch.js" in r.text and "/assets/app.js" not in r.text
|
||||
assert r.headers["cache-control"] == "no-cache"
|
||||
|
||||
|
||||
def test_a_live_link_unfurls_from_the_players_page(pages, client, link_token):
|
||||
r = client.get(f"/watch/{link_token}")
|
||||
assert r.status_code == 200
|
||||
assert "/assets/watch.js" in r.text
|
||||
assert 'content="Holiday clip"' in r.text
|
||||
assert '<meta property="og:title" content="Siftlode" />' not in r.text # replaced, not appended
|
||||
+181
-22
@@ -7,15 +7,18 @@ guards that keep an upload out of paths that assume a re-fetchable source (edit
|
||||
|
||||
DB-backed (real Postgres) — skipped when no database is reachable. `settings.download_root` is
|
||||
pointed at a per-test tmp dir so ingest's on-disk moves are isolated. The ingest/route tests
|
||||
monkeypatch the shared `probe.probe_media` so they don't shell out to ffprobe; the classifier's OWN
|
||||
monkeypatch the shared `probe.probe_json` so they don't shell out to ffprobe; the classifier's OWN
|
||||
parsing (image → file, cover-art skip, dims, duration) is covered directly by `TestProbeClassifier`
|
||||
below, which stubs one level down (`run_ffprobe_json`) and exercises the real `probe_media`.
|
||||
below, which stubs one level down (`run_ffprobe_json`) and exercises the real `probe_media`. The
|
||||
phone-safe conversion of an upload runs the real ffmpeg (`TestPhoneSafeUpload`).
|
||||
"""
|
||||
|
||||
import ast
|
||||
import asyncio
|
||||
import io
|
||||
import pathlib
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
@@ -25,9 +28,11 @@ from app.config import settings
|
||||
from app.routes._common import capped_stream_to
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.downloads import mediakind, quota, storage
|
||||
from app.downloads import gc as gcmod
|
||||
from app.downloads import mediakind, pipeline, playable, quota, service, storage
|
||||
from app.downloads import probe as probemod
|
||||
from app.downloads import upload as uploadmod
|
||||
from app.downloads.stated import StatedFailure
|
||||
from app.models import DISPLAY_TEXT_MAX, DownloadJob, DownloadQuota, MediaAsset
|
||||
|
||||
|
||||
@@ -51,9 +56,20 @@ def _root(tmp_path, monkeypatch):
|
||||
|
||||
|
||||
def _probe(monkeypatch, result):
|
||||
# upload ingest now calls the shared `probe.probe_media` (same ground-truth ffprobe the download
|
||||
# worker uses); patch it at that shared home.
|
||||
monkeypatch.setattr(probemod, "probe_media", lambda _p: result)
|
||||
"""Stub the shared ffprobe call (`probe.probe_json`, the same ground truth the download worker
|
||||
reads) with the JSON a file of these (vcodec, acodec, width, height, duration) would give — its
|
||||
video 8-bit 4:2:0, so an H.264 + AAC .mp4 is already phone-safe and ingests straight to `done`.
|
||||
All-None is a file ffprobe could not read."""
|
||||
v, a, w, h, d = result
|
||||
streams = []
|
||||
if v:
|
||||
streams.append(
|
||||
{"codec_type": "video", "codec_name": v, "pix_fmt": "yuv420p", "width": w, "height": h}
|
||||
)
|
||||
if a:
|
||||
streams.append({"codec_type": "audio", "codec_name": a})
|
||||
data = {"streams": streams, "format": {"format_name": "mov,mp4", "duration": d}}
|
||||
monkeypatch.setattr(probemod, "probe_json", lambda _p: data if streams else None)
|
||||
|
||||
|
||||
def test_ingest_generic_file_is_download_only(db, make_user, _root, monkeypatch):
|
||||
@@ -281,7 +297,7 @@ def test_upload_route_generic_forces_download(
|
||||
|
||||
|
||||
def test_upload_route_media_html_extension_not_served_as_html(
|
||||
as_user, make_user, _root, monkeypatch, upload_headers
|
||||
db, as_user, make_user, _root, monkeypatch, upload_headers
|
||||
):
|
||||
"""The XSS guard: a MEDIA-classified upload (real codecs) named with an executable extension must
|
||||
NOT be served with a guessed text/html type. The served Content-Type comes from the verified
|
||||
@@ -298,6 +314,14 @@ def test_upload_route_media_html_extension_not_served_as_html(
|
||||
assert r.status_code == 200, r.text
|
||||
job = r.json()
|
||||
assert job["asset"]["media_kind"] == "video" and job["asset"]["container"] == "html"
|
||||
# Media in no phone-safe container is queued for conversion to .mp4, so today it is never served
|
||||
# as .html at all. The serving guard below is the second line: a row from before that rule (or
|
||||
# one whose conversion is skipped some day) must still not run as HTML.
|
||||
assert job["status"] == "queued"
|
||||
row = db.get(DownloadJob, job["id"])
|
||||
db.get(MediaAsset, row.asset_id).status = "ready"
|
||||
row.status = "done"
|
||||
db.commit()
|
||||
|
||||
# Authenticated endpoint: never text/html.
|
||||
fr = c.get(f"/api/downloads/{job['id']}/file")
|
||||
@@ -779,10 +803,10 @@ def test_ingest_rejects_over_max_jobs(db, make_user, _root, monkeypatch):
|
||||
|
||||
def test_ingest_cleans_up_placed_files_on_commit_failure(db, make_user, _root, monkeypatch):
|
||||
"""A failure AFTER storage.place_file has moved the file onto disk must roll back AND unlink the
|
||||
placed file + any (even partial) poster — otherwise the orphan is permanent (the per-user delete
|
||||
keys off the rolled-back asset.rel_path, and the GC orphan sweep skips the dot-prefixed .uploads/
|
||||
tree). Exercises the rollback/cleanup branch that IS the leak remediation, incl. the poster case
|
||||
where ensure_poster left a partial file but returned None."""
|
||||
placed file + any (even partial) poster — otherwise nothing names it (the per-user delete keys
|
||||
off the rolled-back asset.rel_path) and it sits on disk until the GC's `.uploads` sweep. Exercises
|
||||
the rollback/cleanup branch, incl. the poster case where ensure_poster left a partial file but
|
||||
returned None."""
|
||||
_probe(monkeypatch, ("h264", "aac", 320, 240, 5)) # video → the poster path runs
|
||||
u = make_user()
|
||||
|
||||
@@ -813,6 +837,28 @@ def test_ingest_cleans_up_placed_files_on_commit_failure(db, make_user, _root, m
|
||||
assert db.query(MediaAsset).count() == 0 # the flushed asset rolled back — no dangling row
|
||||
|
||||
|
||||
def test_ingest_keeps_the_file_when_its_commit_landed_but_raised(db, make_user, _root, monkeypatch):
|
||||
# The commit reached the server and lost its answer: the row names the file now, so removing it
|
||||
# as "rolled back" would leave a ready upload pointing at nothing.
|
||||
_probe(monkeypatch, ("h264", "aac", 320, 240, 5))
|
||||
monkeypatch.setattr(uploadmod.editmod, "ensure_poster", lambda *_a: None)
|
||||
u = make_user()
|
||||
real_commit = db.commit
|
||||
|
||||
def _landed_then_lost():
|
||||
real_commit()
|
||||
raise RuntimeError("connection lost after COMMIT")
|
||||
|
||||
monkeypatch.setattr(db, "commit", _landed_then_lost)
|
||||
src = _root / "clip.mp4"
|
||||
src.write_bytes(b"fake-video-bytes")
|
||||
with pytest.raises(RuntimeError):
|
||||
uploadmod.ingest_upload(db, u.id, filename="clip.mp4", src_path=src, size=16)
|
||||
|
||||
(asset,) = db.query(MediaAsset).all()
|
||||
assert storage.abs_path(str(_root), asset.rel_path).exists()
|
||||
|
||||
|
||||
# --- classifier: the REAL probe_media parsing (stubs ffprobe one level down) ------------------
|
||||
# The ingest/route tests above stub probe_media wholesale; these exercise its OWN logic — image
|
||||
# detection, the cover-art skip, dimension/duration parsing — by faking only run_ffprobe_json, so a
|
||||
@@ -998,19 +1044,132 @@ class TestResolveMediaFacts:
|
||||
)
|
||||
assert v is None and mediakind.derive(v, a) == mediakind.FILE
|
||||
|
||||
def test_info_fills_in_only_when_ffprobe_could_not_read_the_file(self, monkeypatch):
|
||||
# ffprobe failed entirely (None) → fall back to info so we never regress the working path.
|
||||
def test_a_file_the_guarded_probe_cannot_read_is_a_download_only_file(self, monkeypatch):
|
||||
# ffprobe (media demuxers only) failed entirely: the player and the editor cannot open it
|
||||
# either, so info's codecs must not make it an editable 'video'. Dimensions still gap-fill.
|
||||
self._ffprobe(monkeypatch, None)
|
||||
v, a, w, h, _d = probemod.resolve_media_facts(
|
||||
Path("x"), {"vcodec": "vp9", "acodec": "opus", "width": 1920, "height": 1080}
|
||||
Path("x"),
|
||||
{"vcodec": "avc1.64001f", "acodec": "mp4a.40.2", "width": 1920, "height": 1080},
|
||||
)
|
||||
assert (v, a, w, h) == ("vp9", "opus", 1920, 1080)
|
||||
assert (v, a, w, h) == (None, None, 1920, 1080)
|
||||
assert mediakind.derive(v, a) == mediakind.FILE
|
||||
|
||||
def test_info_none_codec_string_is_treated_as_absent(self, monkeypatch):
|
||||
# yt-dlp writes the literal "none" for a missing stream — it must not become a codec name.
|
||||
self._ffprobe(monkeypatch, None) # probe failed → info path, where the "none" scrub applies
|
||||
v, a, w, h, _d = probemod.resolve_media_facts(
|
||||
Path("x"), {"vcodec": "none", "acodec": "aac"}
|
||||
|
||||
# --- the phone-safe conversion (an iPhone's HEVC, a webm, an mkv) ------------------------------
|
||||
|
||||
|
||||
def _queued_upload(db, make_user, root, monkeypatch, name="clip.mov"):
|
||||
"""An upload no phone could play as it is: probed as HEVC, so ingest queues it."""
|
||||
_probe(monkeypatch, ("hevc", "aac", 640, 360, 6))
|
||||
src = root / "src.bin"
|
||||
src.write_bytes(b"hevc bytes")
|
||||
job = uploadmod.ingest_upload(db, make_user().id, filename=name, src_path=src, size=10)
|
||||
return job, db.get(MediaAsset, job.asset_id)
|
||||
|
||||
|
||||
class TestPhoneSafeUpload:
|
||||
def test_an_unsafe_video_is_queued_for_the_worker_not_done(
|
||||
self, db, make_user, _root, monkeypatch
|
||||
):
|
||||
job, asset = _queued_upload(db, make_user, _root, monkeypatch)
|
||||
assert (job.status, job.progress, asset.status) == ("queued", 0, "pending")
|
||||
assert asset.rel_path.endswith(".mov") # the original waits at rel_path for the worker
|
||||
assert storage.abs_path(str(_root), asset.rel_path).exists()
|
||||
|
||||
def test_a_safe_video_still_lands_done(self, db, make_user, _root, monkeypatch):
|
||||
_probe(monkeypatch, ("h264", "aac", 640, 360, 6))
|
||||
src = _root / "src.bin"
|
||||
src.write_bytes(b"h264 bytes")
|
||||
job = uploadmod.ingest_upload(
|
||||
db, make_user().id, filename="clip.mp4", src_path=src, size=10
|
||||
)
|
||||
assert (v, a) == (None, "aac")
|
||||
assert mediakind.derive(v, a) == mediakind.AUDIO
|
||||
assert job.status == "done" and db.get(MediaAsset, job.asset_id).status == "ready"
|
||||
|
||||
def test_deleting_a_queued_upload_removes_its_original(self, db, make_user, _root, monkeypatch):
|
||||
job, asset = _queued_upload(db, make_user, _root, monkeypatch)
|
||||
path, asset_id = storage.abs_path(str(_root), asset.rel_path), asset.id
|
||||
service.delete(db, job)
|
||||
assert not path.exists()
|
||||
assert db.get(MediaAsset, asset_id) is None
|
||||
|
||||
def test_one_cancelled_mid_conversion_is_reaped_once_the_worker_let_go(
|
||||
self, db, make_user, _root, monkeypatch
|
||||
):
|
||||
job, asset = _queued_upload(db, make_user, _root, monkeypatch)
|
||||
path, asset_id = storage.abs_path(str(_root), asset.rel_path), asset.id
|
||||
asset.status = "downloading" # the worker child is reading the original
|
||||
job.status = "running"
|
||||
db.commit()
|
||||
service.cancel(db, job)
|
||||
assert path.exists() # never pulled out from under a running ffmpeg
|
||||
db.get(MediaAsset, asset_id).status = "pending" # the worker stopped the child and let go
|
||||
db.commit()
|
||||
gcmod.run_download_gc(db)
|
||||
db.expire_all()
|
||||
assert not path.exists()
|
||||
assert db.get(MediaAsset, asset_id) is None
|
||||
|
||||
def test_a_failed_conversion_keeps_the_original_for_retry(
|
||||
self, db, make_user, _root, monkeypatch
|
||||
):
|
||||
job, asset = _queued_upload(db, make_user, _root, monkeypatch)
|
||||
path = storage.abs_path(str(_root), asset.rel_path)
|
||||
job.status, asset.status = "running", "downloading"
|
||||
db.commit()
|
||||
|
||||
def _ffmpeg_fails(*_a, **_k):
|
||||
raise StatedFailure("ffmpeg failed (1): unsupported")
|
||||
|
||||
monkeypatch.setattr(pipeline.editmod, "run_ffmpeg", _ffmpeg_fails)
|
||||
pipeline._convert_upload(job.id, asset.id)
|
||||
db.expire_all()
|
||||
assert (db.get(DownloadJob, job.id).status, db.get(MediaAsset, asset.id).status) == (
|
||||
"error",
|
||||
"error",
|
||||
)
|
||||
assert path.exists()
|
||||
service.resume(db, db.get(DownloadJob, job.id))
|
||||
assert db.get(MediaAsset, asset.id).status == "pending" # Retry converts from it again
|
||||
|
||||
@pytest.mark.skipif(shutil.which("ffmpeg") is None, reason="ffmpeg not available")
|
||||
def test_the_worker_converts_it_and_keeps_only_the_safe_file(
|
||||
self, db, make_user, _root, monkeypatch
|
||||
):
|
||||
# Real bytes, real probe, real ffmpeg: a 10-bit H.264 + Opus Matroska, the kind of file
|
||||
# that reached the share page as-is before this rule.
|
||||
src = _root / "src.mkv"
|
||||
subprocess.run(
|
||||
[
|
||||
"ffmpeg", "-y", "-loglevel", "error",
|
||||
"-f", "lavfi", "-i", "testsrc2=size=160x120:rate=10:duration=1",
|
||||
"-f", "lavfi", "-i", "sine=duration=1",
|
||||
"-c:v", "libx264", "-pix_fmt", "yuv420p10le", "-c:a", "libopus", str(src),
|
||||
],
|
||||
check=True,
|
||||
) # fmt: skip
|
||||
job = uploadmod.ingest_upload(
|
||||
db, make_user().id, filename="holiday.mkv", src_path=src, size=src.stat().st_size
|
||||
)
|
||||
asset = db.get(MediaAsset, job.asset_id)
|
||||
original = storage.abs_path(str(_root), asset.rel_path)
|
||||
assert job.status == "queued"
|
||||
job.status, asset.status = "running", "downloading" # what the worker's claim does
|
||||
db.commit()
|
||||
|
||||
pipeline._convert_upload(job.id, asset.id)
|
||||
|
||||
db.expire_all()
|
||||
asset = db.get(MediaAsset, job.asset_id)
|
||||
assert db.get(DownloadJob, job.id).status == "done"
|
||||
assert (asset.status, asset.container, asset.vcodec, asset.acodec) == (
|
||||
"ready",
|
||||
"mp4",
|
||||
"h264",
|
||||
"aac",
|
||||
)
|
||||
final = storage.abs_path(str(_root), asset.rel_path)
|
||||
assert final.suffix == ".mp4" and final.exists()
|
||||
assert asset.size_bytes == final.stat().st_size
|
||||
assert not original.exists() # one file per upload
|
||||
assert not playable.plan(probemod.probe_json(final), final.suffix).needs_change
|
||||
|
||||
@@ -0,0 +1,592 @@
|
||||
"""An upload after ingest: what it costs its owner from the moment it lands, how the worker's
|
||||
conversion names, checks and cleans up the file it makes, and what the owner and a share link see.
|
||||
|
||||
DB-backed. The conversion's ffmpeg step (`pipeline._ensure_phone_safe`) is stubbed to write a file of
|
||||
a chosen size — these tests are about what happens around the encode; `test_upload.py` runs the real
|
||||
one, and `test_playable.py` judges its bytes.
|
||||
"""
|
||||
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app import sysconfig
|
||||
from app.config import settings
|
||||
from app.downloads import gc as gcmod
|
||||
from app.downloads import pipeline, quota, storage
|
||||
from app.downloads import probe as probemod
|
||||
from app.downloads import upload as uploadmod
|
||||
from app.downloads.stated import StatedFailure
|
||||
from app.models import DownloadJob, DownloadQuota, MediaAsset, Notification
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.exc import OperationalError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _root(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(settings, "download_root", str(tmp_path))
|
||||
return tmp_path
|
||||
|
||||
|
||||
def _probe(monkeypatch, vcodec="hevc"):
|
||||
"""ffprobe's view of every file here: `vcodec` + AAC, 8-bit 4:2:0 — HEVC is queued for the
|
||||
worker, H.264 lands as it came."""
|
||||
data = {
|
||||
"streams": [
|
||||
{"codec_type": "video", "codec_name": vcodec, "pix_fmt": "yuv420p",
|
||||
"width": 640, "height": 360},
|
||||
{"codec_type": "audio", "codec_name": "aac"},
|
||||
],
|
||||
"format": {"format_name": "mov,mp4", "duration": "6"},
|
||||
} # fmt: skip
|
||||
monkeypatch.setattr(probemod, "probe_json", lambda _p: data)
|
||||
|
||||
|
||||
def _ingest(db, root, user_id, name="clip.mov", size=10):
|
||||
src = root / "incoming.bin"
|
||||
src.write_bytes(b"o" * size)
|
||||
job = uploadmod.ingest_upload(db, user_id, filename=name, src_path=src, size=size)
|
||||
return job, db.get(MediaAsset, job.asset_id)
|
||||
|
||||
|
||||
def _claim(db, job, asset):
|
||||
"""What the worker's claim does before it converts."""
|
||||
job.status, asset.status = "running", "downloading"
|
||||
db.commit()
|
||||
|
||||
|
||||
def _converts_to(monkeypatch, size, phases=None):
|
||||
"""The encode writes a `size`-byte .mp4 into its staging dir (and notes the phase it reports)."""
|
||||
|
||||
def _encode(job_id, produced, out_dir, phase, total_s=None, **_k):
|
||||
if phases is not None:
|
||||
phases.append(phase)
|
||||
out = Path(out_dir) / "converted.mp4"
|
||||
out.write_bytes(b"c" * size)
|
||||
return out
|
||||
|
||||
monkeypatch.setattr(pipeline, "_ensure_phone_safe", _encode)
|
||||
|
||||
|
||||
def _cap(db, user_id, max_bytes):
|
||||
db.add(
|
||||
DownloadQuota(
|
||||
user_id=user_id, max_bytes=max_bytes, max_concurrent=2, max_jobs=50, unlimited=False
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
def _uploads_dir(root, user_id) -> Path:
|
||||
return root / ".uploads" / str(user_id)
|
||||
|
||||
|
||||
# --- what it costs ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_a_queued_upload_counts_against_its_owner_from_the_moment_it_lands(
|
||||
db, make_user, _root, monkeypatch
|
||||
):
|
||||
_probe(monkeypatch)
|
||||
u = make_user()
|
||||
job, asset = _ingest(db, _root, u.id, size=10)
|
||||
assert (job.status, asset.status) == ("queued", "pending")
|
||||
assert quota.footprint(db, u.id) == 10 # its original is on disk already
|
||||
|
||||
|
||||
def test_a_failed_conversion_still_counts_its_kept_original(db, make_user, _root, monkeypatch):
|
||||
_probe(monkeypatch)
|
||||
u = make_user()
|
||||
job, asset = _ingest(db, _root, u.id, size=10)
|
||||
job.status, asset.status = "error", "error" # kept for Retry
|
||||
db.commit()
|
||||
assert storage.abs_path(str(_root), asset.rel_path).exists()
|
||||
assert quota.footprint(db, u.id) == 10
|
||||
|
||||
|
||||
def test_a_conversion_that_outgrows_the_cap_is_refused_and_keeps_the_original(
|
||||
db, make_user, _root, monkeypatch
|
||||
):
|
||||
_probe(monkeypatch)
|
||||
u = make_user()
|
||||
_cap(db, u.id, max_bytes=100)
|
||||
job, asset = _ingest(db, _root, u.id, size=10)
|
||||
original = storage.abs_path(str(_root), asset.rel_path)
|
||||
_claim(db, job, asset)
|
||||
_converts_to(monkeypatch, size=500)
|
||||
|
||||
pipeline._convert_upload(job.id, asset.id)
|
||||
|
||||
db.expire_all()
|
||||
job = db.get(DownloadJob, job.id)
|
||||
assert (job.status, job.error_code) == ("error", "quota_exceeded")
|
||||
assert original.exists()
|
||||
assert [p.name for p in _uploads_dir(_root, u.id).iterdir()] == [original.name]
|
||||
|
||||
|
||||
def test_a_conversion_that_shrinks_passes_an_owner_at_the_cap(db, make_user, _root, monkeypatch):
|
||||
_probe(monkeypatch)
|
||||
u = make_user()
|
||||
_cap(db, u.id, max_bytes=10)
|
||||
job, asset = _ingest(db, _root, u.id, size=10)
|
||||
_claim(db, job, asset)
|
||||
_converts_to(monkeypatch, size=5)
|
||||
|
||||
pipeline._convert_upload(job.id, asset.id)
|
||||
|
||||
db.expire_all()
|
||||
assert db.get(DownloadJob, job.id).status == "done"
|
||||
assert db.get(MediaAsset, asset.id).size_bytes == 5
|
||||
|
||||
|
||||
# --- the converted file's name and its leftovers -----------------------------------------------
|
||||
|
||||
|
||||
def test_two_dotted_bare_names_keep_a_file_each(db, make_user, _root, monkeypatch):
|
||||
# 'Holiday.2024.ä': its suffix is no extension (nothing shell-safe is left of it), so the stem's
|
||||
# own dot once read as one — and the converted name lost `_a{id}`, the one thing keeping two apart.
|
||||
_probe(monkeypatch)
|
||||
u = make_user()
|
||||
_converts_to(monkeypatch, size=5)
|
||||
finals = []
|
||||
for _ in range(2):
|
||||
job, asset = _ingest(db, _root, u.id, name="Holiday.2024.ä")
|
||||
_claim(db, job, asset)
|
||||
pipeline._convert_upload(job.id, asset.id)
|
||||
db.expire_all()
|
||||
asset = db.get(MediaAsset, asset.id)
|
||||
assert asset.status == "ready"
|
||||
assert asset.rel_path.endswith(f"_a{asset.id}.mp4"), asset.rel_path
|
||||
finals.append(asset.rel_path)
|
||||
assert finals[0] != finals[1]
|
||||
assert all(storage.abs_path(str(_root), r).exists() for r in finals)
|
||||
|
||||
|
||||
def test_a_bare_names_stem_holds_no_dot_so_a_swapped_suffix_keeps_its_id():
|
||||
# Each of the two name rules is held on its own: a new name's stem has no dot, so a sibling
|
||||
# derived by `with_suffix` (the poster, the converted file) keeps `_a{id}`.
|
||||
rel = storage.upload_rel_path(1, 5, "Holiday.2024.ä")
|
||||
assert rel == ".uploads/1/Holiday_2024_a5"
|
||||
assert Path(rel).with_suffix(".jpg").name == "Holiday_2024_a5.jpg"
|
||||
|
||||
|
||||
def test_an_upload_placed_with_a_dotted_bare_name_keeps_its_id_when_converted(
|
||||
db, make_user, _root, monkeypatch
|
||||
):
|
||||
# A row from before the stems lost their dots: its stem's tail is no extension.
|
||||
_probe(monkeypatch)
|
||||
u = make_user()
|
||||
_converts_to(monkeypatch, size=5)
|
||||
job, asset = _ingest(db, _root, u.id, name="Holiday.2024.ä")
|
||||
legacy = f".uploads/{u.id}/Holiday.2024_a{asset.id}"
|
||||
storage.abs_path(str(_root), asset.rel_path).rename(storage.abs_path(str(_root), legacy))
|
||||
asset.rel_path, asset.container = legacy, None
|
||||
_claim(db, job, asset)
|
||||
|
||||
pipeline._convert_upload(job.id, asset.id)
|
||||
|
||||
db.expire_all()
|
||||
assert db.get(MediaAsset, asset.id).rel_path == f"{legacy}.mp4"
|
||||
|
||||
|
||||
def test_a_conversion_failing_after_it_placed_its_file_removes_it(
|
||||
db, make_user, _root, monkeypatch
|
||||
):
|
||||
_probe(monkeypatch)
|
||||
u = make_user()
|
||||
job, asset = _ingest(db, _root, u.id)
|
||||
original = storage.abs_path(str(_root), asset.rel_path)
|
||||
_claim(db, job, asset)
|
||||
_converts_to(monkeypatch, size=5)
|
||||
|
||||
def _probe_fails(_path):
|
||||
raise RuntimeError("probe crashed")
|
||||
|
||||
monkeypatch.setattr(pipeline.probe, "probe_media", _probe_fails)
|
||||
pipeline._convert_upload(job.id, asset.id)
|
||||
|
||||
db.expire_all()
|
||||
assert db.get(DownloadJob, job.id).status == "error"
|
||||
assert original.exists() # Retry starts from it
|
||||
# The row still names the original: a converted file left beside it is one nothing would find.
|
||||
assert [p.name for p in _uploads_dir(_root, u.id).iterdir()] == [original.name]
|
||||
|
||||
|
||||
def _finishing_commit_raises(monkeypatch, *, lands: bool) -> None:
|
||||
"""The commit that makes the converted upload ready raises — after it reached the server
|
||||
(`lands`: the connection dropped before its answer) or before (it rolled back)."""
|
||||
real_finalize, real_commit = pipeline.service.finalize_if_running, Session.commit
|
||||
state = {"session": None, "fired": False}
|
||||
|
||||
def _finalize(db, *a, **k):
|
||||
if not state["fired"] and state["session"] is None:
|
||||
state["session"] = db
|
||||
return real_finalize(db, *a, **k)
|
||||
|
||||
def _commit(self):
|
||||
if self is state["session"] and not state["fired"]:
|
||||
state["fired"] = True
|
||||
if lands:
|
||||
real_commit(self)
|
||||
raise OperationalError("COMMIT", {}, Exception("connection lost"))
|
||||
return real_commit(self)
|
||||
|
||||
monkeypatch.setattr(pipeline.service, "finalize_if_running", _finalize)
|
||||
monkeypatch.setattr(Session, "commit", _commit)
|
||||
|
||||
|
||||
def test_a_finishing_commit_that_landed_but_raised_keeps_the_converted_file(
|
||||
db, make_user, _root, monkeypatch
|
||||
):
|
||||
# The row names the converted file now: removing it as "never committed" would leave the asset
|
||||
# pointing at nothing and its original named by no row, for the sweep to take — both copies gone.
|
||||
_probe(monkeypatch)
|
||||
u = make_user()
|
||||
job, asset = _ingest(db, _root, u.id)
|
||||
_claim(db, job, asset)
|
||||
_converts_to(monkeypatch, size=5)
|
||||
_finishing_commit_raises(monkeypatch, lands=True)
|
||||
|
||||
pipeline._convert_upload(job.id, asset.id)
|
||||
|
||||
db.expire_all()
|
||||
asset = db.get(MediaAsset, asset.id)
|
||||
assert (db.get(DownloadJob, job.id).status, asset.status) == ("done", "ready")
|
||||
assert storage.abs_path(str(_root), asset.rel_path).exists()
|
||||
|
||||
|
||||
def test_a_finishing_commit_that_rolled_back_fails_and_keeps_the_original(
|
||||
db, make_user, _root, monkeypatch
|
||||
):
|
||||
_probe(monkeypatch)
|
||||
u = make_user()
|
||||
job, asset = _ingest(db, _root, u.id)
|
||||
original = storage.abs_path(str(_root), asset.rel_path)
|
||||
_claim(db, job, asset)
|
||||
_converts_to(monkeypatch, size=5)
|
||||
_finishing_commit_raises(monkeypatch, lands=False)
|
||||
|
||||
pipeline._convert_upload(job.id, asset.id)
|
||||
|
||||
db.expire_all()
|
||||
assert db.get(DownloadJob, job.id).status == "error"
|
||||
assert [p.name for p in _uploads_dir(_root, u.id).iterdir()] == [original.name]
|
||||
|
||||
|
||||
def test_an_original_that_cannot_be_removed_leaves_the_upload_ready(
|
||||
db, make_user, _root, monkeypatch
|
||||
):
|
||||
# The converted file is committed and the job done: the original's unlink failing must not flip
|
||||
# a working asset to an error no Retry is offered for. The sweep takes the original later.
|
||||
_probe(monkeypatch)
|
||||
u = make_user()
|
||||
job, asset = _ingest(db, _root, u.id)
|
||||
original = storage.abs_path(str(_root), asset.rel_path)
|
||||
_claim(db, job, asset)
|
||||
_converts_to(monkeypatch, size=5)
|
||||
real_unlink = Path.unlink
|
||||
|
||||
def _unlink(self, missing_ok=False):
|
||||
if self == original:
|
||||
raise PermissionError("read-only")
|
||||
return real_unlink(self, missing_ok=missing_ok)
|
||||
|
||||
monkeypatch.setattr(Path, "unlink", _unlink)
|
||||
pipeline._convert_upload(job.id, asset.id)
|
||||
|
||||
db.expire_all()
|
||||
asset = db.get(MediaAsset, asset.id)
|
||||
assert (db.get(DownloadJob, job.id).status, asset.status) == ("done", "ready")
|
||||
assert original.exists()
|
||||
|
||||
|
||||
# --- what the owner is told ------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_a_failed_encode_is_a_failed_conversion_not_a_download_step(
|
||||
db, make_user, _root, monkeypatch
|
||||
):
|
||||
_probe(monkeypatch)
|
||||
job, asset = _ingest(db, _root, make_user().id)
|
||||
_claim(db, job, asset)
|
||||
|
||||
def _ffmpeg_fails(*_a, **_k):
|
||||
raise StatedFailure("ffmpeg failed (1): unsupported")
|
||||
|
||||
monkeypatch.setattr(pipeline.editmod, "run_ffmpeg", _ffmpeg_fails)
|
||||
pipeline._convert_upload(job.id, asset.id)
|
||||
db.expire_all()
|
||||
assert db.get(DownloadJob, job.id).error_code == "convert_failed"
|
||||
|
||||
|
||||
def test_a_missing_original_says_so(db, make_user, _root, monkeypatch):
|
||||
_probe(monkeypatch)
|
||||
job, asset = _ingest(db, _root, make_user().id)
|
||||
_claim(db, job, asset)
|
||||
storage.abs_path(str(_root), asset.rel_path).unlink()
|
||||
|
||||
pipeline._convert_upload(job.id, asset.id)
|
||||
db.expire_all()
|
||||
assert db.get(DownloadJob, job.id).error_code == "upload_missing"
|
||||
|
||||
|
||||
def test_the_conversion_reports_its_own_phase(db, make_user, _root, monkeypatch):
|
||||
_probe(monkeypatch)
|
||||
job, asset = _ingest(db, _root, make_user().id)
|
||||
_claim(db, job, asset)
|
||||
phases = []
|
||||
_converts_to(monkeypatch, size=5, phases=phases)
|
||||
pipeline._convert_upload(job.id, asset.id)
|
||||
assert phases == ["converting"] # not yt-dlp's "processing": the queue row shows ffmpeg's %
|
||||
|
||||
|
||||
def test_the_owner_is_told_their_upload_is_ready(db, make_user, _root, monkeypatch):
|
||||
_probe(monkeypatch)
|
||||
u = make_user()
|
||||
job, asset = _ingest(db, _root, u.id)
|
||||
_claim(db, job, asset)
|
||||
_converts_to(monkeypatch, size=5)
|
||||
pipeline._convert_upload(job.id, asset.id)
|
||||
notes = db.execute(select(Notification).where(Notification.user_id == u.id)).scalars().all()
|
||||
assert [n.data.get("kind") for n in notes] == ["upload"]
|
||||
|
||||
|
||||
def test_an_extension_is_judged_as_it_is_stored(db, make_user, _root, monkeypatch):
|
||||
# 'clip.mp4~' is stored as .mp4; judged by the raw 'mp4~' it queued a conversion of a file that
|
||||
# was phone-safe already.
|
||||
_probe(monkeypatch, vcodec="h264")
|
||||
job, asset = _ingest(db, _root, make_user().id, name="clip.mp4~")
|
||||
assert (job.status, asset.status, asset.container) == ("done", "ready", "mp4")
|
||||
|
||||
|
||||
# --- the garbage collector ---------------------------------------------------------------------
|
||||
|
||||
|
||||
def _aged(path: Path, hours: float = 5) -> None:
|
||||
old = time.time() - hours * 3600
|
||||
os.utime(path, (old, old))
|
||||
|
||||
|
||||
def test_the_sweep_takes_a_stranded_upload_file_and_keeps_a_waiting_original(
|
||||
db, make_user, _root, monkeypatch
|
||||
):
|
||||
_probe(monkeypatch)
|
||||
u = make_user()
|
||||
_job, asset = _ingest(db, _root, u.id)
|
||||
original = storage.abs_path(str(_root), asset.rel_path)
|
||||
stranded = original.with_name("left_by_a_stopped_conversion_a999.mp4")
|
||||
stranded.write_bytes(b"x")
|
||||
_aged(original)
|
||||
_aged(stranded)
|
||||
|
||||
gcmod._sweep_orphan_files(db, str(_root))
|
||||
|
||||
assert original.exists() # queued for conversion: its row names it, ready or not
|
||||
assert not stranded.exists() # no row names it
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("job_status", "asset_status"),
|
||||
[
|
||||
("queued", "pending"), # waiting for the worker
|
||||
("running", "downloading"), # being converted, for longer than the sweep's age floor
|
||||
("error", "error"), # failed, kept for Retry
|
||||
("done", "ready"), # converted
|
||||
],
|
||||
)
|
||||
def test_the_sweep_keeps_an_aged_upload_file_its_row_names_in_any_state(
|
||||
job_status, asset_status, db, make_user, _root, monkeypatch
|
||||
):
|
||||
_probe(monkeypatch)
|
||||
job, asset = _ingest(db, _root, make_user().id)
|
||||
path = storage.abs_path(str(_root), asset.rel_path)
|
||||
job.status, asset.status = job_status, asset_status
|
||||
db.commit()
|
||||
_aged(path)
|
||||
|
||||
gcmod._sweep_orphan_files(db, str(_root))
|
||||
|
||||
assert path.exists()
|
||||
|
||||
|
||||
def _row(db, asset_id: int) -> MediaAsset | None:
|
||||
"""The asset as the database has it now (a `get` would refresh the session's own copy, and
|
||||
raise for one deleted under it)."""
|
||||
db.expire_all()
|
||||
return db.execute(select(MediaAsset).where(MediaAsset.id == asset_id)).scalar_one_or_none()
|
||||
|
||||
|
||||
def _route_moved(db, job, asset, job_status: str, ref_count: int) -> None:
|
||||
"""What the cancel/pause/delete route leaves behind, written as SQL: the session's copy of
|
||||
`ref_count` is stale (the routes count it in SQL), so an ORM assignment of the same value would
|
||||
write nothing."""
|
||||
db.execute(update(DownloadJob).where(DownloadJob.id == job.id).values(status=job_status))
|
||||
db.execute(update(MediaAsset).where(MediaAsset.id == asset.id).values(ref_count=ref_count))
|
||||
db.commit()
|
||||
|
||||
|
||||
def _stopped_by(monkeypatch, db, job, asset, job_status: str, ref_count: int) -> None:
|
||||
"""The conversion is stopped mid-encode after the route moved the job to `job_status` and its
|
||||
hold on the asset to `ref_count` (cancel and delete release it; pause keeps it)."""
|
||||
|
||||
def _encode(*_a, **_k):
|
||||
_route_moved(db, job, asset, job_status, ref_count)
|
||||
raise pipeline.editmod.EditAborted()
|
||||
|
||||
monkeypatch.setattr(pipeline, "_ensure_phone_safe", _encode)
|
||||
|
||||
|
||||
def test_an_upload_cancelled_mid_conversion_is_freed_once_the_conversion_stops(
|
||||
db, make_user, _root, monkeypatch
|
||||
):
|
||||
# `release_asset` leaves it while the conversion reads the file; once it stopped, nothing holds it
|
||||
# — kept, it would sit on disk outside every footprint until the GC's upload pass.
|
||||
_probe(monkeypatch)
|
||||
u = make_user()
|
||||
job, asset = _ingest(db, _root, u.id, size=10)
|
||||
original = storage.abs_path(str(_root), asset.rel_path)
|
||||
_claim(db, job, asset)
|
||||
_stopped_by(monkeypatch, db, job, asset, "canceled", 0)
|
||||
|
||||
pipeline._convert_upload(job.id, asset.id)
|
||||
|
||||
assert _row(db, asset.id) is None
|
||||
assert not original.exists()
|
||||
assert quota.footprint(db, u.id) == 0
|
||||
|
||||
|
||||
def test_an_upload_paused_mid_conversion_keeps_its_original_for_the_resume(
|
||||
db, make_user, _root, monkeypatch
|
||||
):
|
||||
_probe(monkeypatch)
|
||||
u = make_user()
|
||||
job, asset = _ingest(db, _root, u.id, size=10)
|
||||
original = storage.abs_path(str(_root), asset.rel_path)
|
||||
_claim(db, job, asset)
|
||||
_stopped_by(monkeypatch, db, job, asset, "paused", 1)
|
||||
|
||||
pipeline._convert_upload(job.id, asset.id)
|
||||
|
||||
db.expire_all()
|
||||
assert db.get(MediaAsset, asset.id).status == "pending"
|
||||
assert original.exists()
|
||||
|
||||
|
||||
def test_an_upload_whose_conversion_finished_after_its_job_was_cancelled_is_freed(
|
||||
db, make_user, _root, monkeypatch
|
||||
):
|
||||
_probe(monkeypatch)
|
||||
u = make_user()
|
||||
job, asset = _ingest(db, _root, u.id, size=10)
|
||||
_claim(db, job, asset)
|
||||
_converts_to(monkeypatch, size=5)
|
||||
real_encode = pipeline._ensure_phone_safe
|
||||
|
||||
def _encode_then_cancelled(*a, **k):
|
||||
out = real_encode(*a, **k)
|
||||
_route_moved(db, job, asset, "canceled", 0)
|
||||
return out
|
||||
|
||||
monkeypatch.setattr(pipeline, "_ensure_phone_safe", _encode_then_cancelled)
|
||||
pipeline._convert_upload(job.id, asset.id)
|
||||
|
||||
assert _row(db, asset.id) is None
|
||||
left = _uploads_dir(_root, u.id)
|
||||
assert not left.exists() or list(left.iterdir()) == []
|
||||
|
||||
|
||||
def test_the_cache_cap_never_evicts_an_upload(db, make_user, _root, monkeypatch):
|
||||
_probe(monkeypatch, vcodec="h264")
|
||||
u = make_user()
|
||||
_job, upload = _ingest(db, _root, u.id, name="mine.mp4", size=10)
|
||||
db.add(
|
||||
MediaAsset(
|
||||
source_kind="youtube", source_ref="vid1", format_sig="best", status="ready",
|
||||
rel_path="Chan/clip.mp4", size_bytes=10, title="downloaded",
|
||||
last_access_at=datetime.now(timezone.utc),
|
||||
)
|
||||
) # fmt: skip
|
||||
# The upload is the least recently used: an LRU pass that may take it takes it first.
|
||||
upload.last_access_at = datetime.now(timezone.utc) - timedelta(days=30)
|
||||
db.commit()
|
||||
(_root / "Chan").mkdir()
|
||||
(_root / "Chan" / "clip.mp4").write_bytes(b"d" * 10)
|
||||
sysconfig.set_value(db, "download_total_max_bytes", 15) # 20 on disk
|
||||
db.commit()
|
||||
|
||||
gcmod.run_download_gc(db)
|
||||
|
||||
db.expire_all()
|
||||
assert db.get(MediaAsset, upload.id).status == "ready"
|
||||
assert storage.abs_path(str(_root), upload.rel_path).exists()
|
||||
|
||||
|
||||
# --- a share link ----------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("job_status", "asset_status", "reason"),
|
||||
[
|
||||
("queued", "pending", "video_preparing"), # a re-download waiting for the worker
|
||||
("running", "downloading", "video_preparing"), # being made now
|
||||
("canceled", "pending", "video_gone"), # a rebuild cancelled: nothing will finish it
|
||||
("paused", "pending", "video_gone"), # held until its owner resumes it, maybe never
|
||||
],
|
||||
)
|
||||
def test_a_link_says_preparing_only_while_its_video_is_being_made(
|
||||
job_status, asset_status, reason, db, as_user, make_user, _root, monkeypatch, upload_headers
|
||||
):
|
||||
# A link is made on a finished job; its asset can be (re)made under it later — a shared cache row
|
||||
# re-fetched, a conversion retried. While that runs the page says "try again shortly"; once
|
||||
# nothing is making it, it says the video is gone rather than promising it forever.
|
||||
_probe(monkeypatch, vcodec="h264")
|
||||
c = as_user(make_user())
|
||||
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()
|
||||
assert c.get(f"/api/public/watch/{link['token']}").status_code == 200
|
||||
db.get(MediaAsset, job["asset"]["id"]).status = asset_status
|
||||
db.get(DownloadJob, job["id"]).status = job_status
|
||||
db.commit()
|
||||
r = c.get(f"/api/public/watch/{link['token']}")
|
||||
assert r.status_code == 404
|
||||
assert r.json()["detail"]["reason"] == reason
|
||||
|
||||
|
||||
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)
|
||||
u = make_user()
|
||||
_ingest(db, _root, u.id, size=10)
|
||||
r = as_user(make_user(role="admin")).get("/api/admin/downloads/storage").json()
|
||||
assert (r["files_on_disk"], r["total_bytes"]) == (1, 10)
|
||||
assert {p["user_id"]: p["footprint_bytes"] for p in r["per_user"]} == {u.id: 10}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("job_status", "asset_status", "ref_count"),
|
||||
[
|
||||
("done", "ready", 1), # a finished upload its owner keeps
|
||||
("queued", "pending", 1), # one waiting for the worker
|
||||
("canceled", "downloading", 0), # cancelled mid-conversion: the worker still reads it
|
||||
],
|
||||
)
|
||||
def test_the_upload_reap_leaves_one_still_held_or_still_read(
|
||||
job_status, asset_status, ref_count, db, make_user, _root, monkeypatch
|
||||
):
|
||||
_probe(monkeypatch)
|
||||
job, asset = _ingest(db, _root, make_user().id)
|
||||
path = storage.abs_path(str(_root), asset.rel_path)
|
||||
asset.status = asset_status
|
||||
db.commit()
|
||||
_route_moved(db, job, asset, job_status, ref_count)
|
||||
|
||||
gcmod.run_download_gc(db)
|
||||
|
||||
assert _row(db, asset.id) is not None
|
||||
assert path.exists()
|
||||
@@ -0,0 +1,74 @@
|
||||
"""The worker child's dispatch (`runner._run`) and the edit path's last step before it stores a clip.
|
||||
|
||||
`runner._run` picks the pipeline entry by the job's kind; the tests elsewhere call those entries
|
||||
directly, so a kind routed to the wrong one (an upload sent to yt-dlp) would pass all of them.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.config import settings
|
||||
from app.downloads import pipeline, runner, service, storage
|
||||
from app.downloads import probe as probemod
|
||||
from app.downloads import upload as uploadmod
|
||||
from app.models import DownloadJob, MediaAsset
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("kind", "entry"),
|
||||
[("upload", "_convert_upload"), ("edit", "_process_edit"), ("download", "_download")],
|
||||
)
|
||||
def test_each_job_kind_reaches_its_own_pipeline_entry(kind, entry, monkeypatch):
|
||||
called = []
|
||||
for name in ("_convert_upload", "_process_edit", "_download"):
|
||||
monkeypatch.setattr(pipeline, name, lambda *a, _n=name: called.append(_n))
|
||||
runner._run(5, 7, kind, "url", "https://example.org/v", {}, 1)
|
||||
assert called == [entry]
|
||||
|
||||
|
||||
def test_a_clip_the_phone_safe_step_changed_is_stored_under_its_new_extension(
|
||||
db, make_user, tmp_path, monkeypatch
|
||||
):
|
||||
# A fast trim stream-copies its source's container; when the phone-safe step remuxes it (an
|
||||
# audio clip out of an .mp4 becomes an .m4a), the stored path and container follow the new file.
|
||||
monkeypatch.setattr(settings, "download_root", str(tmp_path))
|
||||
data = {
|
||||
"streams": [{"codec_type": "audio", "codec_name": "aac"}],
|
||||
"format": {"format_name": "mov,mp4", "duration": "10"},
|
||||
}
|
||||
monkeypatch.setattr(probemod, "probe_json", lambda _p: data)
|
||||
src = tmp_path / "in.bin"
|
||||
src.write_bytes(b"a" * 10)
|
||||
u = make_user()
|
||||
source = uploadmod.ingest_upload(db, u.id, filename="song.m4a", src_path=src, size=10)
|
||||
# Stored as an .mp4, the way an audio file was kept before every ready file met the rule.
|
||||
kept = db.get(MediaAsset, source.asset_id)
|
||||
old = storage.abs_path(str(tmp_path), kept.rel_path)
|
||||
kept.rel_path, kept.container = kept.rel_path[: -len(".m4a")] + ".mp4", "mp4"
|
||||
old.rename(storage.abs_path(str(tmp_path), kept.rel_path))
|
||||
db.commit()
|
||||
job = service.enqueue_edit(
|
||||
db, u.id, source, {"trim": {"start_s": 1, "end_s": 3}, "accurate": False}
|
||||
)
|
||||
asset = db.get(MediaAsset, job.asset_id)
|
||||
job.status, asset.status = "running", "downloading"
|
||||
db.commit()
|
||||
|
||||
def _trim(cmd, *_a, **_k):
|
||||
Path(cmd[-1]).write_bytes(b"t" * 4) # the stream-copied clip, at the path ffmpeg was given
|
||||
|
||||
def _remux(job_id, produced, out_dir, phase, total_s=None, **_k):
|
||||
out = Path(out_dir) / "remuxed.m4a"
|
||||
out.write_bytes(b"r" * 3)
|
||||
return out
|
||||
|
||||
monkeypatch.setattr(pipeline.editmod, "run_ffmpeg", _trim)
|
||||
monkeypatch.setattr(pipeline, "_ensure_phone_safe", _remux)
|
||||
pipeline._process_edit(job.id, asset.id)
|
||||
|
||||
db.expire_all()
|
||||
asset = db.get(MediaAsset, asset.id)
|
||||
assert (db.get(DownloadJob, job.id).status, asset.container) == ("done", "m4a")
|
||||
assert asset.rel_path.endswith(".m4a")
|
||||
assert storage.abs_path(str(tmp_path), asset.rel_path).read_bytes() == b"rrr"
|
||||
+11
-2
@@ -528,7 +528,9 @@ of a full-width panel and nothing on the right.
|
||||
**Restart**, the video card's own pair and words (`MediaResumeButtons`, one component for every card and
|
||||
Home's hero). A poster stacks the pair; a landscape picture sets it side by side, icon-only on a small one.
|
||||
On touch a tap continues and the card's "…" has "Play from the beginning" under its Play row. The restart
|
||||
applies to the item the player opened on (`fromStart`); every item it moves on to resumes.
|
||||
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]
|
||||
@@ -541,7 +543,9 @@ of a full-width panel and nothing on the right.
|
||||
fields (the issue composer's Edit / Preview switch); one that loads after opening moves the focus
|
||||
there when it arrives, as long as the focus still rests on the ✕;
|
||||
`action` — a confirm, where the least destructive button takes `autoFocus`; `close` — anything to read,
|
||||
browse or manage. [WAI-ARIA APG]
|
||||
browse or manage; `player` — a dialog whose body is a player (the library's): the focus rests on the
|
||||
panel itself, so Space and the arrows reach the player's keys and no button takes them — Space on a
|
||||
focused ✕ closed the film. [WAI-ARIA APG]
|
||||
- **The header and the footer are fixed; only the body scrolls,** with the B9 fade at each edge, so the
|
||||
primary action stays on screen when the on-screen keyboard takes half of it (the viewport's
|
||||
`interactive-widget=resizes-content` makes the keyboard shrink the layout, where Chrome would otherwise
|
||||
@@ -706,3 +710,8 @@ of a full-width panel and nothing on the right.
|
||||
| 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 | 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" |
|
||||
|
||||
@@ -112,6 +112,7 @@ project checks needs a row.
|
||||
| coverage | not in the gate; no threshold | Owner decision E32 D4: never enforced; "every regression test seen red" is the bar, and a percentage invites tests written to move it. Out of the lanes since E35: read once, by hand, it found 5 bugs; as a lane it was a number nobody acted on, and the slowest vitest run (the one that flaked). On demand: docs/development.md. | frontend 80% / backend 78% of lines (E32 S1) |
|
||||
| CVE gate | HIGH/CRITICAL with a fix available only | `--ignore-unfixed` + `--severity HIGH,CRITICAL`: a finding with no fix cannot be acted on by an upgrade; it shows in the scan output. A fixable one fails the publish, and the only way past it is `.trivyignore.yaml`. | 0 open (E32 S1) |
|
||||
| npm audit | `--omit=dev --audit-level=high` | What ships to the browser, at high or above. The dev toolchain's advisories were burned down by hand in E32 S1 (18 → 0). | 0 |
|
||||
| knip | `entry` — one file added | Not a narrowing: knip's Vite plugin reads entries from index.html only, so the shared link's page (`watch.html` → `src/watchMain.tsx`) is named here, or the lane reports that whole entry unused. It adds files to the lane, never takes any out. | 1 entry |
|
||||
| React Compiler | the functions it skips, pinned | `components/compilerCoverage.contract.test.ts` — the list may only shrink. End state: only VirtualGrid (TanStack Virtual, an IncompatibleLibrary) remains. | 93 (E32 S1) → 29 (E32 S3) → 1, VirtualGrid (E32 S4) |
|
||||
|
||||
## Accepted findings
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { test, expect, type Page } from "@playwright/test";
|
||||
import { openApp, openFirstVideo } from "./helpers";
|
||||
|
||||
// Locks the in-app player's open/close contract. The player is a portaled overlay whose
|
||||
@@ -26,3 +26,53 @@ test.describe("player modal", () => {
|
||||
await expect(page.getByTestId("player-modal")).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
// The video's size, in the windows the owner meets: layout bugs no DOM test can see. Each viewport
|
||||
// is a real one — 1920×911 is a 1080p screen at 100% (the side-panel layout), 1097×520 the same
|
||||
// screen at 175% or a small laptop at 100% (the stacked layout, on a short window), 1400×380 a wide
|
||||
// window made short by docked devtools (the side-panel layout, whose card does not scroll).
|
||||
test.describe("player modal sizing", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.route(/youtube\.com/, (route) => route.abort());
|
||||
});
|
||||
|
||||
const box = async (page: Page, selector: string) =>
|
||||
(await page.locator(selector).first().boundingBox())!;
|
||||
|
||||
for (const [w, h] of [
|
||||
[1920, 911],
|
||||
[1097, 520],
|
||||
[1400, 380],
|
||||
] as const) {
|
||||
test(`the video keeps a real size at ${w}×${h}, and maximised fills the window`, async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: w, height: h });
|
||||
await openApp(page);
|
||||
await openFirstVideo(page);
|
||||
|
||||
// A short window narrows the card so the chrome under the video fits — but the chrome grows
|
||||
// as the card narrows (its text wraps), and that loop once ran the card down to 2 px.
|
||||
const stage = await box(page, ".player-stage");
|
||||
expect(stage.width).toBeGreaterThanOrEqual(Math.min(w * 0.5, 560));
|
||||
expect(stage.height).toBeGreaterThan(stage.width * 0.5);
|
||||
// …and all of it is in sight: its bottom edge is the YouTube control bar, and the side-panel
|
||||
// card clips what overflows it (it does not scroll), the stacked card scrolls to it.
|
||||
const overflow = await page
|
||||
.locator(".player-stage")
|
||||
.first()
|
||||
.evaluate((el) => {
|
||||
const card = el.parentElement!;
|
||||
return el.getBoundingClientRect().bottom - card.getBoundingClientRect().bottom;
|
||||
});
|
||||
expect(overflow).toBeLessThanOrEqual(0.5);
|
||||
|
||||
// Maximise (F): the stage is fixed to the viewport. In the side-panel layout its `self-start`
|
||||
// once aligned the fixed box too, and it shrank to its content's height — 0 px, a thin line.
|
||||
await page.getByTestId("player-modal").press("f");
|
||||
await expect(page.locator(".player-stage--max")).toHaveCount(1);
|
||||
const max = await box(page, ".player-stage--max");
|
||||
expect([Math.round(max.width), Math.round(max.height)]).toEqual([w, h]);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
{
|
||||
"$schema": "https://unpkg.com/knip@6/schema.json",
|
||||
// The shared link's page (watch.html): the Vite plugin reads only index.html for entries.
|
||||
"entry": ["src/watchMain.tsx"],
|
||||
"project": ["**/*.{ts,tsx,js}"],
|
||||
"playwright": {
|
||||
"config": ["playwright.config.ts"],
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -91,7 +91,7 @@ describe("the last sign-in column", () => {
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText(relativeTime(at, "en"))).toBeTruthy();
|
||||
expect(await screen.findByText(relativeTime(at, "en", i18n.t))).toBeTruthy();
|
||||
expect(screen.getAllByText("—")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -316,7 +316,7 @@ function UsersRoles({ me }: { me: Me }) {
|
||||
}
|
||||
>
|
||||
<span className="relative hidden sm:inline-block w-20 shrink-0 text-right text-2xs text-muted tabular-nums">
|
||||
{u.last_login_at ? relativeTime(u.last_login_at, lang) : "—"}
|
||||
{u.last_login_at ? relativeTime(u.last_login_at, lang, t) : "—"}
|
||||
</span>
|
||||
</Tooltip>
|
||||
<MoreButton
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -10,7 +10,9 @@ import type { DownloadJob } from "../lib/api";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
jobs: [] as unknown[],
|
||||
confirm: vi.fn(async (_opts: { title: string; confirmLabel?: string }) => false),
|
||||
confirm: vi.fn(
|
||||
async (_opts: { title: string; message?: string; confirmLabel?: string }) => false,
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
@@ -124,4 +126,58 @@ describe("DownloadCenter confirm dialogs", () => {
|
||||
["downloads.confirm.deleteTitle", "downloads.actions.delete"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("cancelling an upload says the uploaded file goes with it", async () => {
|
||||
// Nothing to fetch again: the cancel deletes the original from the server.
|
||||
mocks.jobs = [{ ...job(3, "queued"), job_kind: "upload" }];
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<DownloadCenter />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
await pick("Clip 3", "downloads.actions.cancel");
|
||||
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(1));
|
||||
const [opts] = mocks.confirm.mock.calls[0]!;
|
||||
expect([opts.title, opts.confirmLabel]).toEqual([
|
||||
"downloads.confirm.cancelUploadTitle",
|
||||
"downloads.confirm.cancelUploadAction",
|
||||
]);
|
||||
});
|
||||
|
||||
it("cancelling a clip speaks of the clip", async () => {
|
||||
mocks.jobs = [{ ...job(4, "running"), job_kind: "edit" }];
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<DownloadCenter />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
await pick("Clip 4", "downloads.actions.cancel");
|
||||
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(1));
|
||||
const [opts] = mocks.confirm.mock.calls[0]!;
|
||||
expect([opts.title, opts.message, opts.confirmLabel]).toEqual([
|
||||
"downloads.confirm.cancelEditTitle",
|
||||
"downloads.confirm.cancelEditBody",
|
||||
"downloads.confirm.cancelEditAction",
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["upload", "downloads.confirm.deleteUploadTitle", "downloads.confirm.deleteUploadBody"],
|
||||
["edit", "downloads.confirm.deleteEditTitle", "downloads.confirm.deleteEditBody"],
|
||||
["download", "downloads.confirm.deleteTitle", "downloads.confirm.deleteBody"],
|
||||
] as const)("deleting a finished %s says what happens to its file", async (kind, title, body) => {
|
||||
// Only a download's file may stay cached for other users; an upload's or a clip's is theirs alone.
|
||||
mocks.jobs = [{ ...job(5, "done"), job_kind: kind }];
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<DownloadCenter />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
fireEvent.click(screen.getByRole("tab", { name: /downloads\.tabs\.done/ }));
|
||||
await screen.findByText("Clip 5");
|
||||
await pick("Clip 5", "downloads.actions.delete");
|
||||
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(1));
|
||||
const [opts] = mocks.confirm.mock.calls[0]!;
|
||||
expect([opts.title, opts.message]).toEqual([title, body]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -235,6 +235,46 @@ describe("DownloadCenter · the library row", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("a started item continues — its bar shows how far — and its menu can start it over", async () => {
|
||||
mocks.jobs = [job(2, "done", { position_seconds: 30 })];
|
||||
mount("done");
|
||||
await screen.findByText("Clip 2");
|
||||
expect(rowButtons("Clip 2")[0]).toBe("card.continueTitle");
|
||||
// The media cards' bar along the picture's bottom edge: 30 s of the 60 s item.
|
||||
expect(rowOf("Clip 2").querySelector<HTMLElement>(".bg-accent")!.style.width).toBe("50%");
|
||||
await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2")));
|
||||
expect(menuRows().slice(0, 2)).toEqual(["card.continue", "card.restartTitle"]);
|
||||
|
||||
// Starting over opens the player at 0:00, not at the kept point.
|
||||
fireEvent.click(
|
||||
screen
|
||||
.getAllByRole("menuitem", { hidden: true })
|
||||
.find((b) => b.textContent === "card.restartTitle")!,
|
||||
);
|
||||
const video = await waitFor(() => {
|
||||
const v = document.querySelector("video");
|
||||
expect(v).not.toBeNull();
|
||||
return v!;
|
||||
});
|
||||
let t = 0;
|
||||
Object.defineProperties(video, {
|
||||
currentTime: { get: () => t, set: (v: number) => void (t = v), configurable: true },
|
||||
duration: { get: () => 60, configurable: true },
|
||||
readyState: { get: () => 1, configurable: true },
|
||||
});
|
||||
video.dispatchEvent(new Event("loadedmetadata"));
|
||||
expect(video.currentTime).toBe(0);
|
||||
});
|
||||
|
||||
it("an item never played offers Play only, with no bar", async () => {
|
||||
mocks.jobs = [job(2, "done", { position_seconds: 0 })];
|
||||
mount("done");
|
||||
await screen.findByText("Clip 2");
|
||||
expect(rowOf("Clip 2").querySelector(".bg-accent")).toBeNull();
|
||||
await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2")));
|
||||
expect(menuRows()).not.toContain("card.restartTitle");
|
||||
});
|
||||
|
||||
it("copies the share link from the row's own Share button", async () => {
|
||||
mocks.jobs = [job(2, "done")];
|
||||
vi.spyOn(api, "downloadLinks").mockResolvedValue([] as never);
|
||||
|
||||
@@ -68,7 +68,9 @@ export default function DownloadCenter() {
|
||||
const [manage, setManage] = useState(false);
|
||||
const [metaJob, setMetaJob] = useState<DownloadJob | null>(null);
|
||||
const [shareJob, setShareJob] = useState<DownloadJob | null>(null);
|
||||
const [playJob, setPlayJob] = useState<DownloadJob | null>(null);
|
||||
// What the player opens, and whether over the kept point ("Play from the beginning").
|
||||
const [playing, setPlaying] = useState<{ job: DownloadJob; fromStart: boolean } | null>(null);
|
||||
const setPlayJob = (job: DownloadJob, fromStart = false) => setPlaying({ job, fromStart });
|
||||
const [editJob, setEditJob] = useState<DownloadJob | null>(null);
|
||||
const [errorJob, setErrorJob] = useState<DownloadJob | null>(null);
|
||||
|
||||
@@ -276,7 +278,9 @@ export default function DownloadCenter() {
|
||||
<IconBtn
|
||||
open
|
||||
onClick={() => setPlayJob(job)}
|
||||
title={t("downloads.actions.play")}
|
||||
title={
|
||||
job.position_seconds ? t("card.continueTitle") : t("downloads.actions.play")
|
||||
}
|
||||
>
|
||||
<PlayCircle className="w-4 h-4" />
|
||||
</IconBtn>
|
||||
@@ -315,7 +319,9 @@ export default function DownloadCenter() {
|
||||
<IconBtn
|
||||
open
|
||||
onClick={() => setPlayJob(job)}
|
||||
title={t("downloads.actions.play")}
|
||||
title={
|
||||
job.position_seconds ? t("card.continueTitle") : t("downloads.actions.play")
|
||||
}
|
||||
>
|
||||
<PlayCircle className="w-4 h-4" />
|
||||
</IconBtn>
|
||||
@@ -357,7 +363,13 @@ export default function DownloadCenter() {
|
||||
{manage && <ProfileEditor onClose={() => setManage(false)} />}
|
||||
{metaJob && <MetaEditModal job={metaJob} onClose={() => setMetaJob(null)} />}
|
||||
{shareJob && <ShareDialog job={shareJob} onClose={() => setShareJob(null)} />}
|
||||
{playJob && <LibraryPlayer job={playJob} onClose={() => setPlayJob(null)} />}
|
||||
{playing && (
|
||||
<LibraryPlayer
|
||||
job={playing.job}
|
||||
fromStart={playing.fromStart}
|
||||
onClose={() => setPlaying(null)}
|
||||
/>
|
||||
)}
|
||||
{editJob && <VideoEditor job={editJob} onClose={() => setEditJob(null)} />}
|
||||
{errorJob && (
|
||||
<ErrorModal
|
||||
|
||||
@@ -1,61 +1,30 @@
|
||||
import { Component, type ErrorInfo, type ReactNode } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import i18n from "../i18n";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { errorAttrs, log } from "../lib/log";
|
||||
import { reloadingForNewBuild } from "../lib/staleBuild";
|
||||
import { Button } from "./ui/Button";
|
||||
import { CrashBoundary, type CrashCopy } from "./ui/CrashBoundary";
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
}
|
||||
interface State {
|
||||
crashed: boolean;
|
||||
}
|
||||
const copy = (): CrashCopy => ({
|
||||
title: i18n.t("errors.boundary.title"),
|
||||
subtitle: i18n.t("errors.boundary.subtitle"),
|
||||
reload: i18n.t("errors.boundary.reload"),
|
||||
});
|
||||
|
||||
// Catches render-time crashes anywhere in the tree, logs a fatal notification (kept
|
||||
// in the center across the reload) and offers a recovery action.
|
||||
export default class ErrorBoundary extends Component<Props, State> {
|
||||
state: State = { crashed: false };
|
||||
// A fatal notification, kept in the center across the reload.
|
||||
const onCrash = () =>
|
||||
notify({
|
||||
level: "fatal",
|
||||
title: i18n.t("errors.boundary.notifTitle"),
|
||||
// Never the raw `error.message`: a JS engine's English ("Cannot read properties of undefined…")
|
||||
// is no message for the reader — the `ui.crash` log carries it for diagnosis.
|
||||
message: i18n.t("errors.boundary.notifMessage"),
|
||||
requiresInteraction: true,
|
||||
});
|
||||
|
||||
static getDerivedStateFromError(): State {
|
||||
return { crashed: true };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||
// The tab is already reloading onto a newer build (lib/staleBuild): this is the chunk it could not
|
||||
// load, not a crash — no fatal notice, which would outlive the reload.
|
||||
if (reloadingForNewBuild()) return;
|
||||
log.fatal("ui.crash", "the app crashed while rendering", {
|
||||
...errorAttrs(error),
|
||||
...(info.componentStack ? { component_stack: info.componentStack } : {}),
|
||||
});
|
||||
notify({
|
||||
level: "fatal",
|
||||
title: i18n.t("errors.boundary.notifTitle"),
|
||||
// Never the raw `error.message`: a JS engine's English ("Cannot read properties of undefined…")
|
||||
// is no message for the reader — the `ui.crash` log above carries it for diagnosis.
|
||||
message: i18n.t("errors.boundary.notifMessage"),
|
||||
requiresInteraction: true,
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.crashed) {
|
||||
if (reloadingForNewBuild()) return null;
|
||||
return (
|
||||
<div className="min-h-dvh grid place-items-center text-muted p-6 text-center">
|
||||
<div>
|
||||
<div className="text-lg font-semibold text-fg mb-1">
|
||||
{i18n.t("errors.boundary.title")}
|
||||
</div>
|
||||
<div className="text-sm mb-3">{i18n.t("errors.boundary.subtitle")}</div>
|
||||
<Button variant="link" onClick={() => location.reload()} className="text-sm">
|
||||
{i18n.t("errors.boundary.reload")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
// The app's crash boundary: `CrashBoundary` in the app's language, plus the notice in the center.
|
||||
export default function ErrorBoundary({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<CrashBoundary copy={copy} onCrash={onCrash}>
|
||||
{children}
|
||||
</CrashBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 }]
|
||||
@@ -546,7 +546,7 @@ function Hero({
|
||||
? // The channel is the subtitle already.
|
||||
[
|
||||
video.view_count != null && `${formatCompact(video.view_count, lang)} ${t("card.views")}`,
|
||||
relativeTime(video.published_at, lang),
|
||||
relativeTime(video.published_at, lang, t),
|
||||
]
|
||||
: [];
|
||||
const factLine = facts.filter(Boolean).join(" · ");
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
// list — so an audio upload with an uncommon/absent container still renders <audio src=/file>. i18n
|
||||
// is key-passthrough here.
|
||||
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||
import { render, cleanup } from "@testing-library/react";
|
||||
import { render as rtlRender, cleanup } from "@testing-library/react";
|
||||
import type { ReactElement } from "react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
@@ -15,7 +17,8 @@ const expose = vi.hoisted(() => vi.fn());
|
||||
vi.mock("../lib/browserBackgroundPlay", () => ({ exposeVideoToBackgroundPlay: expose }));
|
||||
|
||||
import LibraryPlayer from "./LibraryPlayer";
|
||||
import type { DownloadJob } from "../lib/api";
|
||||
import { api, type DownloadJob } from "../lib/api";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
|
||||
const baseJob: DownloadJob = {
|
||||
id: 7,
|
||||
@@ -75,7 +78,27 @@ const withAsset = (over: Partial<NonNullable<DownloadJob["asset"]>>): DownloadJo
|
||||
},
|
||||
});
|
||||
|
||||
afterEach(cleanup);
|
||||
// The player writes its resume point into the library listings' cache, so it sits under a client.
|
||||
const render = (ui: ReactElement, client = new QueryClient()) =>
|
||||
rtlRender(<QueryClientProvider client={client}>{ui}</QueryClientProvider>);
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
/** The rendered <video>, given a length and a clock the test sets (jsdom plays nothing). */
|
||||
function loaded(duration = 600) {
|
||||
const video = document.body.querySelector("video")!;
|
||||
let t = 0;
|
||||
Object.defineProperties(video, {
|
||||
currentTime: { get: () => t, set: (v: number) => void (t = v), configurable: true },
|
||||
duration: { get: () => duration, configurable: true },
|
||||
readyState: { get: () => 1, configurable: true },
|
||||
});
|
||||
video.dispatchEvent(new Event("loadedmetadata"));
|
||||
return video;
|
||||
}
|
||||
|
||||
describe("LibraryPlayer", () => {
|
||||
it("plays a video asset in a <video> pointed at the range-aware /file endpoint", () => {
|
||||
@@ -130,4 +153,81 @@ describe("LibraryPlayer", () => {
|
||||
);
|
||||
expect(document.body.querySelector("audio")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("opens focused on its panel, so Space plays rather than pressing the dialog's ✕", () => {
|
||||
// jsdom lays nothing out, so every element reads as hidden (`offsetParent` null) and a dialog
|
||||
// falls back to its panel whatever it asked for; as in a browser, the ✕ is on screen here.
|
||||
vi.spyOn(HTMLElement.prototype, "offsetParent", "get").mockImplementation(function (
|
||||
this: HTMLElement,
|
||||
) {
|
||||
return this.parentElement;
|
||||
});
|
||||
render(<LibraryPlayer job={withAsset({ width: 1280, height: 720 })} onClose={() => {}} />);
|
||||
const panel = document.activeElement as HTMLElement;
|
||||
expect(panel.tagName).toBe("DIV");
|
||||
expect(panel.contains(document.body.querySelector("video"))).toBe(true);
|
||||
});
|
||||
|
||||
it("opens where the viewer stopped — or from the start when asked", () => {
|
||||
const job = { ...withAsset({ width: 1280, height: 720 }), position_seconds: 300 };
|
||||
render(<LibraryPlayer job={job} onClose={() => {}} />);
|
||||
expect(loaded().currentTime).toBe(300);
|
||||
cleanup();
|
||||
render(<LibraryPlayer job={job} fromStart onClose={() => {}} />);
|
||||
expect(loaded().currentTime).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps where it stopped: on the server, and on the library row at once", () => {
|
||||
const save = vi.spyOn(api, "saveDownloadProgress").mockResolvedValue({
|
||||
ok: true,
|
||||
job_id: 7,
|
||||
position_seconds: 125,
|
||||
});
|
||||
const beacon = vi.spyOn(api, "saveDownloadProgressBeacon").mockImplementation(() => {});
|
||||
const client = new QueryClient();
|
||||
const job = withAsset({ width: 1280, height: 720 });
|
||||
client.setQueryData(qk.downloads(), [job, { ...job, id: 8 }]);
|
||||
const { unmount } = render(<LibraryPlayer job={job} onClose={() => {}} />, client);
|
||||
const video = loaded();
|
||||
video.dispatchEvent(new Event("playing"));
|
||||
video.currentTime = 125.4;
|
||||
video.dispatchEvent(new Event("pause"));
|
||||
|
||||
expect(save).toHaveBeenCalledWith(7, 125.4, 600);
|
||||
const rows = client.getQueryData<DownloadJob[]>(qk.downloads())!;
|
||||
expect(rows.map((j) => j.position_seconds)).toEqual([125, undefined]);
|
||||
// Closing the player sends the point the way that survives the page going away.
|
||||
unmount();
|
||||
expect(beacon).toHaveBeenCalledWith(7, 125.4, 600, expect.any(Function));
|
||||
});
|
||||
|
||||
it("keeps the closing point over a list fetch that read the server before the point landed", async () => {
|
||||
// The listing polls. One already out when the player closed answered with the point the server
|
||||
// held BEFORE the closing beacon, over the row's patch — and Continue then opened there, and its
|
||||
// first checkpoint wrote that older point back over the right one.
|
||||
let settled: (() => void) | undefined;
|
||||
vi.spyOn(api, "saveDownloadProgressBeacon").mockImplementation((_id, _at, _dur, onSettled) => {
|
||||
settled = onSettled;
|
||||
});
|
||||
const client = new QueryClient();
|
||||
const job = withAsset({ width: 1280, height: 720 });
|
||||
client.setQueryData(qk.downloads(), [{ ...job, position_seconds: 10 }]);
|
||||
let answer: (jobs: DownloadJob[]) => void = () => {};
|
||||
const poll = client.fetchQuery({
|
||||
queryKey: qk.downloads(),
|
||||
queryFn: () => new Promise<DownloadJob[]>((r) => (answer = r)),
|
||||
});
|
||||
const { unmount } = render(<LibraryPlayer job={job} onClose={() => {}} />, client);
|
||||
const video = loaded();
|
||||
video.dispatchEvent(new Event("playing"));
|
||||
video.currentTime = 300;
|
||||
unmount();
|
||||
answer([{ ...job, position_seconds: 10 }]);
|
||||
await poll;
|
||||
expect(client.getQueryData<DownloadJob[]>(qk.downloads())![0]!.position_seconds).toBe(300);
|
||||
// Once the point has landed, the listing asks the server again.
|
||||
const invalidate = vi.spyOn(client, "invalidateQueries");
|
||||
settled?.();
|
||||
expect(invalidate).toHaveBeenCalledWith({ queryKey: qk.downloads(), exact: true });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import Modal from "./ui/Modal";
|
||||
import { api, type DownloadJob } from "../lib/api";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { jobTitle } from "./downloadTitle";
|
||||
import { exposeVideoToBackgroundPlay } from "../lib/browserBackgroundPlay";
|
||||
import { useMediaLogRef } from "../lib/mediaLog";
|
||||
import { useMediaSession } from "../lib/mediaSession";
|
||||
import { useMediaKeys } from "../lib/mediaKeys";
|
||||
import { keptPosition, useResume } from "../lib/resume";
|
||||
|
||||
// In-app player for a completed library item. We do NOT reuse PlayerModal (a cross-origin
|
||||
// YouTube IFrame shell) or PlexPlayer (a Plex HLS/session shell): both are bound to their own domain
|
||||
@@ -14,8 +19,21 @@ import { useMediaLogRef } from "../lib/mediaLog";
|
||||
// browser-native container (mp4/H.264/AAC + m4a/mp3), so native playback (seek via the range-aware
|
||||
// /file endpoint, volume, fullscreen, speed) works with zero transcode; an uploaded media item may be
|
||||
// any codec (best-effort playback).
|
||||
export default function LibraryPlayer({ job, onClose }: { job: DownloadJob; onClose: () => void }) {
|
||||
//
|
||||
// It opens where the viewer stopped (`job.position_seconds`, from the library listing) unless
|
||||
// `fromStart`, and keeps the point on the server as it plays (`lib/resume.ts`); the OS's media
|
||||
// controls and the basic keys drive it like the other players.
|
||||
export default function LibraryPlayer({
|
||||
job,
|
||||
fromStart = false,
|
||||
onClose,
|
||||
}: {
|
||||
job: DownloadJob;
|
||||
fromStart?: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const fileUrl = useMemo(() => api.downloadFileUrl(job.id), [job.id]);
|
||||
// Video vs audio-only comes from the stored media_kind (the single source every sibling reads —
|
||||
// WatchPage/downloadRow/DownloadCenter) — NOT a re-derived container list, which would misclassify
|
||||
@@ -32,16 +50,48 @@ export default function LibraryPlayer({ job, onClose }: { job: DownloadJob; onCl
|
||||
// the fallback. Same precedence as the library-row thumbnail.
|
||||
const poster = job.poster_url ? api.posterImageUrl(job.id) : (asset?.thumbnail_url ?? undefined);
|
||||
const logMedia = useMediaLogRef("library");
|
||||
const videoRef = useCallback(
|
||||
(el: HTMLVideoElement | null) => {
|
||||
exposeVideoToBackgroundPlay(el);
|
||||
// The element as STATE, so the session, the keys and the resume bind to it once it exists.
|
||||
const [media, setMedia] = useState<HTMLMediaElement | null>(null);
|
||||
const mediaRef = useCallback(
|
||||
(el: HTMLMediaElement | null) => {
|
||||
if (el instanceof HTMLVideoElement) exposeVideoToBackgroundPlay(el);
|
||||
logMedia(el);
|
||||
setMedia(el);
|
||||
},
|
||||
[logMedia],
|
||||
);
|
||||
|
||||
const title = jobTitle(job);
|
||||
useMediaSession(media, {
|
||||
title,
|
||||
artist: job.display_uploader ?? asset?.uploader ?? null,
|
||||
artwork: poster ?? null,
|
||||
});
|
||||
useMediaKeys(media);
|
||||
// The library row's bar follows the point at once — both listings this job may sit in — rather
|
||||
// than after the next refetch. On the way out a listing poll may already be out, having read the
|
||||
// server before the closing point lands: it is cancelled, or its answer replaced the row's point
|
||||
// with the older one, Continue opened there, and its first checkpoint wrote it back over the right
|
||||
// one. Once the point is on the server, the listings are read again.
|
||||
const lists = [qk.downloads(), qk.downloadsShared()];
|
||||
const keepPoint = (position: number, duration: number, leaving: boolean) => {
|
||||
const at = keptPosition(position, duration);
|
||||
for (const key of lists) {
|
||||
if (leaving) void qc.cancelQueries({ queryKey: key, exact: true });
|
||||
qc.setQueryData<DownloadJob[]>(key, (jobs) =>
|
||||
jobs?.map((j) => (j.id === job.id ? { ...j, position_seconds: at } : j)),
|
||||
);
|
||||
}
|
||||
if (leaving) {
|
||||
api.saveDownloadProgressBeacon(job.id, position, duration, () => {
|
||||
for (const key of lists) void qc.invalidateQueries({ queryKey: key, exact: true });
|
||||
});
|
||||
} else void api.saveDownloadProgress(job.id, position, duration).catch(() => {});
|
||||
};
|
||||
useResume(media, String(job.id), fromStart ? 0 : (job.position_seconds ?? 0), keepPoint);
|
||||
|
||||
return (
|
||||
<Modal initialFocus="close" title={jobTitle(job)} onClose={onClose} maxWidth="max-w-4xl">
|
||||
<Modal initialFocus="player" title={title} onClose={onClose} maxWidth="max-w-4xl">
|
||||
{isAudio ? (
|
||||
<div className="flex flex-col items-center gap-4 py-2">
|
||||
{poster ? (
|
||||
@@ -51,13 +101,13 @@ export default function LibraryPlayer({ job, onClose }: { job: DownloadJob; onCl
|
||||
className="w-40 h-40 rounded-xl object-cover border border-border"
|
||||
/>
|
||||
) : null}
|
||||
<audio ref={logMedia} src={fileUrl} controls autoPlay className="w-full">
|
||||
<audio ref={mediaRef} src={fileUrl} controls autoPlay className="w-full">
|
||||
{t("downloads.player.unsupported")}
|
||||
</audio>
|
||||
</div>
|
||||
) : (
|
||||
<video
|
||||
ref={videoRef}
|
||||
ref={mediaRef}
|
||||
src={fileUrl}
|
||||
poster={poster}
|
||||
controls
|
||||
|
||||
@@ -32,6 +32,20 @@ import { PageHeading } from "./ui/Headings";
|
||||
|
||||
import { qk } from "../lib/queryKeys";
|
||||
|
||||
/** The Navigation API's view of the tab: `below` = whether each entry under the current one belongs
|
||||
* to this document, nearest first. jsdom has no Navigation API; Chrome's says this. */
|
||||
function stubNavigation(below: boolean[]) {
|
||||
const entries = [...below].reverse().map((sameDocument) => ({ sameDocument }));
|
||||
entries.push({ sameDocument: true });
|
||||
Object.defineProperty(window, "navigation", {
|
||||
configurable: true,
|
||||
value: { currentEntry: { index: entries.length - 1 }, entries: () => entries },
|
||||
});
|
||||
}
|
||||
afterEach(() => {
|
||||
Reflect.deleteProperty(window, "navigation");
|
||||
});
|
||||
|
||||
// The provider reads the signed-in account (the tab title) from the `me` cache.
|
||||
let qc = new QueryClient();
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
@@ -206,6 +220,31 @@ describe("NavigationProvider · Back", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("NavigationProvider · leaving a sub-view whose entry is the oldest of this document", () => {
|
||||
// A full tab can have dropped the entry under the one the channel page or the search pushed (see
|
||||
// lib/history's backStaysInDocument): leaving clears it in place instead of stepping out.
|
||||
it("closes the channel page in place", () => {
|
||||
const { result } = render();
|
||||
act(() => result.current.openChannel("UC1", "One"));
|
||||
stubNavigation([false]);
|
||||
const back = vi.spyOn(window.history, "back").mockImplementation(() => {});
|
||||
act(() => result.current.closeChannel());
|
||||
expect(back).not.toHaveBeenCalled();
|
||||
expect(result.current.channelView).toBeNull();
|
||||
expect(window.location.search).not.toContain("chan=");
|
||||
});
|
||||
|
||||
it("leaves the YouTube search in place", () => {
|
||||
const { result } = render();
|
||||
act(() => result.current.enterYtSearch("cats"));
|
||||
stubNavigation([false]);
|
||||
const back = vi.spyOn(window.history, "back").mockImplementation(() => {});
|
||||
act(() => result.current.exitYtSearch());
|
||||
expect(back).not.toHaveBeenCalled();
|
||||
expect(result.current.ytSearch).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("NavigationProvider · never under an open overlay", () => {
|
||||
it("setPage closes the overlay first and pushes only once its entry is reclaimed", async () => {
|
||||
const { result } = render();
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useDocumentObjectTitle } from "../lib/documentTitle";
|
||||
import { setAccountRaw } from "../lib/storage";
|
||||
import { setNavigator } from "../lib/nav";
|
||||
import {
|
||||
backStaysInDocument,
|
||||
closeOverlaysThenNavigate,
|
||||
nextNavId,
|
||||
notifyNav,
|
||||
@@ -119,12 +120,14 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
|
||||
}, []);
|
||||
|
||||
const closeChannel = useCallback(() => {
|
||||
if (window.history.state?._chan) window.history.back();
|
||||
if (window.history.state?._chan && backStaysInDocument()) window.history.back();
|
||||
else {
|
||||
// No owned history entry (e.g. opened straight from a ?chan bookmark): just clear the state and
|
||||
// drop the ?chan param from the address bar.
|
||||
// No owned history entry (e.g. opened straight from a ?chan bookmark), or none of this document
|
||||
// below it to step back to (a full tab, see backStaysInDocument): clear it in place — the
|
||||
// entry's channel marks and the ?chan param.
|
||||
setChannelView(null);
|
||||
window.history.replaceState(window.history.state, "", urlWithParams({ chan: null }));
|
||||
const { _chan: _id, _chanName: _name, ...st } = window.history.state ?? {};
|
||||
window.history.replaceState(st, "", urlWithParams({ chan: null }));
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -143,9 +146,14 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
|
||||
}, []);
|
||||
|
||||
const exitYtSearch = useCallback(() => {
|
||||
// Pop our search entry (so Forward still works); fall back to a plain clear if we don't own one.
|
||||
if (window.history.state?._yt) window.history.back();
|
||||
else setYtSearch(null);
|
||||
// Pop our search entry (so Forward still works); fall back to a clear in place if we don't own
|
||||
// one, or there is no entry of this document below it (a full tab, see backStaysInDocument).
|
||||
if (window.history.state?._yt && backStaysInDocument()) window.history.back();
|
||||
else {
|
||||
setYtSearch(null);
|
||||
const { _yt: _q, _ytScrape: _scrape, ...st } = window.history.state ?? {};
|
||||
window.history.replaceState(st, "");
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Expose setPage to decoupled callers (download toast "View", etc.) via the lib/nav singleton.
|
||||
|
||||
@@ -364,3 +364,19 @@ describe("a download-lifecycle notice", () => {
|
||||
expect(screen.queryByText("download")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("NotificationsPanel · a finished job's title", () => {
|
||||
it("names what finished: a download, a clip, an upload", () => {
|
||||
h.server = [
|
||||
server(1),
|
||||
server(2, { data: { title: "Ready 2", kind: "edit" } }),
|
||||
server(3, { data: { title: "Ready 3", kind: "upload" } }),
|
||||
];
|
||||
h.client = [];
|
||||
mount();
|
||||
const titleOf = (text: string) => rowOf(text).textContent ?? "";
|
||||
expect(titleOf("Ready 1")).toContain("inbox.downloadDone.title");
|
||||
expect(titleOf("Ready 2")).toContain("inbox.editDone.title");
|
||||
expect(titleOf("Ready 3")).toContain("inbox.uploadDone.title");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -446,6 +446,11 @@ const DOWNLOAD_LIFECYCLE: Record<string, string | undefined> = {
|
||||
download_expired: "downloadExpired",
|
||||
download_evicted: "downloadEvicted",
|
||||
};
|
||||
// A `download_done` notice's title by the producer's `kind`; any other kind reads as a download.
|
||||
const DONE_TITLE: Record<string, string | undefined> = {
|
||||
edit: "inbox.editDone.title",
|
||||
upload: "inbox.uploadDone.title",
|
||||
};
|
||||
|
||||
function serverKind(n: AppNotification): "scheduler" | "download" | null {
|
||||
if (n.type === "scheduler" && typeof n.data?.job_id === "string") return "scheduler";
|
||||
@@ -552,10 +557,11 @@ function NotificationRow({
|
||||
title = t(`inbox.${lifecycle}.title`, { from: d.from ?? "" });
|
||||
body = d.title || null;
|
||||
} else if (isDownloadDone) {
|
||||
// "Download ready" / "Edit ready" per the producer's kind, with the video's title as the body
|
||||
// (shipped in data.title). Same notification type; the copy differs so an edit isn't mislabelled.
|
||||
// "Download ready" / "Edit ready" / "Upload ready" per the producer's kind, with the video's title
|
||||
// as the body (shipped in data.title). Same notification type; the copy differs so an edit or an
|
||||
// upload isn't called a download.
|
||||
const d = n.data as { title?: string | null; kind?: string };
|
||||
title = t(d.kind === "edit" ? "inbox.editDone.title" : "inbox.downloadDone.title");
|
||||
title = t(DONE_TITLE[d.kind ?? ""] ?? "inbox.downloadDone.title");
|
||||
body = d.title || n.body;
|
||||
}
|
||||
return (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -444,6 +444,72 @@ describe("PlayerModal — the side arrows", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// 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;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// At 133% zoom (a 685px window) a full-width card ran 20px past the bottom and cut its channel
|
||||
// row. The card's width is capped from the window height less what sits under the video, measured.
|
||||
describe("PlayerModal — fits the window's height", () => {
|
||||
@@ -465,7 +531,9 @@ describe("PlayerModal — fits the window's height", () => {
|
||||
<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((100dvh - 3rem - 160px) * 16 / 9 + 7rem))");
|
||||
expect(row.style.maxWidth).toBe(
|
||||
"min(63rem, calc(max(36rem, (100dvh - 3rem - 160px) * 16 / 9) + 7rem))",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -483,8 +551,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 () => {
|
||||
@@ -589,3 +668,40 @@ 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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type CSSProperties,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { createPortal } from "react-dom";
|
||||
@@ -56,7 +57,7 @@ import {
|
||||
formatCompact,
|
||||
relativeTime,
|
||||
} from "../lib/format";
|
||||
import { fullscreenElement } from "../lib/fullscreen";
|
||||
import { canFullscreenElements, fullscreenElement } from "../lib/fullscreen";
|
||||
import { dispatch, isActivationTarget, useKeyScope } from "../lib/keymap";
|
||||
import { useBackToClose } from "../lib/history";
|
||||
import ChannelNameLink from "./ChannelNameLink";
|
||||
@@ -104,6 +105,37 @@ function canScrollBy(el: HTMLElement | null, dir: -1 | 1): boolean {
|
||||
// a rem width, so it grows with the text size; the video takes what is left.
|
||||
const WIDE_PLAYER_PX = 1280;
|
||||
const SIDE_PANEL = "20rem";
|
||||
// The narrowest the stacked video gets to make room for the chrome under it (see the card's maxWidth).
|
||||
const MIN_VIDEO = "36rem";
|
||||
// YouTube's embed draws its controls at fixed CSS px from the iframe's edges, whatever its size
|
||||
// (measured 576×324 up to 2460×1384): the channel avatar, volume, captions and gear in a row along the
|
||||
// top (48 px), the seek bar across the full width 52–96 px up from the bottom, and the fullscreen button
|
||||
// above its right end, 80–128 px up and 24–72 px in from the right. The click layer's cutouts never
|
||||
// shrink below those, else on a short player (a small window, a browser zoomed to 150 % or more) the
|
||||
// layer lies over that button and a click on it pauses instead. Only the right-hand column needs the
|
||||
// fullscreen button's height: a full-width band that tall would hand the clicks on the picture to the
|
||||
// iframe, which takes the keyboard focus and with it our shortcuts. On a player too short for any
|
||||
// centre (under ~200 px, a phone) the layer is gone and YouTube's own touch controls take every tap.
|
||||
// Px, not rem: the text-size slider does not grow YouTube's controls.
|
||||
const YT_TOP_CONTROLS_PX = 56;
|
||||
const YT_SEEK_BAR_PX = 104;
|
||||
const YT_FULLSCREEN_BUTTON_PX = { above: 140, fromRight: 88 };
|
||||
// The layer is these two boxes: the picture left of the fullscreen button down to the seek bar, and the
|
||||
// column above that button.
|
||||
const YT_CLICK_LAYER: CSSProperties[] = [
|
||||
{
|
||||
left: 0,
|
||||
right: YT_FULLSCREEN_BUTTON_PX.fromRight,
|
||||
top: `max(12%, ${YT_TOP_CONTROLS_PX}px)`,
|
||||
bottom: `max(22%, ${YT_SEEK_BAR_PX}px)`,
|
||||
},
|
||||
{
|
||||
right: 0,
|
||||
width: YT_FULLSCREEN_BUTTON_PX.fromRight,
|
||||
top: `max(12%, ${YT_TOP_CONTROLS_PX}px)`,
|
||||
bottom: `max(22%, ${YT_FULLSCREEN_BUTTON_PX.above}px)`,
|
||||
},
|
||||
];
|
||||
|
||||
// The meta line's items each lead with a "·" that parts them from what stands before: the channel name
|
||||
// on its line. In the wide panel the meta has a line of its own, where the first "·" would lead nothing.
|
||||
@@ -113,6 +145,9 @@ const metaSep = (
|
||||
</span>
|
||||
);
|
||||
const META_OWN_LINE = "w-full [&>span:first-child>[data-meta-sep]]:hidden";
|
||||
// Beside the channel name the meta takes what is left of the line, but never less than this: under it
|
||||
// the line wraps and the meta, or the actions after it, move down instead of squeezing.
|
||||
const META_MIN = "min-w-48 flex-1";
|
||||
|
||||
export default function PlayerModal({
|
||||
video,
|
||||
@@ -611,6 +646,10 @@ export default function PlayerModal({
|
||||
onLoadVideo: loadVideo,
|
||||
};
|
||||
|
||||
// The card's width budget (its maxWidth, below): the video's, and the side strips' gutters.
|
||||
const fitWidth = `(100dvh - 3rem - ${below}px) * 16 / 9`;
|
||||
const gutters = hasQueue ? " + 7rem" : "";
|
||||
|
||||
// Portaled to <body>, like every other full-screen overlay here. A fixed, full-viewport modal
|
||||
// must not render inside the page scroller: once that scroller is a stacking context (its
|
||||
// edge-fade mask makes it one) this z-overlay is scoped inside it, and the nav rail (z-rail) and the
|
||||
@@ -635,13 +674,16 @@ export default function PlayerModal({
|
||||
className={`relative flex w-full max-h-full ${hasQueue ? "sm:px-14" : ""}`}
|
||||
// The design width (63rem with the side strips, else 56rem), narrowed so the 16:9 video plus the
|
||||
// chrome under it fits the window's height less the backdrop's padding (`p-6` = 3rem).
|
||||
// Wide: no design cap — the video is as wide as the window's height allows, plus the side panel.
|
||||
// Wide: no design cap — the video is as wide as the window's height allows, plus the side panel,
|
||||
// whose content sits beside the video (`below` is ~0), so the fit is exact and the card, which
|
||||
// does not scroll there, always holds the whole video.
|
||||
// Stacked: never narrower than MIN_VIDEO. The chrome under the video grows as the card narrows
|
||||
// (its text wraps), so on a short window fitting it was a loop that ran the card down to 2 px
|
||||
// (a laptop at 100%, a 1080p screen at 175%). Past the floor the stacked card scrolls instead.
|
||||
style={{
|
||||
maxWidth: wide
|
||||
? `calc((100dvh - 3rem - ${below}px) * 16 / 9 + ${SIDE_PANEL}${hasQueue ? " + 7rem" : ""})`
|
||||
: `min(${hasQueue ? "63rem" : "56rem"}, calc((100dvh - 3rem - ${below}px) * 16 / 9${
|
||||
hasQueue ? " + 7rem" : ""
|
||||
}))`,
|
||||
? `calc(${fitWidth} + ${SIDE_PANEL}${gutters})`
|
||||
: `min(${hasQueue ? "63rem" : "56rem"}, calc(max(${MIN_VIDEO}, ${fitWidth})${gutters}))`,
|
||||
}}
|
||||
>
|
||||
{hasQueue && (
|
||||
@@ -680,39 +722,48 @@ export default function PlayerModal({
|
||||
which would drop any class on mountRef itself — the iframe then sizes to this wrapper. */}
|
||||
<div
|
||||
className={
|
||||
maximized || isFullscreen ? "absolute inset-x-0 top-0 bottom-14" : "w-full h-full"
|
||||
maximized || isFullscreen
|
||||
? "absolute inset-x-0 top-0 bottom-14"
|
||||
: "relative w-full h-full"
|
||||
}
|
||||
>
|
||||
<div
|
||||
ref={mountRef}
|
||||
className={`w-full h-full ${playerError != null ? "invisible" : ""}`}
|
||||
/>
|
||||
{/* Interaction layer over the CENTRE of the video: catches click (play/pause) + wheel
|
||||
volume (the wheel event bubbles from here to the modal's listener — over the bare
|
||||
cross-origin iframe it would be swallowed) and stops the iframe stealing keyboard focus.
|
||||
It leaves the top AND bottom edges uncovered so YouTube's edge controls stay reachable,
|
||||
measured against the iframe it sits in (in the big views that excludes our docked bar)
|
||||
and never thinner than those controls (see YT_CLICK_LAYER). A mouse surface only, so hidden
|
||||
from assistive tech: the keyboard has the shortcuts it stands for.
|
||||
Armed in the big views too (that is what keeps wheel volume working after F, the reported
|
||||
gap) — it only yields to `nativeControls`, i.e. once the user opens YouTube's own
|
||||
settings menu (focus moves into the iframe), so that menu is navigable end to end.
|
||||
Hidden on error so the "Open on YouTube" CTA is clickable. */}
|
||||
{playerError == null &&
|
||||
YT_CLICK_LAYER.map((box, i) => (
|
||||
<div
|
||||
key={i}
|
||||
data-testid="yt-click-layer"
|
||||
onClick={() => {
|
||||
focusModal();
|
||||
togglePlay();
|
||||
}}
|
||||
title={t("player.shortcutsHint")}
|
||||
aria-hidden="true"
|
||||
style={box}
|
||||
className={`absolute z-base cursor-pointer ${
|
||||
nativeControls ? "pointer-events-none" : ""
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{/* Interaction layer over the CENTRE of the video: catches click (play/pause) + wheel
|
||||
volume (the wheel event bubbles from here to the modal's listener — over the bare
|
||||
cross-origin iframe it would be swallowed) and stops the iframe stealing keyboard focus.
|
||||
It leaves the top AND bottom edges uncovered so YouTube's edge controls stay reachable.
|
||||
Armed in the big views too (that is what keeps wheel volume working after F, the reported
|
||||
gap) — it only yields to `nativeControls`, i.e. once the user opens YouTube's own
|
||||
settings menu (focus moves into the iframe), so that menu is navigable end to end.
|
||||
Hidden on error so the "Open on YouTube" CTA is clickable. */}
|
||||
{playerError == null && (
|
||||
<div
|
||||
onClick={() => {
|
||||
focusModal();
|
||||
togglePlay();
|
||||
}}
|
||||
title={t("player.shortcutsHint")}
|
||||
aria-label={t("player.shortcutsHint")}
|
||||
className={`absolute inset-x-0 top-[12%] bottom-[22%] z-base cursor-pointer ${
|
||||
nativeControls ? "pointer-events-none" : ""
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
{/* Our own control bar for the big views (maximise / our fullscreen), where the card's
|
||||
controls are off-screen. It's IN the stage (so it survives true fullscreen, where a
|
||||
<body> portal would not) and DOCKED in its own strip: the video area above is inset by
|
||||
this bar's height (see mountRef's `bottom-14`), so the bar NEVER overlaps YouTube's seek
|
||||
this bar's height (see the iframe wrapper's `bottom-14`), so the bar NEVER overlaps YouTube's seek
|
||||
bar — that stays reachable by hovering the video's bottom edge, just above ours. Always
|
||||
visible (no auto-hide) so there's no guessing whether it will appear and no fight with the
|
||||
seek bar over the same pixels. stopPropagation so a click here doesn't toggle playback;
|
||||
@@ -762,13 +813,18 @@ export default function PlayerModal({
|
||||
</Button>
|
||||
)}
|
||||
<span className="h-4 w-px bg-white/20" />
|
||||
<IconButton
|
||||
variant="overlay"
|
||||
onClick={toggleTrueFullscreen}
|
||||
label={isFullscreen ? t("player.exitFullscreen") : t("player.fullscreen")}
|
||||
>
|
||||
{isFullscreen ? <Minimize /> : <Maximize />}
|
||||
</IconButton>
|
||||
{/* Absent where the browser cannot fullscreen an element (Brave on an iPad): there
|
||||
the window-filling view is the biggest, and YouTube's own button opens the
|
||||
system player. */}
|
||||
{canFullscreenElements() && (
|
||||
<IconButton
|
||||
variant="overlay"
|
||||
onClick={toggleTrueFullscreen}
|
||||
label={isFullscreen ? t("player.exitFullscreen") : t("player.fullscreen")}
|
||||
>
|
||||
{isFullscreen ? <Minimize /> : <Maximize />}
|
||||
</IconButton>
|
||||
)}
|
||||
<Button
|
||||
variant="overlay"
|
||||
size="sm"
|
||||
@@ -913,10 +969,13 @@ export default function PlayerModal({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Channel + meta on one line, with the watched toggle pushed to the right.
|
||||
{/* Channel + meta on one line, with the actions pushed to the right.
|
||||
When navigated to a linked video we only have its author (from the player),
|
||||
so we show that as plain text and hide feed-video-specific bits. */}
|
||||
<div className={`flex items-center gap-3 mt-3 ${wide ? "flex-wrap" : ""}`}>
|
||||
so we show that as plain text and hide feed-video-specific bits.
|
||||
It wraps: the meta never gets narrower than META_MIN, and the actions move down as one
|
||||
group. Unwrapped, a narrow card (a tablet, a zoomed window) squeezed the meta into a
|
||||
one-word column between the name and the buttons. */}
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 mt-3">
|
||||
{!navigated && (
|
||||
<Avatar
|
||||
src={active.channel_thumbnail}
|
||||
@@ -964,7 +1023,7 @@ export default function PlayerModal({
|
||||
)}
|
||||
{!navigated ? (
|
||||
<div
|
||||
className={`flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm text-muted min-w-0 ${wide ? META_OWN_LINE : ""}`}
|
||||
className={`flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm text-muted ${wide ? META_OWN_LINE : META_MIN}`}
|
||||
>
|
||||
{active.view_count != null && (
|
||||
<span>
|
||||
@@ -977,7 +1036,7 @@ export default function PlayerModal({
|
||||
)}
|
||||
<span>
|
||||
{metaSep}
|
||||
{relativeTime(active.published_at, lang)}
|
||||
{relativeTime(active.published_at, lang, t)}
|
||||
{active.published_at && ` · ${fullDate(active.published_at)}`}
|
||||
</span>
|
||||
{active.duration_seconds != null && (
|
||||
@@ -995,7 +1054,7 @@ export default function PlayerModal({
|
||||
) : (
|
||||
// Linked video's stats come from the (already-fetched) video detail.
|
||||
<div
|
||||
className={`flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm text-muted min-w-0 ${wide ? META_OWN_LINE : ""}`}
|
||||
className={`flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm text-muted ${wide ? META_OWN_LINE : META_MIN}`}
|
||||
>
|
||||
{detail.data?.view_count != null && (
|
||||
<span>
|
||||
@@ -1009,7 +1068,7 @@ export default function PlayerModal({
|
||||
{detail.data?.published_at && (
|
||||
<span>
|
||||
{metaSep}
|
||||
{relativeTime(detail.data.published_at, lang)} ·{" "}
|
||||
{relativeTime(detail.data.published_at, lang, t)} ·{" "}
|
||||
{fullDate(detail.data.published_at)}
|
||||
</span>
|
||||
)}
|
||||
@@ -1028,51 +1087,48 @@ export default function PlayerModal({
|
||||
own (which hides our chrome). Mirrors the F key (maximise, a CSS big view); from there
|
||||
the docked bar offers OUR true fullscreen. */}
|
||||
{!navigated && (
|
||||
<IconButton
|
||||
variant="outline"
|
||||
size="md"
|
||||
onClick={toggleFullscreen}
|
||||
label={t("player.fullscreen")}
|
||||
title={`${t("player.fullscreen")} · F`}
|
||||
// Narrow: the actions end the channel line. Wide: their own line in the panel.
|
||||
className={wide ? undefined : "ml-auto"}
|
||||
>
|
||||
<Maximize />
|
||||
</IconButton>
|
||||
)}
|
||||
{!navigated && <AddToPlaylist videoId={active.id} variant="outline" size="md" />}
|
||||
{!navigated && (
|
||||
<DownloadButton
|
||||
videoId={active.id}
|
||||
title={active.title}
|
||||
variant="outline"
|
||||
size="md"
|
||||
/>
|
||||
)}
|
||||
{!navigated && onRemoveFromList && (
|
||||
<IconButton
|
||||
variant="outline-danger"
|
||||
size="md"
|
||||
onClick={() => void removeFromList()}
|
||||
// Named with its object, like the list's menu row: a bare "Remove" among Fullscreen /
|
||||
// Download / Mark watched reads as "delete this video".
|
||||
label={t("playlists.removeItemOf", { title: active.title })}
|
||||
title={t("playlists.removeItem")}
|
||||
>
|
||||
<Trash2 />
|
||||
</IconButton>
|
||||
)}
|
||||
{!navigated && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
pressed={watched}
|
||||
onClick={() => setWatched(!watched)}
|
||||
title={watched ? t("player.watchedUnmark") : t("player.markWatched")}
|
||||
icon={watched ? <CheckCheck /> : <Check />}
|
||||
>
|
||||
{watched ? t("player.watched") : t("player.markWatched")}
|
||||
</Button>
|
||||
// Narrow: the actions end the channel line. Wide: their own line in the panel.
|
||||
<div className={`flex items-center gap-3 ${wide ? "flex-wrap" : "ml-auto"}`}>
|
||||
<IconButton
|
||||
variant="outline"
|
||||
size="md"
|
||||
onClick={toggleFullscreen}
|
||||
label={t("player.fullscreen")}
|
||||
title={`${t("player.fullscreen")} · F`}
|
||||
>
|
||||
<Maximize />
|
||||
</IconButton>
|
||||
<AddToPlaylist videoId={active.id} variant="outline" size="md" />
|
||||
<DownloadButton
|
||||
videoId={active.id}
|
||||
title={active.title}
|
||||
variant="outline"
|
||||
size="md"
|
||||
/>
|
||||
{onRemoveFromList && (
|
||||
<IconButton
|
||||
variant="outline-danger"
|
||||
size="md"
|
||||
onClick={() => void removeFromList()}
|
||||
// Named with its object, like the list's menu row: a bare "Remove" among Fullscreen /
|
||||
// Download / Mark watched reads as "delete this video".
|
||||
label={t("playlists.removeItemOf", { title: active.title })}
|
||||
title={t("playlists.removeItem")}
|
||||
>
|
||||
<Trash2 />
|
||||
</IconButton>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
pressed={watched}
|
||||
onClick={() => setWatched(!watched)}
|
||||
title={watched ? t("player.watchedUnmark") : t("player.markWatched")}
|
||||
icon={watched ? <CheckCheck /> : <Check />}
|
||||
>
|
||||
{watched ? t("player.watched") : t("player.markWatched")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{wide && (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -68,7 +68,7 @@ const PlexCollectionView = lazy(() => import("./PlexCollectionView"));
|
||||
|
||||
const PAGE = 40;
|
||||
|
||||
// D3: the "last watched" sort auto-applied when you enter the in-progress watch-state view (most
|
||||
// The "last watched" sort auto-applied when you enter the in-progress watch-state view (most
|
||||
// recently watched first). Frozen for the same reason as PLEX_SORT_FALLBACK (handed out by reference).
|
||||
const LASTWATCHED_SORT: NonNullable<SortState> = Object.freeze({
|
||||
key: "lastwatched",
|
||||
@@ -229,7 +229,7 @@ export default function PlexBrowse() {
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [sub, canStepBack]);
|
||||
|
||||
// D3: entering the in-progress watch-state view auto-applies the "last watched" sort (most recently
|
||||
// Entering the in-progress watch-state view auto-applies the "last watched" sort (most recently
|
||||
// watched first); leaving restores the sort that was active before entering. Only the ENTER edge
|
||||
// forces the sort, so a manual sort change while in the in-progress view still wins — it's just not
|
||||
// carried back out (exit returns to the pre-entry preference). We stash the raw provider `sort`, not
|
||||
|
||||
@@ -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)),
|
||||
};
|
||||
}
|
||||
@@ -273,7 +273,7 @@ describe("PlexDiscover", () => {
|
||||
});
|
||||
|
||||
// Every rail and the genre grid are lists the keyboard walks (lib/listNav), so `j` goes on
|
||||
// from one to the next in page order (owner decision D4).
|
||||
// from one to the next in page order (the owner's call).
|
||||
describe("PlexDiscover · keyboard", () => {
|
||||
it("registers every rail and the genre grid, and j walks on from rail to rail", async () => {
|
||||
plexDiscover.mockResolvedValue(fullPayload);
|
||||
|
||||
@@ -10,6 +10,7 @@ import { DetailCustomizeMenu, Filterable, PrefToggle } from "./plexDetailUi";
|
||||
import { DetailAside, DetailAsideToggles, DetailKeywords } from "./plexDetailAside";
|
||||
import {
|
||||
DETAIL_POSTER,
|
||||
episodeLine,
|
||||
tmdbBackdropUrl,
|
||||
useCrossfadeBackdrop,
|
||||
useDetailAside,
|
||||
@@ -315,7 +316,7 @@ export default function PlexInfo({
|
||||
)}
|
||||
{detail.kind === "episode" && (
|
||||
<p className={`text-sm ${overlay ? "text-white/70" : "text-muted"}`}>
|
||||
S{detail.season_number}·E{detail.episode_number} — {detail.title}
|
||||
{episodeLine(detail)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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 = () =>
|
||||
@@ -223,3 +230,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}
|
||||
</>
|
||||
|
||||
@@ -358,7 +358,7 @@ describe("keyboard focus in the bands", () => {
|
||||
it("a Space or Enter on Skip intro, outside the faded bands, stays Skip intro's", async () => {
|
||||
const intro = {
|
||||
...item("A"),
|
||||
markers: [{ type: "intro", start_s: 50, end_s: 200 }],
|
||||
markers: [{ type: "intro", start_s: 50, end_s: 200, final: false }],
|
||||
} as unknown as PlexItemDetail;
|
||||
mount({ details: { A: intro } });
|
||||
await playingAt("/media/A", 100);
|
||||
@@ -857,3 +857,73 @@ 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;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// What may be open over the Plex player at once, and what Back closes. The ⋯ menu, the tracks
|
||||
// menu, the tuning panel, the info overlay and the up-next card are ONE value: opening one replaces
|
||||
// the other, so two can never draw on top of each other. The stats readout is not one of them — it
|
||||
// stays up beside a panel — and ⌫ / Escape peel the panel first, then the stats, and only then
|
||||
// leave the player; the mouse/browser Back peels them one per press too, the one opened last first.
|
||||
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
|
||||
import { render, cleanup, waitFor, act, screen, fireEvent } 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" } }),
|
||||
}));
|
||||
|
||||
import PlexPlayer from "./PlexPlayer";
|
||||
import { api, type PlexItemDetail } 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;
|
||||
for (const [name, value] of Object.entries({
|
||||
play: () => Promise.resolve(),
|
||||
pause: () => {},
|
||||
load: () => {},
|
||||
}))
|
||||
Object.defineProperty(HTMLMediaElement.prototype, name, { configurable: true, value });
|
||||
Object.defineProperty(HTMLMediaElement.prototype, "seekable", {
|
||||
configurable: true,
|
||||
get: () => ({ length: 0, end: () => 0 }),
|
||||
});
|
||||
Object.defineProperty(HTMLMediaElement.prototype, "textTracks", {
|
||||
configurable: true,
|
||||
get: () => Object.assign([], { addEventListener() {}, removeEventListener() {} }),
|
||||
});
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const detail = {
|
||||
id: "1",
|
||||
title: "Title 1",
|
||||
type: "movie",
|
||||
duration_seconds: 3000,
|
||||
status: "new",
|
||||
position_seconds: 0,
|
||||
audio_streams: [],
|
||||
subtitle_streams: [],
|
||||
markers: [],
|
||||
cast: [],
|
||||
genres: [],
|
||||
} as unknown as PlexItemDetail;
|
||||
|
||||
async function mount() {
|
||||
vi.spyOn(api, "plexItem").mockResolvedValue(detail);
|
||||
vi.spyOn(api, "plexSession").mockResolvedValue({
|
||||
mode: "direct",
|
||||
url: "/media/1",
|
||||
start_s: 0,
|
||||
} as Awaited<ReturnType<typeof api.plexSession>>);
|
||||
vi.spyOn(api, "plexProgress").mockResolvedValue(undefined as never);
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<QueryClientProvider
|
||||
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
|
||||
>
|
||||
<PlexPlayer itemId="1" onClose={onClose} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(document.querySelector("video")?.getAttribute("src")).toBe("/media/1"),
|
||||
);
|
||||
return onClose;
|
||||
}
|
||||
|
||||
/** The browser's Back, waited out to its popstate. */
|
||||
const browserBack = () =>
|
||||
act(async () => {
|
||||
const landed = new Promise((resolve) =>
|
||||
window.addEventListener("popstate", resolve, { once: true }),
|
||||
);
|
||||
window.history.back();
|
||||
await landed;
|
||||
});
|
||||
|
||||
const press = (key: string) =>
|
||||
act(() => {
|
||||
window.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }));
|
||||
});
|
||||
|
||||
// `hidden`: the faded-out control bands are aria-hidden while the video plays.
|
||||
const settings = () => screen.queryByRole("dialog", { name: "plex.player.settings", hidden: true });
|
||||
const stats = () => screen.queryByRole("region", { name: "plex.player.stats.title", hidden: true });
|
||||
const moreMenu = () => screen.queryByRole("menu", { hidden: true });
|
||||
// The info overlay is the full-screen scrim the media info sits in.
|
||||
const info = () => document.querySelector(".place-items-center.z-menu");
|
||||
|
||||
async function openSettings() {
|
||||
fireEvent.click(screen.getByLabelText("plex.player.more"));
|
||||
const row = await waitFor(() => {
|
||||
const r = screen
|
||||
.queryAllByRole("menuitem", { hidden: true })
|
||||
.find((b) => b.textContent === "plex.player.settings");
|
||||
expect(r).toBeDefined();
|
||||
return r!;
|
||||
});
|
||||
fireEvent.click(row);
|
||||
await waitFor(() => expect(settings()).not.toBeNull());
|
||||
}
|
||||
|
||||
describe("PlexPlayer · one panel at a time", () => {
|
||||
it("the browser Back with the ⋯ menu open closes the menu, not the player", async () => {
|
||||
const onClose = await mount();
|
||||
fireEvent.click(screen.getByLabelText("plex.player.more"));
|
||||
await waitFor(() => expect(moreMenu()).not.toBeNull());
|
||||
// The menu claims a history entry, so the Back below is spent on it. First in the file: the
|
||||
// history module's entry count is per module, and an earlier test's entries would be in it.
|
||||
await waitFor(() => expect(window.history.state?._ov).toBeDefined());
|
||||
|
||||
await browserBack();
|
||||
|
||||
await waitFor(() => expect(moreMenu()).toBeNull());
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("the browser Back peels each layer that is up, one press at a time", async () => {
|
||||
// One shared history entry for every layer: the first Back spent it on one layer, and the
|
||||
// second left the player with the stats readout still up. Second in the file (see above): the
|
||||
// first test leaves no entry behind.
|
||||
window.history.pushState({ page: "player" }, ""); // the entry the player was opened on
|
||||
const onClose = await mount();
|
||||
press("d");
|
||||
press("i");
|
||||
await waitFor(() => expect(info()).not.toBeNull());
|
||||
|
||||
await browserBack();
|
||||
await waitFor(() => expect(info()).toBeNull());
|
||||
expect(stats()).not.toBeNull();
|
||||
|
||||
await browserBack();
|
||||
await waitFor(() => expect(stats()).toBeNull());
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("the info overlay replaces the tuning panel rather than drawing over it", async () => {
|
||||
await mount();
|
||||
await openSettings();
|
||||
press("i");
|
||||
await waitFor(() => expect(info()).not.toBeNull());
|
||||
expect(settings()).toBeNull();
|
||||
});
|
||||
|
||||
it("the stats readout stays up beside a panel; Escape peels the panel, then the stats, then leaves", async () => {
|
||||
const onClose = await mount();
|
||||
press("d");
|
||||
press("i");
|
||||
await waitFor(() => expect(info()).not.toBeNull());
|
||||
expect(stats()).not.toBeNull();
|
||||
|
||||
press("Escape");
|
||||
expect(info()).toBeNull();
|
||||
expect(stats()).not.toBeNull();
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
|
||||
press("Escape");
|
||||
expect(stats()).toBeNull();
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
|
||||
press("Escape");
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -308,6 +308,45 @@ describe("leaving an item that was played through", () => {
|
||||
act(() => void el.dispatchEvent(new Event("ended")));
|
||||
};
|
||||
|
||||
// A layer the player opens BY ITSELF — the auto-skip countdown, the up-next card — takes no history
|
||||
// entry. Chrome's Back button skips an entry pushed without a user gesture, and one pushed under an
|
||||
// open dialog (the shortcuts sheet) sat above it, so Back closed the hidden layer, not the dialog.
|
||||
const overlayPushes = (push: { mock: { calls: unknown[][] } }) =>
|
||||
push.mock.calls.filter(([state]) => (state as { _ov?: number } | null)?._ov != null);
|
||||
|
||||
it("gives the auto-skip countdown no history entry", async () => {
|
||||
prefs({ autoSkipIntro: true, autoSkipDelay: 5 });
|
||||
const withIntro = {
|
||||
...item("A"),
|
||||
markers: [{ type: "intro", start_s: 100, end_s: 160, final: false }],
|
||||
} as unknown as PlexItemDetail;
|
||||
mount({ details: { A: withIntro }, sessions: { A: () => Promise.resolve(direct("A")) } });
|
||||
await playingAt("/media/A", 50);
|
||||
await tick();
|
||||
const push = vi.spyOn(window.history, "pushState");
|
||||
await playingAt("/media/A", 110); // inside the intro: the countdown starts
|
||||
await waitFor(() => expect(screen.getByText("plex.player.skipIntro")).toBeTruthy());
|
||||
await tick();
|
||||
expect(overlayPushes(push)).toEqual([]);
|
||||
});
|
||||
|
||||
it("gives the up-next card no history entry", async () => {
|
||||
prefs({ upNext: true });
|
||||
mount({
|
||||
details: { A: item("A"), B: item("B") },
|
||||
sessions: { A: () => Promise.resolve(direct("A")), B: () => new Promise(() => {}) },
|
||||
queue: ["A", "B"],
|
||||
});
|
||||
vi.spyOn(api, "plexSetState").mockResolvedValue(undefined as never);
|
||||
await playingAt("/media/A", 2995);
|
||||
await tick();
|
||||
const push = vi.spyOn(window.history, "pushState");
|
||||
endMedia();
|
||||
await screen.findByText("Title B"); // the card names the next item
|
||||
await tick();
|
||||
expect(overlayPushes(push)).toEqual([]);
|
||||
});
|
||||
|
||||
it("settles it as watched when it ended, so Prev does not reopen it at its last seconds", async () => {
|
||||
prefs({ upNext: false });
|
||||
const { qc, progress } = mount({
|
||||
@@ -334,7 +373,7 @@ describe("leaving an item that was played through", () => {
|
||||
prefs({ autoSkipCredits: true, autoSkipDelay: 0 });
|
||||
const withCredits = {
|
||||
...item("A"),
|
||||
markers: [{ type: "credits", start_s: 2900, end_s: 2995 }],
|
||||
markers: [{ type: "credits", start_s: 2900, end_s: 2995, final: true }],
|
||||
} as unknown as PlexItemDetail;
|
||||
const { qc, progress } = mount({
|
||||
details: { A: withCredits, B: item("B") },
|
||||
@@ -446,7 +485,7 @@ describe("leaving an item that was played through", () => {
|
||||
prefs({ upNext: false, autoSkipCredits: true, autoSkipDelay: 0 });
|
||||
const withCredits = {
|
||||
...item("A"),
|
||||
markers: [{ type: "credits", start_s: 2000, end_s: 2990 }],
|
||||
markers: [{ type: "credits", start_s: 2000, end_s: 2990, final: true }],
|
||||
} as unknown as PlexItemDetail;
|
||||
const { qc, progress } = mount({
|
||||
details: { A: withCredits, B: item("B") },
|
||||
@@ -463,6 +502,56 @@ describe("leaving an item that was played through", () => {
|
||||
expect(qc.getQueryData<PlexItemDetail>(qk.plexItem("A", "en"))?.status).not.toBe("watched");
|
||||
});
|
||||
|
||||
it("lets a credits block that does not end the item play on, its Skip only jumping past it", async () => {
|
||||
// An item can carry several credits blocks with scenes between them: the first one moving on
|
||||
// to the next item skipped a post-credit scene.
|
||||
prefs({ autoSkipCredits: true, autoSkipDelay: 0 });
|
||||
const twoCredits = {
|
||||
...item("A"),
|
||||
markers: [
|
||||
{ type: "credits", start_s: 2000, end_s: 2100, final: false },
|
||||
{ type: "credits", start_s: 2900, end_s: 2995, final: true },
|
||||
],
|
||||
} as unknown as PlexItemDetail;
|
||||
const { qc } = mount({
|
||||
details: { A: twoCredits, B: item("B") },
|
||||
sessions: { A: () => Promise.resolve(direct("A")), B: () => new Promise(() => {}) },
|
||||
queue: ["A", "B"],
|
||||
});
|
||||
const setState = vi.spyOn(api, "plexSetState").mockResolvedValue(undefined as never);
|
||||
await playingAt("/media/A", 2010);
|
||||
await tick();
|
||||
expect(screen.queryByText("Title B")).toBeNull();
|
||||
expect(qc.getQueryData<PlexItemDetail>(qk.plexItem("B", "en"))).toBeUndefined();
|
||||
fireEvent.click(screen.getByRole("button", { name: /plex\.player\.skipCredits/ }));
|
||||
await waitFor(() => expect(startsAsked("A").slice(-1)).toEqual([2100])); // past the block, still A
|
||||
expect(screen.queryByText("Title B")).toBeNull();
|
||||
expect(setState).not.toHaveBeenCalled();
|
||||
await playingAt("/media/A", 2910); // the final block: the auto-skip moves on
|
||||
await screen.findByText("Title B");
|
||||
expect(setState).toHaveBeenCalledWith("A", "watched");
|
||||
});
|
||||
|
||||
it("does not leave on its own at credits Plex did not flag final, but its Skip does", async () => {
|
||||
prefs({ autoSkipCredits: true, autoSkipDelay: 0 });
|
||||
const unflagged = {
|
||||
...item("A"),
|
||||
markers: [{ type: "credits", start_s: 2900, end_s: 2995, final: false }],
|
||||
} as unknown as PlexItemDetail;
|
||||
mount({
|
||||
details: { A: unflagged, B: item("B") },
|
||||
sessions: { A: () => Promise.resolve(direct("A")), B: () => new Promise(() => {}) },
|
||||
queue: ["A", "B"],
|
||||
});
|
||||
const setState = vi.spyOn(api, "plexSetState").mockResolvedValue(undefined as never);
|
||||
await playingAt("/media/A", 2910);
|
||||
await tick();
|
||||
expect(screen.queryByText("Title B")).toBeNull();
|
||||
fireEvent.click(screen.getByRole("button", { name: /plex\.player\.skipCredits/ }));
|
||||
await screen.findByText("Title B");
|
||||
expect(setState).toHaveBeenCalledWith("A", "watched");
|
||||
});
|
||||
|
||||
it("records a second viewing when the viewer plays on from the finished frame", async () => {
|
||||
// `finished` is a latch that makes every checkpoint a no-op; the only thing that lowers it is
|
||||
// the video's own 'play' event. Nothing tested that, so dropping the listener would have
|
||||
@@ -502,11 +591,18 @@ describe("restarting the session before playback has ticked", () => {
|
||||
{ ord: 1, label: "Magyar", language: "hu", default: false },
|
||||
],
|
||||
} as unknown as PlexItemDetail;
|
||||
mount({ details: { A: twoAudio }, sessions: { A: () => new Promise(() => {}) } });
|
||||
const answers = [deferred(), deferred(), deferred()];
|
||||
let calls = 0;
|
||||
mount({ details: { A: twoAudio }, sessions: { A: () => answers[calls++]!.promise } });
|
||||
await waitFor(() => expect(startsAsked("A")).toEqual([1200]));
|
||||
fireEvent.keyDown(window, { key: "a" });
|
||||
// One request per item in flight: the switch waits for the start's answer…
|
||||
await act(async () => answers[0]!.resolve(direct("A")));
|
||||
await waitFor(() => expect(startsAsked("A")).toHaveLength(2));
|
||||
fireEvent.keyDown(window, { key: "ArrowRight" });
|
||||
await act(async () => answers[1]!.resolve(direct("A")));
|
||||
await waitFor(() => expect(startsAsked("A")).toHaveLength(3));
|
||||
// …and each restart asked where the resume put the viewer, not at the unticked 0:00.
|
||||
expect(startsAsked("A")).toEqual([1200, 1200, 1210]);
|
||||
});
|
||||
});
|
||||
@@ -529,12 +625,41 @@ it("a seek made while a restart is pending wins over it", async () => {
|
||||
await playingAt("/media/A", 95);
|
||||
fireEvent.keyDown(document.body, { key: "ArrowRight" }); // the 10 s step // 105: past the buffer → restart
|
||||
fireEvent.keyDown(document.body, { key: "ArrowLeft" }); // 95: inside the OLD buffer
|
||||
await waitFor(() => expect(startsAsked("A")).toEqual([0, 105, 95]));
|
||||
await waitFor(() => expect(startsAsked("A")).toEqual([0, 105]));
|
||||
await tick();
|
||||
expect(startsAsked("A")).toEqual([0, 105]); // 95 waits for the 105 answer: one request in flight
|
||||
await act(async () => pending.resolve({ mode: "direct", url: "/media/A2", start_s: 0 } as never));
|
||||
await waitFor(() => expect(startsAsked("A")).toEqual([0, 105, 95]));
|
||||
await tick();
|
||||
expect(video().getAttribute("src")).toBe("/media/A"); // the superseded 105 answer never attached
|
||||
});
|
||||
|
||||
it("a burst of seeks past the buffer asks the server once more, not once per press", async () => {
|
||||
// Twenty presses of ← at key-repeat pace past the buffer used to be twenty session requests —
|
||||
// twenty ffmpeg spawns on the server — and five seconds before the picture moved again (measured
|
||||
// on localdev, 2026-10-06).
|
||||
let calls = 0;
|
||||
const restart = deferred();
|
||||
mount({
|
||||
details: { A: item("A", 1200) },
|
||||
sessions: {
|
||||
A: () => {
|
||||
calls++;
|
||||
if (calls === 1) return Promise.resolve(direct("A"));
|
||||
return calls === 2 ? restart.promise : new Promise(() => {});
|
||||
},
|
||||
},
|
||||
});
|
||||
await waitFor(() => expect(video()?.getAttribute("src")).toBe("/media/A"));
|
||||
await playingAt("/media/A", 1200);
|
||||
for (let i = 0; i < 20; i++) fireEvent.keyDown(document.body, { key: "ArrowLeft" });
|
||||
await tick();
|
||||
expect(startsAsked("A")).toEqual([1200, 1190]);
|
||||
await act(async () => restart.resolve(direct("A")));
|
||||
// The queued target is the LAST press's, asked once.
|
||||
await waitFor(() => expect(startsAsked("A")).toEqual([1200, 1190, 1000]));
|
||||
});
|
||||
|
||||
it("a direct-play item left before its metadata does not seek the next one", async () => {
|
||||
mount({
|
||||
details: { A: item("A", 1200), B: item("B") },
|
||||
@@ -645,3 +770,38 @@ it("a session answer that lands while the next item's detail is still loading is
|
||||
await tick();
|
||||
expect(video().getAttribute("src")).not.toBe("/media/B");
|
||||
});
|
||||
|
||||
describe("the server session the player no longer wants", () => {
|
||||
const hlsA = { mode: "hls", url: "/hls/A/index.m3u8", start_s: 0, id: "sid-A" } as Awaited<
|
||||
ReturnType<typeof api.plexSession>
|
||||
>;
|
||||
|
||||
it("is stopped by its item and its own id when the page is unloaded", async () => {
|
||||
const stop = vi.spyOn(api, "plexStopSession").mockImplementation(() => {});
|
||||
mount({ details: { A: item("A") }, sessions: { A: () => Promise.resolve(hlsA) } });
|
||||
await waitFor(() => expect(video()?.getAttribute("src")).toBe("/hls/A/index.m3u8"));
|
||||
act(
|
||||
() => void window.dispatchEvent(Object.assign(new Event("pagehide"), { persisted: false })),
|
||||
);
|
||||
expect(stop).toHaveBeenCalledWith("A", "sid-A");
|
||||
});
|
||||
|
||||
it("is stopped the same way when the player closes", async () => {
|
||||
const stop = vi.spyOn(api, "plexStopSession").mockImplementation(() => {});
|
||||
const { unmount } = mount({
|
||||
details: { A: item("A") },
|
||||
sessions: { A: () => Promise.resolve(hlsA) },
|
||||
});
|
||||
await waitFor(() => expect(video()?.getAttribute("src")).toBe("/hls/A/index.m3u8"));
|
||||
unmount();
|
||||
expect(stop).toHaveBeenCalledWith("A", "sid-A");
|
||||
});
|
||||
|
||||
it("is kept when the page only goes into the back/forward cache", async () => {
|
||||
const stop = vi.spyOn(api, "plexStopSession").mockImplementation(() => {});
|
||||
mount({ details: { A: item("A") }, sessions: { A: () => Promise.resolve(hlsA) } });
|
||||
await waitFor(() => expect(video()?.getAttribute("src")).toBe("/hls/A/index.m3u8"));
|
||||
act(() => void window.dispatchEvent(Object.assign(new Event("pagehide"), { persisted: true })));
|
||||
expect(stop).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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")),
|
||||
]
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { type RefObject, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type Hls from "hls.js";
|
||||
import { X } from "lucide-react";
|
||||
import { buildStatRows, type FragMeter, type HlsLevelInfo, type StatRow } from "../lib/plexStats";
|
||||
import type { HlsEngine } from "../lib/hlsEngine";
|
||||
import { buildStatRows, type FragMeter, type StatRow } from "../lib/plexStats";
|
||||
import { IconButton } from "./ui/IconButton";
|
||||
|
||||
// "Stats for nerds" debug overlay. Samples the live HTML5 <video> + hls.js state once a second
|
||||
@@ -23,29 +23,24 @@ function frameCounts(v: HTMLVideoElement | null): { dropped: number; total: numb
|
||||
|
||||
export function PlexStatsOverlay({
|
||||
videoRef,
|
||||
hlsRef,
|
||||
engine,
|
||||
fragMeterRef,
|
||||
audioLabel,
|
||||
subLabel,
|
||||
playable,
|
||||
hwRef,
|
||||
tonemapRef,
|
||||
sessionStartRef,
|
||||
absRef,
|
||||
durationRef,
|
||||
onClose,
|
||||
}: {
|
||||
videoRef: RefObject<HTMLVideoElement | null>;
|
||||
hlsRef: RefObject<Hls | null>;
|
||||
// The session machine: the hls.js level, the transcode encoder and the session's start offset.
|
||||
engine: Pick<HlsEngine, "levelInfo" | "hw" | "tonemap" | "sessionStart">;
|
||||
// Rolling FRAG_LOADED meter owned by the player (it lives as long as the hls.js instance, not as
|
||||
// long as this panel) — the source of the MEASURED bitrate row.
|
||||
fragMeterRef: RefObject<FragMeter>;
|
||||
audioLabel?: string; // selected audio track label, as the tracks menu names it
|
||||
subLabel?: string; // selected subtitle track label (or the menu's "Off" entry)
|
||||
playable: string;
|
||||
hwRef: RefObject<boolean | null>; // active transcode encoder: true=GPU, false=CPU, null=not transcode
|
||||
tonemapRef: RefObject<boolean | null>; // HW HDR→SDR tonemap engaged (adds a "· HDR→SDR" mode tag)
|
||||
sessionStartRef: RefObject<number>;
|
||||
absRef: RefObject<number>;
|
||||
durationRef: RefObject<number>;
|
||||
onClose: () => void;
|
||||
@@ -57,7 +52,6 @@ export function PlexStatsOverlay({
|
||||
useEffect(() => {
|
||||
const sample = () => {
|
||||
const v = videoRef.current;
|
||||
const hls = hlsRef.current;
|
||||
|
||||
const { dropped, total } = frameCounts(v);
|
||||
|
||||
@@ -72,29 +66,15 @@ export function PlexStatsOverlay({
|
||||
}
|
||||
}
|
||||
|
||||
let level: HlsLevelInfo | null = null;
|
||||
if (hls) {
|
||||
// currentLevel is -1 while auto-ABR hasn't settled; fall back to loadLevel for the res/bitrate.
|
||||
const idx = hls.currentLevel >= 0 ? hls.currentLevel : hls.loadLevel;
|
||||
const lv = idx >= 0 ? hls.levels?.[idx] : undefined;
|
||||
level = {
|
||||
index: hls.currentLevel,
|
||||
total: hls.levels?.length ?? 0,
|
||||
width: lv?.width,
|
||||
height: lv?.height,
|
||||
bitrate: lv?.bitrate,
|
||||
videoCodec: lv?.videoCodec,
|
||||
audioCodec: lv?.audioCodec,
|
||||
};
|
||||
}
|
||||
const level = engine.levelInfo();
|
||||
|
||||
setRows(
|
||||
buildStatRows({
|
||||
lang,
|
||||
playable,
|
||||
hwEncoder: hwRef.current,
|
||||
tonemap: tonemapRef.current,
|
||||
transport: hls ? "hls" : "direct",
|
||||
hwEncoder: engine.hw,
|
||||
tonemap: engine.tonemap,
|
||||
transport: level ? "hls" : "direct",
|
||||
videoWidth: v?.videoWidth ?? 0,
|
||||
videoHeight: v?.videoHeight ?? 0,
|
||||
droppedFrames: dropped,
|
||||
@@ -104,7 +84,7 @@ export function PlexStatsOverlay({
|
||||
measuredBps: fragMeterRef.current?.bitrate(),
|
||||
audioLabel,
|
||||
subLabel,
|
||||
sessionStartS: sessionStartRef.current ?? 0,
|
||||
sessionStartS: engine.sessionStart.current,
|
||||
abs: absRef.current ?? 0,
|
||||
duration: durationRef.current ?? 0,
|
||||
}),
|
||||
@@ -113,20 +93,7 @@ export function PlexStatsOverlay({
|
||||
sample();
|
||||
const iv = setInterval(sample, 1000);
|
||||
return () => clearInterval(iv);
|
||||
}, [
|
||||
lang,
|
||||
videoRef,
|
||||
hlsRef,
|
||||
fragMeterRef,
|
||||
audioLabel,
|
||||
subLabel,
|
||||
playable,
|
||||
hwRef,
|
||||
tonemapRef,
|
||||
sessionStartRef,
|
||||
absRef,
|
||||
durationRef,
|
||||
]);
|
||||
}, [lang, videoRef, engine, fragMeterRef, audioLabel, subLabel, playable, absRef, durationRef]);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -254,7 +254,7 @@ function JobRow({
|
||||
{job.running
|
||||
? t("scheduler.runningNow")
|
||||
: job.last_finished
|
||||
? t("scheduler.lastRun", { time: relativeTime(job.last_finished, lang) })
|
||||
? t("scheduler.lastRun", { time: relativeTime(job.last_finished, lang, t) })
|
||||
: t("scheduler.neverRun")}
|
||||
{job.last_error ? (
|
||||
<span className="text-danger">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -235,7 +235,7 @@ function LinkShare({ job }: { job: DownloadJob }) {
|
||||
queryFn: () => api.downloadLinks(job.id),
|
||||
});
|
||||
const [creating, setCreating] = useState(false);
|
||||
// D1: downloadable by default — matches the one-click Share (which relies on the backend
|
||||
// Downloadable by default — matches the one-click Share (which relies on the backend
|
||||
// default) and the common "let them have the file" intent. The owner can uncheck it here.
|
||||
const [allowDownload, setAllowDownload] = useState(true);
|
||||
const [expiryDays, setExpiryDays] = useState<number | null>(null);
|
||||
@@ -260,7 +260,7 @@ function LinkShare({ job }: { job: DownloadJob }) {
|
||||
});
|
||||
|
||||
// Open with a link already generated: sharing is almost always the goal, so mint a default
|
||||
// (no password, no expiry, downloadable per D1) link the moment the dialog finds none — as if the
|
||||
// (no password, no expiry, downloadable) link the moment the dialog finds none — as if the
|
||||
// user had pressed "New link" → "Create". All further controls (password, download toggle, expiry,
|
||||
// extra links, revoke) stay available on the created link and via the "New link" button.
|
||||
const autoTried = useRef(false);
|
||||
|
||||
@@ -81,7 +81,9 @@ describe("UpNextCard", () => {
|
||||
expect(onAdvance).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("an episode heading falls back to S?·E? when season/episode are null", async () => {
|
||||
// An episode Plex has no number for (a special) is named by its title alone everywhere — a "?"
|
||||
// told the viewer nothing, and the player's own header already shows the title alone.
|
||||
it("an episode heading drops the numbers when Plex has none for it", async () => {
|
||||
(api.plexItem as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
id: "e7",
|
||||
kind: "episode",
|
||||
@@ -91,6 +93,8 @@ describe("UpNextCard", () => {
|
||||
episode_number: null,
|
||||
});
|
||||
renderCard();
|
||||
expect(await screen.findByText("Westworld · S?·E?")).toBeTruthy();
|
||||
expect(await screen.findByText("Westworld")).toBeTruthy();
|
||||
expect(screen.getByText("The Adversary")).toBeTruthy();
|
||||
expect(screen.queryByText(/\?/)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user