Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a84f2db310 | ||
|
|
872d91f540 | ||
|
|
4eaa68ceeb | ||
|
|
00539a4d97 | ||
|
|
dc24d353e0 | ||
|
|
ce96032edb | ||
|
|
0658f9550b | ||
|
|
f162788135 | ||
|
|
968674bc6a | ||
|
|
90ae9f6e88 | ||
|
|
3682e6a01f | ||
|
|
aca355f43b | ||
|
|
f41c81eb56 | ||
|
|
614c5528ad | ||
|
|
104bd1e3a6 | ||
|
|
584e9ced0c | ||
|
|
b4ad59274b | ||
|
|
fe0b34cd00 | ||
|
|
9eaa01617c | ||
|
|
fdca12968b | ||
|
|
94fcc40bde | ||
|
|
fb2c843fa9 | ||
|
|
bb6cb6e335 | ||
|
|
bdbff89507 |
+7
-4
@@ -45,12 +45,15 @@ RUN adduser --disabled-password --gecos "" appuser \
|
||||
&& mkdir -p /downloads \
|
||||
&& chown appuser /app /downloads
|
||||
|
||||
# ffmpeg: merge separate video+audio streams + postprocessing. Deno: the JavaScript runtime
|
||||
# yt-dlp uses to solve YouTube's "n" signature challenge for the web player clients (required
|
||||
# alongside the PO token, else only storyboards are offered). yt-dlp auto-detects deno on PATH.
|
||||
# ffmpeg: merge separate video+audio streams + postprocessing, and the Plex VAAPI transcode (E6 S1).
|
||||
# intel-media-va-driver (iHD): the VA driver ffmpeg's h264_vaapi needs — libva2 ships in the base but
|
||||
# no driver, so without this `vainfo`/`h264_vaapi` fail and transcode silently falls back to libx264.
|
||||
# Harmless on hosts with no /dev/dri (the driver just goes unused). Deno: the JavaScript runtime yt-dlp
|
||||
# uses to solve YouTube's "n" signature challenge for the web player clients (required alongside the PO
|
||||
# token, else only storyboards are offered). yt-dlp auto-detects deno on PATH.
|
||||
ARG DENO_VERSION=v2.9.1
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ffmpeg unzip curl ca-certificates \
|
||||
&& apt-get install -y --no-install-recommends ffmpeg intel-media-va-driver unzip curl ca-certificates \
|
||||
&& curl -fsSL "https://github.com/denoland/deno/releases/download/${DENO_VERSION}/deno-x86_64-unknown-linux-gnu.zip" -o /tmp/deno.zip \
|
||||
&& unzip -o /tmp/deno.zip -d /usr/local/bin \
|
||||
&& chmod +x /usr/local/bin/deno \
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
"""media_assets.media_kind (E7 S5a: the video|audio|file discriminator for the media library)
|
||||
|
||||
Adds a stored media-kind to each asset so the library can gate its actions on it (play/edit for
|
||||
media, download-only for a generic uploaded file) instead of re-deriving from codecs in prose. The
|
||||
column is the single home of the invariant `downloads.mediakind.derive` computes at finalize.
|
||||
|
||||
Backfill for existing rows: a stored video codec -> 'video', an audio-only codec -> 'audio', an
|
||||
editor derivative (source_kind='edit', which carries no codecs but is always a video clip) ->
|
||||
'video'. Rows still pending/errored (no codecs, not an edit) stay NULL — kind is set when they
|
||||
finalize. Empty `media_kind` never reaches the UI: only `ready`/`done` rows render actions, and
|
||||
those are all backfilled here.
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0076_media_asset_kind"
|
||||
down_revision: Union[str, None] = "0075_playlist_sync_baseline"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("media_assets", sa.Column("media_kind", sa.String(length=16), nullable=True))
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE media_assets
|
||||
SET media_kind = CASE
|
||||
WHEN vcodec IS NOT NULL THEN 'video'
|
||||
WHEN acodec IS NOT NULL THEN 'audio'
|
||||
WHEN source_kind = 'edit' THEN 'video'
|
||||
ELSE NULL
|
||||
END
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("media_assets", "media_kind")
|
||||
+19
-2
@@ -208,6 +208,10 @@ class Settings(BaseSettings):
|
||||
download_default_max_bytes: int = 5_368_709_120 # 5 GiB
|
||||
download_default_max_concurrent: int = 2
|
||||
download_default_max_jobs: int = 50
|
||||
# Per-file cap for local uploads (E7). Unlike a download (whose size is unknown until the worker
|
||||
# finishes), an upload's size is known at ingest, so this bounds a single file directly; the
|
||||
# user's overall byte footprint is still governed by download_default_max_bytes.
|
||||
download_upload_max_bytes: int = 524_288_000 # 500 MiB
|
||||
# Retention: an asset expires this many days after its last access; the GC job then
|
||||
# deletes it (once no job references it) after an additional grace window.
|
||||
download_retention_days: int = 30
|
||||
@@ -254,9 +258,22 @@ class Settings(BaseSettings):
|
||||
# that catches what the incremental history feed can't — notably an un-watch done on the Plex
|
||||
# side — so it runs far less often (daily) than the incremental pass.
|
||||
plex_watch_reconcile_interval_min: int = 1440
|
||||
# Max concurrent transcodes for the P3 fallback. Low by default — CPU-only transcode is
|
||||
# expensive; direct-serve (browser-compatible files) has no such limit.
|
||||
# Max concurrent transcode sessions (playable="transcode"). Low by default — the software-fallback
|
||||
# re-encode is CPU-heavy (the VAAPI hardware path is far cheaper and could sustain more); direct-serve
|
||||
# and remux (stream-copy) are not capped by this.
|
||||
plex_max_transcodes: int = 4
|
||||
# --- Transcode (playable="transcode") re-encode tuning (E6 S1) ---
|
||||
# Hardware-encoder policy: "auto" = VAAPI (h264_vaapi) when the iGPU render node is present AND the
|
||||
# source codec is hardware-decodable, else libx264 software; "on" = force VAAPI wherever the render
|
||||
# node exists (skip the decodability gate); "off" = always software. See plex/stream.py `_want_hw`.
|
||||
plex_transcode_hw: str = "auto"
|
||||
# Software-fallback libx264 tuning (IGNORED on the VAAPI path). `preset` trades CPU for size; `crf`
|
||||
# trades quality for size (lower = better + bigger). `max_height` caps the SOFTWARE transcode's output
|
||||
# height (0 = keep source) so a CPU re-encode of a tall source stays affordable — the GPU path always
|
||||
# encodes at source resolution.
|
||||
plex_transcode_preset: str = "veryfast"
|
||||
plex_transcode_crf: int = 20
|
||||
plex_transcode_max_height: int = 1080
|
||||
# Where on-the-fly HLS remux segments are written (transient, reaped). Empty → a `.plex-hls`
|
||||
# dir under download_root. On localdev the download_root is a slow Windows bind-mount, so point
|
||||
# this at a fast container-local path (e.g. /var/tmp/plex-hls); prod's download_root is fast
|
||||
|
||||
@@ -438,18 +438,42 @@ _BROWSER_SAFE_VCODEC = ("h264",)
|
||||
_BROWSER_SAFE_ACODEC = ("aac", "mp3")
|
||||
|
||||
|
||||
def parse_probe_streams(payload: str) -> tuple[str | None, str | None]:
|
||||
"""(video_codec, audio_codec) out of ffprobe's JSON stream list, lowercased.
|
||||
|
||||
JSON, not the 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."""
|
||||
def run_ffprobe_json(
|
||||
path: Path,
|
||||
*,
|
||||
show_streams: bool = False,
|
||||
show_format: bool = False,
|
||||
entries: str | None = None,
|
||||
) -> dict | None:
|
||||
"""One ffprobe call → its parsed JSON dict, or None on any failure (timeout, non-zero exit,
|
||||
missing binary, unparseable output). The SINGLE ffprobe-JSON-invocation point in the downloads
|
||||
package: the browser-playability codec probe here and the upload-ingest probe (`upload._probe`)
|
||||
both go through it, so the subprocess flags/timeout/error handling live in one place instead of
|
||||
two drifting copies."""
|
||||
args = ["ffprobe", "-v", "error"]
|
||||
if show_streams:
|
||||
args.append("-show_streams")
|
||||
if show_format:
|
||||
args.append("-show_format")
|
||||
if entries:
|
||||
args += ["-show_entries", entries]
|
||||
args += ["-of", "json", str(path)]
|
||||
try:
|
||||
streams = json.loads(payload or "{}").get("streams") or []
|
||||
except ValueError:
|
||||
return None, None
|
||||
out = subprocess.run(args, capture_output=True, text=True, timeout=30, check=True).stdout
|
||||
return json.loads(out)
|
||||
except (subprocess.TimeoutExpired, subprocess.CalledProcessError, OSError, ValueError):
|
||||
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
|
||||
@@ -464,26 +488,10 @@ def parse_probe_streams(payload: str) -> tuple[str | None, str | None]:
|
||||
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)."""
|
||||
try:
|
||||
out = subprocess.run(
|
||||
[
|
||||
"ffprobe",
|
||||
"-v",
|
||||
"error",
|
||||
"-show_entries",
|
||||
"stream=codec_type,codec_name",
|
||||
"-of",
|
||||
"json",
|
||||
str(path),
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
check=True,
|
||||
).stdout
|
||||
except (subprocess.TimeoutExpired, subprocess.CalledProcessError, OSError):
|
||||
data = run_ffprobe_json(path, entries="stream=codec_type,codec_name")
|
||||
if data is None:
|
||||
return None, None
|
||||
return parse_probe_streams(out)
|
||||
return _codecs_from_streams(data.get("streams") or [])
|
||||
|
||||
|
||||
def probe_duration(path: Path) -> int:
|
||||
|
||||
@@ -5,13 +5,16 @@ of truth for what file gets produced. `format_sig` hashes exactly the fields tha
|
||||
OUTPUT BYTES, so two users picking equivalent settings share one cached MediaAsset.
|
||||
`build_ydl_opts` turns a spec into a yt-dlp options dict (format selector + postprocessors).
|
||||
|
||||
spec shape (all keys optional; defaults applied by `normalize`):
|
||||
spec shape (all keys optional; defaults + browser-playability clamp applied by `normalize`):
|
||||
mode "av" (audio+video) | "v" (video-only) | "a" (audio-only)
|
||||
max_height int | null (null = best)
|
||||
container "mp4" | "mkv" | "webm" | null (merge/remux target for av/v)
|
||||
audio_format "m4a" | "mp3" | "opus" | null (extract target for mode "a")
|
||||
vcodec "h264" | "vp9" | "av1" | null (preference, best-effort)
|
||||
container always coerced to "mp4" (the only universally browser-playable container)
|
||||
audio_format "m4a" | "mp3" (mode "a" extract target; anything else — e.g. opus — → "m4a")
|
||||
vcodec always coerced to null → H.264 selected (see `normalize`); no opt-out
|
||||
embed_subs bool embed_chapters bool embed_thumbnail bool sponsorblock bool
|
||||
|
||||
`normalize` clamps container/vcodec/audio_format so EVERY download plays in-browser (E7); the
|
||||
`container`/`vcodec` keys survive only so an old stored profile deserializes without error.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
@@ -50,10 +53,12 @@ _DEFAULTS = {
|
||||
# prefer H.264+AAC (browser/iOS-compatible) rather than yt-dlp's default VP9/Opus best.
|
||||
# v3: mp4 compat now *selects* H.264 (avc1) with a progressive fallback + re-encodes the rare
|
||||
# AV1/VP9-only result to H.264, so cached AV1 assets must re-derive (fixes iOS broken-player).
|
||||
_SIG_VERSION = 3
|
||||
# v4: EVERY download is clamped to the mp4/H.264/AAC compat profile (no more mkv/webm/VP9/AV1/Opus
|
||||
# opt-out), so any asset cached under an old non-compat spec — or a stale non-H.264 mp4 that slipped
|
||||
# the v3 net — must re-derive through the current browser-safe guarantee.
|
||||
_SIG_VERSION = 4
|
||||
|
||||
_SPONSORBLOCK_CATEGORIES = ["sponsor", "selfpromo", "interaction"]
|
||||
_VCODEC_SORT = {"h264": "vcodec:h264", "vp9": "vcodec:vp9", "av1": "vcodec:av01"}
|
||||
|
||||
|
||||
def normalize(spec: dict) -> dict:
|
||||
@@ -76,6 +81,18 @@ def normalize(spec: dict) -> dict:
|
||||
# Audio-only files can't embed subtitles/thumbnails as video; keep the flags meaningful.
|
||||
if s["mode"] == "a":
|
||||
s["embed_subs"] = False
|
||||
# Browser-playability guarantee (E7): the Download module exists to download → share → PLAY
|
||||
# in-browser, so every output must play in EVERY browser, iOS/Safari/WebKit included. The only
|
||||
# universally-safe combo is mp4 + H.264 + AAC/MP3, so we CLAMP the spec to it HERE — the single
|
||||
# point every path (picker, stored profile, raw API) flows through — instead of only hiding
|
||||
# options in the UI. Non-mp4 containers (mkv doesn't play at all; webm/VP9/AV1 fail iOS) and any
|
||||
# explicit vcodec are coerced to the compat profile; Opus audio (Safari-flaky as a bare file)
|
||||
# falls back to AAC/m4a. A source that exposes no H.264 is still caught by the worker's
|
||||
# browser-safe re-encode (`_ensure_browser_playable`), so playback is guaranteed end-to-end.
|
||||
s["container"] = "mp4"
|
||||
s["vcodec"] = None
|
||||
if s["audio_format"] not in ("m4a", "mp3"):
|
||||
s["audio_format"] = "m4a"
|
||||
return s
|
||||
|
||||
|
||||
@@ -153,51 +170,33 @@ def build_ydl_opts(
|
||||
)
|
||||
else:
|
||||
hcap = f"[height<=?{h}]" if h else ""
|
||||
# Compatibility-first for mp4 (the universal container): produce H.264 video + AAC audio
|
||||
# so shared/downloaded files play in EVERY browser, including iOS/Safari. WebKit (which
|
||||
# every iOS browser, Brave included, is forced to use) decodes only H.264/H.265 + AAC
|
||||
# inside mp4 — a VP9/AV1-in-mp4 file shows a broken player there, even though Chromium
|
||||
# plays it fine. A custom profile that explicitly sets `vcodec` opts out.
|
||||
compat = s["container"] == "mp4" and s["vcodec"] is None
|
||||
# Every video download is the mp4/H.264 compat profile (normalize() clamps container→mp4,
|
||||
# vcodec→None), so shared/downloaded files play in EVERY browser, iOS/Safari included. WebKit
|
||||
# (which every iOS browser, Brave included, is forced to use) decodes only H.264/H.265 + AAC
|
||||
# inside mp4 — a VP9/AV1-in-mp4 file shows a broken player there — so H.264 is selected at the
|
||||
# SELECTION level, not merely preferred: a sort key can't conjure an H.264 stream a source
|
||||
# doesn't expose as video-only. A source that ships only non-H.264 is caught after download by
|
||||
# the worker's browser-safe re-encode (`_ensure_browser_playable`).
|
||||
if s["mode"] == "v":
|
||||
if compat:
|
||||
# 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 — same audio-free-when-possible guarantee as the old `bestvideo/best`.
|
||||
opts["format"] = (
|
||||
f"bestvideo[vcodec^=avc1]{hcap}/bestvideo{hcap}"
|
||||
f"/best[vcodec^=avc1]{hcap}/best{hcap}"
|
||||
)
|
||||
else:
|
||||
opts["format"] = f"bestvideo{hcap}/best{hcap}"
|
||||
# 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.
|
||||
opts["format"] = (
|
||||
f"bestvideo[vcodec^=avc1]{hcap}/bestvideo{hcap}/best[vcodec^=avc1]{hcap}/best{hcap}"
|
||||
)
|
||||
else: # av
|
||||
if compat:
|
||||
# Prefer H.264 at the SELECTION level, not just via format_sort: a sort key is only
|
||||
# a *preference* and can't conjure an H.264 stream a source doesn't expose as
|
||||
# video-only. A non-YouTube source (e.g. Facebook) may ship AV1 as the best
|
||||
# video-only stream and H.264 only as a *progressive* (a+v) format — which
|
||||
# `bestvideo` skips — so `bestvideo+bestaudio` picked AV1 and iOS broke. This chain
|
||||
# tries avc1 video-only, then the avc1 progressive, then anything.
|
||||
opts["format"] = (
|
||||
f"bestvideo[vcodec^=avc1]{hcap}+bestaudio[acodec^=mp4a]"
|
||||
f"/best[vcodec^=avc1]{hcap}"
|
||||
f"/bestvideo{hcap}+bestaudio/best{hcap}"
|
||||
)
|
||||
else:
|
||||
opts["format"] = f"bestvideo{hcap}+bestaudio/best{hcap}"
|
||||
if s["container"]:
|
||||
opts["merge_output_format"] = s["container"]
|
||||
sort = []
|
||||
# Keep the codec preference as a tiebreaker (ranks H.264/AAC above resolution among the
|
||||
# selected candidates); the selector above is the hard guarantee, this is belt-and-braces.
|
||||
if compat:
|
||||
sort += ["vcodec:h264", "acodec:aac"]
|
||||
# avc1 video-only + m4a audio, then the avc1 progressive, then anything (re-encoded).
|
||||
opts["format"] = (
|
||||
f"bestvideo[vcodec^=avc1]{hcap}+bestaudio[acodec^=mp4a]"
|
||||
f"/best[vcodec^=avc1]{hcap}"
|
||||
f"/bestvideo{hcap}+bestaudio/best{hcap}"
|
||||
)
|
||||
opts["merge_output_format"] = "mp4"
|
||||
# Codec preference tiebreaker (ranks H.264/AAC among the selected candidates); the selector
|
||||
# above is the hard guarantee, this is belt-and-braces.
|
||||
sort = ["vcodec:h264", "acodec:aac"]
|
||||
if h:
|
||||
sort.append(f"res:{h}")
|
||||
if s["vcodec"] in _VCODEC_SORT:
|
||||
sort.append(_VCODEC_SORT[s["vcodec"]])
|
||||
if sort:
|
||||
opts["format_sort"] = sort
|
||||
opts["format_sort"] = sort
|
||||
|
||||
# SponsorBlock first (marks segments), then strip them from the media + chapters.
|
||||
if s["sponsorblock"]:
|
||||
|
||||
@@ -80,13 +80,13 @@ def public_meta(
|
||||
|
||||
Resolves the owner's per-download display overrides (custom title/channel) over the asset's
|
||||
auto-extracted values, and surfaces the source + any extra reference links as clickable URLs."""
|
||||
from app.downloads import service
|
||||
from app.downloads import mediakind, service
|
||||
|
||||
source_url = service.reference_url(job, asset)
|
||||
# Audio-only assets (mp3 rips, extracted audio) carry a codec but no video stream. WebKit (all
|
||||
# iOS browsers) refuses to play them inside a <video> element, so the watch page must render a
|
||||
# native <audio> instead — flag it here from the recorded codecs (a real video always has vcodec).
|
||||
is_audio = asset.vcodec is None and asset.acodec is not None
|
||||
# Audio-only assets (mp3 rips, extracted audio) can't play inside a <video> element on WebKit
|
||||
# (all iOS browsers), so the watch page renders a native <audio> instead. A generic uploaded
|
||||
# file (media_kind='file') has no player at all — the watch page shows file info + a download.
|
||||
# Both branches read the stored media_kind (the single source), not a re-derived codec check.
|
||||
return {
|
||||
"title": (job and job.display_name) or asset.title,
|
||||
"uploader": (job and job.display_uploader) or asset.uploader,
|
||||
@@ -96,7 +96,10 @@ def public_meta(
|
||||
"duration_s": asset.duration_s,
|
||||
"width": asset.width,
|
||||
"height": asset.height,
|
||||
"audio": is_audio,
|
||||
"audio": asset.media_kind == mediakind.AUDIO,
|
||||
"media_kind": asset.media_kind,
|
||||
"size_bytes": asset.size_bytes,
|
||||
"container": asset.container,
|
||||
"allow_download": link.allow_download,
|
||||
"file_url": file_url,
|
||||
"poster_url": poster_url,
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
"""The media-kind discriminator for a library asset — the single home of one invariant.
|
||||
|
||||
`MediaAsset.media_kind` is `"video" | "audio" | "file"`. It decides which library actions apply:
|
||||
media (video/audio) gets play/edit/share; a generic uploaded file gets download + share only, and
|
||||
is never rendered inline (see the force-download serving in the file/watch routes). Every finalize
|
||||
path computes it HERE — the download worker + the upload ingest via `derive`, the editor by
|
||||
carrying the source's kind over — so the rule lives in code, not in a comment that can drift (an
|
||||
earlier ad-hoc `vcodec is None and acodec is not None` audio check lived only in links.py).
|
||||
"""
|
||||
|
||||
VIDEO = "video"
|
||||
AUDIO = "audio"
|
||||
FILE = "file"
|
||||
|
||||
|
||||
def derive(vcodec: str | None, acodec: str | None) -> str:
|
||||
"""Media kind from the probed codecs: a video stream -> video, audio-only -> audio, neither
|
||||
(a non-media upload: pdf/zip/docx/…) -> file."""
|
||||
if vcodec:
|
||||
return VIDEO
|
||||
if acodec:
|
||||
return AUDIO
|
||||
return FILE
|
||||
@@ -15,7 +15,7 @@ from app.config import settings
|
||||
from app.db import SessionLocal
|
||||
from app.downloads import edit as editmod
|
||||
from app.downloads import errors as dlerrors
|
||||
from app.downloads import adapters, credentials, formats, service, storage
|
||||
from app.downloads import adapters, credentials, formats, mediakind, service, storage
|
||||
from app.downloads.jobdb import (
|
||||
_edit_staging_dir,
|
||||
_job_status,
|
||||
@@ -484,6 +484,7 @@ def _download(
|
||||
asset.acodec = (
|
||||
info.get("acodec") if info.get("acodec") not in (None, "none") else None
|
||||
)
|
||||
asset.media_kind = mediakind.derive(asset.vcodec, asset.acodec)
|
||||
asset.title = meta.title
|
||||
asset.uploader = meta.uploader
|
||||
asset.uploader_url = adapter.uploader_url(info)
|
||||
|
||||
@@ -122,6 +122,22 @@ def check_enqueue(db: Session, user_id: int) -> None:
|
||||
raise QuotaExceeded("max_bytes", lim.max_bytes, used)
|
||||
|
||||
|
||||
def check_upload(db: Session, user_id: int, add_bytes: int) -> None:
|
||||
"""Like check_enqueue, but for an upload whose size is KNOWN at ingest: also reject when adding
|
||||
this file would push the footprint over max_bytes. A download can't pre-check its size (unknown
|
||||
until the worker finishes) so check_enqueue only compares the current footprint; an upload can,
|
||||
so it's charged against the cap before it lands. Raises QuotaExceeded(max_jobs|max_bytes)."""
|
||||
lim = resolve(db, user_id)
|
||||
if lim.unlimited:
|
||||
return
|
||||
n = active_jobs(db, user_id)
|
||||
if n >= lim.max_jobs:
|
||||
raise QuotaExceeded("max_jobs", lim.max_jobs, n)
|
||||
projected = footprint(db, user_id) + add_bytes
|
||||
if projected > lim.max_bytes:
|
||||
raise QuotaExceeded("max_bytes", lim.max_bytes, projected)
|
||||
|
||||
|
||||
def usage(db: Session, user_id: int) -> dict:
|
||||
"""Compact usage snapshot for the UI (footprint + counts vs limits)."""
|
||||
lim = resolve(db, user_id)
|
||||
|
||||
@@ -17,7 +17,7 @@ from app.config import settings
|
||||
from app.downloads import adapters
|
||||
from app.downloads import credentials
|
||||
from app.downloads import edit as editmod
|
||||
from app.downloads import formats, quota, storage
|
||||
from app.downloads import formats, mediakind, quota, storage
|
||||
from app.downloads.status import HOLDING, JobStatus
|
||||
from app.models import Channel, DownloadJob, MediaAsset, Video
|
||||
|
||||
@@ -216,6 +216,10 @@ def enqueue_edit(
|
||||
src_asset = db.get(MediaAsset, source_job.asset_id) if source_job.asset_id else None
|
||||
if src_asset is None or src_asset.status != "ready" or not src_asset.rel_path:
|
||||
raise EditError("source_not_ready")
|
||||
# A generic uploaded file (media_kind='file') has no timeline to trim/crop — the editor path is
|
||||
# media-only. Reject here so an upload's file never reaches the ffmpeg edit worker.
|
||||
if src_asset.media_kind == mediakind.FILE:
|
||||
raise EditError("not_media")
|
||||
|
||||
spec = editmod.normalize_edit_spec(edit_spec)
|
||||
if not spec:
|
||||
@@ -232,6 +236,9 @@ def enqueue_edit(
|
||||
asset.thumbnail_url = src_asset.thumbnail_url
|
||||
asset.upload_date = src_asset.upload_date
|
||||
asset.duration_s = editmod.clip_duration(spec, src_asset.duration_s)
|
||||
# A clip inherits its source's kind (a video edit stays video, an audio trim stays audio);
|
||||
# the ffmpeg edit worker doesn't re-probe codecs, so set it here where the source is known.
|
||||
asset.media_kind = src_asset.media_kind or mediakind.VIDEO
|
||||
|
||||
job = DownloadJob(
|
||||
user_id=user_id,
|
||||
|
||||
@@ -19,6 +19,8 @@ from datetime import date
|
||||
from pathlib import Path
|
||||
from xml.sax.saxutils import escape
|
||||
|
||||
from app.downloads import mediakind
|
||||
|
||||
# Reserve room for the "_id.ext" suffix + parent dirs within the 255-char component limit.
|
||||
_MAX_TITLE = 120
|
||||
# User-facing (Content-Disposition) names can be longer than an on-disk component.
|
||||
@@ -147,6 +149,81 @@ def edit_rel_path(user_id: int, asset_id: int, ext: str) -> str:
|
||||
return f".edits/{user_id}/clip_{asset_id}.{ext}"
|
||||
|
||||
|
||||
def _clean_upload_ext(filename: str) -> str:
|
||||
"""The upload's extension reduced to a shell-safe, lowercased, bounded token (``""`` when the
|
||||
upload has no extension). The SINGLE definition feeding both the on-disk suffix and the stored
|
||||
`container` — they must agree (the served Content-Disposition suffix has to match the container),
|
||||
so the sanitize/truncation rule lives in one place, not two drifting copies."""
|
||||
return _UNSAFE.sub("", Path(filename).suffix.lstrip(".")).lower()[:16]
|
||||
|
||||
|
||||
def upload_rel_path(user_id: int, asset_id: int, filename: str) -> str:
|
||||
"""On-disk path (relative to DOWNLOAD_ROOT) for a locally-uploaded file.
|
||||
|
||||
Kept in a per-user `.uploads/` tree — device-downloadable via the file endpoint, but NOT part of
|
||||
the Plex library (an upload is arbitrary user content, not a canonical episode; no .nfo/poster
|
||||
episode sidecars). The name is system-decided and bound to the asset id (`_a{id}` keeps two
|
||||
same-named uploads apart); the original filename is display metadata only. The extension is
|
||||
preserved (shell-safe, bounded) so the served file + its Content-Disposition carry a correct
|
||||
suffix; an extensionless upload stays extensionless."""
|
||||
stem = sanitize(Path(filename).stem, 80)
|
||||
ext = _clean_upload_ext(filename)
|
||||
suffix = f".{ext}" if ext else ""
|
||||
return f".uploads/{user_id}/{stem}_a{asset_id}{suffix}"
|
||||
|
||||
|
||||
def upload_ext(filename: str) -> str | None:
|
||||
"""The stored `container` for an uploaded file: its shell-safe lowercased extension, or None when
|
||||
the upload has no extension (a bare name). Shares `_clean_upload_ext` with `upload_rel_path` so the
|
||||
stored container and the on-disk suffix can never diverge."""
|
||||
return _clean_upload_ext(filename) or None
|
||||
|
||||
|
||||
# The Content-Type used to serve an inline-playable media asset, keyed by its stored `container`.
|
||||
# DELIBERATELY a strict allow-list of known audio/video containers: the served type is chosen from
|
||||
# the asset's ffprobe-verified media_kind + this table, NEVER guessed from the raw (user-controlled)
|
||||
# upload extension. Without this, an uploaded media/HTML polyglot named "evil.html" — a real MP3/MP4
|
||||
# that ffprobe classifies as media, so it dodges the generic-file force-download — would be served
|
||||
# with a Content-Type Starlette guesses from ".html" (text/html) and execute as stored XSS on the
|
||||
# serving origin. A media asset whose container isn't in this table falls back to a neutral
|
||||
# force-download attachment (see `served_media_type`), so nothing script-executable is ever inline.
|
||||
_MEDIA_MIME: dict[str, str] = {
|
||||
# video containers
|
||||
"mp4": "video/mp4",
|
||||
"m4v": "video/mp4",
|
||||
"mov": "video/quicktime",
|
||||
"webm": "video/webm",
|
||||
"mkv": "video/x-matroska",
|
||||
"ogv": "video/ogg",
|
||||
"avi": "video/x-msvideo",
|
||||
# audio containers
|
||||
"m4a": "audio/mp4",
|
||||
"mp3": "audio/mpeg",
|
||||
"aac": "audio/aac",
|
||||
"ogg": "audio/ogg",
|
||||
"oga": "audio/ogg",
|
||||
"opus": "audio/ogg",
|
||||
"weba": "audio/webm",
|
||||
"wav": "audio/wav",
|
||||
"flac": "audio/flac",
|
||||
}
|
||||
|
||||
|
||||
def served_media_type(media_kind: str | None, container: str | None) -> str | None:
|
||||
"""The Content-Type a library file MUST be served with — a real audio/video MIME for a recognised
|
||||
media container, else ``None`` meaning "serve as a neutral force-download attachment".
|
||||
|
||||
Returns None for a generic ``file`` kind AND for any media whose container we don't positively
|
||||
recognise as safe. Callers serve None as ``application/octet-stream`` + ``attachment`` so the
|
||||
served Content-Type is NEVER derived from the (attacker-controlled) file extension — the guard
|
||||
that stops uploaded HTML/SVG or a media/HTML polyglot from executing inline on the serving
|
||||
origin. A ``None`` media_kind (a legacy asset before the discriminator was backfilled) is treated
|
||||
as media so an old download keeps its inline player."""
|
||||
if media_kind == mediakind.FILE:
|
||||
return None
|
||||
return _MEDIA_MIME.get((container or "").lower())
|
||||
|
||||
|
||||
def download_filename(display_name: str, container: str | None, path: Path) -> str:
|
||||
"""The Content-Disposition filename for a served download: the cleaned display name carrying a single
|
||||
correct extension — the asset container, else the file's own suffix — without doubling it when the
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
"""Local file ingest for the download center (E7 S5) — the non-yt-dlp path into the media library.
|
||||
|
||||
A user-uploaded file joins the SAME library as a downloaded one, so it inherits the whole function
|
||||
set already built (metadata chips, share links, and — for media — in-app play/edit). Unlike a
|
||||
download it bypasses yt-dlp AND the worker entirely: the file is already on the host, so ingest
|
||||
probes it, places it on disk, and creates an already-`ready` MediaAsset + `done` DownloadJob in one
|
||||
step — the same "the file exists, mark it done now" shortcut `service.enqueue` takes on a cache hit.
|
||||
|
||||
Kinds (the invariant lives in `mediakind`): a probe with a real video stream -> video, audio-only
|
||||
-> audio, neither -> a generic 'file' (pdf/zip/docx/…) that is download-only and never rendered
|
||||
inline (see the force-download serving in the file/watch routes). This module is the ingest seam a
|
||||
future sub-function (e.g. convert) plugs a new adapter into, mirroring how it sidesteps the worker.
|
||||
|
||||
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
|
||||
does — it lives until the user deletes it.
|
||||
"""
|
||||
|
||||
import secrets
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import settings
|
||||
from app.downloads import edit as editmod
|
||||
from app.downloads import mediakind, quota, service, storage
|
||||
from app.models import DownloadJob, MediaAsset
|
||||
|
||||
# ffprobe container formats that mean "this is a still image / image sequence", NOT playable media.
|
||||
# A standalone jpg/png/gif/webp/bmp reports a real video stream (codec mjpeg/png/…) with no
|
||||
# `attached_pic` flag, so without this guard it would classify as media_kind='video' and get a Play
|
||||
# button + a broken in-app <video> player (and inline serving on the public share). Keyed on the
|
||||
# container format so an animated gif/webp also lands as a generic 'file' (no browser plays those in
|
||||
# a <video> either). SVG and anything else ffprobe can't decode already fails the probe → 'file'.
|
||||
_IMAGE_FORMATS = {
|
||||
"image2",
|
||||
"image2pipe",
|
||||
"png_pipe",
|
||||
"mjpeg",
|
||||
"mjpeg_pipe",
|
||||
"jpeg_pipe",
|
||||
"gif",
|
||||
"webp_pipe",
|
||||
"bmp_pipe",
|
||||
"tiff_pipe",
|
||||
"ico",
|
||||
"svg_pipe",
|
||||
}
|
||||
|
||||
|
||||
def _probe(path: Path) -> tuple[str | None, str | None, int | None, int | None, int | None]:
|
||||
"""(vcodec, acodec, width, height, duration_s) from one ffprobe call; all None if ffprobe fails
|
||||
or the file isn't playable media (an unprobeable file — or a still image — is treated as a generic
|
||||
non-media upload, so it becomes a download-only 'file' rather than broken 'video').
|
||||
|
||||
A video stream flagged `attached_pic` (embedded cover art in an mp3/m4a) is skipped — it's a
|
||||
thumbnail, not a real video track, so an audio file with album art still classifies as audio."""
|
||||
data = editmod.run_ffprobe_json(path, show_streams=True, show_format=True)
|
||||
if data is None:
|
||||
return None, None, None, None, None
|
||||
|
||||
fmt = data.get("format") or {}
|
||||
fmt_names = {n.strip() for n in (fmt.get("format_name") or "").split(",")}
|
||||
if fmt_names & _IMAGE_FORMATS:
|
||||
# A still image / image sequence is a generic file, not video — no vcodec means media_kind='file'.
|
||||
return None, None, None, None, None
|
||||
|
||||
vcodec = acodec = None
|
||||
width = height = None
|
||||
for stream in data.get("streams", []):
|
||||
kind = stream.get("codec_type")
|
||||
if kind == "video" and vcodec is None:
|
||||
if (stream.get("disposition") or {}).get("attached_pic"):
|
||||
continue # cover art, not a video track
|
||||
vcodec = stream.get("codec_name")
|
||||
width = stream.get("width")
|
||||
height = stream.get("height")
|
||||
elif kind == "audio" and acodec is None:
|
||||
acodec = stream.get("codec_name")
|
||||
|
||||
duration = None
|
||||
try:
|
||||
raw = fmt.get("duration")
|
||||
if raw is not None:
|
||||
duration = int(float(raw))
|
||||
except (TypeError, ValueError):
|
||||
duration = None
|
||||
return vcodec, acodec, width, height, duration
|
||||
|
||||
|
||||
def _title_from_filename(filename: str) -> str:
|
||||
"""A human display title from the original upload name: the stem with separators tidied to
|
||||
spaces (kept as-is otherwise — this is display metadata, not the shell-safe on-disk name)."""
|
||||
stem = Path(filename).stem.replace("_", " ").replace(".", " ").strip()
|
||||
return (stem or "Upload")[:255]
|
||||
|
||||
|
||||
def ingest_upload(
|
||||
db: Session,
|
||||
user_id: int,
|
||||
*,
|
||||
filename: str,
|
||||
src_path: Path,
|
||||
size: int,
|
||||
display_name: str | None = None,
|
||||
) -> DownloadJob:
|
||||
"""Ingest an already-uploaded file at `src_path` into `user_id`'s library and return the `done`
|
||||
job. `src_path` is MOVED into place on success (the caller stages it to a temp file first).
|
||||
|
||||
Raises quota.QuotaExceeded (holdings/footprint cap) — checked BEFORE any disk write, so a
|
||||
rejected upload leaves nothing behind and the caller just cleans up its temp file."""
|
||||
quota.check_upload(db, user_id, size) # the file's size is known → charge it up front
|
||||
|
||||
vcodec, acodec, width, height, duration = _probe(src_path)
|
||||
kind = mediakind.derive(vcodec, acodec)
|
||||
|
||||
# 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.
|
||||
ref = f"{user_id}:{secrets.token_hex(8)}"[:512]
|
||||
asset = MediaAsset(
|
||||
source_kind="upload", source_ref=ref, format_sig="upload", status="pending", ref_count=0
|
||||
)
|
||||
db.add(asset)
|
||||
db.flush() # need asset.id for the on-disk path
|
||||
|
||||
rel = storage.upload_rel_path(user_id, asset.id, filename)
|
||||
storage.place_file(src_path, settings.download_root, rel)
|
||||
# From here the file is on disk under `.uploads/`. If the transaction fails before it commits, the
|
||||
# asset/job rows roll back but the placed file would be ORPHANED — nothing references it, the
|
||||
# per-user delete path keys off asset.rel_path (which is gone), and the GC orphan sweep skips the
|
||||
# dot-prefixed `.uploads/` tree. So roll back the on-disk side too on any failure.
|
||||
try:
|
||||
final = storage.abs_path(settings.download_root, rel)
|
||||
actual_size = final.stat().st_size if final.exists() else size
|
||||
|
||||
asset.status = "ready"
|
||||
asset.rel_path = rel
|
||||
asset.size_bytes = actual_size
|
||||
asset.container = storage.upload_ext(filename)
|
||||
asset.vcodec = vcodec
|
||||
asset.acodec = acodec
|
||||
asset.width = width
|
||||
asset.height = height
|
||||
asset.duration_s = duration
|
||||
asset.media_kind = kind
|
||||
asset.title = (display_name or "").strip() or _title_from_filename(filename)
|
||||
asset.last_access_at = datetime.now(timezone.utc)
|
||||
# No expires_at — see the module docstring (an upload is irreplaceable, so it doesn't age out).
|
||||
if kind == mediakind.VIDEO:
|
||||
# A frame poster gives the library row a thumbnail + the share link an og:image. Audio has
|
||||
# no frame and a generic file no player, so neither gets one. Best-effort (ffmpeg decodes
|
||||
# server side even for a codec the browser can't play).
|
||||
asset.poster_path = editmod.ensure_poster(asset, settings.download_root)
|
||||
|
||||
job = DownloadJob(
|
||||
user_id=user_id,
|
||||
asset_id=asset.id,
|
||||
job_kind="upload",
|
||||
source_kind="upload",
|
||||
source_ref=ref,
|
||||
profile_snapshot={},
|
||||
display_name=(display_name or "").strip() or None,
|
||||
status="done",
|
||||
progress=100,
|
||||
queue_pos=service.next_queue_pos(db),
|
||||
)
|
||||
db.add(job)
|
||||
service.incr_ref_count(db, asset.id)
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
# The rows are gone; remove the now-unreferenced file so no bytes leak. Clean the poster by its
|
||||
# DETERMINISTIC path (rel with a .jpg suffix, the same path ensure_poster writes to) rather than
|
||||
# ensure_poster's return value: it writes the frame directly to that path and returns None on an
|
||||
# ffmpeg failure, so a partial poster can be on disk while the return is None — gating on the
|
||||
# return would skip that orphan. unlink is missing_ok, so the poster path is a harmless no-op
|
||||
# when none was generated (audio/file, or a video whose poster never started).
|
||||
for leaked in (rel, str(Path(rel).with_suffix(".jpg"))):
|
||||
storage.abs_path(settings.download_root, leaked).unlink(missing_ok=True)
|
||||
raise
|
||||
db.refresh(job)
|
||||
return job
|
||||
@@ -708,6 +708,12 @@ class MediaAsset(Base, TimestampMixin):
|
||||
container: Mapped[str | None] = mapped_column(String(16))
|
||||
vcodec: Mapped[str | None] = mapped_column(String(32))
|
||||
acodec: Mapped[str | None] = mapped_column(String(32))
|
||||
# "video" | "audio" | "file" — the media-kind discriminator that drives which library actions
|
||||
# apply (play/edit for media; download-only for a generic uploaded file). Null while an asset is
|
||||
# still pending (kind is known once the file is probed at finalize). It is the single stored home
|
||||
# of the invariant `downloads.mediakind.derive` used to compute — never re-derived in prose (an
|
||||
# earlier ad-hoc `vcodec is None and acodec` check lived only in links.py; consolidated here).
|
||||
media_kind: Mapped[str | None] = mapped_column(String(16))
|
||||
nfo_written: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||
# Set once the GC has warned holders of the upcoming expiry, so it doesn't warn every run.
|
||||
gc_notified: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||
|
||||
+190
-17
@@ -3,7 +3,10 @@
|
||||
Playback is from the LOCAL physical file. Browser-compatible files (playable="direct") are served
|
||||
raw with HTTP range requests. Everything else that is h264 video (playable="remux") is remuxed on
|
||||
the fly to HLS with **video stream-copy** (cheap, I/O-bound — no video re-encode) and audio to AAC
|
||||
when needed. HEVC/VP9 (playable="transcode") needs a full re-encode and is deferred to P3.
|
||||
when needed. Non-h264 video (playable="transcode": HEVC/VP9/AV1/MPEG-2/XviD…) needs a full re-encode
|
||||
to H.264: on the prod host that runs on the Intel iGPU via VAAPI (`h264_vaapi`, near-zero CPU), with
|
||||
software libx264 as the automatic fallback where the render node is absent or the source codec isn't
|
||||
hardware-decodable. Same HLS session/seek machinery either way — only the video encoder differs.
|
||||
|
||||
Seek model (Jellyfin-style): one ffmpeg session per item, started at a given offset via `-ss`
|
||||
(fast keyframe seek). A seek beyond the generated region restarts the session at the new offset,
|
||||
@@ -16,6 +19,7 @@ CPU-only prod host; only P3's full transcode is CPU-heavy.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
@@ -27,6 +31,7 @@ from sqlalchemy.orm import Session as DbSession
|
||||
|
||||
from app import sysconfig
|
||||
from app.config import settings
|
||||
from app.downloads.edit import encode_thread_cap
|
||||
from app.models import PlexItem
|
||||
from app.plex import paths
|
||||
|
||||
@@ -83,11 +88,20 @@ _sessions: dict[str, "HlsSession"] = {}
|
||||
|
||||
class HlsSession:
|
||||
def __init__(
|
||||
self, key: str, directory: Path, proc: subprocess.Popen, start_s: float, entry: str
|
||||
self,
|
||||
key: str,
|
||||
directory: Path,
|
||||
proc: subprocess.Popen,
|
||||
start_s: float,
|
||||
entry: str,
|
||||
transcode: bool = False,
|
||||
hw: bool = False,
|
||||
):
|
||||
self.key = key
|
||||
self.dir = directory
|
||||
self.proc = proc
|
||||
self.transcode = transcode # video re-encoded (non-h264 source), not stream-copied
|
||||
self.hw = hw # transcode ran on the VAAPI hardware encoder (else libx264 software)
|
||||
self.start_s = start_s # the REQUESTED seek offset
|
||||
# The REAL media start: with `-ss X -c:v copy` ffmpeg can only cut at the nearest earlier
|
||||
# keyframe K<=X, and hls.js zero-bases the timeline to K (so video.currentTime=0 is K, not X).
|
||||
@@ -127,6 +141,118 @@ _HLS_TAIL = [
|
||||
]
|
||||
|
||||
|
||||
# --- Transcode (playable="transcode") video re-encode -----------------------------------------
|
||||
# The remux path stream-copies the video (`-c:v copy`); a non-h264 source can't be copied and must be
|
||||
# re-encoded to H.264. Two encoders, chosen per-session by `_want_hw`:
|
||||
# * VAAPI hardware (`h264_vaapi`) — the prod default once the iGPU is passed in. A full GPU pipeline
|
||||
# (decode + scale + encode all on-device) offloads the CPU almost entirely, so several concurrent
|
||||
# 1080p transcodes are fine and the API stays responsive.
|
||||
# * libx264 software — the automatic fallback where `/dev/dri` is absent (dev box, or prod before the
|
||||
# iGPU enablement) or the source codec isn't hardware-decodable (AV1/XviD on Gen 9.5). CPU-bound, so
|
||||
# it reserves a core via `-threads` (see encode_thread_cap).
|
||||
# Everything else — the `-copyts`/`_HLS_TAIL`/segment/seek-restart model and the AAC audio path — is
|
||||
# unchanged, so hls.js, subtitles, multi-audio and seeking keep working exactly as for remux.
|
||||
_VAAPI_DEVICE = "/dev/dri/renderD128"
|
||||
# Codecs the prod iGPU (UHD 630 / Quick Sync Gen 9.5) can hardware-DECODE — the precondition for the
|
||||
# full-GPU VAAPI pipeline. A source outside this set (AV1, MPEG-4 ASP/XviD…) falls back to software even
|
||||
# when the render node is present, because the GPU can't decode it to feed its own encoder.
|
||||
_HW_DECODABLE = {"h264", "hevc", "h265", "vp9", "vc1", "mpeg2video"}
|
||||
|
||||
|
||||
def _hwaccel_in_opts(hw: bool) -> list[str]:
|
||||
"""VAAPI decode options, placed before `-i` so the source is decoded (and kept) on the GPU.
|
||||
`-hwaccel_output_format vaapi` keeps frames as GPU surfaces all the way to `h264_vaapi` — no
|
||||
round-trip to system memory. Empty for the software path."""
|
||||
if not hw:
|
||||
return []
|
||||
return ["-hwaccel", "vaapi", "-hwaccel_output_format", "vaapi", "-vaapi_device", _VAAPI_DEVICE]
|
||||
|
||||
|
||||
def _venc_opts(
|
||||
transcode: bool,
|
||||
hw: bool,
|
||||
preset: str = "veryfast",
|
||||
crf: int = 20,
|
||||
max_height: int = 0,
|
||||
) -> list[str]:
|
||||
"""The video-encoder args that replace `-c:v copy` for a transcode session (returns the copy args
|
||||
when not transcoding). A re-encode must MINT its own keyframes at the segment boundaries — the copy
|
||||
path leaned on the source's keyframes, but a re-encoded stream has none the HLS muxer can cut on
|
||||
unless we force them (`-force_key_frames` every `_SEG_SECONDS`), else segments aren't independent and
|
||||
seeking breaks. `preset`/`crf`/`max_height` (software only) come from admin sysconfig."""
|
||||
if not transcode:
|
||||
return ["-c:v", "copy"]
|
||||
force_kf = ["-force_key_frames", f"expr:gte(t,n_forced*{_SEG_SECONDS})"]
|
||||
if hw:
|
||||
# scale_vaapi with format=nv12 downconverts a 10-bit (p010) HEVC source to 8-bit nv12 so
|
||||
# h264_vaapi (8-bit High) can encode it; for an 8-bit source it's a cheap format pass-through.
|
||||
# The GPU encode is cheap, so it runs at source resolution (max_height is a software-only knob).
|
||||
return ["-vf", "scale_vaapi=format=nv12", "-c:v", "h264_vaapi", *force_kf]
|
||||
# Software fallback: same browser-safe recipe as downloads/edit.py (libx264). yuv420p forces 8-bit
|
||||
# High (not High10) so any browser decodes it; -threads reserves a core so the CPU-bound encode can't
|
||||
# starve request handling / playback on the shared box.
|
||||
args: list[str] = []
|
||||
if max_height and max_height > 0:
|
||||
# Downscale-ONLY to the height cap (min(ih,H)), keeping aspect with an even width (-2), so a tall
|
||||
# source doesn't make the software re-encode ruinously expensive. The comma in min() is escaped
|
||||
# (\,) so ffmpeg's filter parser reads one filter, not two. Never upscales a smaller source.
|
||||
args += ["-vf", f"scale=-2:min(ih\\,{max_height})"]
|
||||
args += [
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-preset",
|
||||
preset,
|
||||
"-crf",
|
||||
str(crf),
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-threads",
|
||||
str(encode_thread_cap()),
|
||||
*force_kf,
|
||||
]
|
||||
return args
|
||||
|
||||
|
||||
def _render_node_present() -> bool:
|
||||
"""Whether the VAAPI render node is available — i.e. `/dev/dri` was passed into the container.
|
||||
Present on prod after the iGPU enablement; absent on the CPU-only dev box and in the localdev
|
||||
Docker. Its own function so the selection is monkeypatchable in tests and one place for S1c's knob."""
|
||||
return Path(_VAAPI_DEVICE).exists()
|
||||
|
||||
|
||||
def _want_hw(db: DbSession, item: PlexItem, transcode: bool) -> bool:
|
||||
"""Whether to use the VAAPI hardware encoder for this transcode session, honouring the admin
|
||||
`plex_transcode_hw` policy: "off" → always software; "on" → VAAPI wherever the render node exists
|
||||
(trusting the admin, skipping the source-decodability gate); "auto" (default) → VAAPI only when the
|
||||
render node is present AND the iGPU can hardware-decode the source (else the full-GPU pipeline can't
|
||||
feed itself → software libx264)."""
|
||||
if not transcode:
|
||||
return False
|
||||
mode = (sysconfig.get_str(db, "plex_transcode_hw") or "auto").lower()
|
||||
if mode == "off" or not _render_node_present():
|
||||
return False
|
||||
if mode == "on":
|
||||
return True
|
||||
return (item.codec_video or "").lower() in _HW_DECODABLE
|
||||
|
||||
|
||||
# CPU-nice increment for the SOFTWARE transcode child. The Plex ffmpeg is a Popen in the long-lived API
|
||||
# process (which must NOT itself be niced), so — unlike the download worker's _deprioritize (run once in
|
||||
# its own main()) — we lower priority per-child via preexec_fn in the fork. The GPU path needs none of
|
||||
# this. Reuses the download child's nice default so all co-located re-encoders share one handicap.
|
||||
_SW_TRANSCODE_NICE = settings.download_child_nice
|
||||
|
||||
|
||||
def _sw_child_preexec() -> None:
|
||||
"""Runs in the forked child (POSIX) before exec: renice this software-transcode ffmpeg down so it
|
||||
can't starve the co-located API / Postgres / playback. Best-effort — a failed renice must not fail
|
||||
playback."""
|
||||
try:
|
||||
os.nice(_SW_TRANSCODE_NICE)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _probe_audio_langs(src: Path) -> list[str]:
|
||||
"""One entry per audio stream (its language tag, or "" if untagged). len() = audio-track count.
|
||||
Used to build the multi-rendition var_stream_map. Empty on probe failure → single-audio path."""
|
||||
@@ -160,8 +286,17 @@ def _ffmpeg_cmd(
|
||||
audio_ord: int | None,
|
||||
aoff: float = 0.0,
|
||||
audio_langs: list[str] | None = None,
|
||||
transcode: bool = False,
|
||||
hw: bool = False,
|
||||
preset: str = "veryfast",
|
||||
crf: int = 20,
|
||||
max_height: int = 0,
|
||||
) -> list[str]:
|
||||
args = ["ffmpeg", "-nostdin", "-loglevel", "error"]
|
||||
# For a transcode session the video is re-encoded (VAAPI or libx264) instead of stream-copied; the
|
||||
# hwaccel decode options go before -i, the encoder args replace `-c:v copy` below. Everything else is
|
||||
# identical to remux.
|
||||
venc = _venc_opts(transcode, hw, preset, crf, max_height)
|
||||
args = ["ffmpeg", "-nostdin", "-loglevel", "error", *_hwaccel_in_opts(hw)]
|
||||
# Keep ORIGINAL timestamps on the output so the first segment carries the true absolute PTS of the
|
||||
# keyframe ffmpeg actually seeked to. hls.js still zero-bases playback to that PTS; we read it back
|
||||
# (see _probe_first_pts) to learn the real content offset. Without -copyts the segment PTS is
|
||||
@@ -182,7 +317,7 @@ def _ffmpeg_cmd(
|
||||
args += ["-map", "0:v:0"]
|
||||
for i in range(len(audio_langs)):
|
||||
args += ["-map", f"{ai}:a:{i}?"]
|
||||
args += ["-sn", "-c:v", "copy", "-c:a", "aac", "-ac", "2", "-b:a", "192k"]
|
||||
args += ["-sn", *venc, "-c:a", "aac", "-ac", "2", "-b:a", "192k"]
|
||||
var = ["v:0,agroup:aud"]
|
||||
for i, lang in enumerate(audio_langs):
|
||||
entry = f"a:{i},agroup:aud,name:a{i}"
|
||||
@@ -203,7 +338,7 @@ def _ffmpeg_cmd(
|
||||
else:
|
||||
ao = audio_ord if audio_ord is not None else 0
|
||||
args += ["-map", "0:v:0", "-map", f"{ai}:a:{ao}?", "-sn"]
|
||||
args += ["-c:v", "copy", "-c:a", "aac", "-ac", "2", "-b:a", "192k"]
|
||||
args += [*venc, "-c:a", "aac", "-ac", "2", "-b:a", "192k"]
|
||||
args += _HLS_TAIL + [
|
||||
"-hls_segment_filename",
|
||||
str(out_dir / "seg_%d.ts"),
|
||||
@@ -258,12 +393,14 @@ def _kill(s: HlsSession) -> None:
|
||||
|
||||
|
||||
def _enforce_cap(cap: int) -> None:
|
||||
# Caller holds _lock. Drop the least-recently-accessed sessions over the cap.
|
||||
if len(_sessions) <= cap:
|
||||
# Caller holds _lock. Only TRANSCODE sessions count toward plex_max_transcodes — remux
|
||||
# (stream-copy) and direct-serve are cheap and deliberately uncapped (see config.py:
|
||||
# plex_max_transcodes, and the user-facing config hint). Drop the least-recently-accessed
|
||||
# transcode sessions over the cap, leaving any concurrent remux streams untouched.
|
||||
tx = [(key, s) for key, s in _sessions.items() if s.transcode]
|
||||
if len(tx) <= cap:
|
||||
return
|
||||
for key, s in sorted(_sessions.items(), key=lambda kv: kv[1].last_access)[
|
||||
: len(_sessions) - cap
|
||||
]:
|
||||
for key, s in sorted(tx, key=lambda kv: kv[1].last_access)[: len(tx) - cap]:
|
||||
_kill(s)
|
||||
_sessions.pop(key, None)
|
||||
|
||||
@@ -275,18 +412,33 @@ def start_session(
|
||||
audio_ord: int | None = None,
|
||||
aoff: float = 0.0,
|
||||
multi: bool = False,
|
||||
transcode: bool = False,
|
||||
) -> HlsSession | None:
|
||||
"""(Re)start the HLS remux for an item at the given offset. `multi` (item has >1 audio track) maps
|
||||
every audio track as an HLS rendition so the client switches audio without a restart; otherwise a
|
||||
single audio track (`audio_ord`) is muxed. Subtitles are separate WebVTT tracks (not muxed here).
|
||||
Returns None if the local file can't be read."""
|
||||
"""(Re)start the HLS session for an item at the given offset. `transcode` (item is non-h264,
|
||||
playable="transcode") re-encodes the video to H.264 (VAAPI or libx264) instead of stream-copying it;
|
||||
otherwise the video is copied (remux). `multi` (item has >1 audio track) maps every audio track as an
|
||||
HLS rendition so the client switches audio without a restart; otherwise a single audio track
|
||||
(`audio_ord`) is muxed. Subtitles are separate WebVTT tracks (not muxed here). Returns None if the
|
||||
local file can't be read."""
|
||||
src = paths.local_media_path(db, item.file_path)
|
||||
if src is None:
|
||||
return None
|
||||
key = item.rating_key
|
||||
start_s = max(0.0, float(start_s))
|
||||
hw = _want_hw(db, item, transcode)
|
||||
# Software-fallback encode tuning — read only on the SW transcode path (the VAAPI path and remux
|
||||
# ignore preset/crf/max_height, and remux must not touch the DB for them).
|
||||
preset, crf, max_height = "veryfast", 20, 0
|
||||
if transcode and not hw:
|
||||
preset = sysconfig.get_str(db, "plex_transcode_preset") or "veryfast"
|
||||
crf = sysconfig.get_int(db, "plex_transcode_crf")
|
||||
max_height = sysconfig.get_int(db, "plex_transcode_max_height")
|
||||
audio_langs = _probe_audio_langs(src) if multi else []
|
||||
is_multi = len(audio_langs) > 1 # only worth a master playlist when there really are ≥2 tracks
|
||||
# Nice the SOFTWARE transcode child (not the HW path, and not remux) so a CPU-bound re-encode can't
|
||||
# starve the co-located API/playback. preexec_fn runs in the forked child (POSIX); on a platform
|
||||
# without fork (the API always runs in a Linux container in prod) it's simply omitted.
|
||||
preexec = _sw_child_preexec if (transcode and not hw and os.name == "posix") else None
|
||||
with _lock:
|
||||
old = _sessions.pop(key, None)
|
||||
if old is not None:
|
||||
@@ -297,12 +449,31 @@ def start_session(
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
proc = subprocess.Popen(
|
||||
_ffmpeg_cmd(
|
||||
src, start_s, directory, audio_ord, aoff, audio_langs if is_multi else None
|
||||
src,
|
||||
start_s,
|
||||
directory,
|
||||
audio_ord,
|
||||
aoff,
|
||||
audio_langs if is_multi else None,
|
||||
transcode=transcode,
|
||||
hw=hw,
|
||||
preset=preset,
|
||||
crf=crf,
|
||||
max_height=max_height,
|
||||
),
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
preexec_fn=preexec,
|
||||
)
|
||||
s = HlsSession(
|
||||
key,
|
||||
directory,
|
||||
proc,
|
||||
start_s,
|
||||
"master.m3u8" if is_multi else "index.m3u8",
|
||||
transcode=transcode,
|
||||
hw=hw,
|
||||
)
|
||||
s = HlsSession(key, directory, proc, start_s, "master.m3u8" if is_multi else "index.m3u8")
|
||||
_sessions[key] = s
|
||||
# Admin-configurable concurrency cap (Configuration → Plex → Max concurrent transcodes);
|
||||
# at least 1 so playback can't be capped to zero.
|
||||
@@ -329,12 +500,14 @@ def start_session(
|
||||
if not wait_for(_seg(i), timeout=_WARMUP_SEG_TIMEOUT_S):
|
||||
break
|
||||
log.info(
|
||||
"plex hls session start key=%s req=%.1f real=%.3f multi=%s audio=%s",
|
||||
"plex hls session start key=%s req=%.1f real=%.3f multi=%s audio=%s transcode=%s hw=%s",
|
||||
key,
|
||||
start_s,
|
||||
s.media_start_s,
|
||||
is_multi,
|
||||
audio_ord,
|
||||
transcode,
|
||||
hw,
|
||||
)
|
||||
return s
|
||||
|
||||
|
||||
@@ -6,11 +6,14 @@ worker is a separate process); this module never downloads — it enqueues, mana
|
||||
serves finished files (range-aware, with the user's custom display name), and shares them.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import func, select
|
||||
@@ -24,6 +27,7 @@ from app.downloads import credentials as dlcreds
|
||||
from app.downloads import edit as editmod
|
||||
from app.downloads import links as linksmod
|
||||
from app.downloads import quota, service, storage
|
||||
from app.downloads import upload as uploadmod
|
||||
from app.models import (
|
||||
DownloadJob,
|
||||
DownloadLink,
|
||||
@@ -123,6 +127,9 @@ def _serialize(job: DownloadJob, asset: MediaAsset | None) -> dict:
|
||||
"width": asset.width,
|
||||
"height": asset.height,
|
||||
"container": asset.container,
|
||||
# "video" | "audio" | "file" — the library gates play/edit on this (media only); a
|
||||
# generic uploaded file is download-only. Null while still in flight (set at finalize).
|
||||
"media_kind": asset.media_kind,
|
||||
"uploader_url": asset.uploader_url,
|
||||
"thumbnail_url": asset.thumbnail_url,
|
||||
"expires_at": iso(asset.expires_at),
|
||||
@@ -354,6 +361,61 @@ def enqueue_download(
|
||||
return _serialize_job(db, job)
|
||||
|
||||
|
||||
# Read the upload in 1 MiB chunks so a large file never sits fully in memory, and the size cap is
|
||||
# enforced on the ACTUAL bytes read (a client-set Content-Length can't be trusted to gate it).
|
||||
_UPLOAD_CHUNK = 1024 * 1024
|
||||
|
||||
|
||||
@router.post("/upload")
|
||||
def upload_download(
|
||||
file: UploadFile = File(...),
|
||||
display_name: str | None = Form(None),
|
||||
user: User = Depends(require_human),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Ingest a local file into the caller's library (E7 S5). Any file type is accepted; media
|
||||
(video/audio) gets the full function set, a generic file is download-only. The file is streamed
|
||||
to a temp path under the download volume (enforcing the per-file cap as it goes), then handed to
|
||||
the non-yt-dlp ingest which probes + places it and returns an already-`done` job.
|
||||
|
||||
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. `file.file` is the sync file object
|
||||
Starlette spools the multipart body into."""
|
||||
cap = sysconfig.get_int(db, "download_upload_max_bytes")
|
||||
staging = Path(settings.download_root) / ".staging" / "uploads"
|
||||
staging.mkdir(parents=True, exist_ok=True)
|
||||
fd, tmp = tempfile.mkstemp(dir=str(staging))
|
||||
tmp_path = Path(tmp)
|
||||
try:
|
||||
size = 0
|
||||
with os.fdopen(fd, "wb") as out:
|
||||
while chunk := file.file.read(_UPLOAD_CHUNK):
|
||||
size += len(chunk)
|
||||
if cap and size > cap:
|
||||
raise HTTPException(
|
||||
status_code=422, detail={"code": "upload_too_large", "limit": cap}
|
||||
)
|
||||
out.write(chunk)
|
||||
if size == 0:
|
||||
raise HTTPException(status_code=400, detail={"code": "upload_empty"})
|
||||
try:
|
||||
job = uploadmod.ingest_upload(
|
||||
db,
|
||||
user.id,
|
||||
filename=file.filename or "upload",
|
||||
src_path=tmp_path,
|
||||
size=size,
|
||||
display_name=(display_name or "").strip() or None,
|
||||
)
|
||||
except quota.QuotaExceeded as e:
|
||||
raise HTTPException(status_code=422, detail=_quota_detail(e))
|
||||
return _serialize_job(db, job)
|
||||
finally:
|
||||
# Ingest MOVES the temp file into place on success (unlink is then a no-op); on any early exit
|
||||
# — cap hit, empty, quota, probe/move error — this removes the abandoned staging file.
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
class EnqueueEditIn(BaseModel):
|
||||
source_job_id: int = 0
|
||||
edit_spec: dict
|
||||
@@ -544,8 +606,10 @@ def redownload_download(
|
||||
never deletes a file another job/user still shares: the old asset is only removed if THIS job
|
||||
was its last holder."""
|
||||
job = _own_job(db, user, job_id)
|
||||
if job.job_kind == "edit":
|
||||
raise HTTPException(status_code=409, detail="Editor clips can't be re-downloaded.")
|
||||
if job.job_kind in ("edit", "upload"):
|
||||
# Neither has a re-fetchable source: an editor clip is derived locally, an upload IS the
|
||||
# source. Re-running yt-dlp against source_kind='upload'/'edit' would just fail.
|
||||
raise HTTPException(status_code=409, detail="This item can't be re-downloaded.")
|
||||
if job.status in ("queued", "running", "paused"):
|
||||
raise HTTPException(status_code=409, detail="This download is still in progress.")
|
||||
service.redownload(db, job)
|
||||
@@ -610,8 +674,21 @@ def download_file(
|
||||
|
||||
asset.last_access_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
# Starlette's FileResponse honours the Range header (206 partial content) for seek/resume.
|
||||
return FileResponse(path, filename=filename)
|
||||
# The served Content-Type comes from the ffprobe-verified media_kind + a container allow-list
|
||||
# (storage.served_media_type), NEVER guessed from the file's own extension. A generic 'file' (and
|
||||
# any media whose container we don't recognise — e.g. a media/HTML polyglot named "evil.html")
|
||||
# returns None → a neutral force-download attachment, so uploaded HTML/SVG can't execute inline on
|
||||
# this origin. Recognised media is served with its real type so the in-app player streams it. Both
|
||||
# are range-aware (FileResponse → 206).
|
||||
media_type = storage.served_media_type(asset.media_kind, asset.container)
|
||||
if media_type is None:
|
||||
return FileResponse(
|
||||
path,
|
||||
filename=filename,
|
||||
media_type="application/octet-stream",
|
||||
content_disposition_type="attachment",
|
||||
)
|
||||
return FileResponse(path, filename=filename, media_type=media_type)
|
||||
|
||||
|
||||
# --- editor filmstrip (scrub track) --------------------------------------------------------
|
||||
@@ -823,7 +900,12 @@ def list_links(
|
||||
|
||||
class CreateLinkIn(BaseModel):
|
||||
password: str = ""
|
||||
allow_download: bool = False
|
||||
# D1 (E7 S3): a newly shared download is downloadable by default — the common intent is "let the
|
||||
# recipient have the file", not stream-only. Forward-only: this only sets the default for links
|
||||
# minted from now on; existing links keep whatever `allow_download` they were created with (the
|
||||
# column default stays False, so no back-catalogue link silently flips). The owner can still
|
||||
# uncheck it in the Share dialog, and toggle any link stream-only afterwards.
|
||||
allow_download: bool = True
|
||||
expires_days: int | None = None
|
||||
|
||||
|
||||
|
||||
@@ -532,15 +532,13 @@ def stream_session(
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Prepare playback from `start` seconds. Direct-playable files are served raw; remux files
|
||||
(re)start an HLS session at the offset (seek-restart); transcode (HEVC/VP9) is P3. Selecting a
|
||||
non-default audio track (`audio` = stream ordinal) forces the HLS path so the chosen audio can be
|
||||
muxed in. Subtitles are independent WebVTT tracks (GET /subtitle) — no session restart."""
|
||||
(re)start an HLS session at the offset (seek-restart, video stream-copied); transcode files (non-h264:
|
||||
HEVC/VP9/AV1/…) take the same HLS path but with the video re-encoded to H.264 (VAAPI or libx264).
|
||||
Selecting a non-default audio track (`audio` = stream ordinal) forces the HLS path so the chosen audio
|
||||
can be muxed in. Subtitles are independent WebVTT tracks (GET /subtitle) — no session restart."""
|
||||
plex_stream.reap_idle() # opportunistic cleanup of idle sessions on each new playback
|
||||
it = item_or_404(db, rating_key)
|
||||
if it.playable == "transcode":
|
||||
raise HTTPException(
|
||||
status_code=501, detail="This format needs transcoding (a later phase)."
|
||||
)
|
||||
transcode = it.playable == "transcode"
|
||||
# `multi` (item has ≥2 audio tracks) forces the HLS path so ALL audio tracks ship as renditions and
|
||||
# the client can switch audio without a restart — even for otherwise direct-playable files.
|
||||
if it.playable == "direct" and audio is None and not aoff and not multi:
|
||||
@@ -551,15 +549,19 @@ def stream_session(
|
||||
}
|
||||
if plex_paths.local_media_path(db, it.file_path) is None:
|
||||
raise HTTPException(status_code=404, detail="Media file not found on disk")
|
||||
s = plex_stream.start_session(db, it, start, audio, aoff, multi)
|
||||
s = plex_stream.start_session(db, it, start, audio, aoff, multi, transcode=transcode)
|
||||
if s is None:
|
||||
raise HTTPException(status_code=404, detail="Media file not found on disk")
|
||||
# Report the REAL media start (the keyframe ffmpeg landed on, which hls.js zero-bases to), not the
|
||||
# requested offset — the client's absolute clock and the subtitle cue-shift both key off this.
|
||||
# `transcode`/`hw` let the player show an honest cold-start ("Preparing stream…") and surface the
|
||||
# active mode + encoder in the stats overlay; both segment streams are otherwise identical.
|
||||
return {
|
||||
"mode": "hls",
|
||||
"start_s": s.media_start_s,
|
||||
"url": f"/api/plex/stream/{it.rating_key}/hls/{s.entry}",
|
||||
"transcode": s.transcode,
|
||||
"hw": s.hw,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -122,9 +122,25 @@ def watch_file(token: str, g: str | None = None, db: Session = Depends(get_db)):
|
||||
raise HTTPException(status_code=404, detail="This video is no longer available.")
|
||||
|
||||
filename = storage.download_filename(asset.title or "video", asset.container, path)
|
||||
# The served Content-Type comes from the asset's ffprobe-verified media_kind + a container
|
||||
# allow-list (storage.served_media_type) — NEVER guessed from the file's own extension. A generic
|
||||
# 'file' (and any media whose container we don't positively recognise, e.g. a media/HTML polyglot
|
||||
# named "evil.html") returns None → a neutral force-download attachment, so arbitrary uploaded
|
||||
# HTML/SVG can't execute inline on this login-free public origin. A recognised media container is
|
||||
# served with its real audio/video type, inline unless the link forces a download so it can play
|
||||
# in the watch page. Both are range-aware (FileResponse → 206) for seeking/resume.
|
||||
media_type = storage.served_media_type(asset.media_kind, asset.container)
|
||||
if media_type is None:
|
||||
return FileResponse(
|
||||
path,
|
||||
filename=filename,
|
||||
media_type="application/octet-stream",
|
||||
content_disposition_type="attachment",
|
||||
)
|
||||
disposition = "attachment" if link.allow_download else "inline"
|
||||
# Starlette's FileResponse honours the Range header (206) for seeking/scrubbing.
|
||||
return FileResponse(path, filename=filename, content_disposition_type=disposition)
|
||||
return FileResponse(
|
||||
path, filename=filename, media_type=media_type, content_disposition_type=disposition
|
||||
)
|
||||
|
||||
|
||||
@router.get("/watch/{token}/poster.jpg")
|
||||
|
||||
@@ -127,6 +127,10 @@ SPECS: tuple[ConfigSpec, ...] = (
|
||||
min=1,
|
||||
max=100_000,
|
||||
),
|
||||
# min=1, NOT 0: the per-file cap is enforced as `if cap and size > cap`, so a stored 0 would read
|
||||
# as falsy and SILENTLY disable the limit (an unbounded streamed write to the volume). Floor it at
|
||||
# 1 so the cap is always active; the overall footprint is separately bounded by the byte quota.
|
||||
ConfigSpec("download_upload_max_bytes", "int", "downloads", "download_upload_max_bytes", min=1),
|
||||
ConfigSpec(
|
||||
"download_retention_days", "int", "downloads", "download_retention_days", min=0, max=3_650
|
||||
),
|
||||
@@ -155,6 +159,14 @@ SPECS: tuple[ConfigSpec, ...] = (
|
||||
ConfigSpec("plex_overlay_map", "str", "plex", "plex_overlay_map"),
|
||||
# (plex_sync_interval_min is tuned from the Scheduler dashboard, not here — it's a scheduler job.)
|
||||
ConfigSpec("plex_max_transcodes", "int", "plex", "plex_max_transcodes", min=0, max=16),
|
||||
# Transcode re-encode tuning (E6 S1). hw = "auto"|"on"|"off" (free-text, like search_source); the
|
||||
# libx264 knobs apply only to the software fallback. crf 0–51 (x264 range); max_height 0 = source.
|
||||
ConfigSpec("plex_transcode_hw", "str", "plex", "plex_transcode_hw"),
|
||||
ConfigSpec("plex_transcode_preset", "str", "plex", "plex_transcode_preset"),
|
||||
ConfigSpec("plex_transcode_crf", "int", "plex", "plex_transcode_crf", min=0, max=51),
|
||||
ConfigSpec(
|
||||
"plex_transcode_max_height", "int", "plex", "plex_transcode_max_height", min=0, max=4320
|
||||
),
|
||||
# NOTE: plex_watch_sync_interval_min is a config.py default read by the watch-sync scheduler job
|
||||
# (scheduler.py); it is intentionally NOT a runtime admin knob, so it stays out of SPECS.
|
||||
# --- TMDB enrichment (E5 S2; optional) — the API key is the only secret ---
|
||||
|
||||
@@ -43,6 +43,7 @@ pydantic-settings==2.15.0
|
||||
pydantic==2.13.4
|
||||
pydantic_core==2.46.4
|
||||
python-dotenv==1.2.3
|
||||
python-multipart==0.0.32
|
||||
requests==2.34.2
|
||||
starlette==1.6.0
|
||||
typing-inspection==0.4.4
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
fastapi>=0.115,<1.0
|
||||
# Multipart form parsing — required by Starlette/FastAPI to accept file uploads (UploadFile/Form).
|
||||
# The download center's local-file Upload (E7) is the only multipart endpoint in the app.
|
||||
python-multipart>=0.0.9,<1.0
|
||||
uvicorn[standard]>=0.30,<1.0
|
||||
sqlalchemy>=2.0,<2.1
|
||||
psycopg[binary]>=3.2,<4.0
|
||||
|
||||
@@ -6,7 +6,7 @@ with "PosixPath('.') has an empty name" instead of downloading."""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.downloads.edit import _concat_quote, parse_probe_streams
|
||||
from app.downloads.edit import _codecs_from_streams, _concat_quote, run_ffprobe_json
|
||||
from app.downloads.pipeline import _media_info, _produced_file
|
||||
from pathlib import Path, PurePosixPath
|
||||
|
||||
@@ -105,39 +105,76 @@ class TestMediaInfo:
|
||||
assert _media_info(info)["id"] == "vid"
|
||||
|
||||
|
||||
class TestParseProbeStreams:
|
||||
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):
|
||||
payload = '{"streams":[{"codec_name":"h264","codec_type":"video"},{"codec_name":"aac","codec_type":"audio"}]}'
|
||||
assert parse_probe_streams(payload) == ("h264", "aac")
|
||||
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):
|
||||
payload = '{"streams":[{"codec_name":"AAC","codec_type":"audio"},{"codec_name":"H264","codec_type":"video"}]}'
|
||||
assert parse_probe_streams(payload) == ("h264", "aac")
|
||||
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):
|
||||
payload = (
|
||||
'{"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 parse_probe_streams(payload) == ("h264", "opus")
|
||||
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 parse_probe_streams('{"streams":[{"codec_name":"opus","codec_type":"audio"}]}') == (
|
||||
assert _codecs_from_streams([{"codec_name": "opus", "codec_type": "audio"}]) == (
|
||||
None,
|
||||
"opus",
|
||||
)
|
||||
assert parse_probe_streams('{"streams":[{"codec_name":"av1","codec_type":"video"}]}') == (
|
||||
"av1",
|
||||
None,
|
||||
)
|
||||
assert _codecs_from_streams([{"codec_name": "av1", "codec_type": "video"}]) == ("av1", None)
|
||||
|
||||
def test_garbage_is_not_a_crash(self):
|
||||
assert parse_probe_streams("") == (None, None)
|
||||
assert parse_probe_streams("not json") == (None, None)
|
||||
def test_empty_stream_list(self):
|
||||
assert _codecs_from_streams([]) == (None, None)
|
||||
|
||||
|
||||
class TestRunFfprobeJson:
|
||||
"""The single ffprobe-JSON invocation point (shared by probe_codecs + upload._probe). A failed
|
||||
probe or unparseable output must degrade to None, not raise — the malformed-input safety the old
|
||||
parse_probe_streams string parser used to own now lives here."""
|
||||
|
||||
def test_unparseable_output_returns_none(self, monkeypatch):
|
||||
import subprocess
|
||||
|
||||
class _R:
|
||||
stdout = "not json"
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", lambda *a, **k: _R())
|
||||
assert run_ffprobe_json(Path("x")) is None
|
||||
|
||||
def test_ffprobe_failure_returns_none(self, monkeypatch):
|
||||
import subprocess
|
||||
|
||||
def _boom(*a, **k):
|
||||
raise subprocess.CalledProcessError(1, "ffprobe")
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", _boom)
|
||||
assert run_ffprobe_json(Path("x")) is None
|
||||
|
||||
def test_parses_streams_from_valid_json(self, monkeypatch):
|
||||
import subprocess
|
||||
|
||||
class _R:
|
||||
stdout = '{"streams":[{"codec_name":"h264","codec_type":"video"}]}'
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", lambda *a, **k: _R())
|
||||
data = run_ffprobe_json(Path("x"), entries="stream=codec_type,codec_name")
|
||||
assert _codecs_from_streams(data["streams"]) == ("h264", None)
|
||||
|
||||
|
||||
class TestConcatQuote:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""build_ydl_opts: the mp4 compat profile must SELECT H.264 (not merely prefer it), so shared
|
||||
downloads play on iOS/Safari, while custom vcodec / non-mp4 profiles opt out. See the iPad AV1
|
||||
broken-player fix."""
|
||||
"""build_ydl_opts: EVERY download is clamped to the mp4/H.264 compat profile (E7 browser-
|
||||
playability guarantee) and must SELECT H.264 (not merely prefer it), so shared downloads play in
|
||||
every browser incl. iOS/Safari. Non-mp4 containers and non-H.264 vcodecs are coerced, not honoured.
|
||||
See the iPad AV1 broken-player fix + E7 format narrowing."""
|
||||
|
||||
from app.downloads import edit as editmod
|
||||
from app.downloads import formats
|
||||
@@ -56,16 +57,37 @@ def test_max_height_caps_every_branch():
|
||||
assert fmt.count("[height<=?720]") >= 3 # applied to each avc1/fallback branch
|
||||
|
||||
|
||||
def test_explicit_vcodec_opts_out_of_avc1_selector():
|
||||
def test_explicit_vcodec_is_clamped_to_the_compat_selector():
|
||||
# E7 browser-playability guarantee: a non-H.264 vcodec no longer opts out — normalize() coerces
|
||||
# vcodec→None, so the avc1 selector (and mp4 merge) is used regardless of what was requested.
|
||||
opts = _opts({"mode": "av", "container": "mp4", "vcodec": "vp9"})
|
||||
assert "vcodec^=avc1" not in opts["format"]
|
||||
assert opts["format"] == "bestvideo+bestaudio/best"
|
||||
assert "vcodec:vp9" in opts["format_sort"]
|
||||
assert opts["format"].startswith("bestvideo[vcodec^=avc1]")
|
||||
assert opts["merge_output_format"] == "mp4"
|
||||
assert "vcodec:vp9" not in opts["format_sort"]
|
||||
|
||||
|
||||
def test_non_mp4_container_opts_out():
|
||||
fmt = _opts({"mode": "av", "container": "webm"})["format"]
|
||||
assert "vcodec^=avc1" not in fmt
|
||||
def test_non_mp4_container_is_clamped_to_mp4():
|
||||
# A webm/mkv request is coerced to the mp4/H.264 compat profile (nothing plays mkv in-browser;
|
||||
# webm/VP9 fails iOS), so the avc1 selector is used and the merge target is mp4.
|
||||
opts = _opts({"mode": "av", "container": "webm"})
|
||||
assert opts["format"].startswith("bestvideo[vcodec^=avc1]")
|
||||
assert opts["merge_output_format"] == "mp4"
|
||||
|
||||
|
||||
def test_normalize_clamps_to_browser_playable():
|
||||
n = formats.normalize({"container": "mkv", "vcodec": "av1", "audio_format": "opus"})
|
||||
assert n["container"] == "mp4"
|
||||
assert n["vcodec"] is None
|
||||
assert n["audio_format"] == "m4a"
|
||||
# A playable audio choice is preserved.
|
||||
assert formats.normalize({"audio_format": "mp3"})["audio_format"] == "mp3"
|
||||
|
||||
|
||||
def test_non_playable_specs_dedup_to_the_compat_identity():
|
||||
# Two requests that differ only in (now-clamped) non-playable fields share ONE cache asset.
|
||||
a = formats.format_sig({"mode": "av", "max_height": 720, "container": "webm", "vcodec": "av1"})
|
||||
b = formats.format_sig({"mode": "av", "max_height": 720, "container": "mp4"})
|
||||
assert a == b
|
||||
|
||||
|
||||
def test_audio_only_unaffected():
|
||||
|
||||
@@ -65,6 +65,9 @@ def _asset(**over):
|
||||
height=720,
|
||||
vcodec="h264",
|
||||
acodec="aac",
|
||||
media_kind="video",
|
||||
size_bytes=1000,
|
||||
container="mp4",
|
||||
source_webpage_url=None,
|
||||
source_kind="url",
|
||||
)
|
||||
@@ -74,12 +77,13 @@ def _asset(**over):
|
||||
|
||||
class TestPublicMetaAudioFlag:
|
||||
"""The watch page picks <audio> vs <video> from this flag; WebKit refuses an audio-only mp3 in a
|
||||
<video>, so an audio-only asset (codec present, no video stream) must report audio=True."""
|
||||
<video>, so an audio-only asset (media_kind='audio') must report audio=True. The flag now reads
|
||||
the stored media_kind (the single source), not a re-derived codec check."""
|
||||
|
||||
_link = SimpleNamespace(allow_download=True)
|
||||
|
||||
def test_audio_only_asset_is_flagged_audio(self):
|
||||
asset = _asset(vcodec=None, acodec="mp3", width=None, height=None)
|
||||
asset = _asset(media_kind="audio", vcodec=None, acodec="mp3", width=None, height=None)
|
||||
meta = public_meta(self._link, None, asset, "/f.mp3")
|
||||
assert meta["audio"] is True
|
||||
|
||||
@@ -87,10 +91,29 @@ class TestPublicMetaAudioFlag:
|
||||
meta = public_meta(self._link, None, _asset(), "/f.mp4")
|
||||
assert meta["audio"] is False
|
||||
|
||||
def test_a_silent_video_is_not_audio(self):
|
||||
# Video present but no audio track — still a video player, not the audio bar.
|
||||
meta = public_meta(self._link, None, _asset(acodec=None), "/f.mp4")
|
||||
def test_a_generic_file_is_not_audio(self):
|
||||
# A non-media upload (media_kind='file') has no player at all — never the audio bar.
|
||||
meta = public_meta(self._link, None, _asset(media_kind="file"), "/f.bin")
|
||||
assert meta["audio"] is False
|
||||
assert meta["media_kind"] == "file"
|
||||
|
||||
|
||||
class TestCreateLinkDefault:
|
||||
"""D1 (E7 S3): a newly minted share link is downloadable by default. The one-click Share relies on
|
||||
this — it POSTs no `allow_download`, so the API default decides. Forward-only lives here (the API
|
||||
default), NOT in a migration: the DownloadLink column default stays False, so no existing back-
|
||||
catalogue link silently flips to downloadable."""
|
||||
|
||||
def test_omitted_allow_download_defaults_downloadable(self):
|
||||
from app.routes.downloads import CreateLinkIn
|
||||
|
||||
assert CreateLinkIn().allow_download is True
|
||||
|
||||
def test_explicit_false_is_respected(self):
|
||||
# The owner can still uncheck it to mint a stream-only link.
|
||||
from app.routes.downloads import CreateLinkIn
|
||||
|
||||
assert CreateLinkIn(allow_download=False).allow_download is False
|
||||
|
||||
|
||||
class TestIsExpired:
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
"""The playback-session route's mode routing (E6 S1b).
|
||||
|
||||
`POST /stream/{rk}/session` is the single switch that decides how an item plays: a browser-native
|
||||
file is served raw (`mode:"direct"`), everything else (re)starts an HLS session — and a
|
||||
`playable="transcode"` item (non-h264: HEVC/VP9/AV1/…) must take that HLS path with the video
|
||||
RE-ENCODED, not stream-copied. That routing replaced a former `HTTP 501`, and the only thing that
|
||||
tells `start_session` to re-encode is the `transcode=` flag this route derives from `it.playable`.
|
||||
The command-builder itself is unit-tested in `test_plex_stream.py`; here we pin the ROUTE decision
|
||||
(no ffmpeg spawned — `start_session` is stubbed to echo the flag it was handed).
|
||||
|
||||
DB lane (`db` fixture) — skipped when no Postgres is reachable.
|
||||
"""
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
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 PlexItem, PlexLibrary, User
|
||||
from app.plex import paths as plex_paths
|
||||
from app.plex import stream as plex_stream
|
||||
|
||||
|
||||
def _item(db, *, playable, rating_key="m1"):
|
||||
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
||||
db.add(lib)
|
||||
db.flush()
|
||||
it = PlexItem(
|
||||
rating_key=rating_key,
|
||||
library_id=lib.id,
|
||||
kind="movie",
|
||||
title="A Movie",
|
||||
file_path="/media/a.mkv",
|
||||
playable=playable,
|
||||
)
|
||||
db.add(it)
|
||||
db.commit()
|
||||
return it
|
||||
|
||||
|
||||
def _user(db, email="play@example.com"):
|
||||
u = User(email=email)
|
||||
db.add(u)
|
||||
db.flush()
|
||||
return u
|
||||
|
||||
|
||||
def _post(db, user, monkeypatch, rating_key, *, captured):
|
||||
"""Drive the endpoint with ffmpeg stubbed out. `start_session` records the `transcode` flag it
|
||||
was called with into `captured` and returns a stand-in session echoing it back."""
|
||||
monkeypatch.setattr(state, "_configured_cache", True) # pass the request gate
|
||||
monkeypatch.setattr(plex_stream, "reap_idle", lambda: None)
|
||||
monkeypatch.setattr(plex_paths, "local_media_path", lambda db_, fp: "/local/a.mkv")
|
||||
|
||||
def fake_start(db_, it, start, audio, aoff, multi, transcode=False):
|
||||
captured["transcode"] = transcode
|
||||
return SimpleNamespace(
|
||||
media_start_s=start, entry="index.m3u8", transcode=transcode, hw=False
|
||||
)
|
||||
|
||||
monkeypatch.setattr(plex_stream, "start_session", fake_start)
|
||||
app.dependency_overrides[get_db] = lambda: db
|
||||
app.dependency_overrides[current_user] = lambda: user
|
||||
try:
|
||||
return TestClient(app).post(f"/api/plex/stream/{rating_key}/session")
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_transcode_item_routes_to_an_hls_reencode_session(db, monkeypatch):
|
||||
# A non-h264 item (formerly HTTP 501) now plays: mode=hls, transcode=True threaded to start_session.
|
||||
it = _item(db, playable="transcode")
|
||||
user = _user(db)
|
||||
captured: dict = {}
|
||||
resp = _post(db, user, monkeypatch, it.rating_key, captured=captured)
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["mode"] == "hls"
|
||||
assert body["transcode"] is True
|
||||
assert captured["transcode"] is True # start_session was told to re-encode
|
||||
|
||||
|
||||
def test_remux_item_stays_a_stream_copy_session(db, monkeypatch):
|
||||
# A remux (h264) item takes the SAME HLS path but must NOT re-encode.
|
||||
it = _item(db, playable="remux", rating_key="m2")
|
||||
user = _user(db, "remux@example.com")
|
||||
captured: dict = {}
|
||||
resp = _post(db, user, monkeypatch, it.rating_key, captured=captured)
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["mode"] == "hls"
|
||||
assert body["transcode"] is False
|
||||
assert captured["transcode"] is False
|
||||
|
||||
|
||||
def test_direct_item_never_opens_a_session(db, monkeypatch):
|
||||
# A browser-native file is served raw — start_session is never reached, so no transcode decision.
|
||||
it = _item(db, playable="direct", rating_key="m3")
|
||||
user = _user(db, "direct@example.com")
|
||||
captured: dict = {}
|
||||
resp = _post(db, user, monkeypatch, it.rating_key, captured=captured)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["mode"] == "direct"
|
||||
assert "transcode" not in captured # start_session not called
|
||||
@@ -8,6 +8,7 @@ Since the sweep stopped being awaited at startup it also runs CONCURRENTLY with
|
||||
second half of this file pins what keeps it off a LIVE session's segments."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -164,10 +165,152 @@ class _FakeProc:
|
||||
|
||||
|
||||
class _FakeItem:
|
||||
def __init__(self, rating_key="12345", file_path="/media/movie.mkv"):
|
||||
def __init__(self, rating_key="12345", file_path="/media/movie.mkv", codec_video="h264"):
|
||||
self.rating_key = rating_key
|
||||
self.file_path = file_path
|
||||
self.playable = "remux"
|
||||
self.codec_video = codec_video
|
||||
|
||||
|
||||
class _CapturingProc:
|
||||
"""Records the argv + kwargs each Popen was called with, then behaves like the ffmpeg proc."""
|
||||
|
||||
calls: list[tuple] = []
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
_CapturingProc.calls.append((args, kwargs))
|
||||
|
||||
def poll(self):
|
||||
return None
|
||||
|
||||
|
||||
def _venc(cmd):
|
||||
"""The video codec ffmpeg was told to use (the value after the first `-c:v`)."""
|
||||
return cmd[cmd.index("-c:v") + 1]
|
||||
|
||||
|
||||
class TestTranscodeCommand:
|
||||
"""The transcode video re-encode (playable='transcode'): the SAME HLS command as remux with the
|
||||
video encoder swapped in for `-c:v copy`. The HW path can only be exercised on prod (VAAPI needs
|
||||
Linux + Intel /dev/dri), so its SHAPE is pinned here; the software path is the one UAT'd locally."""
|
||||
|
||||
SRC = Path("/media/movie.mkv")
|
||||
OUT = Path("/out")
|
||||
|
||||
def test_remux_still_stream_copies_the_video(self):
|
||||
cmd = stream._ffmpeg_cmd(self.SRC, 0.0, self.OUT, None)
|
||||
assert _venc(cmd) == "copy"
|
||||
assert "libx264" not in cmd and "h264_vaapi" not in cmd
|
||||
assert "-hwaccel" not in cmd # no GPU decode on the copy path
|
||||
assert "-force_key_frames" not in cmd # copy leans on the source's own keyframes
|
||||
|
||||
def test_software_transcode_uses_libx264_and_mints_keyframes(self):
|
||||
cmd = stream._ffmpeg_cmd(self.SRC, 0.0, self.OUT, None, transcode=True, hw=False)
|
||||
assert _venc(cmd) == "libx264"
|
||||
assert "copy" not in cmd # the video is no longer stream-copied
|
||||
# 8-bit High (yuv420p), not High10, so any browser decodes a 10-bit HEVC source.
|
||||
assert cmd[cmd.index("-pix_fmt") + 1] == "yuv420p"
|
||||
assert "-threads" in cmd # a core reserved for request handling / playback
|
||||
assert "-hwaccel" not in cmd # software path: CPU decode, no render node needed
|
||||
# A forced keyframe at every segment boundary so the HLS muxer cuts independent segments.
|
||||
assert (
|
||||
cmd[cmd.index("-force_key_frames") + 1] == f"expr:gte(t,n_forced*{stream._SEG_SECONDS})"
|
||||
)
|
||||
|
||||
def test_hardware_transcode_uses_the_full_vaapi_pipeline(self):
|
||||
cmd = stream._ffmpeg_cmd(self.SRC, 0.0, self.OUT, None, transcode=True, hw=True)
|
||||
assert _venc(cmd) == "h264_vaapi"
|
||||
assert "libx264" not in cmd
|
||||
# GPU decode options must precede the input so the source is decoded on-device.
|
||||
assert cmd[cmd.index("-hwaccel") + 1] == "vaapi"
|
||||
assert "-hwaccel_output_format" in cmd
|
||||
assert cmd[cmd.index("-vaapi_device") + 1] == stream._VAAPI_DEVICE
|
||||
assert cmd.index("-hwaccel") < cmd.index("-i")
|
||||
# scale_vaapi=format=nv12 downconverts a 10-bit source to 8-bit nv12 for h264_vaapi.
|
||||
assert "scale_vaapi=format=nv12" in cmd
|
||||
assert (
|
||||
cmd[cmd.index("-force_key_frames") + 1] == f"expr:gte(t,n_forced*{stream._SEG_SECONDS})"
|
||||
)
|
||||
|
||||
def test_multi_audio_transcode_reencodes_the_video_variant(self):
|
||||
cmd = stream._ffmpeg_cmd(
|
||||
self.SRC, 0.0, self.OUT, None, audio_langs=["eng", "hun"], transcode=True, hw=False
|
||||
)
|
||||
assert _venc(cmd) == "libx264" # video re-encoded…
|
||||
assert "-var_stream_map" in cmd # …but still multi-rendition (client-side audio switch)
|
||||
assert "copy" not in cmd
|
||||
|
||||
def test_software_preset_and_crf_come_from_the_knobs(self):
|
||||
cmd = stream._ffmpeg_cmd(
|
||||
self.SRC, 0.0, self.OUT, None, transcode=True, hw=False, preset="slow", crf=18
|
||||
)
|
||||
assert cmd[cmd.index("-preset") + 1] == "slow"
|
||||
assert cmd[cmd.index("-crf") + 1] == "18"
|
||||
|
||||
def test_software_max_height_caps_with_a_downscale_only_filter(self):
|
||||
cmd = stream._ffmpeg_cmd(
|
||||
self.SRC, 0.0, self.OUT, None, transcode=True, hw=False, max_height=720
|
||||
)
|
||||
# Downscale-only, aspect-preserving, even width; the comma in min() escaped so it's ONE filter.
|
||||
assert cmd[cmd.index("-vf") + 1] == "scale=-2:min(ih\\,720)"
|
||||
|
||||
def test_software_max_height_zero_means_no_scale_filter(self):
|
||||
cmd = stream._ffmpeg_cmd(
|
||||
self.SRC, 0.0, self.OUT, None, transcode=True, hw=False, max_height=0
|
||||
)
|
||||
assert "-vf" not in cmd # source resolution kept
|
||||
|
||||
def test_hardware_ignores_max_height(self):
|
||||
# The GPU encode is cheap, so it runs at source resolution; max_height is a software-only knob.
|
||||
cmd = stream._ffmpeg_cmd(
|
||||
self.SRC, 0.0, self.OUT, None, transcode=True, hw=True, max_height=720
|
||||
)
|
||||
assert cmd[cmd.index("-vf") + 1] == "scale_vaapi=format=nv12" # not a software scale
|
||||
|
||||
|
||||
class TestWantHw:
|
||||
"""VAAPI is used only where the render node was passed in AND (in auto) the source is HW-decodable,
|
||||
with an admin `plex_transcode_hw` policy (auto/on/off) on top."""
|
||||
|
||||
@staticmethod
|
||||
def _policy(monkeypatch, mode):
|
||||
monkeypatch.setattr(stream.sysconfig, "get_str", lambda db, k: mode)
|
||||
|
||||
def test_no_hw_when_not_transcoding(self, monkeypatch):
|
||||
self._policy(monkeypatch, "auto")
|
||||
monkeypatch.setattr(stream, "_render_node_present", lambda: True)
|
||||
assert stream._want_hw(None, _FakeItem(codec_video="hevc"), transcode=False) is False
|
||||
|
||||
def test_no_hw_without_a_render_node(self, monkeypatch):
|
||||
self._policy(monkeypatch, "auto")
|
||||
monkeypatch.setattr(stream, "_render_node_present", lambda: False)
|
||||
assert stream._want_hw(None, _FakeItem(codec_video="hevc"), transcode=True) is False
|
||||
|
||||
def test_auto_uses_hw_for_a_decodable_source_with_the_render_node(self, monkeypatch):
|
||||
self._policy(monkeypatch, "auto")
|
||||
monkeypatch.setattr(stream, "_render_node_present", lambda: True)
|
||||
assert stream._want_hw(None, _FakeItem(codec_video="hevc"), transcode=True) is True
|
||||
|
||||
def test_auto_falls_back_to_software_for_a_non_decodable_source(self, monkeypatch):
|
||||
# AV1 / MPEG-4 ASP (XviD) aren't decodable on Gen 9.5 → the GPU can't feed its own encoder.
|
||||
self._policy(monkeypatch, "auto")
|
||||
monkeypatch.setattr(stream, "_render_node_present", lambda: True)
|
||||
assert stream._want_hw(None, _FakeItem(codec_video="av1"), transcode=True) is False
|
||||
assert stream._want_hw(None, _FakeItem(codec_video="mpeg4"), transcode=True) is False
|
||||
|
||||
def test_off_forces_software_even_for_a_decodable_source(self, monkeypatch):
|
||||
self._policy(monkeypatch, "off")
|
||||
monkeypatch.setattr(stream, "_render_node_present", lambda: True)
|
||||
assert stream._want_hw(None, _FakeItem(codec_video="hevc"), transcode=True) is False
|
||||
|
||||
def test_on_forces_hw_even_for_a_non_decodable_source(self, monkeypatch):
|
||||
# The admin override trusts the GPU to decode it; skips the decodability gate (render node still
|
||||
# required — HW is impossible without it).
|
||||
self._policy(monkeypatch, "on")
|
||||
monkeypatch.setattr(stream, "_render_node_present", lambda: True)
|
||||
assert stream._want_hw(None, _FakeItem(codec_video="av1"), transcode=True) is True
|
||||
monkeypatch.setattr(stream, "_render_node_present", lambda: False)
|
||||
assert stream._want_hw(None, _FakeItem(codec_video="hevc"), transcode=True) is False
|
||||
|
||||
|
||||
class TestColdStartWarmup:
|
||||
@@ -225,3 +368,78 @@ class TestColdStartWarmup:
|
||||
s = stream.start_session(db=None, item=_FakeItem(), start_s=0.0)
|
||||
assert s is not None
|
||||
assert waited == ["seg_0.ts"]
|
||||
|
||||
|
||||
class TestTranscodeStartSession:
|
||||
"""The seam TestTranscodeCommand can't reach: start_session reading the three admin knobs
|
||||
(plex_transcode_preset/crf/max_height) and threading them + transcode/hw into the ffmpeg argv and
|
||||
the HlsSession. A swapped knob here is a SILENT mis-encode (still plays), so pin the whole wiring
|
||||
end-to-end with a real (captured) Popen argv — not just _ffmpeg_cmd called with literals."""
|
||||
|
||||
@pytest.fixture
|
||||
def capturing_env(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(stream, "_HLS_ROOT", tmp_path)
|
||||
monkeypatch.setattr(stream, "_sessions", {})
|
||||
src = tmp_path / "movie.mkv"
|
||||
src.write_bytes(b"x")
|
||||
monkeypatch.setattr(stream.paths, "local_media_path", lambda db, fp: src)
|
||||
# Distinct sentinels per knob so a crf<->max_height swap (both ints, both via get_int) fails.
|
||||
monkeypatch.setattr(
|
||||
stream.sysconfig,
|
||||
"get_int",
|
||||
lambda db, k: {
|
||||
"plex_transcode_crf": 18,
|
||||
"plex_transcode_max_height": 480,
|
||||
"plex_max_transcodes": 4,
|
||||
}[k],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
stream.sysconfig,
|
||||
"get_str",
|
||||
lambda db, k: {"plex_transcode_preset": "slow"}.get(k, "auto"),
|
||||
)
|
||||
monkeypatch.setattr(stream, "wait_for", lambda path, timeout=20.0: True)
|
||||
_CapturingProc.calls = []
|
||||
monkeypatch.setattr(stream.subprocess, "Popen", _CapturingProc)
|
||||
return tmp_path
|
||||
|
||||
def test_software_transcode_threads_the_knobs_into_the_command(
|
||||
self, capturing_env, monkeypatch
|
||||
):
|
||||
monkeypatch.setattr(stream, "_want_hw", lambda db, item, transcode: False)
|
||||
s = stream.start_session(
|
||||
db=None, item=_FakeItem(codec_video="mpeg4"), start_s=0.0, transcode=True
|
||||
)
|
||||
assert s is not None and s.transcode is True and s.hw is False
|
||||
(cmd, *_), kwargs = _CapturingProc.calls[0]
|
||||
assert _venc(cmd) == "libx264"
|
||||
assert cmd[cmd.index("-preset") + 1] == "slow" # plex_transcode_preset
|
||||
assert cmd[cmd.index("-crf") + 1] == "18" # plex_transcode_crf, NOT max_height
|
||||
assert cmd[cmd.index("-vf") + 1] == "scale=-2:min(ih\\,480)" # plex_transcode_max_height
|
||||
# The CPU-bound child is niced via preexec_fn on POSIX (the API process itself must not be).
|
||||
if os.name == "posix":
|
||||
assert kwargs.get("preexec_fn") is stream._sw_child_preexec
|
||||
|
||||
def test_hardware_transcode_uses_the_gpu_and_never_nices(self, capturing_env, monkeypatch):
|
||||
monkeypatch.setattr(stream, "_want_hw", lambda db, item, transcode: True)
|
||||
s = stream.start_session(
|
||||
db=None, item=_FakeItem(codec_video="hevc"), start_s=0.0, transcode=True
|
||||
)
|
||||
assert s is not None and s.transcode is True and s.hw is True
|
||||
(cmd, *_), kwargs = _CapturingProc.calls[0]
|
||||
assert _venc(cmd) == "h264_vaapi"
|
||||
assert "libx264" not in cmd
|
||||
assert (
|
||||
"-vf" not in cmd or cmd[cmd.index("-vf") + 1] == "scale_vaapi=format=nv12"
|
||||
) # no SW cap
|
||||
assert kwargs.get("preexec_fn") is None # GPU encode is cheap — never niced
|
||||
|
||||
def test_remux_reads_no_transcode_knobs_and_stream_copies(self, capturing_env, monkeypatch):
|
||||
# A remux start must stream-copy the video and spawn no niced child (the transcode knobs and
|
||||
# the preexec nice are gated behind `transcode=True`).
|
||||
monkeypatch.setattr(stream, "_want_hw", lambda db, item, transcode: False)
|
||||
s = stream.start_session(db=None, item=_FakeItem(), start_s=0.0) # transcode defaults False
|
||||
assert s is not None and s.transcode is False
|
||||
(cmd, *_), kwargs = _CapturingProc.calls[0]
|
||||
assert _venc(cmd) == "copy"
|
||||
assert kwargs.get("preexec_fn") is None
|
||||
|
||||
@@ -0,0 +1,394 @@
|
||||
"""E7 S5a — local file upload ingest (the non-yt-dlp path into the media library).
|
||||
|
||||
Covers the ingest spine: media vs generic-file classification, the download-only serving of a
|
||||
generic file (force-download so uploaded HTML/SVG can't execute inline), quota accounting with the
|
||||
size known up front, the per-file cap enforced while streaming, filename sanitization, and the two
|
||||
guards that keep an upload out of paths that assume a re-fetchable source (edit / redownload).
|
||||
|
||||
DB-backed (real Postgres) — skipped when no database is reachable. `settings.download_root` is
|
||||
pointed at a per-test tmp dir so ingest's on-disk moves are isolated. The ingest/route tests
|
||||
monkeypatch `_probe` so they don't shell out to ffprobe; the classifier's OWN parsing (image →
|
||||
file, cover-art skip, dims, duration) is covered directly by `TestProbeClassifier` below, which
|
||||
stubs one level down (`run_ffprobe_json`) and exercises the real `_probe`.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.config import settings
|
||||
from app.downloads import mediakind, quota, storage
|
||||
from app.downloads import upload as uploadmod
|
||||
from app.models import DownloadQuota, MediaAsset
|
||||
|
||||
|
||||
# --- pure: the kind invariant ---------------------------------------------------------------
|
||||
|
||||
|
||||
def test_derive_media_kind():
|
||||
assert uploadmod.mediakind.derive("h264", "aac") == mediakind.VIDEO
|
||||
assert uploadmod.mediakind.derive("vp9", None) == mediakind.VIDEO
|
||||
assert uploadmod.mediakind.derive(None, "aac") == mediakind.AUDIO
|
||||
assert uploadmod.mediakind.derive(None, None) == mediakind.FILE
|
||||
|
||||
|
||||
# --- ingest (direct) ------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _root(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(settings, "download_root", str(tmp_path))
|
||||
return tmp_path
|
||||
|
||||
|
||||
def _probe(monkeypatch, result):
|
||||
monkeypatch.setattr(uploadmod, "_probe", lambda _p: result)
|
||||
|
||||
|
||||
def test_ingest_generic_file_is_download_only(db, make_user, _root, monkeypatch):
|
||||
_probe(monkeypatch, (None, None, None, None, None))
|
||||
u = make_user()
|
||||
src = _root / "src.bin"
|
||||
src.write_bytes(b"%PDF-1.4 hello")
|
||||
job = uploadmod.ingest_upload(
|
||||
db, u.id, filename="My Notes.pdf", src_path=src, size=src.stat().st_size
|
||||
)
|
||||
asset = db.get(MediaAsset, job.asset_id)
|
||||
assert asset.media_kind == mediakind.FILE
|
||||
assert asset.status == "ready" and job.status == "done" and job.progress == 100
|
||||
assert asset.container == "pdf"
|
||||
assert asset.poster_path is None # a generic file has no player and no poster
|
||||
assert asset.title == "My Notes" # display title from the original name
|
||||
assert job.job_kind == "upload" and job.source_kind == "upload"
|
||||
# The file was MOVED into the per-user uploads tree, inside the root, and the temp is gone.
|
||||
assert not src.exists()
|
||||
assert asset.rel_path.startswith(".uploads/")
|
||||
assert storage.safe_abs_path(str(_root), asset.rel_path) is not None
|
||||
|
||||
|
||||
def test_ingest_audio_is_media_no_poster(db, make_user, _root, monkeypatch):
|
||||
_probe(monkeypatch, (None, "aac", None, None, 200))
|
||||
u = make_user()
|
||||
src = _root / "song.m4a"
|
||||
src.write_bytes(b"fake-audio")
|
||||
job = uploadmod.ingest_upload(db, u.id, filename="song.m4a", src_path=src, size=10)
|
||||
asset = db.get(MediaAsset, job.asset_id)
|
||||
assert asset.media_kind == mediakind.AUDIO
|
||||
assert asset.duration_s == 200
|
||||
assert asset.poster_path is None # audio has no frame to poster
|
||||
|
||||
|
||||
def test_ingest_video_records_dims_and_poster(db, make_user, _root, monkeypatch):
|
||||
_probe(monkeypatch, ("h264", "aac", 640, 480, 60))
|
||||
# Stub the frame-cut so the test doesn't shell out to ffmpeg.
|
||||
monkeypatch.setattr(uploadmod.editmod, "ensure_poster", lambda _a, _root: "p/x.jpg")
|
||||
u = make_user()
|
||||
src = _root / "clip.mp4"
|
||||
src.write_bytes(b"fake-video")
|
||||
job = uploadmod.ingest_upload(db, u.id, filename="clip.mp4", src_path=src, size=10)
|
||||
asset = db.get(MediaAsset, job.asset_id)
|
||||
assert asset.media_kind == mediakind.VIDEO
|
||||
assert (asset.width, asset.height) == (640, 480)
|
||||
assert asset.poster_path == "p/x.jpg"
|
||||
|
||||
|
||||
def test_ingest_filename_is_sanitized_no_traversal(db, make_user, _root, monkeypatch):
|
||||
_probe(monkeypatch, (None, None, None, None, None))
|
||||
u = make_user()
|
||||
src = _root / "src"
|
||||
src.write_bytes(b"x")
|
||||
job = uploadmod.ingest_upload(db, u.id, filename="../../etc/passwd", src_path=src, size=1)
|
||||
asset = db.get(MediaAsset, job.asset_id)
|
||||
assert ".." not in asset.rel_path
|
||||
assert asset.rel_path.startswith(f".uploads/{u.id}/")
|
||||
assert storage.safe_abs_path(str(_root), asset.rel_path) is not None
|
||||
|
||||
|
||||
def test_ingest_rejects_over_footprint_cap_before_writing(db, make_user, _root, monkeypatch):
|
||||
_probe(monkeypatch, (None, None, None, None, None))
|
||||
u = make_user()
|
||||
db.add(
|
||||
DownloadQuota(user_id=u.id, max_bytes=1000, max_concurrent=2, max_jobs=50, unlimited=False)
|
||||
)
|
||||
db.commit()
|
||||
src = _root / "big.bin"
|
||||
src.write_bytes(b"x" * 10)
|
||||
with pytest.raises(quota.QuotaExceeded) as e:
|
||||
uploadmod.ingest_upload(db, u.id, filename="big.bin", src_path=src, size=2000)
|
||||
assert e.value.reason == "max_bytes"
|
||||
assert src.exists() # rejected before any disk move — nothing landed
|
||||
assert db.query(MediaAsset).count() == 0 # and no orphan asset row
|
||||
|
||||
|
||||
# --- route ----------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_upload_route_generic_forces_download(as_user, make_user, _root, monkeypatch):
|
||||
_probe(monkeypatch, (None, None, None, None, None))
|
||||
c = as_user(make_user())
|
||||
r = c.post(
|
||||
"/api/downloads/upload",
|
||||
files={"file": ("report.html", b"<script>alert(1)</script>", "text/html")},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
job = r.json()
|
||||
assert job["asset"]["media_kind"] == "file"
|
||||
assert job["can_download"] is True
|
||||
# The served file must be a neutral force-download attachment — never inline text/html.
|
||||
fr = c.get(f"/api/downloads/{job['id']}/file")
|
||||
assert fr.status_code == 200
|
||||
assert fr.headers["content-type"].startswith("application/octet-stream")
|
||||
assert "attachment" in fr.headers["content-disposition"].lower()
|
||||
|
||||
|
||||
def test_upload_route_media_html_extension_not_served_as_html(
|
||||
as_user, make_user, _root, monkeypatch
|
||||
):
|
||||
"""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
|
||||
media_kind + a container allow-list ('html' isn't one), so it force-downloads as octet-stream —
|
||||
on both the authenticated file endpoint AND the login-free public watch endpoint. Without the fix
|
||||
the public inline branch let a media/HTML polyglot named evil.html run as stored XSS."""
|
||||
_probe(monkeypatch, ("h264", "aac", 320, 240, 5)) # ffprobe says it's video…
|
||||
c = as_user(make_user())
|
||||
r = c.post(
|
||||
"/api/downloads/upload",
|
||||
files={"file": ("evil.html", b"<script>alert(1)</script>", "text/html")},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
job = r.json()
|
||||
assert job["asset"]["media_kind"] == "video" and job["asset"]["container"] == "html"
|
||||
|
||||
# Authenticated endpoint: never text/html.
|
||||
fr = c.get(f"/api/downloads/{job['id']}/file")
|
||||
assert fr.status_code == 200
|
||||
assert fr.headers["content-type"].startswith("application/octet-stream")
|
||||
assert "attachment" in fr.headers["content-disposition"].lower()
|
||||
|
||||
# Public login-free endpoint via a STREAM-ONLY link (the inline branch) — still not text/html.
|
||||
link = c.post(f"/api/downloads/{job['id']}/links", json={"allow_download": False}).json()
|
||||
pr = c.get(f"/api/public/watch/{link['token']}/file")
|
||||
assert pr.status_code == 200
|
||||
assert not pr.headers["content-type"].startswith("text/html")
|
||||
assert pr.headers["content-type"].startswith("application/octet-stream")
|
||||
assert "attachment" in pr.headers["content-disposition"].lower()
|
||||
|
||||
|
||||
def test_public_watch_file_forces_download_for_generic_file(as_user, make_user, _root, monkeypatch):
|
||||
"""The login-free public serve of a generic upload (media_kind='file') must be a neutral
|
||||
force-download attachment — the branch that stops uploaded HTML/SVG executing on the public
|
||||
origin. This is a SEPARATE code path from the authenticated /file route, so it needs its own
|
||||
route-level assertion (previously only the authenticated branch was tested)."""
|
||||
_probe(monkeypatch, (None, None, None, None, None))
|
||||
c = as_user(make_user())
|
||||
job = c.post(
|
||||
"/api/downloads/upload",
|
||||
files={"file": ("notes.html", b"<script>alert(1)</script>", "text/html")},
|
||||
).json()
|
||||
assert job["asset"]["media_kind"] == "file"
|
||||
link = c.post(f"/api/downloads/{job['id']}/links", json={"allow_download": True}).json()
|
||||
pr = c.get(f"/api/public/watch/{link['token']}/file")
|
||||
assert pr.status_code == 200
|
||||
assert pr.headers["content-type"].startswith("application/octet-stream")
|
||||
assert "attachment" in pr.headers["content-disposition"].lower()
|
||||
|
||||
|
||||
def test_public_watch_file_serves_media_inline_with_real_type(
|
||||
as_user, make_user, _root, monkeypatch
|
||||
):
|
||||
"""A recognised media container IS still served inline with its real audio/video type (so it
|
||||
plays in the watch page) — the fix must not break legitimate playback. A stream-only link →
|
||||
inline; the type is video/mp4 (from the container allow-list), never text/* and never a guess."""
|
||||
_probe(monkeypatch, ("h264", "aac", 640, 360, 12))
|
||||
c = as_user(make_user())
|
||||
job = c.post(
|
||||
"/api/downloads/upload",
|
||||
files={"file": ("clip.mp4", b"fake-mp4-bytes", "video/mp4")},
|
||||
).json()
|
||||
link = c.post(f"/api/downloads/{job['id']}/links", json={"allow_download": False}).json()
|
||||
pr = c.get(f"/api/public/watch/{link['token']}/file")
|
||||
assert pr.status_code == 200
|
||||
assert pr.headers["content-type"].startswith("video/mp4")
|
||||
assert "inline" in pr.headers["content-disposition"].lower()
|
||||
|
||||
|
||||
def test_upload_route_enforces_per_file_cap(as_user, make_user, db, _root, monkeypatch):
|
||||
from app import sysconfig
|
||||
|
||||
sysconfig.set_value(db, "download_upload_max_bytes", 5)
|
||||
db.commit()
|
||||
c = as_user(make_user())
|
||||
r = c.post(
|
||||
"/api/downloads/upload",
|
||||
files={"file": ("big.bin", b"way over the cap", "application/octet-stream")},
|
||||
)
|
||||
assert r.status_code == 422
|
||||
assert r.json()["detail"]["code"] == "upload_too_large"
|
||||
|
||||
|
||||
def test_upload_cap_cannot_be_disabled_with_zero(db):
|
||||
"""download_upload_max_bytes floors at 1: a stored 0 would read falsy in the route's
|
||||
`if cap and size > cap` guard and SILENTLY disable the per-file cap, so set_value must reject it.
|
||||
Pins the min=1 invariant so a revert to min=0 can't pass the gate."""
|
||||
from app import sysconfig
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
sysconfig.set_value(db, "download_upload_max_bytes", 0)
|
||||
|
||||
|
||||
def test_upload_route_rejects_empty_file(as_user, make_user, _root, monkeypatch):
|
||||
c = as_user(make_user())
|
||||
r = c.post(
|
||||
"/api/downloads/upload", files={"file": ("empty.bin", b"", "application/octet-stream")}
|
||||
)
|
||||
assert r.status_code == 400
|
||||
assert r.json()["detail"]["code"] == "upload_empty"
|
||||
|
||||
|
||||
def test_redownload_rejects_an_upload(as_user, make_user, _root, monkeypatch):
|
||||
_probe(monkeypatch, (None, None, None, None, None))
|
||||
c = as_user(make_user())
|
||||
job = c.post(
|
||||
"/api/downloads/upload", files={"file": ("a.bin", b"data", "application/octet-stream")}
|
||||
).json()
|
||||
r = c.post(f"/api/downloads/{job['id']}/redownload")
|
||||
assert r.status_code == 409
|
||||
|
||||
|
||||
def test_edit_rejects_a_generic_upload_source(as_user, make_user, _root, monkeypatch):
|
||||
_probe(monkeypatch, (None, None, None, None, None))
|
||||
c = as_user(make_user())
|
||||
job = c.post(
|
||||
"/api/downloads/upload", files={"file": ("a.bin", b"data", "application/octet-stream")}
|
||||
).json()
|
||||
r = c.post(
|
||||
"/api/downloads/edit",
|
||||
json={"source_job_id": job["id"], "edit_spec": {"trim": {"start_s": 0, "end_s": 1}}},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
assert r.json()["detail"]["reason"] == "not_media"
|
||||
|
||||
|
||||
def test_ingest_rejects_over_max_jobs(db, make_user, _root, monkeypatch):
|
||||
"""The other check_upload rejection branch (holdings count, not bytes): once the user is at
|
||||
max_jobs, a further upload is refused before any disk write."""
|
||||
_probe(monkeypatch, (None, None, None, None, None))
|
||||
u = make_user()
|
||||
db.add(
|
||||
DownloadQuota(user_id=u.id, max_bytes=10**12, max_concurrent=2, max_jobs=1, unlimited=False)
|
||||
)
|
||||
db.commit()
|
||||
src1 = _root / "a.bin"
|
||||
src1.write_bytes(b"a")
|
||||
uploadmod.ingest_upload(db, u.id, filename="a.bin", src_path=src1, size=1) # now 1 holding job
|
||||
src2 = _root / "b.bin"
|
||||
src2.write_bytes(b"b")
|
||||
with pytest.raises(quota.QuotaExceeded) as e:
|
||||
uploadmod.ingest_upload(db, u.id, filename="b.bin", src_path=src2, size=1)
|
||||
assert e.value.reason == "max_jobs"
|
||||
assert src2.exists() # rejected before the move — nothing landed
|
||||
|
||||
|
||||
def test_ingest_cleans_up_placed_files_on_commit_failure(db, make_user, _root, monkeypatch):
|
||||
"""A failure AFTER storage.place_file has moved the file onto disk must roll back AND unlink the
|
||||
placed file + any (even partial) poster — otherwise the orphan is permanent (the per-user delete
|
||||
keys off the rolled-back asset.rel_path, and the GC orphan sweep skips the dot-prefixed .uploads/
|
||||
tree). Exercises the rollback/cleanup branch that IS the leak remediation, incl. the poster case
|
||||
where ensure_poster left a partial file but returned None."""
|
||||
_probe(monkeypatch, ("h264", "aac", 320, 240, 5)) # video → the poster path runs
|
||||
u = make_user()
|
||||
|
||||
# Simulate ffmpeg leaving a partial poster on disk, then failing (ensure_poster returns None).
|
||||
def _partial_poster(asset, root):
|
||||
storage.abs_path(root, str(Path(asset.rel_path).with_suffix(".jpg"))).write_bytes(
|
||||
b"partial"
|
||||
)
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(uploadmod.editmod, "ensure_poster", _partial_poster)
|
||||
|
||||
# Force the commit to fail after the file (and partial poster) are on disk.
|
||||
def _boom():
|
||||
raise RuntimeError("db down")
|
||||
|
||||
monkeypatch.setattr(db, "commit", _boom)
|
||||
|
||||
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)
|
||||
|
||||
db.rollback() # drop the monkeypatched-commit session state before querying
|
||||
uploads = _root / ".uploads"
|
||||
leftover = [p for p in uploads.rglob("*") if p.is_file()] if uploads.exists() else []
|
||||
assert leftover == [], f"orphaned files left on disk: {leftover}"
|
||||
assert db.query(MediaAsset).count() == 0 # the flushed asset rolled back — no dangling row
|
||||
|
||||
|
||||
# --- classifier: the REAL _probe parsing (stubs ffprobe one level down) ----------------------
|
||||
# The ingest/route tests above stub _probe wholesale; these exercise _probe's OWN logic — image
|
||||
# detection, the cover-art skip, dimension/duration parsing — by faking only run_ffprobe_json, so a
|
||||
# regression in the classifier the force-download guarantee leans on is actually caught.
|
||||
|
||||
|
||||
class TestProbeClassifier:
|
||||
def _probe_with(self, monkeypatch, data):
|
||||
monkeypatch.setattr(uploadmod.editmod, "run_ffprobe_json", lambda *a, **k: data)
|
||||
return uploadmod._probe(Path("x"))
|
||||
|
||||
def test_probe_failure_is_a_generic_file(self, monkeypatch):
|
||||
# run_ffprobe_json returns None (ffprobe failed / not media) → all None → derive() == FILE.
|
||||
v, a, w, h, d = self._probe_with(monkeypatch, None)
|
||||
assert (v, a, w, h, d) == (None, None, None, None, None)
|
||||
assert mediakind.derive(v, a) == mediakind.FILE
|
||||
|
||||
@pytest.mark.parametrize("fmt", ["image2", "png_pipe", "jpeg_pipe", "webp_pipe", "gif"])
|
||||
def test_still_image_classifies_as_file_not_video(self, monkeypatch, fmt):
|
||||
# A standalone image exposes a real video stream, but its container format is an image demuxer
|
||||
# — it must NOT become media_kind='video' (broken player + inline serving). Stays a file.
|
||||
data = {
|
||||
"format": {"format_name": fmt},
|
||||
"streams": [{"codec_type": "video", "codec_name": "png", "width": 8, "height": 8}],
|
||||
}
|
||||
v, a, w, h, d = self._probe_with(monkeypatch, data)
|
||||
assert v is None and mediakind.derive(v, a) == mediakind.FILE
|
||||
|
||||
def test_cover_art_video_stream_is_skipped_audio_stays_audio(self, monkeypatch):
|
||||
# Album art embedded in an audio file is a video stream flagged attached_pic — skip it so the
|
||||
# asset classifies as audio, not video.
|
||||
data = {
|
||||
"format": {"format_name": "mp3", "duration": "212.5"},
|
||||
"streams": [
|
||||
{
|
||||
"codec_type": "video",
|
||||
"codec_name": "mjpeg",
|
||||
"disposition": {"attached_pic": 1},
|
||||
"width": 600,
|
||||
"height": 600,
|
||||
},
|
||||
{"codec_type": "audio", "codec_name": "mp3"},
|
||||
],
|
||||
}
|
||||
v, a, w, h, d = self._probe_with(monkeypatch, data)
|
||||
assert (v, a, d) == (None, "mp3", 212)
|
||||
assert mediakind.derive(v, a) == mediakind.AUDIO
|
||||
|
||||
def test_real_video_records_codecs_dims_duration(self, monkeypatch):
|
||||
data = {
|
||||
"format": {"format_name": "mov,mp4,m4a", "duration": "60.0"},
|
||||
"streams": [
|
||||
{"codec_type": "video", "codec_name": "h264", "width": 1920, "height": 1080},
|
||||
{"codec_type": "audio", "codec_name": "aac"},
|
||||
],
|
||||
}
|
||||
v, a, w, h, d = self._probe_with(monkeypatch, data)
|
||||
assert (v, a, w, h, d) == ("h264", "aac", 1920, 1080, 60)
|
||||
assert mediakind.derive(v, a) == mediakind.VIDEO
|
||||
|
||||
def test_unparseable_duration_is_none(self, monkeypatch):
|
||||
data = {
|
||||
"format": {"format_name": "wav", "duration": "N/A"},
|
||||
"streams": [{"codec_type": "audio", "codec_name": "pcm_s16le"}],
|
||||
}
|
||||
v, a, w, h, d = self._probe_with(monkeypatch, data)
|
||||
assert d is None and mediakind.derive(v, a) == mediakind.AUDIO
|
||||
@@ -6,8 +6,10 @@ import {
|
||||
Ban,
|
||||
ClipboardPaste,
|
||||
Download,
|
||||
Link2,
|
||||
Pause,
|
||||
Play,
|
||||
PlayCircle,
|
||||
Plus,
|
||||
RotateCcw,
|
||||
Scissors,
|
||||
@@ -15,6 +17,7 @@ import {
|
||||
Share2,
|
||||
Pencil,
|
||||
Trash2,
|
||||
UploadCloud,
|
||||
} from "lucide-react";
|
||||
import Tabs from "./Tabs";
|
||||
import { PageToolbar } from "./PageShell";
|
||||
@@ -22,14 +25,17 @@ import { LoadingState, StateMessage } from "./QueryState";
|
||||
// Lazy: these dialogs/editors open on demand, so they stay out of the Downloads page chunk until
|
||||
// used (the video editor in particular is heavy — filmstrip + scrubber).
|
||||
const DownloadDialog = lazy(() => import("./DownloadDialog"));
|
||||
const UploadDialog = lazy(() => import("./UploadDialog"));
|
||||
const ProfileEditor = lazy(() => import("./ProfileEditor"));
|
||||
const VideoEditor = lazy(() => import("./VideoEditor"));
|
||||
const ShareDialog = lazy(() => import("./ShareDialog"));
|
||||
const LibraryPlayer = lazy(() => import("./LibraryPlayer"));
|
||||
import { useConfirm } from "./ConfirmProvider";
|
||||
import { useLiveQuery } from "../lib/useLiveQuery";
|
||||
import { useMe } from "../lib/useMe";
|
||||
import { api, type DownloadJob } from "../lib/api";
|
||||
import { invalidateDownloads } from "../lib/downloads";
|
||||
import { invalidateDownloads, quickShareLink } from "../lib/downloads";
|
||||
import { libraryRowActions, sharedRowActions } from "../lib/downloadActions";
|
||||
import { inputCls } from "./ui/form";
|
||||
import { Button } from "./ui/Button";
|
||||
import { DownloadRow, IconBtn, UsageBar } from "./downloadRow";
|
||||
@@ -46,9 +52,11 @@ export default function DownloadCenter() {
|
||||
const [tab, setTab] = useState("queue");
|
||||
const [addUrl, setAddUrl] = useState("");
|
||||
const [addSource, setAddSource] = useState<string | null>(null);
|
||||
const [upload, setUpload] = useState(false);
|
||||
const [manage, setManage] = useState(false);
|
||||
const [metaJob, setMetaJob] = useState<DownloadJob | null>(null);
|
||||
const [shareJob, setShareJob] = useState<DownloadJob | null>(null);
|
||||
const [playJob, setPlayJob] = useState<DownloadJob | null>(null);
|
||||
const [editJob, setEditJob] = useState<DownloadJob | null>(null);
|
||||
const [errorJob, setErrorJob] = useState<DownloadJob | null>(null);
|
||||
|
||||
@@ -101,6 +109,21 @@ export default function DownloadCenter() {
|
||||
if (await confirm({ title, message: body, confirmLabel: title, danger: true })) fn();
|
||||
};
|
||||
|
||||
// One-click share (E7 S3 · D2-A): ensure a downloadable link, copy it, toast with "open in new tab"
|
||||
// (see quickShareLink). Re-clicking reuses the existing link; a per-job in-flight guard also stops a
|
||||
// double-click from racing two POSTs into two links before the cache is primed. Advanced controls
|
||||
// (password, expiry, revoke, share-with-a-user) live in the Share dialog, via the manage-share button.
|
||||
const [sharingId, setSharingId] = useState<number | null>(null);
|
||||
const quickShare = async (job: DownloadJob) => {
|
||||
if (sharingId !== null) return;
|
||||
setSharingId(job.id);
|
||||
try {
|
||||
await quickShareLink(qc, job, t);
|
||||
} finally {
|
||||
setSharingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Pull a link off the clipboard into the add-URL box. Clipboard reads need a secure context +
|
||||
// a user gesture/permission (iOS Safari/Brave are strict) — so this is best-effort: the paste
|
||||
// button is an explicit gesture (reliable), and the input's onFocus tries silently (ignored if
|
||||
@@ -188,6 +211,16 @@ export default function DownloadCenter() {
|
||||
>
|
||||
{t("downloads.page.add")}
|
||||
</Button>
|
||||
{/* Local upload is a peer to "add a URL": a general file → the same library (E7 S5c). */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
icon={<UploadCloud className="w-4 h-4" />}
|
||||
onClick={() => setUpload(true)}
|
||||
className="shrink-0"
|
||||
>
|
||||
{t("downloads.page.upload")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{queue.map((job) => (
|
||||
@@ -247,50 +280,69 @@ export default function DownloadCenter() {
|
||||
<>
|
||||
<UsageBar />
|
||||
<div className="space-y-2">
|
||||
{library.map((job) => (
|
||||
<DownloadRow key={job.id} job={job}>
|
||||
{saveBtn(job)}
|
||||
{job.can_download && (job.asset?.duration_s ?? 0) > 0 && (
|
||||
<IconBtn onClick={() => setEditJob(job)} title={t("downloads.actions.edit")}>
|
||||
<Scissors className="w-4 h-4" />
|
||||
{library.map((job) => {
|
||||
// Row actions gated on the stored media_kind + job_kind (see downloadActions).
|
||||
const a = libraryRowActions(job);
|
||||
return (
|
||||
<DownloadRow key={job.id} job={job}>
|
||||
{a.play && (
|
||||
<IconBtn onClick={() => setPlayJob(job)} title={t("downloads.actions.play")}>
|
||||
<PlayCircle className="w-4 h-4" />
|
||||
</IconBtn>
|
||||
)}
|
||||
{saveBtn(job)}
|
||||
{a.edit && (
|
||||
<IconBtn onClick={() => setEditJob(job)} title={t("downloads.actions.edit")}>
|
||||
<Scissors className="w-4 h-4" />
|
||||
</IconBtn>
|
||||
)}
|
||||
{a.editDetails && (
|
||||
<IconBtn
|
||||
onClick={() => setMetaJob(job)}
|
||||
title={t("downloads.actions.editDetails")}
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</IconBtn>
|
||||
)}
|
||||
<IconBtn onClick={() => quickShare(job)} title={t("downloads.actions.share")}>
|
||||
<Share2 className="w-4 h-4" />
|
||||
</IconBtn>
|
||||
)}
|
||||
<IconBtn
|
||||
onClick={() => setMetaJob(job)}
|
||||
title={t("downloads.actions.editDetails")}
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</IconBtn>
|
||||
<IconBtn onClick={() => setShareJob(job)} title={t("downloads.actions.share")}>
|
||||
<Share2 className="w-4 h-4" />
|
||||
</IconBtn>
|
||||
<IconBtn
|
||||
onClick={() =>
|
||||
confirmThen(
|
||||
t("downloads.confirm.redownloadTitle"),
|
||||
t("downloads.confirm.redownloadBody"),
|
||||
() => act.mutate({ id: job.id, op: "redownload" }),
|
||||
)
|
||||
}
|
||||
title={t("downloads.actions.redownload")}
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
</IconBtn>
|
||||
<IconBtn
|
||||
onClick={() =>
|
||||
confirmThen(
|
||||
t("downloads.confirm.deleteTitle"),
|
||||
t("downloads.confirm.deleteBody"),
|
||||
() => act.mutate({ id: job.id, op: "delete" }),
|
||||
)
|
||||
}
|
||||
title={t("downloads.actions.delete")}
|
||||
danger
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</IconBtn>
|
||||
</DownloadRow>
|
||||
))}
|
||||
<IconBtn
|
||||
onClick={() => setShareJob(job)}
|
||||
title={t("downloads.actions.shareManage")}
|
||||
>
|
||||
<Link2 className="w-4 h-4" />
|
||||
</IconBtn>
|
||||
{a.redownload && (
|
||||
<IconBtn
|
||||
onClick={() =>
|
||||
confirmThen(
|
||||
t("downloads.confirm.redownloadTitle"),
|
||||
t("downloads.confirm.redownloadBody"),
|
||||
() => act.mutate({ id: job.id, op: "redownload" }),
|
||||
)
|
||||
}
|
||||
title={t("downloads.actions.redownload")}
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
</IconBtn>
|
||||
)}
|
||||
<IconBtn
|
||||
onClick={() =>
|
||||
confirmThen(
|
||||
t("downloads.confirm.deleteTitle"),
|
||||
t("downloads.confirm.deleteBody"),
|
||||
() => act.mutate({ id: job.id, op: "delete" }),
|
||||
)
|
||||
}
|
||||
title={t("downloads.actions.delete")}
|
||||
danger
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</IconBtn>
|
||||
</DownloadRow>
|
||||
);
|
||||
})}
|
||||
{library.length === 0 &&
|
||||
(jobsQ.isError && !jobsQ.data ? (
|
||||
<StateMessage tone="error" onRetry={() => jobsQ.refetch()}>
|
||||
@@ -309,34 +361,50 @@ export default function DownloadCenter() {
|
||||
|
||||
{tab === "shared" && (
|
||||
<div className="space-y-2">
|
||||
{(sharedQ.data ?? []).map((job) => (
|
||||
<DownloadRow key={job.id} job={job}>
|
||||
{job.can_download && saveBtn(job)}
|
||||
{job.can_download && (job.asset?.duration_s ?? 0) > 0 && (
|
||||
<IconBtn onClick={() => setEditJob(job)} title={t("downloads.actions.edit")}>
|
||||
<Scissors className="w-4 h-4" />
|
||||
{(sharedQ.data ?? []).map((job) => {
|
||||
// Same media_kind + can_download gate as the library, via the shared helper (S5b).
|
||||
const a = sharedRowActions(job);
|
||||
return (
|
||||
<DownloadRow key={job.id} job={job}>
|
||||
{a.play && (
|
||||
<IconBtn onClick={() => setPlayJob(job)} title={t("downloads.actions.play")}>
|
||||
<PlayCircle className="w-4 h-4" />
|
||||
</IconBtn>
|
||||
)}
|
||||
{a.save && saveBtn(job)}
|
||||
{a.edit && (
|
||||
<IconBtn onClick={() => setEditJob(job)} title={t("downloads.actions.edit")}>
|
||||
<Scissors className="w-4 h-4" />
|
||||
</IconBtn>
|
||||
)}
|
||||
<IconBtn
|
||||
onClick={() =>
|
||||
confirmThen(
|
||||
t("downloads.confirm.removeSharedTitle"),
|
||||
t("downloads.confirm.removeSharedBody"),
|
||||
() => removeShared.mutate(job.id),
|
||||
)
|
||||
}
|
||||
title={t("downloads.actions.removeShared")}
|
||||
danger
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</IconBtn>
|
||||
)}
|
||||
<IconBtn
|
||||
onClick={() =>
|
||||
confirmThen(
|
||||
t("downloads.confirm.removeSharedTitle"),
|
||||
t("downloads.confirm.removeSharedBody"),
|
||||
() => removeShared.mutate(job.id),
|
||||
)
|
||||
}
|
||||
title={t("downloads.actions.removeShared")}
|
||||
danger
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</IconBtn>
|
||||
</DownloadRow>
|
||||
))}
|
||||
{sharedQ.data && sharedQ.data.length === 0 && (
|
||||
<div className="text-sm text-muted py-10 text-center">
|
||||
{t("downloads.empty.shared")}
|
||||
</div>
|
||||
)}
|
||||
</DownloadRow>
|
||||
);
|
||||
})}
|
||||
{(sharedQ.data ?? []).length === 0 &&
|
||||
(sharedQ.isError && !sharedQ.data ? (
|
||||
<StateMessage tone="error" onRetry={() => sharedQ.refetch()}>
|
||||
{t("common.loadError")}
|
||||
</StateMessage>
|
||||
) : sharedQ.isLoading ? (
|
||||
<LoadingState />
|
||||
) : (
|
||||
<div className="text-sm text-muted py-10 text-center">
|
||||
{t("downloads.empty.shared")}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -352,9 +420,11 @@ export default function DownloadCenter() {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{upload && <UploadDialog onClose={() => setUpload(false)} />}
|
||||
{manage && <ProfileEditor onClose={() => setManage(false)} />}
|
||||
{metaJob && <MetaEditModal job={metaJob} onClose={() => setMetaJob(null)} />}
|
||||
{shareJob && <ShareDialog job={shareJob} onClose={() => setShareJob(null)} />}
|
||||
{playJob && <LibraryPlayer job={playJob} onClose={() => setPlayJob(null)} />}
|
||||
{editJob && <VideoEditor job={editJob} onClose={() => setEditJob(null)} />}
|
||||
{errorJob && (
|
||||
<ErrorModal
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// E7·S4b — the in-app library player streams the completed item's LOCAL file into a native media
|
||||
// element. video/audio is chosen from the stored media_kind (the single source), NOT a container
|
||||
// list — so an audio upload with an uncommon/absent container still renders <audio src=/file>. i18n
|
||||
// is key-passthrough here.
|
||||
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||
import { render, cleanup } from "@testing-library/react";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
|
||||
});
|
||||
|
||||
import LibraryPlayer from "./LibraryPlayer";
|
||||
import type { DownloadJob } from "../lib/api";
|
||||
|
||||
const baseJob: DownloadJob = {
|
||||
id: 7,
|
||||
status: "done",
|
||||
progress: 100,
|
||||
speed_bps: null,
|
||||
eta_s: null,
|
||||
phase: null,
|
||||
error: null,
|
||||
error_code: null,
|
||||
queue_pos: 0,
|
||||
created_at: null,
|
||||
source_kind: "youtube",
|
||||
source_ref: "abc",
|
||||
source_url: null,
|
||||
poster_url: null,
|
||||
profile_id: null,
|
||||
spec: {
|
||||
mode: "av",
|
||||
max_height: null,
|
||||
container: null,
|
||||
audio_format: null,
|
||||
vcodec: null,
|
||||
embed_subs: false,
|
||||
embed_chapters: false,
|
||||
embed_thumbnail: false,
|
||||
sponsorblock: false,
|
||||
},
|
||||
display_name: "Clip",
|
||||
display_uploader: null,
|
||||
display_uploader_url: null,
|
||||
extra_links: [],
|
||||
can_download: true,
|
||||
expired: false,
|
||||
asset: null,
|
||||
};
|
||||
|
||||
const withAsset = (over: Partial<NonNullable<DownloadJob["asset"]>>): DownloadJob => ({
|
||||
...baseJob,
|
||||
asset: {
|
||||
id: 10,
|
||||
status: "ready",
|
||||
title: "Clip",
|
||||
uploader: null,
|
||||
uploader_url: null,
|
||||
upload_date: null,
|
||||
duration_s: 12,
|
||||
size_bytes: 1000,
|
||||
width: null,
|
||||
height: null,
|
||||
container: null,
|
||||
media_kind: "video",
|
||||
thumbnail_url: null,
|
||||
expires_at: null,
|
||||
...over,
|
||||
},
|
||||
});
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("LibraryPlayer", () => {
|
||||
it("plays a video asset in a <video> pointed at the range-aware /file endpoint", () => {
|
||||
render(
|
||||
<LibraryPlayer
|
||||
job={withAsset({ width: 1280, height: 720, container: "mp4" })}
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
const video = document.body.querySelector("video");
|
||||
expect(video).not.toBeNull();
|
||||
expect(video!.getAttribute("src")).toBe("/api/downloads/7/file");
|
||||
// No <audio> element for a video asset.
|
||||
expect(document.body.querySelector("audio")).toBeNull();
|
||||
});
|
||||
|
||||
it("plays an audio-only asset (media_kind='audio') in an <audio> element", () => {
|
||||
render(
|
||||
<LibraryPlayer
|
||||
job={withAsset({ media_kind: "audio", container: "m4a" })}
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
const audio = document.body.querySelector("audio");
|
||||
expect(audio).not.toBeNull();
|
||||
expect(audio!.getAttribute("src")).toBe("/api/downloads/7/file");
|
||||
expect(document.body.querySelector("video")).toBeNull();
|
||||
});
|
||||
|
||||
it("uses media_kind — an audio upload with an uncommon container still renders <audio>", () => {
|
||||
// The exact bug the fix targets: the old container whitelist (m4a/mp3/…) would drop an .m4b/.weba
|
||||
// audio upload into a <video> box. media_kind='audio' is authoritative regardless of container.
|
||||
render(
|
||||
<LibraryPlayer
|
||||
job={withAsset({ media_kind: "audio", container: "m4b" })}
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(document.body.querySelector("audio")).not.toBeNull();
|
||||
expect(document.body.querySelector("video")).toBeNull();
|
||||
});
|
||||
|
||||
it("falls back to the container heuristic for a legacy null media_kind", () => {
|
||||
// A pre-discriminator asset (media_kind null) still resolves audio vs video from dims+container.
|
||||
render(
|
||||
<LibraryPlayer job={withAsset({ media_kind: null, container: "mp3" })} onClose={() => {}} />,
|
||||
);
|
||||
expect(document.body.querySelector("audio")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Modal from "./Modal";
|
||||
import { api, type DownloadJob } from "../lib/api";
|
||||
import { jobTitle } from "./downloadRow";
|
||||
|
||||
// E7 S4b — 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
|
||||
// APIs and can't point at a local file. Instead we reuse the shared a11y shell (`Modal`: focus-trap,
|
||||
// ESC/back-to-close, scroll-lock, layer-stack, portal) and stream the local file into a NATIVE
|
||||
// <video>/<audio controls> — the same element VideoEditor already plays this URL in. Downloads are a
|
||||
// browser-native container (mp4/H.264/AAC + m4a/mp3), so native playback (seek via the range-aware
|
||||
// /file endpoint, volume, fullscreen, speed) works with zero transcode; an uploaded media item may be
|
||||
// any codec (best-effort playback).
|
||||
export default function LibraryPlayer({ job, onClose }: { job: DownloadJob; onClose: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const fileUrl = useMemo(() => api.downloadFileUrl(job.id), [job.id]);
|
||||
// Video vs audio-only comes from the stored media_kind (the single source every sibling reads —
|
||||
// WatchPage/downloadRow/DownloadCenter) — NOT a re-derived container list, which would misclassify
|
||||
// an uploaded audio file whose container is outside a hardcoded set (uploads accept any container).
|
||||
// Only fall back to the old dimension+container heuristic for a legacy asset with a null media_kind.
|
||||
const asset = job.asset;
|
||||
const isAudio =
|
||||
asset?.media_kind === "audio" ||
|
||||
(asset?.media_kind == null &&
|
||||
!(asset?.width && asset?.height) &&
|
||||
!!asset?.container &&
|
||||
["m4a", "mp3", "opus", "ogg", "wav", "flac", "aac"].includes(asset.container.toLowerCase()));
|
||||
// Our self-hosted poster (always same-origin, never expires) is the primary; the source thumbnail is
|
||||
// the fallback. Same precedence as the library-row thumbnail.
|
||||
const poster = job.poster_url ? api.posterImageUrl(job.id) : (asset?.thumbnail_url ?? undefined);
|
||||
|
||||
return (
|
||||
<Modal title={jobTitle(job)} onClose={onClose} maxWidth="max-w-4xl">
|
||||
{isAudio ? (
|
||||
<div className="flex flex-col items-center gap-4 py-2">
|
||||
{poster ? (
|
||||
<img
|
||||
src={poster}
|
||||
alt=""
|
||||
className="w-40 h-40 rounded-xl object-cover border border-border"
|
||||
/>
|
||||
) : null}
|
||||
<audio src={fileUrl} controls autoPlay className="w-full">
|
||||
{t("downloads.player.unsupported")}
|
||||
</audio>
|
||||
</div>
|
||||
) : (
|
||||
<video
|
||||
src={fileUrl}
|
||||
poster={poster}
|
||||
controls
|
||||
autoPlay
|
||||
playsInline
|
||||
className="w-full max-h-[70vh] rounded-lg bg-black object-contain"
|
||||
>
|
||||
{t("downloads.player.unsupported")}
|
||||
</video>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -425,7 +425,7 @@ export default function PlexInfo({
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="shrink-0 p-1.5 rounded-lg text-white/80 hover:bg-white/15"
|
||||
aria-label={t("plex.player.back")}
|
||||
aria-label={t("plex.player.close")}
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
ArrowLeft,
|
||||
Clock,
|
||||
Download,
|
||||
Gauge,
|
||||
Info,
|
||||
Keyboard,
|
||||
Languages,
|
||||
@@ -38,8 +39,10 @@ import {
|
||||
subShadowCss,
|
||||
type PlexPlayerPrefs,
|
||||
} from "../lib/plexPlayerPrefs";
|
||||
import { Ctrl, PanelHead } from "./plexPlayerControls";
|
||||
import { Ctrl, PanelHead, Skip10 } from "./plexPlayerControls";
|
||||
import { PlexPlayerSettings } from "./PlexPlayerSettings";
|
||||
import { PlexStatsOverlay } from "./PlexStatsOverlay";
|
||||
import { UpNextCard } from "./UpNextCard";
|
||||
import {
|
||||
useAutoSkip,
|
||||
useControlsVisibility,
|
||||
@@ -95,6 +98,9 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
const aliveRef = useRef(true);
|
||||
const wrapRef = useRef<HTMLDivElement>(null);
|
||||
const sessionStartRef = useRef(0); // absolute offset the current HLS/direct session begins at
|
||||
// Which encoder the current transcode session ran: true=VAAPI hardware, false=libx264 software,
|
||||
// null=not a transcode. Feeds the stats overlay's mode row ("transcode · GPU" / "· CPU").
|
||||
const hwRef = useRef<boolean | null>(null);
|
||||
const durationRef = useRef(0);
|
||||
// ≥2 audio tracks → multi-rendition HLS: all tracks ship in one session and hls.js switches audio
|
||||
// client-side (no restart). Ref so the stable loadSession/changeAudio callbacks read it fresh.
|
||||
@@ -114,6 +120,10 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
// which only covers the INITIAL session load; this covers stalls AFTER playback has started (e.g. the
|
||||
// brief cold-ffmpeg rebuffer on auto-advance, now largely prevented by the server-side segment warm-up).
|
||||
const [buffering, setBuffering] = useState(false);
|
||||
// The current session re-encodes the video (playable="transcode"). Its cold-start (the first transcode
|
||||
// segment) is genuinely slower than a remux stall, so the initial-load overlay says "Preparing stream…"
|
||||
// instead of the generic "Loading…" — honest, and it stops a warm-up reading as a hang.
|
||||
const [sessionIsTranscode, setSessionIsTranscode] = useState(false);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
// The current session's real media-start K (reactive mirror of sessionStartRef). Subtitles are
|
||||
// absolute-time VTT shifted by K to the session's zero-based clock, so the <track> src/key keys off
|
||||
@@ -171,6 +181,16 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
const [infoOpen, setInfoOpen] = useState(false);
|
||||
const infoOpenRef = useRef(false);
|
||||
infoOpenRef.current = infoOpen;
|
||||
// "D" toggles the stats-for-nerds debug overlay (active mode, codec, dropped frames, buffer…).
|
||||
const [statsOpen, setStatsOpen] = useState(false);
|
||||
const statsOpenRef = useRef(false);
|
||||
statsOpenRef.current = statsOpen;
|
||||
// Up-next end-card: shown at episode end (with a next item) instead of an instant jump (S4).
|
||||
const [upNextOpen, setUpNextOpen] = useState(false);
|
||||
const upNextOpenRef = useRef(false);
|
||||
upNextOpenRef.current = upNextOpen;
|
||||
const upNextPrefRef = useRef(prefs.upNext);
|
||||
upNextPrefRef.current = prefs.upNext;
|
||||
const now = useWallClock();
|
||||
|
||||
durationRef.current = duration;
|
||||
@@ -186,6 +206,10 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
setReady(false);
|
||||
setBuffering(false);
|
||||
setLoadError(null);
|
||||
// Clear the prior item's transcode flag up front so the cold-start overlay can't show a stale
|
||||
// "Preparing stream…" for a not-yet-known session while the plexSession request is in flight —
|
||||
// it's re-set honestly from the response below.
|
||||
setSessionIsTranscode(false);
|
||||
let sess;
|
||||
try {
|
||||
// Multi-audio → request the multi-rendition session (no single `audio` ordinal); the track is
|
||||
@@ -206,6 +230,11 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Surface the active mode for the stats overlay + the honest cold-start label: a transcode session
|
||||
// re-encodes the video (hw = which encoder), and its first segment is genuinely slower than remux.
|
||||
const isTranscode = sess.mode === "hls" && !!sess.transcode;
|
||||
hwRef.current = isTranscode ? (sess.hw ?? false) : null;
|
||||
setSessionIsTranscode(isTranscode);
|
||||
// hls.js plays our non-zero-start copyts stream ~1s BEHIND the media clock (see HLS_SUB_LAG);
|
||||
// fold that into the session start so the seekbar clock, seeking AND the subtitle shift are all
|
||||
// content-accurate. Direct-play (start_s 0) uses the file's real clock — no compensation.
|
||||
@@ -514,7 +543,11 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
// (absRef 0) — its stray 'ended' must not mark anything watched or cascade another advance.
|
||||
if (absRef.current <= 0) return;
|
||||
api.plexSetState(id, "watched").catch(() => {});
|
||||
if (nextId) go(nextId);
|
||||
// With a next item and the up-next pref on, surface the end-card (poster + countdown + Cancel)
|
||||
// and let IT advance — binge stays in the viewer's control (S4/D6-A). The card only appears here,
|
||||
// on play-to-the-end; credits-auto-skip advances earlier and this handler never fires for it.
|
||||
if (nextId && upNextPrefRef.current) setUpNextOpen(true);
|
||||
else if (nextId) go(nextId);
|
||||
// Nothing queued next → dismiss the player back to the previous page (same as Stop/Escape),
|
||||
// instead of stranding a black, ended <video> on screen.
|
||||
else onClose();
|
||||
@@ -523,6 +556,14 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
return () => video.removeEventListener("ended", onEnded);
|
||||
}, [id, nextId, go, onClose]);
|
||||
|
||||
// Up-next card actions: advance = go to the next item (it was already marked watched on 'ended');
|
||||
// cancel = dismiss and stay on the finished frame. Loading a new item closes any lingering card.
|
||||
const advanceUpNext = useCallback(() => {
|
||||
setUpNextOpen(false);
|
||||
if (nextId) go(nextId);
|
||||
}, [nextId, go]);
|
||||
useEffect(() => setUpNextOpen(false), [id]);
|
||||
|
||||
// "Back" cascade shared by the on-screen Back arrow AND the keyboard/remote Backspace·Escape: peel
|
||||
// off whatever is layered on top (a tuning/tracks menu, then the shortcuts sheet, then the info
|
||||
// overlay) before leaving the player. The arrow used to call onClose() unconditionally, so Back
|
||||
@@ -533,6 +574,8 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
setTracksOpen(false);
|
||||
} else if (helpOpenRef.current) setHelpOpen(false);
|
||||
else if (infoOpenRef.current) setInfoOpen(false);
|
||||
else if (statsOpenRef.current) setStatsOpen(false);
|
||||
else if (upNextOpenRef.current) setUpNextOpen(false);
|
||||
else onClose();
|
||||
}, [onClose]);
|
||||
|
||||
@@ -618,6 +661,10 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
case "I":
|
||||
setInfoOpen((v) => !v);
|
||||
break;
|
||||
case "d":
|
||||
case "D":
|
||||
setStatsOpen((v) => !v);
|
||||
break;
|
||||
case "Backspace":
|
||||
// HTPC-style "stop & back to the feed" — mirrors the mouse Back button (shared cascade).
|
||||
e.preventDefault();
|
||||
@@ -639,6 +686,8 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
setTracksOpen(false);
|
||||
} else if (helpOpenRef.current) setHelpOpen(false);
|
||||
else if (infoOpenRef.current) setInfoOpen(false);
|
||||
else if (statsOpenRef.current) setStatsOpen(false);
|
||||
else if (upNextOpenRef.current) setUpNextOpen(false);
|
||||
else if (!fullscreenElement()) onClose();
|
||||
break;
|
||||
}
|
||||
@@ -774,7 +823,9 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
rebuffer/seek (ready, no error) has no visible label, so give it an sr-only one via the
|
||||
aria-live region so a screen reader distinguishes "buffering" from a frozen frame. */}
|
||||
{!ready ? (
|
||||
<span>{t("plex.player.loading")}</span>
|
||||
<span>
|
||||
{t(sessionIsTranscode ? "plex.player.preparing" : "plex.player.loading")}
|
||||
</span>
|
||||
) : buffering ? (
|
||||
<span className="sr-only">{t("plex.player.buffering")}</span>
|
||||
) : null}
|
||||
@@ -785,9 +836,13 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
|
||||
{/* While a panel/menu is open or an auto-skip countdown is running, take over the browser/mouse
|
||||
Back button (history popstate) so it closes/cancels that instead of dropping to the grid. */}
|
||||
{(menuOpen || tracksOpen || helpOpen || infoOpen || skipProgress != null) && (
|
||||
<BackClose onBack={backIntent} />
|
||||
)}
|
||||
{(menuOpen ||
|
||||
tracksOpen ||
|
||||
helpOpen ||
|
||||
infoOpen ||
|
||||
statsOpen ||
|
||||
upNextOpen ||
|
||||
skipProgress != null) && <BackClose onBack={backIntent} />}
|
||||
|
||||
{/* Top bar */}
|
||||
<div
|
||||
@@ -936,9 +991,19 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 text-white">
|
||||
<Ctrl label={t("plex.player.playPause")} onClick={togglePlay} align="start">
|
||||
<Ctrl
|
||||
label={t("plex.player.rewind10")}
|
||||
onClick={() => seekTo(absRef.current - 10)}
|
||||
align="start"
|
||||
>
|
||||
<Skip10 dir="back" />
|
||||
</Ctrl>
|
||||
<Ctrl label={t("plex.player.playPause")} onClick={togglePlay}>
|
||||
{playing ? <Pause className="w-6 h-6" /> : <Play className="w-6 h-6" />}
|
||||
</Ctrl>
|
||||
<Ctrl label={t("plex.player.forward10")} onClick={() => seekTo(absRef.current + 10)}>
|
||||
<Skip10 dir="forward" />
|
||||
</Ctrl>
|
||||
<Ctrl label={t("plex.player.restart")} onClick={() => seekTo(0)}>
|
||||
<RotateCcw className="w-5 h-5" />
|
||||
</Ctrl>
|
||||
@@ -1114,6 +1179,9 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<Ctrl label={t("plex.player.stats.title")} onClick={() => setStatsOpen((v) => !v)}>
|
||||
<Gauge className="w-5 h-5" />
|
||||
</Ctrl>
|
||||
<Ctrl label={t("plex.player.infoBtn")} onClick={() => setInfoOpen((v) => !v)}>
|
||||
<Info className="w-5 h-5" />
|
||||
</Ctrl>
|
||||
@@ -1130,6 +1198,31 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats-for-nerds debug overlay (toggle with "d" or the gauge button). Non-modal corner panel —
|
||||
the video keeps playing and stays interactive behind it. */}
|
||||
{statsOpen && (
|
||||
<PlexStatsOverlay
|
||||
videoRef={videoRef}
|
||||
hlsRef={hlsRef}
|
||||
playable={detail?.playable ?? ""}
|
||||
hwRef={hwRef}
|
||||
sessionStartRef={sessionStartRef}
|
||||
absRef={absRef}
|
||||
durationRef={durationRef}
|
||||
onClose={() => setStatsOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Up-next end-card (episode end, next item queued, pref on). Non-modal — the finished frame
|
||||
stays behind it; the card's own countdown advances unless the viewer cancels. */}
|
||||
{upNextOpen && nextId && (
|
||||
<UpNextCard
|
||||
nextId={nextId}
|
||||
onAdvance={advanceUpNext}
|
||||
onCancel={() => setUpNextOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Keyboard-shortcut cheat sheet (toggle with "h" or the keyboard button). */}
|
||||
{helpOpen && (
|
||||
<div
|
||||
@@ -1150,7 +1243,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
<button
|
||||
onClick={() => setHelpOpen(false)}
|
||||
className="p-1 rounded-lg hover:bg-white/10"
|
||||
aria-label={t("plex.player.back")}
|
||||
aria-label={t("plex.player.close")}
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
@@ -1170,6 +1263,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
[["S"], "plex.player.keys.subtitle"],
|
||||
[["F"], "plex.player.keys.fullscreen"],
|
||||
[["I"], "plex.player.keys.info"],
|
||||
[["D"], "plex.player.keys.stats"],
|
||||
[["⌫", "Esc"], "plex.player.keys.back"],
|
||||
[["H"], "plex.player.keys.help"],
|
||||
] as [string[], string][]
|
||||
|
||||
@@ -113,6 +113,11 @@ export function PlexPlayerSettings(props: {
|
||||
checked={prefs.autoSkipCredits}
|
||||
onChange={(v) => patchPrefs({ autoSkipCredits: v })}
|
||||
/>
|
||||
<Toggle
|
||||
label={t("plex.player.upNext.pref")}
|
||||
checked={prefs.upNext}
|
||||
onChange={(v) => patchPrefs({ upNext: v })}
|
||||
/>
|
||||
{(prefs.autoSkipIntro || prefs.autoSkipCredits) && (
|
||||
<Slider
|
||||
label={t("plex.player.autoSkipDelay")}
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { type RefObject, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type Hls from "hls.js";
|
||||
import { X } from "lucide-react";
|
||||
import { buildStatRows, type HlsLevelInfo, type StatRow } from "../lib/plexStats";
|
||||
|
||||
// "Stats for nerds" debug overlay (E6 S2). Samples the live HTML5 <video> + hls.js state once a second
|
||||
// and renders the active mode, resolution, codec/bitrate, dropped frames, buffer, hls level and session
|
||||
// offset. No data layer — the refs are already in hand. Doubles as the S1 verification surface (it shows
|
||||
// the transcode mode + that the re-encode is engaged). Toggled with "d" / the gauge button / its X.
|
||||
export function PlexStatsOverlay({
|
||||
videoRef,
|
||||
hlsRef,
|
||||
playable,
|
||||
hwRef,
|
||||
sessionStartRef,
|
||||
absRef,
|
||||
durationRef,
|
||||
onClose,
|
||||
}: {
|
||||
videoRef: RefObject<HTMLVideoElement | null>;
|
||||
hlsRef: RefObject<Hls | null>;
|
||||
playable: string;
|
||||
hwRef: RefObject<boolean | null>; // active transcode encoder: true=GPU, false=CPU, null=not transcode
|
||||
sessionStartRef: RefObject<number>;
|
||||
absRef: RefObject<number>;
|
||||
durationRef: RefObject<number>;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [rows, setRows] = useState<StatRow[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const sample = () => {
|
||||
const v = videoRef.current;
|
||||
const hls = hlsRef.current;
|
||||
|
||||
let dropped = 0;
|
||||
let total = 0;
|
||||
try {
|
||||
const q = v?.getVideoPlaybackQuality?.();
|
||||
if (q) {
|
||||
dropped = q.droppedVideoFrames;
|
||||
total = q.totalVideoFrames;
|
||||
}
|
||||
} catch {
|
||||
/* getVideoPlaybackQuality unsupported — leave 0/0 (renders as a dash) */
|
||||
}
|
||||
|
||||
let bufferAhead = 0;
|
||||
if (v) {
|
||||
const now = v.currentTime;
|
||||
for (let i = 0; i < v.buffered.length; i++) {
|
||||
if (now >= v.buffered.start(i) && now <= v.buffered.end(i)) {
|
||||
bufferAhead = v.buffered.end(i) - now;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let level: HlsLevelInfo | null = null;
|
||||
if (hls) {
|
||||
// currentLevel is -1 while auto-ABR hasn't settled; fall back to loadLevel for the res/bitrate.
|
||||
const idx = hls.currentLevel >= 0 ? hls.currentLevel : hls.loadLevel;
|
||||
const lv = idx >= 0 ? hls.levels?.[idx] : undefined;
|
||||
level = {
|
||||
index: hls.currentLevel,
|
||||
total: hls.levels?.length ?? 0,
|
||||
width: lv?.width,
|
||||
height: lv?.height,
|
||||
bitrate: lv?.bitrate,
|
||||
videoCodec: lv?.videoCodec,
|
||||
audioCodec: lv?.audioCodec,
|
||||
};
|
||||
}
|
||||
|
||||
setRows(
|
||||
buildStatRows({
|
||||
playable,
|
||||
hwEncoder: hwRef.current,
|
||||
transport: hls ? "hls" : "direct",
|
||||
videoWidth: v?.videoWidth ?? 0,
|
||||
videoHeight: v?.videoHeight ?? 0,
|
||||
droppedFrames: dropped,
|
||||
totalFrames: total,
|
||||
bufferAheadS: bufferAhead,
|
||||
hlsLevel: level,
|
||||
sessionStartS: sessionStartRef.current ?? 0,
|
||||
abs: absRef.current ?? 0,
|
||||
duration: durationRef.current ?? 0,
|
||||
}),
|
||||
);
|
||||
};
|
||||
sample();
|
||||
const iv = setInterval(sample, 1000);
|
||||
return () => clearInterval(iv);
|
||||
}, [videoRef, hlsRef, playable, hwRef, sessionStartRef, absRef, durationRef]);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="region"
|
||||
aria-label={t("plex.player.stats.title")}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="absolute left-4 top-16 z-menu w-64 rounded-xl border border-white/15 bg-black/80 p-3 text-white shadow-2xl backdrop-blur-sm"
|
||||
>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-white/70">
|
||||
{t("plex.player.stats.title")}
|
||||
</span>
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label={t("plex.player.close")}
|
||||
className="rounded p-0.5 text-white/60 hover:bg-white/10 hover:text-white"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
{/* No aria-live here: the rows re-sample every second, so a live region would make a screen
|
||||
reader announce the changing values continuously and drown out real player announcements.
|
||||
The overlay is a role="region" (labelled above) a user reads on demand, not a status feed. */}
|
||||
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-2xs">
|
||||
{rows.map((r) => (
|
||||
<div key={r.key} className="contents">
|
||||
<dt className="text-white/55">{t(r.labelKey)}</dt>
|
||||
<dd className="text-right font-mono tabular-nums text-white/90">{r.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -195,6 +195,8 @@ export default function ProfileEditor({ onClose }: { onClose: () => void }) {
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
{t("downloads.profiles.audioFormat")}
|
||||
</label>
|
||||
{/* Only browser-playable audio: AAC (m4a) + MP3. Opus is dropped — Safari plays it
|
||||
unreliably as a bare file, and the module's purpose is in-browser playback. */}
|
||||
<select
|
||||
value={spec.audio_format ?? "m4a"}
|
||||
onChange={set("audio_format")}
|
||||
@@ -202,39 +204,12 @@ export default function ProfileEditor({ onClose }: { onClose: () => void }) {
|
||||
>
|
||||
<option value="m4a">M4A</option>
|
||||
<option value="mp3">MP3</option>
|
||||
<option value="opus">Opus</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
{spec.mode !== "a" && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
{t("downloads.profiles.container")}
|
||||
</label>
|
||||
<select
|
||||
value={spec.container ?? "mp4"}
|
||||
onChange={set("container")}
|
||||
className={inputCls}
|
||||
>
|
||||
<option value="mp4">MP4</option>
|
||||
<option value="mkv">MKV</option>
|
||||
<option value="webm">WebM</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
{t("downloads.profiles.vcodec")}
|
||||
</label>
|
||||
<select value={spec.vcodec ?? ""} onChange={set("vcodec")} className={inputCls}>
|
||||
<option value="">{t("downloads.profiles.any")}</option>
|
||||
<option value="h264">H.264</option>
|
||||
<option value="vp9">VP9</option>
|
||||
<option value="av1">AV1</option>
|
||||
</select>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{/* Container/codec are no longer user-selectable: every download is clamped to the
|
||||
mp4/H.264/AAC compat profile (the only universally browser-playable combo) so a
|
||||
shared link plays everywhere, iOS/Safari included. Enforced in backend `normalize`. */}
|
||||
</div>
|
||||
<div className="space-y-1.5 pt-1">
|
||||
{spec.mode !== "a" && (
|
||||
|
||||
@@ -254,7 +254,9 @@ function LinkShare({ job }: { job: DownloadJob }) {
|
||||
queryFn: () => api.downloadLinks(job.id),
|
||||
});
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [allowDownload, setAllowDownload] = useState(false);
|
||||
// D1 (E7 S3): downloadable by default — matches the one-click Share (which relies on the backend
|
||||
// default) and the common "let them have the file" intent. The owner can uncheck it here.
|
||||
const [allowDownload, setAllowDownload] = useState(true);
|
||||
const [expiryDays, setExpiryDays] = useState<number | null>(null);
|
||||
const [password, setPassword] = useState("");
|
||||
const invalidate = () => qc.invalidateQueries({ queryKey: qk.downloadLinks(job.id) });
|
||||
@@ -276,8 +278,8 @@ function LinkShare({ job }: { job: DownloadJob }) {
|
||||
});
|
||||
|
||||
// Open with a link already generated: sharing is almost always the goal, so mint a default
|
||||
// (no password, no expiry, view-only) link the moment the dialog finds none — as if the user
|
||||
// had pressed "New link" → "Create". All further controls (password, download toggle, expiry,
|
||||
// (no password, no expiry, downloadable per D1) link the moment the dialog finds none — as if the
|
||||
// user had pressed "New link" → "Create". All further controls (password, download toggle, expiry,
|
||||
// extra links, revoke) stay available on the created link and via the "New link" button.
|
||||
const autoTried = useRef(false);
|
||||
useEffect(() => {
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The up-next end-card's core behaviour (E6 S4): the countdown that auto-advances at ~10s (the whole
|
||||
// point — it REPLACED the old instant jump, so a regression that fires early or never is exactly the
|
||||
// bug to guard), the "Play now"/Cancel controls, and the episode heading's null season/episode
|
||||
// fallback. i18n is key-passthrough; `../lib/api` is mocked so no network runs; the countdown uses
|
||||
// performance.now, which vitest's fake timers advance alongside setInterval.
|
||||
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup, act } from "@testing-library/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 }) };
|
||||
});
|
||||
|
||||
vi.mock("../lib/api", () => ({
|
||||
api: { plexItem: vi.fn(), plexImageUrl: () => "/img" },
|
||||
}));
|
||||
|
||||
import { UpNextCard } from "./UpNextCard";
|
||||
import { api } from "../lib/api";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
function renderCard(opts: { onAdvance?: () => void; onCancel?: () => void } = {}) {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<UpNextCard
|
||||
nextId="e7"
|
||||
onAdvance={opts.onAdvance ?? vi.fn()}
|
||||
onCancel={opts.onCancel ?? vi.fn()}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("UpNextCard", () => {
|
||||
it("auto-advances after the ~10s countdown, and NOT before", () => {
|
||||
vi.useFakeTimers();
|
||||
// A never-resolving detail query: the heading is irrelevant here and this avoids async state
|
||||
// updates racing the fake clock.
|
||||
(api.plexItem as ReturnType<typeof vi.fn>).mockReturnValue(new Promise(() => {}));
|
||||
const onAdvance = vi.fn();
|
||||
renderCard({ onAdvance });
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(9000);
|
||||
});
|
||||
expect(onAdvance).not.toHaveBeenCalled(); // still counting down
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1200);
|
||||
});
|
||||
expect(onAdvance).toHaveBeenCalledTimes(1); // crossed zero → advanced exactly once
|
||||
});
|
||||
|
||||
it("'Play now' advances immediately, without waiting for the countdown", () => {
|
||||
(api.plexItem as ReturnType<typeof vi.fn>).mockReturnValue(new Promise(() => {}));
|
||||
const onAdvance = vi.fn();
|
||||
renderCard({ onAdvance });
|
||||
fireEvent.click(screen.getByRole("button", { name: /playNow/ }));
|
||||
expect(onAdvance).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("Cancel dismisses via onCancel and does not advance", () => {
|
||||
(api.plexItem as ReturnType<typeof vi.fn>).mockReturnValue(new Promise(() => {}));
|
||||
const onAdvance = vi.fn();
|
||||
const onCancel = vi.fn();
|
||||
renderCard({ onAdvance, onCancel });
|
||||
fireEvent.click(screen.getByText("plex.player.upNext.cancel"));
|
||||
expect(onCancel).toHaveBeenCalledTimes(1);
|
||||
expect(onAdvance).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("an episode heading falls back to S?·E? when season/episode are null", async () => {
|
||||
(api.plexItem as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
id: "e7",
|
||||
kind: "episode",
|
||||
show_title: "Westworld",
|
||||
title: "The Adversary",
|
||||
season_number: null,
|
||||
episode_number: null,
|
||||
});
|
||||
renderCard();
|
||||
expect(await screen.findByText("Westworld · S?·E?")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Play, X } from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
import { plexLang } from "../i18n";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
|
||||
// Up-next / end-card (E6 S4). Shown at episode end when a next item exists and the pref is on, INSTEAD
|
||||
// of the old instant jump — so binge stays under the viewer's control (D6-A). Counts down ~10s then
|
||||
// advances; "Play now" advances immediately; Cancel/✕ dismisses and stays on the finished frame. It only
|
||||
// lives on the play-to-the-end path; credits-auto-skip advances earlier and never reaches this card.
|
||||
const COUNTDOWN_S = 10;
|
||||
|
||||
export function UpNextCard({
|
||||
nextId,
|
||||
onAdvance,
|
||||
onCancel,
|
||||
}: {
|
||||
nextId: string;
|
||||
onAdvance: () => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const lang = plexLang();
|
||||
const nextQ = useQuery({
|
||||
queryKey: qk.plexItem(nextId, lang),
|
||||
queryFn: () => api.plexItem(nextId, lang),
|
||||
});
|
||||
const next = nextQ.data;
|
||||
|
||||
// Countdown. `onAdvance` is read through a ref so the 100ms interval never restarts on re-render.
|
||||
const [remaining, setRemaining] = useState(COUNTDOWN_S);
|
||||
const advanceRef = useRef(onAdvance);
|
||||
advanceRef.current = onAdvance;
|
||||
useEffect(() => {
|
||||
const started = performance.now();
|
||||
const iv = window.setInterval(() => {
|
||||
const left = COUNTDOWN_S - (performance.now() - started) / 1000;
|
||||
if (left <= 0) {
|
||||
window.clearInterval(iv);
|
||||
setRemaining(0);
|
||||
advanceRef.current();
|
||||
} else {
|
||||
setRemaining(left);
|
||||
}
|
||||
}, 100);
|
||||
return () => window.clearInterval(iv);
|
||||
}, []);
|
||||
|
||||
const secs = Math.ceil(remaining);
|
||||
const frac = remaining / COUNTDOWN_S; // 1 → 0 as it counts down
|
||||
const R = 10;
|
||||
const C = 2 * Math.PI * R;
|
||||
|
||||
// Title: "Show · S2·E5" over the episode title for episodes; the plain title for movies/queue items.
|
||||
const heading =
|
||||
next?.kind === "episode" && next.show_title
|
||||
? `${next.show_title} · S${next.season_number ?? "?"}·E${next.episode_number ?? "?"}`
|
||||
: (next?.title ?? "");
|
||||
const sub = next?.kind === "episode" ? next.title : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="region"
|
||||
aria-label={t("plex.player.upNext.title")}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="absolute bottom-24 right-6 z-menu flex w-[22rem] max-w-[calc(100vw-3rem)] gap-3 rounded-2xl border border-white/15 bg-neutral-900/95 p-3.5 text-white shadow-2xl"
|
||||
>
|
||||
<div className="h-[6.5rem] w-[4.6rem] flex-none overflow-hidden rounded-lg bg-white/10">
|
||||
{next && (
|
||||
<img
|
||||
src={api.plexImageUrl(nextId, "thumb")}
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
draggable={false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<div className="mb-0.5 text-2xs font-semibold uppercase tracking-wide text-white/50">
|
||||
{t("plex.player.upNext.title")}
|
||||
</div>
|
||||
<div className="truncate text-sm font-semibold leading-tight">{heading || "…"}</div>
|
||||
{sub && <div className="truncate text-xs text-white/60">{sub}</div>}
|
||||
<div className="mt-auto flex items-center gap-2 pt-2.5">
|
||||
<button
|
||||
onClick={onAdvance}
|
||||
className="flex items-center gap-1.5 rounded-full bg-accent px-3 py-1.5 text-xs font-semibold text-black hover:brightness-110"
|
||||
>
|
||||
<span className="relative grid h-[22px] w-[22px] flex-none place-items-center">
|
||||
<svg viewBox="0 0 24 24" className="absolute inset-0 -rotate-90">
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r={R}
|
||||
fill="none"
|
||||
stroke="rgba(0,0,0,0.25)"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r={R}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={C}
|
||||
strokeDashoffset={C * (1 - frac)}
|
||||
/>
|
||||
</svg>
|
||||
<Play className="h-2.5 w-2.5 fill-black" />
|
||||
</span>
|
||||
{t("plex.player.upNext.playNow")}
|
||||
<span className="tabular-nums">{secs}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="rounded-full border border-white/25 px-3 py-1.5 text-xs text-white/85 hover:bg-white/10"
|
||||
>
|
||||
{t("plex.player.upNext.cancel")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
aria-label={t("plex.player.upNext.cancel")}
|
||||
className="absolute right-2 top-2 rounded p-0.5 text-white/50 hover:bg-white/10 hover:text-white"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// E7·S5c — the local-upload dialog. It's a general file upload (any type, no format list): a picked
|
||||
// file surfaces its name+size, and Upload streams it through api.uploadDownload (the XHR-progress
|
||||
// path). This drives the real dialog against a mocked api so we can assert the file + optional name
|
||||
// actually reach the ingest call, and that the success toast points at the library. i18n is
|
||||
// key-passthrough.
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
const h = vi.hoisted(() => ({
|
||||
uploadDownload: vi.fn(async (_f: File, _o: unknown) => ({ id: 42 })),
|
||||
notify: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
|
||||
});
|
||||
vi.mock("../lib/api", () => ({ api: { uploadDownload: h.uploadDownload } }));
|
||||
vi.mock("../lib/downloads", () => ({ invalidateDownloads: vi.fn() }));
|
||||
vi.mock("../lib/notifications", () => ({ notify: h.notify }));
|
||||
vi.mock("../lib/nav", () => ({ navigateTo: vi.fn() }));
|
||||
|
||||
import UploadDialog from "./UploadDialog";
|
||||
|
||||
const wrap = (onClose = vi.fn()) => {
|
||||
const qc = new QueryClient();
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<UploadDialog onClose={onClose} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
};
|
||||
|
||||
// The dialog portals into document.body, so query the whole document, not the render container.
|
||||
const fileInput = () => document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
|
||||
beforeEach(() => {
|
||||
h.uploadDownload.mockClear();
|
||||
h.notify.mockClear();
|
||||
});
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("UploadDialog", () => {
|
||||
it("shows the picked file's name and uploads it with the optional name", async () => {
|
||||
const onClose = vi.fn();
|
||||
wrap(onClose);
|
||||
const f = new File(["hello world"], "notes.pdf", { type: "application/pdf" });
|
||||
fireEvent.change(fileInput(), { target: { files: [f] } });
|
||||
|
||||
// The drop zone now surfaces the file name.
|
||||
expect(screen.getByText("notes.pdf")).toBeTruthy();
|
||||
|
||||
// Optional display name.
|
||||
const nameBox = screen.getByPlaceholderText("downloads.upload.namePlaceholder");
|
||||
fireEvent.change(nameBox, { target: { value: "My notes" } });
|
||||
|
||||
fireEvent.click(screen.getByText("downloads.upload.submit"));
|
||||
|
||||
await waitFor(() => expect(h.uploadDownload).toHaveBeenCalledTimes(1));
|
||||
const [passedFile, opts] = h.uploadDownload.mock.calls[0]!;
|
||||
expect(passedFile).toBe(f);
|
||||
expect((opts as { displayName?: string }).displayName).toBe("My notes");
|
||||
await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(1));
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it("keeps Upload disabled until a file is chosen", () => {
|
||||
wrap();
|
||||
const btn = screen.getByText("downloads.upload.submit").closest("button")!;
|
||||
expect(btn.disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { UploadCloud, File as FileIcon } from "lucide-react";
|
||||
import Modal from "./Modal";
|
||||
import { Button } from "./ui/Button";
|
||||
import { api } from "../lib/api";
|
||||
import { invalidateDownloads } from "../lib/downloads";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { navigateTo } from "../lib/nav";
|
||||
import { formatBytes } from "../lib/format";
|
||||
import { inputCls } from "./ui/form";
|
||||
|
||||
// Local-file ingest (E7 S5c · D5-A). A general upload — ANY file type, no format list: media
|
||||
// (video/audio) joins the library with the full function set, a generic file is download-only. The
|
||||
// file is streamed to the server via XHR so this dialog can show real upload progress. On success it
|
||||
// lands in the Library as an already-`done` job, so we invalidate + point the user there.
|
||||
export default function UploadDialog({ onClose }: { onClose: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [name, setName] = useState("");
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const [pct, setPct] = useState<number | null>(null); // null = idle; 0–100 while uploading
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
// dragenter/dragleave fire as the cursor crosses the drop zone's OWN children (icon/text), so a
|
||||
// plain boolean flickers. Count enter vs leave and only clear the highlight at depth 0.
|
||||
const dragDepth = useRef(0);
|
||||
const busy = pct !== null;
|
||||
|
||||
// Closing the dialog mid-upload (X / ESC / backdrop / browser Back all unmount us) must abort the
|
||||
// in-flight XHR — otherwise it keeps streaming in the background and still toasts success. Only the
|
||||
// footer Cancel wired the abort; this covers every other dismissal.
|
||||
useEffect(() => () => abortRef.current?.abort(), []);
|
||||
|
||||
const pick = () => inputRef.current?.click();
|
||||
|
||||
const submit = async () => {
|
||||
if (!file || busy) return;
|
||||
const ac = new AbortController();
|
||||
abortRef.current = ac;
|
||||
setPct(0);
|
||||
try {
|
||||
await api.uploadDownload(file, {
|
||||
displayName: name.trim() || undefined,
|
||||
onProgress: setPct,
|
||||
signal: ac.signal,
|
||||
});
|
||||
invalidateDownloads(qc);
|
||||
notify({
|
||||
level: "success",
|
||||
message: t("downloads.upload.added"),
|
||||
action: { label: t("downloads.dialog.view"), onClick: () => navigateTo("downloads") },
|
||||
});
|
||||
onClose();
|
||||
} catch {
|
||||
// An aborted upload (Cancel) or a server rejection (cap/quota — already surfaced by the global
|
||||
// error modal, like every other req call). Re-arm the form so the user can retry or pick again.
|
||||
setPct(null);
|
||||
abortRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal title={t("downloads.upload.title")} onClose={onClose}>
|
||||
<p className="text-sm text-muted mb-4">{t("downloads.upload.hint")}</p>
|
||||
|
||||
{/* Drop zone doubles as the picker trigger — a click anywhere opens the native file dialog. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={pick}
|
||||
onDragEnter={(e) => {
|
||||
e.preventDefault();
|
||||
dragDepth.current += 1;
|
||||
setDragOver(true);
|
||||
}}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDragLeave={() => {
|
||||
dragDepth.current = Math.max(0, dragDepth.current - 1);
|
||||
if (dragDepth.current === 0) setDragOver(false);
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
dragDepth.current = 0;
|
||||
setDragOver(false);
|
||||
const f = e.dataTransfer.files?.[0];
|
||||
if (f) {
|
||||
setFile(f);
|
||||
setName("");
|
||||
}
|
||||
}}
|
||||
disabled={busy}
|
||||
className={`w-full flex flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed px-4 py-8 text-center transition disabled:opacity-60 ${
|
||||
dragOver ? "border-accent bg-accent/5" : "border-border hover:border-fg/40"
|
||||
}`}
|
||||
>
|
||||
{file ? (
|
||||
<>
|
||||
<FileIcon className="w-6 h-6 text-muted" />
|
||||
<span className="text-sm font-medium break-all">{file.name}</span>
|
||||
<span className="text-xs text-muted">{formatBytes(file.size)}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<UploadCloud className="w-7 h-7 text-muted" />
|
||||
<span className="text-sm text-muted">
|
||||
{dragOver ? t("downloads.upload.dropActive") : t("downloads.upload.drop")}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f) {
|
||||
setFile(f);
|
||||
setName("");
|
||||
}
|
||||
e.target.value = ""; // let re-picking the same file fire onChange again
|
||||
}}
|
||||
/>
|
||||
|
||||
<label className="block text-sm font-medium mt-4 mb-1">
|
||||
{t("downloads.upload.nameLabel")}
|
||||
</label>
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={t("downloads.upload.namePlaceholder")}
|
||||
disabled={busy}
|
||||
className={`${inputCls} mb-5`}
|
||||
/>
|
||||
|
||||
{busy && (
|
||||
<div className="mb-5" aria-live="polite">
|
||||
<div
|
||||
className="h-2 rounded-full bg-surface overflow-hidden"
|
||||
role="progressbar"
|
||||
aria-valuenow={pct ?? 0}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
// A STABLE descriptor as the name; the live percentage rides aria-valuenow (and the visible
|
||||
// <p> below re-announces it via the aria-live wrapper) — don't bake the value into the name.
|
||||
aria-label={t("downloads.upload.uploadProgress")}
|
||||
>
|
||||
<div
|
||||
className="h-full bg-accent transition-[width] duration-150"
|
||||
style={{ width: `${pct ?? 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted mt-1.5 text-center">
|
||||
{t("downloads.upload.uploading", { pct: pct ?? 0 })}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => (busy ? abortRef.current?.abort() : onClose())}
|
||||
>
|
||||
{t("downloads.actions.cancel")}
|
||||
</Button>
|
||||
<Button variant="primary" size="sm" onClick={submit} disabled={!file || busy}>
|
||||
{t("downloads.upload.submit")}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import clsx from "clsx";
|
||||
import Modal from "./Modal";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { api, type DownloadJob, type EditSpec } from "../lib/api";
|
||||
import { contentRect, cropToSourcePx, type Frac } from "../lib/cropGeometry";
|
||||
import { inputCls, btnCls } from "./ui/form";
|
||||
import { Button } from "./ui/Button";
|
||||
|
||||
@@ -31,7 +32,6 @@ function parseTc(v: string): number | null {
|
||||
}
|
||||
|
||||
type Seg = { id: number; start: number; end: number; keep: boolean };
|
||||
type Frac = { x: number; y: number; w: number; h: number };
|
||||
type Drag = { type: "seek" } | { type: "boundary"; bi: number };
|
||||
|
||||
const numCls =
|
||||
@@ -51,6 +51,11 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
||||
const [current, setCurrent] = useState(0);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [trackW, setTrackW] = useState(0);
|
||||
// Intrinsic (source) video dims and the video element's rendered box, both needed to map the
|
||||
// crop marquee onto the letterboxed picture. `vdim` starts from asset metadata so the overlay
|
||||
// can size before the media decodes; `box` is measured off the element (max-h can clamp it).
|
||||
const [vdim, setVdim] = useState({ w: job.asset?.width ?? 0, h: job.asset?.height ?? 0 });
|
||||
const [box, setBox] = useState({ w: 0, h: 0 });
|
||||
|
||||
// The cut-list: contiguous segments covering [0, duration]. Starts as the whole video, kept.
|
||||
const [segments, setSegments] = useState<Seg[]>([{ id: 0, start: 0, end: srcDur, keep: true }]);
|
||||
@@ -83,11 +88,28 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
// Measure the video element's rendered box (max-h can shrink it below its intrinsic size, which
|
||||
// then pillarboxes the picture). The crop overlay is anchored to the picture, not this box.
|
||||
useEffect(() => {
|
||||
const v = videoRef.current;
|
||||
if (!v || typeof ResizeObserver === "undefined") return;
|
||||
const ro = new ResizeObserver(() => setBox({ w: v.clientWidth, h: v.clientHeight }));
|
||||
ro.observe(v);
|
||||
setBox({ w: v.clientWidth, h: v.clientHeight });
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
// The picture's rendered rectangle inside the (possibly letterboxed) video box — the overlay's
|
||||
// frame, so a crop fraction is a fraction of the real picture, not of the black bars.
|
||||
const va = vdim.w && vdim.h ? vdim.w / vdim.h : aspect;
|
||||
const picRect = contentRect(box.w, box.h, va);
|
||||
|
||||
const onLoaded = () => {
|
||||
const v = videoRef.current;
|
||||
if (!v) return;
|
||||
const d = v.duration && isFinite(v.duration) ? v.duration : srcDur;
|
||||
setDuration(d);
|
||||
if (v.videoWidth && v.videoHeight) setVdim({ w: v.videoWidth, h: v.videoHeight });
|
||||
// Reconcile the still-pristine full-length segment to the REAL decoded duration. The initial
|
||||
// segment ends at the (integer, often-rounded) metadata `srcDur`; once the media loads we know
|
||||
// the true length (e.g. 213.44 vs 213). Without this the untouched last segment stays short, so
|
||||
@@ -258,16 +280,12 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
||||
|
||||
const cropPx = (): { x: number; y: number; w: number; h: number } | undefined => {
|
||||
const v = videoRef.current;
|
||||
const vw = v?.videoWidth || job.asset?.width || 0;
|
||||
const vh = v?.videoHeight || job.asset?.height || 0;
|
||||
if (!cropOn || !vw || !vh) return undefined;
|
||||
const even = (n: number) => Math.max(2, Math.round(n / 2) * 2);
|
||||
return {
|
||||
x: even(crop.x * vw),
|
||||
y: even(crop.y * vh),
|
||||
w: even(crop.w * vw),
|
||||
h: even(crop.h * vh),
|
||||
};
|
||||
const vw = v?.videoWidth || vdim.w;
|
||||
const vh = v?.videoHeight || vdim.h;
|
||||
if (!cropOn) return undefined;
|
||||
// crop fractions are relative to the picture (the overlay is anchored to it), so this is a
|
||||
// straight fraction→source-px scale; see cropGeometry for the even-rounding + in-bounds clamp.
|
||||
return cropToSourcePx(crop, vw, vh) ?? undefined;
|
||||
};
|
||||
|
||||
const create = useMutation({
|
||||
@@ -322,11 +340,20 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
||||
onTimeUpdate={onTimeUpdate}
|
||||
onPlay={() => setPlaying(true)}
|
||||
onPause={() => setPlaying(false)}
|
||||
className="w-full max-h-[42vh] mx-auto block"
|
||||
className="w-full max-h-[42vh] mx-auto block object-contain"
|
||||
playsInline
|
||||
/>
|
||||
{cropOn && (
|
||||
<div ref={overlayRef} className="absolute inset-0">
|
||||
{cropOn && picRect && (
|
||||
<div
|
||||
ref={overlayRef}
|
||||
className="absolute"
|
||||
style={{
|
||||
left: picRect.left,
|
||||
top: picRect.top,
|
||||
width: picRect.width,
|
||||
height: picRect.height,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute border-2 border-accent cursor-move"
|
||||
style={{
|
||||
|
||||
@@ -36,4 +36,41 @@ describe("WatchPage media element (S3 · 5a)", () => {
|
||||
await waitFor(() => expect(container.querySelector("video")).not.toBeNull());
|
||||
expect(container.querySelector("audio")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders an info card + Download for a generic file, no player (S5d)", async () => {
|
||||
window.history.replaceState({}, "", "/watch/tok-file");
|
||||
mockMeta({
|
||||
needs_password: false,
|
||||
media_kind: "file",
|
||||
title: "report.pdf",
|
||||
container: "pdf",
|
||||
size_bytes: 2_400_000,
|
||||
allow_download: true,
|
||||
file_url: "/f.pdf",
|
||||
});
|
||||
const { container, findByText } = render(<WatchPage />);
|
||||
await findByText("report.pdf");
|
||||
expect(container.querySelector("video")).toBeNull();
|
||||
expect(container.querySelector("audio")).toBeNull();
|
||||
// Type · size chip and a Download link to the file.
|
||||
await findByText(/PDF · 2\.3 MB/);
|
||||
const dl = container.querySelector('a[href="/f.pdf"]');
|
||||
expect(dl).not.toBeNull();
|
||||
});
|
||||
|
||||
it("omits the Download for a view-only file link (S5d)", async () => {
|
||||
window.history.replaceState({}, "", "/watch/tok-file-noview");
|
||||
mockMeta({
|
||||
needs_password: false,
|
||||
media_kind: "file",
|
||||
title: "secret.zip",
|
||||
container: "zip",
|
||||
size_bytes: 1000,
|
||||
allow_download: false,
|
||||
file_url: "/f.zip",
|
||||
});
|
||||
const { container, findByText } = render(<WatchPage />);
|
||||
await findByText("secret.zip");
|
||||
expect(container.querySelector('a[href="/f.zip"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Download, Link2, Lock, PlayCircle } from "lucide-react";
|
||||
import { Download, File as FileIcon, Link2, Lock, PlayCircle } from "lucide-react";
|
||||
import { Button } from "./ui/Button";
|
||||
import { formatBytes } from "../lib/format";
|
||||
|
||||
// Standalone, login-free video page for a shared /watch/<token> link. Rendered OUTSIDE the app
|
||||
// tree (no auth, no providers) — like the /privacy and /terms pages. Uses plain fetch and a tiny
|
||||
@@ -48,6 +49,9 @@ type Meta = {
|
||||
extra_links?: string[];
|
||||
duration_s?: number | null;
|
||||
audio?: boolean;
|
||||
media_kind?: "video" | "audio" | "file" | null;
|
||||
size_bytes?: number | null;
|
||||
container?: string | null;
|
||||
allow_download?: boolean;
|
||||
file_url?: string;
|
||||
poster_url?: string | null;
|
||||
@@ -73,6 +77,7 @@ const STR = {
|
||||
watch: "Watch",
|
||||
wrong: "Wrong password.",
|
||||
download: "Download",
|
||||
file: "File",
|
||||
links: "Links",
|
||||
brand: "Shared via Siftlode",
|
||||
},
|
||||
@@ -85,6 +90,7 @@ const STR = {
|
||||
watch: "Megnézem",
|
||||
wrong: "Hibás jelszó.",
|
||||
download: "Letöltés",
|
||||
file: "Fájl",
|
||||
links: "Linkek",
|
||||
brand: "Megosztva a Siftlode-dal",
|
||||
},
|
||||
@@ -97,6 +103,7 @@ const STR = {
|
||||
watch: "Ansehen",
|
||||
wrong: "Falsches Passwort.",
|
||||
download: "Herunterladen",
|
||||
file: "Datei",
|
||||
links: "Links",
|
||||
brand: "Geteilt über Siftlode",
|
||||
},
|
||||
@@ -215,7 +222,37 @@ export default function WatchPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === "ready" && meta && (
|
||||
{status === "ready" && meta && meta.media_kind === "file" && (
|
||||
/* A generic uploaded file has no player — just an info card (name/type/size) and, if the
|
||||
link allows it, a Download. The backend serves the file force-download regardless, so
|
||||
gating the button on allow_download only mirrors the owner's ACL, matching the media
|
||||
branch's download affordance below. */
|
||||
<div className="flex-1 grid place-items-center">
|
||||
<div className="w-full max-w-md rounded-2xl glass p-6 text-center">
|
||||
<div className="mx-auto w-16 h-16 rounded-xl bg-surface grid place-items-center mb-4">
|
||||
<FileIcon className="w-8 h-8 text-accent" />
|
||||
</div>
|
||||
<h1 className="text-lg font-semibold leading-snug break-words">
|
||||
{meta.title || T.file}
|
||||
</h1>
|
||||
<div className="text-sm text-muted mt-1">
|
||||
{[meta.container?.toUpperCase(), formatBytes(meta.size_bytes)]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
</div>
|
||||
{meta.allow_download && (
|
||||
<a
|
||||
href={meta.file_url}
|
||||
className="mt-5 inline-flex items-center gap-1.5 rounded-lg bg-surface hover:bg-card px-4 py-2 text-sm font-medium transition"
|
||||
>
|
||||
<Download className="w-4 h-4" /> {T.download}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === "ready" && meta && meta.media_kind !== "file" && (
|
||||
<div>
|
||||
{meta.audio ? (
|
||||
/* Audio-only asset (mp3): WebKit won't play it in a <video>, so render a native
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Library-row metadata (E7·S1): the completed row surfaces the concrete container/format alongside the
|
||||
// already-shown size. The format chip reads the raw `asset.container` (uppercased in the UI), so it must
|
||||
// render when present and be absent when the asset has no container yet (queued/running before the worker
|
||||
// fills it). i18n is key-passthrough here.
|
||||
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||
import { render, screen, cleanup } from "@testing-library/react";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
|
||||
});
|
||||
|
||||
import { DownloadRow } from "./downloadRow";
|
||||
import type { DownloadJob } from "../lib/api";
|
||||
|
||||
const baseJob: DownloadJob = {
|
||||
id: 1,
|
||||
status: "done",
|
||||
progress: 100,
|
||||
speed_bps: null,
|
||||
eta_s: null,
|
||||
phase: null,
|
||||
error: null,
|
||||
error_code: null,
|
||||
queue_pos: 0,
|
||||
created_at: null,
|
||||
source_kind: "youtube",
|
||||
source_ref: "abc",
|
||||
source_url: null,
|
||||
poster_url: null,
|
||||
profile_id: null,
|
||||
spec: {
|
||||
mode: "av",
|
||||
max_height: null,
|
||||
container: null,
|
||||
audio_format: null,
|
||||
vcodec: null,
|
||||
embed_subs: false,
|
||||
embed_chapters: false,
|
||||
embed_thumbnail: false,
|
||||
sponsorblock: false,
|
||||
},
|
||||
display_name: "Clip",
|
||||
display_uploader: null,
|
||||
display_uploader_url: null,
|
||||
extra_links: [],
|
||||
can_download: true,
|
||||
expired: false,
|
||||
asset: null,
|
||||
};
|
||||
|
||||
const withAsset = (
|
||||
container: string | null,
|
||||
over: Partial<NonNullable<DownloadJob["asset"]>> = {},
|
||||
): DownloadJob => ({
|
||||
...baseJob,
|
||||
asset: {
|
||||
id: 10,
|
||||
status: "ready",
|
||||
title: "Clip",
|
||||
uploader: null,
|
||||
uploader_url: null,
|
||||
upload_date: null,
|
||||
duration_s: 90,
|
||||
size_bytes: 148_800_000,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
container,
|
||||
media_kind: "video",
|
||||
thumbnail_url: null,
|
||||
expires_at: null,
|
||||
...over,
|
||||
},
|
||||
});
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("DownloadRow format chip", () => {
|
||||
it("shows the uppercased container next to the size when ready", () => {
|
||||
render(<DownloadRow job={withAsset("mp4")} />);
|
||||
// Raw container is stored lowercase; the row uppercases it via CSS, so the text node stays "mp4".
|
||||
expect(screen.getByText(/·\s*mp4/i)).toBeTruthy();
|
||||
expect(screen.getByText(/142 MB/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("omits the format chip when the asset has no container yet", () => {
|
||||
const { container } = render(<DownloadRow job={withAsset(null)} />);
|
||||
expect(container.textContent).not.toMatch(/·\s*(mp4|m4a|webm)/i);
|
||||
});
|
||||
});
|
||||
|
||||
// E7·S5b — a generic uploaded file (media_kind='file') has no video frame and no timeline: the row
|
||||
// shows a neutral file glyph instead of a thumbnail <img>, and no duration chip (it stays media info
|
||||
// like the container/size). Media rows keep their thumbnail path (an <img>, or empty until a poster).
|
||||
describe("DownloadRow file kind", () => {
|
||||
it("renders a file glyph, not a thumbnail <img>, for a generic file", () => {
|
||||
const { container } = render(
|
||||
<DownloadRow
|
||||
job={withAsset("pdf", { media_kind: "file", duration_s: null, size_bytes: 2_400_000 })}
|
||||
/>,
|
||||
);
|
||||
// The file glyph is an inline svg; no <img> element is emitted for a file.
|
||||
expect(container.querySelector("img")).toBeNull();
|
||||
expect(container.querySelector("svg")).not.toBeNull();
|
||||
// File info still reads (container + size), but there is no duration chip.
|
||||
expect(screen.getByText(/·\s*pdf/i)).toBeTruthy();
|
||||
expect(screen.getByText(/2\.3 MB/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("keeps the thumbnail path (an <img> when a poster exists) for a media asset", () => {
|
||||
const { container } = render(
|
||||
<DownloadRow job={{ ...withAsset("mp4"), poster_url: "/api/downloads/1/poster.jpg" }} />,
|
||||
);
|
||||
expect(container.querySelector("img")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CalendarClock, Copy, Link2, Scissors } from "lucide-react";
|
||||
import { CalendarClock, Copy, File, Link2, Scissors } from "lucide-react";
|
||||
import clsx from "clsx";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { useLiveQuery } from "../lib/useLiveQuery";
|
||||
@@ -43,12 +43,12 @@ export function StatusPill({ job }: { job: DownloadJob }) {
|
||||
// confused by the bar resetting between the separate video and audio streams.
|
||||
if (job.status === "running" && job.phase && PHASE_KEYS.includes(job.phase)) {
|
||||
return (
|
||||
<span className="text-xs font-medium text-accent">{t(`downloads.phase.${job.phase}`)}</span>
|
||||
<span className="text-sm font-medium text-accent">{t(`downloads.phase.${job.phase}`)}</span>
|
||||
);
|
||||
}
|
||||
const key = job.expired ? "expired" : job.status;
|
||||
return (
|
||||
<span className={clsx("text-xs font-medium", STATUS_CLS[job.status] ?? "text-muted")}>
|
||||
<span className={clsx("text-sm font-medium", STATUS_CLS[job.status] ?? "text-muted")}>
|
||||
{t(`downloads.status.${key}`)}
|
||||
</span>
|
||||
);
|
||||
@@ -69,6 +69,15 @@ function Thumb({ job }: { job: DownloadJob }) {
|
||||
// On load failure (a genuinely missing poster file) fall back to the remote source thumbnail.
|
||||
const [src, setSrc] = useState(primary);
|
||||
useEffect(() => setSrc(primary), [primary]);
|
||||
// A generic uploaded file (media_kind='file') has no video frame to poster — it's a document/
|
||||
// archive, not playable media. Show a neutral file glyph instead of an (always-empty) thumbnail.
|
||||
if (job.asset?.media_kind === "file") {
|
||||
return (
|
||||
<div className="w-28 aspect-video shrink-0 rounded-lg overflow-hidden bg-surface border border-border flex items-center justify-center text-muted">
|
||||
<File className="w-8 h-8" aria-hidden="true" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="w-28 aspect-video shrink-0 rounded-lg overflow-hidden bg-surface border border-border">
|
||||
{src ? (
|
||||
@@ -145,8 +154,8 @@ function SourceRef({ url }: { url: string }) {
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className="flex items-center gap-1 mt-1 text-xs text-muted min-w-0">
|
||||
<Link2 className="w-3 h-3 shrink-0" />
|
||||
<div className="flex items-center gap-1 mt-1 text-sm text-muted min-w-0">
|
||||
<Link2 className="w-3.5 h-3.5 shrink-0" />
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
@@ -162,7 +171,7 @@ function SourceRef({ url }: { url: string }) {
|
||||
title={t("downloads.source.copy")}
|
||||
className="shrink-0 p-0.5 rounded-sm hover:bg-surface hover:text-fg transition"
|
||||
>
|
||||
<Copy className="w-3 h-3" />
|
||||
<Copy className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
@@ -173,7 +182,7 @@ function ChannelLine({ job }: { job: DownloadJob }) {
|
||||
const { name, url } = jobChannel(job);
|
||||
if (url) {
|
||||
return (
|
||||
<div className="text-xs mt-0.5 truncate">
|
||||
<div className="text-sm mt-0.5 truncate">
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
@@ -186,7 +195,7 @@ function ChannelLine({ job }: { job: DownloadJob }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <div className="text-xs text-muted truncate mt-0.5">{name}</div>;
|
||||
return <div className="text-sm text-muted truncate mt-0.5">{name}</div>;
|
||||
}
|
||||
|
||||
export function DownloadRow({
|
||||
@@ -206,15 +215,20 @@ export function DownloadRow({
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-medium leading-snug line-clamp-1 flex items-center gap-1.5">
|
||||
{job.job_kind === "edit" && (
|
||||
<span className="inline-flex items-center gap-1 shrink-0 px-1.5 py-0.5 rounded-sm text-3xs font-semibold uppercase tracking-wide bg-accent/15 text-accent">
|
||||
<span className="inline-flex items-center gap-1 shrink-0 px-1.5 py-0.5 rounded-sm text-2xs font-semibold uppercase tracking-wide bg-accent/15 text-accent">
|
||||
<Scissors className="w-3 h-3" /> {t("downloads.clipBadge")}
|
||||
</span>
|
||||
)}
|
||||
<span className="truncate">{jobTitle(job)}</span>
|
||||
</div>
|
||||
<ChannelLine job={job} />
|
||||
<div className="flex items-center gap-2 mt-1 text-xs">
|
||||
<div className="flex items-center gap-2 mt-1 text-sm">
|
||||
<StatusPill job={job} />
|
||||
{job.asset?.container ? (
|
||||
// Concrete container (mp4 / m4a / webm …) — reads as the file format, and its audio-vs-video
|
||||
// extension is the video/audio distinction the running-only phase pill can't show at rest.
|
||||
<span className="text-muted uppercase">· {job.asset.container}</span>
|
||||
) : null}
|
||||
{job.asset?.size_bytes ? (
|
||||
<span className="text-muted">· {formatBytes(job.asset.size_bytes)}</span>
|
||||
) : null}
|
||||
@@ -226,7 +240,7 @@ export function DownloadRow({
|
||||
className="text-muted inline-flex items-center gap-1"
|
||||
title={new Date(job.created_at).toLocaleString()}
|
||||
>
|
||||
· <CalendarClock className="w-3 h-3" /> {relativeTime(job.created_at)}
|
||||
· <CalendarClock className="w-3.5 h-3.5" /> {relativeTime(job.created_at)}
|
||||
</span>
|
||||
) : null}
|
||||
{job.status === "error" ? (
|
||||
@@ -259,7 +273,7 @@ export function DownloadRow({
|
||||
style={{ width: `${job.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-2xs text-muted mt-0.5 flex gap-2">
|
||||
<div className="text-xs text-muted mt-0.5 flex gap-2">
|
||||
<span>{job.progress}%</span>
|
||||
{job.speed_bps ? <span>{formatSpeed(job.speed_bps)}</span> : null}
|
||||
{job.eta_s ? <span>{formatEta(job.eta_s)}</span> : null}
|
||||
@@ -269,7 +283,12 @@ export function DownloadRow({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-start gap-0.5 shrink-0">{children}</div>
|
||||
{/* Row action buttons: grown a step here (bigger tap target + icon) without touching the shared
|
||||
IconBtn — scoped so the same primitive stays smaller in the modals. `shrink-0` + no wrap keeps
|
||||
them on the title's line; the text column shrinks instead. */}
|
||||
<div className="flex items-start gap-1 shrink-0 [&_button]:p-2 [&_svg]:size-[18px]">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,49 @@
|
||||
import { type ReactNode } from "react";
|
||||
import { RotateCcw } from "lucide-react";
|
||||
|
||||
// ±10s skip glyph: an outline circular arrow with a "10" in the centre — the YouTube/Plex-native
|
||||
// muscle-memory shape. Deliberately distinct from the episode-level SkipBack/SkipForward (double
|
||||
// triangles) and from Restart's plain RotateCcw. The arrowhead is an L-corner at the arc's open end,
|
||||
// the same construction lucide uses for RotateCcw/RotateCw, so it sits consistently in the bar.
|
||||
export function Skip10({ dir }: { dir: "back" | "forward" }) {
|
||||
const back = dir === "back";
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="w-6 h-6"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{back ? (
|
||||
<>
|
||||
<path d="M4 8.5 A8 8 0 1 1 4 12" />
|
||||
<path d="M4 8.5 L4 4 M4 8.5 L8.5 8.5" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<path d="M20 8.5 A8 8 0 1 0 20 12" />
|
||||
<path d="M20 8.5 L20 4 M20 8.5 L15.5 8.5" />
|
||||
</>
|
||||
)}
|
||||
<text
|
||||
x="12"
|
||||
y="14.5"
|
||||
textAnchor="middle"
|
||||
fontSize="8"
|
||||
fontWeight={700}
|
||||
fill="currentColor"
|
||||
stroke="none"
|
||||
>
|
||||
10
|
||||
</text>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// --- settings-panel building blocks ------------------------------------------------------------
|
||||
export function PanelHead({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
|
||||
@@ -166,7 +166,23 @@
|
||||
},
|
||||
"plex_max_transcodes": {
|
||||
"label": "Max concurrent transcodes",
|
||||
"hint": "Cap on simultaneous CPU transcodes for incompatible files (a later fallback). Direct-playable files aren't limited."
|
||||
"hint": "Cap on simultaneous transcode sessions for non-H.264 files. Direct-play and remux (stream-copy) files aren't limited."
|
||||
},
|
||||
"plex_transcode_hw": {
|
||||
"label": "Hardware transcode",
|
||||
"hint": "GPU (VAAPI) encoding policy: auto (GPU when available and the source is decodable, else software), on (force GPU), off (always software)."
|
||||
},
|
||||
"plex_transcode_preset": {
|
||||
"label": "Software preset",
|
||||
"hint": "libx264 preset for the software fallback (e.g. veryfast, faster, medium). Slower presets shrink the stream but cost more CPU. Ignored on the GPU path."
|
||||
},
|
||||
"plex_transcode_crf": {
|
||||
"label": "Software quality (CRF)",
|
||||
"hint": "libx264 CRF for the software fallback (0–51; lower = better quality, larger). 20 is a good default. Ignored on the GPU path."
|
||||
},
|
||||
"plex_transcode_max_height": {
|
||||
"label": "Software max height",
|
||||
"hint": "Cap the software transcode's output height in pixels (e.g. 1080; 0 = keep source) so a tall source stays affordable to re-encode. The GPU path keeps source resolution."
|
||||
}
|
||||
},
|
||||
"save": "Save",
|
||||
|
||||
@@ -21,8 +21,21 @@
|
||||
"addUrl": "Paste a YouTube link or video id…",
|
||||
"add": "Add",
|
||||
"paste": "Paste link from clipboard",
|
||||
"upload": "Upload",
|
||||
"manage": "Manage formats"
|
||||
},
|
||||
"upload": {
|
||||
"title": "Upload a file",
|
||||
"hint": "Any file type. Video and audio can be played and edited; other files are download-only.",
|
||||
"drop": "Drag a file here, or click to choose",
|
||||
"dropActive": "Drop to upload",
|
||||
"nameLabel": "Name (optional)",
|
||||
"namePlaceholder": "Leave blank to use the file name",
|
||||
"submit": "Upload",
|
||||
"uploading": "Uploading… {{pct}}%",
|
||||
"uploadProgress": "Upload progress",
|
||||
"added": "File uploaded"
|
||||
},
|
||||
"tabs": {
|
||||
"queue": "Queue",
|
||||
"done": "Library",
|
||||
@@ -56,13 +69,18 @@
|
||||
"delete": "Remove",
|
||||
"retry": "Retry",
|
||||
"download": "Save to my device",
|
||||
"play": "Play",
|
||||
"rename": "Rename",
|
||||
"editDetails": "Edit details",
|
||||
"share": "Share",
|
||||
"share": "Share (copy link)",
|
||||
"shareManage": "Manage sharing",
|
||||
"edit": "Edit / trim",
|
||||
"redownload": "Re-download (rebuild file)",
|
||||
"removeShared": "Remove from my list"
|
||||
},
|
||||
"player": {
|
||||
"unsupported": "Your browser can't play this file."
|
||||
},
|
||||
"empty": {
|
||||
"queue": "Nothing in the queue.",
|
||||
"done": "No downloads yet.",
|
||||
@@ -104,6 +122,9 @@
|
||||
"noUsers": "No other users yet.",
|
||||
"linkSection": "Share a link",
|
||||
"linkHint": "Anyone with the link can watch — no account needed.",
|
||||
"linkCopied": "Share link copied to clipboard",
|
||||
"openNewTab": "Open in new tab",
|
||||
"linkFailed": "Couldn’t create the share link",
|
||||
"noLinks": "No links yet.",
|
||||
"newLink": "Create a link",
|
||||
"createLink": "Create link",
|
||||
@@ -151,10 +172,7 @@
|
||||
"modeA": "Audio only",
|
||||
"quality": "Max quality",
|
||||
"best": "Best",
|
||||
"container": "Container",
|
||||
"audioFormat": "Audio format",
|
||||
"vcodec": "Video codec",
|
||||
"any": "Any",
|
||||
"embedSubs": "Embed subtitles",
|
||||
"embedChapters": "Embed chapters",
|
||||
"embedThumbnail": "Embed thumbnail",
|
||||
@@ -207,6 +225,10 @@
|
||||
"source_not_ready": "The source download isn't ready to edit.",
|
||||
"generic": "That edit couldn't be created."
|
||||
},
|
||||
"upload": {
|
||||
"tooLarge": "That file is too large (max {{size}}).",
|
||||
"empty": "That file is empty."
|
||||
},
|
||||
"fail": {
|
||||
"title": "Download failed",
|
||||
"detailsLink": "details",
|
||||
|
||||
@@ -155,11 +155,15 @@
|
||||
"player": {
|
||||
"loading": "Loading…",
|
||||
"buffering": "Buffering…",
|
||||
"preparing": "Preparing stream…",
|
||||
"back": "Back",
|
||||
"close": "Close",
|
||||
"skipIntro": "Skip intro",
|
||||
"skipCredits": "Skip credits",
|
||||
"playPause": "Play / pause (space)",
|
||||
"restart": "Restart from beginning",
|
||||
"rewind10": "Back 10 seconds",
|
||||
"forward10": "Forward 10 seconds",
|
||||
"prev": "Previous episode",
|
||||
"next": "Next episode",
|
||||
"mute": "Mute (m)",
|
||||
@@ -187,7 +191,27 @@
|
||||
"help": "Show this help",
|
||||
"audio": "Cycle audio track",
|
||||
"subtitle": "Cycle subtitle",
|
||||
"fineSeek": "Fine seek"
|
||||
"fineSeek": "Fine seek",
|
||||
"stats": "Stats for nerds"
|
||||
},
|
||||
"upNext": {
|
||||
"title": "Up next",
|
||||
"playNow": "Play now",
|
||||
"cancel": "Cancel",
|
||||
"pref": "Up-next card"
|
||||
},
|
||||
"stats": {
|
||||
"title": "Stats for nerds",
|
||||
"mode": "Mode",
|
||||
"transport": "Transport",
|
||||
"resolution": "Resolution",
|
||||
"codec": "Codec",
|
||||
"bitrate": "Bitrate",
|
||||
"level": "HLS level",
|
||||
"frames": "Dropped frames",
|
||||
"buffer": "Buffer ahead",
|
||||
"offset": "Session offset",
|
||||
"position": "Position"
|
||||
},
|
||||
"settings": "Settings",
|
||||
"sync": "Offsets",
|
||||
|
||||
@@ -166,7 +166,23 @@
|
||||
},
|
||||
"plex_max_transcodes": {
|
||||
"label": "Max párhuzamos transcode",
|
||||
"hint": "A nem kompatibilis fájlok egyidejű CPU-transcode-jainak felső korlátja (későbbi tartalék). A direktben játszható fájlokra nincs korlát."
|
||||
"hint": "A nem-H.264 fájlok egyidejű transcode-munkameneteinek felső korlátja. A direktben játszható és a remux (stream-copy) fájlokra nincs korlát."
|
||||
},
|
||||
"plex_transcode_hw": {
|
||||
"label": "Hardveres transcode",
|
||||
"hint": "GPU (VAAPI) kódolási házirend: auto (GPU, ha elérhető és a forrás dekódolható, különben szoftver), on (GPU kényszerítése), off (mindig szoftver)."
|
||||
},
|
||||
"plex_transcode_preset": {
|
||||
"label": "Szoftveres preset",
|
||||
"hint": "libx264 preset a szoftveres tartalékhoz (pl. veryfast, faster, medium). A lassabb preset kisebb streamet ad, de több CPU-t eszik. A GPU-úton figyelmen kívül marad."
|
||||
},
|
||||
"plex_transcode_crf": {
|
||||
"label": "Szoftveres minőség (CRF)",
|
||||
"hint": "libx264 CRF a szoftveres tartalékhoz (0–51; alacsonyabb = jobb minőség, nagyobb méret). A 20 jó alapérték. A GPU-úton figyelmen kívül marad."
|
||||
},
|
||||
"plex_transcode_max_height": {
|
||||
"label": "Szoftveres max. magasság",
|
||||
"hint": "A szoftveres transcode kimeneti magasságának korlátja képpontban (pl. 1080; 0 = forrás megtartása), hogy egy magas forrás újrakódolása megfizethető maradjon. A GPU-út a forrásfelbontást tartja."
|
||||
}
|
||||
},
|
||||
"save": "Mentés",
|
||||
|
||||
@@ -21,8 +21,21 @@
|
||||
"addUrl": "Illessz be egy YouTube linket vagy videó azonosítót…",
|
||||
"add": "Hozzáad",
|
||||
"paste": "Link beillesztése a vágólapról",
|
||||
"upload": "Feltöltés",
|
||||
"manage": "Formátumok kezelése"
|
||||
},
|
||||
"upload": {
|
||||
"title": "Fájl feltöltése",
|
||||
"hint": "Bármilyen fájltípus. A videó és a hang lejátszható és szerkeszthető; a többi fájl csak letölthető.",
|
||||
"drop": "Húzz ide egy fájlt, vagy kattints a kiválasztáshoz",
|
||||
"dropActive": "Engedd el a feltöltéshez",
|
||||
"nameLabel": "Név (opcionális)",
|
||||
"namePlaceholder": "Hagyd üresen a fájlnév használatához",
|
||||
"submit": "Feltöltés",
|
||||
"uploading": "Feltöltés… {{pct}}%",
|
||||
"uploadProgress": "Feltöltés folyamata",
|
||||
"added": "Fájl feltöltve"
|
||||
},
|
||||
"tabs": {
|
||||
"queue": "Sor",
|
||||
"done": "Könyvtár",
|
||||
@@ -56,13 +69,18 @@
|
||||
"delete": "Eltávolítás",
|
||||
"retry": "Újra",
|
||||
"download": "Mentés a gépemre",
|
||||
"play": "Lejátszás",
|
||||
"rename": "Átnevezés",
|
||||
"editDetails": "Adatok szerkesztése",
|
||||
"share": "Megosztás",
|
||||
"share": "Megosztás (link másolása)",
|
||||
"shareManage": "Megosztás kezelése",
|
||||
"edit": "Szerkesztés / vágás",
|
||||
"redownload": "Újraletöltés (fájl újraépítése)",
|
||||
"removeShared": "Eltávolítás a listámból"
|
||||
},
|
||||
"player": {
|
||||
"unsupported": "A böngésződ nem tudja lejátszani ezt a fájlt."
|
||||
},
|
||||
"empty": {
|
||||
"queue": "A sor üres.",
|
||||
"done": "Még nincs letöltés.",
|
||||
@@ -104,6 +122,9 @@
|
||||
"noUsers": "Nincs más felhasználó.",
|
||||
"linkSection": "Megosztás linkkel",
|
||||
"linkHint": "A link birtokában bárki megnézheti — fiók nélkül is.",
|
||||
"linkCopied": "Megosztási link a vágólapra másolva",
|
||||
"openNewTab": "Megnyitás új lapon",
|
||||
"linkFailed": "Nem sikerült a megosztási link létrehozása",
|
||||
"noLinks": "Még nincs link.",
|
||||
"newLink": "Link létrehozása",
|
||||
"createLink": "Létrehozás",
|
||||
@@ -151,10 +172,7 @@
|
||||
"modeA": "Csak hang",
|
||||
"quality": "Max. minőség",
|
||||
"best": "Legjobb",
|
||||
"container": "Konténer",
|
||||
"audioFormat": "Hangformátum",
|
||||
"vcodec": "Videó codec",
|
||||
"any": "Bármelyik",
|
||||
"embedSubs": "Feliratok beágyazása",
|
||||
"embedChapters": "Fejezetek beágyazása",
|
||||
"embedThumbnail": "Bélyegkép beágyazása",
|
||||
@@ -207,6 +225,10 @@
|
||||
"source_not_ready": "A forrásletöltés még nem szerkeszthető.",
|
||||
"generic": "Ezt a vágást nem sikerült létrehozni."
|
||||
},
|
||||
"upload": {
|
||||
"tooLarge": "Ez a fájl túl nagy (legfeljebb {{size}}).",
|
||||
"empty": "Ez a fájl üres."
|
||||
},
|
||||
"fail": {
|
||||
"title": "A letöltés nem sikerült",
|
||||
"detailsLink": "részletek",
|
||||
|
||||
@@ -155,11 +155,15 @@
|
||||
"player": {
|
||||
"loading": "Betöltés…",
|
||||
"buffering": "Pufferelés…",
|
||||
"preparing": "Stream előkészítése…",
|
||||
"back": "Vissza",
|
||||
"close": "Bezárás",
|
||||
"skipIntro": "Intro átugrása",
|
||||
"skipCredits": "Stáblista átugrása",
|
||||
"playPause": "Lejátszás / szünet (szóköz)",
|
||||
"restart": "Újra az elejéről",
|
||||
"rewind10": "10 másodperc vissza",
|
||||
"forward10": "10 másodperc előre",
|
||||
"prev": "Előző rész",
|
||||
"next": "Következő rész",
|
||||
"mute": "Némítás (m)",
|
||||
@@ -187,7 +191,27 @@
|
||||
"help": "Súgó megjelenítése",
|
||||
"audio": "Hangsáv váltása",
|
||||
"subtitle": "Felirat váltása",
|
||||
"fineSeek": "Finom léptetés"
|
||||
"fineSeek": "Finom léptetés",
|
||||
"stats": "Statisztika (nerdöknek)"
|
||||
},
|
||||
"upNext": {
|
||||
"title": "Következik",
|
||||
"playNow": "Lejátszás most",
|
||||
"cancel": "Mégse",
|
||||
"pref": "Következik-kártya"
|
||||
},
|
||||
"stats": {
|
||||
"title": "Statisztika (nerdöknek)",
|
||||
"mode": "Mód",
|
||||
"transport": "Átvitel",
|
||||
"resolution": "Felbontás",
|
||||
"codec": "Kodek",
|
||||
"bitrate": "Bitráta",
|
||||
"level": "HLS szint",
|
||||
"frames": "Eldobott képkockák",
|
||||
"buffer": "Puffer előre",
|
||||
"offset": "Munkamenet-eltolás",
|
||||
"position": "Pozíció"
|
||||
},
|
||||
"settings": "Beállítások",
|
||||
"sync": "Eltolások",
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// uploadReq is the XHR upload path (progress/abort/error/401/4xx-localize) that `fetch`/`req` can't
|
||||
// cover. UploadDialog's DOM test mocks api.uploadDownload wholesale, so without this the abort, 401,
|
||||
// and structured-detail-localize branches ship untested. A controllable fake XMLHttpRequest drives
|
||||
// each lifecycle branch.
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
|
||||
const { reportError, notify, remove } = vi.hoisted(() => ({
|
||||
reportError: vi.fn(),
|
||||
notify: vi.fn(() => 1),
|
||||
remove: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../errorDialog", () => ({ reportError }));
|
||||
vi.mock("../notifications", () => ({ notify, remove }));
|
||||
vi.mock("../../i18n", () => ({ default: { t: (k: string) => k, language: "en" } }));
|
||||
vi.mock("../activeAccount", () => ({
|
||||
activeAccountId: () => null,
|
||||
ACTIVE_ACCOUNT_KEY: "k",
|
||||
notifyActiveAccountChange: vi.fn(),
|
||||
}));
|
||||
|
||||
import { uploadReq, setUnauthorizedHandler, HttpError } from "./core";
|
||||
|
||||
// A fake XHR whose lifecycle the test drives: capture the instance, set status/responseText, then
|
||||
// invoke onload/onerror/abort by hand.
|
||||
class FakeXHR {
|
||||
static last: FakeXHR;
|
||||
status = 0;
|
||||
responseText = "";
|
||||
withCredentials = false;
|
||||
headers: Record<string, string> = {};
|
||||
upload: { onprogress: ((e: ProgressEvent) => void) | null } = { onprogress: null };
|
||||
onload: (() => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
onabort: (() => void) | null = null;
|
||||
sent = false;
|
||||
constructor() {
|
||||
FakeXHR.last = this;
|
||||
}
|
||||
open() {}
|
||||
setRequestHeader(k: string, v: string) {
|
||||
this.headers[k] = v;
|
||||
}
|
||||
send() {
|
||||
this.sent = true;
|
||||
}
|
||||
abort() {
|
||||
this.onabort?.();
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
reportError.mockReset();
|
||||
notify.mockReset();
|
||||
setUnauthorizedHandler(null);
|
||||
vi.stubGlobal("XMLHttpRequest", FakeXHR as unknown as typeof XMLHttpRequest);
|
||||
});
|
||||
|
||||
const form = () => new FormData();
|
||||
|
||||
describe("uploadReq", () => {
|
||||
it("resolves with the parsed JSON on a 2xx", async () => {
|
||||
const p = uploadReq<{ id: number }>("/u", form());
|
||||
const x = FakeXHR.last;
|
||||
x.status = 200;
|
||||
x.responseText = '{"id":7}';
|
||||
x.onload!();
|
||||
await expect(p).resolves.toEqual({ id: 7 });
|
||||
});
|
||||
|
||||
it("rejects immediately if the signal is already aborted (never sends)", async () => {
|
||||
const ac = new AbortController();
|
||||
ac.abort();
|
||||
const p = uploadReq("/u", form(), { signal: ac.signal });
|
||||
await expect(p).rejects.toBeInstanceOf(HttpError);
|
||||
expect(FakeXHR.last?.sent).not.toBe(true);
|
||||
});
|
||||
|
||||
it("aborts the XHR and rejects when the signal fires", async () => {
|
||||
const ac = new AbortController();
|
||||
const p = uploadReq("/u", form(), { signal: ac.signal });
|
||||
ac.abort(); // → our abort listener calls xhr.abort() → onabort → reject
|
||||
await expect(p).rejects.toMatchObject({ status: 0 });
|
||||
});
|
||||
|
||||
it("calls the unauthorized handler on 401", async () => {
|
||||
const onUnauth = vi.fn();
|
||||
setUnauthorizedHandler(onUnauth);
|
||||
const p = uploadReq("/u", form());
|
||||
const x = FakeXHR.last;
|
||||
x.status = 401;
|
||||
x.onload!();
|
||||
await expect(p).rejects.toBeInstanceOf(HttpError);
|
||||
expect(onUnauth).toHaveBeenCalledOnce();
|
||||
expect(reportError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("localizes a structured 422 upload_too_large and surfaces the error", async () => {
|
||||
const p = uploadReq("/u", form());
|
||||
const x = FakeXHR.last;
|
||||
x.status = 422;
|
||||
x.responseText = '{"detail":{"code":"upload_too_large","limit":5}}';
|
||||
x.onload!();
|
||||
await expect(p).rejects.toMatchObject({ status: 422 });
|
||||
// localizeDetail routes upload_too_large → the tooLarge key (t is passthrough in the mock).
|
||||
expect(reportError).toHaveBeenCalledWith("downloads.errors.upload.tooLarge");
|
||||
});
|
||||
|
||||
it("reports a connectivity failure on a network error", async () => {
|
||||
const p = uploadReq("/u", form());
|
||||
FakeXHR.last.onerror!();
|
||||
await expect(p).rejects.toMatchObject({ status: 0 });
|
||||
// The soft self-clearing "connection lost" toast (via notify), not a blocking modal.
|
||||
expect(notify).toHaveBeenCalled();
|
||||
expect(reportError).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@
|
||||
// so the runtime graph is a clean barrel → domain → core with no cycles.
|
||||
import { notify, remove } from "../notifications";
|
||||
import i18n from "../../i18n";
|
||||
import { formatBytes } from "../format";
|
||||
import { reportError } from "../errorDialog";
|
||||
import { activeAccountId, ACTIVE_ACCOUNT_KEY, notifyActiveAccountChange } from "../activeAccount";
|
||||
|
||||
@@ -45,6 +46,10 @@ function localizeDetail(d: unknown): string {
|
||||
return t(`downloads.errors.edit.${o.reason}`, t("downloads.errors.edit.generic"));
|
||||
if (o?.code === "cookies")
|
||||
return t(`downloads.errors.cookies.${o.reason}`, t("downloads.errors.cookies.generic"));
|
||||
// Local-upload ingest (E7 S5): the per-file cap carries the limit in bytes; empty is a bare code.
|
||||
if (o?.code === "upload_too_large")
|
||||
return t("downloads.errors.upload.tooLarge", { size: formatBytes(o.limit) });
|
||||
if (o?.code === "upload_empty") return t("downloads.errors.upload.empty");
|
||||
return t("errors.generic");
|
||||
}
|
||||
|
||||
@@ -235,3 +240,67 @@ export async function req<T = unknown>(
|
||||
return (r.status === 204 ? null : await r.json()) as T;
|
||||
}
|
||||
}
|
||||
|
||||
// Multipart upload with progress. `req` rides `fetch`, which can't report upload progress, so a
|
||||
// file upload uses XHR instead: the caller gets a 0–100% callback as the body streams and an
|
||||
// optional AbortSignal to cancel a large upload. Everything else mirrors `req` — the per-tab
|
||||
// account header, credentials, the structured-detail localization, the global error modal for
|
||||
// 4xx/5xx, and an `HttpError` throw — so callers handle failures exactly as they do a `req` call.
|
||||
// The browser sets the multipart Content-Type (with its boundary), so we deliberately don't.
|
||||
export function uploadReq<T = unknown>(
|
||||
url: string,
|
||||
form: FormData,
|
||||
cfg: { onProgress?: (pct: number) => void; signal?: AbortSignal } = {},
|
||||
): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open("POST", url);
|
||||
xhr.withCredentials = true;
|
||||
const active = getActiveAccount();
|
||||
if (active != null) xhr.setRequestHeader(ACTIVE_ACCOUNT_HEADER, String(active));
|
||||
if (cfg.onProgress) {
|
||||
xhr.upload.onprogress = (e) => {
|
||||
if (e.lengthComputable) cfg.onProgress?.(Math.round((e.loaded / e.total) * 100));
|
||||
};
|
||||
}
|
||||
if (cfg.signal) {
|
||||
if (cfg.signal.aborted) return reject(new HttpError(0, "aborted"));
|
||||
cfg.signal.addEventListener("abort", () => xhr.abort());
|
||||
}
|
||||
xhr.onload = () => {
|
||||
markConnectivityRestored();
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
try {
|
||||
resolve((xhr.responseText ? JSON.parse(xhr.responseText) : null) as T);
|
||||
} catch {
|
||||
resolve(null as T);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let detail: string | undefined;
|
||||
let detailData: unknown;
|
||||
try {
|
||||
const body = JSON.parse(xhr.responseText);
|
||||
if (body && typeof body.detail === "string") detail = body.detail;
|
||||
else if (body && body.detail && typeof body.detail === "object") {
|
||||
detailData = body.detail;
|
||||
detail = localizeDetail(body.detail);
|
||||
}
|
||||
} catch {
|
||||
/* no JSON body */
|
||||
}
|
||||
if (xhr.status === 401) onUnauthorized?.();
|
||||
else if (xhr.status >= 500)
|
||||
reportError(detail || `${i18n.t("errors.server")} (${xhr.status})`);
|
||||
else if (xhr.status === 400 || xhr.status === 409 || xhr.status === 422) reportError(detail);
|
||||
reject(new HttpError(xhr.status, detail, detailData));
|
||||
};
|
||||
// A network-level failure (server unreachable) — same soft "connection lost" status as `req`.
|
||||
xhr.onerror = () => {
|
||||
markConnectivityLost();
|
||||
reject(new HttpError(0, i18n.t("errors.offline.body")));
|
||||
};
|
||||
xhr.onabort = () => reject(new HttpError(0, "aborted"));
|
||||
xhr.send(form);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Download-center domain: the yt-dlp job lifecycle, per-user profiles, share links/recipients, the
|
||||
// phase-2 editor, storyboards, and the admin storage/quota views. Self-contained — its types only
|
||||
// reference each other, and its methods only touch the shared `req`/`getActiveAccount` from core.
|
||||
import { req, getActiveAccount } from "./core";
|
||||
import { req, uploadReq, getActiveAccount } from "./core";
|
||||
|
||||
export interface DownloadSpec {
|
||||
mode: "av" | "v" | "a"; // audio+video | video-only | audio-only
|
||||
@@ -35,14 +35,19 @@ interface DownloadAsset {
|
||||
width: number | null;
|
||||
height: number | null;
|
||||
container: string | null;
|
||||
// "video" | "audio" | "file" — the stored discriminator that gates library row actions: media
|
||||
// (video/audio) gets play/edit; a generic uploaded file is download+share only, no player. Null
|
||||
// while still in flight (set at finalize). See backend downloads/mediakind.py.
|
||||
media_kind: "video" | "audio" | "file" | null;
|
||||
thumbnail_url: string | null;
|
||||
expires_at: string | null;
|
||||
}
|
||||
|
||||
export type DownloadStatus = "queued" | "running" | "paused" | "done" | "error" | "canceled";
|
||||
// "download" (default) or "edit" (a per-user trim/crop derivative cut from another job).
|
||||
// "download" (default), "edit" (a per-user trim/crop derivative cut from another job), or "upload"
|
||||
// (a locally-uploaded file — no re-fetchable source, so re-download is rejected backend-side).
|
||||
// Local (not exported): consumers compare `job.job_kind` against literals, never name the type.
|
||||
type DownloadJobKind = "download" | "edit";
|
||||
type DownloadJobKind = "download" | "edit" | "upload";
|
||||
|
||||
// Phase-2 editor recipe. A single `trim` (one output file) or a `segments` cut-list joined into
|
||||
// one file. `accurate` re-encodes for a frame-accurate cut (a crop always re-encodes).
|
||||
@@ -178,6 +183,21 @@ export const downloadsApi = {
|
||||
spec?: DownloadSpec;
|
||||
display_name?: string;
|
||||
}): Promise<DownloadJob> => req("/api/downloads", { method: "POST", body: JSON.stringify(body) }),
|
||||
// Ingest a local file into the library (E7 S5). Multipart via XHR so the dialog can show upload
|
||||
// progress; any file type is accepted (media gets the full function set, a generic file is
|
||||
// download-only). Returns the already-`done` job the ingest creates.
|
||||
uploadDownload: (
|
||||
file: File,
|
||||
opts: { displayName?: string; onProgress?: (pct: number) => void; signal?: AbortSignal } = {},
|
||||
): Promise<DownloadJob> => {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
if (opts.displayName) form.append("display_name", opts.displayName);
|
||||
return uploadReq<DownloadJob>("/api/downloads/upload", form, {
|
||||
onProgress: opts.onProgress,
|
||||
signal: opts.signal,
|
||||
});
|
||||
},
|
||||
enqueueEdit: (body: {
|
||||
source_job_id: number;
|
||||
edit_spec: EditSpec;
|
||||
|
||||
@@ -390,6 +390,8 @@ export interface PlexPlaySession {
|
||||
mode: "direct" | "hls";
|
||||
url: string;
|
||||
start_s: number;
|
||||
transcode?: boolean; // hls only: video re-encoded (non-h264 source) vs stream-copied (remux)
|
||||
hw?: boolean; // hls transcode only: ran on the VAAPI hardware encoder (else libx264 software)
|
||||
}
|
||||
|
||||
// Discover surface (E5 S4): the consolidated rail payload. Every rail is a plain PlexCard list the
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { contentRect, cropToSourcePx } from "./cropGeometry";
|
||||
|
||||
describe("contentRect (display-px letterbox mapping)", () => {
|
||||
it("returns the full box when box aspect matches the video aspect", () => {
|
||||
// 16:9 picture in a 16:9 box → no bars, content == box
|
||||
expect(contentRect(1600, 900, 16 / 9)).toEqual({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 1600,
|
||||
height: 900,
|
||||
});
|
||||
});
|
||||
|
||||
it("pillarboxes (bars left/right) when the box is wider than the picture", () => {
|
||||
// 16:9 picture in a 2:1 box → full height, narrower centered width
|
||||
const r = contentRect(2000, 1000, 16 / 9)!;
|
||||
expect(r.height).toBe(1000);
|
||||
expect(r.width).toBeCloseTo(1000 * (16 / 9), 3);
|
||||
expect(r.top).toBe(0);
|
||||
expect(r.left).toBeCloseTo((2000 - 1000 * (16 / 9)) / 2, 3);
|
||||
});
|
||||
|
||||
it("letterboxes (bars top/bottom) when the box is taller than the picture", () => {
|
||||
// 16:9 picture in a 1:1 box → full width, shorter centered height
|
||||
const r = contentRect(1000, 1000, 16 / 9)!;
|
||||
expect(r.width).toBe(1000);
|
||||
expect(r.height).toBeCloseTo(1000 / (16 / 9), 3);
|
||||
expect(r.left).toBe(0);
|
||||
expect(r.top).toBeCloseTo((1000 - 1000 / (16 / 9)) / 2, 3);
|
||||
});
|
||||
|
||||
it("returns null for unmeasurable inputs", () => {
|
||||
expect(contentRect(0, 900, 16 / 9)).toBeNull();
|
||||
expect(contentRect(1600, 0, 16 / 9)).toBeNull();
|
||||
expect(contentRect(1600, 900, 0)).toBeNull();
|
||||
expect(contentRect(1600, 900, NaN)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("cropToSourcePx (fraction-of-content → even source px)", () => {
|
||||
it("scales content fractions straight to source pixels", () => {
|
||||
// A marquee anchored to the content rect: a fraction is a fraction of the real picture.
|
||||
expect(cropToSourcePx({ x: 0.25, y: 0.25, w: 0.5, h: 0.5 }, 1920, 1080)).toEqual({
|
||||
x: 480,
|
||||
y: 270,
|
||||
w: 960,
|
||||
h: 540,
|
||||
});
|
||||
});
|
||||
|
||||
it("rounds to even dimensions", () => {
|
||||
// 0.1 * 1920 = 192 (even); 0.333 * 1080 = 359.64 → 360
|
||||
const c = cropToSourcePx({ x: 0.1, y: 0.333, w: 0.2, h: 0.111 }, 1920, 1080)!;
|
||||
expect(c.w % 2).toBe(0);
|
||||
expect(c.h % 2).toBe(0);
|
||||
expect(c.x % 2).toBe(0);
|
||||
expect(c.y % 2).toBe(0);
|
||||
});
|
||||
|
||||
it("allows a top-left origin (x/y may be 0)", () => {
|
||||
const c = cropToSourcePx({ x: 0, y: 0, w: 0.5, h: 0.5 }, 1920, 1080)!;
|
||||
expect(c.x).toBe(0);
|
||||
expect(c.y).toBe(0);
|
||||
});
|
||||
|
||||
it("clamps the window inside the frame so even-rounding can't overflow", () => {
|
||||
// A full-frame crop must stay exactly at the source size, not round past it.
|
||||
const c = cropToSourcePx({ x: 0, y: 0, w: 1, h: 1 }, 1921, 1081)!;
|
||||
expect(c.w).toBeLessThanOrEqual(1921);
|
||||
expect(c.h).toBeLessThanOrEqual(1081);
|
||||
expect(c.x + c.w).toBeLessThanOrEqual(1921);
|
||||
expect(c.y + c.h).toBeLessThanOrEqual(1081);
|
||||
});
|
||||
|
||||
it("keeps x+w within the frame for an off-square region near the right edge", () => {
|
||||
const srcW = 1280;
|
||||
const srcH = 720;
|
||||
const c = cropToSourcePx({ x: 0.7, y: 0.1, w: 0.3, h: 0.8 }, srcW, srcH)!;
|
||||
expect(c.x + c.w).toBeLessThanOrEqual(srcW);
|
||||
expect(c.y + c.h).toBeLessThanOrEqual(srcH);
|
||||
});
|
||||
|
||||
it("returns null for an unusable source size", () => {
|
||||
expect(cropToSourcePx({ x: 0, y: 0, w: 1, h: 1 }, 0, 1080)).toBeNull();
|
||||
expect(cropToSourcePx({ x: 0, y: 0, w: 1, h: 1 }, 1920, 0)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
// Crop geometry for the video editor — the display-px ↔ source-px mapping, isolated as pure
|
||||
// functions so the math is unit-testable without a DOM.
|
||||
//
|
||||
// The bug this fixes (E7 S1→S2): the crop marquee is drawn in fractions of the *video element box*,
|
||||
// but a letterboxed video (object-fit: contain) does NOT fill that box — pillar/letterbox bars sit
|
||||
// between the box edge and the actual picture. Multiplying box-fractions by the source resolution
|
||||
// therefore emitted an ffmpeg crop shifted/scaled off from what the selector showed. The fix is to
|
||||
// anchor the overlay to the *content* rect (below), so a fraction of the overlay is a fraction of
|
||||
// the real picture, and cropToSourcePx() is then a straight fraction→pixel scale.
|
||||
|
||||
export type Frac = { x: number; y: number; w: number; h: number };
|
||||
type Rect = { left: number; top: number; width: number; height: number };
|
||||
type CropPx = { x: number; y: number; w: number; h: number };
|
||||
|
||||
/**
|
||||
* The rendered video-content rectangle inside an element box of `boxW × boxH`, given the video's
|
||||
* intrinsic aspect ratio, when the video is laid out `object-fit: contain` (letterboxed). Returns
|
||||
* null while any input is unmeasurable (0/NaN), so callers can skip drawing until the video sizes.
|
||||
*/
|
||||
export function contentRect(boxW: number, boxH: number, videoAspect: number): Rect | null {
|
||||
if (!(boxW > 0) || !(boxH > 0) || !(videoAspect > 0)) return null;
|
||||
const boxAspect = boxW / boxH;
|
||||
if (boxAspect > videoAspect) {
|
||||
// box wider than the picture → pillarbox (bars left/right), full height
|
||||
const width = boxH * videoAspect;
|
||||
return { left: (boxW - width) / 2, top: 0, width, height: boxH };
|
||||
}
|
||||
// box taller than the picture → letterbox (bars top/bottom), full width
|
||||
const height = boxW / videoAspect;
|
||||
return { left: 0, top: (boxH - height) / 2, width: boxW, height };
|
||||
}
|
||||
|
||||
/**
|
||||
* A crop expressed as fractions of the video *content* → integer, even source-pixel crop args
|
||||
* (ffmpeg `crop=w:h:x:y`). Even dimensions keep chroma-subsampled encoders happy; the window is
|
||||
* clamped inside the frame so even-rounding can never push `x+w`/`y+h` past the source resolution
|
||||
* (which ffmpeg rejects as "crop area out of bounds"). Returns null for an unusable source size.
|
||||
*/
|
||||
export function cropToSourcePx(crop: Frac, srcW: number, srcH: number): CropPx | null {
|
||||
if (!(srcW > 0) || !(srcH > 0)) return null;
|
||||
const evenDim = (n: number) => Math.max(2, Math.round(n / 2) * 2); // w/h ≥ 2
|
||||
const evenOff = (n: number) => Math.max(0, Math.round(n / 2) * 2); // x/y ≥ 0 (top-left is valid)
|
||||
const w = Math.min(evenDim(crop.w * srcW), srcW);
|
||||
const h = Math.min(evenDim(crop.h * srcH), srcH);
|
||||
const x = Math.min(evenOff(crop.x * srcW), srcW - w);
|
||||
const y = Math.min(evenOff(crop.y * srcH), srcH - h);
|
||||
return { x, y, w, h };
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
// E7·S5b — the Library/Shared row-action gate. A generic uploaded file (media_kind='file') is
|
||||
// download+share only (no Play/Edit/Details); an edit clip and an upload can't be re-downloaded.
|
||||
// These pin the gate so a regression that shows Play/Edit on a file, or re-download on an upload,
|
||||
// is caught (the affordance itself, not just the backend guard behind it).
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { libraryRowActions, sharedRowActions } from "./downloadActions";
|
||||
import type { DownloadJob } from "./api";
|
||||
|
||||
const job = (
|
||||
over: Partial<DownloadJob>,
|
||||
asset: Partial<NonNullable<DownloadJob["asset"]>> | null,
|
||||
) =>
|
||||
({
|
||||
id: 1,
|
||||
status: "done",
|
||||
job_kind: "download",
|
||||
can_download: true,
|
||||
asset: asset && {
|
||||
id: 1,
|
||||
status: "ready",
|
||||
title: "x",
|
||||
duration_s: 90,
|
||||
media_kind: "video",
|
||||
container: "mp4",
|
||||
...asset,
|
||||
},
|
||||
...over,
|
||||
}) as unknown as DownloadJob;
|
||||
|
||||
describe("libraryRowActions", () => {
|
||||
it("a media (video) item gets play, edit, details, redownload", () => {
|
||||
expect(libraryRowActions(job({}, {}))).toEqual({
|
||||
play: true,
|
||||
edit: true,
|
||||
editDetails: true,
|
||||
redownload: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("a generic file gets NO play/edit/details (download+share only)", () => {
|
||||
const a = libraryRowActions(job({}, { media_kind: "file", duration_s: null }));
|
||||
expect(a.play).toBe(false);
|
||||
expect(a.edit).toBe(false);
|
||||
expect(a.editDetails).toBe(false);
|
||||
});
|
||||
|
||||
it("an upload cannot be re-downloaded (no re-fetchable source)", () => {
|
||||
expect(libraryRowActions(job({ job_kind: "upload" }, { media_kind: "file" })).redownload).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("an edit clip cannot be re-downloaded", () => {
|
||||
expect(libraryRowActions(job({ job_kind: "edit" }, {})).redownload).toBe(false);
|
||||
});
|
||||
|
||||
it("edit is hidden when the media has no duration", () => {
|
||||
expect(libraryRowActions(job({}, { duration_s: 0 })).edit).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sharedRowActions", () => {
|
||||
it("a downloadable media item gets play, save, edit", () => {
|
||||
expect(sharedRowActions(job({}, {}))).toEqual({ play: true, save: true, edit: true });
|
||||
});
|
||||
|
||||
it("nothing is offered when the owner disabled download", () => {
|
||||
expect(sharedRowActions(job({ can_download: false }, {}))).toEqual({
|
||||
play: false,
|
||||
save: false,
|
||||
edit: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("a shared generic file gets only save (no player/trim)", () => {
|
||||
const a = sharedRowActions(job({}, { media_kind: "file", duration_s: null }));
|
||||
expect(a).toEqual({ play: false, save: true, edit: false });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { DownloadJob } from "./api";
|
||||
|
||||
// Which row actions apply to a completed library / shared item — gated on the stored media_kind and
|
||||
// job_kind (E7·S5b). A generic uploaded file (media_kind='file') is download + share only: no player,
|
||||
// no trim, no details edit. Re-download needs a re-fetchable source, which an edit clip and an upload
|
||||
// both lack (the backend 409s both). A single source for the gate the Library + Shared rows render,
|
||||
// so it's unit-testable instead of buried in JSX conditionals — and the two tabs can't drift apart.
|
||||
|
||||
const isMediaKind = (job: DownloadJob): boolean => job.asset?.media_kind !== "file";
|
||||
const hasDuration = (job: DownloadJob): boolean => (job.asset?.duration_s ?? 0) > 0;
|
||||
|
||||
export interface LibraryRowActions {
|
||||
play: boolean;
|
||||
edit: boolean;
|
||||
editDetails: boolean;
|
||||
redownload: boolean;
|
||||
}
|
||||
|
||||
export function libraryRowActions(job: DownloadJob): LibraryRowActions {
|
||||
const media = isMediaKind(job);
|
||||
return {
|
||||
play: media,
|
||||
edit: media && job.can_download && hasDuration(job),
|
||||
editDetails: media,
|
||||
redownload: job.job_kind !== "edit" && job.job_kind !== "upload",
|
||||
};
|
||||
}
|
||||
|
||||
export interface SharedRowActions {
|
||||
play: boolean;
|
||||
save: boolean;
|
||||
edit: boolean;
|
||||
}
|
||||
|
||||
export function sharedRowActions(job: DownloadJob): SharedRowActions {
|
||||
const media = isMediaKind(job);
|
||||
// The owner's `can_download` gates save/play/edit on a shared item (it reflects their link ACL).
|
||||
return {
|
||||
play: media && job.can_download,
|
||||
save: job.can_download,
|
||||
edit: media && job.can_download && hasDuration(job),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// One-click share (E7 S3 · D2-A): quickShareLink turns the row's Share button into a single action —
|
||||
// ensure a public link (mint on demand, downloadable per D1), copy it, and toast with an "open in new
|
||||
// tab" action. These pin the branch matrix: reuse-vs-mint, copy-ok-vs-fail, and the create-failure path.
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
|
||||
// vi.mock is hoisted above these consts, so the mock fns must live in a hoisted block too.
|
||||
const { downloadLinks, createDownloadLink, copyText, notify } = vi.hoisted(() => ({
|
||||
downloadLinks: vi.fn(),
|
||||
createDownloadLink: vi.fn(),
|
||||
copyText: vi.fn(),
|
||||
notify: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./api", () => ({ api: { downloadLinks, createDownloadLink } }));
|
||||
vi.mock("./clipboard", () => ({ copyText }));
|
||||
vi.mock("./notifications", () => ({ notify }));
|
||||
|
||||
import { quickShareLink } from "./downloads";
|
||||
|
||||
const t = (k: string) => k;
|
||||
const origin = window.location.origin;
|
||||
|
||||
type ToastArg = {
|
||||
level: string;
|
||||
message: string;
|
||||
action?: { label: string; onClick: () => void };
|
||||
};
|
||||
const firstToast = () => notify.mock.calls[0]![0] as ToastArg;
|
||||
|
||||
function qc() {
|
||||
return new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
downloadLinks.mockReset();
|
||||
createDownloadLink.mockReset();
|
||||
copyText.mockReset().mockResolvedValue(true);
|
||||
notify.mockReset();
|
||||
});
|
||||
|
||||
describe("quickShareLink", () => {
|
||||
it("mints a downloadable link on demand, copies it, and toasts with an open-in-new-tab action", async () => {
|
||||
downloadLinks.mockResolvedValue([]); // none yet
|
||||
createDownloadLink.mockResolvedValue({ id: 7, url: "/watch/tok7" });
|
||||
const openSpy = vi.spyOn(window, "open").mockReturnValue(null);
|
||||
|
||||
const url = await quickShareLink(qc(), { id: 42 }, t);
|
||||
|
||||
expect(url).toBe(`${origin}/watch/tok7`);
|
||||
// Minted with an EMPTY body so the backend D1 default (allow_download=true) applies.
|
||||
expect(createDownloadLink).toHaveBeenCalledWith(42, {});
|
||||
expect(copyText).toHaveBeenCalledWith(`${origin}/watch/tok7`);
|
||||
const arg = firstToast();
|
||||
expect(arg.level).toBe("success");
|
||||
expect(arg.message).toBe("downloads.share.linkCopied");
|
||||
expect(arg.action!.label).toBe("downloads.share.openNewTab");
|
||||
arg.action!.onClick();
|
||||
expect(openSpy).toHaveBeenCalledWith(`${origin}/watch/tok7`, "_blank", "noopener,noreferrer");
|
||||
});
|
||||
|
||||
it("reuses an existing link instead of minting a duplicate", async () => {
|
||||
downloadLinks.mockResolvedValue([{ id: 3, url: "/watch/old" }]);
|
||||
|
||||
const url = await quickShareLink(qc(), { id: 1 }, t);
|
||||
|
||||
expect(url).toBe(`${origin}/watch/old`);
|
||||
expect(createDownloadLink).not.toHaveBeenCalled();
|
||||
expect(firstToast().level).toBe("success");
|
||||
});
|
||||
|
||||
it("still toasts (warning) with the open action when the clipboard copy fails", async () => {
|
||||
downloadLinks.mockResolvedValue([{ id: 5, url: "/watch/x" }]);
|
||||
copyText.mockResolvedValue(false);
|
||||
|
||||
const url = await quickShareLink(qc(), { id: 2 }, t);
|
||||
|
||||
expect(url).toBe(`${origin}/watch/x`); // the link is never a dead end
|
||||
const arg = firstToast();
|
||||
expect(arg.level).toBe("warning");
|
||||
expect(arg.message).toBe("downloads.share.copyFailed");
|
||||
expect(arg.action!.label).toBe("downloads.share.openNewTab");
|
||||
});
|
||||
|
||||
it("reports an error and returns null when link creation fails", async () => {
|
||||
downloadLinks.mockResolvedValue([]);
|
||||
createDownloadLink.mockRejectedValue(new Error("boom"));
|
||||
|
||||
const url = await quickShareLink(qc(), { id: 9 }, t);
|
||||
|
||||
expect(url).toBeNull();
|
||||
expect(copyText).not.toHaveBeenCalled();
|
||||
expect(notify).toHaveBeenCalledWith({ level: "error", message: "downloads.share.linkFailed" });
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import { qk } from "./queryKeys";
|
||||
import { api } from "./api";
|
||||
import { copyText } from "./clipboard";
|
||||
import { notify } from "./notifications";
|
||||
|
||||
// The download queue, the per-card download index, and the storage-usage meter all move together
|
||||
// whenever a job is enqueued, deleted, or its state changes — invalidate them as one unit.
|
||||
@@ -8,3 +11,42 @@ export function invalidateDownloads(qc: QueryClient) {
|
||||
qc.invalidateQueries({ queryKey: qk.downloadIndex() });
|
||||
qc.invalidateQueries({ queryKey: qk.downloadUsage() });
|
||||
}
|
||||
|
||||
// One-click share (E7 S3 · D2-A): the row's Share button turns "share this" into a single action.
|
||||
// Ensure the job has a public link (minting one on demand — omitting `allow_download` so the backend
|
||||
// D1 default makes it downloadable), copy it to the clipboard, and toast with an "open in new tab"
|
||||
// action. A copy failure still toasts (warning) WITH that action, so the link is never a dead end.
|
||||
// Advanced controls (password, expiry, revoke, share-with-a-user) live in the Share dialog. Returns
|
||||
// the shared URL, or null if link creation failed — so the caller/tests can react to the outcome.
|
||||
export async function quickShareLink(
|
||||
qc: QueryClient,
|
||||
job: { id: number },
|
||||
t: (key: string) => string,
|
||||
): Promise<string | null> {
|
||||
let link;
|
||||
try {
|
||||
const existing = await qc.fetchQuery({
|
||||
queryKey: qk.downloadLinks(job.id),
|
||||
queryFn: () => api.downloadLinks(job.id),
|
||||
});
|
||||
link = existing[0];
|
||||
if (!link) {
|
||||
link = await api.createDownloadLink(job.id, {}); // omit allow_download → backend D1 default (true)
|
||||
qc.setQueryData(qk.downloadLinks(job.id), [link]);
|
||||
}
|
||||
} catch {
|
||||
notify({ level: "error", message: t("downloads.share.linkFailed") });
|
||||
return null;
|
||||
}
|
||||
const url = window.location.origin + link.url;
|
||||
const action = {
|
||||
label: t("downloads.share.openNewTab"),
|
||||
onClick: () => window.open(url, "_blank", "noopener,noreferrer"),
|
||||
};
|
||||
notify(
|
||||
(await copyText(url))
|
||||
? { level: "success", message: t("downloads.share.linkCopied"), action }
|
||||
: { level: "warning", message: t("downloads.share.copyFailed"), action },
|
||||
);
|
||||
return url;
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ export type PlexPlayerPrefs = {
|
||||
autoSkipIntro: boolean; // auto-skip the intro marker after a countdown
|
||||
autoSkipCredits: boolean; // auto-skip credits → jump to the next item (binge)
|
||||
autoSkipDelay: number; // countdown before an auto-skip fires (s, 0 = immediate)
|
||||
upNext: boolean; // at episode end (with a next item) show an up-next card + countdown before advancing
|
||||
};
|
||||
export const DEFAULT_PREFS: PlexPlayerPrefs = {
|
||||
volume: 1,
|
||||
@@ -71,6 +72,7 @@ export const DEFAULT_PREFS: PlexPlayerPrefs = {
|
||||
autoSkipIntro: false,
|
||||
autoSkipCredits: false,
|
||||
autoSkipDelay: 5,
|
||||
upNext: true,
|
||||
};
|
||||
export const clamp01 = (n: number) => Math.max(0, Math.min(1, n));
|
||||
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildStatRows,
|
||||
fmtBitrate,
|
||||
fmtBufferSecs,
|
||||
fmtClock,
|
||||
fmtCodec,
|
||||
fmtFrames,
|
||||
fmtLevel,
|
||||
fmtMode,
|
||||
fmtRes,
|
||||
type PlexStatsInput,
|
||||
} from "./plexStats";
|
||||
|
||||
const DASH = "—";
|
||||
|
||||
describe("plexStats formatters", () => {
|
||||
it("fmtBitrate scales kbps/Mbps and dashes zero/undefined", () => {
|
||||
expect(fmtBitrate(undefined)).toBe(DASH);
|
||||
expect(fmtBitrate(0)).toBe(DASH);
|
||||
expect(fmtBitrate(820_000)).toBe("820 kbps");
|
||||
expect(fmtBitrate(4_200_000)).toBe("4.2 Mbps");
|
||||
expect(fmtBitrate(1_000_000)).toBe("1.0 Mbps");
|
||||
});
|
||||
|
||||
it("fmtBufferSecs shows one decimal, dashes negatives/non-finite", () => {
|
||||
expect(fmtBufferSecs(12.34)).toBe("12.3 s");
|
||||
expect(fmtBufferSecs(0)).toBe("0.0 s");
|
||||
expect(fmtBufferSecs(-1)).toBe(DASH);
|
||||
expect(fmtBufferSecs(Infinity)).toBe(DASH);
|
||||
});
|
||||
|
||||
it("fmtFrames shows ratio + drop %, dashes when nothing decoded", () => {
|
||||
expect(fmtFrames(0, 0)).toBe(DASH);
|
||||
expect(fmtFrames(0, 1000)).toBe("0 / 1000 (0.00%)");
|
||||
expect(fmtFrames(5, 1000)).toBe("5 / 1000 (0.50%)");
|
||||
expect(fmtFrames(30, 1000)).toBe("30 / 1000 (3.0%)");
|
||||
});
|
||||
|
||||
it("fmtRes dashes an undecoded 0×0 frame", () => {
|
||||
expect(fmtRes(1920, 1080)).toBe("1920×1080");
|
||||
expect(fmtRes(0, 0)).toBe(DASH);
|
||||
expect(fmtRes(1920, 0)).toBe(DASH);
|
||||
});
|
||||
|
||||
it("fmtClock renders m:ss and dashes bad input", () => {
|
||||
expect(fmtClock(0)).toBe("0:00");
|
||||
expect(fmtClock(9)).toBe("0:09");
|
||||
expect(fmtClock(75)).toBe("1:15");
|
||||
expect(fmtClock(3661)).toBe("61:01");
|
||||
expect(fmtClock(-1)).toBe(DASH);
|
||||
});
|
||||
|
||||
it("fmtLevel is 1-based with resolution, handles auto and empty", () => {
|
||||
expect(fmtLevel(null)).toBe(DASH);
|
||||
expect(fmtLevel({ index: 1, total: 4, width: 1280, height: 720 })).toBe("2 / 4 · 1280×720");
|
||||
expect(fmtLevel({ index: -1, total: 3 })).toBe("auto / 3");
|
||||
expect(fmtLevel({ index: 0, total: 0 })).toBe(DASH);
|
||||
});
|
||||
|
||||
it("fmtCodec strips profile suffixes and dashes when absent", () => {
|
||||
expect(fmtCodec("avc1.640028", "mp4a.40.2")).toBe("avc1 / mp4a");
|
||||
expect(fmtCodec("hvc1.1.6.L120", undefined)).toBe("hvc1");
|
||||
expect(fmtCodec(undefined, undefined)).toBe(DASH);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildStatRows", () => {
|
||||
const base: PlexStatsInput = {
|
||||
playable: "remux",
|
||||
transport: "hls",
|
||||
videoWidth: 1280,
|
||||
videoHeight: 720,
|
||||
droppedFrames: 2,
|
||||
totalFrames: 500,
|
||||
bufferAheadS: 8.2,
|
||||
hlsLevel: { index: 0, total: 1, width: 1280, height: 720, bitrate: 3_000_000 },
|
||||
sessionStartS: 65,
|
||||
abs: 130,
|
||||
duration: 5214,
|
||||
};
|
||||
|
||||
it("includes the HLS-only rows (codec/bitrate/level) for hls transport", () => {
|
||||
const keys = buildStatRows(base).map((r) => r.key);
|
||||
expect(keys).toEqual([
|
||||
"mode",
|
||||
"transport",
|
||||
"res",
|
||||
"codec",
|
||||
"bitrate",
|
||||
"level",
|
||||
"frames",
|
||||
"buffer",
|
||||
"offset",
|
||||
"position",
|
||||
]);
|
||||
});
|
||||
|
||||
it("drops the HLS-only rows for direct play", () => {
|
||||
const keys = buildStatRows({ ...base, transport: "direct", hlsLevel: null }).map((r) => r.key);
|
||||
expect(keys).not.toContain("codec");
|
||||
expect(keys).not.toContain("bitrate");
|
||||
expect(keys).not.toContain("level");
|
||||
expect(keys).toContain("res");
|
||||
});
|
||||
|
||||
it("surfaces the classification as the mode value and formats position", () => {
|
||||
const rows = buildStatRows({ ...base, playable: "transcode" });
|
||||
expect(rows.find((r) => r.key === "mode")?.value).toBe("transcode");
|
||||
expect(rows.find((r) => r.key === "offset")?.value).toBe("1:05");
|
||||
expect(rows.find((r) => r.key === "position")?.value).toBe("2:10 / 86:54");
|
||||
});
|
||||
|
||||
it("appends the transcode encoder to the mode row when known", () => {
|
||||
const gpu = buildStatRows({ ...base, playable: "transcode", hwEncoder: true });
|
||||
expect(gpu.find((r) => r.key === "mode")?.value).toBe("transcode · GPU");
|
||||
const cpu = buildStatRows({ ...base, playable: "transcode", hwEncoder: false });
|
||||
expect(cpu.find((r) => r.key === "mode")?.value).toBe("transcode · CPU");
|
||||
});
|
||||
|
||||
it("shows an empty classification honestly as a dash", () => {
|
||||
expect(buildStatRows({ ...base, playable: "" }).find((r) => r.key === "mode")?.value).toBe(
|
||||
DASH,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fmtMode", () => {
|
||||
it("appends the encoder only for a transcode with a known encoder", () => {
|
||||
expect(fmtMode("transcode", true)).toBe("transcode · GPU");
|
||||
expect(fmtMode("transcode", false)).toBe("transcode · CPU");
|
||||
expect(fmtMode("transcode", null)).toBe("transcode"); // encoder unknown → bare mode
|
||||
expect(fmtMode("transcode")).toBe("transcode");
|
||||
expect(fmtMode("remux", true)).toBe("remux"); // not a transcode → no encoder suffix
|
||||
expect(fmtMode("")).toBe(DASH);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
// Pure formatters + row builder for the Plex player "stats for nerds" overlay (E6 S2). Kept free of
|
||||
// React/DOM so the number-crunching is unit-testable: the component gathers raw values off videoRef /
|
||||
// hlsRef and hands them here; this returns i18n-keyed rows the overlay renders. No data pipeline — the
|
||||
// HTML5 <video> + hls.js already expose everything (intrinsic dims, getVideoPlaybackQuality, levels).
|
||||
|
||||
export interface PlexStatsInput {
|
||||
// The item's codec classification (backend `classify_playable`): the "active mode" a viewer cares
|
||||
// about — direct copy, remux (stream-copy → HLS), or transcode (re-encode). Empty until detail loads.
|
||||
playable: string; // "direct" | "remux" | "transcode" | ""
|
||||
transport: "direct" | "hls"; // session transport (raw file vs HLS) — orthogonal to `playable`
|
||||
// For a transcode session, which encoder actually ran: true = VAAPI hardware, false = libx264
|
||||
// software, null/undefined = not a transcode (or unknown) — so the mode row can read "transcode · GPU"
|
||||
// vs "transcode · CPU", the visible confirmation of which S1 path engaged.
|
||||
hwEncoder?: boolean | null;
|
||||
videoWidth: number; // intrinsic decoded dims (0 while the browser can't decode / pre-metadata)
|
||||
videoHeight: number;
|
||||
droppedFrames: number; // getVideoPlaybackQuality().droppedVideoFrames
|
||||
totalFrames: number; // getVideoPlaybackQuality().totalVideoFrames
|
||||
bufferAheadS: number; // seconds buffered ahead of the playhead
|
||||
hlsLevel: HlsLevelInfo | null; // null for direct play / Safari-native HLS
|
||||
sessionStartS: number; // media-clock offset the current session was opened at
|
||||
abs: number; // current media time (absolute, session offset already folded in)
|
||||
duration: number;
|
||||
}
|
||||
|
||||
export interface HlsLevelInfo {
|
||||
index: number; // active level index (-1 = auto/undetermined)
|
||||
total: number; // number of renditions
|
||||
width?: number;
|
||||
height?: number;
|
||||
bitrate?: number; // bits per second
|
||||
videoCodec?: string;
|
||||
audioCodec?: string;
|
||||
}
|
||||
|
||||
export interface StatRow {
|
||||
key: string; // stable react key
|
||||
labelKey: string; // i18n key under plex.player.stats.*
|
||||
value: string;
|
||||
}
|
||||
|
||||
const DASH = "—";
|
||||
|
||||
// bits-per-second → "4.2 Mbps" / "820 kbps". Undefined/0 → dash (hls.js reports 0 before a level loads).
|
||||
export function fmtBitrate(bps: number | undefined): string {
|
||||
if (!bps || bps <= 0) return DASH;
|
||||
if (bps >= 1_000_000) return `${(bps / 1_000_000).toFixed(1)} Mbps`;
|
||||
return `${Math.round(bps / 1000)} kbps`;
|
||||
}
|
||||
|
||||
// Seconds of buffer ahead of the playhead → "12.3 s".
|
||||
export function fmtBufferSecs(s: number): string {
|
||||
if (!Number.isFinite(s) || s < 0) return DASH;
|
||||
return `${s.toFixed(1)} s`;
|
||||
}
|
||||
|
||||
// Dropped / total decoded frames, with a drop percentage once any frames have been decoded.
|
||||
export function fmtFrames(dropped: number, total: number): string {
|
||||
if (total <= 0) return DASH;
|
||||
const pct = (dropped / total) * 100;
|
||||
return `${dropped} / ${total} (${pct.toFixed(pct < 1 ? 2 : 1)}%)`;
|
||||
}
|
||||
|
||||
// Intrinsic decoded resolution. 0×0 means the browser hasn't decoded (or can't) — an honest dash,
|
||||
// which is itself the tell for a not-yet-playable codec pre-transcode.
|
||||
export function fmtRes(w: number, h: number): string {
|
||||
if (w <= 0 || h <= 0) return DASH;
|
||||
return `${w}×${h}`;
|
||||
}
|
||||
|
||||
// Seconds → m:ss (offsets/positions are small; hours are folded into minutes deliberately).
|
||||
export function fmtClock(s: number): string {
|
||||
if (!Number.isFinite(s) || s < 0) return DASH;
|
||||
const total = Math.floor(s);
|
||||
const m = Math.floor(total / 60);
|
||||
const sec = total % 60;
|
||||
return `${m}:${sec.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
// hls.js active level → "2 / 4 · 1280×720" (1-based index for humans). Auto/unknown level → dash + count.
|
||||
export function fmtLevel(l: HlsLevelInfo | null): string {
|
||||
if (!l || l.total <= 0) return DASH;
|
||||
const idx = l.index >= 0 ? `${l.index + 1} / ${l.total}` : `auto / ${l.total}`;
|
||||
const res = l.width && l.height ? ` · ${l.width}×${l.height}` : "";
|
||||
return `${idx}${res}`;
|
||||
}
|
||||
|
||||
// Video/audio codec from the active hls.js level (only HLS exposes them; direct play → dash).
|
||||
export function fmtCodec(video: string | undefined, audio: string | undefined): string {
|
||||
const v = video?.split(".")[0] || null; // "avc1.640028" → "avc1"
|
||||
const a = audio?.split(".")[0] || null;
|
||||
if (!v && !a) return DASH;
|
||||
return [v, a].filter(Boolean).join(" / ");
|
||||
}
|
||||
|
||||
// Active mode, with the transcode encoder appended when known: "transcode · GPU" (VAAPI hardware) or
|
||||
// "transcode · CPU" (libx264 software) — the at-a-glance confirmation of which S1 path engaged. Non-
|
||||
// transcode modes (direct/remux) and an unknown encoder read as the bare mode. Raw tokens (not i18n'd),
|
||||
// consistent with the other technical values in this overlay (e.g. transport "hls").
|
||||
export function fmtMode(playable: string, hwEncoder?: boolean | null): string {
|
||||
const mode = playable || DASH;
|
||||
if (playable !== "transcode" || hwEncoder == null) return mode;
|
||||
return `${mode} · ${hwEncoder ? "GPU" : "CPU"}`;
|
||||
}
|
||||
|
||||
// Build the ordered, i18n-keyed rows the overlay renders. Rows whose data is absent still show a dash
|
||||
// (honest "not available" beats a missing row) — except HLS-only rows, dropped entirely for direct play.
|
||||
export function buildStatRows(s: PlexStatsInput): StatRow[] {
|
||||
const rows: StatRow[] = [
|
||||
{ key: "mode", labelKey: "plex.player.stats.mode", value: fmtMode(s.playable, s.hwEncoder) },
|
||||
{ key: "transport", labelKey: "plex.player.stats.transport", value: s.transport },
|
||||
{
|
||||
key: "res",
|
||||
labelKey: "plex.player.stats.resolution",
|
||||
value: fmtRes(s.videoWidth, s.videoHeight),
|
||||
},
|
||||
];
|
||||
if (s.transport === "hls") {
|
||||
rows.push(
|
||||
{
|
||||
key: "codec",
|
||||
labelKey: "plex.player.stats.codec",
|
||||
value: fmtCodec(s.hlsLevel?.videoCodec, s.hlsLevel?.audioCodec),
|
||||
},
|
||||
{
|
||||
key: "bitrate",
|
||||
labelKey: "plex.player.stats.bitrate",
|
||||
value: fmtBitrate(s.hlsLevel?.bitrate),
|
||||
},
|
||||
{ key: "level", labelKey: "plex.player.stats.level", value: fmtLevel(s.hlsLevel) },
|
||||
);
|
||||
}
|
||||
rows.push(
|
||||
{
|
||||
key: "frames",
|
||||
labelKey: "plex.player.stats.frames",
|
||||
value: fmtFrames(s.droppedFrames, s.totalFrames),
|
||||
},
|
||||
{ key: "buffer", labelKey: "plex.player.stats.buffer", value: fmtBufferSecs(s.bufferAheadS) },
|
||||
{ key: "offset", labelKey: "plex.player.stats.offset", value: fmtClock(s.sessionStartS) },
|
||||
{
|
||||
key: "position",
|
||||
labelKey: "plex.player.stats.position",
|
||||
value: `${fmtClock(s.abs)} / ${fmtClock(s.duration)}`,
|
||||
},
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
@@ -14,6 +14,44 @@ export interface ReleaseEntry {
|
||||
}
|
||||
|
||||
export const RELEASE_NOTES: ReleaseEntry[] = [
|
||||
{
|
||||
version: "0.78.0",
|
||||
date: "2026-08-30",
|
||||
summary:
|
||||
"The Plex player now plays any format. Videos in codecs your browser can't open natively — HEVC/H.265, VP9, AV1, MPEG-2 and older formats — are transcoded on the fly, hardware-accelerated on a supported server GPU with an automatic software fallback. Plus three player touches: an up-next card at episode end, on-screen 10-second skip buttons, and a stats overlay.",
|
||||
features: [
|
||||
"Play any Plex video in the browser, even formats it can't open natively (HEVC/H.265, VP9, AV1, MPEG-2, older codecs). Siftlode re-encodes the video on the fly to something every browser plays, with seeking, subtitles, audio-track switching and audio offset all still working. It uses a supported server GPU when available (barely touching the CPU) and falls back to software automatically, so playback just works.",
|
||||
"At the end of an episode, an up-next card shows the next one with a ~10-second countdown — let it advance, hit “Play now”, or Cancel to stay on the finished frame. Binge on your terms instead of an instant jump (on by default; togglable).",
|
||||
"On-screen 10-second rewind and forward buttons now flank Play/Pause, matching the ← / → keys — a discoverable control for touch and mouse, distinct from the previous/next-episode buttons.",
|
||||
"A “stats for nerds” overlay (press D) shows the live playback mode (direct / remux / transcode), resolution, dropped frames, buffer and more — handy for seeing exactly how a video is being served.",
|
||||
],
|
||||
fixes: [
|
||||
"While a transcoded stream warms up, the player shows an honest “Preparing stream…” instead of a plain spinner, so a slightly longer start doesn't read as a hang.",
|
||||
"The browser (and mouse) Back button now closes the stats or up-next overlay first, instead of jumping straight out of the player.",
|
||||
],
|
||||
chores: [
|
||||
"The transcode path uses VAAPI hardware encoding on a supported Intel GPU, with a de-prioritised, concurrency-capped software fallback so it never starves the rest of the app. The whole epic went through a multi-agent deep-review — findings fixed and re-reviewed each round until a round came back clean.",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.76.0",
|
||||
date: "2026-08-29",
|
||||
summary:
|
||||
"The Download Center becomes a real media library: play your downloaded and uploaded video and audio right in the app, upload any file into the same library, and share anything in one click. Plus a fix so a cropped clip is framed exactly as you selected.",
|
||||
features: [
|
||||
"Play a finished download or upload in the app — video and audio open in a proper in-app player with seek, volume and fullscreen, no separate download needed.",
|
||||
"Upload your own files into the library. Video and audio join with the full function set (play, edit, share); any other file (PDF, zip, document…) joins as a download-only item with its own info card. Drag-and-drop or pick a file, with a live progress bar you can cancel.",
|
||||
"One-click Share: the Share button now creates a link (if there isn't one), copies it, and offers “open in new tab” — all in a single click, downloadable by default. The full link controls (password, expiry, revoke, share-with-a-user) stay one click away.",
|
||||
"Library rows now show the file format and size at a glance (e.g. “MP4 · 142 MB”), and a shared link to a plain file shows a clean info card with a Download button.",
|
||||
],
|
||||
fixes: [
|
||||
"Cropping a clip in the editor now produces output framed exactly as the on-screen selection showed — the crop rectangle and the saved result no longer drift apart on a non-16:9 video.",
|
||||
"Every download is now guaranteed to play in any browser (including iPhone/iPad): the output is a universally-playable MP4/H.264 (or M4A/MP3 for audio), so “Play” always works.",
|
||||
],
|
||||
chores: [
|
||||
"The whole epic went through several multi-agent deep-review rounds — findings fixed and re-reviewed each round until a round came back clean. Included a security hardening pass on the new upload path (an uploaded file can never be served in a way that lets it run code in your browser) and a fix so a failed upload never leaves an orphaned file on disk.",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.75.0",
|
||||
date: "2026-08-29",
|
||||
|
||||
Reference in New Issue
Block a user