Compare 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).
|
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
|
## 0.100.0 — 2026-10-05
|
||||||
|
|
||||||
### New
|
### New
|
||||||
|
|||||||
@@ -65,7 +65,8 @@ how it gets built. Concretely:
|
|||||||
`downloads/`, `youtube/`, `issues/` (shared by routes + scheduler + worker) → flat core (`auth`, `quota`,
|
`downloads/`, `youtube/`, `issues/` (shared by routes + scheduler + worker) → flat core (`auth`, `quota`,
|
||||||
`sysconfig`, `state`, `audit`, `notifications`, `userscope`, `ratelimit`, `bodylimit`, `progress`, `collation`,
|
`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.**
|
`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
|
components → the **platform layer `components/ui/`** (domain-free primitives: the popover engine and
|
||||||
its menus, `Modal`/`Overlay`, `Button`/`form`, `DataTable`/`VirtualGrid`, `QueryState`, …). That
|
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
|
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 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 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 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 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 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])` |
|
| 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.
|
- **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.
|
- **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
|
- **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
|
presets, per-user storage quota), trim / crop / split & join them in a built-in editor, play them
|
||||||
to your device, share with another user, or hand out a public watch link.
|
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
|
- **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.
|
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.
|
- **Multi-user** with per-user private state, a shared catalog, and a fair daily API-quota guard.
|
||||||
|
|||||||
@@ -743,14 +743,6 @@
|
|||||||
"lineCount": 1
|
"lineCount": 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"code": "reportArgumentType",
|
|
||||||
"range": {
|
|
||||||
"startColumn": 37,
|
|
||||||
"endColumn": 47,
|
|
||||||
"lineCount": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"code": "reportArgumentType",
|
"code": "reportArgumentType",
|
||||||
"range": {
|
"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 --------------------------------------------------------------------
|
# --- 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]:
|
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.
|
"""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:
|
if start:
|
||||||
cmd += ["-ss", f"{start:.3f}"]
|
cmd += ["-ss", f"{start:.3f}"]
|
||||||
cmd += ["-i", str(src)]
|
cmd += [*INPUT_GUARD, "-i", str(src)]
|
||||||
if end is not None:
|
if end is not None:
|
||||||
dur = end - start
|
dur = end - start
|
||||||
if dur > 0:
|
if dur > 0:
|
||||||
@@ -222,21 +267,9 @@ def build_edit_cmd(src: Path, dest: Path, spec: dict, out_ext: str) -> list[str]
|
|||||||
|
|
||||||
if reencode:
|
if reencode:
|
||||||
# Input -ss + re-encode is frame-accurate (decodes from the prior keyframe, discards up to
|
# 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.
|
# the exact cut). A crop adds the filter; an accurate trim only evens the frame.
|
||||||
if crop:
|
cropf = f"crop={crop['w']}:{crop['h']}:{crop['x']}:{crop['y']}," if crop else ""
|
||||||
cmd += ["-vf", f"crop={crop['w']}:{crop['h']}:{crop['x']}:{crop['y']}"]
|
cmd += ["-vf", cropf + EVEN_FRAME, *X264_ARGS, *AAC_ARGS]
|
||||||
cmd += [
|
|
||||||
"-c:v",
|
|
||||||
"libx264",
|
|
||||||
"-preset",
|
|
||||||
"veryfast",
|
|
||||||
"-crf",
|
|
||||||
"20",
|
|
||||||
"-c:a",
|
|
||||||
"aac",
|
|
||||||
"-b:a",
|
|
||||||
"192k",
|
|
||||||
]
|
|
||||||
else:
|
else:
|
||||||
cmd += ["-c", "copy", "-avoid_negative_ts", "make_zero"]
|
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 = [], []
|
parts, labels = [], []
|
||||||
for i, s in enumerate(segs):
|
for i, s in enumerate(segs):
|
||||||
st, en = s["start_s"], s["end_s"]
|
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}]")
|
parts.append(f"[0:a]atrim=start={st}:end={en},asetpts=PTS-STARTPTS[a{i}]")
|
||||||
labels.append(f"[v{i}][a{i}]")
|
labels.append(f"[v{i}][a{i}]")
|
||||||
parts.append(f"{''.join(labels)}concat=n={len(segs)}:v=1:a=1[v][a]")
|
parts.append(f"{''.join(labels)}concat=n={len(segs)}:v=1:a=1[v][a]")
|
||||||
cmd = head + [
|
cmd = head + [
|
||||||
|
*INPUT_GUARD,
|
||||||
"-i",
|
"-i",
|
||||||
str(src),
|
str(src),
|
||||||
"-filter_complex",
|
"-filter_complex",
|
||||||
@@ -297,26 +333,23 @@ def build_concat_plan(src: Path, dest: Path, spec: dict, out_ext: str, staging:
|
|||||||
"[v]",
|
"[v]",
|
||||||
"-map",
|
"-map",
|
||||||
"[a]",
|
"[a]",
|
||||||
"-c:v",
|
*X264_ARGS,
|
||||||
"libx264",
|
*AAC_ARGS,
|
||||||
"-preset",
|
|
||||||
"veryfast",
|
|
||||||
"-crf",
|
|
||||||
"20",
|
|
||||||
"-c:a",
|
|
||||||
"aac",
|
|
||||||
"-b:a",
|
|
||||||
"192k",
|
|
||||||
]
|
]
|
||||||
else:
|
else:
|
||||||
listp = staging / "concat.txt"
|
listp = staging / "concat.txt"
|
||||||
lines = []
|
lines = ["ffconcat version 1.0"]
|
||||||
for s in segs:
|
for s in segs:
|
||||||
lines.append(f"file '{_concat_quote(src)}'")
|
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"inpoint {s['start_s']}")
|
||||||
lines.append(f"outpoint {s['end_s']}")
|
lines.append(f"outpoint {s['end_s']}")
|
||||||
prep[listp] = "\n".join(lines) + "\n"
|
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"):
|
if out_ext in ("mp4", "m4a", "mov"):
|
||||||
cmd += ["-movflags", "+faststart"]
|
cmd += ["-movflags", "+faststart"]
|
||||||
@@ -474,14 +507,7 @@ def run_ffmpeg(
|
|||||||
raise StatedFailure(f"ffmpeg failed ({ret}): {''.join(err_tail)[-300:]}")
|
raise StatedFailure(f"ffmpeg failed ({ret}): {''.join(err_tail)[-300:]}")
|
||||||
|
|
||||||
|
|
||||||
# --- ffmpeg: universal browser playability --------------------------------------------------
|
# --- ffprobe ---------------------------------------------------------------------------------
|
||||||
# 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")
|
|
||||||
|
|
||||||
|
|
||||||
def run_ffprobe_json(
|
def run_ffprobe_json(
|
||||||
@@ -493,10 +519,10 @@ def run_ffprobe_json(
|
|||||||
) -> dict | None:
|
) -> dict | None:
|
||||||
"""One ffprobe call → its parsed JSON dict, or None on any failure (timeout, non-zero exit,
|
"""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
|
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`,
|
package: the shared media probe (`probe.probe_json`: the stored facts and the phone-safe rule) goes
|
||||||
used by both the download worker's finalize and the upload ingest) both go through it, so the
|
through it, so the
|
||||||
subprocess flags/timeout/error handling live in one place instead of drifting copies."""
|
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:
|
if show_streams:
|
||||||
args.append("-show_streams")
|
args.append("-show_streams")
|
||||||
if show_format:
|
if show_format:
|
||||||
@@ -517,35 +543,6 @@ def run_ffprobe_json(
|
|||||||
return None
|
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:
|
def probe_duration(path: Path) -> int:
|
||||||
"""Media duration in whole seconds via ffprobe, or 0 if unknown — a progress denominator for a
|
"""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)."""
|
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",
|
"ffprobe",
|
||||||
"-v",
|
"-v",
|
||||||
"error",
|
"error",
|
||||||
|
*INPUT_GUARD,
|
||||||
"-show_entries",
|
"-show_entries",
|
||||||
"format=duration",
|
"format=duration",
|
||||||
"-of",
|
"-of",
|
||||||
@@ -577,49 +575,6 @@ def probe_duration(path: Path) -> int:
|
|||||||
return 0
|
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) ---------------------------------------------------------
|
# --- filmstrip (editor scrub track) ---------------------------------------------------------
|
||||||
|
|
||||||
_SB_COLS = 12
|
_SB_COLS = 12
|
||||||
@@ -655,6 +610,7 @@ def build_storyboard_cmd(src: Path, dest: Path, duration_s: int | None, geom: di
|
|||||||
"-loglevel",
|
"-loglevel",
|
||||||
"error",
|
"error",
|
||||||
"-nostdin",
|
"-nostdin",
|
||||||
|
*INPUT_GUARD,
|
||||||
"-i",
|
"-i",
|
||||||
str(src),
|
str(src),
|
||||||
"-frames:v",
|
"-frames:v",
|
||||||
@@ -678,6 +634,7 @@ def build_poster_cmd(src: Path, dest: Path, at_s: float) -> list[str]:
|
|||||||
"-nostdin",
|
"-nostdin",
|
||||||
"-ss",
|
"-ss",
|
||||||
f"{max(0.0, at_s):.3f}",
|
f"{max(0.0, at_s):.3f}",
|
||||||
|
*INPUT_GUARD,
|
||||||
"-i",
|
"-i",
|
||||||
str(src),
|
str(src),
|
||||||
"-frames:v",
|
"-frames:v",
|
||||||
@@ -699,6 +656,7 @@ def build_thumbnail_jpg_cmd(src: Path, dest: Path) -> list[str]:
|
|||||||
"-loglevel",
|
"-loglevel",
|
||||||
"error",
|
"error",
|
||||||
"-nostdin",
|
"-nostdin",
|
||||||
|
*THUMB_GUARD,
|
||||||
"-i",
|
"-i",
|
||||||
str(src),
|
str(src),
|
||||||
"-frames:v",
|
"-frames:v",
|
||||||
@@ -734,7 +692,7 @@ def _step_failure(err: BaseException) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _is_jpeg(path: Path) -> bool:
|
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:
|
try:
|
||||||
with path.open("rb") as f:
|
with path.open("rb") as f:
|
||||||
return f.read(3) == b"\xff\xd8\xff"
|
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
|
# constants, imported above; each has a rule below that gives its category back, and a catalog entry
|
||||||
# that gives the site's sentence.
|
# that gives the site's sentence.
|
||||||
QUOTA_FULL_MESSAGE = "Your download storage is full — free up space, then try again."
|
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."
|
NETWORK_REDACTED_MESSAGE = "The server could not reach that address."
|
||||||
|
|
||||||
# (marker, category) — first match wins, so order most-specific → most-general. Matching is
|
# (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
|
# 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.
|
# "no longer available" below, so it must be matched first.
|
||||||
("source download is no longer available", "source_missing"),
|
("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"),
|
("source file is missing", "source_missing"),
|
||||||
# Private / deleted / removed / terminated.
|
# Private / deleted / removed / terminated.
|
||||||
("private video", "unavailable"),
|
("private video", "unavailable"),
|
||||||
@@ -263,6 +266,15 @@ def classify(msg: str | None) -> str:
|
|||||||
return "unknown"
|
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(
|
def diagnose(
|
||||||
msg: str | None, *, url: str | None = None, cookie_attached: bool = False
|
msg: str | None, *, url: str | None = None, cookie_attached: bool = False
|
||||||
) -> Diagnosis:
|
) -> 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
|
# 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
|
# 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.
|
# 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
|
# 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
|
# 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.
|
# 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
|
s["embed_subs"] = False
|
||||||
# Browser-playability guarantee: the Download module exists to download → share → PLAY
|
# 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
|
# 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
|
# universally-safe combo is mp4 + H.264 + AAC (an .mp3/.m4a for audio), so we CLAMP the spec to
|
||||||
# point every path (picker, stored profile, raw API) flows through — instead of only hiding
|
# it HERE — the single point every path (picker, stored profile, raw API) flows through — instead
|
||||||
# options in the UI. Non-mp4 containers (mkv doesn't play at all; webm/VP9/AV1 fail iOS) and any
|
# of only hiding options in the UI. Non-mp4 containers (mkv plays nowhere on iOS; AV1 fails there,
|
||||||
# explicit vcodec are coerced to the compat profile; Opus audio (Safari-flaky as a bare file)
|
# and webm/VP9 plays only on recent devices) and any explicit vcodec are coerced to the compat
|
||||||
# falls back to AAC/m4a. A source that exposes no H.264 is still caught by the worker's
|
# profile; Opus audio falls back to AAC/m4a. A source that exposes no H.264 (or a 10-bit one) is
|
||||||
# browser-safe re-encode (`_ensure_browser_playable`), so playback is guaranteed end-to-end.
|
# still caught by the worker's phone-safe step (`downloads.playable`), so playback is guaranteed
|
||||||
|
# end-to-end.
|
||||||
s["container"] = "mp4"
|
s["container"] = "mp4"
|
||||||
s["vcodec"] = None
|
s["vcodec"] = None
|
||||||
if s["audio_format"] not in ("m4a", "mp3"):
|
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
|
# 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
|
# 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
|
# 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":
|
if s["mode"] == "v":
|
||||||
# Video-only: keep it audio-free. Prefer an H.264 video-only stream, then any video-only,
|
# 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.
|
# 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
|
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.
|
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
|
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
|
4. Orphan reap — delete errored, fileless, unreferenced asset rows (they carry no TTL, so
|
||||||
passes 1-3 skip them; benign row-count hygiene).
|
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
|
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
|
re-download renames an asset, e.g. old accented channel dirs, or an upload's stranded file).
|
||||||
anything newer than an hour so an in-flight download is never touched.
|
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.
|
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 datetime import datetime, timedelta, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, or_, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app import logs, sysconfig
|
from app import logs, sysconfig
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.downloads import storage
|
from app.downloads import quota, storage
|
||||||
from app.models import DownloadJob, MediaAsset
|
from app.models import DownloadJob, MediaAsset
|
||||||
from app.notifications import create_notification
|
from app.notifications import create_notification
|
||||||
|
|
||||||
log = logs.get_logger(__name__)
|
log = logs.get_logger(__name__)
|
||||||
# Dot-prefixed top-level trees that are NOT part of the Plex/flat asset layout — the orphan sweep
|
# The one dot-prefixed top-level tree the orphan sweep enters. The others are not part of the asset
|
||||||
# never touches them (user clips, Plex HLS/img caches, in-progress staging, editor storyboards).
|
# layout (user clips, Plex HLS/img caches, in-progress staging, editor storyboards) and are skipped.
|
||||||
_SYSTEM_TREE = {".edits", ".plex-hls", ".plex-img-cache", ".staging", ".storyboards"}
|
_UPLOAD_TREE = ".uploads"
|
||||||
_ORPHAN_MIN_AGE_S = 3600 # only sweep files older than this, so an in-flight download is never hit
|
_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:
|
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
|
"""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
|
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
|
channel dir, or a poster from a superseded naming scheme) — or, under `.uploads`, that no upload
|
||||||
and only removes files older than `_ORPHAN_MIN_AGE_S`, so a just-completed or in-flight download
|
row names at all. Skips the other dot-prefixed system trees and only removes files older than
|
||||||
is never deleted, and only files NOT owned by a surviving asset."""
|
`_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)
|
root = Path(download_root)
|
||||||
if not root.is_dir():
|
if not root.is_dir():
|
||||||
return 0
|
return 0
|
||||||
keep: set[Path] = set()
|
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(
|
for rel in db.execute(
|
||||||
select(MediaAsset.rel_path).where(
|
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():
|
).scalars():
|
||||||
media = (root / rel).resolve()
|
media = (root / rel).resolve()
|
||||||
@@ -99,7 +104,10 @@ def _sweep_orphan_files(db: Session, download_root: str) -> int:
|
|||||||
|
|
||||||
unreclaimed = 0
|
unreclaimed = 0
|
||||||
for entry in root.iterdir():
|
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
|
continue
|
||||||
files = [entry] if entry.is_file() else [p for p in entry.rglob("*") if p.is_file()]
|
files = [entry] if entry.is_file() else [p for p in entry.rglob("*") if p.is_file()]
|
||||||
for f in files:
|
for f in files:
|
||||||
@@ -188,17 +196,17 @@ def run_download_gc(db: Session) -> dict:
|
|||||||
if cap > 0:
|
if cap > 0:
|
||||||
total = (
|
total = (
|
||||||
db.execute(
|
db.execute(
|
||||||
select(func.coalesce(func.sum(MediaAsset.size_bytes), 0)).where(
|
select(func.coalesce(func.sum(MediaAsset.size_bytes), 0)).where(quota.ON_DISK)
|
||||||
MediaAsset.status == "ready"
|
|
||||||
)
|
|
||||||
).scalar()
|
).scalar()
|
||||||
or 0
|
or 0
|
||||||
)
|
)
|
||||||
if total > cap:
|
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 = (
|
candidates = (
|
||||||
db.execute(
|
db.execute(
|
||||||
select(MediaAsset)
|
select(MediaAsset)
|
||||||
.where(MediaAsset.status == "ready")
|
.where(MediaAsset.status == "ready", MediaAsset.source_kind != "upload")
|
||||||
.order_by(MediaAsset.last_access_at.asc().nulls_first())
|
.order_by(MediaAsset.last_access_at.asc().nulls_first())
|
||||||
)
|
)
|
||||||
.scalars()
|
.scalars()
|
||||||
@@ -232,6 +240,29 @@ def run_download_gc(db: Session) -> dict:
|
|||||||
if orphans:
|
if orphans:
|
||||||
db.commit()
|
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
|
# 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.
|
# posters). Runs last so the keep-set reflects the assets that survived passes 2-4.
|
||||||
swept = _sweep_orphan_files(db, settings.download_root)
|
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)
|
return "\n ".join(tags)
|
||||||
|
|
||||||
|
|
||||||
def render_watch_html(token: str, index_html: Path, db: Session) -> str | None:
|
def render_watch_html(token: str, page_html: Path, db: Session) -> str | None:
|
||||||
"""Return index.html with the OG block replaced for this watch token, or None to serve the
|
"""Return the watch page (watch.html) with the OG block replaced for this token, or None to
|
||||||
generic page unchanged (invalid/expired/not-ready link, or a template without the markers)."""
|
serve the generic page unchanged (invalid/expired/not-ready link, or a template without the
|
||||||
|
markers)."""
|
||||||
tags = _watch_tags(token, db)
|
tags = _watch_tags(token, db)
|
||||||
if tags is None:
|
if tags is None:
|
||||||
return 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:
|
if "<!--OG:START-->" not in text:
|
||||||
return None
|
return None
|
||||||
# A function replacement avoids re.sub interpreting backslashes/group refs in `tags`.
|
# 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.db import SessionLocal
|
||||||
from app.downloads import edit as editmod
|
from app.downloads import edit as editmod
|
||||||
from app.downloads import errors as dlerrors
|
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 import urlguard
|
||||||
from app.downloads.jobdb import (
|
from app.downloads.jobdb import (
|
||||||
_edit_staging_dir,
|
_edit_staging_dir,
|
||||||
@@ -58,6 +59,10 @@ _DB_WRITE_INTERVAL_S = 1.0
|
|||||||
_CANCEL_POLL_INTERVAL_S = 1.0
|
_CANCEL_POLL_INTERVAL_S = 1.0
|
||||||
|
|
||||||
_THUMB_SUFFIXES = (".jpg", ".jpeg", ".png", ".webp")
|
_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
|
# 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
|
# 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
|
# 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
|
"""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
|
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
|
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
|
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."""
|
caller BEFORE its commit, so reading them here needs no post-commit refresh of an expired row."""
|
||||||
if user_id is None:
|
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:
|
def _ensure_phone_safe(
|
||||||
"""Make an mp4-compat-profile download play in every browser: H.264 + AAC/MP3 in mp4. If the
|
job_id: int,
|
||||||
download (whatever the source shipped) isn't already that, re-encode the offending stream(s) —
|
produced: Path,
|
||||||
or just remux when the codecs are fine but the container isn't. **Scope:** only the mp4 compat
|
out_dir: Path,
|
||||||
profile is normalized; an explicit `vcodec` or a non-mp4 custom profile is the user opting out
|
phase: str,
|
||||||
of the guarantee and is left as-is (so a `webm` profile can still ship VP9/Opus). Returns the
|
total_s: int | None = None,
|
||||||
file to store (converted, or the original) and corrects `info`'s codec fields to match disk.
|
*,
|
||||||
|
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 ready path calls this — a download, an upload's worker conversion and an editor clip — so
|
||||||
every browser plays as audio/mpeg / audio/mp4. Remuxing it into .mp4 served an mp3 as video/mp4,
|
a share link can only ever serve the one shape every phone and browser plays."""
|
||||||
which iPad's WebKit refuses to play (prod job 268)."""
|
p = playable.plan(probe.probe_json(produced), produced.suffix)
|
||||||
if spec.get("mode") == "a" or spec.get("container") != "mp4" or spec.get("vcodec"):
|
if p is None or not p.needs_change:
|
||||||
return produced
|
return produced
|
||||||
vcodec, acodec = editmod.probe_codecs(produced)
|
dest = out_dir / f"{produced.stem}.safe.{p.ext}"
|
||||||
is_mp4 = produced.suffix.lower() == ".mp4"
|
action = "re-encode" if (p.reencode_video or p.reencode_audio) else "remux"
|
||||||
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"
|
|
||||||
log.info(
|
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)
|
_set_job(job_id, phase=phase, 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
|
# A re-encode with no known length would hold a frozen 0%; probe the file for a denominator.
|
||||||
# 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)
|
|
||||||
editmod.run_ffmpeg(
|
editmod.run_ffmpeg(
|
||||||
editmod.build_browser_safe_cmd(
|
playable.build_cmd(produced, dest, p, threads=editmod.encode_thread_cap()),
|
||||||
produced, dest, reenc_v, reenc_a, threads=editmod.encode_thread_cap()
|
total_s=total_s or editmod.probe_duration(produced),
|
||||||
),
|
on_progress=_ffmpeg_progress_writer(job_id, phase),
|
||||||
total_s=total_s,
|
|
||||||
on_progress=_ffmpeg_progress_writer(job_id, "processing"),
|
|
||||||
should_cancel=_cancel_poll(job_id),
|
should_cancel=_cancel_poll(job_id),
|
||||||
)
|
)
|
||||||
|
if not dest.exists():
|
||||||
|
raise StatedFailure("ffmpeg produced no output file")
|
||||||
|
if not keep_source:
|
||||||
produced.unlink(missing_ok=True)
|
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"
|
|
||||||
return dest
|
return dest
|
||||||
|
|
||||||
|
|
||||||
@@ -522,7 +529,13 @@ def _download(
|
|||||||
adapter = adapters.resolve_adapter(source_kind, source_ref, info)
|
adapter = adapters.resolve_adapter(source_kind, source_ref, info)
|
||||||
|
|
||||||
produced = _produced_file(info, staging)
|
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()
|
ext = produced.suffix.lstrip(".").lower()
|
||||||
# Codecs/dims/duration come from a PROBE of the produced file — the ground truth — NOT yt-dlp's
|
# 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
|
# `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:
|
def _fail_edit(
|
||||||
"""Fail an edit job, and say so ONCE — every way an edit fails comes through here, so this is
|
job_id: int, asset_id: int, msg: str, exc: BaseException | None = None, *, what: str = "edit"
|
||||||
where its line and its event code live. `exc` is the exception when there was one.
|
) -> 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`).
|
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."""
|
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,
|
job_id,
|
||||||
status=JobStatus.ERROR,
|
status=JobStatus.ERROR,
|
||||||
error=msg,
|
error=msg,
|
||||||
error_code=dlerrors.classify(msg),
|
error_code=dlerrors.classify_conversion(msg)
|
||||||
|
if what == "upload"
|
||||||
|
else dlerrors.classify(msg),
|
||||||
phase=None,
|
phase=None,
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
# `msg` is the user's copy; ffmpeg's stderr tail in it can name the source file, whose path is
|
# `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.
|
# built from the channel and the title.
|
||||||
log.warning(
|
log.warning(
|
||||||
"edit job %s failed: %s",
|
"%s job %s failed: %s",
|
||||||
|
what,
|
||||||
job_id,
|
job_id,
|
||||||
formats.log_safe(msg),
|
formats.log_safe(msg),
|
||||||
extra={
|
extra={
|
||||||
**logs.event(
|
**logs.event(
|
||||||
"edit.job.failed",
|
f"{what}.job.failed",
|
||||||
job_id=job_id,
|
job_id=job_id,
|
||||||
asset_id=asset_id,
|
asset_id=asset_id,
|
||||||
**({"error_type": type(exc).__name__} if exc is not None else {}),
|
**({"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():
|
if not dest_staging.exists():
|
||||||
raise StatedFailure("ffmpeg produced no output file")
|
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)
|
rel = storage.edit_rel_path(user_id, asset_id, out_ext)
|
||||||
storage.place_file(dest_staging, settings.download_root, rel)
|
storage.place_file(dest_staging, settings.download_root, rel)
|
||||||
final = storage.abs_path(settings.download_root, rel)
|
final = storage.abs_path(settings.download_root, rel)
|
||||||
size = final.stat().st_size if final.exists() else None
|
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()
|
_, retention_days = _download_settings()
|
||||||
expires = datetime.now(timezone.utc) + timedelta(days=retention_days)
|
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.size_bytes = size
|
||||||
asset.container = out_ext
|
asset.container = out_ext
|
||||||
asset.duration_s = total_s or asset.duration_s
|
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.width = edit_spec["crop"]["w"]
|
||||||
asset.height = edit_spec["crop"]["h"]
|
asset.height = edit_spec["crop"]["h"]
|
||||||
else:
|
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)
|
_fail_edit(job_id, asset_id, str(exc)[:500], exc)
|
||||||
finally:
|
finally:
|
||||||
shutil.rmtree(staging, ignore_errors=True)
|
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(
|
def _facts_from_probe(
|
||||||
data: dict,
|
data: dict,
|
||||||
) -> tuple[str | None, str | None, int | None, int | None, int | None]:
|
) -> 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(",")}
|
fmt_names = {n.strip() for n in (fmt.get("format_name") or "").split(",")}
|
||||||
if fmt_names & _IMAGE_FORMATS:
|
if fmt_names & _IMAGE_FORMATS:
|
||||||
return None, None, None, None, None
|
return None, None, None, None, None
|
||||||
|
video, audio = primary_streams(data)
|
||||||
vcodec = acodec = None
|
vcodec = video.get("codec_name") if video else None
|
||||||
width = height = None
|
width = video.get("width") if video else None
|
||||||
for stream in data.get("streams", []):
|
height = video.get("height") if video else None
|
||||||
kind = stream.get("codec_type")
|
acodec = audio.get("codec_name") if audio else None
|
||||||
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")
|
|
||||||
|
|
||||||
duration = None
|
duration = None
|
||||||
try:
|
try:
|
||||||
@@ -71,16 +85,29 @@ def _facts_from_probe(
|
|||||||
return vcodec, acodec, width, height, duration
|
return vcodec, acodec, width, height, duration
|
||||||
|
|
||||||
|
|
||||||
def probe_media(path: Path) -> tuple[str | None, str | None, int | None, int | None, int | None]:
|
def probe_json(path: Path) -> dict | None:
|
||||||
"""(vcodec, acodec, width, height, duration_s) from one ffprobe call; all None if ffprobe fails
|
"""The full ffprobe JSON (streams + format) of `path`, or None when ffprobe could not read it —
|
||||||
or the file isn't playable media (an unprobeable file — or a still image — is treated as a generic
|
the one call both the stored facts (`facts`) and the phone-safe rule read from."""
|
||||||
non-media file, so it becomes a download-only 'file' rather than a broken 'video')."""
|
return editmod.run_ffprobe_json(path, show_streams=True, show_format=True)
|
||||||
data = 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:
|
if data is None:
|
||||||
return None, None, None, None, None
|
return None, None, None, None, None
|
||||||
return _facts_from_probe(data)
|
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(
|
def resolve_media_facts(
|
||||||
path: Path, info: dict
|
path: Path, info: dict
|
||||||
) -> tuple[str | None, str | None, int | None, int | None, int | None]:
|
) -> 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
|
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
|
`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),
|
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
|
mislabelling an audio download as a broken in-app 'video'. When ffprobe could NOT read the file
|
||||||
file at all do we fall back to `info` (the pre-probe behaviour, a safety net); the cosmetic
|
at all, no codec is reported: the probe runs with the media demuxer whitelist, so a file it
|
||||||
dimensions and the duration still gap-fill from `info` in either case."""
|
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)
|
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_w = int(info["width"]) if info.get("width") else None
|
||||||
info_h = int(info["height"]) if info.get("height") else None
|
info_h = int(info["height"]) if info.get("height") else None
|
||||||
info_d = int(info["duration"]) if info.get("duration") else None
|
info_d = int(info["duration"]) if info.get("duration") else None
|
||||||
if data is None:
|
if data is None:
|
||||||
# ffprobe could not read the file — fall back to the extractor's info entirely.
|
# Unreadable to the guarded probe: a download-only 'file' (no codecs → media_kind FILE).
|
||||||
return info_v, info_a, info_w, info_h, info_d
|
return None, None, info_w, info_h, info_d
|
||||||
pv, pa, pw, ph, pd = _facts_from_probe(data)
|
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
|
# 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.
|
# gap-fills the width/height/duration the probe couldn't read.
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
"""Per-user download limits + footprint accounting.
|
"""Per-user download limits + footprint accounting.
|
||||||
|
|
||||||
A user's *footprint* is the total size of the ready assets their library references (cache hits
|
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.
|
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.
|
`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 dataclasses import dataclass
|
||||||
|
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import and_, func, or_, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app import sysconfig
|
from app import sysconfig
|
||||||
|
from app.downloads.status import JobStatus
|
||||||
from app.models import DownloadJob, DownloadQuota, MediaAsset
|
from app.models import DownloadJob, DownloadQuota, MediaAsset
|
||||||
|
|
||||||
# Jobs a user is considered to be "holding" (occupy a job slot / footprint). Terminal failures
|
# Jobs a user is considered to be "holding" (occupy a job slot / footprint). Terminal failures
|
||||||
# (error, canceled) don't count.
|
# (error, canceled) don't count.
|
||||||
_HOLDING = ("queued", "running", "paused", "done")
|
_HOLDING = (JobStatus.QUEUED, JobStatus.RUNNING, JobStatus.PAUSED, JobStatus.DONE)
|
||||||
_RUNNING = ("queued", "running")
|
_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
|
@dataclass
|
||||||
@@ -67,12 +77,17 @@ def resolve(db: Session, user_id: int) -> QuotaLimits:
|
|||||||
|
|
||||||
|
|
||||||
def footprint(db: Session, user_id: int) -> int:
|
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 = (
|
held_assets = (
|
||||||
select(DownloadJob.asset_id)
|
select(DownloadJob.asset_id)
|
||||||
.where(
|
.where(
|
||||||
DownloadJob.user_id == user_id,
|
DownloadJob.user_id == user_id,
|
||||||
|
or_(
|
||||||
DownloadJob.status.in_(_HOLDING),
|
DownloadJob.status.in_(_HOLDING),
|
||||||
|
and_(DownloadJob.job_kind == "upload", DownloadJob.status == JobStatus.ERROR),
|
||||||
|
),
|
||||||
DownloadJob.asset_id.is_not(None),
|
DownloadJob.asset_id.is_not(None),
|
||||||
)
|
)
|
||||||
.distinct()
|
.distinct()
|
||||||
@@ -80,7 +95,7 @@ def footprint(db: Session, user_id: int) -> int:
|
|||||||
return int(
|
return int(
|
||||||
db.execute(
|
db.execute(
|
||||||
select(func.coalesce(func.sum(MediaAsset.size_bytes), 0)).where(
|
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()
|
).scalar()
|
||||||
or 0
|
or 0
|
||||||
@@ -111,7 +126,7 @@ def at_concurrency_limit(db: Session, user_id: int) -> bool:
|
|||||||
lim = resolve(db, user_id)
|
lim = resolve(db, user_id)
|
||||||
if lim.unlimited:
|
if lim.unlimited:
|
||||||
return False
|
return False
|
||||||
running = _count(db, user_id, ("running",))
|
running = _count(db, user_id, (JobStatus.RUNNING,))
|
||||||
return running >= lim.max_concurrent
|
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)
|
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:
|
def usage(db: Session, user_id: int) -> dict:
|
||||||
"""Compact usage snapshot for the UI (footprint + counts vs limits)."""
|
"""Compact usage snapshot for the UI (footprint + counts vs limits)."""
|
||||||
lim = resolve(db, user_id)
|
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`)."""
|
(`cause=child_exit`)."""
|
||||||
# Imported here (not at module top) so the child pays the yt-dlp import cost only when it has
|
# 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.
|
# 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):
|
with formats.hiding(source_ref):
|
||||||
try:
|
try:
|
||||||
if job_kind == "edit":
|
if job_kind == "edit":
|
||||||
_process_edit(job_id, asset_id)
|
_process_edit(job_id, asset_id)
|
||||||
|
elif job_kind == "upload":
|
||||||
|
_convert_upload(job_id, asset_id)
|
||||||
else:
|
else:
|
||||||
_download(job_id, asset_id, source_kind, source_ref, spec, user_id)
|
_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
|
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
|
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).
|
+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
|
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:
|
if not job.asset_id or job.status not in HOLDING:
|
||||||
return
|
return
|
||||||
asset = db.get(MediaAsset, job.asset_id)
|
asset = db.get(MediaAsset, job.asset_id)
|
||||||
if asset is None:
|
if asset is None:
|
||||||
return
|
return
|
||||||
new_count = decr_ref_count(db, asset.id)
|
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:
|
if asset.rel_path:
|
||||||
storage.delete_asset_files(settings.download_root, asset.rel_path)
|
storage.delete_asset_files(settings.download_root, asset.rel_path)
|
||||||
db.delete(asset)
|
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
|
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
|
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
|
preserved (shell-safe, bounded) so the served file + its Content-Disposition carry a correct
|
||||||
suffix; an extensionless upload stays extensionless."""
|
suffix; an extensionless upload stays extensionless.
|
||||||
stem = sanitize(Path(filename).stem, 80)
|
|
||||||
|
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)
|
ext = _clean_upload_ext(filename)
|
||||||
suffix = f".{ext}" if ext else ""
|
suffix = f".{ext}" if ext else ""
|
||||||
return f".uploads/{user_id}/{stem}_a{asset_id}{suffix}"
|
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:
|
def upload_ext(filename: str) -> str | None:
|
||||||
"""The stored `container` for an uploaded file: its shell-safe lowercased extension, or None when
|
"""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
|
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
|
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
|
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
|
download it bypasses yt-dlp: the file is already on the host, so ingest probes it and places it on
|
||||||
probes it, places it on disk, and creates an already-`ready` MediaAsset + `done` DownloadJob in one
|
disk. A file that already is the phone-safe shape (`playable`) — or is not media at all — becomes an
|
||||||
step — the same "the file exists, mark it done now" shortcut `service.enqueue` takes on a cache hit.
|
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
|
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
|
-> 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
|
inline (see the force-download serving in the file/watch routes).
|
||||||
future sub-function (e.g. convert) plugs a new adapter into, mirroring how it sidesteps the worker.
|
|
||||||
|
|
||||||
An upload does NOT get an `expires_at`: it's user-provided and may be irreplaceable (there's no
|
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
|
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.config import settings
|
||||||
from app.downloads import edit as editmod
|
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
|
from app.models import DownloadJob, MediaAsset, display_text
|
||||||
|
|
||||||
|
|
||||||
@@ -44,15 +54,22 @@ def ingest_upload(
|
|||||||
size: int,
|
size: int,
|
||||||
display_name: str | None = None,
|
display_name: str | None = None,
|
||||||
) -> DownloadJob:
|
) -> DownloadJob:
|
||||||
"""Ingest an already-uploaded file at `src_path` into `user_id`'s library and return the `done`
|
"""Ingest an already-uploaded file at `src_path` into `user_id`'s library and return its job —
|
||||||
job. `src_path` is MOVED into place on success (the caller stages it to a temp file first).
|
`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
|
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."""
|
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
|
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)
|
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,
|
# 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.
|
# 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)
|
rel = storage.upload_rel_path(user_id, asset.id, filename)
|
||||||
storage.place_file(src_path, settings.download_root, rel)
|
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
|
# 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
|
# asset/job rows roll back and nothing names the placed file: the per-user delete path keys off
|
||||||
# per-user delete path keys off asset.rel_path (which is gone), and the GC orphan sweep skips the
|
# asset.rel_path (which is gone), and the GC's `.uploads` sweep reaches it only an hour later. So
|
||||||
# dot-prefixed `.uploads/` tree. So roll back the on-disk side too on any failure.
|
# roll back the on-disk side too — unless the commit itself raised after it landed (see below).
|
||||||
|
asset_id = asset.id
|
||||||
try:
|
try:
|
||||||
final = storage.abs_path(settings.download_root, rel)
|
final = storage.abs_path(settings.download_root, rel)
|
||||||
actual_size = final.stat().st_size if final.exists() else size
|
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.rel_path = rel
|
||||||
asset.size_bytes = actual_size
|
asset.size_bytes = actual_size
|
||||||
asset.container = storage.upload_ext(filename)
|
asset.container = ext or None
|
||||||
asset.vcodec = vcodec
|
asset.vcodec = vcodec
|
||||||
asset.acodec = acodec
|
asset.acodec = acodec
|
||||||
asset.width = width
|
asset.width = width
|
||||||
@@ -100,8 +118,8 @@ def ingest_upload(
|
|||||||
source_ref=ref,
|
source_ref=ref,
|
||||||
profile_snapshot={},
|
profile_snapshot={},
|
||||||
display_name=display_text(display_name),
|
display_name=display_text(display_name),
|
||||||
status="done",
|
status=JobStatus.QUEUED if convert else JobStatus.DONE,
|
||||||
progress=100,
|
progress=0 if convert else 100,
|
||||||
queue_pos=service.next_queue_pos(db),
|
queue_pos=service.next_queue_pos(db),
|
||||||
)
|
)
|
||||||
db.add(job)
|
db.add(job)
|
||||||
@@ -109,6 +127,11 @@ def ingest_upload(
|
|||||||
db.commit()
|
db.commit()
|
||||||
except Exception:
|
except Exception:
|
||||||
db.rollback()
|
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
|
# 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
|
# 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
|
# 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
|
raise
|
||||||
db.refresh(job)
|
db.refresh(job)
|
||||||
return 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
|
# 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).
|
# hashed bundles under /assets can cache forever (a new build changes their filename).
|
||||||
INDEX_HTML = STATIC_DIR / "index.html"
|
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"}
|
INDEX_HEADERS = {"Cache-Control": "no-cache"}
|
||||||
app.mount(
|
app.mount(
|
||||||
"/assets",
|
"/assets",
|
||||||
@@ -354,16 +356,16 @@ async def index() -> FileResponse:
|
|||||||
|
|
||||||
@app.get("/watch/{token}")
|
@app.get("/watch/{token}")
|
||||||
async def watch_page(token: str):
|
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
|
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
|
ignores the extra tags and boots WatchPage; invalid/expired/password links fall back to the
|
||||||
back to the generic card."""
|
generic card."""
|
||||||
from app.downloads import og
|
from app.downloads import og
|
||||||
|
|
||||||
with SessionLocal() as db:
|
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:
|
if rendered is None:
|
||||||
return FileResponse(INDEX_HTML, headers=INDEX_HEADERS)
|
return FileResponse(WATCH_HTML, headers=INDEX_HEADERS)
|
||||||
return HTMLResponse(rendered, 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
|
# 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.
|
# stable names, so a moderate cache is safe and satisfies the cache-lifetime audit.
|
||||||
return FileResponse(candidate, headers={"Cache-Control": "public, max-age=604800"})
|
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)
|
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))
|
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):
|
class SearchFind(Base, TimestampMixin):
|
||||||
"""Per-user record of a video the user surfaced via their own live YouTube search.
|
"""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_kind: Mapped[str] = mapped_column(String(16)) # "youtube" | "url"
|
||||||
source_ref: Mapped[str] = mapped_column(String(512))
|
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")
|
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).
|
# 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(
|
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
|
# 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`.
|
# 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))
|
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)
|
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;
|
# 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
|
# `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)
|
keys = ",".join(str(k) for k in rating_keys)
|
||||||
return f"server://{self.machine_id}/com.plexapp.plugins.library/library/metadata/{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:
|
def create_collection(
|
||||||
"""Create a (dumb) collection seeded with one item; returns its Metadata (incl. ratingKey)."""
|
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="")
|
uri = quote(self._metadata_uri(item_rating_key), safe="")
|
||||||
mc = self._write(
|
mc = self._write(
|
||||||
"POST",
|
"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}",
|
f"&title={quote(title, safe='')}&uri={uri}",
|
||||||
)
|
)
|
||||||
items = mc.get("Metadata", []) or []
|
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
|
# `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
|
# 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
|
# 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
|
# registered session is no longer `replace_only`, we abort and return None, and the caller
|
||||||
# whoever is registered now). The INITIAL spawn never gets None — see its call site.
|
# 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
|
# 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).
|
# remux ignore preset/crf/max_height, and remux must not touch the DB for them).
|
||||||
preset, crf, max_height = "veryfast", 20, 0
|
preset, crf, max_height = "veryfast", 20, 0
|
||||||
@@ -840,12 +841,13 @@ def start_session(
|
|||||||
)
|
)
|
||||||
respawned = _spawn(False, False, replace_only=s)
|
respawned = _spawn(False, False, replace_only=s)
|
||||||
if respawned is None:
|
if respawned is None:
|
||||||
# Superseded/culled mid-fallback — yield whoever is registered now (a newer seek-restart), or
|
# Superseded/culled mid-fallback — answer with our own now-dead session. Never the newer one
|
||||||
# our own now-dead session if the key was cap-culled. Never None here: the POST /session route
|
# registered meanwhile: this answer goes to the request that LOST, whose player drops it as
|
||||||
# turns a None return into a "Media file not found on disk" 404 — dishonest for a file that
|
# stale and stops the session it names (`stop_session`), which stopped the playback that won.
|
||||||
# exists. A (dead) session instead degrades to a retryable 404 ("No active playback session" /
|
# Never None either: the POST /session route turns a None return into a "Media file not found
|
||||||
# "Not available yet") the client re-requests.
|
# on disk" 404 — dishonest for a file that exists. A dead session instead degrades to a
|
||||||
return current_session(user_id, key) or s
|
# retryable 404 ("No active playback session" / "Not available yet") the client re-requests.
|
||||||
|
return s
|
||||||
s = respawned
|
s = respawned
|
||||||
ready = wait_for(_seg(0), timeout=25.0, proc=s.proc)
|
ready = wait_for(_seg(0), timeout=25.0, proc=s.proc)
|
||||||
if ready:
|
if ready:
|
||||||
@@ -1117,6 +1119,23 @@ def reap_idle(finished_idle_s: float = _FINISHED_IDLE_S) -> int:
|
|||||||
return dropped
|
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:
|
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
|
"""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
|
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 typing import IO, Any, TypeVar
|
||||||
|
|
||||||
from fastapi import HTTPException, Request
|
from fastapi import HTTPException, Request
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.db import Base
|
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.issues import gitea
|
||||||
from app.refusal import refused
|
from app.refusal import refused
|
||||||
from app.youtube.client import YouTubeError, refusal_reason
|
from app.youtube.client import YouTubeError, refusal_reason
|
||||||
|
|
||||||
M = TypeVar("M", bound=Base)
|
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:
|
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
|
"""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 os
|
||||||
import re
|
import re
|
||||||
import tempfile
|
import tempfile
|
||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator, Sequence
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from pathlib import Path
|
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 import APIRouter, Depends, HTTPException, Request
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from pydantic import BaseModel
|
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 sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.mailtext import lang_for_user
|
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 quota, service, storage
|
||||||
from app.downloads import upload as uploadmod
|
from app.downloads import upload as uploadmod
|
||||||
from app.downloads import urlguard
|
from app.downloads import urlguard
|
||||||
|
from app.downloads.status import JobStatus
|
||||||
from app.models import (
|
from app.models import (
|
||||||
DownloadJob,
|
DownloadJob,
|
||||||
DownloadLink,
|
DownloadLink,
|
||||||
DownloadProfile,
|
DownloadProfile,
|
||||||
DownloadQuota,
|
DownloadQuota,
|
||||||
DownloadShare,
|
DownloadShare,
|
||||||
|
LibraryState,
|
||||||
MediaAsset,
|
MediaAsset,
|
||||||
User,
|
User,
|
||||||
display_text,
|
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.security import hash_password
|
||||||
from app.userscope import is_messageable_user, messageable_clauses
|
from app.userscope import is_messageable_user, messageable_clauses
|
||||||
from app.utils import iso
|
from app.utils import iso
|
||||||
@@ -131,8 +141,8 @@ def _serialize(job: DownloadJob, asset: MediaAsset | None) -> dict:
|
|||||||
"job_kind": job.job_kind,
|
"job_kind": job.job_kind,
|
||||||
"source_job_id": job.source_job_id,
|
"source_job_id": job.source_job_id,
|
||||||
"edit_spec": job.edit_spec,
|
"edit_spec": job.edit_spec,
|
||||||
"can_download": ready and job.status == "done",
|
"can_download": ready and job.status == JobStatus.DONE,
|
||||||
"expired": job.asset_id is None and job.status == "done",
|
"expired": job.asset_id is None and job.status == JobStatus.DONE,
|
||||||
"asset": None
|
"asset": None
|
||||||
if asset is None
|
if asset is None
|
||||||
else {
|
else {
|
||||||
@@ -164,6 +174,19 @@ def _assets_for(db: Session, jobs: list[DownloadJob]) -> dict[int, MediaAsset]:
|
|||||||
return {a.id: a for a in rows}
|
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:
|
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
|
"""Resolve a job's cache asset (if any) and serialize the pair — the resolve-then-serialize step
|
||||||
every single-job handler shares."""
|
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
|
(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
|
(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
|
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
|
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
|
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
|
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 file is only read, never modified, so editing a shared video is safe."""
|
||||||
source_job = _accessible_job(db, user, body.source_job_id)
|
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")
|
raise refused(409, "edit_unfinished")
|
||||||
spec = body.edit_spec
|
spec = body.edit_spec
|
||||||
display_name = display_text(body.display_name)
|
display_name = display_text(body.display_name)
|
||||||
@@ -571,7 +595,14 @@ def list_downloads(user: User = Depends(current_user), db: Session = Depends(get
|
|||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
assets = _assets_for(db, jobs)
|
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")
|
@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).
|
# 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")
|
@router.get("/index")
|
||||||
@@ -603,7 +639,7 @@ def my_download_index(user: User = Depends(current_user), db: Session = Depends(
|
|||||||
rows = db.execute(
|
rows = db.execute(
|
||||||
select(DownloadJob.source_ref, DownloadJob.status).where(
|
select(DownloadJob.source_ref, DownloadJob.status).where(
|
||||||
DownloadJob.user_id == user.id,
|
DownloadJob.user_id == user.id,
|
||||||
DownloadJob.status.in_(("queued", "running", "paused", "done")),
|
DownloadJob.status.in_(tuple(_INDEX_RANK)),
|
||||||
)
|
)
|
||||||
).all()
|
).all()
|
||||||
out: dict[str, str] = {}
|
out: dict[str, str] = {}
|
||||||
@@ -627,10 +663,12 @@ def shared_with_me(user: User = Depends(current_user), db: Session = Depends(get
|
|||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
assets = _assets_for(db, jobs)
|
assets = _assets_for(db, jobs)
|
||||||
|
positions = _positions_for(db, user, jobs)
|
||||||
out = []
|
out = []
|
||||||
for j in jobs:
|
for j in jobs:
|
||||||
data = _serialize(j, assets.get(j.asset_id))
|
data = _serialize(j, assets.get(j.asset_id))
|
||||||
data["shared"] = True
|
data["shared"] = True
|
||||||
|
data["position_seconds"] = positions.get(j.id, 0)
|
||||||
out.append(data)
|
out.append(data)
|
||||||
return out
|
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
|
# 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.
|
# source. Re-running yt-dlp against source_kind='upload'/'edit' would just fail.
|
||||||
raise refused(409, "not_redownloadable")
|
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")
|
raise refused(409, "still_downloading")
|
||||||
service.redownload(db, job)
|
service.redownload(db, job)
|
||||||
return _serialize_job(db, job)
|
return _serialize_job(db, job)
|
||||||
@@ -818,6 +856,39 @@ def download_file(
|
|||||||
# --- editor filmstrip (scrub track) --------------------------------------------------------
|
# --- 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")
|
@router.get("/{job_id}/storyboard")
|
||||||
def storyboard_meta(
|
def storyboard_meta(
|
||||||
job_id: int, user: User = Depends(current_user), db: Session = Depends(get_db)
|
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),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
job = owned_or_404(db, DownloadJob, job_id, user, what="download")
|
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")
|
raise refused(409, "share_unfinished")
|
||||||
target = body.email.strip().lower()
|
target = body.email.strip().lower()
|
||||||
if not target:
|
if not target:
|
||||||
@@ -1033,7 +1104,7 @@ def list_links(
|
|||||||
|
|
||||||
class CreateLinkIn(BaseModel):
|
class CreateLinkIn(BaseModel):
|
||||||
password: str = ""
|
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
|
# 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
|
# 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
|
# 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),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
job = owned_or_404(db, DownloadJob, job_id, user, what="download")
|
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")
|
raise refused(409, "share_unfinished")
|
||||||
pw = body.password.strip()
|
pw = body.password.strip()
|
||||||
link = DownloadLink(
|
link = DownloadLink(
|
||||||
@@ -1137,10 +1208,10 @@ def admin_list_downloads(
|
|||||||
|
|
||||||
@admin_router.get("/storage")
|
@admin_router.get("/storage")
|
||||||
def admin_storage(admin: User = Depends(admin_user), db: Session = Depends(get_db)) -> dict:
|
def admin_storage(admin: User = Depends(admin_user), db: Session = Depends(get_db)) -> dict:
|
||||||
ready = db.execute(
|
# Every file on disk (`quota.ON_DISK`): ready ones, and uploads waiting for or failed in their
|
||||||
select(func.count(), func.coalesce(func.sum(MediaAsset.size_bytes), 0)).where(
|
# conversion — the same disk the footprint and the GC cap count.
|
||||||
MediaAsset.status == "ready"
|
on_disk = db.execute(
|
||||||
)
|
select(func.count(), func.coalesce(func.sum(MediaAsset.size_bytes), 0)).where(quota.ON_DISK)
|
||||||
).one()
|
).one()
|
||||||
total_assets = db.execute(select(func.count()).select_from(MediaAsset)).scalar()
|
total_assets = db.execute(select(func.count()).select_from(MediaAsset)).scalar()
|
||||||
per_user = db.execute(
|
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()
|
for u in db.execute(select(User).where(User.id.in_([uid for uid, _ in per_user]))).scalars()
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
"ready_files": ready[0],
|
"files_on_disk": on_disk[0],
|
||||||
"total_bytes": int(ready[1]),
|
"total_bytes": int(on_disk[1]),
|
||||||
"total_assets": total_assets,
|
"total_assets": total_assets,
|
||||||
"total_cap_bytes": sysconfig.get_int(db, "download_total_max_bytes"),
|
"total_cap_bytes": sysconfig.get_int(db, "download_total_max_bytes"),
|
||||||
"per_user": [
|
"per_user": [
|
||||||
|
|||||||
+11
-34
@@ -1,7 +1,7 @@
|
|||||||
from datetime import date, datetime, timezone
|
from datetime import date, datetime, timezone
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query
|
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 import and_, func, or_, select
|
||||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
@@ -26,14 +26,19 @@ from app.feedquery import (
|
|||||||
windowed_relevance_rows,
|
windowed_relevance_rows,
|
||||||
)
|
)
|
||||||
from app.models import (
|
from app.models import (
|
||||||
POSITION_MAX_S,
|
|
||||||
ChannelTag,
|
ChannelTag,
|
||||||
Tag,
|
Tag,
|
||||||
User,
|
User,
|
||||||
Video,
|
Video,
|
||||||
VideoState,
|
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.sync.videos import parse_iso8601_duration
|
||||||
from app.utils import iso
|
from app.utils import iso
|
||||||
from app.youtube.client import YouTubeClient, YouTubeError
|
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.
|
# "saved" used to be a status; it's now membership in the built-in Watch later playlist.
|
||||||
VALID_STATES = {"new", "watched", "hidden"}
|
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.
|
# Shared query parameters for /feed and /feed/count.
|
||||||
def _feed_params(
|
def _feed_params(
|
||||||
@@ -326,23 +323,10 @@ def clear_video_state(
|
|||||||
return {"ok": True, "video_id": video_id, "status": "new", "position_seconds": 0}
|
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")
|
@router.post("/videos/{video_id}/progress")
|
||||||
def set_video_progress(
|
def set_video_progress(
|
||||||
video_id: str,
|
video_id: str,
|
||||||
body: VideoProgressIn,
|
body: ProgressIn,
|
||||||
user: User = Depends(current_user),
|
user: User = Depends(current_user),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> 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
|
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.
|
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."""
|
Trivially-early and near-finished positions clear the position rather than store it."""
|
||||||
position = int(body.position_seconds)
|
position = kept_position(body)
|
||||||
duration = int(body.duration_seconds)
|
|
||||||
if position < 0:
|
|
||||||
position = 0
|
|
||||||
|
|
||||||
get_or_404(db, Video, video_id, what="video")
|
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(
|
row = db.execute(
|
||||||
select(VideoState).where(VideoState.user_id == user.id, VideoState.video_id == video_id)
|
select(VideoState).where(VideoState.user_id == user.id, VideoState.video_id == video_id)
|
||||||
).scalar_one_or_none()
|
).scalar_one_or_none()
|
||||||
|
|
||||||
if not keep:
|
if not position:
|
||||||
# Nothing meaningful to store: drop a status-less row entirely, otherwise just
|
# Nothing meaningful to store: drop a status-less row entirely, otherwise just
|
||||||
# zero the position (a saved/hidden video keeps its status).
|
# zero the position (a saved/hidden video keeps its status).
|
||||||
if row is not None:
|
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):
|
class Member(NamedTuple):
|
||||||
"""One collection member flattened to everything the Sort control can order by. The page's two
|
"""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
|
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
|
seed = body.item_rating_key
|
||||||
if not title or not seed:
|
if not title or not seed:
|
||||||
raise refused(400, "collection_fields_required")
|
raise refused(400, "collection_fields_required")
|
||||||
|
seed_show = by_rating_key(db, PlexShow, seed)
|
||||||
if library:
|
if library:
|
||||||
lib = (
|
lib = (
|
||||||
db.execute(select(PlexLibrary).where(PlexLibrary.plex_key == library)).scalars().first()
|
db.execute(select(PlexLibrary).where(PlexLibrary.plex_key == library)).scalars().first()
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# The "New collection" flow only knows the seed title → derive its library.
|
# 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
|
lib = db.get(PlexLibrary, seed_item.library_id) if seed_item else None
|
||||||
if lib is None:
|
if lib is None:
|
||||||
raise not_found("library")
|
raise not_found("library")
|
||||||
_reject_duplicate_name(db, lib.id, title)
|
_reject_duplicate_name(db, lib.id, title)
|
||||||
wrote = False
|
wrote = False
|
||||||
|
new_rk = ""
|
||||||
try:
|
try:
|
||||||
with PlexClient(db) as plex:
|
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
|
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
|
col.editable = True
|
||||||
db.commit()
|
db.commit()
|
||||||
except (PlexError, PlexNotConfigured) as e:
|
except (PlexError, PlexNotConfigured) as e:
|
||||||
db.rollback()
|
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
|
raise _write_or_pending_error(wrote, e) from e
|
||||||
return _collection_card(col)
|
return _collection_card(col)
|
||||||
|
|
||||||
|
|||||||
@@ -404,6 +404,10 @@ def item_detail(
|
|||||||
"type": m.get("type"),
|
"type": m.get("type"),
|
||||||
"start_s": round((m.get("startTimeOffset") or 0) / 1000, 1),
|
"start_s": round((m.get("startTimeOffset") or 0) / 1000, 1),
|
||||||
"end_s": round((m.get("endTimeOffset") 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).
|
# Audio + subtitle tracks (ordinals among their stream type — used to -map the selection).
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ from fastapi import (
|
|||||||
Response,
|
Response,
|
||||||
)
|
)
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import select, update
|
from sqlalchemy import select, update
|
||||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||||
from sqlalchemy.orm import Session
|
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.auth import current_user, resolved_user_id
|
||||||
from app.db import SessionLocal, get_db
|
from app.db import SessionLocal, get_db
|
||||||
from app.models import (
|
from app.models import (
|
||||||
POSITION_MAX_S,
|
|
||||||
PlexItem,
|
PlexItem,
|
||||||
PlexSeason,
|
PlexSeason,
|
||||||
PlexShow,
|
PlexShow,
|
||||||
@@ -39,7 +38,7 @@ from app.plex import watch_sync as plex_watch
|
|||||||
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
|
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
|
||||||
from app.routes.plex._shared import item_or_404
|
from app.routes.plex._shared import item_or_404
|
||||||
from app.refusal import refused
|
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"])
|
router = APIRouter(prefix="/api/plex", tags=["plex"])
|
||||||
|
|
||||||
@@ -160,11 +159,11 @@ def subtitle(
|
|||||||
return Response(subtitles.shift_vtt(hit, offset), media_type="text/vtt")
|
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
|
# Resume/watched thresholds. _PROGRESS_MIN_S matches PROGRESS_MIN_SECONDS (5), but _FINISH_MARGIN_S is
|
||||||
# _FINISH_MARGIN_S is DELIBERATELY larger than the feed's FINISH_MARGIN_SECONDS (30 vs 10): this is
|
# DELIBERATELY larger than FINISH_MARGIN_SECONDS (30 vs 10): this is long-form content
|
||||||
# long-form content (movies/episodes with credits) and crossing it MARKS THE ITEM WATCHED, whereas
|
# (movies/episodes with credits) and crossing it MARKS THE ITEM WATCHED, whereas the feed's and the
|
||||||
# the feed's margin only clears a short video's resume bar — different domains, intentional divergence,
|
# library's margin only clears a resume bar — different domains, intentional divergence, not drift.
|
||||||
# not drift. Keep them separate on purpose (see routes/feed.py).
|
# Keep them separate on purpose (see routes/_common.py).
|
||||||
_PROGRESS_MIN_S = 5
|
_PROGRESS_MIN_S = 5
|
||||||
|
|
||||||
_FINISH_MARGIN_S = 30
|
_FINISH_MARGIN_S = 30
|
||||||
@@ -337,16 +336,9 @@ def _mark_finished(db: Session, user_id: int, item_id: int, now: datetime) -> bo
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
class ItemProgressIn(BaseModel):
|
class ItemProgressIn(ProgressIn):
|
||||||
# Finite only: JSON parsing here accepts `Infinity`/`NaN`, and the handler's `int()` turned
|
"""The shared checkpoint (its bounds included), plus whether the player is leaving the item."""
|
||||||
# one into a 500.
|
|
||||||
model_config = ConfigDict(allow_inf_nan=False)
|
|
||||||
|
|
||||||
# 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
|
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
|
# 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.
|
# 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}",
|
"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,
|
"transcode": s.transcode,
|
||||||
"hw": s.hw,
|
"hw": s.hw,
|
||||||
"tonemap": s.tonemap,
|
"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")
|
@router.get("/stream/{rating_key}/file")
|
||||||
def stream_file(
|
def stream_file(
|
||||||
rating_key: str,
|
rating_key: str,
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from app.config import settings
|
|||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.downloads import links as linksmod
|
from app.downloads import links as linksmod
|
||||||
from app.downloads import storage
|
from app.downloads import storage
|
||||||
|
from app.downloads.status import JobStatus
|
||||||
from app.models import DownloadJob, DownloadLink, MediaAsset
|
from app.models import DownloadJob, DownloadLink, MediaAsset
|
||||||
from app.ratelimit import RateLimiter, client_key
|
from app.ratelimit import RateLimiter, client_key
|
||||||
from app.security import verify_password
|
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]:
|
def _ready_target(db: Session, link: DownloadLink) -> tuple[DownloadJob, MediaAsset]:
|
||||||
job = db.get(DownloadJob, link.job_id)
|
job = db.get(DownloadJob, link.job_id)
|
||||||
asset = db.get(MediaAsset, job.asset_id) if job and job.asset_id else None
|
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:
|
if job is None or asset is None or asset.status != "ready" or not asset.rel_path:
|
||||||
raise refused(404, "video_gone")
|
raise refused(404, "video_gone")
|
||||||
# Verify the file is actually on disk here (not just flagged ready), so the watch page never
|
# 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.
|
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
|
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``
|
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
|
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.
|
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.config import settings
|
||||||
from app.downloads import edit as editmod
|
from app.downloads import edit as editmod
|
||||||
|
from app.downloads import playable
|
||||||
from app.downloads import runner
|
from app.downloads import runner
|
||||||
|
|
||||||
|
|
||||||
@@ -32,11 +33,12 @@ class TestEncodeThreadCap:
|
|||||||
assert editmod.encode_thread_cap() == 1 # max(1, 2 - 1)
|
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):
|
def test_threads_flag_added_when_capped(self):
|
||||||
cmd = editmod.build_browser_safe_cmd(
|
cmd = playable.build_cmd("/in.mkv", "/out.mp4", _REENCODE_VIDEO, threads=2)
|
||||||
"/in.mkv", "/out.mp4", reencode_video=True, reencode_audio=False, threads=2
|
|
||||||
)
|
|
||||||
assert "-threads" in cmd
|
assert "-threads" in cmd
|
||||||
assert cmd[cmd.index("-threads") + 1] == "2"
|
assert cmd[cmd.index("-threads") + 1] == "2"
|
||||||
# The cap precedes the output file, so it binds the encoder (not input decoding).
|
# 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])
|
@pytest.mark.parametrize("threads", [0, None])
|
||||||
def test_no_threads_flag_when_uncapped(self, threads):
|
def test_no_threads_flag_when_uncapped(self, threads):
|
||||||
cmd = editmod.build_browser_safe_cmd(
|
cmd = playable.build_cmd("/in.mkv", "/out.mp4", _REENCODE_VIDEO, threads=threads)
|
||||||
"/in.mkv", "/out.mp4", reencode_video=True, reencode_audio=False, threads=threads
|
|
||||||
)
|
|
||||||
assert "-threads" not in cmd
|
assert "-threads" not in cmd
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ with "PosixPath('.') has an empty name" instead of downloading."""
|
|||||||
|
|
||||||
import pytest
|
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 app.downloads.pipeline import _media_info, _produced_file
|
||||||
from pathlib import Path, PurePosixPath
|
from pathlib import Path, PurePosixPath
|
||||||
|
|
||||||
@@ -105,46 +105,9 @@ class TestMediaInfo:
|
|||||||
assert _media_info(info)["id"] == "vid"
|
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:
|
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
|
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."""
|
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())
|
monkeypatch.setattr(subprocess, "run", lambda *a, **k: _R())
|
||||||
data = run_ffprobe_json(Path("x"), entries="stream=codec_type,codec_name")
|
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:
|
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"
|
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 -------------------------------------------
|
# --- 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:
|
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
|
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-
|
default), NOT in a migration: the DownloadLink column default stays False, so no existing back-
|
||||||
catalogue link silently flips to downloadable."""
|
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 plex.seen[-1].method == "POST"
|
||||||
assert params["title"] == "Best of: 90s" and params["sectionId"] == "3"
|
assert params["title"] == "Best of: 90s" and params["sectionId"] == "3"
|
||||||
assert params["uri"] == "server://abc/com.plexapp.plugins.library/library/metadata/42"
|
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"] = {}
|
plex.routes["/library/collections"] = {}
|
||||||
with pytest.raises(PlexError):
|
with pytest.raises(PlexError):
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ DB lane (`db` fixture) — skipped without Postgres.
|
|||||||
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
@@ -14,6 +15,7 @@ from app.db import get_db
|
|||||||
from app.main import app
|
from app.main import app
|
||||||
from app.models import PlexCollection, PlexItem, PlexLibrary, PlexShow, User
|
from app.models import PlexCollection, PlexItem, PlexLibrary, PlexShow, User
|
||||||
from app.plex.client import PlexError
|
from app.plex.client import PlexError
|
||||||
|
from app.plex.lookups import by_rating_key
|
||||||
from app.routes.plex import collections as coll_routes
|
from app.routes.plex import collections as coll_routes
|
||||||
|
|
||||||
|
|
||||||
@@ -224,8 +226,9 @@ class _CollStub:
|
|||||||
if self.fail_on == name:
|
if self.fail_on == name:
|
||||||
raise PlexError("boom")
|
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._maybe("create_collection")
|
||||||
|
self.created_type = item_type
|
||||||
return {"ratingKey": "NEW"}
|
return {"ratingKey": "NEW"}
|
||||||
|
|
||||||
def add_collection_item(self, rk, item):
|
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
|
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):
|
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 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".
|
# 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.status_code == 422, r.text
|
||||||
assert r.json()["detail"] == {"code": "refused", "reason": "collection_pending_sync"}
|
assert r.json()["detail"] == {"code": "refused", "reason": "collection_pending_sync"}
|
||||||
assert "create_collection" in stub.calls
|
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):
|
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),
|
("magyar Forced", "hu", True, False, None),
|
||||||
("English SDH", "en", False, True, 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["mode"] == "hls"
|
||||||
assert body["transcode"] is False
|
assert body["transcode"] is False
|
||||||
assert captured["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):
|
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
|
# 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
|
# 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.
|
# 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(
|
monkeypatch.setattr(
|
||||||
stream, "_select_encode", lambda db, item, transcode, src: (True, False)
|
stream, "_select_encode", lambda db, item, transcode, src: (True, False)
|
||||||
)
|
)
|
||||||
@@ -708,9 +710,12 @@ class TestTranscodeStartSession:
|
|||||||
|
|
||||||
newer = _Newer()
|
newer = _Newer()
|
||||||
|
|
||||||
|
captured = {}
|
||||||
|
|
||||||
def wait_then_supersede(path, timeout=20.0, proc=None):
|
def wait_then_supersede(path, timeout=20.0, proc=None):
|
||||||
# Simulate the concurrent replacement happening during our out-of-lock wait, then report the
|
# Simulate the concurrent replacement happening during our out-of-lock wait, then report the
|
||||||
# (now-killed) HW proc produced nothing.
|
# (now-killed) HW proc produced nothing.
|
||||||
|
captured["s"] = stream._sessions[(7, "12345")]
|
||||||
stream._sessions[(7, "12345")] = newer
|
stream._sessions[(7, "12345")] = newer
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -720,7 +725,7 @@ class TestTranscodeStartSession:
|
|||||||
)
|
)
|
||||||
# No SW respawn — only the initial HW _spawn ran; the newer session was left intact.
|
# No SW respawn — only the initial HW _spawn ran; the newer session was left intact.
|
||||||
assert len(_CapturingProc.calls) == 1
|
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
|
assert stream._sessions[(7, "12345")] is newer
|
||||||
|
|
||||||
def test_a_cap_culled_hw_session_does_not_respawn_and_is_not_reported_missing(
|
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)
|
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"])
|
@pytest.mark.parametrize("raw", ["Infinity", "-Infinity", "NaN"])
|
||||||
def test_a_non_finite_position_is_refused(client, path, raw):
|
def test_a_non_finite_position_is_refused(client, path, raw):
|
||||||
r = client.post(
|
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
|
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"])
|
@pytest.mark.parametrize("field", ["position_seconds", "duration_seconds"])
|
||||||
def test_a_position_past_the_int4_column_is_refused_not_a_500(client, path, field):
|
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."""
|
"""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
|
@pytest.fixture
|
||||||
def unlock_url(as_user, make_user, tmp_path, monkeypatch, upload_headers):
|
def unlock_url(as_user, make_user, tmp_path, monkeypatch, upload_headers):
|
||||||
monkeypatch.setattr(settings, "download_root", str(tmp_path))
|
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())
|
owner = as_user(make_user())
|
||||||
job = owner.post(
|
job = owner.post(
|
||||||
"/api/downloads/upload", content=b"fake-video", headers=upload_headers("clip.mp4")
|
"/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.channels import AttachTagIn, ChannelUpdateIn
|
||||||
from app.routes.downloads import AdminSetQuotaIn
|
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.setup import SetupAdminIn
|
||||||
from app.routes.tags import TagUpdateIn
|
from app.routes.tags import TagUpdateIn
|
||||||
|
|
||||||
@@ -52,10 +52,10 @@ def test_admin_quota_rejects_non_numeric_limit():
|
|||||||
def test_progress_accepts_fractional_seconds():
|
def test_progress_accepts_fractional_seconds():
|
||||||
# The player may post a raw currentTime (a float). The field must accept it (truncated in the
|
# 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.
|
# handler) — a plain `int` field would 422 on the progress hot path.
|
||||||
assert VideoProgressIn(position_seconds=12.7).position_seconds == 12.7
|
assert ProgressIn(position_seconds=12.7).position_seconds == 12.7
|
||||||
assert int(VideoProgressIn(position_seconds=12.7).position_seconds) == 12
|
assert int(ProgressIn(position_seconds=12.7).position_seconds) == 12
|
||||||
with pytest.raises(ValidationError):
|
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". ---
|
# --- 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
|
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
|
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`
|
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 ast
|
||||||
import asyncio
|
import asyncio
|
||||||
import io
|
import io
|
||||||
import pathlib
|
import pathlib
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -25,9 +28,11 @@ from app.config import settings
|
|||||||
from app.routes._common import capped_stream_to
|
from app.routes._common import capped_stream_to
|
||||||
from sqlalchemy import select
|
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 probe as probemod
|
||||||
from app.downloads import upload as uploadmod
|
from app.downloads import upload as uploadmod
|
||||||
|
from app.downloads.stated import StatedFailure
|
||||||
from app.models import DISPLAY_TEXT_MAX, DownloadJob, DownloadQuota, MediaAsset
|
from app.models import DISPLAY_TEXT_MAX, DownloadJob, DownloadQuota, MediaAsset
|
||||||
|
|
||||||
|
|
||||||
@@ -51,9 +56,20 @@ def _root(tmp_path, monkeypatch):
|
|||||||
|
|
||||||
|
|
||||||
def _probe(monkeypatch, result):
|
def _probe(monkeypatch, result):
|
||||||
# upload ingest now calls the shared `probe.probe_media` (same ground-truth ffprobe the download
|
"""Stub the shared ffprobe call (`probe.probe_json`, the same ground truth the download worker
|
||||||
# worker uses); patch it at that shared home.
|
reads) with the JSON a file of these (vcodec, acodec, width, height, duration) would give — its
|
||||||
monkeypatch.setattr(probemod, "probe_media", lambda _p: result)
|
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):
|
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(
|
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
|
"""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
|
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
|
assert r.status_code == 200, r.text
|
||||||
job = r.json()
|
job = r.json()
|
||||||
assert job["asset"]["media_kind"] == "video" and job["asset"]["container"] == "html"
|
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.
|
# Authenticated endpoint: never text/html.
|
||||||
fr = c.get(f"/api/downloads/{job['id']}/file")
|
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):
|
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
|
"""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
|
placed file + any (even partial) poster — otherwise nothing names it (the per-user delete keys
|
||||||
keys off the rolled-back asset.rel_path, and the GC orphan sweep skips the dot-prefixed .uploads/
|
off the rolled-back asset.rel_path) and it sits on disk until the GC's `.uploads` sweep. Exercises
|
||||||
tree). Exercises the rollback/cleanup branch that IS the leak remediation, incl. the poster case
|
the rollback/cleanup branch, incl. the poster case where ensure_poster left a partial file but
|
||||||
where ensure_poster left a partial file but returned None."""
|
returned None."""
|
||||||
_probe(monkeypatch, ("h264", "aac", 320, 240, 5)) # video → the poster path runs
|
_probe(monkeypatch, ("h264", "aac", 320, 240, 5)) # video → the poster path runs
|
||||||
u = make_user()
|
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
|
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) ------------------
|
# --- 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
|
# 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
|
# 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
|
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):
|
def test_a_file_the_guarded_probe_cannot_read_is_a_download_only_file(self, monkeypatch):
|
||||||
# ffprobe failed entirely (None) → fall back to info so we never regress the working path.
|
# 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)
|
self._ffprobe(monkeypatch, None)
|
||||||
v, a, w, h, _d = probemod.resolve_media_facts(
|
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.
|
# --- the phone-safe conversion (an iPhone's HEVC, a webm, an mkv) ------------------------------
|
||||||
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"}
|
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 job.status == "done" and db.get(MediaAsset, job.asset_id).status == "ready"
|
||||||
assert mediakind.derive(v, a) == mediakind.AUDIO
|
|
||||||
|
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
|
**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.
|
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
|
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
|
- **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
|
screen, and the description opens from an ⓘ button, because a hover popover never opens on a tap (measured
|
||||||
live). [WCAG 1.4.13]
|
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
|
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 ✕;
|
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,
|
`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
|
- **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
|
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
|
`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-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 | 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-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) |
|
| 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) |
|
| 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 |
|
| 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) |
|
| 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
|
## 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";
|
import { openApp, openFirstVideo } from "./helpers";
|
||||||
|
|
||||||
// Locks the in-app player's open/close contract. The player is a portaled overlay whose
|
// 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);
|
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",
|
"$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}"],
|
"project": ["**/*.{ts,tsx,js}"],
|
||||||
"playwright": {
|
"playwright": {
|
||||||
"config": ["playwright.config.ts"],
|
"config": ["playwright.config.ts"],
|
||||||
|
|||||||
@@ -2,23 +2,19 @@ import { useLayoutEffect, useState } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { qk } from "../lib/queryKeys";
|
import { qk } from "../lib/queryKeys";
|
||||||
import { ListPlus, Plus } from "lucide-react";
|
import { ListPlus } from "lucide-react";
|
||||||
import { YouTubeIcon } from "../lib/brandIcons";
|
import { YouTubeIcon } from "../lib/brandIcons";
|
||||||
import { MenuCheck } from "./ui/MenuCheck";
|
|
||||||
import { api, type Playlist } from "../lib/api";
|
import { api, type Playlist } from "../lib/api";
|
||||||
import { notifyActionError } from "../lib/actionErrors";
|
import { notifyActionError } from "../lib/actionErrors";
|
||||||
import { useScrollFade } from "../lib/useScrollFade";
|
|
||||||
import { playlistName } from "../lib/playlistName";
|
import { playlistName } from "../lib/playlistName";
|
||||||
import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover";
|
import { PopoverSurface } from "./ui/AnchoredPopover";
|
||||||
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
|
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
|
||||||
import { IconButton } from "./ui/IconButton";
|
import { IconButton } from "./ui/IconButton";
|
||||||
import { Input } from "./ui/Input";
|
import { MembershipMenu } from "./ui/MembershipMenu";
|
||||||
import { SURFACE } from "./ui/surfaces";
|
import { SURFACE } from "./ui/surfaces";
|
||||||
|
|
||||||
// A small anchored menu for toggling a video's membership in the user's playlists, with inline
|
// 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
|
// "new playlist" creation. Its body is the shared MembershipMenu (as the channel tag editor's).
|
||||||
// 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.
|
|
||||||
export default function AddToPlaylist({
|
export default function AddToPlaylist({
|
||||||
videoId,
|
videoId,
|
||||||
variant = "ghost",
|
variant = "ghost",
|
||||||
@@ -94,9 +90,7 @@ function MembershipPanel({
|
|||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const { ref: fadeRef, style: fadeStyle } = useScrollFade();
|
|
||||||
const open = pop.open;
|
const open = pop.open;
|
||||||
const [newName, setNewName] = useState("");
|
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
const membership = useQuery({
|
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
|
// 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
|
// 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) {
|
async function toggle(pl: Playlist) {
|
||||||
if (busy) return;
|
if (busy) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
if (pl.has_video) await api.removeFromPlaylist(pl.id, videoId);
|
if (pl.has_video) await api.removeFromPlaylist(pl.id, videoId);
|
||||||
else await api.addToPlaylist(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) {
|
} catch (e) {
|
||||||
notifyActionError(e, t(pl.has_video ? "playlists.removeFailed" : "playlists.addFailed"));
|
notifyActionError(e, t(pl.has_video ? "playlists.removeFailed" : "playlists.addFailed"));
|
||||||
}
|
}
|
||||||
@@ -129,19 +127,20 @@ function MembershipPanel({
|
|||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createAndAdd() {
|
async function createAndAdd(name: string) {
|
||||||
const name = newName.trim();
|
if (busy) return false;
|
||||||
if (!name || busy) return;
|
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
|
let made = false;
|
||||||
try {
|
try {
|
||||||
const pl = await api.createPlaylist(name);
|
const pl = await api.createPlaylist(name);
|
||||||
await api.addToPlaylist(pl.id, videoId);
|
await api.addToPlaylist(pl.id, videoId);
|
||||||
setNewName("");
|
made = true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
notifyActionError(e, t("playlists.addFailed"));
|
notifyActionError(e, t("playlists.addFailed"));
|
||||||
}
|
}
|
||||||
refresh();
|
refresh();
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
|
return made;
|
||||||
}
|
}
|
||||||
|
|
||||||
// All of the user's playlists are add targets, including YouTube-linked ones — adding
|
// All of the user's playlists are add targets, including YouTube-linked ones — adding
|
||||||
@@ -149,67 +148,40 @@ function MembershipPanel({
|
|||||||
const lists = membership.data ?? [];
|
const lists = membership.data ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<PopoverSurface
|
||||||
<PopoverSurface popover={pop} className={`${SURFACE.popover} w-60 p-2`}>
|
popover={pop}
|
||||||
{/* A React portal still propagates events up the REACT tree, so a click inside this panel
|
className={`${SURFACE.popover} w-60 p-2`}
|
||||||
would otherwise reach the feed card that hosts the trigger and start playback. */}
|
ariaLabel={t("playlists.addToPlaylistTitle")}
|
||||||
<div onClick={(e) => e.stopPropagation()}>
|
listInside
|
||||||
<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 && (
|
<MembershipMenu
|
||||||
<div className="text-xs text-muted px-1.5 py-2">{t("playlists.noneYet")}</div>
|
heading={t("playlists.addToPlaylistTitle")}
|
||||||
)}
|
rows={lists.map((pl) => ({
|
||||||
{lists.map((pl) => (
|
id: pl.id,
|
||||||
<PopoverItem
|
label: playlistName(pl, t),
|
||||||
key={pl.id}
|
checked: !!pl.has_video,
|
||||||
// A video can sit in several playlists at once — checkboxes, not one choice.
|
extra:
|
||||||
role="menuitemcheckbox"
|
pl.source === "youtube" || pl.yt_playlist_id ? (
|
||||||
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" />
|
<YouTubeIcon className="w-3 h-3 shrink-0 text-muted" />
|
||||||
)}
|
) : undefined,
|
||||||
</PopoverItem>
|
}))}
|
||||||
))}
|
onToggle={(row) => {
|
||||||
</div>
|
const pl = lists.find((p) => p.id === row.id);
|
||||||
<div className="flex items-center gap-1.5 border-t border-border mt-1.5 pt-1.5">
|
if (pl) void toggle(pl);
|
||||||
<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")}
|
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 }}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</PopoverSurface>
|
</PopoverSurface>
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ describe("the last sign-in column", () => {
|
|||||||
</QueryClientProvider>,
|
</QueryClientProvider>,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(await screen.findByText(relativeTime(at, "en"))).toBeTruthy();
|
expect(await screen.findByText(relativeTime(at, "en", i18n.t))).toBeTruthy();
|
||||||
expect(screen.getAllByText("—")).toHaveLength(1);
|
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">
|
<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>
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<MoreButton
|
<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 { useRef, useState } from "react";
|
||||||
import { Plus, Sparkles } from "lucide-react";
|
import { Plus, Sparkles } from "lucide-react";
|
||||||
import { MenuCheck } from "./ui/MenuCheck";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { type Tag } from "../lib/api";
|
import { type Tag } from "../lib/api";
|
||||||
import { PopoverSurface, PopoverItem } from "./ui/AnchoredPopover";
|
import { PopoverSurface } from "./ui/AnchoredPopover";
|
||||||
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
|
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
|
||||||
import { useScrollFade } from "../lib/useScrollFade";
|
|
||||||
import { fitChipsWithPill } from "../lib/chipOverflow";
|
import { fitChipsWithPill } from "../lib/chipOverflow";
|
||||||
import { chipLaneClass, useChipOverflowFit } from "../lib/useChipOverflowFit";
|
import { chipLaneClass, useChipOverflowFit } from "../lib/useChipOverflowFit";
|
||||||
import { IconButton } from "./ui/IconButton";
|
|
||||||
import { Chip, DismissibleChip } from "./ui/Chip";
|
import { Chip, DismissibleChip } from "./ui/Chip";
|
||||||
import { chipClass } from "./ui/chipStyles";
|
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";
|
import { SURFACE } from "./ui/surfaces";
|
||||||
|
|
||||||
// The channel tag surface, shared by the channel manager (table + cards) and the channel detail
|
// 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
|
/** 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
|
* "create + attach" input. Rendered inside an anchored `menu` popover. The rows are the caller's
|
||||||
* detail page in Part 2; internal for now.) */
|
* `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({
|
function TagEditorMenu({
|
||||||
userTags,
|
userTags,
|
||||||
attachedIds,
|
attachedIds,
|
||||||
@@ -208,71 +207,27 @@ function TagEditorMenu({
|
|||||||
onCreate: (name: string) => void;
|
onCreate: (name: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [draft, setDraft] = useState("");
|
const tags = useTags();
|
||||||
const { ref: fadeRef, style: fadeStyle } = useScrollFade();
|
|
||||||
const attached = new Set(attachedIds);
|
const attached = new Set(attachedIds);
|
||||||
const submit = () => {
|
return (
|
||||||
const name = draft.trim();
|
<MembershipMenu
|
||||||
if (!name) return;
|
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);
|
onCreate(name);
|
||||||
setDraft("");
|
return true;
|
||||||
};
|
},
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -473,6 +428,7 @@ export function ChannelTagsControl({
|
|||||||
<PopoverSurface
|
<PopoverSurface
|
||||||
popover={editPop}
|
popover={editPop}
|
||||||
className={`${SURFACE.menu} w-56 animate-[popIn_0.16s_ease]`}
|
className={`${SURFACE.menu} w-56 animate-[popIn_0.16s_ease]`}
|
||||||
|
listInside
|
||||||
>
|
>
|
||||||
<TagEditorMenu
|
<TagEditorMenu
|
||||||
userTags={userTags}
|
userTags={userTags}
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ import type { DownloadJob } from "../lib/api";
|
|||||||
|
|
||||||
const mocks = vi.hoisted(() => ({
|
const mocks = vi.hoisted(() => ({
|
||||||
jobs: [] as unknown[],
|
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) => {
|
vi.mock("react-i18next", async (importOriginal) => {
|
||||||
@@ -124,4 +126,58 @@ describe("DownloadCenter confirm dialogs", () => {
|
|||||||
["downloads.confirm.deleteTitle", "downloads.actions.delete"],
|
["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 () => {
|
it("copies the share link from the row's own Share button", async () => {
|
||||||
mocks.jobs = [job(2, "done")];
|
mocks.jobs = [job(2, "done")];
|
||||||
vi.spyOn(api, "downloadLinks").mockResolvedValue([] as never);
|
vi.spyOn(api, "downloadLinks").mockResolvedValue([] as never);
|
||||||
|
|||||||
@@ -68,7 +68,9 @@ export default function DownloadCenter() {
|
|||||||
const [manage, setManage] = useState(false);
|
const [manage, setManage] = useState(false);
|
||||||
const [metaJob, setMetaJob] = useState<DownloadJob | null>(null);
|
const [metaJob, setMetaJob] = useState<DownloadJob | null>(null);
|
||||||
const [shareJob, setShareJob] = 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 [editJob, setEditJob] = useState<DownloadJob | null>(null);
|
||||||
const [errorJob, setErrorJob] = useState<DownloadJob | null>(null);
|
const [errorJob, setErrorJob] = useState<DownloadJob | null>(null);
|
||||||
|
|
||||||
@@ -276,7 +278,9 @@ export default function DownloadCenter() {
|
|||||||
<IconBtn
|
<IconBtn
|
||||||
open
|
open
|
||||||
onClick={() => setPlayJob(job)}
|
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" />
|
<PlayCircle className="w-4 h-4" />
|
||||||
</IconBtn>
|
</IconBtn>
|
||||||
@@ -315,7 +319,9 @@ export default function DownloadCenter() {
|
|||||||
<IconBtn
|
<IconBtn
|
||||||
open
|
open
|
||||||
onClick={() => setPlayJob(job)}
|
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" />
|
<PlayCircle className="w-4 h-4" />
|
||||||
</IconBtn>
|
</IconBtn>
|
||||||
@@ -357,7 +363,13 @@ export default function DownloadCenter() {
|
|||||||
{manage && <ProfileEditor onClose={() => setManage(false)} />}
|
{manage && <ProfileEditor onClose={() => setManage(false)} />}
|
||||||
{metaJob && <MetaEditModal job={metaJob} onClose={() => setMetaJob(null)} />}
|
{metaJob && <MetaEditModal job={metaJob} onClose={() => setMetaJob(null)} />}
|
||||||
{shareJob && <ShareDialog job={shareJob} onClose={() => setShareJob(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)} />}
|
{editJob && <VideoEditor job={editJob} onClose={() => setEditJob(null)} />}
|
||||||
{errorJob && (
|
{errorJob && (
|
||||||
<ErrorModal
|
<ErrorModal
|
||||||
|
|||||||
@@ -1,61 +1,30 @@
|
|||||||
import { Component, type ErrorInfo, type ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import i18n from "../i18n";
|
import i18n from "../i18n";
|
||||||
import { notify } from "../lib/notifications";
|
import { notify } from "../lib/notifications";
|
||||||
import { errorAttrs, log } from "../lib/log";
|
import { CrashBoundary, type CrashCopy } from "./ui/CrashBoundary";
|
||||||
import { reloadingForNewBuild } from "../lib/staleBuild";
|
|
||||||
import { Button } from "./ui/Button";
|
|
||||||
|
|
||||||
interface Props {
|
const copy = (): CrashCopy => ({
|
||||||
children: ReactNode;
|
title: i18n.t("errors.boundary.title"),
|
||||||
}
|
subtitle: i18n.t("errors.boundary.subtitle"),
|
||||||
interface State {
|
reload: i18n.t("errors.boundary.reload"),
|
||||||
crashed: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 };
|
|
||||||
|
|
||||||
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 } : {}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// A fatal notification, kept in the center across the reload.
|
||||||
|
const onCrash = () =>
|
||||||
notify({
|
notify({
|
||||||
level: "fatal",
|
level: "fatal",
|
||||||
title: i18n.t("errors.boundary.notifTitle"),
|
title: i18n.t("errors.boundary.notifTitle"),
|
||||||
// Never the raw `error.message`: a JS engine's English ("Cannot read properties of undefined…")
|
// 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.
|
// is no message for the reader — the `ui.crash` log carries it for diagnosis.
|
||||||
message: i18n.t("errors.boundary.notifMessage"),
|
message: i18n.t("errors.boundary.notifMessage"),
|
||||||
requiresInteraction: true,
|
requiresInteraction: true,
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
// The app's crash boundary: `CrashBoundary` in the app's language, plus the notice in the center.
|
||||||
if (this.state.crashed) {
|
export default function ErrorBoundary({ children }: { children: ReactNode }) {
|
||||||
if (reloadingForNewBuild()) return null;
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-dvh grid place-items-center text-muted p-6 text-center">
|
<CrashBoundary copy={copy} onCrash={onCrash}>
|
||||||
<div>
|
{children}
|
||||||
<div className="text-lg font-semibold text-fg mb-1">
|
</CrashBoundary>
|
||||||
{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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -199,7 +199,7 @@ function useHomeCardMenu({
|
|||||||
.map((it, i) => (i === 0 ? { ...it, separatorBefore: true } : it))
|
.map((it, i) => (i === 0 ? { ...it, separatorBefore: true } : it))
|
||||||
: target.plex
|
: target.plex
|
||||||
? plexActions
|
? 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))
|
.map((it, i) => (i === 0 ? { ...it, separatorBefore: true } : it))
|
||||||
: target.kind === "channel" || target.kind === "playlist"
|
: target.kind === "channel" || target.kind === "playlist"
|
||||||
? [{ ...openPage.item(target), separatorBefore: true }]
|
? [{ ...openPage.item(target), separatorBefore: true }]
|
||||||
@@ -546,7 +546,7 @@ function Hero({
|
|||||||
? // The channel is the subtitle already.
|
? // The channel is the subtitle already.
|
||||||
[
|
[
|
||||||
video.view_count != null && `${formatCompact(video.view_count, lang)} ${t("card.views")}`,
|
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(" · ");
|
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
|
// list — so an audio upload with an uncommon/absent container still renders <audio src=/file>. i18n
|
||||||
// is key-passthrough here.
|
// is key-passthrough here.
|
||||||
import { describe, it, expect, afterEach, vi } from "vitest";
|
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) => {
|
vi.mock("react-i18next", async (importOriginal) => {
|
||||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||||
@@ -15,7 +17,8 @@ const expose = vi.hoisted(() => vi.fn());
|
|||||||
vi.mock("../lib/browserBackgroundPlay", () => ({ exposeVideoToBackgroundPlay: expose }));
|
vi.mock("../lib/browserBackgroundPlay", () => ({ exposeVideoToBackgroundPlay: expose }));
|
||||||
|
|
||||||
import LibraryPlayer from "./LibraryPlayer";
|
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 = {
|
const baseJob: DownloadJob = {
|
||||||
id: 7,
|
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", () => {
|
describe("LibraryPlayer", () => {
|
||||||
it("plays a video asset in a <video> pointed at the range-aware /file endpoint", () => {
|
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();
|
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 { useTranslation } from "react-i18next";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import Modal from "./ui/Modal";
|
import Modal from "./ui/Modal";
|
||||||
import { api, type DownloadJob } from "../lib/api";
|
import { api, type DownloadJob } from "../lib/api";
|
||||||
|
import { qk } from "../lib/queryKeys";
|
||||||
import { jobTitle } from "./downloadTitle";
|
import { jobTitle } from "./downloadTitle";
|
||||||
import { exposeVideoToBackgroundPlay } from "../lib/browserBackgroundPlay";
|
import { exposeVideoToBackgroundPlay } from "../lib/browserBackgroundPlay";
|
||||||
import { useMediaLogRef } from "../lib/mediaLog";
|
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
|
// 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
|
// 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
|
// 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
|
// /file endpoint, volume, fullscreen, speed) works with zero transcode; an uploaded media item may be
|
||||||
// any codec (best-effort playback).
|
// 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 { t } = useTranslation();
|
||||||
|
const qc = useQueryClient();
|
||||||
const fileUrl = useMemo(() => api.downloadFileUrl(job.id), [job.id]);
|
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 —
|
// 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
|
// 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.
|
// the fallback. Same precedence as the library-row thumbnail.
|
||||||
const poster = job.poster_url ? api.posterImageUrl(job.id) : (asset?.thumbnail_url ?? undefined);
|
const poster = job.poster_url ? api.posterImageUrl(job.id) : (asset?.thumbnail_url ?? undefined);
|
||||||
const logMedia = useMediaLogRef("library");
|
const logMedia = useMediaLogRef("library");
|
||||||
const videoRef = useCallback(
|
// The element as STATE, so the session, the keys and the resume bind to it once it exists.
|
||||||
(el: HTMLVideoElement | null) => {
|
const [media, setMedia] = useState<HTMLMediaElement | null>(null);
|
||||||
exposeVideoToBackgroundPlay(el);
|
const mediaRef = useCallback(
|
||||||
|
(el: HTMLMediaElement | null) => {
|
||||||
|
if (el instanceof HTMLVideoElement) exposeVideoToBackgroundPlay(el);
|
||||||
logMedia(el);
|
logMedia(el);
|
||||||
|
setMedia(el);
|
||||||
},
|
},
|
||||||
[logMedia],
|
[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 (
|
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 ? (
|
{isAudio ? (
|
||||||
<div className="flex flex-col items-center gap-4 py-2">
|
<div className="flex flex-col items-center gap-4 py-2">
|
||||||
{poster ? (
|
{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"
|
className="w-40 h-40 rounded-xl object-cover border border-border"
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : 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")}
|
{t("downloads.player.unsupported")}
|
||||||
</audio>
|
</audio>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<video
|
<video
|
||||||
ref={videoRef}
|
ref={mediaRef}
|
||||||
src={fileUrl}
|
src={fileUrl}
|
||||||
poster={poster}
|
poster={poster}
|
||||||
controls
|
controls
|
||||||
|
|||||||
@@ -32,6 +32,20 @@ import { PageHeading } from "./ui/Headings";
|
|||||||
|
|
||||||
import { qk } from "../lib/queryKeys";
|
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.
|
// The provider reads the signed-in account (the tab title) from the `me` cache.
|
||||||
let qc = new QueryClient();
|
let qc = new QueryClient();
|
||||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
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", () => {
|
describe("NavigationProvider · never under an open overlay", () => {
|
||||||
it("setPage closes the overlay first and pushes only once its entry is reclaimed", async () => {
|
it("setPage closes the overlay first and pushes only once its entry is reclaimed", async () => {
|
||||||
const { result } = render();
|
const { result } = render();
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { useDocumentObjectTitle } from "../lib/documentTitle";
|
|||||||
import { setAccountRaw } from "../lib/storage";
|
import { setAccountRaw } from "../lib/storage";
|
||||||
import { setNavigator } from "../lib/nav";
|
import { setNavigator } from "../lib/nav";
|
||||||
import {
|
import {
|
||||||
|
backStaysInDocument,
|
||||||
closeOverlaysThenNavigate,
|
closeOverlaysThenNavigate,
|
||||||
nextNavId,
|
nextNavId,
|
||||||
notifyNav,
|
notifyNav,
|
||||||
@@ -119,12 +120,14 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const closeChannel = useCallback(() => {
|
const closeChannel = useCallback(() => {
|
||||||
if (window.history.state?._chan) window.history.back();
|
if (window.history.state?._chan && backStaysInDocument()) window.history.back();
|
||||||
else {
|
else {
|
||||||
// No owned history entry (e.g. opened straight from a ?chan bookmark): just clear the state and
|
// No owned history entry (e.g. opened straight from a ?chan bookmark), or none of this document
|
||||||
// drop the ?chan param from the address bar.
|
// 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);
|
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(() => {
|
const exitYtSearch = useCallback(() => {
|
||||||
// Pop our search entry (so Forward still works); fall back to a plain clear if we don't own one.
|
// Pop our search entry (so Forward still works); fall back to a clear in place if we don't own
|
||||||
if (window.history.state?._yt) window.history.back();
|
// one, or there is no entry of this document below it (a full tab, see backStaysInDocument).
|
||||||
else setYtSearch(null);
|
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.
|
// 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();
|
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_expired: "downloadExpired",
|
||||||
download_evicted: "downloadEvicted",
|
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 {
|
function serverKind(n: AppNotification): "scheduler" | "download" | null {
|
||||||
if (n.type === "scheduler" && typeof n.data?.job_id === "string") return "scheduler";
|
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 ?? "" });
|
title = t(`inbox.${lifecycle}.title`, { from: d.from ?? "" });
|
||||||
body = d.title || null;
|
body = d.title || null;
|
||||||
} else if (isDownloadDone) {
|
} else if (isDownloadDone) {
|
||||||
// "Download ready" / "Edit ready" per the producer's kind, with the video's title as the body
|
// "Download ready" / "Edit ready" / "Upload ready" per the producer's kind, with the video's title
|
||||||
// (shipped in data.title). Same notification type; the copy differs so an edit isn't mislabelled.
|
// 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 };
|
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;
|
body = d.title || n.body;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -28,7 +28,8 @@ function useCap(): string {
|
|||||||
// words matched nothing.
|
// words matched nothing.
|
||||||
|
|
||||||
/** The key cap at the end of a search field. A button, so the pointer and a screen reader reach it
|
/** 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() {
|
export function PaletteKeycap() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const cap = useCap();
|
const cap = useCap();
|
||||||
@@ -37,7 +38,7 @@ export function PaletteKeycap() {
|
|||||||
onClick={openPalette}
|
onClick={openPalette}
|
||||||
aria-label={`${t("palette.open")} (${cap})`}
|
aria-label={`${t("palette.open")} (${cap})`}
|
||||||
title={`${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}
|
{cap}
|
||||||
</Pressable>
|
</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
|
// 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.
|
// 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", () => {
|
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={() => {}} />,
|
<PlayerModal video={A} queue={[A, B]} startIndex={0} onClose={() => {}} onState={() => {}} />,
|
||||||
);
|
);
|
||||||
const row = document.querySelector<HTMLElement>(".player-stage")!.parentElement!.parentElement!;
|
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: () => {},
|
removeEventListener: () => {},
|
||||||
}) as unknown as MediaQueryList,
|
}) 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(() => {
|
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 () => {
|
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();
|
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,
|
useLayoutEffect,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
|
type CSSProperties,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
@@ -56,7 +57,7 @@ import {
|
|||||||
formatCompact,
|
formatCompact,
|
||||||
relativeTime,
|
relativeTime,
|
||||||
} from "../lib/format";
|
} from "../lib/format";
|
||||||
import { fullscreenElement } from "../lib/fullscreen";
|
import { canFullscreenElements, fullscreenElement } from "../lib/fullscreen";
|
||||||
import { dispatch, isActivationTarget, useKeyScope } from "../lib/keymap";
|
import { dispatch, isActivationTarget, useKeyScope } from "../lib/keymap";
|
||||||
import { useBackToClose } from "../lib/history";
|
import { useBackToClose } from "../lib/history";
|
||||||
import ChannelNameLink from "./ChannelNameLink";
|
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.
|
// a rem width, so it grows with the text size; the video takes what is left.
|
||||||
const WIDE_PLAYER_PX = 1280;
|
const WIDE_PLAYER_PX = 1280;
|
||||||
const SIDE_PANEL = "20rem";
|
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
|
// 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.
|
// 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>
|
</span>
|
||||||
);
|
);
|
||||||
const META_OWN_LINE = "w-full [&>span:first-child>[data-meta-sep]]:hidden";
|
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({
|
export default function PlayerModal({
|
||||||
video,
|
video,
|
||||||
@@ -611,6 +646,10 @@ export default function PlayerModal({
|
|||||||
onLoadVideo: loadVideo,
|
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
|
// 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
|
// 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
|
// 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" : ""}`}
|
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
|
// 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).
|
// 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={{
|
style={{
|
||||||
maxWidth: wide
|
maxWidth: wide
|
||||||
? `calc((100dvh - 3rem - ${below}px) * 16 / 9 + ${SIDE_PANEL}${hasQueue ? " + 7rem" : ""})`
|
? `calc(${fitWidth} + ${SIDE_PANEL}${gutters})`
|
||||||
: `min(${hasQueue ? "63rem" : "56rem"}, calc((100dvh - 3rem - ${below}px) * 16 / 9${
|
: `min(${hasQueue ? "63rem" : "56rem"}, calc(max(${MIN_VIDEO}, ${fitWidth})${gutters}))`,
|
||||||
hasQueue ? " + 7rem" : ""
|
|
||||||
}))`,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{hasQueue && (
|
{hasQueue && (
|
||||||
@@ -680,39 +722,48 @@ export default function PlayerModal({
|
|||||||
which would drop any class on mountRef itself — the iframe then sizes to this wrapper. */}
|
which would drop any class on mountRef itself — the iframe then sizes to this wrapper. */}
|
||||||
<div
|
<div
|
||||||
className={
|
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
|
<div
|
||||||
ref={mountRef}
|
ref={mountRef}
|
||||||
className={`w-full h-full ${playerError != null ? "invisible" : ""}`}
|
className={`w-full h-full ${playerError != null ? "invisible" : ""}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
{/* Interaction layer over the CENTRE of the video: catches click (play/pause) + wheel
|
{/* 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
|
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.
|
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.
|
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
|
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
|
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.
|
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. */}
|
Hidden on error so the "Open on YouTube" CTA is clickable. */}
|
||||||
{playerError == null && (
|
{playerError == null &&
|
||||||
|
YT_CLICK_LAYER.map((box, i) => (
|
||||||
<div
|
<div
|
||||||
|
key={i}
|
||||||
|
data-testid="yt-click-layer"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
focusModal();
|
focusModal();
|
||||||
togglePlay();
|
togglePlay();
|
||||||
}}
|
}}
|
||||||
title={t("player.shortcutsHint")}
|
title={t("player.shortcutsHint")}
|
||||||
aria-label={t("player.shortcutsHint")}
|
aria-hidden="true"
|
||||||
className={`absolute inset-x-0 top-[12%] bottom-[22%] z-base cursor-pointer ${
|
style={box}
|
||||||
|
className={`absolute z-base cursor-pointer ${
|
||||||
nativeControls ? "pointer-events-none" : ""
|
nativeControls ? "pointer-events-none" : ""
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
)}
|
))}
|
||||||
|
</div>
|
||||||
{/* Our own control bar for the big views (maximise / our fullscreen), where the card's
|
{/* 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
|
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
|
<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
|
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
|
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;
|
seek bar over the same pixels. stopPropagation so a click here doesn't toggle playback;
|
||||||
@@ -762,6 +813,10 @@ export default function PlayerModal({
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<span className="h-4 w-px bg-white/20" />
|
<span className="h-4 w-px bg-white/20" />
|
||||||
|
{/* 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
|
<IconButton
|
||||||
variant="overlay"
|
variant="overlay"
|
||||||
onClick={toggleTrueFullscreen}
|
onClick={toggleTrueFullscreen}
|
||||||
@@ -769,6 +824,7 @@ export default function PlayerModal({
|
|||||||
>
|
>
|
||||||
{isFullscreen ? <Minimize /> : <Maximize />}
|
{isFullscreen ? <Minimize /> : <Maximize />}
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant="overlay"
|
variant="overlay"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -913,10 +969,13 @@ export default function PlayerModal({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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),
|
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. */}
|
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" : ""}`}>
|
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 && (
|
{!navigated && (
|
||||||
<Avatar
|
<Avatar
|
||||||
src={active.channel_thumbnail}
|
src={active.channel_thumbnail}
|
||||||
@@ -964,7 +1023,7 @@ export default function PlayerModal({
|
|||||||
)}
|
)}
|
||||||
{!navigated ? (
|
{!navigated ? (
|
||||||
<div
|
<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 && (
|
{active.view_count != null && (
|
||||||
<span>
|
<span>
|
||||||
@@ -977,7 +1036,7 @@ export default function PlayerModal({
|
|||||||
)}
|
)}
|
||||||
<span>
|
<span>
|
||||||
{metaSep}
|
{metaSep}
|
||||||
{relativeTime(active.published_at, lang)}
|
{relativeTime(active.published_at, lang, t)}
|
||||||
{active.published_at && ` · ${fullDate(active.published_at)}`}
|
{active.published_at && ` · ${fullDate(active.published_at)}`}
|
||||||
</span>
|
</span>
|
||||||
{active.duration_seconds != null && (
|
{active.duration_seconds != null && (
|
||||||
@@ -995,7 +1054,7 @@ export default function PlayerModal({
|
|||||||
) : (
|
) : (
|
||||||
// Linked video's stats come from the (already-fetched) video detail.
|
// Linked video's stats come from the (already-fetched) video detail.
|
||||||
<div
|
<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 && (
|
{detail.data?.view_count != null && (
|
||||||
<span>
|
<span>
|
||||||
@@ -1009,7 +1068,7 @@ export default function PlayerModal({
|
|||||||
{detail.data?.published_at && (
|
{detail.data?.published_at && (
|
||||||
<span>
|
<span>
|
||||||
{metaSep}
|
{metaSep}
|
||||||
{relativeTime(detail.data.published_at, lang)} ·{" "}
|
{relativeTime(detail.data.published_at, lang, t)} ·{" "}
|
||||||
{fullDate(detail.data.published_at)}
|
{fullDate(detail.data.published_at)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -1028,28 +1087,25 @@ export default function PlayerModal({
|
|||||||
own (which hides our chrome). Mirrors the F key (maximise, a CSS big view); from there
|
own (which hides our chrome). Mirrors the F key (maximise, a CSS big view); from there
|
||||||
the docked bar offers OUR true fullscreen. */}
|
the docked bar offers OUR true fullscreen. */}
|
||||||
{!navigated && (
|
{!navigated && (
|
||||||
|
// 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
|
<IconButton
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="md"
|
size="md"
|
||||||
onClick={toggleFullscreen}
|
onClick={toggleFullscreen}
|
||||||
label={t("player.fullscreen")}
|
label={t("player.fullscreen")}
|
||||||
title={`${t("player.fullscreen")} · F`}
|
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 />
|
<Maximize />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
)}
|
<AddToPlaylist videoId={active.id} variant="outline" size="md" />
|
||||||
{!navigated && <AddToPlaylist videoId={active.id} variant="outline" size="md" />}
|
|
||||||
{!navigated && (
|
|
||||||
<DownloadButton
|
<DownloadButton
|
||||||
videoId={active.id}
|
videoId={active.id}
|
||||||
title={active.title}
|
title={active.title}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="md"
|
size="md"
|
||||||
/>
|
/>
|
||||||
)}
|
{onRemoveFromList && (
|
||||||
{!navigated && onRemoveFromList && (
|
|
||||||
<IconButton
|
<IconButton
|
||||||
variant="outline-danger"
|
variant="outline-danger"
|
||||||
size="md"
|
size="md"
|
||||||
@@ -1062,7 +1118,6 @@ export default function PlayerModal({
|
|||||||
<Trash2 />
|
<Trash2 />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
{!navigated && (
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -1073,6 +1128,7 @@ export default function PlayerModal({
|
|||||||
>
|
>
|
||||||
{watched ? t("player.watched") : t("player.markWatched")}
|
{watched ? t("player.watched") : t("player.markWatched")}
|
||||||
</Button>
|
</Button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{wide && (
|
{wide && (
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ vi.mock("../lib/notifications", async (importOriginal) => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
import PlexPlaylistView from "./PlexPlaylistView";
|
import PlexPlaylistView from "./PlexPlaylistView";
|
||||||
import AddToPlaylist from "./AddToPlaylist";
|
import AddToPlaylist, { AddToPlaylistAt } from "./AddToPlaylist";
|
||||||
import PlexPlaylistAdd from "./PlexPlaylistAdd";
|
import PlexPlaylistAdd from "./PlexPlaylistAdd";
|
||||||
import TagManager from "./TagManager";
|
import TagManager from "./TagManager";
|
||||||
import { api, HttpError, type PlexCard } from "../lib/api";
|
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));
|
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 () => {
|
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
|
// 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
|
// 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;
|
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).
|
// recently watched first). Frozen for the same reason as PLEX_SORT_FALLBACK (handed out by reference).
|
||||||
const LASTWATCHED_SORT: NonNullable<SortState> = Object.freeze({
|
const LASTWATCHED_SORT: NonNullable<SortState> = Object.freeze({
|
||||||
key: "lastwatched",
|
key: "lastwatched",
|
||||||
@@ -229,7 +229,7 @@ export default function PlexBrowse() {
|
|||||||
return () => window.removeEventListener("keydown", onKey);
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
}, [sub, canStepBack]);
|
}, [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
|
// 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
|
// 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
|
// 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
|
// 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", () => {
|
describe("PlexDiscover · keyboard", () => {
|
||||||
it("registers every rail and the genre grid, and j walks on from rail to rail", async () => {
|
it("registers every rail and the genre grid, and j walks on from rail to rail", async () => {
|
||||||
plexDiscover.mockResolvedValue(fullPayload);
|
plexDiscover.mockResolvedValue(fullPayload);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { DetailCustomizeMenu, Filterable, PrefToggle } from "./plexDetailUi";
|
|||||||
import { DetailAside, DetailAsideToggles, DetailKeywords } from "./plexDetailAside";
|
import { DetailAside, DetailAsideToggles, DetailKeywords } from "./plexDetailAside";
|
||||||
import {
|
import {
|
||||||
DETAIL_POSTER,
|
DETAIL_POSTER,
|
||||||
|
episodeLine,
|
||||||
tmdbBackdropUrl,
|
tmdbBackdropUrl,
|
||||||
useCrossfadeBackdrop,
|
useCrossfadeBackdrop,
|
||||||
useDetailAside,
|
useDetailAside,
|
||||||
@@ -315,7 +316,7 @@ export default function PlexInfo({
|
|||||||
)}
|
)}
|
||||||
{detail.kind === "episode" && (
|
{detail.kind === "episode" && (
|
||||||
<p className={`text-sm ${overlay ? "text-white/70" : "text-muted"}`}>
|
<p className={`text-sm ${overlay ? "text-white/70" : "text-muted"}`}>
|
||||||
S{detail.season_number}·E{detail.episode_number} — {detail.title}
|
{episodeLine(detail)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
|||||||
import { FloatingTree } from "@floating-ui/react";
|
import { FloatingTree } from "@floating-ui/react";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
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) => {
|
vi.mock("react-i18next", async (importOriginal) => {
|
||||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
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 = {
|
const handlers = {
|
||||||
onOpen: vi.fn(),
|
onOpen: vi.fn(),
|
||||||
onInfo: vi.fn(),
|
onInfo: vi.fn(),
|
||||||
onToggleWatched: vi.fn(),
|
onToggleWatched: vi.fn(),
|
||||||
onRestart: vi.fn(),
|
onRestart: vi.fn(),
|
||||||
};
|
};
|
||||||
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
if (admin) qc.setQueryData(qk.me(), { role: "admin" });
|
||||||
render(
|
render(
|
||||||
<QueryClientProvider client={new QueryClient()}>
|
<QueryClientProvider client={qc}>
|
||||||
<FloatingTree>
|
<FloatingTree>
|
||||||
<Grid c={c} handlers={handlers} {...opts} />
|
<Grid c={c} handlers={handlers} {...opts} />
|
||||||
</FloatingTree>
|
</FloatingTree>
|
||||||
</QueryClientProvider>,
|
</QueryClientProvider>,
|
||||||
);
|
);
|
||||||
return { handlers };
|
return { handlers, qc };
|
||||||
}
|
}
|
||||||
// The card root holds the menu's trigger props (right-click anywhere on it).
|
// The card root holds the menu's trigger props (right-click anywhere on it).
|
||||||
const rightClick = () =>
|
const rightClick = () =>
|
||||||
@@ -223,3 +230,189 @@ describe("a Plex title's menu", () => {
|
|||||||
await vi.waitFor(() => expect(remove).toHaveBeenCalledWith("m1"));
|
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
|
<ContextMenu
|
||||||
menu={menu}
|
menu={menu}
|
||||||
ariaLabel={t("plex.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}
|
{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 () => {
|
it("a Space or Enter on Skip intro, outside the faded bands, stays Skip intro's", async () => {
|
||||||
const intro = {
|
const intro = {
|
||||||
...item("A"),
|
...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;
|
} as unknown as PlexItemDetail;
|
||||||
mount({ details: { A: intro } });
|
mount({ details: { A: intro } });
|
||||||
await playingAt("/media/A", 100);
|
await playingAt("/media/A", 100);
|
||||||
@@ -857,3 +857,73 @@ describe("the seek bar (C3)", () => {
|
|||||||
expect(head?.className).toContain("[@media(hover:none)]:opacity-100");
|
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")));
|
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 () => {
|
it("settles it as watched when it ended, so Prev does not reopen it at its last seconds", async () => {
|
||||||
prefs({ upNext: false });
|
prefs({ upNext: false });
|
||||||
const { qc, progress } = mount({
|
const { qc, progress } = mount({
|
||||||
@@ -334,7 +373,7 @@ describe("leaving an item that was played through", () => {
|
|||||||
prefs({ autoSkipCredits: true, autoSkipDelay: 0 });
|
prefs({ autoSkipCredits: true, autoSkipDelay: 0 });
|
||||||
const withCredits = {
|
const withCredits = {
|
||||||
...item("A"),
|
...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;
|
} as unknown as PlexItemDetail;
|
||||||
const { qc, progress } = mount({
|
const { qc, progress } = mount({
|
||||||
details: { A: withCredits, B: item("B") },
|
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 });
|
prefs({ upNext: false, autoSkipCredits: true, autoSkipDelay: 0 });
|
||||||
const withCredits = {
|
const withCredits = {
|
||||||
...item("A"),
|
...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;
|
} as unknown as PlexItemDetail;
|
||||||
const { qc, progress } = mount({
|
const { qc, progress } = mount({
|
||||||
details: { A: withCredits, B: item("B") },
|
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");
|
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 () => {
|
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
|
// `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
|
// 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 },
|
{ ord: 1, label: "Magyar", language: "hu", default: false },
|
||||||
],
|
],
|
||||||
} as unknown as PlexItemDetail;
|
} 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]));
|
await waitFor(() => expect(startsAsked("A")).toEqual([1200]));
|
||||||
fireEvent.keyDown(window, { key: "a" });
|
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" });
|
fireEvent.keyDown(window, { key: "ArrowRight" });
|
||||||
|
await act(async () => answers[1]!.resolve(direct("A")));
|
||||||
await waitFor(() => expect(startsAsked("A")).toHaveLength(3));
|
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]);
|
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);
|
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: "ArrowRight" }); // the 10 s step // 105: past the buffer → restart
|
||||||
fireEvent.keyDown(document.body, { key: "ArrowLeft" }); // 95: inside the OLD buffer
|
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 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();
|
await tick();
|
||||||
expect(video().getAttribute("src")).toBe("/media/A"); // the superseded 105 answer never attached
|
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 () => {
|
it("a direct-play item left before its metadata does not seek the next one", async () => {
|
||||||
mount({
|
mount({
|
||||||
details: { A: item("A", 1200), B: item("B") },
|
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();
|
await tick();
|
||||||
expect(video().getAttribute("src")).not.toBe("/media/B");
|
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";
|
import { pushLayer, removeLayer } from "../lib/layerStack";
|
||||||
|
|
||||||
beforeAll(() => {
|
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 {
|
class Stub {
|
||||||
observe() {}
|
observe() {}
|
||||||
unobserve() {}
|
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 PlexPlaylistView from "./PlexPlaylistView";
|
||||||
import { api, type PlexCard } from "../lib/api";
|
import { api, type PlexCard } from "../lib/api";
|
||||||
|
import { qk } from "../lib/queryKeys";
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
class Stub {
|
class Stub {
|
||||||
@@ -63,15 +64,15 @@ const ep = (id: string, season: number, n: number) =>
|
|||||||
thumb: "",
|
thumb: "",
|
||||||
}) as unknown as PlexCard;
|
}) 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, "plexPlaylist").mockResolvedValue({ id: 5, title: "Films", items } as never);
|
||||||
vi.spyOn(api, "comfortPins").mockResolvedValue([] as never);
|
vi.spyOn(api, "comfortPins").mockResolvedValue([] as never);
|
||||||
const onPlay = vi.fn();
|
const onPlay = vi.fn();
|
||||||
const onBack = vi.fn();
|
const onBack = vi.fn();
|
||||||
|
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
if (admin) client.setQueryData(qk.me(), { role: "admin" });
|
||||||
render(
|
render(
|
||||||
<QueryClientProvider
|
<QueryClientProvider client={client}>
|
||||||
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
|
|
||||||
>
|
|
||||||
<PlexPlaylistView playlistId={5} onBack={onBack} onPlay={onPlay} onInfoCard={onInfoCard} />
|
<PlexPlaylistView playlistId={5} onBack={onBack} onPlay={onPlay} onInfoCard={onInfoCard} />
|
||||||
</QueryClientProvider>,
|
</QueryClientProvider>,
|
||||||
);
|
);
|
||||||
@@ -112,6 +113,13 @@ const pressKey = (key: string) =>
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("PlexPlaylistView · a row's menu", () => {
|
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 () => {
|
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)]);
|
mount([movie("1"), ep("e1", 1, 1)]);
|
||||||
await screen.findByText("Movie 1");
|
await screen.findByText("Movie 1");
|
||||||
|
|||||||
@@ -626,7 +626,7 @@ export default function PlexPlaylistView({
|
|||||||
? []
|
? []
|
||||||
: menuTarget.kind === "item"
|
: menuTarget.kind === "item"
|
||||||
? [
|
? [
|
||||||
...titleActions.items(menuTarget.item),
|
...titleActions.items(menuTarget.item, { anchor: rowMenu.anchor }),
|
||||||
...orderRows(menuTarget),
|
...orderRows(menuTarget),
|
||||||
removeRow(menuTarget, t("plex.playlist.removeFromPlaylist")),
|
removeRow(menuTarget, t("plex.playlist.removeFromPlaylist")),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { type RefObject, useEffect, useState } from "react";
|
import { type RefObject, useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type Hls from "hls.js";
|
|
||||||
import { X } from "lucide-react";
|
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";
|
import { IconButton } from "./ui/IconButton";
|
||||||
|
|
||||||
// "Stats for nerds" debug overlay. Samples the live HTML5 <video> + hls.js state once a second
|
// "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({
|
export function PlexStatsOverlay({
|
||||||
videoRef,
|
videoRef,
|
||||||
hlsRef,
|
engine,
|
||||||
fragMeterRef,
|
fragMeterRef,
|
||||||
audioLabel,
|
audioLabel,
|
||||||
subLabel,
|
subLabel,
|
||||||
playable,
|
playable,
|
||||||
hwRef,
|
|
||||||
tonemapRef,
|
|
||||||
sessionStartRef,
|
|
||||||
absRef,
|
absRef,
|
||||||
durationRef,
|
durationRef,
|
||||||
onClose,
|
onClose,
|
||||||
}: {
|
}: {
|
||||||
videoRef: RefObject<HTMLVideoElement | null>;
|
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
|
// 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.
|
// long as this panel) — the source of the MEASURED bitrate row.
|
||||||
fragMeterRef: RefObject<FragMeter>;
|
fragMeterRef: RefObject<FragMeter>;
|
||||||
audioLabel?: string; // selected audio track label, as the tracks menu names it
|
audioLabel?: string; // selected audio track label, as the tracks menu names it
|
||||||
subLabel?: string; // selected subtitle track label (or the menu's "Off" entry)
|
subLabel?: string; // selected subtitle track label (or the menu's "Off" entry)
|
||||||
playable: string;
|
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>;
|
absRef: RefObject<number>;
|
||||||
durationRef: RefObject<number>;
|
durationRef: RefObject<number>;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -57,7 +52,6 @@ export function PlexStatsOverlay({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const sample = () => {
|
const sample = () => {
|
||||||
const v = videoRef.current;
|
const v = videoRef.current;
|
||||||
const hls = hlsRef.current;
|
|
||||||
|
|
||||||
const { dropped, total } = frameCounts(v);
|
const { dropped, total } = frameCounts(v);
|
||||||
|
|
||||||
@@ -72,29 +66,15 @@ export function PlexStatsOverlay({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let level: HlsLevelInfo | null = null;
|
const level = engine.levelInfo();
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
setRows(
|
setRows(
|
||||||
buildStatRows({
|
buildStatRows({
|
||||||
lang,
|
lang,
|
||||||
playable,
|
playable,
|
||||||
hwEncoder: hwRef.current,
|
hwEncoder: engine.hw,
|
||||||
tonemap: tonemapRef.current,
|
tonemap: engine.tonemap,
|
||||||
transport: hls ? "hls" : "direct",
|
transport: level ? "hls" : "direct",
|
||||||
videoWidth: v?.videoWidth ?? 0,
|
videoWidth: v?.videoWidth ?? 0,
|
||||||
videoHeight: v?.videoHeight ?? 0,
|
videoHeight: v?.videoHeight ?? 0,
|
||||||
droppedFrames: dropped,
|
droppedFrames: dropped,
|
||||||
@@ -104,7 +84,7 @@ export function PlexStatsOverlay({
|
|||||||
measuredBps: fragMeterRef.current?.bitrate(),
|
measuredBps: fragMeterRef.current?.bitrate(),
|
||||||
audioLabel,
|
audioLabel,
|
||||||
subLabel,
|
subLabel,
|
||||||
sessionStartS: sessionStartRef.current ?? 0,
|
sessionStartS: engine.sessionStart.current,
|
||||||
abs: absRef.current ?? 0,
|
abs: absRef.current ?? 0,
|
||||||
duration: durationRef.current ?? 0,
|
duration: durationRef.current ?? 0,
|
||||||
}),
|
}),
|
||||||
@@ -113,20 +93,7 @@ export function PlexStatsOverlay({
|
|||||||
sample();
|
sample();
|
||||||
const iv = setInterval(sample, 1000);
|
const iv = setInterval(sample, 1000);
|
||||||
return () => clearInterval(iv);
|
return () => clearInterval(iv);
|
||||||
}, [
|
}, [lang, videoRef, engine, fragMeterRef, audioLabel, subLabel, playable, absRef, durationRef]);
|
||||||
lang,
|
|
||||||
videoRef,
|
|
||||||
hlsRef,
|
|
||||||
fragMeterRef,
|
|
||||||
audioLabel,
|
|
||||||
subLabel,
|
|
||||||
playable,
|
|
||||||
hwRef,
|
|
||||||
tonemapRef,
|
|
||||||
sessionStartRef,
|
|
||||||
absRef,
|
|
||||||
durationRef,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<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
|
{job.running
|
||||||
? t("scheduler.runningNow")
|
? t("scheduler.runningNow")
|
||||||
: job.last_finished
|
: 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")}
|
: t("scheduler.neverRun")}
|
||||||
{job.last_error ? (
|
{job.last_error ? (
|
||||||
<span className="text-danger">
|
<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
|
// 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
|
// (@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.
|
// 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 { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { render } from "@testing-library/react";
|
import { cleanup, render } from "@testing-library/react";
|
||||||
import SearchBar from "./SearchBar";
|
import SearchBar from "./SearchBar";
|
||||||
|
|
||||||
vi.mock("react-i18next", async (importOriginal) => {
|
vi.mock("react-i18next", async (importOriginal) => {
|
||||||
@@ -13,14 +13,46 @@ vi.mock("react-i18next", async (importOriginal) => {
|
|||||||
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
|
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
describe("SearchBar responsive wrapper", () => {
|
describe("SearchBar responsive wrapper", () => {
|
||||||
it("carries the container-query classes that make it wrap instead of collapsing", () => {
|
it("carries the container-query classes that make it wrap instead of collapsing", () => {
|
||||||
const { container } = render(<SearchBar value="" onChange={() => {}} placeholder="Search…" />);
|
const { container } = render(<SearchBar value="" onChange={() => {}} placeholder="Search…" />);
|
||||||
const cls = (container.firstElementChild as HTMLElement).className;
|
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:
|
// 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):
|
// 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:basis-full");
|
||||||
expect(cls).toContain("@max-[640px]/appmain:order-last");
|
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
|
// 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
|
// 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
|
// 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 (
|
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">
|
<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. */}
|
{/* `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
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
fieldClass({ size: "free", pill: true, floating: true }),
|
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" />}
|
{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")}
|
title={goTitle ?? goLabel ?? t("header.goTip")}
|
||||||
>
|
>
|
||||||
{goIcon}
|
{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")}
|
{goLabel ?? t("header.go")}
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -235,7 +235,7 @@ function LinkShare({ job }: { job: DownloadJob }) {
|
|||||||
queryFn: () => api.downloadLinks(job.id),
|
queryFn: () => api.downloadLinks(job.id),
|
||||||
});
|
});
|
||||||
const [creating, setCreating] = useState(false);
|
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.
|
// default) and the common "let them have the file" intent. The owner can uncheck it here.
|
||||||
const [allowDownload, setAllowDownload] = useState(true);
|
const [allowDownload, setAllowDownload] = useState(true);
|
||||||
const [expiryDays, setExpiryDays] = useState<number | null>(null);
|
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
|
// 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,
|
// 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.
|
// extra links, revoke) stay available on the created link and via the "New link" button.
|
||||||
const autoTried = useRef(false);
|
const autoTried = useRef(false);
|
||||||
|
|||||||
@@ -81,7 +81,9 @@ describe("UpNextCard", () => {
|
|||||||
expect(onAdvance).not.toHaveBeenCalled();
|
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({
|
(api.plexItem as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
id: "e7",
|
id: "e7",
|
||||||
kind: "episode",
|
kind: "episode",
|
||||||
@@ -91,6 +93,8 @@ describe("UpNextCard", () => {
|
|||||||
episode_number: null,
|
episode_number: null,
|
||||||
});
|
});
|
||||||
renderCard();
|
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