Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
66132b6dda | ||
|
|
d09f021f0c | ||
|
|
d7e9240e54 | ||
|
|
f2fa49b778 | ||
|
|
d4ed45b2eb | ||
|
|
375f6e58be | ||
|
|
1a102ce81a | ||
|
|
bfd45a155e | ||
|
|
1102ad6a28 | ||
|
|
c1a3b7b935 | ||
|
|
bf98b48517 | ||
|
|
b563155a37 | ||
|
|
915319fa35 | ||
|
|
a2a8f5681d | ||
|
|
4827aebb7f | ||
|
|
840526b1b8 | ||
|
|
10d69cb316 | ||
|
|
a86131e062 | ||
|
|
a84f2db310 | ||
|
|
872d91f540 | ||
|
|
4eaa68ceeb | ||
|
|
00539a4d97 | ||
|
|
dc24d353e0 | ||
|
|
ce96032edb | ||
|
|
0658f9550b | ||
|
|
f162788135 | ||
|
|
968674bc6a | ||
|
|
90ae9f6e88 | ||
|
|
3682e6a01f | ||
|
|
aca355f43b | ||
|
|
f41c81eb56 | ||
|
|
614c5528ad | ||
|
|
104bd1e3a6 | ||
|
|
584e9ced0c | ||
|
|
b4ad59274b | ||
|
|
fe0b34cd00 | ||
|
|
9eaa01617c | ||
|
|
fdca12968b | ||
|
|
94fcc40bde | ||
|
|
fb2c843fa9 | ||
|
|
bb6cb6e335 | ||
|
|
bdbff89507 | ||
|
|
9b87838e3a | ||
|
|
f014416e31 | ||
|
|
3369587412 | ||
|
|
9af4e8f4ff | ||
|
|
a0b725d779 | ||
|
|
bc33b2ed16 | ||
|
|
5280949667 | ||
|
|
4892c38466 | ||
|
|
9ae6c47d6c | ||
|
|
b7a6451dba | ||
|
|
11e32163b8 | ||
|
|
257dc5741d |
+20
-4
@@ -45,18 +45,34 @@ RUN adduser --disabled-password --gecos "" appuser \
|
|||||||
&& mkdir -p /downloads \
|
&& mkdir -p /downloads \
|
||||||
&& chown appuser /app /downloads
|
&& chown appuser /app /downloads
|
||||||
|
|
||||||
# ffmpeg: merge separate video+audio streams + postprocessing. Deno: the JavaScript runtime
|
# ffmpeg: merge separate video+audio streams + postprocessing, and the Plex VAAPI transcode (E6 S1).
|
||||||
# yt-dlp uses to solve YouTube's "n" signature challenge for the web player clients (required
|
# intel-media-va-driver (iHD): the VA driver ffmpeg's h264_vaapi needs — libva2 ships in the base but
|
||||||
# alongside the PO token, else only storyboards are offered). yt-dlp auto-detects deno on PATH.
|
# 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
|
ARG DENO_VERSION=v2.9.1
|
||||||
RUN apt-get update \
|
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 \
|
&& 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 \
|
&& unzip -o /tmp/deno.zip -d /usr/local/bin \
|
||||||
&& chmod +x /usr/local/bin/deno \
|
&& chmod +x /usr/local/bin/deno \
|
||||||
&& rm /tmp/deno.zip \
|
&& rm /tmp/deno.zip \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Intel NEO OpenCL runtime (intel-opencl-icd) — needed by the Plex transcode's HDR/10-bit HW path, which
|
||||||
|
# tone-maps HDR→SDR on the GPU via `tonemap_opencl` (Gen 9.5 / UHD 630 can't tonemap in the VAAPI VPP, so
|
||||||
|
# OpenCL is the only HW route; without this the 10-bit branch drops to CPU libx264). NEO was REMOVED from
|
||||||
|
# Debian trixie (this base = python:3.12-slim = trixie), so it's pulled from **bookworm** — the 22.43 there
|
||||||
|
# still supports Gen 9.5 and its .deb runs on trixie (verified live on the prod UHD 630). The bookworm apt
|
||||||
|
# source is added only for this one install and removed after, so it can't drift the rest of the image.
|
||||||
|
# Harmless on hosts with no /dev/dri or no OpenCL use (the runtime just sits unused).
|
||||||
|
RUN printf 'Types: deb\nURIs: http://deb.debian.org/debian\nSuites: bookworm\nComponents: main\nSigned-By: /usr/share/keyrings/debian-archive-keyring.pgp\n' > /etc/apt/sources.list.d/bookworm.sources \
|
||||||
|
&& apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends -t bookworm intel-opencl-icd \
|
||||||
|
&& rm /etc/apt/sources.list.d/bookworm.sources \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Install from the PINNED lock (requirements.lock, generated from requirements.txt) so the image is
|
# Install from the PINNED lock (requirements.lock, generated from requirements.txt) so the image is
|
||||||
# byte-reproducible and the exact versions are the ones pip-audit scanned in CI — not whatever floats
|
# byte-reproducible and the exact versions are the ones pip-audit scanned in CI — not whatever floats
|
||||||
# up from the floor ranges on build day. Regenerate the lock when requirements.txt changes (see its header).
|
# up from the floor ranges on build day. Regenerate the lock when requirements.txt changes (see its header).
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""download_credentials (S6: per-user, per-host yt-dlp cookie/auth store)
|
||||||
|
|
||||||
|
Each user stores their OWN login cookies (Netscape cookies.txt) for an auth-walled download host
|
||||||
|
(Facebook, Instagram, …), Fernet-encrypted at rest. A download of a yt-dlp-supported, auth-walled
|
||||||
|
link on that host is retried with the acting user's cookie. Unique per (user_id, host_key) so a
|
||||||
|
user holds at most one credential per host. Empty for every existing install; forward-only in
|
||||||
|
practice (a downgrade just drops the table + its secrets).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0074_download_credentials"
|
||||||
|
down_revision: Union[str, None] = "0073_saved_view_source"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"download_credentials",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column(
|
||||||
|
"user_id",
|
||||||
|
sa.Integer(),
|
||||||
|
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column("host_key", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("cookies_enc", sa.Text(), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||||
|
),
|
||||||
|
sa.UniqueConstraint("user_id", "host_key", name="uq_download_credential"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_download_credentials_user_id", "download_credentials", ["user_id"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_download_credentials_user_id", table_name="download_credentials")
|
||||||
|
op.drop_table("download_credentials")
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"""playlist synced_video_ids (S7: 3-way merge baseline for two-way YouTube sync)
|
||||||
|
|
||||||
|
The one-directional "local wins" push is replaced by a 3-way merge that classifies each item
|
||||||
|
against the last-synced state. That needs the raw ordered id set as a baseline, not just the
|
||||||
|
`synced_fingerprint` hash. Add a nullable `synced_video_ids` (JSON list) column; NULL on every
|
||||||
|
existing row means "no baseline yet" — the first merge then unions losslessly and records one.
|
||||||
|
Forward-only in practice (a downgrade just drops the column).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0075_playlist_sync_baseline"
|
||||||
|
down_revision: Union[str, None] = "0074_download_credentials"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("playlists", sa.Column("synced_video_ids", sa.Text(), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("playlists", "synced_video_ids")
|
||||||
@@ -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")
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"""plex_items playback-class facet (E7: filter the feed by Direct Play / Direct Stream / Transcode)
|
||||||
|
|
||||||
|
Adds the columns the playback-class filter needs:
|
||||||
|
* transcode_hw — for a `playable="transcode"` item, whether the prod iGPU can hardware-transcode it
|
||||||
|
(codec HW-decodable AND 8-bit SDR) vs software-only. NULL for non-transcode items.
|
||||||
|
* video_bit_depth — the source video bit depth (from the Plex media Stream), NULL until probed.
|
||||||
|
* media_probed_at — watermark for the bit-depth backfill (NULL = not yet probed).
|
||||||
|
|
||||||
|
Backfill for existing rows: `transcode_hw` is derived from the ALREADY-stored `codec_video` alone (the
|
||||||
|
same HW-decodable allow-list `plex.sync.HW_DECODABLE_CODECS` / `plex.stream` use), so the filter works
|
||||||
|
immediately after migration. Bit depth is unknown for existing rows (the cheap Plex listing doesn't
|
||||||
|
carry it), so a 10-bit/HDR HEVC is provisionally 'hw' here and later refined to 'sw' by the bit-depth
|
||||||
|
backfill (`plex.sync._probe_media_batch`) once its Stream is probed. `video_bit_depth`/`media_probed_at`
|
||||||
|
start NULL.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0077_plex_playback_class"
|
||||||
|
down_revision: Union[str, None] = "0076_media_asset_kind"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("plex_items", sa.Column("transcode_hw", sa.Boolean(), nullable=True))
|
||||||
|
op.add_column("plex_items", sa.Column("video_bit_depth", sa.Integer(), nullable=True))
|
||||||
|
op.add_column(
|
||||||
|
"plex_items", sa.Column("media_probed_at", sa.DateTime(timezone=True), nullable=True)
|
||||||
|
)
|
||||||
|
op.create_index("ix_plex_items_transcode_hw", "plex_items", ["transcode_hw"])
|
||||||
|
# Codec-only derivation for existing rows — keep this codec set identical to
|
||||||
|
# plex.sync.HW_DECODABLE_CODECS. Bit depth is unknown here, so provisionally 8-bit (→ hw for a
|
||||||
|
# decodable codec); the bit-depth backfill downgrades 10-bit/HDR HEVC to sw later.
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
UPDATE plex_items
|
||||||
|
SET transcode_hw = CASE
|
||||||
|
WHEN playable = 'transcode'
|
||||||
|
THEN lower(codec_video) IN ('h264','hevc','h265','vp9','vc1','mpeg2video')
|
||||||
|
ELSE NULL
|
||||||
|
END
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_plex_items_transcode_hw", table_name="plex_items")
|
||||||
|
op.drop_column("plex_items", "media_probed_at")
|
||||||
|
op.drop_column("plex_items", "video_bit_depth")
|
||||||
|
op.drop_column("plex_items", "transcode_hw")
|
||||||
@@ -44,6 +44,9 @@ class AuditAction:
|
|||||||
SYNC_RESUME = "sync_resume"
|
SYNC_RESUME = "sync_resume"
|
||||||
# Scheduler job outcome / run-summary (actor_id set = a manual "run now"; NULL = automatic)
|
# Scheduler job outcome / run-summary (actor_id set = a manual "run now"; NULL = automatic)
|
||||||
JOB_RUN = "job_run"
|
JOB_RUN = "job_run"
|
||||||
|
# Per-user download cookie/auth credential set or removed (the value is NEVER recorded)
|
||||||
|
DOWNLOAD_CREDENTIAL_SET = "download_credential_set"
|
||||||
|
DOWNLOAD_CREDENTIAL_DELETE = "download_credential_delete"
|
||||||
# The log itself was cleared (a single row survives the clear as tamper-evidence)
|
# The log itself was cleared (a single row survives the clear as tamper-evidence)
|
||||||
AUDIT_CLEAR = "audit_clear"
|
AUDIT_CLEAR = "audit_clear"
|
||||||
|
|
||||||
|
|||||||
+34
-2
@@ -186,6 +186,21 @@ class Settings(BaseSettings):
|
|||||||
download_supervisor_poll_seconds: int = 3
|
download_supervisor_poll_seconds: int = 3
|
||||||
# Grace between SIGTERM and SIGKILL when stopping a child (matches run_ffmpeg's escalation).
|
# Grace between SIGTERM and SIGKILL when stopping a child (matches run_ffmpeg's escalation).
|
||||||
download_kill_grace_seconds: int = 5
|
download_kill_grace_seconds: int = 5
|
||||||
|
# --- worker-child resource de-prioritization ---
|
||||||
|
# The download/edit child (yt-dlp + ffmpeg) shares ONE LAN box with the API, Postgres, and Plex
|
||||||
|
# playback. Without a priority handicap a CPU re-encode pegs every core for ~30s and starves web
|
||||||
|
# requests + playback until it finishes. The child lowers its OWN priority at startup — inherited
|
||||||
|
# by every ffmpeg/yt-dlp descendant — so any foreground work always wins. Each knob is best-effort
|
||||||
|
# and independently disable-able with 0.
|
||||||
|
download_child_nice: int = 10 # CPU nice increment applied to the child; 0 = don't renice
|
||||||
|
download_child_ionice_class: int = 3 # best-effort IO class (3 = idle); 0 = leave IO untouched
|
||||||
|
# Cap the browser-safe re-encode's encoder threads so a transcode can't claim every core. 0 =
|
||||||
|
# reserve one core for foreground work (max(1, cpu_count - 1)); >0 pins exactly that many.
|
||||||
|
download_ffmpeg_threads: int = 0
|
||||||
|
# Throttle the retention GC's whole-tree orphan file sweep so its rglob can't monopolize disk IO
|
||||||
|
# in the API process: sleep `pause_ms` after every `batch` files touched. pause_ms=0 disables.
|
||||||
|
download_gc_sweep_pause_ms: int = 20
|
||||||
|
download_gc_sweep_batch: int = 400
|
||||||
# Total cache size cap across ALL users (bytes). When >0 and exceeded, the GC evicts the
|
# Total cache size cap across ALL users (bytes). When >0 and exceeded, the GC evicts the
|
||||||
# least-recently-accessed ready assets until under it. 0 = unlimited (disk is the limit).
|
# least-recently-accessed ready assets until under it. 0 = unlimited (disk is the limit).
|
||||||
download_total_max_bytes: int = 0
|
download_total_max_bytes: int = 0
|
||||||
@@ -193,6 +208,10 @@ class Settings(BaseSettings):
|
|||||||
download_default_max_bytes: int = 5_368_709_120 # 5 GiB
|
download_default_max_bytes: int = 5_368_709_120 # 5 GiB
|
||||||
download_default_max_concurrent: int = 2
|
download_default_max_concurrent: int = 2
|
||||||
download_default_max_jobs: int = 50
|
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
|
# 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.
|
# deletes it (once no job references it) after an additional grace window.
|
||||||
download_retention_days: int = 30
|
download_retention_days: int = 30
|
||||||
@@ -239,9 +258,22 @@ class Settings(BaseSettings):
|
|||||||
# that catches what the incremental history feed can't — notably an un-watch done on the Plex
|
# 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.
|
# side — so it runs far less often (daily) than the incremental pass.
|
||||||
plex_watch_reconcile_interval_min: int = 1440
|
plex_watch_reconcile_interval_min: int = 1440
|
||||||
# Max concurrent transcodes for the P3 fallback. Low by default — CPU-only transcode is
|
# Max concurrent transcode sessions (playable="transcode"). Low by default — the software-fallback
|
||||||
# expensive; direct-serve (browser-compatible files) has no such limit.
|
# 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
|
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 `_select_encode`.
|
||||||
|
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`
|
# 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
|
# 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
|
# this at a fast container-local path (e.g. /var/tmp/plex-hls); prod's download_root is fast
|
||||||
|
|||||||
@@ -0,0 +1,258 @@
|
|||||||
|
"""Per-user, per-host download cookies — the single source of truth for "does the acting user's
|
||||||
|
cookie apply to this download, and how does that keep the result private".
|
||||||
|
|
||||||
|
Two call sites depend on ONE consistent answer (house rule: an invariant lives in shared code, not
|
||||||
|
in prose):
|
||||||
|
* `enqueue` salts the MediaAsset cache signature with the user id WHEN a cookie applies, so the
|
||||||
|
authed download becomes a PRIVATE asset that is never cache-served to another user.
|
||||||
|
* the worker attaches the cookie file to yt-dlp ONLY when a cookie still applies AND the asset it
|
||||||
|
is downloading into is that private, salted identity — so a cookie can never poison a shared
|
||||||
|
asset (the load-bearing cross-user privacy fix).
|
||||||
|
|
||||||
|
Both go through `cookie_salt` / `authed_format_sig` / `host_key_for_url` here, so they can't drift.
|
||||||
|
|
||||||
|
Security controls enforced here:
|
||||||
|
* **host scope (SEC-12)** — `host_key_for_url` matches the download URL's netloc against a curated
|
||||||
|
registry via the netloc-scoped `_host_is`, so a cookie is only ever offered to the exact site it
|
||||||
|
belongs to; an arbitrary `?url=` (or a look-alike host) resolves to no host_key and gets no
|
||||||
|
cookie.
|
||||||
|
* **secret handling** — cookies are Fernet-encrypted at rest; the blob is only ever decrypted
|
||||||
|
in-worker into a 0600 file that is unlinked in a `finally`, and is never returned by a route,
|
||||||
|
logged, or written to a job's error/snapshot.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.downloads import formats
|
||||||
|
from app.downloads.adapters import _host_is
|
||||||
|
from app.models import DownloadCredential
|
||||||
|
from app.security import decrypt_optional, encrypt
|
||||||
|
|
||||||
|
log = logging.getLogger("siftlode.downloads.credentials")
|
||||||
|
|
||||||
|
|
||||||
|
class HostSpec:
|
||||||
|
"""A supported cookie host: a short canonical `key` (stored on the credential + folded into the
|
||||||
|
cache salt), a human `label` for the UI, and the netloc `domains` it matches."""
|
||||||
|
|
||||||
|
__slots__ = ("key", "label", "domains")
|
||||||
|
|
||||||
|
def __init__(self, key: str, label: str, domains: tuple[str, ...]):
|
||||||
|
self.key = key
|
||||||
|
self.label = label
|
||||||
|
self.domains = domains
|
||||||
|
|
||||||
|
|
||||||
|
# Curated registry (user's call: seed the common auth-walled hosts now). One `key` can cover several
|
||||||
|
# domains of the same site. Add a host by adding a row — the FE dropdown, host-scope guard, and salt
|
||||||
|
# all derive from this, so there is nothing else to wire up.
|
||||||
|
SUPPORTED_HOSTS: tuple[HostSpec, ...] = (
|
||||||
|
HostSpec("facebook", "Facebook", ("facebook.com", "fb.watch")),
|
||||||
|
HostSpec("instagram", "Instagram", ("instagram.com",)),
|
||||||
|
HostSpec("twitter", "X (Twitter)", ("twitter.com", "x.com")),
|
||||||
|
HostSpec("tiktok", "TikTok", ("tiktok.com",)),
|
||||||
|
HostSpec("reddit", "Reddit", ("reddit.com",)),
|
||||||
|
)
|
||||||
|
|
||||||
|
_BY_KEY: dict[str, HostSpec] = {h.key: h for h in SUPPORTED_HOSTS}
|
||||||
|
|
||||||
|
|
||||||
|
def is_supported_host(host_key: str) -> bool:
|
||||||
|
return host_key in _BY_KEY
|
||||||
|
|
||||||
|
|
||||||
|
def host_label(host_key: str) -> str:
|
||||||
|
spec = _BY_KEY.get(host_key)
|
||||||
|
return spec.label if spec else host_key
|
||||||
|
|
||||||
|
|
||||||
|
def host_key_for_url(url: str | None) -> str | None:
|
||||||
|
"""The supported host_key whose domains own `url`'s netloc, or None. Netloc-scoped (via
|
||||||
|
`_host_is`), so a look-alike host ('myfacebook.com', 'facebook.com.evil') never matches. When
|
||||||
|
several specs could match, the most-specific (longest matched domain) wins so the result is
|
||||||
|
deterministic across the enqueue and worker call sites."""
|
||||||
|
if not url:
|
||||||
|
return None
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
host = urlparse(url).netloc.lower()
|
||||||
|
if not host:
|
||||||
|
return None
|
||||||
|
best_key: str | None = None
|
||||||
|
best_len = -1
|
||||||
|
for spec in SUPPORTED_HOSTS:
|
||||||
|
for dom in spec.domains:
|
||||||
|
if _host_is(host, dom) and len(dom) > best_len:
|
||||||
|
best_key, best_len = spec.key, len(dom)
|
||||||
|
return best_key
|
||||||
|
|
||||||
|
|
||||||
|
def cookie_salt(user_id: int, host_key: str) -> str:
|
||||||
|
"""The cache-signature salt for a cookie-authed download. Keyed by user id + host so the authed
|
||||||
|
asset is private to that user (never dedup-shared) and stable across that user's own re-adds."""
|
||||||
|
return f"cookie:{user_id}:{host_key}"
|
||||||
|
|
||||||
|
|
||||||
|
def authed_format_sig(spec: dict, user_id: int, host_key: str) -> str:
|
||||||
|
"""The salted `format_sig` an authed download for (user, host) resolves to — the private asset
|
||||||
|
identity. Both enqueue (to create it) and the worker (to recognise it) compute it HERE."""
|
||||||
|
return formats.format_sig(spec, salt=cookie_salt(user_id, host_key))
|
||||||
|
|
||||||
|
|
||||||
|
def salt_for_enqueue(db: Session, user_id: int, url: str | None) -> str | None:
|
||||||
|
"""At enqueue: the cache salt to apply if this user has a cookie for `url`'s host, else None (a
|
||||||
|
normal shared download). Returns the salt string so the caller passes it straight to
|
||||||
|
`format_sig(spec, salt=...)`."""
|
||||||
|
host_key = host_key_for_url(url)
|
||||||
|
if host_key is None:
|
||||||
|
return None
|
||||||
|
if get_credential(db, user_id, host_key) is None:
|
||||||
|
return None
|
||||||
|
return cookie_salt(user_id, host_key)
|
||||||
|
|
||||||
|
|
||||||
|
# --- storage (CRUD) ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def get_credential(db: Session, user_id: int, host_key: str) -> DownloadCredential | None:
|
||||||
|
return db.execute(
|
||||||
|
select(DownloadCredential).where(
|
||||||
|
DownloadCredential.user_id == user_id, DownloadCredential.host_key == host_key
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidCookies(ValueError):
|
||||||
|
"""The pasted cookies aren't a usable Netscape cookies.txt — surfaced as a 400 by the route."""
|
||||||
|
|
||||||
|
|
||||||
|
def validate_cookies(raw: str) -> str:
|
||||||
|
"""Sanity-check a pasted cookies.txt (Netscape format) and return the cleaned text. yt-dlp reads
|
||||||
|
the Netscape format: comment/`#` lines plus TAB-separated cookie lines (domain, flag, path,
|
||||||
|
secure, expiry, name, value). We don't fully parse — just reject obvious non-cookie paste (empty,
|
||||||
|
or no tab-separated data line) so a mistaken paste fails HERE with a clear message rather than
|
||||||
|
silently producing a cookie file yt-dlp ignores."""
|
||||||
|
text = (raw or "").strip()
|
||||||
|
if not text:
|
||||||
|
raise InvalidCookies("empty")
|
||||||
|
has_data_line = any(
|
||||||
|
line.strip() and not line.lstrip().startswith("#") and "\t" in line
|
||||||
|
for line in text.splitlines()
|
||||||
|
)
|
||||||
|
if not has_data_line:
|
||||||
|
raise InvalidCookies("format")
|
||||||
|
# Netscape cookie files must end with a newline for some parsers; normalise.
|
||||||
|
return text if text.endswith("\n") else text + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def upsert_credential(db: Session, user_id: int, host_key: str, raw: str) -> DownloadCredential:
|
||||||
|
"""Create or replace this user's cookie blob for `host_key`. Caller has validated host_key is
|
||||||
|
supported and `raw` via `validate_cookies`. Does NOT commit (the route commits with the audit
|
||||||
|
row so the trail can't drift). The blob is encrypted here; the plaintext never leaves this call."""
|
||||||
|
cred = get_credential(db, user_id, host_key)
|
||||||
|
enc = encrypt(raw)
|
||||||
|
if cred is None:
|
||||||
|
cred = DownloadCredential(user_id=user_id, host_key=host_key, cookies_enc=enc)
|
||||||
|
db.add(cred)
|
||||||
|
else:
|
||||||
|
cred.cookies_enc = enc
|
||||||
|
return cred
|
||||||
|
|
||||||
|
|
||||||
|
def delete_credential(db: Session, user_id: int, host_key: str) -> bool:
|
||||||
|
"""Delete this user's cookie for `host_key`. Returns whether a row existed. Does NOT commit."""
|
||||||
|
cred = get_credential(db, user_id, host_key)
|
||||||
|
if cred is None:
|
||||||
|
return False
|
||||||
|
db.delete(cred)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def status_list(db: Session, user_id: int) -> list[dict]:
|
||||||
|
"""The supported hosts with this user's configured/updated state — the write-only Settings view.
|
||||||
|
Never includes the blob."""
|
||||||
|
have = {
|
||||||
|
c.host_key: c
|
||||||
|
for c in db.execute(
|
||||||
|
select(DownloadCredential).where(DownloadCredential.user_id == user_id)
|
||||||
|
).scalars()
|
||||||
|
}
|
||||||
|
out = []
|
||||||
|
for spec in SUPPORTED_HOSTS:
|
||||||
|
cred = have.get(spec.key)
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"host_key": spec.key,
|
||||||
|
"label": spec.label,
|
||||||
|
"configured": cred is not None,
|
||||||
|
"updated_at": cred.updated_at.isoformat() if cred is not None else None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# --- worker: resolve + write the cookie file --------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_cookiefile(
|
||||||
|
db: Session,
|
||||||
|
user_id: int,
|
||||||
|
url: str | None,
|
||||||
|
asset_format_sig: str,
|
||||||
|
spec: dict,
|
||||||
|
staging: Path,
|
||||||
|
) -> Path | None:
|
||||||
|
"""In the worker child: the cookies.txt to hand yt-dlp for this download, or None.
|
||||||
|
|
||||||
|
The double gate that prevents cross-user leakage:
|
||||||
|
1. a credential must still exist for `url`'s host (host-scoped via `host_key_for_url`), AND
|
||||||
|
2. the asset being downloaded into must BE this user's private, salted identity
|
||||||
|
(`asset_format_sig == authed_format_sig(...)`).
|
||||||
|
|
||||||
|
If (2) fails the asset is the SHARED (unsalted) one — attaching the cookie would poison the
|
||||||
|
global cache with a private-group video, so we attach nothing. An unreadable blob (rotated key →
|
||||||
|
InvalidToken) also yields None; the download then fails honestly on the login wall (S3's
|
||||||
|
`login_required`)."""
|
||||||
|
host_key = host_key_for_url(url)
|
||||||
|
if host_key is None:
|
||||||
|
return None
|
||||||
|
cred = get_credential(db, user_id, host_key)
|
||||||
|
if cred is None:
|
||||||
|
return None
|
||||||
|
if asset_format_sig != authed_format_sig(spec, user_id, host_key):
|
||||||
|
return None # shared asset — never attach a cookie to a non-private identity
|
||||||
|
raw = decrypt_optional(cred.cookies_enc)
|
||||||
|
if not raw:
|
||||||
|
log.warning(
|
||||||
|
"download cookie for user %s host %s could not be decrypted (rotated key?) — "
|
||||||
|
"proceeding without it",
|
||||||
|
user_id,
|
||||||
|
host_key,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
return _write_cookiefile(raw, staging)
|
||||||
|
|
||||||
|
|
||||||
|
def _write_cookiefile(raw: str, staging: Path) -> Path:
|
||||||
|
"""Write the cookie blob to a private 0600 file under `staging/.auth/` and return its path.
|
||||||
|
|
||||||
|
Deliberately NOT in the staging root: the download retry loop unlinks every file it finds there,
|
||||||
|
and `_produced_file` picks the largest file there as the output — either would corrupt a
|
||||||
|
cookies.txt in the root. A `.auth` SUBDIR survives both (they skip directories) and is still
|
||||||
|
removed by the parent worker's staging cleanup on a hard kill. The caller unlinks it in a
|
||||||
|
`finally` on the normal/exception paths."""
|
||||||
|
auth_dir = staging / ".auth"
|
||||||
|
auth_dir.mkdir(mode=0o700, exist_ok=True)
|
||||||
|
path = auth_dir / "cookies.txt"
|
||||||
|
# Open with 0600 from the start (don't create world-readable then chmod).
|
||||||
|
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||||
|
try:
|
||||||
|
os.write(fd, raw.encode("utf-8"))
|
||||||
|
finally:
|
||||||
|
os.close(fd)
|
||||||
|
return path
|
||||||
@@ -25,6 +25,7 @@ import hashlib
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import math
|
import math
|
||||||
|
import os
|
||||||
import queue
|
import queue
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -32,9 +33,21 @@ import threading
|
|||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
log = logging.getLogger("siftlode.edit")
|
log = logging.getLogger("siftlode.edit")
|
||||||
|
|
||||||
|
|
||||||
|
def encode_thread_cap() -> int:
|
||||||
|
"""How many encoder threads a browser-safe re-encode may use. `download_ffmpeg_threads` when the
|
||||||
|
admin pinned one (>0), else reserve a core for foreground work (max(1, cpu_count - 1)). Shared by
|
||||||
|
the ffmpeg `-threads` flag and the child's `OMP_NUM_THREADS` so both honour the same budget."""
|
||||||
|
n = settings.download_ffmpeg_threads
|
||||||
|
if n > 0:
|
||||||
|
return n
|
||||||
|
return max(1, (os.cpu_count() or 2) - 1)
|
||||||
|
|
||||||
|
|
||||||
class EditAborted(Exception):
|
class EditAborted(Exception):
|
||||||
"""Raised by run_ffmpeg when the job was paused/canceled mid-encode."""
|
"""Raised by run_ffmpeg when the job was paused/canceled mid-encode."""
|
||||||
|
|
||||||
@@ -425,18 +438,42 @@ _BROWSER_SAFE_VCODEC = ("h264",)
|
|||||||
_BROWSER_SAFE_ACODEC = ("aac", "mp3")
|
_BROWSER_SAFE_ACODEC = ("aac", "mp3")
|
||||||
|
|
||||||
|
|
||||||
def parse_probe_streams(payload: str) -> tuple[str | None, str | None]:
|
def run_ffprobe_json(
|
||||||
"""(video_codec, audio_codec) out of ffprobe's JSON stream list, lowercased.
|
path: Path,
|
||||||
|
*,
|
||||||
JSON, not the flat `default=nw=1` output: there the fields arrive in ffprobe's own struct
|
show_streams: bool = False,
|
||||||
order, which puts `codec_name` BEFORE `codec_type`, so a line-by-line "remember the last
|
show_format: bool = False,
|
||||||
codec_type" parser attributes every name to the PREVIOUS stream. Measured on real downloads:
|
entries: str | None = None,
|
||||||
an ordinary H.264+AAC mp4 probed as `v=aac a=None` — the browser-playability check then
|
) -> dict | None:
|
||||||
believed the video stream was AAC and re-encoded a perfectly fine file on every download."""
|
"""One ffprobe call → its parsed JSON dict, or None on any failure (timeout, non-zero exit,
|
||||||
|
missing binary, unparseable output). The SINGLE ffprobe-JSON-invocation point in the downloads
|
||||||
|
package: the browser-playability codec probe here and the shared media probe (`probe.probe_media`,
|
||||||
|
used by both the download worker's finalize and the upload ingest) both go through it, so the
|
||||||
|
subprocess flags/timeout/error handling live in one place instead of 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:
|
try:
|
||||||
streams = json.loads(payload or "{}").get("streams") or []
|
out = subprocess.run(args, capture_output=True, text=True, timeout=30, check=True).stdout
|
||||||
except ValueError:
|
return json.loads(out)
|
||||||
return None, None
|
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
|
vcodec = acodec = None
|
||||||
for s in streams:
|
for s in streams:
|
||||||
name = (s.get("codec_name") or "").lower() or None
|
name = (s.get("codec_name") or "").lower() or None
|
||||||
@@ -451,26 +488,10 @@ def parse_probe_streams(payload: str) -> tuple[str | None, str | None]:
|
|||||||
def probe_codecs(path: Path) -> 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
|
"""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)."""
|
stream or if ffprobe fails (a failed probe is treated as 'leave it alone' upstream)."""
|
||||||
try:
|
data = run_ffprobe_json(path, entries="stream=codec_type,codec_name")
|
||||||
out = subprocess.run(
|
if data is None:
|
||||||
[
|
|
||||||
"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):
|
|
||||||
return None, None
|
return None, None
|
||||||
return parse_probe_streams(out)
|
return _codecs_from_streams(data.get("streams") or [])
|
||||||
|
|
||||||
|
|
||||||
def probe_duration(path: Path) -> int:
|
def probe_duration(path: Path) -> int:
|
||||||
@@ -510,11 +531,12 @@ def browser_transcode_flags(
|
|||||||
|
|
||||||
|
|
||||||
def build_browser_safe_cmd(
|
def build_browser_safe_cmd(
|
||||||
src: Path, dest: Path, reencode_video: bool, reencode_audio: bool
|
src: Path, dest: Path, reencode_video: bool, reencode_audio: bool, threads: int | None = None
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
"""ffmpeg command that lands `src` as a browser-safe mp4 (faststart): re-encode only the
|
"""ffmpeg command that lands `src` as a browser-safe mp4 (faststart): re-encode only the
|
||||||
stream(s) that need it (libx264 / aac), stream-copy the rest, always into mp4. `-progress
|
stream(s) that need it (libx264 / aac), stream-copy the rest, always into mp4. `-progress
|
||||||
pipe:1` streams machine-readable progress for run_ffmpeg."""
|
pipe:1` streams machine-readable progress for run_ffmpeg. `threads` caps the encoder so a
|
||||||
|
re-encode can't claim every core on the shared box (see the worker-child de-prioritization)."""
|
||||||
cmd = [
|
cmd = [
|
||||||
"ffmpeg",
|
"ffmpeg",
|
||||||
"-y",
|
"-y",
|
||||||
@@ -534,6 +556,8 @@ def build_browser_safe_cmd(
|
|||||||
else ["-c:v", "copy"]
|
else ["-c:v", "copy"]
|
||||||
)
|
)
|
||||||
cmd += ["-c:a", "aac", "-b:a", "192k"] if reencode_audio else ["-c:a", "copy"]
|
cmd += ["-c:a", "aac", "-b:a", "192k"] if reencode_audio else ["-c:a", "copy"]
|
||||||
|
if threads and threads > 0:
|
||||||
|
cmd += ["-threads", str(threads)]
|
||||||
cmd += ["-movflags", "+faststart", str(dest)]
|
cmd += ["-movflags", "+faststart", str(dest)]
|
||||||
return cmd
|
return cmd
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,16 @@ _RULES: tuple[tuple[str, str], ...] = (
|
|||||||
# Some extractors (e.g. Reddit) gate content behind a logged-in account: "Account
|
# Some extractors (e.g. Reddit) gate content behind a logged-in account: "Account
|
||||||
# authentication is required." — an auth wall we deliberately don't cross, not a transient error.
|
# authentication is required." — an auth wall we deliberately don't cross, not a transient error.
|
||||||
("authentication is required", "login_required"),
|
("authentication is required", "login_required"),
|
||||||
|
# Closed-group / private-post auth walls (Facebook groups, Instagram private, …). yt-dlp phrases
|
||||||
|
# these many ways; the common thread is "you must be signed in" or its own suggestion to supply
|
||||||
|
# cookies. Treating the cookie suggestion as an auth signal is what makes a login-walled link read
|
||||||
|
# honestly (and stay non-retryable) when the acting user has no usable cookie for that host.
|
||||||
|
("log in to", "login_required"),
|
||||||
|
("login required", "login_required"),
|
||||||
|
("you must be logged in", "login_required"),
|
||||||
|
("only available for registered users", "login_required"),
|
||||||
|
("use --cookies", "login_required"),
|
||||||
|
("--cookies-from-browser", "login_required"),
|
||||||
# Geo-restricted.
|
# Geo-restricted.
|
||||||
("in your country", "geo_blocked"),
|
("in your country", "geo_blocked"),
|
||||||
("available in your location", "geo_blocked"),
|
("available in your location", "geo_blocked"),
|
||||||
@@ -42,6 +52,10 @@ _RULES: tuple[tuple[str, str], ...] = (
|
|||||||
("account associated with this video has been terminated", "unavailable"),
|
("account associated with this video has been terminated", "unavailable"),
|
||||||
("this video is not available", "unavailable"),
|
("this video is not available", "unavailable"),
|
||||||
("this video has been removed", "unavailable"),
|
("this video has been removed", "unavailable"),
|
||||||
|
# yt-dlp doesn't support this site at all ("Unsupported URL: …") — distinct from an auth wall on a
|
||||||
|
# supported site. Non-retryable: no cookie or retry can make an unsupported host downloadable.
|
||||||
|
("unsupported url", "unsupported_host"),
|
||||||
|
("no video could be found in this url", "unsupported_host"),
|
||||||
# DRM.
|
# DRM.
|
||||||
("drm protected", "drm"),
|
("drm protected", "drm"),
|
||||||
("drm-protected", "drm"),
|
("drm-protected", "drm"),
|
||||||
|
|||||||
@@ -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.
|
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).
|
`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)
|
mode "av" (audio+video) | "v" (video-only) | "a" (audio-only)
|
||||||
max_height int | null (null = best)
|
max_height int | null (null = best)
|
||||||
container "mp4" | "mkv" | "webm" | null (merge/remux target for av/v)
|
container always coerced to "mp4" (the only universally browser-playable container)
|
||||||
audio_format "m4a" | "mp3" | "opus" | null (extract target for mode "a")
|
audio_format "m4a" | "mp3" (mode "a" extract target; anything else — e.g. opus — → "m4a")
|
||||||
vcodec "h264" | "vp9" | "av1" | null (preference, best-effort)
|
vcodec always coerced to null → H.264 selected (see `normalize`); no opt-out
|
||||||
embed_subs bool embed_chapters bool embed_thumbnail bool sponsorblock bool
|
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
|
import hashlib
|
||||||
@@ -50,10 +53,12 @@ _DEFAULTS = {
|
|||||||
# prefer H.264+AAC (browser/iOS-compatible) rather than yt-dlp's default VP9/Opus best.
|
# 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
|
# 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).
|
# 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"]
|
_SPONSORBLOCK_CATEGORIES = ["sponsor", "selfpromo", "interaction"]
|
||||||
_VCODEC_SORT = {"h264": "vcodec:h264", "vp9": "vcodec:vp9", "av1": "vcodec:av01"}
|
|
||||||
|
|
||||||
|
|
||||||
def normalize(spec: dict) -> dict:
|
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.
|
# Audio-only files can't embed subtitles/thumbnails as video; keep the flags meaningful.
|
||||||
if s["mode"] == "a":
|
if s["mode"] == "a":
|
||||||
s["embed_subs"] = False
|
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
|
return s
|
||||||
|
|
||||||
|
|
||||||
@@ -98,12 +115,17 @@ def build_ydl_opts(
|
|||||||
outtmpl: str,
|
outtmpl: str,
|
||||||
progress_hook,
|
progress_hook,
|
||||||
extra: dict | None = None,
|
extra: dict | None = None,
|
||||||
|
cookiefile: str | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Assemble yt-dlp options for a single download to `outtmpl` (a full path template).
|
"""Assemble yt-dlp options for a single download to `outtmpl` (a full path template).
|
||||||
|
|
||||||
`extra` is source-specific options contributed by the SourceAdapter (e.g. YouTube's
|
`extra` is source-specific options contributed by the SourceAdapter (e.g. YouTube's
|
||||||
`extractor_args` with the player-client set + bgutil PO-token base_url); it is merged onto the
|
`extractor_args` with the player-client set + bgutil PO-token base_url); it is merged onto the
|
||||||
generic options below. Everything here is source-agnostic."""
|
generic options below. Everything here is source-agnostic.
|
||||||
|
|
||||||
|
`cookiefile` is the acting user's per-host cookies.txt path (S6), attached ONLY by the worker
|
||||||
|
after the host-scope + private-asset gate in `downloads.credentials`. It is set AFTER the `extra`
|
||||||
|
merge so a source adapter can never clobber it."""
|
||||||
s = normalize(spec)
|
s = normalize(spec)
|
||||||
h = s["max_height"]
|
h = s["max_height"]
|
||||||
opts: dict = {
|
opts: dict = {
|
||||||
@@ -132,6 +154,11 @@ def build_ydl_opts(
|
|||||||
if extra:
|
if extra:
|
||||||
opts.update(extra)
|
opts.update(extra)
|
||||||
|
|
||||||
|
# The acting user's per-host cookie (S6). AFTER the adapter merge on purpose: an adapter must not
|
||||||
|
# be able to override the authenticated session the worker resolved for this specific user.
|
||||||
|
if cookiefile:
|
||||||
|
opts["cookiefile"] = cookiefile
|
||||||
|
|
||||||
if s["mode"] == "a":
|
if s["mode"] == "a":
|
||||||
opts["format"] = "bestaudio/best"
|
opts["format"] = "bestaudio/best"
|
||||||
opts["postprocessors"].append(
|
opts["postprocessors"].append(
|
||||||
@@ -143,51 +170,33 @@ def build_ydl_opts(
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
hcap = f"[height<=?{h}]" if h else ""
|
hcap = f"[height<=?{h}]" if h else ""
|
||||||
# Compatibility-first for mp4 (the universal container): produce H.264 video + AAC audio
|
# Every video download is the mp4/H.264 compat profile (normalize() clamps container→mp4,
|
||||||
# so shared/downloaded files play in EVERY browser, including iOS/Safari. WebKit (which
|
# vcodec→None), so shared/downloaded files play in EVERY browser, iOS/Safari included. WebKit
|
||||||
# every iOS browser, Brave included, is forced to use) decodes only H.264/H.265 + AAC
|
# (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
|
# inside mp4 — a VP9/AV1-in-mp4 file shows a broken player there — so H.264 is selected at the
|
||||||
# plays it fine. A custom profile that explicitly sets `vcodec` opts out.
|
# SELECTION level, not merely preferred: a sort key can't conjure an H.264 stream a source
|
||||||
compat = s["container"] == "mp4" and s["vcodec"] is None
|
# 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 s["mode"] == "v":
|
||||||
if compat:
|
# Video-only: keep it audio-free. Prefer an H.264 video-only stream, then any video-only,
|
||||||
# Video-only: keep it audio-free. Prefer an H.264 *video-only* stream, then any
|
# and only fall to a progressive (audio-bearing) `best` as a last resort.
|
||||||
# video-only, and only fall to a progressive (audio-bearing) `best` as a last
|
opts["format"] = (
|
||||||
# resort — same audio-free-when-possible guarantee as the old `bestvideo/best`.
|
f"bestvideo[vcodec^=avc1]{hcap}/bestvideo{hcap}/best[vcodec^=avc1]{hcap}/best{hcap}"
|
||||||
opts["format"] = (
|
)
|
||||||
f"bestvideo[vcodec^=avc1]{hcap}/bestvideo{hcap}"
|
|
||||||
f"/best[vcodec^=avc1]{hcap}/best{hcap}"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
opts["format"] = f"bestvideo{hcap}/best{hcap}"
|
|
||||||
else: # av
|
else: # av
|
||||||
if compat:
|
# avc1 video-only + m4a audio, then the avc1 progressive, then anything (re-encoded).
|
||||||
# Prefer H.264 at the SELECTION level, not just via format_sort: a sort key is only
|
opts["format"] = (
|
||||||
# a *preference* and can't conjure an H.264 stream a source doesn't expose as
|
f"bestvideo[vcodec^=avc1]{hcap}+bestaudio[acodec^=mp4a]"
|
||||||
# video-only. A non-YouTube source (e.g. Facebook) may ship AV1 as the best
|
f"/best[vcodec^=avc1]{hcap}"
|
||||||
# video-only stream and H.264 only as a *progressive* (a+v) format — which
|
f"/bestvideo{hcap}+bestaudio/best{hcap}"
|
||||||
# `bestvideo` skips — so `bestvideo+bestaudio` picked AV1 and iOS broke. This chain
|
)
|
||||||
# tries avc1 video-only, then the avc1 progressive, then anything.
|
opts["merge_output_format"] = "mp4"
|
||||||
opts["format"] = (
|
# Codec preference tiebreaker (ranks H.264/AAC among the selected candidates); the selector
|
||||||
f"bestvideo[vcodec^=avc1]{hcap}+bestaudio[acodec^=mp4a]"
|
# above is the hard guarantee, this is belt-and-braces.
|
||||||
f"/best[vcodec^=avc1]{hcap}"
|
sort = ["vcodec:h264", "acodec:aac"]
|
||||||
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"]
|
|
||||||
if h:
|
if h:
|
||||||
sort.append(f"res:{h}")
|
sort.append(f"res:{h}")
|
||||||
if s["vcodec"] in _VCODEC_SORT:
|
opts["format_sort"] = sort
|
||||||
sort.append(_VCODEC_SORT[s["vcodec"]])
|
|
||||||
if sort:
|
|
||||||
opts["format_sort"] = sort
|
|
||||||
|
|
||||||
# SponsorBlock first (marks segments), then strip them from the media + chapters.
|
# SponsorBlock first (marks segments), then strip them from the media + chapters.
|
||||||
if s["sponsorblock"]:
|
if s["sponsorblock"]:
|
||||||
|
|||||||
@@ -86,11 +86,25 @@ def _sweep_orphan_files(db: Session, download_root: str) -> int:
|
|||||||
|
|
||||||
cutoff = time.time() - _ORPHAN_MIN_AGE_S
|
cutoff = time.time() - _ORPHAN_MIN_AGE_S
|
||||||
removed = 0
|
removed = 0
|
||||||
|
# Throttle the whole-tree walk: this runs in the API process, and an unpaced rglob+stat over a
|
||||||
|
# large download root monopolizes disk IO long enough to make web requests + playback stutter.
|
||||||
|
# Yield the IO channel after every `batch` files touched so foreground work interleaves.
|
||||||
|
pause_s = max(0, settings.download_gc_sweep_pause_ms) / 1000.0
|
||||||
|
batch = max(1, settings.download_gc_sweep_batch)
|
||||||
|
touched = 0
|
||||||
|
|
||||||
|
def _throttle() -> None:
|
||||||
|
nonlocal touched
|
||||||
|
touched += 1
|
||||||
|
if pause_s and touched % batch == 0:
|
||||||
|
time.sleep(pause_s)
|
||||||
|
|
||||||
for entry in root.iterdir():
|
for entry in root.iterdir():
|
||||||
if entry.name.startswith("."): # .edits / .plex-* / .staging / .storyboards + any dotfile
|
if entry.name.startswith("."): # .edits / .plex-* / .staging / .storyboards + any dotfile
|
||||||
continue
|
continue
|
||||||
files = [entry] if entry.is_file() else [p for p in entry.rglob("*") if p.is_file()]
|
files = [entry] if entry.is_file() else [p for p in entry.rglob("*") if p.is_file()]
|
||||||
for f in files:
|
for f in files:
|
||||||
|
_throttle()
|
||||||
try:
|
try:
|
||||||
if f.resolve() in keep or f.stat().st_mtime > cutoff:
|
if f.resolve() in keep or f.stat().st_mtime > cutoff:
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -80,9 +80,13 @@ def public_meta(
|
|||||||
|
|
||||||
Resolves the owner's per-download display overrides (custom title/channel) over the asset's
|
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."""
|
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)
|
source_url = service.reference_url(job, asset)
|
||||||
|
# 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 {
|
return {
|
||||||
"title": (job and job.display_name) or asset.title,
|
"title": (job and job.display_name) or asset.title,
|
||||||
"uploader": (job and job.display_uploader) or asset.uploader,
|
"uploader": (job and job.display_uploader) or asset.uploader,
|
||||||
@@ -92,6 +96,10 @@ def public_meta(
|
|||||||
"duration_s": asset.duration_s,
|
"duration_s": asset.duration_s,
|
||||||
"width": asset.width,
|
"width": asset.width,
|
||||||
"height": asset.height,
|
"height": asset.height,
|
||||||
|
"audio": asset.media_kind == mediakind.AUDIO,
|
||||||
|
"media_kind": asset.media_kind,
|
||||||
|
"size_bytes": asset.size_bytes,
|
||||||
|
"container": asset.container,
|
||||||
"allow_download": link.allow_download,
|
"allow_download": link.allow_download,
|
||||||
"file_url": file_url,
|
"file_url": file_url,
|
||||||
"poster_url": poster_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
|
||||||
@@ -8,13 +8,14 @@ from datetime import date, datetime, timedelta, timezone
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import yt_dlp
|
import yt_dlp
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
from app import sysconfig
|
from app import sysconfig
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.db import SessionLocal
|
from app.db import SessionLocal
|
||||||
from app.downloads import edit as editmod
|
from app.downloads import edit as editmod
|
||||||
from app.downloads import errors as dlerrors
|
from app.downloads import errors as dlerrors
|
||||||
from app.downloads import adapters, formats, service, storage
|
from app.downloads import adapters, credentials, formats, mediakind, probe, service, storage
|
||||||
from app.downloads.jobdb import (
|
from app.downloads.jobdb import (
|
||||||
_edit_staging_dir,
|
_edit_staging_dir,
|
||||||
_job_status,
|
_job_status,
|
||||||
@@ -304,10 +305,13 @@ def _media_info(info: dict) -> dict:
|
|||||||
return {**info, **chosen}
|
return {**info, **chosen}
|
||||||
|
|
||||||
|
|
||||||
def _extract_with_fallback(job_id, asset_id, url, spec, staging, adapter):
|
def _extract_with_fallback(job_id, asset_id, url, spec, staging, adapter, cookiefile=None):
|
||||||
"""Download via yt-dlp using the source `adapter`'s client sets + options. For YouTube the adapter
|
"""Download via yt-dlp using the source `adapter`'s client sets + options. For YouTube the adapter
|
||||||
supplies the primary player-client set then a POT-backed fallback (retried on a bot/DRM/format
|
supplies the primary player-client set then a POT-backed fallback (retried on a bot/DRM/format
|
||||||
error); other sources make a single attempt. Returns the info dict of the successful attempt."""
|
error); other sources make a single attempt. Returns the info dict of the successful attempt.
|
||||||
|
|
||||||
|
`cookiefile` (S6) is the acting user's per-host cookies.txt, already gated by
|
||||||
|
`credentials.resolve_cookiefile`; passed to every attempt so the fallback stays authenticated."""
|
||||||
client_sets = adapter.client_sets()
|
client_sets = adapter.client_sets()
|
||||||
outtmpl = str(staging / "%(id)s.%(ext)s")
|
outtmpl = str(staging / "%(id)s.%(ext)s")
|
||||||
last_exc: Exception | None = None
|
last_exc: Exception | None = None
|
||||||
@@ -317,6 +321,7 @@ def _extract_with_fallback(job_id, asset_id, url, spec, staging, adapter):
|
|||||||
outtmpl,
|
outtmpl,
|
||||||
_make_progress_hook(job_id, asset_id),
|
_make_progress_hook(job_id, asset_id),
|
||||||
extra=adapter.ydl_opts_extra(clients),
|
extra=adapter.ydl_opts_extra(clients),
|
||||||
|
cookiefile=cookiefile,
|
||||||
)
|
)
|
||||||
opts["postprocessor_hooks"] = [_make_pp_hook(job_id)]
|
opts["postprocessor_hooks"] = [_make_pp_hook(job_id)]
|
||||||
_touch_job(job_id) # stall-watchdog heartbeat: extraction writes no progress, so a slow /
|
_touch_job(job_id) # stall-watchdog heartbeat: extraction writes no progress, so a slow /
|
||||||
@@ -381,7 +386,9 @@ def _ensure_browser_playable(job_id: int, produced: Path, spec: dict, info: dict
|
|||||||
# re-encode still shows real progress instead of a frozen 0%.
|
# re-encode still shows real progress instead of a frozen 0%.
|
||||||
total_s = int(info["duration"]) if info.get("duration") else editmod.probe_duration(produced)
|
total_s = int(info["duration"]) if info.get("duration") else editmod.probe_duration(produced)
|
||||||
editmod.run_ffmpeg(
|
editmod.run_ffmpeg(
|
||||||
editmod.build_browser_safe_cmd(produced, dest, reenc_v, reenc_a),
|
editmod.build_browser_safe_cmd(
|
||||||
|
produced, dest, reenc_v, reenc_a, threads=editmod.encode_thread_cap()
|
||||||
|
),
|
||||||
total_s=total_s,
|
total_s=total_s,
|
||||||
on_progress=_ffmpeg_progress_writer(job_id, "processing"),
|
on_progress=_ffmpeg_progress_writer(job_id, "processing"),
|
||||||
should_cancel=_cancel_poll(job_id),
|
should_cancel=_cancel_poll(job_id),
|
||||||
@@ -395,8 +402,11 @@ def _ensure_browser_playable(job_id: int, produced: Path, spec: dict, info: dict
|
|||||||
return dest
|
return dest
|
||||||
|
|
||||||
|
|
||||||
def _download(job_id: int, asset_id: int, source_kind: str, source_ref: str, spec: dict) -> None:
|
def _download(
|
||||||
|
job_id: int, asset_id: int, source_kind: str, source_ref: str, spec: dict, user_id: int
|
||||||
|
) -> None:
|
||||||
staging = _staging_dir(asset_id)
|
staging = _staging_dir(asset_id)
|
||||||
|
cookiefile: Path | None = None
|
||||||
try:
|
try:
|
||||||
if staging.exists():
|
if staging.exists():
|
||||||
shutil.rmtree(staging, ignore_errors=True)
|
shutil.rmtree(staging, ignore_errors=True)
|
||||||
@@ -405,7 +415,27 @@ def _download(job_id: int, asset_id: int, source_kind: str, source_ref: str, spe
|
|||||||
_set_job(job_id, phase="downloading", progress=0)
|
_set_job(job_id, phase="downloading", progress=0)
|
||||||
adapter = adapters.resolve_adapter(source_kind, source_ref)
|
adapter = adapters.resolve_adapter(source_kind, source_ref)
|
||||||
url = service.source_url(source_kind, source_ref)
|
url = service.source_url(source_kind, source_ref)
|
||||||
info = _media_info(_extract_with_fallback(job_id, asset_id, url, spec, staging, adapter))
|
# S6: attach the acting user's per-host cookie ONLY when it's host-scoped to this URL AND
|
||||||
|
# this asset is that user's private, salted identity (the double gate lives in credentials).
|
||||||
|
with SessionLocal() as cdb:
|
||||||
|
asset_sig = cdb.execute(
|
||||||
|
select(MediaAsset.format_sig).where(MediaAsset.id == asset_id)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if asset_sig is not None:
|
||||||
|
cookiefile = credentials.resolve_cookiefile(
|
||||||
|
cdb, user_id, url, asset_sig, spec, staging
|
||||||
|
)
|
||||||
|
info = _media_info(
|
||||||
|
_extract_with_fallback(
|
||||||
|
job_id,
|
||||||
|
asset_id,
|
||||||
|
url,
|
||||||
|
spec,
|
||||||
|
staging,
|
||||||
|
adapter,
|
||||||
|
cookiefile=str(cookiefile) if cookiefile else None,
|
||||||
|
)
|
||||||
|
)
|
||||||
# Re-resolve now that yt-dlp's info reveals the true source (a bare/redirected url only
|
# Re-resolve now that yt-dlp's info reveals the true source (a bare/redirected url only
|
||||||
# identifies post-extraction) so the .nfo uniqueid type + uploader_url agree on one source.
|
# identifies post-extraction) so the .nfo uniqueid type + uploader_url agree on one source.
|
||||||
adapter = adapters.resolve_adapter(source_kind, source_ref, info)
|
adapter = adapters.resolve_adapter(source_kind, source_ref, info)
|
||||||
@@ -445,15 +475,15 @@ def _download(job_id: int, asset_id: int, source_kind: str, source_ref: str, spe
|
|||||||
asset.rel_path = rel
|
asset.rel_path = rel
|
||||||
asset.size_bytes = size
|
asset.size_bytes = size
|
||||||
asset.duration_s = meta.duration_s
|
asset.duration_s = meta.duration_s
|
||||||
asset.width = int(info["width"]) if info.get("width") else None
|
# Codecs/dims come from a PROBE of the finished file — the ground truth — NOT yt-dlp's
|
||||||
asset.height = int(info["height"]) if info.get("height") else None
|
# `info` dict, whose codec fields are absent for some sources (Facebook among them):
|
||||||
|
# deriving media_kind from those nulls misclassified a real H.264 video as a
|
||||||
|
# download-only 'file' with no in-app play/edit. Same probe the upload ingest uses.
|
||||||
|
asset.vcodec, asset.acodec, asset.width, asset.height = probe.resolve_media_facts(
|
||||||
|
final, info
|
||||||
|
)
|
||||||
asset.container = ext
|
asset.container = ext
|
||||||
asset.vcodec = (
|
asset.media_kind = mediakind.derive(asset.vcodec, asset.acodec)
|
||||||
info.get("vcodec") if info.get("vcodec") not in (None, "none") else None
|
|
||||||
)
|
|
||||||
asset.acodec = (
|
|
||||||
info.get("acodec") if info.get("acodec") not in (None, "none") else None
|
|
||||||
)
|
|
||||||
asset.title = meta.title
|
asset.title = meta.title
|
||||||
asset.uploader = meta.uploader
|
asset.uploader = meta.uploader
|
||||||
asset.uploader_url = adapter.uploader_url(info)
|
asset.uploader_url = adapter.uploader_url(info)
|
||||||
@@ -515,6 +545,10 @@ def _download(job_id: int, asset_id: int, source_kind: str, source_ref: str, spe
|
|||||||
db.commit()
|
db.commit()
|
||||||
log.warning("job %s failed: %s", job_id, msg)
|
log.warning("job %s failed: %s", job_id, msg)
|
||||||
finally:
|
finally:
|
||||||
|
# Unlink the decrypted cookie file explicitly (the rmtree below removes the whole staging
|
||||||
|
# tree too, but never leave a plaintext session cookie on disk a moment longer than needed).
|
||||||
|
if cookiefile is not None:
|
||||||
|
cookiefile.unlink(missing_ok=True)
|
||||||
shutil.rmtree(staging, ignore_errors=True)
|
shutil.rmtree(staging, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
"""Ground-truth media probe for a produced library file — the SINGLE place both ingest paths ask
|
||||||
|
"what is actually on disk?".
|
||||||
|
|
||||||
|
`media_kind` (and the stored `vcodec`/`acodec`/`width`/`height`) must reflect the real file, not an
|
||||||
|
extractor's metadata: yt-dlp's `info` dict omits codec fields for some sources (e.g. Facebook), and
|
||||||
|
deriving the kind from those nulls misclassified a genuine H.264 video as a download-only `file`
|
||||||
|
(no in-app play). So the download worker (`pipeline`) and the local upload ingest (`upload`) both
|
||||||
|
probe the finished file here; `mediakind.derive` then turns the probed codecs into the kind.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.downloads import edit as editmod
|
||||||
|
|
||||||
|
# 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 _facts_from_probe(
|
||||||
|
data: dict,
|
||||||
|
) -> tuple[str | None, str | None, int | None, int | None, int | None]:
|
||||||
|
"""(vcodec, acodec, width, height, duration_s) parsed from a NON-None ffprobe JSON dict. A still
|
||||||
|
image / image-sequence container yields no vcodec (→ generic 'file'); 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."""
|
||||||
|
fmt = data.get("format") or {}
|
||||||
|
fmt_names = {n.strip() for n in (fmt.get("format_name") or "").split(",")}
|
||||||
|
if fmt_names & _IMAGE_FORMATS:
|
||||||
|
return None, None, None, None, None
|
||||||
|
|
||||||
|
vcodec = acodec = None
|
||||||
|
width = height = None
|
||||||
|
for stream in data.get("streams", []):
|
||||||
|
kind = stream.get("codec_type")
|
||||||
|
if kind == "video" and vcodec is None:
|
||||||
|
if (stream.get("disposition") or {}).get("attached_pic"):
|
||||||
|
continue # cover art, not a video track
|
||||||
|
vcodec = stream.get("codec_name")
|
||||||
|
width = stream.get("width")
|
||||||
|
height = stream.get("height")
|
||||||
|
elif kind == "audio" and acodec is None:
|
||||||
|
acodec = stream.get("codec_name")
|
||||||
|
|
||||||
|
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 probe_media(path: Path) -> tuple[str | None, str | None, int | None, int | None, int | None]:
|
||||||
|
"""(vcodec, acodec, width, height, duration_s) from one ffprobe call; all None if ffprobe fails
|
||||||
|
or the file isn't playable media (an unprobeable file — or a still image — is treated as a generic
|
||||||
|
non-media file, so it becomes a download-only 'file' rather than a broken 'video')."""
|
||||||
|
data = editmod.run_ffprobe_json(path, show_streams=True, show_format=True)
|
||||||
|
if data is None:
|
||||||
|
return None, None, None, None, None
|
||||||
|
return _facts_from_probe(data)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_media_facts(
|
||||||
|
path: Path, info: dict
|
||||||
|
) -> tuple[str | None, str | None, int | None, int | None]:
|
||||||
|
"""(vcodec, acodec, width, height) for the stored download asset: PROBE the finished file on disk
|
||||||
|
(the ground truth). yt-dlp's `info` omits codec/dimension fields for some sources (Facebook among
|
||||||
|
them), so deriving media_kind from it there labelled a real H.264 video a download-only 'file'.
|
||||||
|
|
||||||
|
Precedence is by whether the probe RAN, not per-field: if ffprobe read the file, its codec verdict
|
||||||
|
is authoritative — INCLUDING a deliberate None (cover-art-only audio, a still image). A per-field
|
||||||
|
`probe or info` fallback would let `info`'s stale vcodec resurrect a video track the file doesn't
|
||||||
|
have (yt-dlp reports a progressive `vcodec` for an audio-mode download whose video it then strips),
|
||||||
|
mislabelling an audio download as a broken in-app 'video'. Only when ffprobe could NOT read the
|
||||||
|
file at all do we fall back to `info` (the pre-probe behaviour, a safety net); the cosmetic
|
||||||
|
dimensions still gap-fill from `info` in either case."""
|
||||||
|
data = editmod.run_ffprobe_json(path, show_streams=True, show_format=True)
|
||||||
|
info_v = info.get("vcodec") if info.get("vcodec") not in (None, "none") else None
|
||||||
|
info_a = info.get("acodec") if info.get("acodec") not in (None, "none") else None
|
||||||
|
info_w = int(info["width"]) if info.get("width") else None
|
||||||
|
info_h = int(info["height"]) if info.get("height") else None
|
||||||
|
if data is None:
|
||||||
|
# ffprobe could not read the file — fall back to the extractor's info entirely.
|
||||||
|
return info_v, info_a, info_w, info_h
|
||||||
|
pv, pa, pw, ph, _dur = _facts_from_probe(data)
|
||||||
|
# Probe ran → its codecs win outright (a None means "no such track", not "unknown"); info only
|
||||||
|
# gap-fills the cosmetic width/height the probe couldn't read.
|
||||||
|
return pv, pa, pw if pw is not None else info_w, ph if ph is not None else info_h
|
||||||
@@ -122,6 +122,22 @@ def check_enqueue(db: Session, user_id: int) -> None:
|
|||||||
raise QuotaExceeded("max_bytes", lim.max_bytes, used)
|
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:
|
def usage(db: Session, user_id: int) -> dict:
|
||||||
"""Compact usage snapshot for the UI (footprint + counts vs limits)."""
|
"""Compact usage snapshot for the UI (footprint + counts vs limits)."""
|
||||||
lim = resolve(db, user_id)
|
lim = resolve(db, user_id)
|
||||||
|
|||||||
@@ -12,16 +12,48 @@ progress hook), then exits 0. An unexpected crash exits non-zero and the parent
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
from app.db import SessionLocal
|
from app.db import SessionLocal
|
||||||
|
from app.downloads import edit as editmod
|
||||||
from app.models import DownloadJob
|
from app.models import DownloadJob
|
||||||
|
|
||||||
log = logging.getLogger("siftlode.worker.runner")
|
log = logging.getLogger("siftlode.worker.runner")
|
||||||
|
|
||||||
|
|
||||||
|
def _deprioritize() -> None:
|
||||||
|
"""Lower THIS child's CPU + IO priority so its yt-dlp/ffmpeg work — every descendant inherits the
|
||||||
|
handicap — can never starve the co-located API, Postgres, or Plex playback. Run once at startup,
|
||||||
|
before any heavy work. Every step is best-effort: a failed renice must never fail a download."""
|
||||||
|
nice_inc = settings.download_child_nice
|
||||||
|
if nice_inc > 0:
|
||||||
|
try:
|
||||||
|
os.nice(nice_inc) # relative increment; the child starts at the worker's nice (0)
|
||||||
|
except OSError as exc:
|
||||||
|
log.info("could not renice download child: %s", str(exc)[:120])
|
||||||
|
cls = settings.download_child_ionice_class
|
||||||
|
if cls > 0 and shutil.which("ionice"): # util-linux; absent on some minimal images
|
||||||
|
try:
|
||||||
|
subprocess.run(
|
||||||
|
["ionice", "-c", str(cls), "-p", str(os.getpid())],
|
||||||
|
capture_output=True,
|
||||||
|
timeout=5,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
except (OSError, subprocess.SubprocessError) as exc:
|
||||||
|
log.info("could not ionice download child: %s", str(exc)[:120])
|
||||||
|
# Cap OpenMP-parallel ffmpeg filters to the same budget as the encoder threads, so a filter
|
||||||
|
# graph can't fan out across every core behind ffmpeg's own `-threads` cap.
|
||||||
|
os.environ.setdefault("OMP_NUM_THREADS", str(editmod.encode_thread_cap()))
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
logging.basicConfig(level=logging.INFO)
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
_deprioritize()
|
||||||
if len(sys.argv) != 2:
|
if len(sys.argv) != 2:
|
||||||
print("usage: python -m app.downloads.runner <job_id>", file=sys.stderr)
|
print("usage: python -m app.downloads.runner <job_id>", file=sys.stderr)
|
||||||
raise SystemExit(2)
|
raise SystemExit(2)
|
||||||
@@ -35,6 +67,7 @@ def main() -> None:
|
|||||||
job_kind = job.job_kind
|
job_kind = job.job_kind
|
||||||
spec = dict(job.profile_snapshot or {})
|
spec = dict(job.profile_snapshot or {})
|
||||||
source_kind, source_ref = job.source_kind, job.source_ref
|
source_kind, source_ref = job.source_kind, job.source_ref
|
||||||
|
user_id = job.user_id
|
||||||
if asset_id is None:
|
if asset_id is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -45,7 +78,7 @@ def main() -> None:
|
|||||||
if job_kind == "edit":
|
if job_kind == "edit":
|
||||||
_process_edit(job_id, asset_id)
|
_process_edit(job_id, asset_id)
|
||||||
else:
|
else:
|
||||||
_download(job_id, asset_id, source_kind, source_ref, spec)
|
_download(job_id, asset_id, source_kind, source_ref, spec, user_id)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -15,8 +15,9 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.downloads import adapters
|
from app.downloads import adapters
|
||||||
|
from app.downloads import credentials
|
||||||
from app.downloads import edit as editmod
|
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.downloads.status import HOLDING, JobStatus
|
||||||
from app.models import Channel, DownloadJob, MediaAsset, Video
|
from app.models import Channel, DownloadJob, MediaAsset, Video
|
||||||
|
|
||||||
@@ -155,7 +156,12 @@ def enqueue(
|
|||||||
Raises quota.QuotaExceeded if the user is at their job-count or footprint cap."""
|
Raises quota.QuotaExceeded if the user is at their job-count or footprint cap."""
|
||||||
quota.check_enqueue(db, user_id)
|
quota.check_enqueue(db, user_id)
|
||||||
spec = formats.normalize(spec)
|
spec = formats.normalize(spec)
|
||||||
sig = formats.format_sig(spec)
|
# If this user has a stored cookie for the download's host, salt the cache signature with their
|
||||||
|
# id so the cookie-authed result is a PRIVATE asset — never dedup-shared with, or cache-served
|
||||||
|
# to, another user (the load-bearing privacy fix; the worker attaches the cookie to exactly this
|
||||||
|
# salted identity). A normal download (no matching cookie) gets salt=None and shares as before.
|
||||||
|
salt = credentials.salt_for_enqueue(db, user_id, source_url(source_kind, source_ref))
|
||||||
|
sig = formats.format_sig(spec, salt=salt)
|
||||||
asset = get_or_create_asset(db, source_kind, source_ref, sig)
|
asset = get_or_create_asset(db, source_kind, source_ref, sig)
|
||||||
# Show a real title + thumbnail the moment it's queued (feed downloads are catalog videos).
|
# Show a real title + thumbnail the moment it's queued (feed downloads are catalog videos).
|
||||||
populate_from_catalog(db, asset)
|
populate_from_catalog(db, asset)
|
||||||
@@ -210,6 +216,10 @@ def enqueue_edit(
|
|||||||
src_asset = db.get(MediaAsset, source_job.asset_id) if source_job.asset_id else None
|
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:
|
if src_asset is None or src_asset.status != "ready" or not src_asset.rel_path:
|
||||||
raise EditError("source_not_ready")
|
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)
|
spec = editmod.normalize_edit_spec(edit_spec)
|
||||||
if not spec:
|
if not spec:
|
||||||
@@ -226,6 +236,9 @@ def enqueue_edit(
|
|||||||
asset.thumbnail_url = src_asset.thumbnail_url
|
asset.thumbnail_url = src_asset.thumbnail_url
|
||||||
asset.upload_date = src_asset.upload_date
|
asset.upload_date = src_asset.upload_date
|
||||||
asset.duration_s = editmod.clip_duration(spec, src_asset.duration_s)
|
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(
|
job = DownloadJob(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ from datetime import date
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from xml.sax.saxutils import escape
|
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.
|
# Reserve room for the "_id.ext" suffix + parent dirs within the 255-char component limit.
|
||||||
_MAX_TITLE = 120
|
_MAX_TITLE = 120
|
||||||
# User-facing (Content-Disposition) names can be longer than an on-disk component.
|
# 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}"
|
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:
|
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
|
"""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
|
correct extension — the asset container, else the file's own suffix — without doubling it when the
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
"""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, probe, quota, service, storage
|
||||||
|
from app.models import DownloadJob, MediaAsset
|
||||||
|
|
||||||
|
|
||||||
|
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.probe_media(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
|
||||||
@@ -551,6 +551,10 @@ class Playlist(Base, TimestampMixin, UpdatedAtMixin):
|
|||||||
# SHA-256 of name + ordered video ids as last synced from / pushed to YouTube; the
|
# SHA-256 of name + ordered video ids as last synced from / pushed to YouTube; the
|
||||||
# baseline `dirty` is derived against. NULL = no baseline yet (treated as dirty).
|
# baseline `dirty` is derived against. NULL = no baseline yet (treated as dirty).
|
||||||
synced_fingerprint: Mapped[str | None] = mapped_column(String)
|
synced_fingerprint: Mapped[str | None] = mapped_column(String)
|
||||||
|
# JSON list of the ordered video ids as of the last sync — the 3-way-merge baseline the
|
||||||
|
# two-way YouTube sync classifies each item against (local-add vs YT-add vs real delete).
|
||||||
|
# NULL = no baseline yet; the first merge then does a lossless union (never deletes).
|
||||||
|
synced_video_ids: Mapped[str | None] = mapped_column(Text)
|
||||||
position: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
position: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||||||
|
|
||||||
items: Mapped[list["PlaylistItem"]] = relationship(
|
items: Mapped[list["PlaylistItem"]] = relationship(
|
||||||
@@ -704,6 +708,12 @@ class MediaAsset(Base, TimestampMixin):
|
|||||||
container: Mapped[str | None] = mapped_column(String(16))
|
container: Mapped[str | None] = mapped_column(String(16))
|
||||||
vcodec: Mapped[str | None] = mapped_column(String(32))
|
vcodec: Mapped[str | None] = mapped_column(String(32))
|
||||||
acodec: 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")
|
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.
|
# 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")
|
gc_notified: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||||
@@ -867,6 +877,35 @@ class DownloadLink(Base, TimestampMixin):
|
|||||||
view_count: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
view_count: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||||||
|
|
||||||
|
|
||||||
|
class DownloadCredential(Base, TimestampMixin, UpdatedAtMixin):
|
||||||
|
"""A user's stored login cookies for ONE auth-walled download host (Facebook, Instagram, …).
|
||||||
|
|
||||||
|
Per-user, per-host: each user pastes their OWN browser cookies.txt (Netscape format) for a host
|
||||||
|
where they're logged in (e.g. a closed Facebook group they belong to), and a download of a
|
||||||
|
yt-dlp-supported, auth-walled link on that host is retried with the ACTING user's cookie. The
|
||||||
|
blob is Fernet-encrypted at rest (`cookies_enc`) and is NEVER returned by any API/log/audit —
|
||||||
|
the routes are write-only (host + updated_at only). See `downloads.credentials`.
|
||||||
|
|
||||||
|
Two load-bearing security controls live around this row, NOT on it:
|
||||||
|
* **cache privacy** — a cookie-authed download SALTS its MediaAsset `format_sig` with the
|
||||||
|
user id, so the authed asset is private to that user and can never be cache-served to
|
||||||
|
another user (`downloads.credentials.cookie_salt`).
|
||||||
|
* **host scope (SEC-12)** — a cookie is attached ONLY when the download URL's netloc matches
|
||||||
|
this row's `host_key` (`downloads.credentials.host_key_for_url`), so an arbitrary `?url=`
|
||||||
|
can't exfiltrate the user's session to a look-alike host.
|
||||||
|
|
||||||
|
`host_key` is a short canonical key from the supported-host registry (e.g. "facebook"), not the
|
||||||
|
raw netloc — one credential can cover several domains of the same site (facebook.com/fb.watch)."""
|
||||||
|
|
||||||
|
__tablename__ = "download_credentials"
|
||||||
|
__table_args__ = (UniqueConstraint("user_id", "host_key", name="uq_download_credential"),)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||||
|
host_key: Mapped[str] = mapped_column(String(64))
|
||||||
|
cookies_enc: Mapped[str] = mapped_column(Text) # Fernet ciphertext — never returned/logged
|
||||||
|
|
||||||
|
|
||||||
class MessageKey(Base, TimestampMixin):
|
class MessageKey(Base, TimestampMixin):
|
||||||
"""A user's end-to-end-encryption key material for direct messaging (phase 2).
|
"""A user's end-to-end-encryption key material for direct messaging (phase 2).
|
||||||
|
|
||||||
@@ -1088,6 +1127,22 @@ class PlexItem(Base, TimestampMixin, UpdatedAtMixin):
|
|||||||
# Browser-playability class from ffprobe: "direct" | "remux" | "transcode" (None = unprobed).
|
# Browser-playability class from ffprobe: "direct" | "remux" | "transcode" (None = unprobed).
|
||||||
playable: Mapped[str | None] = mapped_column(String(12), index=True)
|
playable: Mapped[str | None] = mapped_column(String(12), index=True)
|
||||||
probed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
probed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
# Transcode HW-capability facet (E7 playback-class filter). NULL for non-transcode items; for a
|
||||||
|
# `playable="transcode"` item: True = the prod iGPU can hardware-transcode it (codec is HW-decodable
|
||||||
|
# AND the source is 8-bit SDR), False = software libx264 only (AV1/XviD, or a 10-bit/HDR source that
|
||||||
|
# breaks the VAAPI nv12 pipeline — see plex/stream.py). Derived by `plex.sync.transcode_hw_capable`:
|
||||||
|
# set codec-only at sync (bit depth unknown from the cheap listing), then refined by the bit-depth
|
||||||
|
# backfill (`_probe_media_batch`) which downgrades a 10-bit/HDR HEVC to False. The one filterable
|
||||||
|
# home of the invariant `_want_hw`'s source gate computes at play time — kept in sync via the shared
|
||||||
|
# classifier so the catalog facet and the runtime encoder choice never disagree by codec.
|
||||||
|
transcode_hw: Mapped[bool | None] = mapped_column(Boolean, index=True)
|
||||||
|
# Video bit depth (8/10/12…) from the Plex media Stream, filled by the bit-depth backfill. NULL =
|
||||||
|
# not yet probed. Surfaced on the info page (e.g. "HEVC · 10-bit") and feeds `transcode_hw`.
|
||||||
|
video_bit_depth: Mapped[int | None] = mapped_column(Integer)
|
||||||
|
# Watermark for the bit-depth backfill: when this item's media Stream was last probed for
|
||||||
|
# bit-depth/HDR. NULL = never (the backfill's work-list). Only HW-decodable transcode items are
|
||||||
|
# probed (the rest are SW-only by codec and need no bit-depth); see `_probe_media_batch`.
|
||||||
|
media_probed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
# Intro/credit markers: [{"type": "intro"|"credits", "start_s": int, "end_s": int}, ...]
|
# Intro/credit markers: [{"type": "intro"|"credits", "start_s": int, "end_s": int}, ...]
|
||||||
markers: Mapped[list | None] = mapped_column(JSON)
|
markers: Mapped[list | None] = mapped_column(JSON)
|
||||||
added_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
|
added_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ def movie_card(it: PlexItem, st: PlexState | None) -> dict:
|
|||||||
"thumb": f"/api/plex/image/{it.rating_key}",
|
"thumb": f"/api/plex/image/{it.rating_key}",
|
||||||
"art": _art_url(it.rating_key, it.art_key),
|
"art": _art_url(it.rating_key, it.art_key),
|
||||||
"playable": it.playable,
|
"playable": it.playable,
|
||||||
|
"transcode_hw": it.transcode_hw, # HW/SW transcode split → the 4-way corner-dot badge
|
||||||
"status": st.status if st else "new",
|
"status": st.status if st else "new",
|
||||||
"position_seconds": st.position_seconds if st else 0,
|
"position_seconds": st.position_seconds if st else 0,
|
||||||
}
|
}
|
||||||
@@ -65,6 +66,7 @@ def episode_card(e: PlexItem, st: PlexState | None) -> dict:
|
|||||||
"duration_seconds": e.duration_s,
|
"duration_seconds": e.duration_s,
|
||||||
"thumb": f"/api/plex/image/{e.rating_key}",
|
"thumb": f"/api/plex/image/{e.rating_key}",
|
||||||
"playable": e.playable,
|
"playable": e.playable,
|
||||||
|
"transcode_hw": e.transcode_hw, # HW/SW transcode split → the 4-way corner-dot badge
|
||||||
"status": st.status if st else "new",
|
"status": st.status if st else "new",
|
||||||
"position_seconds": st.position_seconds if st else 0,
|
"position_seconds": st.position_seconds if st else 0,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ from datetime import datetime, timedelta, timezone
|
|||||||
from sqlalchemy import and_, case, func, or_, select, type_coerce
|
from sqlalchemy import and_, case, func, or_, select, type_coerce
|
||||||
from sqlalchemy.dialects.postgresql import JSONB
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
|
|
||||||
|
from app.models import PlexItem
|
||||||
|
|
||||||
TS_CONFIG = "public.unaccent_simple"
|
TS_CONFIG = "public.unaccent_simple"
|
||||||
|
|
||||||
# --- Language-aware column pickers (E5 S6) ----------------------------------
|
# --- Language-aware column pickers (E5 S6) ----------------------------------
|
||||||
@@ -206,6 +208,33 @@ def apply_meta_filters(q, model, p: dict, lang: str = "en"):
|
|||||||
return q.where(*conds) if conds else q
|
return q.where(*conds) if conds else q
|
||||||
|
|
||||||
|
|
||||||
|
# --- Playback-class filter (E7) — MOVIE-ONLY (a show is an aggregate with no single class) ---------
|
||||||
|
# The 4 classes the feed filter + facet expose, derived from the stored `playable` + `transcode_hw`.
|
||||||
|
# ONE definition (this SQL CASE) so the filter WHERE and the facet counts can't drift: `playable_conds`
|
||||||
|
# filters on it, `facets` groups by it. transcode_hw IS NOT True groups NULL (unprobed) with software.
|
||||||
|
# Public — it is the shared source of truth imported by routes.plex.browse (facet order + zero-fill).
|
||||||
|
PLAYBACK_CLASSES = ("direct", "remux", "transcode_hw", "transcode_sw")
|
||||||
|
|
||||||
|
|
||||||
|
def playback_class_expr():
|
||||||
|
"""SQL CASE mapping a PlexItem (movie) to its playback-class string, or NULL. The HW branch must
|
||||||
|
precede the generic transcode branch (ordered evaluation)."""
|
||||||
|
return case(
|
||||||
|
(PlexItem.playable == "direct", "direct"),
|
||||||
|
(PlexItem.playable == "remux", "remux"),
|
||||||
|
(and_(PlexItem.playable == "transcode", PlexItem.transcode_hw.is_(True)), "transcode_hw"),
|
||||||
|
(PlexItem.playable == "transcode", "transcode_sw"),
|
||||||
|
else_=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def playable_conds(sel: list[str]) -> list:
|
||||||
|
"""A one-element condition list (or empty) selecting the given playback classes — an OR via
|
||||||
|
`class IN (...)` over the shared CASE. Unknown keys are dropped."""
|
||||||
|
keep = [c for c in sel if c in PLAYBACK_CLASSES]
|
||||||
|
return [playback_class_expr().in_(keep)] if keep else []
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class LibraryFilters:
|
class LibraryFilters:
|
||||||
"""The shared sidebar filter query-params for /library + /facets, declared once and injected via
|
"""The shared sidebar filter query-params for /library + /facets, declared once and injected via
|
||||||
@@ -228,6 +257,7 @@ class LibraryFilters:
|
|||||||
keywords: str | None = None # TMDB keyword chips (E5 S5), CSV; language-neutral
|
keywords: str | None = None # TMDB keyword chips (E5 S5), CSV; language-neutral
|
||||||
collections: str | None = None # CSV of collection rating_keys
|
collections: str | None = None # CSV of collection rating_keys
|
||||||
collection_mode: str = "any" # how multiple collections combine: any (OR, default) | all (AND)
|
collection_mode: str = "any" # how multiple collections combine: any (OR, default) | all (AND)
|
||||||
|
playable: str | None = None # CSV of playback classes: direct|remux|transcode_hw|transcode_sw
|
||||||
|
|
||||||
def as_dict(self) -> dict:
|
def as_dict(self) -> dict:
|
||||||
return {
|
return {
|
||||||
@@ -247,6 +277,7 @@ class LibraryFilters:
|
|||||||
"keywords": self.keywords,
|
"keywords": self.keywords,
|
||||||
"collections": self.collections,
|
"collections": self.collections,
|
||||||
"collection_mode": self.collection_mode,
|
"collection_mode": self.collection_mode,
|
||||||
|
"playable": self.playable,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+418
-41
@@ -3,7 +3,10 @@
|
|||||||
Playback is from the LOCAL physical file. Browser-compatible files (playable="direct") are served
|
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
|
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
|
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`
|
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,
|
(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 logging
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -27,8 +31,10 @@ from sqlalchemy.orm import Session as DbSession
|
|||||||
|
|
||||||
from app import sysconfig
|
from app import sysconfig
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
from app.downloads.edit import encode_thread_cap
|
||||||
from app.models import PlexItem
|
from app.models import PlexItem
|
||||||
from app.plex import paths
|
from app.plex import paths
|
||||||
|
from app.plex.sync import HW_DECODABLE_CODECS
|
||||||
|
|
||||||
log = logging.getLogger("siftlode.plex")
|
log = logging.getLogger("siftlode.plex")
|
||||||
|
|
||||||
@@ -83,11 +89,22 @@ _sessions: dict[str, "HlsSession"] = {}
|
|||||||
|
|
||||||
class HlsSession:
|
class HlsSession:
|
||||||
def __init__(
|
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,
|
||||||
|
tonemap: bool = False,
|
||||||
):
|
):
|
||||||
self.key = key
|
self.key = key
|
||||||
self.dir = directory
|
self.dir = directory
|
||||||
self.proc = proc
|
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.tonemap = tonemap # HW HDR/10-bit path: OpenCL tonemap between VAAPI decode and encode
|
||||||
self.start_s = start_s # the REQUESTED seek offset
|
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
|
# 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).
|
# keyframe K<=X, and hls.js zero-bases the timeline to K (so video.currentTime=0 is K, not X).
|
||||||
@@ -127,6 +144,269 @@ _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. The encoder + tonemap are chosen per-session by `_select_encode`:
|
||||||
|
# * 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"
|
||||||
|
# Where an OpenCL ICD registers its driver. The Intel NEO runtime (intel-opencl-icd) drops an .icd file
|
||||||
|
# here; its presence is the precondition for the HW HDR→SDR tonemap graph (see _opencl_present).
|
||||||
|
_OPENCL_ICD_DIR = Path("/etc/OpenCL/vendors")
|
||||||
|
# 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 it (AV1, MPEG-4 ASP/XviD…) falls back to software even with
|
||||||
|
# the render node present, because the GPU can't decode it to feed its own encoder. THE single source is
|
||||||
|
# plex.sync, so the runtime encoder choice here and the catalog `transcode_hw` facet can't disagree by
|
||||||
|
# codec; aliased to the old name to keep the call sites below unchanged.
|
||||||
|
_HW_DECODABLE = HW_DECODABLE_CODECS
|
||||||
|
# Color-transfer values that mark a true-HDR source (PQ / HLG). A HW-decodable HDR source can't take the
|
||||||
|
# plain scale_vaapi=format=nv12 path (Gen 9.5 VPP refuses it) — it needs the OpenCL tonemap graph
|
||||||
|
# (_venc_opts tonemap branch) when the OpenCL runtime is present, else software libx264. A 10-bit source
|
||||||
|
# WITHOUT one of these transfers is SDR, not HDR, and must not be tone-mapped. See _probe_video_kind.
|
||||||
|
_HDR_TRANSFERS = {"smpte2084", "arib-std-b67"}
|
||||||
|
|
||||||
|
|
||||||
|
# The three source classes that drive the transcode-pipeline choice (see _probe_video_kind).
|
||||||
|
_VK_HWSAFE = "hwsafe" # 8-bit SDR — the plain scale_vaapi=format=nv12 GPU path
|
||||||
|
_VK_HDR = "hdr" # PQ/HLG HDR — needs the OpenCL tonemap graph (or software if no runtime)
|
||||||
|
_VK_SDR10 = "sdr10" # 10-bit *SDR* — a plain 10→8-bit downconvert, NEVER the HDR tone curve
|
||||||
|
|
||||||
|
|
||||||
|
def _probe_video_kind(src: Path) -> str:
|
||||||
|
"""Classify the source for the transcode pipeline — one of `_VK_HWSAFE` / `_VK_HDR` / `_VK_SDR10`:
|
||||||
|
|
||||||
|
* `_VK_HWSAFE` — 8-bit SDR: safe for the plain VAAPI path (`scale_vaapi=format=nv12` + h264_vaapi).
|
||||||
|
* `_VK_HDR` — PQ/HLG HDR (any depth): the plain scale path breaks on Gen 9.5 VPP, so it needs the
|
||||||
|
OpenCL tonemap graph (`tonemap_opencl=hable`) when the runtime is present, else software libx264.
|
||||||
|
* `_VK_SDR10` — 10-bit but *SDR* (bt709 etc.): NOT HDR. Applying the HDR hable tone curve here would
|
||||||
|
visibly alter an SDR picture (crushed highlights/midtones) where only a 10→8-bit downconvert was
|
||||||
|
needed, so it must NOT take the tonemap graph — it goes to software libx264, which CPU-decodes and
|
||||||
|
downconverts to 8-bit yuv420p exactly. (Kept off the plain scale_vaapi 10-bit path too: that path
|
||||||
|
is unverified on Gen 9.5 VPP, and software is the known-correct downconvert.)
|
||||||
|
|
||||||
|
Ground truth from ffprobe (bit depth via pix_fmt / bits_per_raw_sample, HDR via color_transfer); the
|
||||||
|
Plex mirror doesn't store these. Returns `_VK_HWSAFE` on any probe failure: the runtime HW→SW fallback
|
||||||
|
in start_session backstops a HW session that still produces nothing, so a probe miss only costs the
|
||||||
|
rare wasted first attempt, never a stranded item."""
|
||||||
|
try:
|
||||||
|
out = subprocess.run(
|
||||||
|
[
|
||||||
|
"ffprobe",
|
||||||
|
"-v",
|
||||||
|
"error",
|
||||||
|
"-select_streams",
|
||||||
|
"v:0",
|
||||||
|
"-show_entries",
|
||||||
|
"stream=pix_fmt,bits_per_raw_sample,color_transfer",
|
||||||
|
"-of",
|
||||||
|
"default=noprint_wrappers=1",
|
||||||
|
str(src),
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
except (subprocess.SubprocessError, OSError):
|
||||||
|
return _VK_HWSAFE
|
||||||
|
pix_fmt = bits = transfer = ""
|
||||||
|
for line in out.stdout.splitlines():
|
||||||
|
k, _, v = line.strip().partition("=")
|
||||||
|
v = v.strip().lower()
|
||||||
|
if k == "pix_fmt":
|
||||||
|
pix_fmt = v
|
||||||
|
elif k == "bits_per_raw_sample":
|
||||||
|
bits = v
|
||||||
|
elif k == "color_transfer":
|
||||||
|
transfer = v
|
||||||
|
if transfer in _HDR_TRANSFERS:
|
||||||
|
return _VK_HDR # PQ/HLG — needs a real HDR→SDR tonemap, not a plain downconvert
|
||||||
|
# >8-bit but not HDR = 10-bit SDR. bits_per_raw_sample OR the pix_fmt bit-depth suffix
|
||||||
|
# (yuv420p10le, p010le, yuv444p12le…) signals >8-bit even when the other is absent.
|
||||||
|
if (bits.isdigit() and int(bits) > 8) or re.search(r"(?:10|12|16)(?:le|be)$", pix_fmt):
|
||||||
|
return _VK_SDR10
|
||||||
|
return _VK_HWSAFE
|
||||||
|
|
||||||
|
|
||||||
|
def _opencl_present() -> bool:
|
||||||
|
"""Whether an OpenCL runtime (Intel NEO / intel-opencl-icd) is installed — the precondition for the
|
||||||
|
HW HDR→SDR tonemap graph. Gen 9.5 VPP can't tonemap natively (tonemap_vaapi fails), so the working
|
||||||
|
HW path for a 10-bit/HDR source is VAAPI-decode → OpenCL tonemap → VAAPI-encode, which needs an ICD
|
||||||
|
registered here. Absent on the dev box and until the image bakes NEO in → those sources take
|
||||||
|
software libx264. Its own function so it's monkeypatchable in tests, mirroring _render_node_present."""
|
||||||
|
try:
|
||||||
|
return any(_OPENCL_ICD_DIR.glob("*.icd"))
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _hwaccel_in_opts(hw: bool, tonemap: bool = False) -> 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.
|
||||||
|
|
||||||
|
The tonemap variant additionally derives an OpenCL device FROM the VAAPI one (`opencl=ocl@va`) so
|
||||||
|
tonemap_opencl can map the decoded surfaces zero-copy (see _venc_opts); it needs named devices, so it
|
||||||
|
uses `-init_hw_device` + `-hwaccel_device` rather than the plain path's implicit `-vaapi_device`."""
|
||||||
|
if not hw:
|
||||||
|
return []
|
||||||
|
if tonemap:
|
||||||
|
return [
|
||||||
|
"-init_hw_device",
|
||||||
|
f"vaapi=va:{_VAAPI_DEVICE}",
|
||||||
|
"-init_hw_device",
|
||||||
|
"opencl=ocl@va",
|
||||||
|
"-hwaccel",
|
||||||
|
"vaapi",
|
||||||
|
"-hwaccel_output_format",
|
||||||
|
"vaapi",
|
||||||
|
"-hwaccel_device",
|
||||||
|
"va",
|
||||||
|
]
|
||||||
|
return ["-hwaccel", "vaapi", "-hwaccel_output_format", "vaapi", "-vaapi_device", _VAAPI_DEVICE]
|
||||||
|
|
||||||
|
|
||||||
|
def _venc_opts(
|
||||||
|
transcode: bool,
|
||||||
|
hw: bool,
|
||||||
|
tonemap: bool = False,
|
||||||
|
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 and tonemap:
|
||||||
|
# HDR/10-bit HW path: Gen 9.5 VPP can't tonemap (scale_vaapi=format=nv12 fails), so the decoded
|
||||||
|
# VAAPI surfaces are mapped to OpenCL (hwmap=derive_device=opencl), tone-mapped HDR→SDR to 8-bit
|
||||||
|
# nv12 there (tonemap_opencl, hable curve, bt709 out), then mapped back to VAAPI (reverse=1) for
|
||||||
|
# h264_vaapi. `-filter_hw_device ocl` tells the filter graph which device to run the OpenCL
|
||||||
|
# filters on. Full GPU pipeline — ~1 CPU core, several×realtime on the UHD 630 (measured).
|
||||||
|
return [
|
||||||
|
"-filter_hw_device",
|
||||||
|
"ocl",
|
||||||
|
"-vf",
|
||||||
|
"hwmap=derive_device=opencl,"
|
||||||
|
"tonemap_opencl=tonemap=hable:transfer=bt709:matrix=bt709:primaries=bt709:format=nv12,"
|
||||||
|
"hwmap=derive_device=vaapi:reverse=1",
|
||||||
|
"-c:v",
|
||||||
|
"h264_vaapi",
|
||||||
|
*force_kf,
|
||||||
|
]
|
||||||
|
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 _select_encode(db: DbSession, item: PlexItem, transcode: bool, src: Path) -> tuple[bool, bool]:
|
||||||
|
"""Pick the transcode video pipeline for this session — returns `(hw, tonemap)`:
|
||||||
|
* `(False, False)` → software libx264 (the fallback: no render node, non-decodable source, or a
|
||||||
|
10-bit/HDR source with no OpenCL runtime to tonemap on the GPU).
|
||||||
|
* `(True, False)` → full-GPU VAAPI (`scale_vaapi=format=nv12` + `h264_vaapi`) — an 8-bit SDR source.
|
||||||
|
* `(True, True)` → VAAPI decode → OpenCL tonemap → VAAPI encode — a true-HDR (PQ/HLG) source, when
|
||||||
|
the OpenCL runtime is present (Gen 9.5 VPP can't tonemap natively).
|
||||||
|
|
||||||
|
A 10-bit *SDR* source is deliberately NOT a `(True, True)` case: it needs a plain 10→8-bit downconvert,
|
||||||
|
not the HDR tone curve (which would visibly darken/flatten an SDR picture), so it drops to software.
|
||||||
|
|
||||||
|
Honours the admin `plex_transcode_hw` policy: "off" → always software; "on" → HW wherever the render
|
||||||
|
node exists (trusting the admin, skipping the decodability gate — the runtime HW→SW fallback still
|
||||||
|
backstops a session that produces nothing — but still routing a true-HDR source through the tonemap
|
||||||
|
graph so it doesn't hit the scale_vaapi wall); "auto" (default) → HW only when the render node is
|
||||||
|
present AND the iGPU can hardware-decode the source, with tonemap chosen only for a true-HDR (PQ/HLG)
|
||||||
|
source that has an OpenCL runtime, while a 10-bit SDR source drops to software."""
|
||||||
|
if not transcode:
|
||||||
|
return (False, False)
|
||||||
|
mode = (sysconfig.get_str(db, "plex_transcode_hw") or "auto").lower()
|
||||||
|
if mode == "off" or not _render_node_present():
|
||||||
|
return (False, False)
|
||||||
|
if mode == "on":
|
||||||
|
# Trust the admin: HW wherever the render node exists, skipping the decodability gate (the runtime
|
||||||
|
# backstop catches a miss). Only a true-HDR (PQ/HLG) source needs the tonemap graph — a 10-bit SDR
|
||||||
|
# source takes the plain scale path here, NOT the HDR tone curve. Probe only when OpenCL exists
|
||||||
|
# (short-circuit), so "on" stays probe-free where tonemap can't apply anyway.
|
||||||
|
tonemap = _opencl_present() and _probe_video_kind(src) == _VK_HDR
|
||||||
|
return (True, tonemap)
|
||||||
|
# auto
|
||||||
|
if (item.codec_video or "").lower() not in _HW_DECODABLE:
|
||||||
|
return (False, False)
|
||||||
|
kind = _probe_video_kind(src)
|
||||||
|
if kind == _VK_HWSAFE:
|
||||||
|
return (True, False) # 8-bit SDR → full-GPU scale path
|
||||||
|
if kind == _VK_HDR:
|
||||||
|
# PQ/HLG: HW OpenCL tonemap when the runtime is present, else software libx264.
|
||||||
|
return (True, True) if _opencl_present() else (False, False)
|
||||||
|
# _VK_SDR10 — 10-bit SDR: a plain 10→8-bit downconvert, done exactly on the CPU. Never the HDR tone
|
||||||
|
# curve (it would alter an SDR picture), and off the unverified scale_vaapi 10-bit path — software.
|
||||||
|
return (False, False)
|
||||||
|
|
||||||
|
|
||||||
|
def _want_hw(db: DbSession, item: PlexItem, transcode: bool, src: Path) -> bool:
|
||||||
|
"""The HW gate on its own (the `hw` half of `_select_encode`). A readability shim for the tests that
|
||||||
|
assert only whether a session runs on the GPU (production decides via `_select_encode` directly)."""
|
||||||
|
return _select_encode(db, item, transcode, src)[0]
|
||||||
|
|
||||||
|
|
||||||
|
# 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]:
|
def _probe_audio_langs(src: Path) -> list[str]:
|
||||||
"""One entry per audio stream (its language tag, or "" if untagged). len() = audio-track count.
|
"""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."""
|
Used to build the multi-rendition var_stream_map. Empty on probe failure → single-audio path."""
|
||||||
@@ -160,8 +440,19 @@ def _ffmpeg_cmd(
|
|||||||
audio_ord: int | None,
|
audio_ord: int | None,
|
||||||
aoff: float = 0.0,
|
aoff: float = 0.0,
|
||||||
audio_langs: list[str] | None = None,
|
audio_langs: list[str] | None = None,
|
||||||
|
transcode: bool = False,
|
||||||
|
hw: bool = False,
|
||||||
|
tonemap: bool = False,
|
||||||
|
preset: str = "veryfast",
|
||||||
|
crf: int = 20,
|
||||||
|
max_height: int = 0,
|
||||||
) -> list[str]:
|
) -> 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. `tonemap` is the HDR/10-bit HW variant (OpenCL tonemap between VAAPI decode and
|
||||||
|
# encode) — it needs matching decode-side (named devices) and encode-side (the OpenCL filter graph) opts.
|
||||||
|
venc = _venc_opts(transcode, hw, tonemap, preset, crf, max_height)
|
||||||
|
args = ["ffmpeg", "-nostdin", "-loglevel", "error", *_hwaccel_in_opts(hw, tonemap)]
|
||||||
# Keep ORIGINAL timestamps on the output so the first segment carries the true absolute PTS of the
|
# 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
|
# 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
|
# (see _probe_first_pts) to learn the real content offset. Without -copyts the segment PTS is
|
||||||
@@ -182,7 +473,7 @@ def _ffmpeg_cmd(
|
|||||||
args += ["-map", "0:v:0"]
|
args += ["-map", "0:v:0"]
|
||||||
for i in range(len(audio_langs)):
|
for i in range(len(audio_langs)):
|
||||||
args += ["-map", f"{ai}:a:{i}?"]
|
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"]
|
var = ["v:0,agroup:aud"]
|
||||||
for i, lang in enumerate(audio_langs):
|
for i, lang in enumerate(audio_langs):
|
||||||
entry = f"a:{i},agroup:aud,name:a{i}"
|
entry = f"a:{i},agroup:aud,name:a{i}"
|
||||||
@@ -203,7 +494,7 @@ def _ffmpeg_cmd(
|
|||||||
else:
|
else:
|
||||||
ao = audio_ord if audio_ord is not None else 0
|
ao = audio_ord if audio_ord is not None else 0
|
||||||
args += ["-map", "0:v:0", "-map", f"{ai}:a:{ao}?", "-sn"]
|
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 + [
|
args += _HLS_TAIL + [
|
||||||
"-hls_segment_filename",
|
"-hls_segment_filename",
|
||||||
str(out_dir / "seg_%d.ts"),
|
str(out_dir / "seg_%d.ts"),
|
||||||
@@ -258,12 +549,14 @@ def _kill(s: HlsSession) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def _enforce_cap(cap: int) -> None:
|
def _enforce_cap(cap: int) -> None:
|
||||||
# Caller holds _lock. Drop the least-recently-accessed sessions over the cap.
|
# Caller holds _lock. Only TRANSCODE sessions count toward plex_max_transcodes — remux
|
||||||
if len(_sessions) <= cap:
|
# (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
|
return
|
||||||
for key, s in sorted(_sessions.items(), key=lambda kv: kv[1].last_access)[
|
for key, s in sorted(tx, key=lambda kv: kv[1].last_access)[: len(tx) - cap]:
|
||||||
: len(_sessions) - cap
|
|
||||||
]:
|
|
||||||
_kill(s)
|
_kill(s)
|
||||||
_sessions.pop(key, None)
|
_sessions.pop(key, None)
|
||||||
|
|
||||||
@@ -275,11 +568,14 @@ def start_session(
|
|||||||
audio_ord: int | None = None,
|
audio_ord: int | None = None,
|
||||||
aoff: float = 0.0,
|
aoff: float = 0.0,
|
||||||
multi: bool = False,
|
multi: bool = False,
|
||||||
|
transcode: bool = False,
|
||||||
) -> HlsSession | None:
|
) -> HlsSession | None:
|
||||||
"""(Re)start the HLS remux for an item at the given offset. `multi` (item has >1 audio track) maps
|
"""(Re)start the HLS session for an item at the given offset. `transcode` (item is non-h264,
|
||||||
every audio track as an HLS rendition so the client switches audio without a restart; otherwise a
|
playable="transcode") re-encodes the video to H.264 (VAAPI or libx264) instead of stream-copying it;
|
||||||
single audio track (`audio_ord`) is muxed. Subtitles are separate WebVTT tracks (not muxed here).
|
otherwise the video is copied (remux). `multi` (item has >1 audio track) maps every audio track as an
|
||||||
Returns None if the local file can't be read."""
|
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)
|
src = paths.local_media_path(db, item.file_path)
|
||||||
if src is None:
|
if src is None:
|
||||||
return None
|
return None
|
||||||
@@ -287,34 +583,102 @@ def start_session(
|
|||||||
start_s = max(0.0, float(start_s))
|
start_s = max(0.0, float(start_s))
|
||||||
audio_langs = _probe_audio_langs(src) if multi else []
|
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
|
is_multi = len(audio_langs) > 1 # only worth a master playlist when there really are ≥2 tracks
|
||||||
with _lock:
|
tag = "multi" if is_multi else str(audio_ord)
|
||||||
old = _sessions.pop(key, None)
|
directory = _HLS_ROOT / f"{key}_{int(start_s)}_{tag}_{aoff:+.2f}"
|
||||||
if old is not None:
|
|
||||||
_kill(old)
|
|
||||||
tag = "multi" if is_multi else str(audio_ord)
|
|
||||||
directory = _HLS_ROOT / f"{key}_{int(start_s)}_{tag}_{aoff:+.2f}"
|
|
||||||
shutil.rmtree(directory, ignore_errors=True)
|
|
||||||
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
|
|
||||||
),
|
|
||||||
stdout=subprocess.DEVNULL,
|
|
||||||
stderr=subprocess.DEVNULL,
|
|
||||||
)
|
|
||||||
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.
|
|
||||||
_enforce_cap(max(1, sysconfig.get_int(db, "plex_max_transcodes")))
|
|
||||||
|
|
||||||
# Wait for the first video segment, then warm up a few more — all outside the lock (this blocks on
|
|
||||||
# ffmpeg producing them, and we must not hold up other sessions).
|
|
||||||
def _seg(i: int) -> Path:
|
def _seg(i: int) -> Path:
|
||||||
# multi-audio: the video rendition is variant v0 → seg_{i}_0.ts; single-audio → seg_{i}.ts
|
# multi-audio: the video rendition is variant v0 → seg_{i}_0.ts; single-audio → seg_{i}.ts
|
||||||
return directory / (f"seg_{i}_0.ts" if is_multi else f"seg_{i}.ts")
|
return directory / (f"seg_{i}_0.ts" if is_multi else f"seg_{i}.ts")
|
||||||
|
|
||||||
if wait_for(_seg(0), timeout=25.0):
|
def _spawn(
|
||||||
|
hw: bool, tonemap: bool = False, *, replace_only: "HlsSession | None" = None
|
||||||
|
) -> "HlsSession | None":
|
||||||
|
# `replace_only` makes the supersede identity-aware for the runtime fallback: the whole
|
||||||
|
# check-and-replace happens under ONE _lock, so a newer same-key session (a concurrent
|
||||||
|
# seek-restart) installed between the fallback's decision and here is never clobbered. When the
|
||||||
|
# registered session is no longer `replace_only`, we abort and return None (the caller yields to
|
||||||
|
# whoever is registered now). The INITIAL spawn passes replace_only=None and always supersedes —
|
||||||
|
# that is the seek-restart's intended last-writer-wins.
|
||||||
|
# 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")
|
||||||
|
# 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:
|
||||||
|
if replace_only is not None and _sessions.get(key) is not replace_only:
|
||||||
|
return None # superseded/culled while we waited out of lock — don't clobber the newer one
|
||||||
|
old = _sessions.pop(key, None)
|
||||||
|
if old is not None:
|
||||||
|
_kill(old)
|
||||||
|
shutil.rmtree(directory, ignore_errors=True)
|
||||||
|
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,
|
||||||
|
transcode=transcode,
|
||||||
|
hw=hw,
|
||||||
|
tonemap=tonemap,
|
||||||
|
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,
|
||||||
|
tonemap=tonemap,
|
||||||
|
)
|
||||||
|
_sessions[key] = s
|
||||||
|
# Admin-configurable concurrency cap (Configuration → Plex → Max concurrent transcodes);
|
||||||
|
# at least 1 so playback can't be capped to zero.
|
||||||
|
_enforce_cap(max(1, sysconfig.get_int(db, "plex_max_transcodes")))
|
||||||
|
return s
|
||||||
|
|
||||||
|
s = _spawn(*_select_encode(db, item, transcode, src))
|
||||||
|
# Wait for the first video segment outside the lock (this blocks on ffmpeg, and we must not hold up
|
||||||
|
# other sessions). Pass the proc so a HW session that dies at VAAPI init fails fast (not after 25s).
|
||||||
|
ready = wait_for(_seg(0), timeout=25.0, proc=s.proc)
|
||||||
|
if not ready and s.hw:
|
||||||
|
# We waited on _seg(0) OUTSIDE _lock, so `s.proc` dying is ambiguous: it may have failed on its
|
||||||
|
# own (VAProfile/init/tonemap refusal), OR a concurrent same-key _spawn (a rapid seek-restart) or
|
||||||
|
# _enforce_cap may have killed it to install/keep a newer session. Respawn on libx264 (no HW, no
|
||||||
|
# tonemap) so a genuinely-failed GPU session never strands playback — but pass `replace_only=s` so
|
||||||
|
# the respawn is a no-op if we've been superseded: the identity check and the pop+replace share
|
||||||
|
# ONE lock in _spawn, so we can never tear down the newer session. s.hw is now False, so the route
|
||||||
|
# honestly reports "CPU" and the player's Preparing-stream state stays correct.
|
||||||
|
log.warning(
|
||||||
|
"plex transcode: HW session produced no segments — falling back to software key=%s", key
|
||||||
|
)
|
||||||
|
respawned = _spawn(False, False, replace_only=s)
|
||||||
|
if respawned is None:
|
||||||
|
# Superseded/culled mid-fallback — yield whoever is registered now (a newer seek-restart), or
|
||||||
|
# our own now-dead session if the key was cap-culled. Never None here: the POST /session route
|
||||||
|
# turns a None return into a "Media file not found on disk" 404 — dishonest for a file that
|
||||||
|
# exists. A (dead) session instead degrades to a retryable 404 ("No active playback session" /
|
||||||
|
# "Not available yet") the client re-requests.
|
||||||
|
return current_session(key) or s
|
||||||
|
s = respawned
|
||||||
|
ready = wait_for(_seg(0), timeout=25.0, proc=s.proc)
|
||||||
|
if ready:
|
||||||
# For a seek (start_s>0) learn the REAL start offset K from the first segment's absolute PTS,
|
# For a seek (start_s>0) learn the REAL start offset K from the first segment's absolute PTS,
|
||||||
# so the client's absolute clock + subtitle shift key off the true keyframe, not the requested
|
# so the client's absolute clock + subtitle shift key off the true keyframe, not the requested
|
||||||
# offset. On probe failure we keep the provisional requested offset (old behaviour, small drift).
|
# offset. On probe failure we keep the provisional requested offset (old behaviour, small drift).
|
||||||
@@ -326,15 +690,18 @@ def start_session(
|
|||||||
# at the first segment that isn't ready in time — ffmpeg produces them faster than realtime once
|
# at the first segment that isn't ready in time — ffmpeg produces them faster than realtime once
|
||||||
# warm, so a miss means something slow and we proceed rather than block the request further.
|
# warm, so a miss means something slow and we proceed rather than block the request further.
|
||||||
for i in range(1, _WARMUP_SEGMENTS):
|
for i in range(1, _WARMUP_SEGMENTS):
|
||||||
if not wait_for(_seg(i), timeout=_WARMUP_SEG_TIMEOUT_S):
|
if not wait_for(_seg(i), timeout=_WARMUP_SEG_TIMEOUT_S, proc=s.proc):
|
||||||
break
|
break
|
||||||
log.info(
|
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 tonemap=%s",
|
||||||
key,
|
key,
|
||||||
start_s,
|
start_s,
|
||||||
s.media_start_s,
|
s.media_start_s,
|
||||||
is_multi,
|
is_multi,
|
||||||
audio_ord,
|
audio_ord,
|
||||||
|
transcode,
|
||||||
|
s.hw,
|
||||||
|
s.tonemap,
|
||||||
)
|
)
|
||||||
return s
|
return s
|
||||||
|
|
||||||
@@ -347,10 +714,14 @@ def current_session(key: str) -> HlsSession | None:
|
|||||||
return s
|
return s
|
||||||
|
|
||||||
|
|
||||||
def wait_for(path: Path, timeout: float = 20.0) -> bool:
|
def wait_for(path: Path, timeout: float = 20.0, proc: subprocess.Popen | None = None) -> bool:
|
||||||
"""Wait until a session file (playlist / segment) exists and is non-empty. Segments are
|
"""Wait until a session file (playlist / segment) exists and is non-empty. Segments are
|
||||||
produced ~faster than realtime, so a segment just ahead of playback appears quickly; a segment
|
produced ~faster than realtime, so a segment just ahead of playback appears quickly; a segment
|
||||||
far beyond the generated region won't (the frontend restarts the session at a seek instead)."""
|
far beyond the generated region won't (the frontend restarts the session at a seek instead).
|
||||||
|
|
||||||
|
With `proc` given, also stop as soon as that ffmpeg exits: a VAAPI session that fails to init
|
||||||
|
(10-bit/HDR VAProfile refusal) dies within ~1s producing nothing, and without this we'd block the
|
||||||
|
whole `timeout` before the HW→SW fallback could kick in — turning the fix into a stall."""
|
||||||
end = time.time() + timeout
|
end = time.time() + timeout
|
||||||
while time.time() < end:
|
while time.time() < end:
|
||||||
try:
|
try:
|
||||||
@@ -358,6 +729,12 @@ def wait_for(path: Path, timeout: float = 20.0) -> bool:
|
|||||||
return True
|
return True
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
|
if proc is not None and proc.poll() is not None:
|
||||||
|
# the producer exited — no more segments are coming; one last check then give up.
|
||||||
|
try:
|
||||||
|
return path.exists() and path.stat().st_size > 0
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
time.sleep(0.15)
|
time.sleep(0.15)
|
||||||
return path.exists()
|
return path.exists()
|
||||||
|
|
||||||
|
|||||||
+130
-3
@@ -18,7 +18,7 @@ item-detail/player path (P2), not here.
|
|||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app import sysconfig
|
from app import sysconfig
|
||||||
@@ -58,6 +58,57 @@ def classify_playable(vcodec: str | None, acodec: str | None, container: str | N
|
|||||||
return "direct"
|
return "direct"
|
||||||
|
|
||||||
|
|
||||||
|
# Video codecs the prod iGPU (UHD 630 / Quick Sync Gen 9.5) can hardware-DECODE — the precondition for
|
||||||
|
# the full-GPU VAAPI transcode pipeline. THE single source of this set: plex.stream imports it here so
|
||||||
|
# the runtime encoder choice (`_want_hw`) and this catalog facet can never disagree by codec. A source
|
||||||
|
# outside it (AV1, MPEG-4 ASP/XviD…) is software-only even on the iGPU (it can't decode it to feed its
|
||||||
|
# own encoder).
|
||||||
|
HW_DECODABLE_CODECS = frozenset({"h264", "hevc", "h265", "vp9", "vc1", "mpeg2video"})
|
||||||
|
# Color-transfer values marking an HDR source (PQ / HLG). A 10-bit/HDR source breaks the VAAPI
|
||||||
|
# scale=nv12 path on Gen 9.5 (see plex.stream) → software transcode, so it counts as NOT HW-capable.
|
||||||
|
_HDR_TRANSFERS = frozenset({"smpte2084", "arib-std-b67"})
|
||||||
|
|
||||||
|
|
||||||
|
def transcode_hw_capable(
|
||||||
|
playable: str | None,
|
||||||
|
vcodec: str | None,
|
||||||
|
bit_depth: int | None = None,
|
||||||
|
hdr: bool = False,
|
||||||
|
) -> bool | None:
|
||||||
|
"""The stored `PlexItem.transcode_hw` facet: for a `playable="transcode"` item, whether the prod
|
||||||
|
iGPU can HARDWARE-transcode it (else software libx264 only); None for a non-transcode item. HW needs
|
||||||
|
a HW-decodable codec AND an 8-bit SDR source — a 10-bit/HDR source (bit_depth>8 or an HDR transfer)
|
||||||
|
breaks the VAAPI nv12 pipeline and falls back to software. `bit_depth`/`hdr` are unknown at sync
|
||||||
|
time (the cheap listing carries no Stream), so they default to 8-bit SDR (codec-only) and the
|
||||||
|
bit-depth backfill refines a 10-bit/HDR HEVC to False later."""
|
||||||
|
if playable != "transcode":
|
||||||
|
return None
|
||||||
|
if (vcodec or "").lower() not in HW_DECODABLE_CODECS:
|
||||||
|
return False
|
||||||
|
if bit_depth is not None and bit_depth > 8:
|
||||||
|
return False
|
||||||
|
return not hdr
|
||||||
|
|
||||||
|
|
||||||
|
def _video_stream_props(meta: dict) -> tuple[int | None, bool]:
|
||||||
|
"""(bit_depth, is_hdr) from a FULL Plex metadata dict's video Stream (Media→Part→Stream, streamType
|
||||||
|
1). Only the rich metadata call carries Stream; the cheap listing does not — so this is read by the
|
||||||
|
bit-depth backfill, not the listing sync. (None, False) when absent/unparseable."""
|
||||||
|
for m in meta.get("Media") or []:
|
||||||
|
for part in m.get("Part") or []:
|
||||||
|
for s in part.get("Stream") or []:
|
||||||
|
if s.get("streamType") != 1: # 1 = video
|
||||||
|
continue
|
||||||
|
bd = s.get("bitDepth")
|
||||||
|
try:
|
||||||
|
depth = int(bd) if bd else None
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
depth = None
|
||||||
|
hdr = str(s.get("colorTrc") or "").lower() in _HDR_TRANSFERS
|
||||||
|
return depth, hdr
|
||||||
|
return None, False
|
||||||
|
|
||||||
|
|
||||||
def _epoch(v) -> datetime | None:
|
def _epoch(v) -> datetime | None:
|
||||||
try:
|
try:
|
||||||
return datetime.fromtimestamp(int(v), tz=timezone.utc) if v else None
|
return datetime.fromtimestamp(int(v), tz=timezone.utc) if v else None
|
||||||
@@ -217,6 +268,13 @@ def sync(db: Session) -> dict:
|
|||||||
stats["people_enriched"] = _enrich_people_batch(db, plex)
|
stats["people_enriched"] = _enrich_people_batch(db, plex)
|
||||||
except Exception as e: # best-effort — never fail the sync over the backfill
|
except Exception as e: # best-effort — never fail the sync over the backfill
|
||||||
log.warning("Plex people enrich batch failed: %s", e)
|
log.warning("Plex people enrich batch failed: %s", e)
|
||||||
|
# Probe a bounded batch of HW-decodable transcode items for bit-depth/HDR, so the
|
||||||
|
# playback-class filter's HW/SW split is precise (a 10-bit/HDR HEVC → software) over
|
||||||
|
# successive runs. Only HW-decodable-codec transcodes are probed — the rest are SW by codec.
|
||||||
|
try:
|
||||||
|
stats["media_probed"] = _probe_media_batch(db, plex)
|
||||||
|
except Exception as e: # best-effort — never fail the sync over the backfill
|
||||||
|
log.warning("Plex media probe batch failed: %s", e)
|
||||||
except PlexNotConfigured as e:
|
except PlexNotConfigured as e:
|
||||||
return {"skipped": str(e)}
|
return {"skipped": str(e)}
|
||||||
except PlexError as e:
|
except PlexError as e:
|
||||||
@@ -236,6 +294,49 @@ def sync(db: Session) -> dict:
|
|||||||
# How many titles to rich-fetch per sync run for the cast backfill — bounds the extra Plex load; the
|
# How many titles to rich-fetch per sync run for the cast backfill — bounds the extra Plex load; the
|
||||||
# whole library is covered over successive runs (each fetched item is stamped people_enriched_at once).
|
# whole library is covered over successive runs (each fetched item is stamped people_enriched_at once).
|
||||||
_ENRICH_BATCH = 60
|
_ENRICH_BATCH = 60
|
||||||
|
# Same bounded-per-run pattern for the bit-depth probe. Only HW-decodable-codec transcode items are
|
||||||
|
# probed (typically a small minority — the bulk of transcodes are old codecs that are SW by codec), so
|
||||||
|
# a modest batch covers the work-list in a run or two; a stuck item retries next run (watermark unset).
|
||||||
|
_PROBE_BATCH = 60
|
||||||
|
|
||||||
|
|
||||||
|
def _probe_media_batch(db: Session, plex: PlexClient, limit: int = _PROBE_BATCH) -> int:
|
||||||
|
"""Rich-fetch up to `limit` not-yet-probed HW-decodable transcode items, read the video Stream's
|
||||||
|
bit-depth + HDR, and store `video_bit_depth` + the refined `transcode_hw` (a 10-bit/HDR HEVC →
|
||||||
|
False/software). Stamps `media_probed_at` on every attempt (success OR a Plex miss with no Stream),
|
||||||
|
so an item is probed once and not re-fetched every sync; a transient fetch FAILURE leaves the
|
||||||
|
watermark unset to retry. Oldest-id first. Returns how many items were probed this run."""
|
||||||
|
rows = (
|
||||||
|
db.execute(
|
||||||
|
select(PlexItem)
|
||||||
|
.where(
|
||||||
|
PlexItem.playable == "transcode",
|
||||||
|
func.lower(PlexItem.codec_video).in_(HW_DECODABLE_CODECS),
|
||||||
|
PlexItem.media_probed_at.is_(None),
|
||||||
|
)
|
||||||
|
.order_by(PlexItem.id)
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
n = 0
|
||||||
|
for row in rows:
|
||||||
|
try:
|
||||||
|
meta = plex.metadata(row.rating_key) or {}
|
||||||
|
except Exception as e: # transient — leave media_probed_at NULL so it retries next run
|
||||||
|
log.debug("Plex media probe fetch failed for %s: %s", row.rating_key, e)
|
||||||
|
continue
|
||||||
|
bit_depth, hdr = _video_stream_props(meta)
|
||||||
|
row.video_bit_depth = bit_depth
|
||||||
|
row.transcode_hw = transcode_hw_capable("transcode", row.codec_video, bit_depth, hdr)
|
||||||
|
row.media_probed_at = (
|
||||||
|
now # probed (even if Plex carried no Stream — don't re-fetch forever)
|
||||||
|
)
|
||||||
|
n += 1
|
||||||
|
db.commit()
|
||||||
|
return n
|
||||||
|
|
||||||
|
|
||||||
def _enrich_people_batch(db: Session, plex: PlexClient, limit: int = _ENRICH_BATCH) -> int:
|
def _enrich_people_batch(db: Session, plex: PlexClient, limit: int = _ENRICH_BATCH) -> int:
|
||||||
@@ -369,15 +470,41 @@ def _apply_item(row: PlexItem, lib_id: int, kind: str, meta: dict) -> None:
|
|||||||
row.added_at = _epoch(meta.get("addedAt"))
|
row.added_at = _epoch(meta.get("addedAt"))
|
||||||
_apply_facet_fields(row, meta) # filterable/orderable extras (shared with shows)
|
_apply_facet_fields(row, meta) # filterable/orderable extras (shared with shows)
|
||||||
mf = _media_facts(meta)
|
mf = _media_facts(meta)
|
||||||
|
new_vcodec = mf.get("codec_video")
|
||||||
|
new_part_key = mf.get("part_key")
|
||||||
|
# The physical file was replaced when its video codec changes OR — for a SINGLE-version item — when its
|
||||||
|
# Plex Part key changes (a re-encode / quality upgrade gets a new part id). Either way any probed
|
||||||
|
# bit-depth is stale, so reset the probe watermark and re-derive transcode_hw from codec alone until the
|
||||||
|
# backfill re-probes. Codec alone is NOT enough: a same-codec bit-depth re-encode (8-bit→10-bit HEVC)
|
||||||
|
# keeps the codec `hevc` but flips the HW class, and only the part-key move catches it.
|
||||||
|
#
|
||||||
|
# The part-key check is gated on single-version because `_media_facts` reads media[0]/part[0], and for a
|
||||||
|
# MULTI-version item (an original + a Plex "Optimized" copy) that order isn't guaranteed stable across
|
||||||
|
# listings — an unguarded part-key compare would then reset+re-probe every sync and oscillate the
|
||||||
|
# transcode_hw bucket between the two versions. A multi-version same-codec re-encode not self-healing is
|
||||||
|
# an acceptable rare edge; a per-sync re-probe loop is not.
|
||||||
|
single_version = len(meta.get("Media") or []) <= 1
|
||||||
|
codec_changed = (row.codec_video or "").lower() != (new_vcodec or "").lower()
|
||||||
|
if codec_changed or (single_version and row.part_key != new_part_key):
|
||||||
|
row.video_bit_depth = None
|
||||||
|
row.media_probed_at = None
|
||||||
row.file_path = mf.get("file_path")
|
row.file_path = mf.get("file_path")
|
||||||
row.part_key = mf.get("part_key")
|
row.part_key = new_part_key
|
||||||
row.container = mf.get("container")
|
row.container = mf.get("container")
|
||||||
row.codec_video = mf.get("codec_video")
|
row.codec_video = new_vcodec
|
||||||
row.codec_audio = mf.get("codec_audio")
|
row.codec_audio = mf.get("codec_audio")
|
||||||
row.size_bytes = mf.get("size_bytes")
|
row.size_bytes = mf.get("size_bytes")
|
||||||
row.playable = classify_playable(
|
row.playable = classify_playable(
|
||||||
mf.get("codec_video"), mf.get("codec_audio"), mf.get("container")
|
mf.get("codec_video"), mf.get("codec_audio"), mf.get("container")
|
||||||
)
|
)
|
||||||
|
# transcode_hw: keep the bit-depth backfill's authoritative value (it folds in bit-depth + HDR) once
|
||||||
|
# probed; otherwise (re)derive the provisional codec-only class. A non-transcode item is always None,
|
||||||
|
# even if it had been probed as a transcode before (an audio/container flip to remux without a codec
|
||||||
|
# change wouldn't reset the watermark, so pin it here).
|
||||||
|
if row.playable != "transcode":
|
||||||
|
row.transcode_hw = None
|
||||||
|
elif row.media_probed_at is None:
|
||||||
|
row.transcode_hw = transcode_hw_capable("transcode", row.codec_video, row.video_bit_depth)
|
||||||
|
|
||||||
|
|
||||||
def _prune_stale(
|
def _prune_stale(
|
||||||
|
|||||||
@@ -439,15 +439,89 @@ def _repush_dirty(db: Session, plex: PlexClient, link: PlexLink) -> int:
|
|||||||
return n
|
return n
|
||||||
|
|
||||||
|
|
||||||
|
# How many stale in-progress movies we probe per incremental run (one metadata() call each). The set
|
||||||
|
# is normally tiny (movies started but no longer on Plex's Continue-Watching); the daily full reconcile
|
||||||
|
# is the backstop for anything beyond the cap. Most-recently-touched are probed first.
|
||||||
|
_MOVIE_PROBE_CAP = 100
|
||||||
|
|
||||||
|
|
||||||
|
def _reconcile_inprogress_movies(
|
||||||
|
db: Session, plex: PlexClient, link: PlexLink, on_deck_ids: set[int]
|
||||||
|
) -> dict[str, int]:
|
||||||
|
"""Close the movie "stuck in progress" gap that the history + onDeck feeds structurally miss.
|
||||||
|
|
||||||
|
When a movie is finished on Plex (often by scrubbing through the end credits) Plex sets
|
||||||
|
``viewCount>0``, clears ``viewOffset`` and DROPS the movie from Continue-Watching. So onDeck no
|
||||||
|
longer lists it (and onDeck can only ever ADD an in-progress row, never resolve one to watched —
|
||||||
|
see `_pull_apply`), and a scrub-to-end frequently emits no `/status/sessions/history` session row,
|
||||||
|
so the history feed misses it too. The stale local ``status='new', position>0`` row then survives
|
||||||
|
until the *daily* full reconcile. Movies are hit hardest: a movie is a single leaf with no
|
||||||
|
episode-rollup to mask the stuck row, and once watched it leaves onDeck entirely.
|
||||||
|
|
||||||
|
Fix: for each local in-progress MOVIE that Plex's onDeck no longer lists, read the item's current
|
||||||
|
per-(token-)account ``viewCount``/``viewOffset`` with one cheap `metadata()` call and apply it via
|
||||||
|
the shared `_pull_apply` — flipping to watched when Plex says so. Returns ``{"watched", "in_progress",
|
||||||
|
"probed"}`` counts. Episodes are intentionally left to onDeck/history (they promote the next episode
|
||||||
|
and aren't affected). Note: like the rest of this job, `metadata()` reads the TOKEN account (the
|
||||||
|
owner) — a HTPC playing under a managed Plex user (accountID>1) is out of scope here as elsewhere."""
|
||||||
|
conds = [
|
||||||
|
PlexState.user_id == link.user_id,
|
||||||
|
PlexState.status.notin_(("watched", "hidden")),
|
||||||
|
PlexState.position_seconds > _PROGRESS_MIN_S,
|
||||||
|
PlexItem.kind == "movie",
|
||||||
|
]
|
||||||
|
if on_deck_ids:
|
||||||
|
conds.append(PlexState.item_id.notin_(on_deck_ids))
|
||||||
|
rows = db.execute(
|
||||||
|
select(PlexState, PlexItem.rating_key)
|
||||||
|
.join(PlexItem, PlexItem.id == PlexState.item_id)
|
||||||
|
.where(*conds)
|
||||||
|
.order_by(PlexState.progress_updated_at.desc().nullslast())
|
||||||
|
.limit(_MOVIE_PROBE_CAP + 1)
|
||||||
|
).all()
|
||||||
|
if len(rows) > _MOVIE_PROBE_CAP:
|
||||||
|
rows = rows[:_MOVIE_PROBE_CAP]
|
||||||
|
log.warning(
|
||||||
|
"in-progress movie probe hit the %d cap (user %s); reconcile catches the tail",
|
||||||
|
_MOVIE_PROBE_CAP,
|
||||||
|
link.user_id,
|
||||||
|
)
|
||||||
|
out = {"watched": 0, "in_progress": 0, "probed": 0}
|
||||||
|
for st, rk in rows:
|
||||||
|
try:
|
||||||
|
meta = plex.metadata(rk)
|
||||||
|
except PlexError as e:
|
||||||
|
log.warning(
|
||||||
|
"movie watch-probe failed (user %s, item %s): %s", link.user_id, st.item_id, e
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
out["probed"] += 1
|
||||||
|
if meta is None:
|
||||||
|
continue
|
||||||
|
res = _plex_watch_to_state(
|
||||||
|
meta.get("viewCount"), meta.get("viewOffset"), meta.get("lastViewedAt")
|
||||||
|
)
|
||||||
|
if res is None:
|
||||||
|
# Plex holds no watch signal now (viewCount 0, no offset) — don't clear it here; an un-watch
|
||||||
|
# is the daily reconcile's job, and a Siftlode-only in-progress row must be preserved.
|
||||||
|
continue
|
||||||
|
status, pos, watched_at, prog_at = res
|
||||||
|
ch = _pull_apply(db, link.user_id, st.item_id, status, pos, watched_at or prog_at)
|
||||||
|
if ch:
|
||||||
|
out[ch] += 1
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def run_plex_watch_sync(db: Session) -> dict:
|
def run_plex_watch_sync(db: Session) -> dict:
|
||||||
"""Scheduler job (default 15 min) — the cheap incremental pass. Pulls Plex-side changes since the
|
"""Scheduler job (default 15 min) — the cheap incremental pass. Pulls Plex-side changes since the
|
||||||
high-water mark (watch history + on-deck, filtered to the owner account) into Siftlode, resolving a
|
high-water mark (watch history + on-deck, filtered to the owner account) into Siftlode, resolving a
|
||||||
divergence to Plex (the source of truth — see `_pull_apply`), then re-pushes any still-unsynced
|
divergence to Plex (the source of truth — see `_pull_apply`), reconciles stale in-progress movies
|
||||||
|
Plex has since finished (see `_reconcile_inprogress_movies`), then re-pushes any still-unsynced
|
||||||
local states. Does NOT propagate un-watches (the history feed only carries positive views) — that's
|
local states. Does NOT propagate un-watches (the history feed only carries positive views) — that's
|
||||||
`run_plex_watch_reconcile`'s job."""
|
`run_plex_watch_reconcile`'s job."""
|
||||||
if not sysconfig.get_bool(db, "plex_enabled"):
|
if not sysconfig.get_bool(db, "plex_enabled"):
|
||||||
return {"skipped": "disabled"}
|
return {"skipped": "disabled"}
|
||||||
total = {"links": 0, "watched": 0, "in_progress": 0, "repushed": 0}
|
total = {"links": 0, "watched": 0, "in_progress": 0, "repushed": 0, "movie_probes": 0}
|
||||||
for link in _active_sync_links(db):
|
for link in _active_sync_links(db):
|
||||||
total["links"] += 1
|
total["links"] += 1
|
||||||
try:
|
try:
|
||||||
@@ -467,10 +541,14 @@ def run_plex_watch_sync(db: Session) -> dict:
|
|||||||
if ch:
|
if ch:
|
||||||
total[ch] += 1
|
total[ch] += 1
|
||||||
|
|
||||||
|
# onDeck resolves resume-position changes AND tells us which items Plex still considers
|
||||||
|
# in-progress (needed by the movie reconcile below to know which local rows are stale).
|
||||||
|
on_deck_ids: set[int] = set()
|
||||||
for row in plex.on_deck():
|
for row in plex.on_deck():
|
||||||
iid = rk_to_id.get(str(row.get("ratingKey") or ""))
|
iid = rk_to_id.get(str(row.get("ratingKey") or ""))
|
||||||
if iid is None:
|
if iid is None:
|
||||||
continue
|
continue
|
||||||
|
on_deck_ids.add(iid)
|
||||||
off = int(row.get("viewOffset") or 0) // 1000
|
off = int(row.get("viewOffset") or 0) // 1000
|
||||||
if off < _PROGRESS_MIN_S:
|
if off < _PROGRESS_MIN_S:
|
||||||
continue
|
continue
|
||||||
@@ -480,6 +558,11 @@ def run_plex_watch_sync(db: Session) -> dict:
|
|||||||
if ch:
|
if ch:
|
||||||
total[ch] += 1
|
total[ch] += 1
|
||||||
|
|
||||||
|
mv = _reconcile_inprogress_movies(db, plex, link, on_deck_ids)
|
||||||
|
total["watched"] += mv["watched"]
|
||||||
|
total["in_progress"] += mv["in_progress"]
|
||||||
|
total["movie_probes"] += mv["probed"]
|
||||||
|
|
||||||
total["repushed"] += _repush_dirty(db, plex, link)
|
total["repushed"] += _repush_dirty(db, plex, link)
|
||||||
link.last_watch_sync_at = now
|
link.last_watch_sync_at = now
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|||||||
@@ -6,23 +6,28 @@ 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.
|
serves finished files (range-aware, with the user's custom display name), and shares them.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
|
import tempfile
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
from urllib.parse import parse_qs, urlparse
|
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 fastapi.responses import FileResponse
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app import sysconfig
|
from app import audit, sysconfig
|
||||||
from app.auth import admin_user, require_human
|
from app.auth import admin_user, require_human
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
|
from app.downloads import credentials as dlcreds
|
||||||
from app.downloads import edit as editmod
|
from app.downloads import edit as editmod
|
||||||
from app.downloads import links as linksmod
|
from app.downloads import links as linksmod
|
||||||
from app.downloads import quota, service, storage
|
from app.downloads import quota, service, storage
|
||||||
|
from app.downloads import upload as uploadmod
|
||||||
from app.models import (
|
from app.models import (
|
||||||
DownloadJob,
|
DownloadJob,
|
||||||
DownloadLink,
|
DownloadLink,
|
||||||
@@ -122,6 +127,9 @@ def _serialize(job: DownloadJob, asset: MediaAsset | None) -> dict:
|
|||||||
"width": asset.width,
|
"width": asset.width,
|
||||||
"height": asset.height,
|
"height": asset.height,
|
||||||
"container": asset.container,
|
"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,
|
"uploader_url": asset.uploader_url,
|
||||||
"thumbnail_url": asset.thumbnail_url,
|
"thumbnail_url": asset.thumbnail_url,
|
||||||
"expires_at": iso(asset.expires_at),
|
"expires_at": iso(asset.expires_at),
|
||||||
@@ -247,6 +255,65 @@ def delete_profile(
|
|||||||
return {"deleted": profile_id}
|
return {"deleted": profile_id}
|
||||||
|
|
||||||
|
|
||||||
|
# --- per-user download auth (cookies) ------------------------------------------------------
|
||||||
|
# Write-only surface: the stored blob is NEVER returned — only the supported hosts + whether each
|
||||||
|
# is configured + when. See downloads.credentials for the host-scope + cache-privacy guarantees.
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/credentials")
|
||||||
|
def list_credentials(user: User = Depends(require_human), db: Session = Depends(get_db)) -> dict:
|
||||||
|
return {"hosts": dlcreds.status_list(db, user.id)}
|
||||||
|
|
||||||
|
|
||||||
|
class SetCredentialIn(BaseModel):
|
||||||
|
cookies: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/credentials/{host_key}")
|
||||||
|
def set_credential(
|
||||||
|
host_key: str,
|
||||||
|
body: SetCredentialIn,
|
||||||
|
user: User = Depends(require_human),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
"""Store (encrypted) this user's cookies.txt for one supported host. Write-only: the value is
|
||||||
|
never echoed back, logged, or audited — only the fact that it changed."""
|
||||||
|
if not dlcreds.is_supported_host(host_key):
|
||||||
|
raise HTTPException(status_code=404, detail="Unknown host")
|
||||||
|
try:
|
||||||
|
cleaned = dlcreds.validate_cookies(body.cookies)
|
||||||
|
except dlcreds.InvalidCookies as e:
|
||||||
|
raise HTTPException(status_code=400, detail={"code": "cookies", "reason": str(e)})
|
||||||
|
dlcreds.upsert_credential(db, user.id, host_key, cleaned)
|
||||||
|
audit.record(
|
||||||
|
db,
|
||||||
|
user.id,
|
||||||
|
audit.AuditAction.DOWNLOAD_CREDENTIAL_SET,
|
||||||
|
target_type="download_credential",
|
||||||
|
target_id=host_key,
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True, "hosts": dlcreds.status_list(db, user.id)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/credentials/{host_key}")
|
||||||
|
def delete_credential(
|
||||||
|
host_key: str, user: User = Depends(require_human), db: Session = Depends(get_db)
|
||||||
|
) -> dict:
|
||||||
|
if not dlcreds.is_supported_host(host_key):
|
||||||
|
raise HTTPException(status_code=404, detail="Unknown host")
|
||||||
|
if dlcreds.delete_credential(db, user.id, host_key):
|
||||||
|
audit.record(
|
||||||
|
db,
|
||||||
|
user.id,
|
||||||
|
audit.AuditAction.DOWNLOAD_CREDENTIAL_DELETE,
|
||||||
|
target_type="download_credential",
|
||||||
|
target_id=host_key,
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True, "hosts": dlcreds.status_list(db, user.id)}
|
||||||
|
|
||||||
|
|
||||||
def _resolve_spec(
|
def _resolve_spec(
|
||||||
db: Session, user: User, profile_id: int | None, spec: dict | None
|
db: Session, user: User, profile_id: int | None, spec: dict | None
|
||||||
) -> tuple[dict, int | None]:
|
) -> tuple[dict, int | None]:
|
||||||
@@ -294,6 +361,61 @@ def enqueue_download(
|
|||||||
return _serialize_job(db, job)
|
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):
|
class EnqueueEditIn(BaseModel):
|
||||||
source_job_id: int = 0
|
source_job_id: int = 0
|
||||||
edit_spec: dict
|
edit_spec: dict
|
||||||
@@ -484,8 +606,10 @@ def redownload_download(
|
|||||||
never deletes a file another job/user still shares: the old asset is only removed if THIS job
|
never deletes a file another job/user still shares: the old asset is only removed if THIS job
|
||||||
was its last holder."""
|
was its last holder."""
|
||||||
job = _own_job(db, user, job_id)
|
job = _own_job(db, user, job_id)
|
||||||
if job.job_kind == "edit":
|
if job.job_kind in ("edit", "upload"):
|
||||||
raise HTTPException(status_code=409, detail="Editor clips can't be re-downloaded.")
|
# 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"):
|
if job.status in ("queued", "running", "paused"):
|
||||||
raise HTTPException(status_code=409, detail="This download is still in progress.")
|
raise HTTPException(status_code=409, detail="This download is still in progress.")
|
||||||
service.redownload(db, job)
|
service.redownload(db, job)
|
||||||
@@ -550,8 +674,21 @@ def download_file(
|
|||||||
|
|
||||||
asset.last_access_at = datetime.now(timezone.utc)
|
asset.last_access_at = datetime.now(timezone.utc)
|
||||||
db.commit()
|
db.commit()
|
||||||
# Starlette's FileResponse honours the Range header (206 partial content) for seek/resume.
|
# The served Content-Type comes from the ffprobe-verified media_kind + a container allow-list
|
||||||
return FileResponse(path, filename=filename)
|
# (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) --------------------------------------------------------
|
# --- editor filmstrip (scrub track) --------------------------------------------------------
|
||||||
@@ -763,7 +900,12 @@ def list_links(
|
|||||||
|
|
||||||
class CreateLinkIn(BaseModel):
|
class CreateLinkIn(BaseModel):
|
||||||
password: str = ""
|
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
|
expires_days: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ from app.db import get_db
|
|||||||
from app.feedquery import feed_columns, serialize
|
from app.feedquery import feed_columns, serialize
|
||||||
from app.models import Channel, Playlist, PlaylistItem, User, Video, VideoState
|
from app.models import Channel, Playlist, PlaylistItem, User, Video, VideoState
|
||||||
from app.sync.playlists import (
|
from app.sync.playlists import (
|
||||||
plan_push,
|
merge_playlist,
|
||||||
push_playlist,
|
plan_merge,
|
||||||
recompute_dirty,
|
recompute_dirty,
|
||||||
repull_playlist,
|
repull_playlist,
|
||||||
sync_user_playlists,
|
sync_user_playlists,
|
||||||
@@ -329,13 +329,13 @@ def push_plan(
|
|||||||
user: User = Depends(require_write_scope),
|
user: User = Depends(require_write_scope),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Dry run: what 'Sync to YouTube' would do + the quota estimate, so the UI can show a
|
"""Dry run: what a two-way 'Sync to YouTube' merge would do (what each side gains/loses) +
|
||||||
confirm. For a linked playlist this reads the live YouTube state (cheap, 1 unit/page).
|
the quota estimate, so the UI can show a confirm. For a linked playlist this reads the live
|
||||||
Write scope is required (require_write_scope) — syncing is a YouTube write."""
|
YouTube state (cheap, 1 unit/page). Write scope is required — syncing is a YouTube write."""
|
||||||
pl = _pushable(db, user, playlist_id)
|
pl = _pushable(db, user, playlist_id)
|
||||||
try:
|
try:
|
||||||
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PUSH):
|
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PUSH):
|
||||||
plan = plan_push(db, user, pl)
|
plan = plan_merge(db, user, pl)
|
||||||
except YouTubeError:
|
except YouTubeError:
|
||||||
raise HTTPException(status_code=422, detail="Couldn't read the playlist on YouTube.")
|
raise HTTPException(status_code=422, detail="Couldn't read the playlist on YouTube.")
|
||||||
plan["remaining_today"] = quota.remaining_today(db)
|
plan["remaining_today"] = quota.remaining_today(db)
|
||||||
@@ -349,14 +349,14 @@ def push(
|
|||||||
user: User = Depends(require_write_scope),
|
user: User = Depends(require_write_scope),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Push a local playlist to YouTube (create it on first push, else reconcile to match
|
"""Two-way sync a local playlist with YouTube (create it on first push, else 3-way merge so
|
||||||
local — local wins). Refuses if the estimated quota exceeds what's left today, so a
|
neither side loses an item). Refuses if the estimated quota exceeds what's left today, so a
|
||||||
big playlist can't strand half-pushed. Write scope required (require_write_scope)."""
|
big sync can't strand half-applied. Write scope required (require_write_scope)."""
|
||||||
pl = _pushable(db, user, playlist_id)
|
pl = _pushable(db, user, playlist_id)
|
||||||
try:
|
try:
|
||||||
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PUSH):
|
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PUSH):
|
||||||
# Read the live playlist ONCE (linked playlists only) and share it with both the
|
# Read the live playlist ONCE (linked playlists only) and share it with both the
|
||||||
# plan and the push, halving read-quota and closing the second read→write TOCTOU
|
# plan and the merge, halving read-quota and closing the second read→write TOCTOU
|
||||||
# window. A fresh export (no yt_playlist_id) reads nothing.
|
# window. A fresh export (no yt_playlist_id) reads nothing.
|
||||||
live = None
|
live = None
|
||||||
if pl.yt_playlist_id:
|
if pl.yt_playlist_id:
|
||||||
@@ -365,13 +365,13 @@ def push(
|
|||||||
list(yt.iter_playlist_items_with_ids(pl.yt_playlist_id)),
|
list(yt.iter_playlist_items_with_ids(pl.yt_playlist_id)),
|
||||||
yt.get_playlist_snippet(pl.yt_playlist_id),
|
yt.get_playlist_snippet(pl.yt_playlist_id),
|
||||||
)
|
)
|
||||||
plan = plan_push(db, user, pl, live=live)
|
plan = plan_merge(db, user, pl, live=live)
|
||||||
if plan["units_estimate"] > quota.remaining_today(db):
|
if plan["units_estimate"] > quota.remaining_today(db):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=429,
|
status_code=429,
|
||||||
detail="Not enough YouTube quota left today for this sync.",
|
detail="Not enough YouTube quota left today for this sync.",
|
||||||
)
|
)
|
||||||
return push_playlist(db, user, pl, live=live)
|
return merge_playlist(db, user, pl, live=live)
|
||||||
except YouTubeError:
|
except YouTubeError:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
raise HTTPException(status_code=422, detail="YouTube playlist sync failed.")
|
raise HTTPException(status_code=422, detail="YouTube playlist sync failed.")
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query
|
from fastapi import APIRouter, Depends, Query
|
||||||
from sqlalchemy import (
|
from sqlalchemy import (
|
||||||
|
Boolean,
|
||||||
Integer,
|
Integer,
|
||||||
String,
|
String,
|
||||||
and_,
|
and_,
|
||||||
@@ -27,6 +28,7 @@ from app.plex import rollup
|
|||||||
from app.plex.cards import episode_card
|
from app.plex.cards import episode_card
|
||||||
from app.plex.query import (
|
from app.plex.query import (
|
||||||
FIELD_COLUMNS,
|
FIELD_COLUMNS,
|
||||||
|
PLAYBACK_CLASSES,
|
||||||
TS_CONFIG,
|
TS_CONFIG,
|
||||||
LibraryFilters,
|
LibraryFilters,
|
||||||
apply_meta_filters,
|
apply_meta_filters,
|
||||||
@@ -36,6 +38,8 @@ from app.plex.query import (
|
|||||||
genres_col,
|
genres_col,
|
||||||
meta_filter_conds,
|
meta_filter_conds,
|
||||||
movie_status_case,
|
movie_status_case,
|
||||||
|
playable_conds,
|
||||||
|
playback_class_expr,
|
||||||
show_status_case,
|
show_status_case,
|
||||||
split_csv,
|
split_csv,
|
||||||
summary_col,
|
summary_col,
|
||||||
@@ -135,6 +139,7 @@ def unified_library(
|
|||||||
PlexItem.originally_available_at.label("rel"),
|
PlexItem.originally_available_at.label("rel"),
|
||||||
PlexItem.duration_s.label("duration_s"),
|
PlexItem.duration_s.label("duration_s"),
|
||||||
PlexItem.playable.label("playable"),
|
PlexItem.playable.label("playable"),
|
||||||
|
PlexItem.transcode_hw.label("transcode_hw"),
|
||||||
cast(null(), Integer).label("season_count"),
|
cast(null(), Integer).label("season_count"),
|
||||||
func.coalesce(st.position_seconds, 0).label("position_seconds"),
|
func.coalesce(st.position_seconds, 0).label("position_seconds"),
|
||||||
m_status.label("status"),
|
m_status.label("status"),
|
||||||
@@ -156,6 +161,8 @@ def unified_library(
|
|||||||
mq = mq.where(PlexItem.duration_s >= f.duration_min)
|
mq = mq.where(PlexItem.duration_s >= f.duration_min)
|
||||||
if f.duration_max is not None:
|
if f.duration_max is not None:
|
||||||
mq = mq.where(PlexItem.duration_s <= f.duration_max)
|
mq = mq.where(PlexItem.duration_s <= f.duration_max)
|
||||||
|
# Playback-class filter (movie-only — a show is an aggregate with no single class).
|
||||||
|
mq = mq.where(*playable_conds(split_csv(f.playable)))
|
||||||
if tsq is not None:
|
if tsq is not None:
|
||||||
mq = mq.where(vector_match(PlexItem, lang, tsq))
|
mq = mq.where(vector_match(PlexItem, lang, tsq))
|
||||||
if recheck is not None:
|
if recheck is not None:
|
||||||
@@ -179,6 +186,7 @@ def unified_library(
|
|||||||
PlexShow.originally_available_at.label("rel"),
|
PlexShow.originally_available_at.label("rel"),
|
||||||
cast(null(), Integer).label("duration_s"),
|
cast(null(), Integer).label("duration_s"),
|
||||||
cast(null(), String).label("playable"),
|
cast(null(), String).label("playable"),
|
||||||
|
cast(null(), Boolean).label("transcode_hw"),
|
||||||
PlexShow.child_count.label("season_count"),
|
PlexShow.child_count.label("season_count"),
|
||||||
literal(0).label("position_seconds"),
|
literal(0).label("position_seconds"),
|
||||||
s_status.label("status"),
|
s_status.label("status"),
|
||||||
@@ -237,6 +245,7 @@ def unified_library(
|
|||||||
"duration_seconds": r.duration_s,
|
"duration_seconds": r.duration_s,
|
||||||
"thumb": f"/api/plex/image/{r.rk}",
|
"thumb": f"/api/plex/image/{r.rk}",
|
||||||
"playable": r.playable,
|
"playable": r.playable,
|
||||||
|
"transcode_hw": r.transcode_hw,
|
||||||
"status": r.status,
|
"status": r.status,
|
||||||
"position_seconds": r.position_seconds,
|
"position_seconds": r.position_seconds,
|
||||||
}
|
}
|
||||||
@@ -312,6 +321,7 @@ def facets(
|
|||||||
"genres": [],
|
"genres": [],
|
||||||
"content_ratings": [],
|
"content_ratings": [],
|
||||||
"studios": [],
|
"studios": [],
|
||||||
|
"playable": [],
|
||||||
"year_min": None,
|
"year_min": None,
|
||||||
"year_max": None,
|
"year_max": None,
|
||||||
"rating_max": None,
|
"rating_max": None,
|
||||||
@@ -338,6 +348,9 @@ def facets(
|
|||||||
query = query.where(model.duration_s >= pp["duration_min"])
|
query = query.where(model.duration_s >= pp["duration_min"])
|
||||||
if with_dur and pp.get("duration_max") is not None:
|
if with_dur and pp.get("duration_max") is not None:
|
||||||
query = query.where(model.duration_s <= pp["duration_max"])
|
query = query.where(model.duration_s <= pp["duration_max"])
|
||||||
|
# Playback-class filter (movie-only) — applied for the OTHER groups so an active class
|
||||||
|
# narrows them; the playable facet itself passes pp with playable=None (self-exclusion).
|
||||||
|
query = query.where(*playable_conds(split_csv(pp.get("playable"))))
|
||||||
# Join watch-state and reuse the grid's exact movie state filter (unified_library) —
|
# Join watch-state and reuse the grid's exact movie state filter (unified_library) —
|
||||||
# hidden movies never appear in the grid, so they must not inflate facet counts/bounds either.
|
# hidden movies never appear in the grid, so they must not inflate facet counts/bounds either.
|
||||||
st = aliased(PlexState)
|
st = aliased(PlexState)
|
||||||
@@ -356,6 +369,7 @@ def facets(
|
|||||||
genre_counts: dict[str, int] = {}
|
genre_counts: dict[str, int] = {}
|
||||||
cr_counts: dict[str, int] = {}
|
cr_counts: dict[str, int] = {}
|
||||||
studio_counts: dict[str, int] = {}
|
studio_counts: dict[str, int] = {}
|
||||||
|
playable_counts: dict[str, int] = {}
|
||||||
years: list[int] = []
|
years: list[int] = []
|
||||||
ratings: list[float] = []
|
ratings: list[float] = []
|
||||||
durs: list[int] = []
|
durs: list[int] = []
|
||||||
@@ -420,6 +434,23 @@ def facets(
|
|||||||
)
|
)
|
||||||
for s, c in db.execute(stq).all():
|
for s, c in db.execute(stq).all():
|
||||||
studio_counts[s] = studio_counts.get(s, 0) + c
|
studio_counts[s] = studio_counts.get(s, 0) + c
|
||||||
|
# Playback class (movie-only) — exclude its own so each class stays an addable OR option.
|
||||||
|
if kind_movie:
|
||||||
|
pcls = playback_class_expr()
|
||||||
|
pcq = (
|
||||||
|
wsq(
|
||||||
|
select(pcls.label("cls"), func.count()),
|
||||||
|
model,
|
||||||
|
ids,
|
||||||
|
{**p, "playable": None},
|
||||||
|
kind_movie,
|
||||||
|
True,
|
||||||
|
)
|
||||||
|
.where(pcls.isnot(None))
|
||||||
|
.group_by(pcls)
|
||||||
|
)
|
||||||
|
for cls, c in db.execute(pcq).all():
|
||||||
|
playable_counts[cls] = playable_counts.get(cls, 0) + c
|
||||||
# Year bounds — exclude the year filter.
|
# Year bounds — exclude the year filter.
|
||||||
by = db.execute(
|
by = db.execute(
|
||||||
wsq(
|
wsq(
|
||||||
@@ -477,6 +508,11 @@ def facets(
|
|||||||
# Same for the actively-selected studios, so a selected studio stays removable even at zero matches.
|
# Same for the actively-selected studios, so a selected studio stays removable even at zero matches.
|
||||||
for s in split_csv(f.studios):
|
for s in split_csv(f.studios):
|
||||||
studio_counts.setdefault(s, 0)
|
studio_counts.setdefault(s, 0)
|
||||||
|
# Keep an actively-selected playback class visible even at zero matches (the sidebar hides the whole
|
||||||
|
# section when its facet list is empty, which would trap a zero-result selection with no way to undo).
|
||||||
|
for c in split_csv(f.playable):
|
||||||
|
if c in PLAYBACK_CLASSES:
|
||||||
|
playable_counts.setdefault(c, 0)
|
||||||
return {
|
return {
|
||||||
"genres": [
|
"genres": [
|
||||||
{"value": g, "count": c} for g, c in sorted(genre_counts.items(), key=lambda kv: kv[0])
|
{"value": g, "count": c} for g, c in sorted(genre_counts.items(), key=lambda kv: kv[0])
|
||||||
@@ -488,6 +524,12 @@ def facets(
|
|||||||
{"value": s, "count": c}
|
{"value": s, "count": c}
|
||||||
for s, c in sorted(studio_counts.items(), key=lambda kv: (-kv[1], kv[0]))
|
for s, c in sorted(studio_counts.items(), key=lambda kv: (-kv[1], kv[0]))
|
||||||
],
|
],
|
||||||
|
# Fixed good→heavy order (not by count), so the class list reads as a severity scale.
|
||||||
|
"playable": [
|
||||||
|
{"value": c, "count": playable_counts[c]}
|
||||||
|
for c in PLAYBACK_CLASSES
|
||||||
|
if c in playable_counts
|
||||||
|
],
|
||||||
"year_min": min(years) if years else None,
|
"year_min": min(years) if years else None,
|
||||||
"year_max": max(years) if years else None,
|
"year_max": max(years) if years else None,
|
||||||
"rating_max": max(ratings) if ratings else None,
|
"rating_max": max(ratings) if ratings else None,
|
||||||
|
|||||||
@@ -411,6 +411,10 @@ def item_detail(
|
|||||||
"year": it.year,
|
"year": it.year,
|
||||||
"duration_seconds": it.duration_s,
|
"duration_seconds": it.duration_s,
|
||||||
"playable": it.playable,
|
"playable": it.playable,
|
||||||
|
# Playback-class detail: HW/SW transcode split + the codec/bit-depth the info-page chip shows.
|
||||||
|
"transcode_hw": it.transcode_hw,
|
||||||
|
"codec_video": it.codec_video,
|
||||||
|
"video_bit_depth": it.video_bit_depth,
|
||||||
"thumb": f"/api/plex/image/{it.rating_key}",
|
"thumb": f"/api/plex/image/{it.rating_key}",
|
||||||
"art": f"/api/plex/image/{it.rating_key}?variant=art",
|
"art": f"/api/plex/image/{it.rating_key}?variant=art",
|
||||||
"library": _lib_key(db, it.library_id),
|
"library": _lib_key(db, it.library_id),
|
||||||
|
|||||||
@@ -532,15 +532,13 @@ def stream_session(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Prepare playback from `start` seconds. Direct-playable files are served raw; remux files
|
"""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
|
(re)start an HLS session at the offset (seek-restart, video stream-copied); transcode files (non-h264:
|
||||||
non-default audio track (`audio` = stream ordinal) forces the HLS path so the chosen audio can be
|
HEVC/VP9/AV1/…) take the same HLS path but with the video re-encoded to H.264 (VAAPI or libx264).
|
||||||
muxed in. Subtitles are independent WebVTT tracks (GET /subtitle) — no session restart."""
|
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
|
plex_stream.reap_idle() # opportunistic cleanup of idle sessions on each new playback
|
||||||
it = item_or_404(db, rating_key)
|
it = item_or_404(db, rating_key)
|
||||||
if it.playable == "transcode":
|
transcode = it.playable == "transcode"
|
||||||
raise HTTPException(
|
|
||||||
status_code=501, detail="This format needs transcoding (a later phase)."
|
|
||||||
)
|
|
||||||
# `multi` (item has ≥2 audio tracks) forces the HLS path so ALL audio tracks ship as renditions and
|
# `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.
|
# 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:
|
if it.playable == "direct" and audio is None and not aoff and not multi:
|
||||||
@@ -551,15 +549,21 @@ def stream_session(
|
|||||||
}
|
}
|
||||||
if plex_paths.local_media_path(db, it.file_path) is None:
|
if plex_paths.local_media_path(db, it.file_path) is None:
|
||||||
raise HTTPException(status_code=404, detail="Media file not found on disk")
|
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:
|
if s is None:
|
||||||
raise HTTPException(status_code=404, detail="Media file not found on disk")
|
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
|
# 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.
|
# requested offset — the client's absolute clock and the subtitle cue-shift both key off this.
|
||||||
|
# `transcode`/`hw`/`tonemap` let the player show an honest cold-start ("Preparing stream…") and
|
||||||
|
# surface the active mode + encoder in the stats overlay (`tonemap` = the HW HDR→SDR path); both
|
||||||
|
# segment streams are otherwise identical.
|
||||||
return {
|
return {
|
||||||
"mode": "hls",
|
"mode": "hls",
|
||||||
"start_s": s.media_start_s,
|
"start_s": s.media_start_s,
|
||||||
"url": f"/api/plex/stream/{it.rating_key}/hls/{s.entry}",
|
"url": f"/api/plex/stream/{it.rating_key}/hls/{s.entry}",
|
||||||
|
"transcode": s.transcode,
|
||||||
|
"hw": s.hw,
|
||||||
|
"tonemap": s.tonemap,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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.")
|
raise HTTPException(status_code=404, detail="This video is no longer available.")
|
||||||
|
|
||||||
filename = storage.download_filename(asset.title or "video", asset.container, path)
|
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"
|
disposition = "attachment" if link.allow_download else "inline"
|
||||||
# Starlette's FileResponse honours the Range header (206) for seeking/scrubbing.
|
return FileResponse(
|
||||||
return FileResponse(path, filename=filename, content_disposition_type=disposition)
|
path, filename=filename, media_type=media_type, content_disposition_type=disposition
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/watch/{token}/poster.jpg")
|
@router.get("/watch/{token}/poster.jpg")
|
||||||
|
|||||||
+240
-71
@@ -1,14 +1,16 @@
|
|||||||
"""YouTube playlist sync, both directions. Read: mirror each user's own YouTube playlists
|
"""YouTube playlist sync, both directions. Read: mirror each user's own YouTube playlists
|
||||||
into local `source='youtube'` playlists (kept fresh by the scheduler). Write-back: push local
|
into local `source='youtube'` playlists (kept fresh by the scheduler). Write-back: two-way sync
|
||||||
edits out to YouTube (`plan_push` / `push_playlist`), behind the optional write scope.
|
local edits with YouTube (`plan_merge` / `merge_playlist`), behind the optional write scope.
|
||||||
|
|
||||||
YouTube's special Watch Later / History playlists are not exposed by the Data API and are
|
YouTube's special Watch Later / History playlists are not exposed by the Data API and are
|
||||||
never synced. Videos in a playlist that aren't in our shared catalog yet are fetched and
|
never synced. Videos in a playlist that aren't in our shared catalog yet are fetched and
|
||||||
ingested (with a stub channel) so the mirror is faithful."""
|
ingested (with a stub channel) so the mirror is faithful."""
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
|
from dataclasses import dataclass
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import TypeVar
|
from typing import TypeVar
|
||||||
|
|
||||||
@@ -42,6 +44,57 @@ def current_fingerprint(db: Session, pl: Playlist) -> str:
|
|||||||
return fingerprint(pl.name, _local_video_ids(db, pl))
|
return fingerprint(pl.name, _local_video_ids(db, pl))
|
||||||
|
|
||||||
|
|
||||||
|
def _dump_ids(ids: list[str]) -> str:
|
||||||
|
"""Serialize the ordered id list stored as the 3-way-merge baseline (`synced_video_ids`)."""
|
||||||
|
return json.dumps(ids)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_baseline(pl: Playlist) -> tuple[list[str] | None, set[str]]:
|
||||||
|
"""Parse `synced_video_ids` into (ordered id set, un-ingestable id set). Two on-disk forms:
|
||||||
|
a plain JSON list (legacy / no un-ingestable ids) or `{"ids": [...], "unavailable": [...]}`.
|
||||||
|
`unavailable` = YouTube-only videos that exist on YouTube but the Data API won't return
|
||||||
|
(private/deleted/region-locked), so they never became local rows — tracked so the merge can
|
||||||
|
skip them instead of forever re-classifying them as a YouTube-add (they'd never converge)."""
|
||||||
|
if not pl.synced_video_ids:
|
||||||
|
return None, set()
|
||||||
|
try:
|
||||||
|
val = json.loads(pl.synced_video_ids)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return None, set()
|
||||||
|
if isinstance(val, list):
|
||||||
|
return [str(v) for v in val], set()
|
||||||
|
if isinstance(val, dict):
|
||||||
|
raw_ids = val.get("ids")
|
||||||
|
ids = [str(v) for v in raw_ids] if isinstance(raw_ids, list) else None
|
||||||
|
raw_un = val.get("unavailable")
|
||||||
|
un = {str(v) for v in raw_un} if isinstance(raw_un, list) else set()
|
||||||
|
return ids, un
|
||||||
|
return None, set()
|
||||||
|
|
||||||
|
|
||||||
|
def parse_baseline(pl: Playlist) -> list[str] | None:
|
||||||
|
"""The last-synced ordered id set, or None when no baseline has been recorded yet (a fresh
|
||||||
|
export, or a link created before S7). None makes the first merge union losslessly."""
|
||||||
|
return _load_baseline(pl)[0]
|
||||||
|
|
||||||
|
|
||||||
|
def parse_unavailable(pl: Playlist) -> set[str]:
|
||||||
|
"""The last-synced set of un-ingestable-but-live YouTube ids (see `_load_baseline`)."""
|
||||||
|
return _load_baseline(pl)[1]
|
||||||
|
|
||||||
|
|
||||||
|
def _set_baseline(pl: Playlist, ids: list[str], unavailable: list[str] | None = None) -> None:
|
||||||
|
"""Record `ids` as both the hash (drives `dirty`, over the local ids only) and the raw ordered
|
||||||
|
set (drives merge). `unavailable` = un-ingestable-but-live YouTube ids to carry forward so the
|
||||||
|
next merge skips them; stored only when non-empty (keeps the common case a plain list)."""
|
||||||
|
pl.synced_fingerprint = fingerprint(pl.name, ids)
|
||||||
|
if unavailable:
|
||||||
|
pl.synced_video_ids = json.dumps({"ids": ids, "unavailable": list(unavailable)})
|
||||||
|
else:
|
||||||
|
pl.synced_video_ids = _dump_ids(ids)
|
||||||
|
pl.dirty = False
|
||||||
|
|
||||||
|
|
||||||
def recompute_dirty(db: Session, pl: Playlist) -> None:
|
def recompute_dirty(db: Session, pl: Playlist) -> None:
|
||||||
"""Set `dirty` by comparing the current state to the synced baseline. Unlinked lists
|
"""Set `dirty` by comparing the current state to the synced baseline. Unlinked lists
|
||||||
(and Watch later) are never dirty; a missing baseline counts as dirty (unknown YT state)."""
|
(and Watch later) are never dirty; a missing baseline counts as dirty (unknown YT state)."""
|
||||||
@@ -116,12 +169,18 @@ def _replace_items_from_youtube(
|
|||||||
if v in present and v not in seen:
|
if v in present and v not in seen:
|
||||||
seen.add(v)
|
seen.add(v)
|
||||||
ordered.append(v)
|
ordered.append(v)
|
||||||
|
_set_local_items(db, pl, ordered)
|
||||||
|
# After a pull/reset local == YouTube, so this ordered set IS the new merge baseline.
|
||||||
|
_set_baseline(pl, ordered)
|
||||||
|
return ordered
|
||||||
|
|
||||||
|
|
||||||
|
def _set_local_items(db: Session, pl: Playlist, ordered: list[str]) -> None:
|
||||||
|
"""Authoritatively replace `pl`'s local items with `ordered` (positions 0..n). The caller
|
||||||
|
is responsible for having ensured every id exists in the catalog."""
|
||||||
db.execute(delete(PlaylistItem).where(PlaylistItem.playlist_id == pl.id))
|
db.execute(delete(PlaylistItem).where(PlaylistItem.playlist_id == pl.id))
|
||||||
for pos, vid in enumerate(ordered):
|
for pos, vid in enumerate(ordered):
|
||||||
db.add(PlaylistItem(playlist_id=pl.id, video_id=vid, position=pos))
|
db.add(PlaylistItem(playlist_id=pl.id, video_id=vid, position=pos))
|
||||||
pl.synced_fingerprint = fingerprint(pl.name, ordered)
|
|
||||||
pl.dirty = False
|
|
||||||
return ordered
|
|
||||||
|
|
||||||
|
|
||||||
def sync_user_playlists(db: Session, user: User) -> dict:
|
def sync_user_playlists(db: Session, user: User) -> dict:
|
||||||
@@ -216,22 +275,91 @@ def _reorder_moves(current: list[str], desired: list[str]) -> int:
|
|||||||
return moves
|
return moves
|
||||||
|
|
||||||
|
|
||||||
def plan_push(db: Session, user: User, pl: Playlist, *, live=None) -> dict:
|
@dataclass
|
||||||
"""Compute (without writing) what pushing this local playlist to YouTube would do:
|
class MergePlan:
|
||||||
create vs update, how many inserts/deletes/reorders, the quota estimate, and whether
|
"""The 3-way merge of a linked playlist: what each side gains and loses so neither side
|
||||||
YouTube has diverged (items present there that local will remove). Costs read units
|
loses an item, plus the merged order. `final` is the id set both sides end up with."""
|
||||||
(1/page) for a linked playlist; nothing for a fresh export. `live` = a pre-fetched
|
|
||||||
(current_items, snippet) tuple so the caller can share one read with push_playlist."""
|
final: list[str] # merged order (local order, YouTube-only adds appended)
|
||||||
desired = _local_video_ids(db, pl)
|
add_to_yt: list[str] # local-adds to push out to YouTube
|
||||||
|
add_to_local: list[str] # YouTube-adds to pull into Siftlode
|
||||||
|
remove_from_yt: list[str] # items deleted locally that are still on YouTube
|
||||||
|
remove_from_local: list[str] # items deleted on YouTube that are still local
|
||||||
|
reorders: int # estimated YouTube playlistItems.update moves to reach `final`
|
||||||
|
baseline_known: bool
|
||||||
|
|
||||||
|
|
||||||
|
def merge_plan(
|
||||||
|
baseline: list[str] | None,
|
||||||
|
local: list[str],
|
||||||
|
youtube: list[str],
|
||||||
|
unavailable: set[str] | None = None,
|
||||||
|
) -> MergePlan:
|
||||||
|
"""Pure 3-way merge. Classify each id against the last-synced `baseline`:
|
||||||
|
- in local but not youtube, not deleted → add to YouTube (a local-add)
|
||||||
|
- in youtube but not local, not deleted → add to Siftlode (a YouTube-add)
|
||||||
|
- in baseline but gone from local → a genuine local delete → propagate to YouTube
|
||||||
|
- in baseline but gone from youtube → a genuine YouTube delete → propagate to Siftlode
|
||||||
|
With NO baseline (first-ever merge) we can't tell an add from a delete, so we UNION both
|
||||||
|
sides (never delete) — the lossless default. Merged order: local order first, YouTube-only
|
||||||
|
additions appended in their YouTube order.
|
||||||
|
`unavailable` = known un-ingestable-but-live YouTube ids: dropped from the YouTube side up
|
||||||
|
front so they're never re-classified as a YouTube-add (they can't ingest) nor as a local
|
||||||
|
delete (they were never local) — otherwise such a video would make the playlist never
|
||||||
|
converge. They stay in place on YouTube, untouched by this merge."""
|
||||||
|
un = set(unavailable or ())
|
||||||
|
L = list(dict.fromkeys(local))
|
||||||
|
Y = [v for v in dict.fromkeys(youtube) if v not in un]
|
||||||
|
Lset, Yset = set(L), set(Y)
|
||||||
|
if baseline is None:
|
||||||
|
add_to_yt = [v for v in L if v not in Yset]
|
||||||
|
add_to_local = [v for v in Y if v not in Lset]
|
||||||
|
remove_from_yt: list[str] = []
|
||||||
|
remove_from_local: list[str] = []
|
||||||
|
final = L + [v for v in Y if v not in Lset]
|
||||||
|
else:
|
||||||
|
B = set(baseline)
|
||||||
|
deleted_local = B - Lset # was synced, removed on our side
|
||||||
|
deleted_yt = B - Yset # was synced, removed on YouTube's side
|
||||||
|
gone = deleted_local | deleted_yt
|
||||||
|
remove_from_yt = [v for v in Y if v in deleted_local]
|
||||||
|
remove_from_local = [v for v in L if v in deleted_yt]
|
||||||
|
add_to_yt = [v for v in L if v not in Yset and v not in gone]
|
||||||
|
add_to_local = [v for v in Y if v not in Lset and v not in gone]
|
||||||
|
final = [v for v in L if v not in gone] + [v for v in Y if v not in Lset and v not in gone]
|
||||||
|
final_set = set(final)
|
||||||
|
# On YouTube, after membership reconciliation the order is current-kept + inserts appended;
|
||||||
|
# the reorder pass then moves items into `final`.
|
||||||
|
after_membership = [v for v in Y if v in final_set] + add_to_yt
|
||||||
|
reorders = _reorder_moves(after_membership, final)
|
||||||
|
return MergePlan(
|
||||||
|
final=final,
|
||||||
|
add_to_yt=add_to_yt,
|
||||||
|
add_to_local=add_to_local,
|
||||||
|
remove_from_yt=remove_from_yt,
|
||||||
|
remove_from_local=remove_from_local,
|
||||||
|
reorders=reorders,
|
||||||
|
baseline_known=baseline is not None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def plan_merge(db: Session, user: User, pl: Playlist, *, live=None) -> dict:
|
||||||
|
"""Compute (without writing) what a two-way sync of this playlist would do: create vs
|
||||||
|
update, how many items each side gains/loses, reorders, and the quota estimate (YouTube
|
||||||
|
writes only — local edits are free). Reads live YouTube state (1 unit/page) for a linked
|
||||||
|
playlist; nothing for a fresh export. `live` = a pre-fetched (current_items, snippet) tuple
|
||||||
|
so the caller can share one read with merge_playlist."""
|
||||||
|
local = _local_video_ids(db, pl)
|
||||||
if not pl.yt_playlist_id:
|
if not pl.yt_playlist_id:
|
||||||
ops = 1 + len(desired) # create the playlist + one insert per video
|
ops = 1 + len(local) # create the playlist + one insert per video
|
||||||
return {
|
return {
|
||||||
"action": "create",
|
"action": "create",
|
||||||
"to_insert": len(desired),
|
"add_yt": len(local),
|
||||||
"to_delete": 0,
|
"add_local": 0,
|
||||||
|
"remove_yt": 0,
|
||||||
|
"remove_local": 0,
|
||||||
"to_reorder": 0,
|
"to_reorder": 0,
|
||||||
"rename": False,
|
"rename": False,
|
||||||
"yt_extra": 0,
|
|
||||||
"units_estimate": ops * WRITE_UNIT_COST,
|
"units_estimate": ops * WRITE_UNIT_COST,
|
||||||
}
|
}
|
||||||
if live is not None:
|
if live is not None:
|
||||||
@@ -241,23 +369,17 @@ def plan_push(db: Session, user: User, pl: Playlist, *, live=None) -> dict:
|
|||||||
current = list(yt.iter_playlist_items_with_ids(pl.yt_playlist_id))
|
current = list(yt.iter_playlist_items_with_ids(pl.yt_playlist_id))
|
||||||
snippet = yt.get_playlist_snippet(pl.yt_playlist_id)
|
snippet = yt.get_playlist_snippet(pl.yt_playlist_id)
|
||||||
rename = bool(snippet) and (snippet.get("title") or "") != pl.name
|
rename = bool(snippet) and (snippet.get("title") or "") != pl.name
|
||||||
cur_vids = [it["video_id"] for it in current]
|
yt_ids = [it["video_id"] for it in current]
|
||||||
cur_set = set(cur_vids)
|
mp = merge_plan(parse_baseline(pl), local, yt_ids, parse_unavailable(pl))
|
||||||
desired_set = set(desired)
|
ops = len(mp.add_to_yt) + len(mp.remove_from_yt) + mp.reorders + (1 if rename else 0)
|
||||||
to_insert = [v for v in desired if v not in cur_set]
|
|
||||||
to_delete = [v for v in cur_vids if v not in desired_set]
|
|
||||||
# After membership reconciliation, the YT order is current-minus-deletes plus inserts
|
|
||||||
# appended; the reorder pass then moves items into the desired order.
|
|
||||||
after_membership = [v for v in cur_vids if v in desired_set] + to_insert
|
|
||||||
reorders = _reorder_moves(after_membership, desired)
|
|
||||||
ops = len(to_insert) + len(to_delete) + reorders + (1 if rename else 0)
|
|
||||||
return {
|
return {
|
||||||
"action": "update",
|
"action": "update",
|
||||||
"to_insert": len(to_insert),
|
"add_yt": len(mp.add_to_yt),
|
||||||
"to_delete": len(to_delete),
|
"add_local": len(mp.add_to_local),
|
||||||
"to_reorder": reorders,
|
"remove_yt": len(mp.remove_from_yt),
|
||||||
|
"remove_local": len(mp.remove_from_local),
|
||||||
|
"to_reorder": mp.reorders,
|
||||||
"rename": rename,
|
"rename": rename,
|
||||||
"yt_extra": len(to_delete),
|
|
||||||
"units_estimate": ops * WRITE_UNIT_COST,
|
"units_estimate": ops * WRITE_UNIT_COST,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -327,13 +449,16 @@ class _Breaker:
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def push_playlist(db: Session, user: User, pl: Playlist, *, live=None) -> dict:
|
def merge_playlist(db: Session, user: User, pl: Playlist, *, live=None) -> dict:
|
||||||
"""Push a local playlist to YouTube: create it if needed, then reconcile membership
|
"""Two-way sync a local playlist with YouTube: create it on first push, else 3-way MERGE
|
||||||
and order so YouTube matches local (local wins). Marks the playlist clean on success.
|
both sides so an item added (or deleted) on either side survives — no side wins wholesale.
|
||||||
|
Local edits are applied directly; YouTube edits go through the failure breaker. On full
|
||||||
|
success the merged set becomes the new baseline and the playlist is marked clean; on any
|
||||||
|
YouTube failure the baseline is left untouched so the next sync retries and converges.
|
||||||
Caller must have checked the write scope and the quota budget. `live` = a pre-fetched
|
Caller must have checked the write scope and the quota budget. `live` = a pre-fetched
|
||||||
(current_items, snippet) tuple (from plan_push's read) to avoid a second live page-through."""
|
(current_items, snippet) tuple (from plan_merge's read) to avoid a second page-through."""
|
||||||
desired = _local_video_ids(db, pl)
|
local = _local_video_ids(db, pl)
|
||||||
inserted = deleted = reordered = created = 0
|
inserted = deleted = reordered = created = pulled = removed_local = 0
|
||||||
failures: list[str] = []
|
failures: list[str] = []
|
||||||
breaker = _Breaker()
|
breaker = _Breaker()
|
||||||
with YouTubeClient(db, user) as yt:
|
with YouTubeClient(db, user) as yt:
|
||||||
@@ -341,8 +466,8 @@ def push_playlist(db: Session, user: User, pl: Playlist, *, live=None) -> dict:
|
|||||||
pl.yt_playlist_id = yt.create_playlist(pl.name)
|
pl.yt_playlist_id = yt.create_playlist(pl.name)
|
||||||
db.commit()
|
db.commit()
|
||||||
created = 1
|
created = 1
|
||||||
# Brand-new playlist: just append every video in order.
|
# Brand-new playlist: just append every local video in order.
|
||||||
for vid in desired:
|
for vid in local:
|
||||||
added = breaker.run(
|
added = breaker.run(
|
||||||
lambda v=vid: yt.add_playlist_item(pl.yt_playlist_id, v),
|
lambda v=vid: yt.add_playlist_item(pl.yt_playlist_id, v),
|
||||||
lambda v=vid: failures.append(v),
|
lambda v=vid: failures.append(v),
|
||||||
@@ -350,60 +475,95 @@ def push_playlist(db: Session, user: User, pl: Playlist, *, live=None) -> dict:
|
|||||||
if not isinstance(added, _Failed):
|
if not isinstance(added, _Failed):
|
||||||
inserted += 1
|
inserted += 1
|
||||||
if not failures:
|
if not failures:
|
||||||
pl.synced_fingerprint = fingerprint(pl.name, desired)
|
_set_baseline(pl, local)
|
||||||
pl.dirty = False
|
|
||||||
db.commit()
|
db.commit()
|
||||||
return {
|
return {
|
||||||
"created": created,
|
"created": created,
|
||||||
"inserted": inserted,
|
"inserted": inserted,
|
||||||
"deleted": deleted,
|
"deleted": deleted,
|
||||||
"reordered": reordered,
|
"reordered": reordered,
|
||||||
|
"pulled": pulled,
|
||||||
|
"removed_local": removed_local,
|
||||||
"failures": failures,
|
"failures": failures,
|
||||||
"aborted": breaker.tripped,
|
"aborted": breaker.tripped,
|
||||||
"yt_playlist_id": pl.yt_playlist_id,
|
"yt_playlist_id": pl.yt_playlist_id,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Existing link: diff against the live YouTube state (reuse plan_push's read if given).
|
# Existing link: 3-way merge against the live YouTube state (reuse plan_merge's read).
|
||||||
if live is not None:
|
if live is not None:
|
||||||
current, snippet = live
|
current, snippet = live
|
||||||
else:
|
else:
|
||||||
snippet = yt.get_playlist_snippet(pl.yt_playlist_id)
|
snippet = yt.get_playlist_snippet(pl.yt_playlist_id)
|
||||||
current = list(yt.iter_playlist_items_with_ids(pl.yt_playlist_id))
|
current = list(yt.iter_playlist_items_with_ids(pl.yt_playlist_id))
|
||||||
|
item_id_by_vid = {it["video_id"]: it["item_id"] for it in current}
|
||||||
|
yt_ids = [it["video_id"] for it in current]
|
||||||
|
# Re-attempt ingestion of ids previously marked un-ingestable that are still on YouTube: a
|
||||||
|
# video that has since become available (e.g. private→public) should re-enter the merge, not
|
||||||
|
# stay skipped forever. Those that still won't ingest remain filtered (`effective_unavailable`).
|
||||||
|
prev_unavailable = parse_unavailable(pl)
|
||||||
|
still_listed = [v for v in yt_ids if v in prev_unavailable]
|
||||||
|
if still_listed:
|
||||||
|
_ensure_videos(db, yt, still_listed)
|
||||||
|
healed = set(
|
||||||
|
db.execute(select(Video.id).where(Video.id.in_(still_listed))).scalars().all()
|
||||||
|
)
|
||||||
|
effective_unavailable = prev_unavailable - healed
|
||||||
|
else:
|
||||||
|
effective_unavailable = prev_unavailable
|
||||||
|
mp = merge_plan(parse_baseline(pl), local, yt_ids, effective_unavailable)
|
||||||
|
|
||||||
|
# --- Siftlode side (free, direct): pull YouTube-adds in, drop YouTube-deletes, land the
|
||||||
|
# merged order authoritatively. Do this first so local reflects the merge even if a later
|
||||||
|
# YouTube write trips the breaker.
|
||||||
|
_ensure_videos(db, yt, mp.add_to_local)
|
||||||
|
# Only the ids that actually exist in the catalog can become local rows (a private/deleted
|
||||||
|
# YouTube video won't ingest); keep the merged order, dropping any that didn't materialize.
|
||||||
|
present = (
|
||||||
|
set(db.execute(select(Video.id).where(Video.id.in_(mp.final))).scalars().all())
|
||||||
|
if mp.final
|
||||||
|
else set()
|
||||||
|
)
|
||||||
|
final_local = [v for v in mp.final if v in present]
|
||||||
|
pulled = len([v for v in mp.add_to_local if v in present])
|
||||||
|
removed_local = len(mp.remove_from_local)
|
||||||
|
_set_local_items(db, pl, final_local)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# --- YouTube side (metered, breaker-guarded).
|
||||||
if snippet is not None and (snippet.get("title") or "") != pl.name:
|
if snippet is not None and (snippet.get("title") or "") != pl.name:
|
||||||
try:
|
try:
|
||||||
yt.update_playlist_title(pl.yt_playlist_id, pl.name)
|
yt.update_playlist_title(pl.yt_playlist_id, pl.name)
|
||||||
except YouTubeError:
|
except YouTubeError:
|
||||||
failures.append("title")
|
failures.append("title")
|
||||||
item_id_by_vid = {it["video_id"]: it["item_id"] for it in current}
|
# Propagate genuine local deletes.
|
||||||
cur_vids = [it["video_id"] for it in current]
|
for vid in mp.remove_from_yt:
|
||||||
desired_set = set(desired)
|
item_id = item_id_by_vid.get(vid)
|
||||||
# Delete extras (local is authoritative).
|
if item_id is None:
|
||||||
for it in current:
|
continue
|
||||||
if it["video_id"] not in desired_set:
|
dropped = breaker.run(
|
||||||
dropped = breaker.run(
|
lambda i=item_id: yt.delete_playlist_item(i),
|
||||||
lambda i=it: yt.delete_playlist_item(i["item_id"]),
|
lambda v=vid: failures.append(v),
|
||||||
lambda i=it: failures.append(i["video_id"]),
|
)
|
||||||
)
|
if not isinstance(dropped, _Failed):
|
||||||
if not isinstance(dropped, _Failed):
|
deleted += 1
|
||||||
deleted += 1
|
item_id_by_vid.pop(vid, None)
|
||||||
# Insert missing (append; the reorder pass below fixes positions).
|
# Insert local-adds (append; the reorder pass below fixes positions).
|
||||||
cur_set = set(cur_vids)
|
for vid in mp.add_to_yt:
|
||||||
for vid in desired:
|
item_id = breaker.run(
|
||||||
if vid not in cur_set:
|
lambda v=vid: yt.add_playlist_item(pl.yt_playlist_id, v),
|
||||||
item_id = breaker.run(
|
lambda v=vid: failures.append(v),
|
||||||
lambda v=vid: yt.add_playlist_item(pl.yt_playlist_id, v),
|
)
|
||||||
lambda v=vid: failures.append(v),
|
if not isinstance(item_id, _Failed):
|
||||||
)
|
item_id_by_vid[vid] = item_id
|
||||||
if not isinstance(item_id, _Failed):
|
inserted += 1
|
||||||
item_id_by_vid[vid] = item_id
|
# Reorder YouTube to match `final` via insertion-sort moves over a local model.
|
||||||
inserted += 1
|
final_set = set(mp.final)
|
||||||
# Reorder to match `desired` via insertion-sort moves over a local model.
|
model = [v for v in yt_ids if v in final_set and v in item_id_by_vid] + [
|
||||||
model = [v for v in cur_vids if v in desired_set] + [
|
v for v in mp.add_to_yt if v in item_id_by_vid
|
||||||
v for v in desired if v not in cur_set and v in item_id_by_vid
|
|
||||||
]
|
]
|
||||||
for i, want in enumerate(desired):
|
for i, want in enumerate(mp.final):
|
||||||
if want not in item_id_by_vid:
|
if want not in item_id_by_vid:
|
||||||
continue # failed insert; skip
|
continue # deleted-from-YT or failed insert; skip
|
||||||
if i < len(model) and model[i] == want:
|
if i < len(model) and model[i] == want:
|
||||||
continue
|
continue
|
||||||
moved = breaker.run(
|
moved = breaker.run(
|
||||||
@@ -419,14 +579,23 @@ def push_playlist(db: Session, user: User, pl: Playlist, *, live=None) -> dict:
|
|||||||
model.pop(j)
|
model.pop(j)
|
||||||
model.insert(i, want)
|
model.insert(i, want)
|
||||||
if not failures:
|
if not failures:
|
||||||
pl.synced_fingerprint = fingerprint(pl.name, desired)
|
# Carry forward un-ingestable-but-live YouTube ids so the next merge skips them
|
||||||
pl.dirty = False
|
# (see merge_plan): this run's failed pulls, plus prior ones still on YouTube, minus
|
||||||
|
# any that have since left YouTube. Recomputed each success → self-healing.
|
||||||
|
yt_id_set = set(yt_ids)
|
||||||
|
unavailable = sorted(
|
||||||
|
(effective_unavailable | {v for v in mp.add_to_local if v not in present})
|
||||||
|
& yt_id_set
|
||||||
|
)
|
||||||
|
_set_baseline(pl, final_local, unavailable)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {
|
return {
|
||||||
"created": created,
|
"created": created,
|
||||||
"inserted": inserted,
|
"inserted": inserted,
|
||||||
"deleted": deleted,
|
"deleted": deleted,
|
||||||
"reordered": reordered,
|
"reordered": reordered,
|
||||||
|
"pulled": pulled,
|
||||||
|
"removed_local": removed_local,
|
||||||
"failures": failures,
|
"failures": failures,
|
||||||
"aborted": breaker.tripped,
|
"aborted": breaker.tripped,
|
||||||
"yt_playlist_id": pl.yt_playlist_id,
|
"yt_playlist_id": pl.yt_playlist_id,
|
||||||
|
|||||||
@@ -127,6 +127,10 @@ SPECS: tuple[ConfigSpec, ...] = (
|
|||||||
min=1,
|
min=1,
|
||||||
max=100_000,
|
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(
|
ConfigSpec(
|
||||||
"download_retention_days", "int", "downloads", "download_retention_days", min=0, max=3_650
|
"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"),
|
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.)
|
# (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),
|
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
|
# 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.
|
# (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 ---
|
# --- 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==2.13.4
|
||||||
pydantic_core==2.46.4
|
pydantic_core==2.46.4
|
||||||
python-dotenv==1.2.3
|
python-dotenv==1.2.3
|
||||||
|
python-multipart==0.0.32
|
||||||
requests==2.34.2
|
requests==2.34.2
|
||||||
starlette==1.6.0
|
starlette==1.6.0
|
||||||
typing-inspection==0.4.4
|
typing-inspection==0.4.4
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
fastapi>=0.115,<1.0
|
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
|
uvicorn[standard]>=0.30,<1.0
|
||||||
sqlalchemy>=2.0,<2.1
|
sqlalchemy>=2.0,<2.1
|
||||||
psycopg[binary]>=3.2,<4.0
|
psycopg[binary]>=3.2,<4.0
|
||||||
|
|||||||
@@ -0,0 +1,257 @@
|
|||||||
|
"""S6 — per-user, per-host download cookies: host-scope, cache privacy, encryption, write-only API.
|
||||||
|
|
||||||
|
The two load-bearing security properties are covered as behaviour, not prose:
|
||||||
|
* **cache privacy** — a cookie-authed download is a PRIVATE, salted asset; a second user without a
|
||||||
|
cookie enqueuing the same URL gets the shared (unsalted) asset and can never be cache-served the
|
||||||
|
authed one, and the worker attaches a cookie ONLY to that private identity.
|
||||||
|
* **host scope (SEC-12)** — a cookie is resolved ONLY for a URL whose netloc the credential owns; a
|
||||||
|
look-alike or unrelated host resolves to no host_key and gets no cookie.
|
||||||
|
|
||||||
|
Pure-logic parts run with no DB; the CRUD / enqueue-salting / API parts use the `db`/`as_user` lane.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from cryptography.fernet import Fernet
|
||||||
|
|
||||||
|
from app import security
|
||||||
|
from app.downloads import credentials, formats, service
|
||||||
|
from app.models import DownloadCredential, MediaAsset, User
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _fernet(monkeypatch):
|
||||||
|
# A throwaway key so encrypt/decrypt work without TOKEN_ENCRYPTION_KEY in the env (mirrors
|
||||||
|
# test_security). Autouse: every credential write in this module encrypts.
|
||||||
|
monkeypatch.setattr(security, "_fernet", Fernet(Fernet.generate_key()))
|
||||||
|
|
||||||
|
|
||||||
|
COOKIES = "# Netscape HTTP Cookie File\n.facebook.com\tTRUE\t/\tTRUE\t0\tc_user\t123\n"
|
||||||
|
|
||||||
|
|
||||||
|
# --- host scope (pure logic) ------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestHostKeyForUrl:
|
||||||
|
def test_matches_a_supported_host(self):
|
||||||
|
assert credentials.host_key_for_url("https://www.facebook.com/watch?v=1") == "facebook"
|
||||||
|
assert credentials.host_key_for_url("https://fb.watch/abc/") == "facebook"
|
||||||
|
assert credentials.host_key_for_url("https://x.com/i/status/1") == "twitter"
|
||||||
|
|
||||||
|
def test_look_alike_host_never_matches(self):
|
||||||
|
assert credentials.host_key_for_url("https://myfacebook.com/x") is None
|
||||||
|
assert credentials.host_key_for_url("https://facebook.com.evil.test/x") is None
|
||||||
|
|
||||||
|
def test_unrelated_or_empty_url_has_no_host_key(self):
|
||||||
|
assert credentials.host_key_for_url("https://www.youtube.com/watch?v=abc") is None
|
||||||
|
assert credentials.host_key_for_url("") is None
|
||||||
|
assert credentials.host_key_for_url(None) is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestValidateCookies:
|
||||||
|
def test_accepts_a_netscape_file(self):
|
||||||
|
assert credentials.validate_cookies(COOKIES).endswith("\n")
|
||||||
|
|
||||||
|
def test_rejects_empty(self):
|
||||||
|
with pytest.raises(credentials.InvalidCookies):
|
||||||
|
credentials.validate_cookies(" ")
|
||||||
|
|
||||||
|
def test_rejects_non_cookie_paste(self):
|
||||||
|
# Comments only, no tab-separated data line -> not a usable cookies.txt.
|
||||||
|
with pytest.raises(credentials.InvalidCookies):
|
||||||
|
credentials.validate_cookies("just some pasted text\n# a comment")
|
||||||
|
|
||||||
|
|
||||||
|
def test_authed_sig_differs_per_user_and_from_the_shared_sig():
|
||||||
|
spec = formats.normalize({})
|
||||||
|
shared = formats.format_sig(spec)
|
||||||
|
a = credentials.authed_format_sig(spec, 1, "facebook")
|
||||||
|
b = credentials.authed_format_sig(spec, 2, "facebook")
|
||||||
|
assert a != shared and b != shared and a != b
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_cookiefile_is_private_and_survives_the_retry_wipe(tmp_path):
|
||||||
|
staging = tmp_path / "staging"
|
||||||
|
staging.mkdir()
|
||||||
|
path = credentials._write_cookiefile(COOKIES, staging)
|
||||||
|
assert path.read_text() == COOKIES
|
||||||
|
# 0600 (owner-only) — POSIX perms only (the worker is Linux-only; Windows has no mode bits).
|
||||||
|
if os.name == "posix":
|
||||||
|
assert (path.stat().st_mode & 0o777) == 0o600
|
||||||
|
# It lives in a SUBDIR so the download retry loop (which unlinks files in the staging ROOT) and
|
||||||
|
# _produced_file (which scans the root) never see it.
|
||||||
|
assert path.parent == staging / ".auth"
|
||||||
|
assert not any(p.is_file() for p in staging.iterdir())
|
||||||
|
|
||||||
|
|
||||||
|
# --- CRUD + encryption at rest (DB) -----------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _user(db, email="cred@example.com") -> User:
|
||||||
|
u = User(email=email)
|
||||||
|
db.add(u)
|
||||||
|
db.flush()
|
||||||
|
return u
|
||||||
|
|
||||||
|
|
||||||
|
class TestCrud:
|
||||||
|
def test_upsert_stores_ciphertext_not_plaintext(self, db):
|
||||||
|
u = _user(db)
|
||||||
|
credentials.upsert_credential(db, u.id, "facebook", COOKIES)
|
||||||
|
db.commit()
|
||||||
|
row = db.get(DownloadCredential, 1)
|
||||||
|
assert row.cookies_enc != COOKIES # encrypted at rest
|
||||||
|
assert COOKIES not in row.cookies_enc
|
||||||
|
assert security.decrypt(row.cookies_enc) == COOKIES
|
||||||
|
|
||||||
|
def test_upsert_replaces_in_place(self, db):
|
||||||
|
u = _user(db)
|
||||||
|
credentials.upsert_credential(db, u.id, "facebook", COOKIES)
|
||||||
|
db.commit()
|
||||||
|
credentials.upsert_credential(db, u.id, "facebook", COOKIES.replace("123", "999"))
|
||||||
|
db.commit()
|
||||||
|
rows = db.query(DownloadCredential).filter_by(user_id=u.id, host_key="facebook").all()
|
||||||
|
assert len(rows) == 1
|
||||||
|
assert "999" in security.decrypt(rows[0].cookies_enc)
|
||||||
|
|
||||||
|
def test_status_list_never_leaks_the_blob(self, db):
|
||||||
|
u = _user(db)
|
||||||
|
credentials.upsert_credential(db, u.id, "facebook", COOKIES)
|
||||||
|
db.commit()
|
||||||
|
status = credentials.status_list(db, u.id)
|
||||||
|
fb = next(h for h in status if h["host_key"] == "facebook")
|
||||||
|
assert fb["configured"] is True and fb["updated_at"] is not None
|
||||||
|
ig = next(h for h in status if h["host_key"] == "instagram")
|
||||||
|
assert ig["configured"] is False and ig["updated_at"] is None
|
||||||
|
# No field anywhere carries the cookie value.
|
||||||
|
assert all(COOKIES not in str(v) for h in status for v in h.values())
|
||||||
|
|
||||||
|
def test_delete(self, db):
|
||||||
|
u = _user(db)
|
||||||
|
credentials.upsert_credential(db, u.id, "facebook", COOKIES)
|
||||||
|
db.commit()
|
||||||
|
assert credentials.delete_credential(db, u.id, "facebook") is True
|
||||||
|
db.commit()
|
||||||
|
assert credentials.get_credential(db, u.id, "facebook") is None
|
||||||
|
assert credentials.delete_credential(db, u.id, "facebook") is False
|
||||||
|
|
||||||
|
|
||||||
|
# --- the crux: enqueue salts privately, no cross-user cache serve (DB) -------------------------
|
||||||
|
|
||||||
|
FB_URL = "https://www.facebook.com/watch?v=100"
|
||||||
|
|
||||||
|
|
||||||
|
class TestCachePrivacy:
|
||||||
|
def test_enqueue_with_a_cookie_creates_a_private_salted_asset(self, db):
|
||||||
|
u = _user(db)
|
||||||
|
credentials.upsert_credential(db, u.id, "facebook", COOKIES)
|
||||||
|
db.commit()
|
||||||
|
job = service.enqueue(db, u.id, "url", FB_URL, {})
|
||||||
|
asset = db.get(MediaAsset, job.asset_id)
|
||||||
|
spec = formats.normalize({})
|
||||||
|
assert asset.format_sig == credentials.authed_format_sig(spec, u.id, "facebook")
|
||||||
|
assert asset.format_sig != formats.format_sig(spec)
|
||||||
|
|
||||||
|
def test_enqueue_without_a_cookie_uses_the_shared_sig(self, db):
|
||||||
|
u = _user(db)
|
||||||
|
job = service.enqueue(db, u.id, "url", FB_URL, {})
|
||||||
|
asset = db.get(MediaAsset, job.asset_id)
|
||||||
|
assert asset.format_sig == formats.format_sig(formats.normalize({}))
|
||||||
|
|
||||||
|
def test_two_users_never_share_the_authed_asset(self, db):
|
||||||
|
a = _user(db, "a@example.com")
|
||||||
|
b = _user(db, "b@example.com")
|
||||||
|
credentials.upsert_credential(db, a.id, "facebook", COOKIES)
|
||||||
|
db.commit()
|
||||||
|
ja = service.enqueue(db, a.id, "url", FB_URL, {}) # A: authed, private
|
||||||
|
jb = service.enqueue(db, b.id, "url", FB_URL, {}) # B: no cookie, shared
|
||||||
|
assert ja.asset_id != jb.asset_id
|
||||||
|
# B's asset is the shared identity — A's private group video can never be served to B.
|
||||||
|
assert db.get(MediaAsset, jb.asset_id).format_sig == formats.format_sig(
|
||||||
|
formats.normalize({})
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- the worker gate: attach only to the private identity, host-scoped (DB) --------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestResolveCookiefile:
|
||||||
|
def _asset_sig(self, sig: str) -> MediaAsset:
|
||||||
|
return MediaAsset(
|
||||||
|
source_kind="url", source_ref=FB_URL, format_sig=sig, status="downloading"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_attaches_to_the_private_salted_asset(self, db, tmp_path):
|
||||||
|
u = _user(db)
|
||||||
|
credentials.upsert_credential(db, u.id, "facebook", COOKIES)
|
||||||
|
db.commit()
|
||||||
|
spec = formats.normalize({})
|
||||||
|
salted = credentials.authed_format_sig(spec, u.id, "facebook")
|
||||||
|
path = credentials.resolve_cookiefile(db, u.id, FB_URL, salted, spec, tmp_path)
|
||||||
|
assert path is not None and Path(path).read_text() == COOKIES
|
||||||
|
|
||||||
|
def test_never_attaches_to_a_shared_asset(self, db, tmp_path):
|
||||||
|
# Cookie exists, but the asset is the UNSALTED shared identity -> attaching would poison the
|
||||||
|
# global cache with a private-group video. Must return None.
|
||||||
|
u = _user(db)
|
||||||
|
credentials.upsert_credential(db, u.id, "facebook", COOKIES)
|
||||||
|
db.commit()
|
||||||
|
spec = formats.normalize({})
|
||||||
|
shared = formats.format_sig(spec)
|
||||||
|
assert credentials.resolve_cookiefile(db, u.id, FB_URL, shared, spec, tmp_path) is None
|
||||||
|
|
||||||
|
def test_non_matching_host_gets_no_cookie(self, db, tmp_path):
|
||||||
|
u = _user(db)
|
||||||
|
credentials.upsert_credential(db, u.id, "facebook", COOKIES)
|
||||||
|
db.commit()
|
||||||
|
spec = formats.normalize({})
|
||||||
|
# A YouTube URL has no supported host_key -> no cookie regardless of what's stored.
|
||||||
|
yt = "https://www.youtube.com/watch?v=abc"
|
||||||
|
salted = credentials.authed_format_sig(spec, u.id, "facebook")
|
||||||
|
assert credentials.resolve_cookiefile(db, u.id, yt, salted, spec, tmp_path) is None
|
||||||
|
|
||||||
|
def test_no_credential_gets_no_cookie(self, db, tmp_path):
|
||||||
|
u = _user(db)
|
||||||
|
spec = formats.normalize({})
|
||||||
|
salted = credentials.authed_format_sig(spec, u.id, "facebook")
|
||||||
|
assert credentials.resolve_cookiefile(db, u.id, FB_URL, salted, spec, tmp_path) is None
|
||||||
|
|
||||||
|
|
||||||
|
# --- API: write-only, never echoes the blob (DB) ----------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestApi:
|
||||||
|
def test_put_get_delete_roundtrip_never_returns_the_blob(self, db, as_user):
|
||||||
|
u = _user(db)
|
||||||
|
db.commit()
|
||||||
|
client = as_user(u)
|
||||||
|
|
||||||
|
r = client.put("/api/downloads/credentials/facebook", json={"cookies": COOKIES})
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert body["ok"] is True
|
||||||
|
assert COOKIES not in r.text # the response must never carry the value
|
||||||
|
fb = next(h for h in body["hosts"] if h["host_key"] == "facebook")
|
||||||
|
assert fb["configured"] is True
|
||||||
|
|
||||||
|
r = client.get("/api/downloads/credentials")
|
||||||
|
assert r.status_code == 200 and COOKIES not in r.text
|
||||||
|
|
||||||
|
r = client.delete("/api/downloads/credentials/facebook")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert credentials.get_credential(db, u.id, "facebook") is None
|
||||||
|
|
||||||
|
def test_unknown_host_is_404(self, db, as_user):
|
||||||
|
u = _user(db)
|
||||||
|
db.commit()
|
||||||
|
r = as_user(u).put("/api/downloads/credentials/nope", json={"cookies": COOKIES})
|
||||||
|
assert r.status_code == 404
|
||||||
|
|
||||||
|
def test_bad_cookies_are_rejected(self, db, as_user):
|
||||||
|
u = _user(db)
|
||||||
|
db.commit()
|
||||||
|
r = as_user(u).put("/api/downloads/credentials/facebook", json={"cookies": " "})
|
||||||
|
assert r.status_code == 400
|
||||||
|
assert r.json()["detail"]["code"] == "cookies"
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
"""Worker-child resource de-prioritization (S4): the encoder thread cap, the `-threads` flag on the
|
||||||
|
browser-safe re-encode, and the child's startup renice/ionice/OMP handicap. These keep a CPU-heavy
|
||||||
|
transcode from pegging every core on the box it shares with the API, Postgres, and Plex playback."""
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.downloads import edit as editmod
|
||||||
|
from app.downloads import runner
|
||||||
|
|
||||||
|
|
||||||
|
class TestEncodeThreadCap:
|
||||||
|
def test_pinned_setting_wins(self, monkeypatch):
|
||||||
|
monkeypatch.setattr(settings, "download_ffmpeg_threads", 3)
|
||||||
|
assert editmod.encode_thread_cap() == 3
|
||||||
|
|
||||||
|
def test_zero_reserves_one_core(self, monkeypatch):
|
||||||
|
monkeypatch.setattr(settings, "download_ffmpeg_threads", 0)
|
||||||
|
monkeypatch.setattr(editmod.os, "cpu_count", lambda: 8)
|
||||||
|
assert editmod.encode_thread_cap() == 7
|
||||||
|
|
||||||
|
def test_single_core_box_never_caps_below_one(self, monkeypatch):
|
||||||
|
monkeypatch.setattr(settings, "download_ffmpeg_threads", 0)
|
||||||
|
monkeypatch.setattr(editmod.os, "cpu_count", lambda: 1)
|
||||||
|
assert editmod.encode_thread_cap() == 1
|
||||||
|
|
||||||
|
def test_unknown_cpu_count_falls_back(self, monkeypatch):
|
||||||
|
monkeypatch.setattr(settings, "download_ffmpeg_threads", 0)
|
||||||
|
monkeypatch.setattr(editmod.os, "cpu_count", lambda: None)
|
||||||
|
assert editmod.encode_thread_cap() == 1 # max(1, 2 - 1)
|
||||||
|
|
||||||
|
|
||||||
|
class TestBrowserSafeThreads:
|
||||||
|
def test_threads_flag_added_when_capped(self):
|
||||||
|
cmd = editmod.build_browser_safe_cmd(
|
||||||
|
"/in.mkv", "/out.mp4", reencode_video=True, reencode_audio=False, threads=2
|
||||||
|
)
|
||||||
|
assert "-threads" in cmd
|
||||||
|
assert cmd[cmd.index("-threads") + 1] == "2"
|
||||||
|
# The cap precedes the output file, so it binds the encoder (not input decoding).
|
||||||
|
assert cmd.index("-threads") < cmd.index("/out.mp4")
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("threads", [0, None])
|
||||||
|
def test_no_threads_flag_when_uncapped(self, threads):
|
||||||
|
cmd = editmod.build_browser_safe_cmd(
|
||||||
|
"/in.mkv", "/out.mp4", reencode_video=True, reencode_audio=False, threads=threads
|
||||||
|
)
|
||||||
|
assert "-threads" not in cmd
|
||||||
|
|
||||||
|
|
||||||
|
class TestDeprioritize:
|
||||||
|
def test_renices_by_configured_increment(self, monkeypatch):
|
||||||
|
monkeypatch.setattr(settings, "download_child_nice", 12)
|
||||||
|
monkeypatch.setattr(settings, "download_child_ionice_class", 0) # skip ionice branch
|
||||||
|
calls = []
|
||||||
|
monkeypatch.setattr(runner.os, "nice", lambda n: calls.append(n))
|
||||||
|
monkeypatch.setattr(runner.os, "environ", {})
|
||||||
|
runner._deprioritize()
|
||||||
|
assert calls == [12]
|
||||||
|
|
||||||
|
def test_nice_zero_does_not_renice(self, monkeypatch):
|
||||||
|
monkeypatch.setattr(settings, "download_child_nice", 0)
|
||||||
|
monkeypatch.setattr(settings, "download_child_ionice_class", 0)
|
||||||
|
calls = []
|
||||||
|
monkeypatch.setattr(runner.os, "nice", lambda n: calls.append(n))
|
||||||
|
monkeypatch.setattr(runner.os, "environ", {})
|
||||||
|
runner._deprioritize()
|
||||||
|
assert calls == []
|
||||||
|
|
||||||
|
def test_sets_omp_budget_to_thread_cap(self, monkeypatch):
|
||||||
|
monkeypatch.setattr(settings, "download_child_nice", 0)
|
||||||
|
monkeypatch.setattr(settings, "download_child_ionice_class", 0)
|
||||||
|
monkeypatch.setattr(settings, "download_ffmpeg_threads", 4)
|
||||||
|
env: dict = {}
|
||||||
|
monkeypatch.setattr(runner.os, "environ", env)
|
||||||
|
runner._deprioritize()
|
||||||
|
assert env["OMP_NUM_THREADS"] == "4"
|
||||||
|
|
||||||
|
def test_ionice_invoked_when_binary_present(self, monkeypatch):
|
||||||
|
monkeypatch.setattr(settings, "download_child_nice", 0)
|
||||||
|
monkeypatch.setattr(settings, "download_child_ionice_class", 3)
|
||||||
|
monkeypatch.setattr(runner.os, "environ", {})
|
||||||
|
monkeypatch.setattr(runner.shutil, "which", lambda name: "/usr/bin/ionice")
|
||||||
|
ran = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
runner.subprocess,
|
||||||
|
"run",
|
||||||
|
lambda cmd, **kw: ran.append(cmd) or subprocess.CompletedProcess(cmd, 0),
|
||||||
|
)
|
||||||
|
runner._deprioritize()
|
||||||
|
assert ran and ran[0][:3] == ["ionice", "-c", "3"]
|
||||||
|
|
||||||
|
def test_ionice_skipped_when_binary_absent(self, monkeypatch):
|
||||||
|
monkeypatch.setattr(settings, "download_child_nice", 0)
|
||||||
|
monkeypatch.setattr(settings, "download_child_ionice_class", 3)
|
||||||
|
monkeypatch.setattr(runner.os, "environ", {})
|
||||||
|
monkeypatch.setattr(runner.shutil, "which", lambda name: None)
|
||||||
|
ran = []
|
||||||
|
monkeypatch.setattr(runner.subprocess, "run", lambda cmd, **kw: ran.append(cmd))
|
||||||
|
runner._deprioritize()
|
||||||
|
assert ran == []
|
||||||
@@ -14,6 +14,13 @@ def test_real_ytdlp_messages_map_to_categories():
|
|||||||
"ERROR: [youtube] abc: Sign in to confirm you're not a bot": "login_required",
|
"ERROR: [youtube] abc: Sign in to confirm you're not a bot": "login_required",
|
||||||
"ERROR: [youtube] abc: Join this channel to get access to members-only content": "login_required",
|
"ERROR: [youtube] abc: Join this channel to get access to members-only content": "login_required",
|
||||||
"ERROR: [Reddit] 1vi1x56: Account authentication is required.": "login_required",
|
"ERROR: [Reddit] 1vi1x56: Account authentication is required.": "login_required",
|
||||||
|
# Closed-group / private-post auth walls: yt-dlp either says to sign in or suggests cookies.
|
||||||
|
"ERROR: [facebook] 123: You must be logged in to see this post": "login_required",
|
||||||
|
"ERROR: [facebook] 123: Login required. Use --cookies for the authentication.": "login_required",
|
||||||
|
"ERROR: [instagram] abc: This content is only available for registered users": "login_required",
|
||||||
|
"ERROR: [youtube] abc: Sign in to confirm... Use --cookies-from-browser or --cookies": "login_required",
|
||||||
|
# A site yt-dlp has no extractor for at all — distinct from an auth wall, still non-retryable.
|
||||||
|
"ERROR: Unsupported URL: https://example.com/whatever": "unsupported_host",
|
||||||
"ERROR: [youtube] abc: The uploader has not made this video available in your country": "geo_blocked",
|
"ERROR: [youtube] abc: The uploader has not made this video available in your country": "geo_blocked",
|
||||||
"ERROR: [youtube] abc: Requested format is not available": "format_unavailable",
|
"ERROR: [youtube] abc: Requested format is not available": "format_unavailable",
|
||||||
"ERROR: [youtube] abc: Only images are available for download": "format_unavailable",
|
"ERROR: [youtube] abc: Only images are available for download": "format_unavailable",
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ with "PosixPath('.') has an empty name" instead of downloading."""
|
|||||||
|
|
||||||
import pytest
|
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 app.downloads.pipeline import _media_info, _produced_file
|
||||||
from pathlib import Path, PurePosixPath
|
from pathlib import Path, PurePosixPath
|
||||||
|
|
||||||
@@ -105,39 +105,76 @@ class TestMediaInfo:
|
|||||||
assert _media_info(info)["id"] == "vid"
|
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
|
# 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.
|
# 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):
|
def test_reads_each_stream_by_its_own_type(self):
|
||||||
payload = '{"streams":[{"codec_name":"h264","codec_type":"video"},{"codec_name":"aac","codec_type":"audio"}]}'
|
streams = [
|
||||||
assert parse_probe_streams(payload) == ("h264", "aac")
|
{"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):
|
def test_audio_first_ordering_is_handled(self):
|
||||||
payload = '{"streams":[{"codec_name":"AAC","codec_type":"audio"},{"codec_name":"H264","codec_type":"video"}]}'
|
streams = [
|
||||||
assert parse_probe_streams(payload) == ("h264", "aac")
|
{"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):
|
def test_extra_streams_do_not_win_over_the_first_of_their_kind(self):
|
||||||
payload = (
|
streams = [
|
||||||
'{"streams":[{"codec_name":"h264","codec_type":"video"},'
|
{"codec_name": "h264", "codec_type": "video"},
|
||||||
'{"codec_name":"vp9","codec_type":"video"},'
|
{"codec_name": "vp9", "codec_type": "video"},
|
||||||
'{"codec_name":"mov_text","codec_type":"subtitle"},'
|
{"codec_name": "mov_text", "codec_type": "subtitle"},
|
||||||
'{"codec_name":"opus","codec_type":"audio"}]}'
|
{"codec_name": "opus", "codec_type": "audio"},
|
||||||
)
|
]
|
||||||
assert parse_probe_streams(payload) == ("h264", "opus")
|
assert _codecs_from_streams(streams) == ("h264", "opus")
|
||||||
|
|
||||||
def test_audio_only_and_video_only(self):
|
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,
|
None,
|
||||||
"opus",
|
"opus",
|
||||||
)
|
)
|
||||||
assert parse_probe_streams('{"streams":[{"codec_name":"av1","codec_type":"video"}]}') == (
|
assert _codecs_from_streams([{"codec_name": "av1", "codec_type": "video"}]) == ("av1", None)
|
||||||
"av1",
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_garbage_is_not_a_crash(self):
|
def test_empty_stream_list(self):
|
||||||
assert parse_probe_streams("") == (None, None)
|
assert _codecs_from_streams([]) == (None, None)
|
||||||
assert parse_probe_streams("not json") == (None, None)
|
|
||||||
|
|
||||||
|
class TestRunFfprobeJson:
|
||||||
|
"""The single ffprobe-JSON invocation point (shared by probe_codecs + probe.probe_media). 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:
|
class TestConcatQuote:
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""build_ydl_opts: the mp4 compat profile must SELECT H.264 (not merely prefer it), so shared
|
"""build_ydl_opts: EVERY download is clamped to the mp4/H.264 compat profile (E7 browser-
|
||||||
downloads play on iOS/Safari, while custom vcodec / non-mp4 profiles opt out. See the iPad AV1
|
playability guarantee) and must SELECT H.264 (not merely prefer it), so shared downloads play in
|
||||||
broken-player fix."""
|
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 edit as editmod
|
||||||
from app.downloads import formats
|
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
|
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"})
|
opts = _opts({"mode": "av", "container": "mp4", "vcodec": "vp9"})
|
||||||
assert "vcodec^=avc1" not in opts["format"]
|
assert opts["format"].startswith("bestvideo[vcodec^=avc1]")
|
||||||
assert opts["format"] == "bestvideo+bestaudio/best"
|
assert opts["merge_output_format"] == "mp4"
|
||||||
assert "vcodec:vp9" in opts["format_sort"]
|
assert "vcodec:vp9" not in opts["format_sort"]
|
||||||
|
|
||||||
|
|
||||||
def test_non_mp4_container_opts_out():
|
def test_non_mp4_container_is_clamped_to_mp4():
|
||||||
fmt = _opts({"mode": "av", "container": "webm"})["format"]
|
# A webm/mkv request is coerced to the mp4/H.264 compat profile (nothing plays mkv in-browser;
|
||||||
assert "vcodec^=avc1" not in fmt
|
# 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():
|
def test_audio_only_unaffected():
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import os
|
|||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.downloads import gc as gcmod
|
||||||
from app.downloads.gc import _sweep_orphan_files
|
from app.downloads.gc import _sweep_orphan_files
|
||||||
|
|
||||||
|
|
||||||
@@ -69,3 +71,38 @@ def test_sweep_removes_orphans_but_keeps_owned_system_and_fresh(tmp_path: Path):
|
|||||||
assert not (root / "OldChan").exists()
|
assert not (root / "OldChan").exists()
|
||||||
assert sysfile.exists() # system tree untouched
|
assert sysfile.exists() # system tree untouched
|
||||||
assert fresh.exists() # too new to sweep
|
assert fresh.exists() # too new to sweep
|
||||||
|
|
||||||
|
|
||||||
|
def test_sweep_throttles_after_each_batch(tmp_path: Path, monkeypatch):
|
||||||
|
"""The whole-tree walk yields the IO channel after every `batch` files so it can't monopolize
|
||||||
|
disk IO in the API process. batch=2 over 5 touched files → 2 pauses (at file 2 and 4)."""
|
||||||
|
root = tmp_path
|
||||||
|
d = root / "Chan"
|
||||||
|
d.mkdir()
|
||||||
|
for i in range(5):
|
||||||
|
(d / f"orphan_{i}.dat").write_bytes(b"x")
|
||||||
|
for f in d.iterdir():
|
||||||
|
_aged(f, 5) # old enough that every file is "touched" by the walk
|
||||||
|
|
||||||
|
monkeypatch.setattr(settings, "download_gc_sweep_pause_ms", 10)
|
||||||
|
monkeypatch.setattr(settings, "download_gc_sweep_batch", 2)
|
||||||
|
pauses = []
|
||||||
|
monkeypatch.setattr(gcmod.time, "sleep", lambda s: pauses.append(s))
|
||||||
|
|
||||||
|
_sweep_orphan_files(_StubDB([]), str(root))
|
||||||
|
|
||||||
|
assert pauses == [0.01, 0.01] # 5 files, batch 2 → yield at #2 and #4
|
||||||
|
|
||||||
|
|
||||||
|
def test_sweep_no_throttle_when_pause_disabled(tmp_path: Path, monkeypatch):
|
||||||
|
(tmp_path / "Chan").mkdir()
|
||||||
|
f = tmp_path / "Chan" / "orphan.dat"
|
||||||
|
f.write_bytes(b"x")
|
||||||
|
_aged(f, 5)
|
||||||
|
monkeypatch.setattr(settings, "download_gc_sweep_pause_ms", 0)
|
||||||
|
pauses = []
|
||||||
|
monkeypatch.setattr(gcmod.time, "sleep", lambda s: pauses.append(s))
|
||||||
|
|
||||||
|
_sweep_orphan_files(_StubDB([]), str(tmp_path))
|
||||||
|
|
||||||
|
assert pauses == []
|
||||||
|
|||||||
@@ -4,7 +4,14 @@ token, rides in the file URL (a <video src> can't send a header), and expires.""
|
|||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
|
||||||
from app.downloads.links import _GRANT_TTL, check_grant, is_expired, make_grant, new_token
|
from app.downloads.links import (
|
||||||
|
_GRANT_TTL,
|
||||||
|
check_grant,
|
||||||
|
is_expired,
|
||||||
|
make_grant,
|
||||||
|
new_token,
|
||||||
|
public_meta,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_new_token_is_unguessable_and_unique():
|
def test_new_token_is_unguessable_and_unique():
|
||||||
@@ -48,6 +55,67 @@ class TestGrant:
|
|||||||
assert check_grant("tok", bad, 0) is False
|
assert check_grant("tok", bad, 0) is False
|
||||||
|
|
||||||
|
|
||||||
|
def _asset(**over):
|
||||||
|
base = dict(
|
||||||
|
title="Clip",
|
||||||
|
uploader="Someone",
|
||||||
|
uploader_url=None,
|
||||||
|
duration_s=42,
|
||||||
|
width=1280,
|
||||||
|
height=720,
|
||||||
|
vcodec="h264",
|
||||||
|
acodec="aac",
|
||||||
|
media_kind="video",
|
||||||
|
size_bytes=1000,
|
||||||
|
container="mp4",
|
||||||
|
source_webpage_url=None,
|
||||||
|
source_kind="url",
|
||||||
|
)
|
||||||
|
base.update(over)
|
||||||
|
return SimpleNamespace(**base)
|
||||||
|
|
||||||
|
|
||||||
|
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 (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(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
|
||||||
|
|
||||||
|
def test_a_video_asset_is_not_audio(self):
|
||||||
|
meta = public_meta(self._link, None, _asset(), "/f.mp4")
|
||||||
|
assert meta["audio"] is False
|
||||||
|
|
||||||
|
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:
|
class TestIsExpired:
|
||||||
def test_none_expiry_never_expires(self):
|
def test_none_expiry_never_expires(self):
|
||||||
assert is_expired(SimpleNamespace(expires_at=None)) is False
|
assert is_expired(SimpleNamespace(expires_at=None)) is False
|
||||||
|
|||||||
@@ -0,0 +1,360 @@
|
|||||||
|
"""S7 — two-way (3-way) YouTube playlist merge.
|
||||||
|
|
||||||
|
The load-bearing property: an item added (or deleted) on EITHER side survives the sync — the old
|
||||||
|
push was one-directional (local wins, deletes everything not local from YouTube), so YouTube-side
|
||||||
|
adds were lost. Pure-merge classification runs with no DB; the apply path uses the `db` lane with a
|
||||||
|
fake YouTube client so both the local rows and the simulated YouTube state can be asserted.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.models import Channel, Playlist, PlaylistItem, Video
|
||||||
|
from app.sync import playlists as pl_mod
|
||||||
|
from app.sync.playlists import (
|
||||||
|
merge_plan,
|
||||||
|
merge_playlist,
|
||||||
|
parse_baseline,
|
||||||
|
parse_unavailable,
|
||||||
|
plan_merge,
|
||||||
|
)
|
||||||
|
from app.youtube.client import YouTubeError
|
||||||
|
|
||||||
|
# --- pure merge classification (no DB) --------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestMergePlan:
|
||||||
|
def test_no_baseline_unions_losslessly(self):
|
||||||
|
# First-ever merge: can't tell an add from a delete, so keep everything, delete nothing.
|
||||||
|
mp = merge_plan(None, local=["a", "b"], youtube=["b", "c"])
|
||||||
|
assert mp.add_to_yt == ["a"] # local-only pushed out
|
||||||
|
assert mp.add_to_local == ["c"] # youtube-only pulled in
|
||||||
|
assert mp.remove_from_yt == [] and mp.remove_from_local == []
|
||||||
|
assert mp.final == ["a", "b", "c"] # local order, youtube-only appended
|
||||||
|
assert mp.baseline_known is False
|
||||||
|
|
||||||
|
def test_adds_on_both_sides_both_survive(self):
|
||||||
|
# baseline b,c ; local added 'a' ; youtube added 'd' — nothing deleted.
|
||||||
|
mp = merge_plan(["b", "c"], local=["a", "b", "c"], youtube=["b", "c", "d"])
|
||||||
|
assert mp.add_to_yt == ["a"]
|
||||||
|
assert mp.add_to_local == ["d"]
|
||||||
|
assert mp.remove_from_yt == [] and mp.remove_from_local == []
|
||||||
|
assert mp.final == ["a", "b", "c", "d"]
|
||||||
|
|
||||||
|
def test_genuine_local_delete_propagates_to_youtube(self):
|
||||||
|
# baseline a,b,c ; local removed 'b' ; youtube unchanged -> remove b from youtube.
|
||||||
|
mp = merge_plan(["a", "b", "c"], local=["a", "c"], youtube=["a", "b", "c"])
|
||||||
|
assert mp.remove_from_yt == ["b"]
|
||||||
|
assert mp.add_to_yt == [] and mp.add_to_local == []
|
||||||
|
assert mp.final == ["a", "c"]
|
||||||
|
|
||||||
|
def test_genuine_youtube_delete_propagates_to_local(self):
|
||||||
|
# baseline a,b,c ; youtube removed 'b' ; local unchanged -> remove b from local.
|
||||||
|
mp = merge_plan(["a", "b", "c"], local=["a", "b", "c"], youtube=["a", "c"])
|
||||||
|
assert mp.remove_from_local == ["b"]
|
||||||
|
assert mp.final == ["a", "c"]
|
||||||
|
|
||||||
|
def test_delete_on_both_sides_is_gone_not_a_conflict(self):
|
||||||
|
mp = merge_plan(["a", "b", "c"], local=["a", "c"], youtube=["a", "c"])
|
||||||
|
assert mp.remove_from_yt == [] and mp.remove_from_local == []
|
||||||
|
assert mp.final == ["a", "c"]
|
||||||
|
|
||||||
|
def test_a_local_delete_beats_a_stale_youtube_copy(self):
|
||||||
|
# 'b' was synced, deleted locally, still present on youtube -> it must be removed there,
|
||||||
|
# NOT resurrected into local as a youtube-add.
|
||||||
|
mp = merge_plan(["a", "b"], local=["a"], youtube=["a", "b"])
|
||||||
|
assert mp.add_to_local == []
|
||||||
|
assert mp.remove_from_yt == ["b"]
|
||||||
|
assert "b" not in mp.final
|
||||||
|
|
||||||
|
|
||||||
|
# --- apply path (db lane + fake YouTube) ------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class FakeYouTube:
|
||||||
|
"""Enough of YouTubeClient for merge_playlist: an in-memory playlist of (item_id, video_id)
|
||||||
|
and the write ops that mutate it. Every catalog video is pre-seeded so _ensure_videos never
|
||||||
|
needs get_videos."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
title: str,
|
||||||
|
items: list[str],
|
||||||
|
unavailable: set[str] | None = None,
|
||||||
|
fail_add: set[str] | None = None,
|
||||||
|
):
|
||||||
|
self.title = title
|
||||||
|
self._seq = 0
|
||||||
|
self.items = [(self._new_id(), v) for v in items]
|
||||||
|
# Ids the Data API won't return (private/deleted) — get_videos omits them, so _ensure_videos
|
||||||
|
# can't ingest them. They still live in the playlist (`items`) like a real un-ingestable video.
|
||||||
|
self.unavailable = set(unavailable or ())
|
||||||
|
# Ids whose add_playlist_item raises, to exercise the partial-failure baseline-hold path.
|
||||||
|
self.fail_add = set(fail_add or ())
|
||||||
|
|
||||||
|
def _new_id(self) -> str:
|
||||||
|
self._seq += 1
|
||||||
|
return f"it{self._seq}"
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *exc):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def create_playlist(self, title, description="", privacy="private"):
|
||||||
|
self.title = title
|
||||||
|
return "PLnew"
|
||||||
|
|
||||||
|
def get_playlist_snippet(self, pid):
|
||||||
|
return {"title": self.title}
|
||||||
|
|
||||||
|
def iter_playlist_items_with_ids(self, pid):
|
||||||
|
return [{"item_id": iid, "video_id": v} for iid, v in self.items]
|
||||||
|
|
||||||
|
def get_videos(self, ids):
|
||||||
|
# Mirror the Data API: a minimal ingestable record for each id, EXCEPT the un-ingestable
|
||||||
|
# ones (private/deleted), which are simply absent from the response.
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": v,
|
||||||
|
"snippet": {
|
||||||
|
"channelId": "UCtest",
|
||||||
|
"channelTitle": "Test",
|
||||||
|
"publishedAt": "2020-01-01T00:00:00Z",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for v in ids
|
||||||
|
if v not in self.unavailable
|
||||||
|
]
|
||||||
|
|
||||||
|
def update_playlist_title(self, pid, title):
|
||||||
|
self.title = title
|
||||||
|
|
||||||
|
def add_playlist_item(self, pid, vid, position=None):
|
||||||
|
if vid in self.fail_add:
|
||||||
|
raise YouTubeError("simulated add failure")
|
||||||
|
iid = self._new_id()
|
||||||
|
self.items.append((iid, vid))
|
||||||
|
return iid
|
||||||
|
|
||||||
|
def delete_playlist_item(self, item_id):
|
||||||
|
self.items = [(iid, v) for iid, v in self.items if iid != item_id]
|
||||||
|
|
||||||
|
def move_playlist_item(self, item_id, pid, vid, position):
|
||||||
|
row = next((r for r in self.items if r[0] == item_id), None)
|
||||||
|
if row is not None:
|
||||||
|
self.items.remove(row)
|
||||||
|
self.items.insert(position, row)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def video_ids(self) -> list[str]:
|
||||||
|
return [v for _, v in self.items]
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_videos(db, ids):
|
||||||
|
db.add(Channel(id="UCtest", title="Test"))
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
for v in ids:
|
||||||
|
db.add(Video(id=v, channel_id="UCtest", published_at=now))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _linked_playlist(db, user, *, local_ids, baseline):
|
||||||
|
pl = Playlist(
|
||||||
|
user_id=user.id,
|
||||||
|
name="Mix",
|
||||||
|
source="local",
|
||||||
|
yt_playlist_id="PLyt",
|
||||||
|
synced_video_ids=None if baseline is None else pl_mod._dump_ids(baseline),
|
||||||
|
)
|
||||||
|
db.add(pl)
|
||||||
|
db.flush()
|
||||||
|
for pos, v in enumerate(local_ids):
|
||||||
|
db.add(PlaylistItem(playlist_id=pl.id, video_id=v, position=pos))
|
||||||
|
db.commit()
|
||||||
|
return pl
|
||||||
|
|
||||||
|
|
||||||
|
def _local_ids(db, pl):
|
||||||
|
return pl_mod._local_video_ids(db, pl)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def _fake_yt(monkeypatch):
|
||||||
|
holder = {}
|
||||||
|
|
||||||
|
def install(fake):
|
||||||
|
holder["fake"] = fake
|
||||||
|
monkeypatch.setattr(pl_mod, "YouTubeClient", lambda db, user: fake)
|
||||||
|
return fake
|
||||||
|
|
||||||
|
return install
|
||||||
|
|
||||||
|
|
||||||
|
def test_adds_on_both_sides_survive_on_both_sides(db, make_user, _fake_yt):
|
||||||
|
user = make_user()
|
||||||
|
_seed_videos(db, ["a", "b", "c", "d"])
|
||||||
|
# baseline b,c ; local added 'a' ; youtube added 'd'
|
||||||
|
pl = _linked_playlist(db, user, local_ids=["a", "b", "c"], baseline=["b", "c"])
|
||||||
|
fake = _fake_yt(FakeYouTube(title="Mix", items=["b", "c", "d"]))
|
||||||
|
|
||||||
|
r = merge_playlist(db, user, pl)
|
||||||
|
|
||||||
|
assert r["failures"] == []
|
||||||
|
assert set(_local_ids(db, pl)) == {"a", "b", "c", "d"}, "youtube-add 'd' pulled into Siftlode"
|
||||||
|
assert set(fake.video_ids) == {"a", "b", "c", "d"}, "local-add 'a' pushed to YouTube"
|
||||||
|
assert r["inserted"] == 1 and r["pulled"] == 1
|
||||||
|
# Baseline advanced to the merged set so a re-sync is a no-op.
|
||||||
|
assert set(parse_baseline(pl)) == {"a", "b", "c", "d"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_genuine_delete_propagates_without_collateral_loss(db, make_user, _fake_yt):
|
||||||
|
user = make_user()
|
||||||
|
_seed_videos(db, ["a", "b", "c", "d"])
|
||||||
|
# baseline a,b,c ; local removed 'b' ; youtube added 'd' (a real add, must NOT be collateral)
|
||||||
|
pl = _linked_playlist(db, user, local_ids=["a", "c"], baseline=["a", "b", "c"])
|
||||||
|
fake = _fake_yt(FakeYouTube(title="Mix", items=["a", "b", "c", "d"]))
|
||||||
|
|
||||||
|
r = merge_playlist(db, user, pl)
|
||||||
|
|
||||||
|
assert r["failures"] == []
|
||||||
|
assert set(fake.video_ids) == {"a", "c", "d"}, "'b' removed on YouTube, 'd' kept"
|
||||||
|
assert set(_local_ids(db, pl)) == {"a", "c", "d"}, "'d' pulled in, 'b' stays deleted"
|
||||||
|
assert r["deleted"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_first_merge_with_no_baseline_never_deletes(db, make_user, _fake_yt):
|
||||||
|
user = make_user()
|
||||||
|
_seed_videos(db, ["a", "b", "c"])
|
||||||
|
pl = _linked_playlist(db, user, local_ids=["a", "b"], baseline=None)
|
||||||
|
fake = _fake_yt(FakeYouTube(title="Mix", items=["b", "c"]))
|
||||||
|
|
||||||
|
r = merge_playlist(db, user, pl)
|
||||||
|
|
||||||
|
assert r["deleted"] == 0 and r["removed_local"] == 0
|
||||||
|
assert set(_local_ids(db, pl)) == {"a", "b", "c"}
|
||||||
|
assert set(fake.video_ids) == {"a", "b", "c"}
|
||||||
|
assert parse_baseline(pl) is not None, "a baseline is recorded after the first union"
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_path_appends_and_records_baseline(db, make_user, _fake_yt):
|
||||||
|
user = make_user()
|
||||||
|
_seed_videos(db, ["a", "b"])
|
||||||
|
pl = Playlist(user_id=user.id, name="Fresh", source="local")
|
||||||
|
db.add(pl)
|
||||||
|
db.flush()
|
||||||
|
for pos, v in enumerate(["a", "b"]):
|
||||||
|
db.add(PlaylistItem(playlist_id=pl.id, video_id=v, position=pos))
|
||||||
|
db.commit()
|
||||||
|
_fake_yt(FakeYouTube(title="Fresh", items=[]))
|
||||||
|
|
||||||
|
r = merge_playlist(db, user, pl)
|
||||||
|
|
||||||
|
assert r["created"] == 1 and r["inserted"] == 2
|
||||||
|
assert pl.yt_playlist_id # created and linked
|
||||||
|
assert parse_baseline(pl) == ["a", "b"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_reorder_reaches_the_merged_order_on_youtube(db, make_user, _fake_yt):
|
||||||
|
# Same membership on both sides, but the user reordered locally to c,a,b. The apply-side
|
||||||
|
# insertion-sort must drive YouTube to that exact order — asserted as an ordered list, not a
|
||||||
|
# set, so an off-by-one or a dropped reorder pass can't pass unnoticed.
|
||||||
|
user = make_user()
|
||||||
|
_seed_videos(db, ["a", "b", "c"])
|
||||||
|
pl = _linked_playlist(db, user, local_ids=["c", "a", "b"], baseline=["a", "b", "c"])
|
||||||
|
fake = _fake_yt(FakeYouTube(title="Mix", items=["a", "b", "c"]))
|
||||||
|
|
||||||
|
r = merge_playlist(db, user, pl)
|
||||||
|
|
||||||
|
assert r["failures"] == []
|
||||||
|
assert fake.video_ids == ["c", "a", "b"], "YouTube order matches the merged (local) order"
|
||||||
|
assert r["reordered"] >= 1
|
||||||
|
assert parse_baseline(pl) == ["c", "a", "b"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_partial_youtube_failure_holds_the_baseline_for_retry(db, make_user, _fake_yt):
|
||||||
|
# A local-add whose YouTube insert fails must NOT advance the baseline — otherwise the retry
|
||||||
|
# sync would read the un-pushed add as a genuine YouTube-side delete and silently drop it.
|
||||||
|
user = make_user()
|
||||||
|
_seed_videos(db, ["a", "b", "c"])
|
||||||
|
pl = _linked_playlist(db, user, local_ids=["a", "b", "c"], baseline=["b", "c"])
|
||||||
|
fake = _fake_yt(FakeYouTube(title="Mix", items=["b", "c"], fail_add={"a"}))
|
||||||
|
|
||||||
|
r = merge_playlist(db, user, pl)
|
||||||
|
|
||||||
|
assert r["failures"] == ["a"], "the failed local-add is reported"
|
||||||
|
assert "a" not in fake.video_ids, "the add never landed on YouTube"
|
||||||
|
# Baseline is UNCHANGED so the next sync re-classifies 'a' as a local-add and re-pushes it,
|
||||||
|
# rather than treating it as a YouTube delete and removing it locally.
|
||||||
|
assert parse_baseline(pl) == ["b", "c"]
|
||||||
|
assert "a" in set(_local_ids(db, pl)), "'a' stays local for the retry"
|
||||||
|
|
||||||
|
|
||||||
|
def test_unavailable_youtube_video_converges(db, make_user, _fake_yt):
|
||||||
|
# A YouTube-only video that can't be ingested (private/deleted) must not make the playlist
|
||||||
|
# loop forever re-classifying it as a pending pull. It is recorded as un-ingestable and skipped
|
||||||
|
# by the next merge; the playlist reports "up to date".
|
||||||
|
user = make_user()
|
||||||
|
_seed_videos(db, ["a", "b"]) # note: 'x' is NOT in the catalog and can't be ingested
|
||||||
|
pl = _linked_playlist(db, user, local_ids=["a", "b"], baseline=["a", "b"])
|
||||||
|
fake = _fake_yt(FakeYouTube(title="Mix", items=["a", "b", "x"], unavailable={"x"}))
|
||||||
|
|
||||||
|
r1 = merge_playlist(db, user, pl)
|
||||||
|
|
||||||
|
assert r1["failures"] == []
|
||||||
|
assert r1["pulled"] == 0, "the un-ingestable video did not become a local row"
|
||||||
|
assert set(_local_ids(db, pl)) == {"a", "b"}
|
||||||
|
assert parse_unavailable(pl) == {"x"}, "'x' recorded as un-ingestable"
|
||||||
|
assert "x" in fake.video_ids, "'x' left untouched on YouTube"
|
||||||
|
|
||||||
|
# Second sync: the preview reports nothing pending (plan_merge filters 'x' via the stored
|
||||||
|
# unavailable set). The sync itself re-attempts ingestion of 'x' (the heal path), but since it
|
||||||
|
# still won't ingest, 'x' stays recorded as unavailable — no perpetual pending pull.
|
||||||
|
plan = plan_merge(db, user, pl)
|
||||||
|
assert plan["add_local"] == 0, "no perpetual pending pull — the playlist is up to date"
|
||||||
|
r2 = merge_playlist(db, user, pl)
|
||||||
|
assert r2["pulled"] == 0 and r2["failures"] == []
|
||||||
|
assert parse_unavailable(pl) == {"x"}
|
||||||
|
assert "x" in fake.video_ids
|
||||||
|
|
||||||
|
|
||||||
|
def test_unavailable_video_that_becomes_available_is_pulled_in(db, make_user, _fake_yt):
|
||||||
|
# A video marked un-ingestable (e.g. private) that later becomes available (public) must
|
||||||
|
# re-enter the merge on a normal sync — not stay skipped forever.
|
||||||
|
user = make_user()
|
||||||
|
_seed_videos(db, ["a", "b"])
|
||||||
|
pl = _linked_playlist(db, user, local_ids=["a", "b"], baseline=["a", "b"])
|
||||||
|
fake = _fake_yt(FakeYouTube(title="Mix", items=["a", "b", "x"], unavailable={"x"}))
|
||||||
|
|
||||||
|
merge_playlist(db, user, pl)
|
||||||
|
assert parse_unavailable(pl) == {"x"}, "recorded un-ingestable on the first sync"
|
||||||
|
|
||||||
|
# 'x' becomes available; the next sync re-attempts ingestion and pulls it in.
|
||||||
|
fake.unavailable.clear()
|
||||||
|
r2 = merge_playlist(db, user, pl)
|
||||||
|
|
||||||
|
assert r2["failures"] == []
|
||||||
|
assert "x" in set(_local_ids(db, pl)), "the now-available video is pulled into Siftlode"
|
||||||
|
assert parse_unavailable(pl) == set(), "'x' is no longer marked un-ingestable"
|
||||||
|
|
||||||
|
|
||||||
|
def test_unavailable_id_that_leaves_youtube_is_pruned(db, make_user, _fake_yt):
|
||||||
|
# A recorded un-ingestable id that is later removed from the YouTube playlist must be pruned
|
||||||
|
# from the persisted `unavailable` set (the `& yt_id_set` self-heal), not carried forever.
|
||||||
|
user = make_user()
|
||||||
|
_seed_videos(db, ["a", "b"])
|
||||||
|
pl = _linked_playlist(db, user, local_ids=["a", "b"], baseline=["a", "b"])
|
||||||
|
fake = _fake_yt(FakeYouTube(title="Mix", items=["a", "b", "x"], unavailable={"x"}))
|
||||||
|
|
||||||
|
merge_playlist(db, user, pl)
|
||||||
|
assert parse_unavailable(pl) == {"x"}, "recorded un-ingestable on the first sync"
|
||||||
|
|
||||||
|
# 'x' is removed from the YouTube playlist entirely; the next sync must drop it.
|
||||||
|
fake.items = [row for row in fake.items if row[1] != "x"]
|
||||||
|
r2 = merge_playlist(db, user, pl)
|
||||||
|
|
||||||
|
assert r2["failures"] == []
|
||||||
|
assert parse_unavailable(pl) == set(), "'x' pruned once it left YouTube"
|
||||||
@@ -0,0 +1,495 @@
|
|||||||
|
"""The playback-class filter (E7): filter the Plex feed by Direct Play / Direct Stream / Transcode·HW /
|
||||||
|
Transcode·SW. Covers the shared classifier + Stream parsing (unit), the sync derivation + codec-change
|
||||||
|
reset, the bit-depth backfill, and the /library filter + /facets counts (DB lane).
|
||||||
|
|
||||||
|
The HW/SW split is derived from the stored `transcode_hw` facet (codec at sync, refined by the bit-depth
|
||||||
|
backfill). These tests pin that a 10-bit/HDR HEVC lands in SW even though its codec is HW-decodable —
|
||||||
|
the exact case the runtime VAAPI path (plex.stream) also special-cases.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
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 sync
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
class TestTranscodeHwCapable:
|
||||||
|
"""The single classifier both the catalog facet and (by codec) the runtime encoder choice key off."""
|
||||||
|
|
||||||
|
def test_non_transcode_items_are_none(self):
|
||||||
|
assert sync.transcode_hw_capable("direct", "h264") is None
|
||||||
|
assert sync.transcode_hw_capable("remux", "h264") is None
|
||||||
|
assert sync.transcode_hw_capable(None, "hevc") is None
|
||||||
|
|
||||||
|
def test_hw_decodable_8bit_sdr_is_hw(self):
|
||||||
|
assert sync.transcode_hw_capable("transcode", "hevc") is True
|
||||||
|
assert sync.transcode_hw_capable("transcode", "vp9", bit_depth=8) is True
|
||||||
|
|
||||||
|
def test_non_decodable_codec_is_sw(self):
|
||||||
|
# AV1 / MPEG-4 ASP (XviD) can't be HW-decoded on Gen 9.5 → software regardless of bit depth.
|
||||||
|
assert sync.transcode_hw_capable("transcode", "av1") is False
|
||||||
|
assert sync.transcode_hw_capable("transcode", "mpeg4", bit_depth=8) is False
|
||||||
|
|
||||||
|
def test_10bit_or_hdr_hevc_is_sw(self):
|
||||||
|
# A HW-decodable codec that is 10-bit or HDR still breaks the VAAPI nv12 pipeline → software.
|
||||||
|
assert sync.transcode_hw_capable("transcode", "hevc", bit_depth=10) is False
|
||||||
|
assert sync.transcode_hw_capable("transcode", "hevc", bit_depth=8, hdr=True) is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestVideoStreamProps:
|
||||||
|
def test_reads_bit_depth_and_hdr_from_the_video_stream(self):
|
||||||
|
meta = {
|
||||||
|
"Media": [
|
||||||
|
{
|
||||||
|
"Part": [
|
||||||
|
{
|
||||||
|
"Stream": [
|
||||||
|
{"streamType": 2, "codec": "eac3"}, # audio — skipped
|
||||||
|
{"streamType": 1, "bitDepth": 10, "colorTrc": "smpte2084"},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
assert sync._video_stream_props(meta) == (10, True)
|
||||||
|
|
||||||
|
def test_8bit_sdr_stream(self):
|
||||||
|
meta = {"Media": [{"Part": [{"Stream": [{"streamType": 1, "bitDepth": 8}]}]}]}
|
||||||
|
assert sync._video_stream_props(meta) == (8, False)
|
||||||
|
|
||||||
|
def test_absent_stream_is_none_false(self):
|
||||||
|
assert sync._video_stream_props({}) == (None, False)
|
||||||
|
assert sync._video_stream_props({"Media": [{"Part": [{}]}]}) == (None, False)
|
||||||
|
|
||||||
|
|
||||||
|
class TestSyncDerivation:
|
||||||
|
"""`_apply_item` sets transcode_hw codec-only at sync (bit depth unknown from the listing), and a
|
||||||
|
changed video codec resets the probe watermark so a stale bit-depth can't linger."""
|
||||||
|
|
||||||
|
def _meta(self, vcodec, *, key="/parts/1/file.mkv", file="/m.mkv"):
|
||||||
|
return {
|
||||||
|
"title": "X",
|
||||||
|
"Media": [{"videoCodec": vcodec, "Part": [{"file": file, "key": key}]}],
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_codec_only_derivation_at_sync(self):
|
||||||
|
row = PlexItem(rating_key="x", kind="movie")
|
||||||
|
sync._apply_item(row, lib_id=1, kind="movie", meta=self._meta("hevc"))
|
||||||
|
assert row.playable == "transcode" and row.transcode_hw is True # decodable, 8-bit assumed
|
||||||
|
row2 = PlexItem(rating_key="y", kind="movie")
|
||||||
|
sync._apply_item(row2, lib_id=1, kind="movie", meta=self._meta("av1"))
|
||||||
|
assert row2.transcode_hw is False # av1 not HW-decodable
|
||||||
|
|
||||||
|
def test_probed_value_survives_resync_when_codec_unchanged(self):
|
||||||
|
row = PlexItem(rating_key="x", kind="movie")
|
||||||
|
sync._apply_item(row, lib_id=1, kind="movie", meta=self._meta("hevc"))
|
||||||
|
# Simulate the backfill downgrading this 10-bit HEVC to software.
|
||||||
|
row.transcode_hw = False
|
||||||
|
row.video_bit_depth = 10
|
||||||
|
row.media_probed_at = sync.datetime.now(sync.timezone.utc)
|
||||||
|
sync._apply_item(row, lib_id=1, kind="movie", meta=self._meta("hevc"))
|
||||||
|
assert row.transcode_hw is False # NOT re-derived back to True
|
||||||
|
assert row.video_bit_depth == 10 and row.media_probed_at is not None
|
||||||
|
|
||||||
|
def test_codec_change_resets_the_probe(self):
|
||||||
|
row = PlexItem(rating_key="x", kind="movie")
|
||||||
|
sync._apply_item(row, lib_id=1, kind="movie", meta=self._meta("hevc"))
|
||||||
|
row.transcode_hw = False
|
||||||
|
row.video_bit_depth = 10
|
||||||
|
row.media_probed_at = sync.datetime.now(sync.timezone.utc)
|
||||||
|
# The file was replaced with an h264 remux — the stale 10-bit probe must be cleared.
|
||||||
|
sync._apply_item(row, lib_id=1, kind="movie", meta=self._meta("h264"))
|
||||||
|
assert row.video_bit_depth is None and row.media_probed_at is None
|
||||||
|
|
||||||
|
def test_same_codec_reencode_with_a_new_part_resets_the_probe(self):
|
||||||
|
# A same-codec bit-depth re-encode (e.g. 8-bit→10-bit HEVC) keeps codec 'hevc' but replaces the
|
||||||
|
# physical file, so its Plex Part key changes. Codec-only reset would miss this and keep the stale
|
||||||
|
# 8-bit/HW verdict forever (the backfill only re-probes media_probed_at IS NULL rows) — the
|
||||||
|
# part-key move must reset the watermark so the new bit depth is re-probed.
|
||||||
|
row = PlexItem(rating_key="x", kind="movie")
|
||||||
|
sync._apply_item(row, lib_id=1, kind="movie", meta=self._meta("hevc", key="/parts/1/f.mkv"))
|
||||||
|
row.transcode_hw = True
|
||||||
|
row.video_bit_depth = 8
|
||||||
|
row.media_probed_at = sync.datetime.now(sync.timezone.utc)
|
||||||
|
# Re-encoded in place: same codec 'hevc', but a new Part key (new file).
|
||||||
|
sync._apply_item(row, lib_id=1, kind="movie", meta=self._meta("hevc", key="/parts/2/f.mkv"))
|
||||||
|
assert row.video_bit_depth is None and row.media_probed_at is None # re-probe scheduled
|
||||||
|
|
||||||
|
|
||||||
|
def test_migration_codec_set_matches_hw_decodable_codecs():
|
||||||
|
# Migration 0077 hardcodes the HW-decodable codec set in raw SQL and its comment mandates it stay
|
||||||
|
# identical to plex.sync.HW_DECODABLE_CODECS. A comment isn't enforcement (house rule) — pin the
|
||||||
|
# equality so adding/removing a codec from the frozenset without editing the migration fails here
|
||||||
|
# (else already-migrated prod rows keep a wrong transcode_hw the runtime derivation wouldn't catch).
|
||||||
|
mig = (
|
||||||
|
Path(__file__).resolve().parent.parent
|
||||||
|
/ "alembic"
|
||||||
|
/ "versions"
|
||||||
|
/ "0077_plex_playback_class.py"
|
||||||
|
)
|
||||||
|
m = re.search(r"lower\(codec_video\)\s+IN\s+\(([^)]*)\)", mig.read_text())
|
||||||
|
assert m, "could not find the codec IN(...) list in migration 0077"
|
||||||
|
codecs = {c.strip().strip("'\"").lower() for c in m.group(1).split(",")}
|
||||||
|
assert codecs == {c.lower() for c in sync.HW_DECODABLE_CODECS}
|
||||||
|
|
||||||
|
|
||||||
|
class _FakePlex:
|
||||||
|
"""Stands in for PlexClient.metadata — returns a canned full-metadata dict per rating_key."""
|
||||||
|
|
||||||
|
def __init__(self, by_key):
|
||||||
|
self.by_key = by_key
|
||||||
|
|
||||||
|
def metadata(self, rating_key, markers=False):
|
||||||
|
return self.by_key.get(rating_key)
|
||||||
|
|
||||||
|
|
||||||
|
def test_probe_media_batch_refines_hw_to_sw_for_10bit(db):
|
||||||
|
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
||||||
|
db.add(lib)
|
||||||
|
db.flush()
|
||||||
|
hevc8 = PlexItem(
|
||||||
|
rating_key="h8",
|
||||||
|
library_id=lib.id,
|
||||||
|
kind="movie",
|
||||||
|
title="8bit",
|
||||||
|
playable="transcode",
|
||||||
|
codec_video="hevc",
|
||||||
|
transcode_hw=True,
|
||||||
|
)
|
||||||
|
hevc10 = PlexItem(
|
||||||
|
rating_key="h10",
|
||||||
|
library_id=lib.id,
|
||||||
|
kind="movie",
|
||||||
|
title="10bit",
|
||||||
|
playable="transcode",
|
||||||
|
codec_video="hevc",
|
||||||
|
transcode_hw=True,
|
||||||
|
)
|
||||||
|
xvid = PlexItem(
|
||||||
|
rating_key="xv",
|
||||||
|
library_id=lib.id,
|
||||||
|
kind="movie",
|
||||||
|
title="xvid",
|
||||||
|
playable="transcode",
|
||||||
|
codec_video="mpeg4",
|
||||||
|
transcode_hw=False,
|
||||||
|
)
|
||||||
|
db.add_all([hevc8, hevc10, xvid])
|
||||||
|
db.commit()
|
||||||
|
plex = _FakePlex(
|
||||||
|
{
|
||||||
|
"h8": {"Media": [{"Part": [{"Stream": [{"streamType": 1, "bitDepth": 8}]}]}]},
|
||||||
|
"h10": {"Media": [{"Part": [{"Stream": [{"streamType": 1, "bitDepth": 10}]}]}]},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
probed = sync._probe_media_batch(db, plex)
|
||||||
|
db.refresh(hevc8)
|
||||||
|
db.refresh(hevc10)
|
||||||
|
db.refresh(xvid)
|
||||||
|
# Only the two HW-decodable HEVC items are probed; the XviD is SW-by-codec and never fetched.
|
||||||
|
assert probed == 2
|
||||||
|
assert hevc8.video_bit_depth == 8 and hevc8.transcode_hw is True and hevc8.media_probed_at
|
||||||
|
assert hevc10.video_bit_depth == 10 and hevc10.transcode_hw is False # downgraded to software
|
||||||
|
assert xvid.media_probed_at is None # not in the work-list
|
||||||
|
# A second run has nothing left to do (watermarks set).
|
||||||
|
assert sync._probe_media_batch(db, plex) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_probe_stamps_even_when_plex_has_no_stream(db):
|
||||||
|
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
||||||
|
db.add(lib)
|
||||||
|
db.flush()
|
||||||
|
item = PlexItem(
|
||||||
|
rating_key="ns",
|
||||||
|
library_id=lib.id,
|
||||||
|
kind="movie",
|
||||||
|
title="nostream",
|
||||||
|
playable="transcode",
|
||||||
|
codec_video="hevc",
|
||||||
|
transcode_hw=True,
|
||||||
|
)
|
||||||
|
db.add(item)
|
||||||
|
db.commit()
|
||||||
|
plex = _FakePlex({"ns": {"Media": [{"Part": [{}]}]}}) # no Stream array
|
||||||
|
assert sync._probe_media_batch(db, plex) == 1
|
||||||
|
db.refresh(item)
|
||||||
|
# Probed (watermark set so it isn't re-fetched forever); bit depth unknown → stays codec-HW.
|
||||||
|
assert item.media_probed_at is not None and item.video_bit_depth is None
|
||||||
|
assert item.transcode_hw is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_probe_media_batch_is_bounded_and_oldest_first(db):
|
||||||
|
# The per-run bound (limit) caps Plex load; oldest-id-first means successive runs cover the whole
|
||||||
|
# backlog without starving any item. Seed 3 eligible items, probe with limit=2.
|
||||||
|
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
||||||
|
db.add(lib)
|
||||||
|
db.flush()
|
||||||
|
items = [
|
||||||
|
PlexItem(
|
||||||
|
rating_key=f"h{i}",
|
||||||
|
library_id=lib.id,
|
||||||
|
kind="movie",
|
||||||
|
title=f"m{i}",
|
||||||
|
playable="transcode",
|
||||||
|
codec_video="hevc",
|
||||||
|
transcode_hw=True,
|
||||||
|
)
|
||||||
|
for i in range(3)
|
||||||
|
]
|
||||||
|
db.add_all(items) # inserted in order → ascending ids
|
||||||
|
db.commit()
|
||||||
|
plex = _FakePlex(
|
||||||
|
{
|
||||||
|
f"h{i}": {"Media": [{"Part": [{"Stream": [{"streamType": 1, "bitDepth": 8}]}]}]}
|
||||||
|
for i in range(3)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert sync._probe_media_batch(db, plex, limit=2) == 2 # exactly the bound
|
||||||
|
for it in items:
|
||||||
|
db.refresh(it)
|
||||||
|
# The two LOWEST-id rows were probed; the third waits for the next run.
|
||||||
|
assert items[0].media_probed_at is not None and items[1].media_probed_at is not None
|
||||||
|
assert items[2].media_probed_at is None
|
||||||
|
# The next run continues with the remaining item.
|
||||||
|
assert sync._probe_media_batch(db, plex, limit=2) == 1
|
||||||
|
db.refresh(items[2])
|
||||||
|
assert items[2].media_probed_at is not None
|
||||||
|
|
||||||
|
|
||||||
|
class _RaisingPlex:
|
||||||
|
"""metadata() raises for `bad_key`, returns a canned dict otherwise — to exercise the transient
|
||||||
|
fetch-failure branch (leave the watermark NULL so the item retries next run)."""
|
||||||
|
|
||||||
|
def __init__(self, by_key, bad_key):
|
||||||
|
self.by_key = by_key
|
||||||
|
self.bad_key = bad_key
|
||||||
|
|
||||||
|
def metadata(self, rating_key, markers=False):
|
||||||
|
if rating_key == self.bad_key:
|
||||||
|
raise RuntimeError("plex timeout")
|
||||||
|
return self.by_key.get(rating_key)
|
||||||
|
|
||||||
|
|
||||||
|
def test_probe_media_batch_leaves_a_transient_failure_unstamped_to_retry(db):
|
||||||
|
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
||||||
|
db.add(lib)
|
||||||
|
db.flush()
|
||||||
|
good = PlexItem(
|
||||||
|
rating_key="g",
|
||||||
|
library_id=lib.id,
|
||||||
|
kind="movie",
|
||||||
|
title="good",
|
||||||
|
playable="transcode",
|
||||||
|
codec_video="hevc",
|
||||||
|
transcode_hw=True,
|
||||||
|
)
|
||||||
|
bad = PlexItem(
|
||||||
|
rating_key="b",
|
||||||
|
library_id=lib.id,
|
||||||
|
kind="movie",
|
||||||
|
title="bad",
|
||||||
|
playable="transcode",
|
||||||
|
codec_video="hevc",
|
||||||
|
transcode_hw=True,
|
||||||
|
)
|
||||||
|
db.add_all([good, bad])
|
||||||
|
db.commit()
|
||||||
|
plex = _RaisingPlex(
|
||||||
|
{"g": {"Media": [{"Part": [{"Stream": [{"streamType": 1, "bitDepth": 10}]}]}]}}, bad_key="b"
|
||||||
|
)
|
||||||
|
# The raise on 'b' must not abort the batch: 'g' is still probed and counted; 'b' stays NULL.
|
||||||
|
assert sync._probe_media_batch(db, plex) == 1
|
||||||
|
db.refresh(good)
|
||||||
|
db.refresh(bad)
|
||||||
|
assert good.media_probed_at is not None and good.transcode_hw is False # 10-bit → SW
|
||||||
|
assert bad.media_probed_at is None # transient failure → retried next run, not stamped
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_classes(db):
|
||||||
|
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
||||||
|
db.add(lib)
|
||||||
|
db.flush()
|
||||||
|
db.add_all(
|
||||||
|
[
|
||||||
|
PlexItem(
|
||||||
|
rating_key="d1", library_id=lib.id, kind="movie", title="Direct", playable="direct"
|
||||||
|
),
|
||||||
|
PlexItem(
|
||||||
|
rating_key="r1", library_id=lib.id, kind="movie", title="Remux", playable="remux"
|
||||||
|
),
|
||||||
|
PlexItem(
|
||||||
|
rating_key="thw",
|
||||||
|
library_id=lib.id,
|
||||||
|
kind="movie",
|
||||||
|
title="HW",
|
||||||
|
playable="transcode",
|
||||||
|
codec_video="hevc",
|
||||||
|
transcode_hw=True,
|
||||||
|
),
|
||||||
|
PlexItem(
|
||||||
|
rating_key="tsw",
|
||||||
|
library_id=lib.id,
|
||||||
|
kind="movie",
|
||||||
|
title="SW",
|
||||||
|
playable="transcode",
|
||||||
|
codec_video="mpeg4",
|
||||||
|
transcode_hw=False,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return lib
|
||||||
|
|
||||||
|
|
||||||
|
def _client(db, user, monkeypatch):
|
||||||
|
monkeypatch.setattr(state, "_configured_cache", True)
|
||||||
|
app.dependency_overrides[get_db] = lambda: db
|
||||||
|
app.dependency_overrides[current_user] = lambda: user
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
def test_library_filters_by_playback_class(db, monkeypatch):
|
||||||
|
_seed_classes(db)
|
||||||
|
user = User(email="pclass@example.com")
|
||||||
|
db.add(user)
|
||||||
|
db.commit()
|
||||||
|
client = _client(db, user, monkeypatch)
|
||||||
|
try:
|
||||||
|
# Software-only transcodes.
|
||||||
|
sw = client.get("/api/plex/library?scope=movie&playable=transcode_sw")
|
||||||
|
assert sw.status_code == 200, sw.text
|
||||||
|
assert {i["id"] for i in sw.json()["items"]} == {"tsw"}
|
||||||
|
# HW transcode + Direct Play, OR-combined.
|
||||||
|
both = client.get("/api/plex/library?scope=movie&playable=transcode_hw,direct")
|
||||||
|
assert {i["id"] for i in both.json()["items"]} == {"thw", "d1"}
|
||||||
|
# The HW card carries the transcode_hw flag for the 4-way badge.
|
||||||
|
card = next(i for i in both.json()["items"] if i["id"] == "thw")
|
||||||
|
assert card["transcode_hw"] is True
|
||||||
|
# No filter → everything.
|
||||||
|
allm = client.get("/api/plex/library?scope=movie")
|
||||||
|
assert {i["id"] for i in allm.json()["items"]} == {"d1", "r1", "thw", "tsw"}
|
||||||
|
finally:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def test_facets_count_playback_classes_in_severity_order(db, monkeypatch):
|
||||||
|
_seed_classes(db)
|
||||||
|
user = User(email="pclassfacet@example.com")
|
||||||
|
db.add(user)
|
||||||
|
db.commit()
|
||||||
|
client = _client(db, user, monkeypatch)
|
||||||
|
try:
|
||||||
|
resp = client.get("/api/plex/facets?scope=movie")
|
||||||
|
finally:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
playable = resp.json()["playable"]
|
||||||
|
# Fixed good→heavy order (not by count), one per seeded class.
|
||||||
|
assert [p["value"] for p in playable] == ["direct", "remux", "transcode_hw", "transcode_sw"]
|
||||||
|
assert {p["value"]: p["count"] for p in playable} == {
|
||||||
|
"direct": 1,
|
||||||
|
"remux": 1,
|
||||||
|
"transcode_hw": 1,
|
||||||
|
"transcode_sw": 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_unprobed_transcode_folds_into_sw(db, monkeypatch):
|
||||||
|
# transcode_hw NULL is the COMMON initial state (before the bit-depth backfill runs). The query's
|
||||||
|
# CASE has no explicit NULL arm — it relies on the `playable=='transcode'` else folding NULL in with
|
||||||
|
# software. Pin that fold for BOTH the filter and the facet, so a reorder can't silently drop the
|
||||||
|
# item from the feed (an honest-state violation).
|
||||||
|
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
||||||
|
db.add(lib)
|
||||||
|
db.flush()
|
||||||
|
db.add(
|
||||||
|
PlexItem(
|
||||||
|
rating_key="tnull",
|
||||||
|
library_id=lib.id,
|
||||||
|
kind="movie",
|
||||||
|
title="Unprobed",
|
||||||
|
playable="transcode",
|
||||||
|
codec_video="hevc",
|
||||||
|
transcode_hw=None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
user = User(email="pnull@example.com")
|
||||||
|
db.add(user)
|
||||||
|
db.commit()
|
||||||
|
client = _client(db, user, monkeypatch)
|
||||||
|
try:
|
||||||
|
sw = client.get("/api/plex/library?scope=movie&playable=transcode_sw")
|
||||||
|
assert {i["id"] for i in sw.json()["items"]} == {"tnull"} # NULL → SW bucket
|
||||||
|
hw = client.get("/api/plex/library?scope=movie&playable=transcode_hw")
|
||||||
|
assert hw.json()["items"] == [] # never HW
|
||||||
|
facets = client.get("/api/plex/facets?scope=movie")
|
||||||
|
counts = {p["value"]: p["count"] for p in facets.json()["playable"]}
|
||||||
|
assert counts.get("transcode_sw") == 1 and counts.get("transcode_hw", 0) == 0
|
||||||
|
finally:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def test_facet_playable_self_excludes_the_active_selection(db, monkeypatch):
|
||||||
|
# A selected playable class must NOT narrow the playable facet's OWN counts (facet self-exclusion),
|
||||||
|
# otherwise picking one class would collapse the list and you couldn't OR-add a second. Selecting
|
||||||
|
# 'direct' must still return all four classes at their full, unnarrowed counts.
|
||||||
|
_seed_classes(db)
|
||||||
|
user = User(email="pself@example.com")
|
||||||
|
db.add(user)
|
||||||
|
db.commit()
|
||||||
|
client = _client(db, user, monkeypatch)
|
||||||
|
try:
|
||||||
|
resp = client.get("/api/plex/facets?scope=movie&playable=direct")
|
||||||
|
finally:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
counts = {p["value"]: p["count"] for p in resp.json()["playable"]}
|
||||||
|
assert counts == {"direct": 1, "remux": 1, "transcode_hw": 1, "transcode_sw": 1}
|
||||||
|
|
||||||
|
|
||||||
|
def test_active_playable_narrows_the_other_facets(db, monkeypatch):
|
||||||
|
# The flip side of self-exclusion: an active playable class DOES narrow the OTHER facet groups (so
|
||||||
|
# e.g. the year range reflects only the selected class). Seed a Direct movie (2000) and a
|
||||||
|
# software-transcode movie (2020); selecting playable=direct must collapse the year facet to 2000.
|
||||||
|
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
||||||
|
db.add(lib)
|
||||||
|
db.flush()
|
||||||
|
db.add_all(
|
||||||
|
[
|
||||||
|
PlexItem(
|
||||||
|
rating_key="d2000",
|
||||||
|
library_id=lib.id,
|
||||||
|
kind="movie",
|
||||||
|
title="Direct2000",
|
||||||
|
playable="direct",
|
||||||
|
year=2000,
|
||||||
|
),
|
||||||
|
PlexItem(
|
||||||
|
rating_key="s2020",
|
||||||
|
library_id=lib.id,
|
||||||
|
kind="movie",
|
||||||
|
title="SW2020",
|
||||||
|
playable="transcode",
|
||||||
|
codec_video="mpeg4",
|
||||||
|
transcode_hw=False,
|
||||||
|
year=2020,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
user = User(email="pnarrow@example.com")
|
||||||
|
db.add(user)
|
||||||
|
db.commit()
|
||||||
|
client = _client(db, user, monkeypatch)
|
||||||
|
try:
|
||||||
|
wide = client.get("/api/plex/facets?scope=movie").json()
|
||||||
|
narrow = client.get("/api/plex/facets?scope=movie&playable=direct").json()
|
||||||
|
finally:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
assert (wide["year_min"], wide["year_max"]) == (2000, 2020) # both classes span the range
|
||||||
|
assert (narrow["year_min"], narrow["year_max"]) == (2000, 2000) # narrowed to the Direct subset
|
||||||
@@ -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, tonemap=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."""
|
second half of this file pins what keeps it off a LIVE session's segments."""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -164,10 +165,308 @@ class _FakeProc:
|
|||||||
|
|
||||||
|
|
||||||
class _FakeItem:
|
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.rating_key = rating_key
|
||||||
self.file_path = file_path
|
self.file_path = file_path
|
||||||
self.playable = "remux"
|
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
|
||||||
|
|
||||||
|
def test_hdr_tonemap_uses_the_opencl_graph(self):
|
||||||
|
# The HDR/10-bit HW path: VAAPI decode → OpenCL tonemap → VAAPI encode. Gen 9.5 can't tonemap in
|
||||||
|
# the VAAPI VPP, so this is the only HW route for a 10-bit/HDR source.
|
||||||
|
cmd = stream._ffmpeg_cmd(
|
||||||
|
self.SRC, 0.0, self.OUT, None, transcode=True, hw=True, tonemap=True
|
||||||
|
)
|
||||||
|
assert _venc(cmd) == "h264_vaapi"
|
||||||
|
assert "libx264" not in cmd
|
||||||
|
# Named devices + an OpenCL device DERIVED from the VAAPI one, all before -i.
|
||||||
|
assert cmd[cmd.index("-init_hw_device") + 1] == f"vaapi=va:{stream._VAAPI_DEVICE}"
|
||||||
|
assert "opencl=ocl@va" in cmd
|
||||||
|
assert cmd[cmd.index("-hwaccel_device") + 1] == "va"
|
||||||
|
assert cmd.index("-init_hw_device") < cmd.index("-i")
|
||||||
|
# The filter graph runs on the derived OpenCL device and tone-maps HDR→SDR to 8-bit nv12.
|
||||||
|
assert cmd[cmd.index("-filter_hw_device") + 1] == "ocl"
|
||||||
|
vf = cmd[cmd.index("-vf") + 1]
|
||||||
|
assert "hwmap=derive_device=opencl" in vf
|
||||||
|
assert "tonemap_opencl=" in vf and "format=nv12" in vf
|
||||||
|
assert "hwmap=derive_device=vaapi:reverse=1" in vf
|
||||||
|
assert "scale_vaapi" not in vf # the plain 8-bit path's filter must NOT be used here
|
||||||
|
assert (
|
||||||
|
cmd[cmd.index("-force_key_frames") + 1] == f"expr:gte(t,n_forced*{stream._SEG_SECONDS})"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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. A HW session on a 10-bit/HDR source
|
||||||
|
tone-maps on the GPU via OpenCL (tonemap=True) when the OpenCL runtime is present, else drops to
|
||||||
|
software — see the `_select_encode` cases below."""
|
||||||
|
|
||||||
|
SRC = Path("/media/movie.mkv")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _policy(monkeypatch, mode):
|
||||||
|
monkeypatch.setattr(stream.sysconfig, "get_str", lambda db, k: mode)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _kind(monkeypatch, kind):
|
||||||
|
# Pin the source classifier so the tests don't shell out to ffprobe. `kind` is one of
|
||||||
|
# stream._VK_HWSAFE / _VK_HDR / _VK_SDR10.
|
||||||
|
monkeypatch.setattr(stream, "_probe_video_kind", lambda src: kind)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _hwsafe(monkeypatch, safe):
|
||||||
|
# Back-compat convenience for the hw-gate tests that only care about 8-bit-SDR vs not: True →
|
||||||
|
# _VK_HWSAFE, False → _VK_SDR10 (a non-HDR non-hwsafe class, so the hw half still resolves).
|
||||||
|
monkeypatch.setattr(
|
||||||
|
stream, "_probe_video_kind", lambda src: stream._VK_HWSAFE if safe else stream._VK_SDR10
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _opencl(monkeypatch, present):
|
||||||
|
# Pin the OpenCL-runtime probe so the routing doesn't depend on the CI box's /etc/OpenCL.
|
||||||
|
monkeypatch.setattr(stream, "_opencl_present", lambda: present)
|
||||||
|
|
||||||
|
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, src=self.SRC)
|
||||||
|
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, src=self.SRC)
|
||||||
|
is False
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_auto_uses_hw_for_a_decodable_8bit_source_with_the_render_node(self, monkeypatch):
|
||||||
|
self._policy(monkeypatch, "auto")
|
||||||
|
self._hwsafe(monkeypatch, True)
|
||||||
|
monkeypatch.setattr(stream, "_render_node_present", lambda: True)
|
||||||
|
assert (
|
||||||
|
stream._want_hw(None, _FakeItem(codec_video="hevc"), transcode=True, src=self.SRC)
|
||||||
|
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")
|
||||||
|
self._hwsafe(monkeypatch, True) # decodability gate must fire BEFORE the probe
|
||||||
|
monkeypatch.setattr(stream, "_render_node_present", lambda: True)
|
||||||
|
assert (
|
||||||
|
stream._want_hw(None, _FakeItem(codec_video="av1"), transcode=True, src=self.SRC)
|
||||||
|
is False
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
stream._want_hw(None, _FakeItem(codec_video="mpeg4"), transcode=True, src=self.SRC)
|
||||||
|
is False
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_auto_hdr_source_falls_back_to_software_without_opencl(self, monkeypatch):
|
||||||
|
# A decodable HEVC that is true-HDR (PQ/HLG) can't use scale_vaapi=format=nv12 (Gen 9.5), and with
|
||||||
|
# no OpenCL runtime to tonemap on the GPU it drops to software libx264 (BUG 1's behaviour).
|
||||||
|
self._policy(monkeypatch, "auto")
|
||||||
|
self._kind(monkeypatch, stream._VK_HDR)
|
||||||
|
self._opencl(monkeypatch, False)
|
||||||
|
monkeypatch.setattr(stream, "_render_node_present", lambda: True)
|
||||||
|
assert stream._select_encode(
|
||||||
|
None, _FakeItem(codec_video="hevc"), transcode=True, src=self.SRC
|
||||||
|
) == (False, False)
|
||||||
|
|
||||||
|
def test_auto_hdr_source_uses_hw_tonemap_with_opencl(self, monkeypatch):
|
||||||
|
# With the OpenCL runtime present, a true-HDR HEVC tone-maps on the GPU (VAAPI decode → OpenCL
|
||||||
|
# tonemap → VAAPI encode) instead of dropping to CPU — the HW HDR path.
|
||||||
|
self._policy(monkeypatch, "auto")
|
||||||
|
self._kind(monkeypatch, stream._VK_HDR)
|
||||||
|
self._opencl(monkeypatch, True)
|
||||||
|
monkeypatch.setattr(stream, "_render_node_present", lambda: True)
|
||||||
|
assert stream._select_encode(
|
||||||
|
None, _FakeItem(codec_video="hevc"), transcode=True, src=self.SRC
|
||||||
|
) == (True, True)
|
||||||
|
|
||||||
|
def test_auto_10bit_SDR_source_uses_software_not_tonemap(self, monkeypatch):
|
||||||
|
# F1 regression: a 10-bit *SDR* (bt709) source is NOT HDR, so it must NOT get the hable HDR tone
|
||||||
|
# curve even when the OpenCL runtime is present — that would visibly alter an SDR picture. It takes
|
||||||
|
# software libx264 (an exact 10→8-bit downconvert), never (True, True).
|
||||||
|
self._policy(monkeypatch, "auto")
|
||||||
|
self._kind(monkeypatch, stream._VK_SDR10)
|
||||||
|
self._opencl(monkeypatch, True)
|
||||||
|
monkeypatch.setattr(stream, "_render_node_present", lambda: True)
|
||||||
|
assert stream._select_encode(
|
||||||
|
None, _FakeItem(codec_video="hevc"), transcode=True, src=self.SRC
|
||||||
|
) == (False, False)
|
||||||
|
|
||||||
|
def test_auto_8bit_source_uses_plain_hw_not_tonemap(self, monkeypatch):
|
||||||
|
# An 8-bit SDR source takes the plain full-GPU scale path — never the tonemap graph — even where
|
||||||
|
# OpenCL is available.
|
||||||
|
self._policy(monkeypatch, "auto")
|
||||||
|
self._kind(monkeypatch, stream._VK_HWSAFE)
|
||||||
|
self._opencl(monkeypatch, True)
|
||||||
|
monkeypatch.setattr(stream, "_render_node_present", lambda: True)
|
||||||
|
assert stream._select_encode(
|
||||||
|
None, _FakeItem(codec_video="hevc"), transcode=True, src=self.SRC
|
||||||
|
) == (True, 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, src=self.SRC)
|
||||||
|
is False
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_on_forces_hw_and_skips_both_source_gates(self, monkeypatch):
|
||||||
|
# The admin override trusts the GPU; skips the decodability AND the bit-depth gate (the runtime
|
||||||
|
# HW→SW fallback still backstops it). Render node still required — HW is impossible without it.
|
||||||
|
self._policy(monkeypatch, "on")
|
||||||
|
self._hwsafe(monkeypatch, False) # would veto in auto; "on" ignores it
|
||||||
|
self._opencl(
|
||||||
|
monkeypatch, False
|
||||||
|
) # pin: tonemap-half is irrelevant here, keep it deterministic
|
||||||
|
monkeypatch.setattr(stream, "_render_node_present", lambda: True)
|
||||||
|
assert (
|
||||||
|
stream._want_hw(None, _FakeItem(codec_video="av1"), transcode=True, src=self.SRC)
|
||||||
|
is True
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(stream, "_render_node_present", lambda: False)
|
||||||
|
assert (
|
||||||
|
stream._want_hw(None, _FakeItem(codec_video="hevc"), transcode=True, src=self.SRC)
|
||||||
|
is False
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_on_still_picks_the_tonemap_graph_for_an_hdr_source(self, monkeypatch):
|
||||||
|
# "on" trusts the GPU but must still route a true-HDR source through the tonemap graph (plain
|
||||||
|
# scale_vaapi would hit the VPP wall) when OpenCL is present.
|
||||||
|
self._policy(monkeypatch, "on")
|
||||||
|
self._kind(monkeypatch, stream._VK_HDR)
|
||||||
|
self._opencl(monkeypatch, True)
|
||||||
|
monkeypatch.setattr(stream, "_render_node_present", lambda: True)
|
||||||
|
assert stream._select_encode(
|
||||||
|
None, _FakeItem(codec_video="hevc"), transcode=True, src=self.SRC
|
||||||
|
) == (True, True)
|
||||||
|
|
||||||
|
def test_on_10bit_SDR_uses_plain_hw_not_tonemap(self, monkeypatch):
|
||||||
|
# F1 regression under "on": a 10-bit SDR source is NOT HDR, so even with OpenCL present "on" must
|
||||||
|
# pick plain HW (True, False) — the HDR tone curve must never touch an SDR picture.
|
||||||
|
self._policy(monkeypatch, "on")
|
||||||
|
self._kind(monkeypatch, stream._VK_SDR10)
|
||||||
|
self._opencl(monkeypatch, True)
|
||||||
|
monkeypatch.setattr(stream, "_render_node_present", lambda: True)
|
||||||
|
assert stream._select_encode(
|
||||||
|
None, _FakeItem(codec_video="hevc"), transcode=True, src=self.SRC
|
||||||
|
) == (True, False)
|
||||||
|
|
||||||
|
def test_on_without_opencl_is_plain_hw(self, monkeypatch):
|
||||||
|
# "on" with no OpenCL runtime → plain HW, tonemap=False, regardless of source class (the runtime
|
||||||
|
# HW→SW fallback backstops a source the plain path can't actually handle). Pins the tonemap half
|
||||||
|
# of the "on" tuple, which test_on_forces_hw_and_skips_both_source_gates only checks via _want_hw.
|
||||||
|
self._policy(monkeypatch, "on")
|
||||||
|
self._kind(monkeypatch, stream._VK_HDR) # even HDR: no runtime → can't tonemap on the GPU
|
||||||
|
self._opencl(monkeypatch, False)
|
||||||
|
monkeypatch.setattr(stream, "_render_node_present", lambda: True)
|
||||||
|
assert stream._select_encode(
|
||||||
|
None, _FakeItem(codec_video="hevc"), transcode=True, src=self.SRC
|
||||||
|
) == (True, False)
|
||||||
|
|
||||||
|
|
||||||
class TestColdStartWarmup:
|
class TestColdStartWarmup:
|
||||||
@@ -190,7 +489,7 @@ class TestColdStartWarmup:
|
|||||||
def test_warmup_waits_for_the_first_few_segments(self, fake_env, monkeypatch):
|
def test_warmup_waits_for_the_first_few_segments(self, fake_env, monkeypatch):
|
||||||
waited = []
|
waited = []
|
||||||
|
|
||||||
def spy(path, timeout=20.0):
|
def spy(path, timeout=20.0, proc=None):
|
||||||
waited.append(path.name)
|
waited.append(path.name)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -203,7 +502,7 @@ class TestColdStartWarmup:
|
|||||||
def test_warmup_stops_at_the_first_segment_that_isnt_ready(self, fake_env, monkeypatch):
|
def test_warmup_stops_at_the_first_segment_that_isnt_ready(self, fake_env, monkeypatch):
|
||||||
waited = []
|
waited = []
|
||||||
|
|
||||||
def spy(path, timeout=20.0):
|
def spy(path, timeout=20.0, proc=None):
|
||||||
waited.append(path.name)
|
waited.append(path.name)
|
||||||
return path.name != "seg_1.ts" # seg_1 never appears → warm-up gives up there
|
return path.name != "seg_1.ts" # seg_1 never appears → warm-up gives up there
|
||||||
|
|
||||||
@@ -217,7 +516,7 @@ class TestColdStartWarmup:
|
|||||||
# still returned so the client's own segment requests can drive it, as before.
|
# still returned so the client's own segment requests can drive it, as before.
|
||||||
waited = []
|
waited = []
|
||||||
|
|
||||||
def spy(path, timeout=20.0):
|
def spy(path, timeout=20.0, proc=None):
|
||||||
waited.append(path.name)
|
waited.append(path.name)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -225,3 +524,347 @@ class TestColdStartWarmup:
|
|||||||
s = stream.start_session(db=None, item=_FakeItem(), start_s=0.0)
|
s = stream.start_session(db=None, item=_FakeItem(), start_s=0.0)
|
||||||
assert s is not None
|
assert s is not None
|
||||||
assert waited == ["seg_0.ts"]
|
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, proc=None: 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, "_select_encode", lambda db, item, transcode, src: (False, 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, "_select_encode", lambda db, item, transcode, src: (True, False)
|
||||||
|
)
|
||||||
|
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 and s.tonemap is False
|
||||||
|
assert len(_CapturingProc.calls) == 1 # first segment arrived — no HW→SW fallback respawn
|
||||||
|
(cmd, *_), kwargs = _CapturingProc.calls[0]
|
||||||
|
assert _venc(cmd) == "h264_vaapi"
|
||||||
|
assert "libx264" not in cmd
|
||||||
|
# Plain-HW path unconditionally emits the nv12 downconvert (a disjunction on "-vf not in cmd"
|
||||||
|
# would silently pass if the filter were dropped — assert it is actually present).
|
||||||
|
assert 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_hdr_tonemap_transcode_threads_the_opencl_graph_and_flags_the_session(
|
||||||
|
self, capturing_env, monkeypatch
|
||||||
|
):
|
||||||
|
# A HW tonemap session (10-bit/HDR + OpenCL) must build the OpenCL graph in the argv and carry
|
||||||
|
# tonemap=True on the session, still on the GPU (hw=True) and never niced.
|
||||||
|
monkeypatch.setattr(stream, "_select_encode", lambda db, item, transcode, src: (True, 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 and s.tonemap is True
|
||||||
|
(cmd, *_), kwargs = _CapturingProc.calls[0]
|
||||||
|
assert _venc(cmd) == "h264_vaapi"
|
||||||
|
assert "opencl=ocl@va" in cmd and cmd[cmd.index("-filter_hw_device") + 1] == "ocl"
|
||||||
|
assert "tonemap_opencl=" in cmd[cmd.index("-vf") + 1]
|
||||||
|
assert kwargs.get("preexec_fn") is None
|
||||||
|
|
||||||
|
def test_a_hw_session_that_produces_nothing_falls_back_to_software(
|
||||||
|
self, capturing_env, monkeypatch
|
||||||
|
):
|
||||||
|
# BUG 1 backstop: _want_hw picked HW but the VAAPI session emits no first segment (a VAProfile
|
||||||
|
# refusal the gates didn't catch). start_session must respawn on libx264 and report hw=False so
|
||||||
|
# the item still plays and the route/player honestly show CPU.
|
||||||
|
monkeypatch.setattr(
|
||||||
|
stream, "_select_encode", lambda db, item, transcode, src: (True, False)
|
||||||
|
)
|
||||||
|
seen = {"n": 0}
|
||||||
|
|
||||||
|
def flaky_wait(path, timeout=20.0, proc=None):
|
||||||
|
seen["n"] += 1
|
||||||
|
return seen["n"] > 1 # the first seg-0 wait (HW) fails; every wait after succeeds (SW)
|
||||||
|
|
||||||
|
monkeypatch.setattr(stream, "wait_for", flaky_wait)
|
||||||
|
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 False
|
||||||
|
) # honest CPU after the fallback
|
||||||
|
assert len(_CapturingProc.calls) == 2 # the HW attempt, then the SW respawn
|
||||||
|
(hw_cmd, *_), _ = _CapturingProc.calls[0]
|
||||||
|
(sw_cmd, *_), sw_kwargs = _CapturingProc.calls[1]
|
||||||
|
assert _venc(hw_cmd) == "h264_vaapi"
|
||||||
|
assert _venc(sw_cmd) == "libx264"
|
||||||
|
if os.name == "posix":
|
||||||
|
assert sw_kwargs.get("preexec_fn") is stream._sw_child_preexec # the SW child is niced
|
||||||
|
|
||||||
|
def test_a_hw_TONEMAP_session_that_produces_nothing_falls_back_to_software(
|
||||||
|
self, capturing_env, monkeypatch
|
||||||
|
):
|
||||||
|
# The fallback gate is `not ready and s.hw`, shared by plain-HW and tonemap sessions — but only
|
||||||
|
# the plain-HW variant was covered. Pin the tonemap variant too: a (True, True) session that
|
||||||
|
# emits nothing (an OpenCL/tonemap init refusal) must respawn on libx264 and end hw=False,
|
||||||
|
# tonemap=False, so an HDR item still plays on CPU.
|
||||||
|
monkeypatch.setattr(stream, "_select_encode", lambda db, item, transcode, src: (True, True))
|
||||||
|
seen = {"n": 0}
|
||||||
|
|
||||||
|
def flaky_wait(path, timeout=20.0, proc=None):
|
||||||
|
seen["n"] += 1
|
||||||
|
return (
|
||||||
|
seen["n"] > 1
|
||||||
|
) # the first seg-0 wait (HW tonemap) fails; every wait after succeeds
|
||||||
|
|
||||||
|
monkeypatch.setattr(stream, "wait_for", flaky_wait)
|
||||||
|
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 False and s.tonemap is False
|
||||||
|
assert len(_CapturingProc.calls) == 2 # the HW tonemap attempt, then the SW respawn
|
||||||
|
(hw_cmd, *_), _ = _CapturingProc.calls[0]
|
||||||
|
(sw_cmd, *_), _ = _CapturingProc.calls[1]
|
||||||
|
assert (
|
||||||
|
"tonemap_opencl=" in hw_cmd[hw_cmd.index("-vf") + 1]
|
||||||
|
) # first attempt built the OpenCL graph
|
||||||
|
assert _venc(sw_cmd) == "libx264" # respawn is plain software, no tonemap
|
||||||
|
|
||||||
|
def test_a_superseded_hw_session_does_not_respawn_over_the_newer_one(
|
||||||
|
self, capturing_env, monkeypatch
|
||||||
|
):
|
||||||
|
# F2/race: start_session waits on seg_0 OUTSIDE _lock. If a concurrent same-key start (a rapid
|
||||||
|
# seek-restart) or _enforce_cap replaces _sessions[key] while we wait, our proc dying must NOT
|
||||||
|
# trigger the SW fallback — that would tear down the NEWER session and breach the transcode cap.
|
||||||
|
# The identity guard must detect it's been superseded and yield the currently-registered session.
|
||||||
|
monkeypatch.setattr(
|
||||||
|
stream, "_select_encode", lambda db, item, transcode, src: (True, False)
|
||||||
|
)
|
||||||
|
|
||||||
|
class _Newer: # stands in for the session a concurrent _spawn registered
|
||||||
|
hw = True
|
||||||
|
tonemap = False
|
||||||
|
transcode = True
|
||||||
|
last_access = 0.0
|
||||||
|
|
||||||
|
newer = _Newer()
|
||||||
|
|
||||||
|
def wait_then_supersede(path, timeout=20.0, proc=None):
|
||||||
|
# Simulate the concurrent replacement happening during our out-of-lock wait, then report the
|
||||||
|
# (now-killed) HW proc produced nothing.
|
||||||
|
stream._sessions["12345"] = newer
|
||||||
|
return False
|
||||||
|
|
||||||
|
monkeypatch.setattr(stream, "wait_for", wait_then_supersede)
|
||||||
|
s = stream.start_session(
|
||||||
|
db=None, item=_FakeItem(codec_video="hevc"), start_s=0.0, transcode=True
|
||||||
|
)
|
||||||
|
# No SW respawn — only the initial HW _spawn ran; the newer session was left intact.
|
||||||
|
assert len(_CapturingProc.calls) == 1
|
||||||
|
assert s is newer # yielded the currently-registered session, did not clobber it
|
||||||
|
assert stream._sessions["12345"] is newer
|
||||||
|
|
||||||
|
def test_a_cap_culled_hw_session_does_not_respawn_and_is_not_reported_missing(
|
||||||
|
self, capturing_env, monkeypatch
|
||||||
|
):
|
||||||
|
# F2/race, the cull sub-case: while we wait on seg_0 out of lock, cross-key cap pressure lets
|
||||||
|
# _enforce_cap evict THIS session's key entirely (no replacement). The fallback must still not
|
||||||
|
# respawn (that would re-breach the cap the cull just enforced), and must NOT return None — a None
|
||||||
|
# is turned by the route into a dishonest "file not found" 404 for a file that exists. It yields
|
||||||
|
# our own (now-dead) session instead, so the client's segment requests degrade to the retryable
|
||||||
|
# "not available yet" path.
|
||||||
|
monkeypatch.setattr(
|
||||||
|
stream, "_select_encode", lambda db, item, transcode, src: (True, False)
|
||||||
|
)
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
def wait_then_cull(path, timeout=20.0, proc=None):
|
||||||
|
captured["s"] = stream._sessions[
|
||||||
|
"12345"
|
||||||
|
] # the HW session the initial _spawn just registered
|
||||||
|
del stream._sessions[
|
||||||
|
"12345"
|
||||||
|
] # simulate _enforce_cap dropping the LRU under cap pressure
|
||||||
|
return False
|
||||||
|
|
||||||
|
monkeypatch.setattr(stream, "wait_for", wait_then_cull)
|
||||||
|
s = stream.start_session(
|
||||||
|
db=None, item=_FakeItem(codec_video="hevc"), start_s=0.0, transcode=True
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
len(_CapturingProc.calls) == 1
|
||||||
|
) # no SW respawn — the cull is respected, cap not re-breached
|
||||||
|
assert (
|
||||||
|
s is captured["s"]
|
||||||
|
) # our own session yielded, NOT None (which the route would 404 as missing)
|
||||||
|
assert "12345" not in stream._sessions # the culled key stays gone
|
||||||
|
|
||||||
|
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, "_select_encode", lambda db, item, transcode, src: (False, 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
|
||||||
|
|
||||||
|
|
||||||
|
class TestProbeVideoKind:
|
||||||
|
"""The ffprobe-based source classifier that routes the transcode pipeline: 8-bit SDR → plain VAAPI;
|
||||||
|
true-HDR (PQ/HLG) → the OpenCL tonemap graph; 10-bit *SDR* → software (an exact downconvert, NEVER
|
||||||
|
the HDR tone curve — the F1 distinction). Exercises the REAL function body (not a monkeypatch)."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _probe(monkeypatch, stdout):
|
||||||
|
class _R:
|
||||||
|
def __init__(self):
|
||||||
|
self.stdout = stdout
|
||||||
|
|
||||||
|
monkeypatch.setattr(stream.subprocess, "run", lambda *a, **k: _R())
|
||||||
|
|
||||||
|
def test_8bit_sdr_is_hwsafe(self, monkeypatch):
|
||||||
|
self._probe(monkeypatch, "pix_fmt=yuv420p\nbits_per_raw_sample=8\ncolor_transfer=bt709\n")
|
||||||
|
assert stream._probe_video_kind(Path("/x")) == stream._VK_HWSAFE
|
||||||
|
|
||||||
|
def test_10bit_sdr_pix_fmt_is_sdr10(self, monkeypatch):
|
||||||
|
# yuv420p10le with a non-HDR transfer (bt2020-10 is NOT PQ/HLG) = 10-bit SDR → software, not
|
||||||
|
# tonemap. Signalled by the pix_fmt suffix even when bits_per_raw_sample is absent.
|
||||||
|
self._probe(monkeypatch, "pix_fmt=yuv420p10le\ncolor_transfer=bt2020-10\n")
|
||||||
|
assert stream._probe_video_kind(Path("/x")) == stream._VK_SDR10
|
||||||
|
|
||||||
|
def test_bits_per_raw_sample_over_8_sdr_is_sdr10(self, monkeypatch):
|
||||||
|
self._probe(monkeypatch, "pix_fmt=yuv420p\nbits_per_raw_sample=10\ncolor_transfer=bt709\n")
|
||||||
|
assert stream._probe_video_kind(Path("/x")) == stream._VK_SDR10
|
||||||
|
|
||||||
|
def test_pq_transfer_is_hdr(self, monkeypatch):
|
||||||
|
# PQ (smpte2084) is true HDR — needs a real tonemap, even if only 8-bit-tagged.
|
||||||
|
self._probe(
|
||||||
|
monkeypatch, "pix_fmt=yuv420p10le\nbits_per_raw_sample=10\ncolor_transfer=smpte2084\n"
|
||||||
|
)
|
||||||
|
assert stream._probe_video_kind(Path("/x")) == stream._VK_HDR
|
||||||
|
|
||||||
|
def test_hlg_transfer_is_hdr(self, monkeypatch):
|
||||||
|
# HLG (arib-std-b67) is the other HDR transfer — previously untested; it must classify as HDR.
|
||||||
|
self._probe(
|
||||||
|
monkeypatch,
|
||||||
|
"pix_fmt=yuv420p10le\nbits_per_raw_sample=10\ncolor_transfer=arib-std-b67\n",
|
||||||
|
)
|
||||||
|
assert stream._probe_video_kind(Path("/x")) == stream._VK_HDR
|
||||||
|
|
||||||
|
def test_empty_probe_output_is_hwsafe(self, monkeypatch):
|
||||||
|
# A probe that returns nothing (no streams / stripped metadata) → assume hwsafe; the runtime
|
||||||
|
# HW→SW fallback backstops a wrong guess.
|
||||||
|
self._probe(monkeypatch, "")
|
||||||
|
assert stream._probe_video_kind(Path("/x")) == stream._VK_HWSAFE
|
||||||
|
|
||||||
|
def test_probe_failure_assumes_hwsafe(self, monkeypatch):
|
||||||
|
# ffprobe missing/erroring → assume hwsafe; the runtime HW→SW fallback is the real backstop, so a
|
||||||
|
# probe miss costs at most one wasted HW attempt, never a stranded item.
|
||||||
|
def boom(*a, **k):
|
||||||
|
raise OSError("no ffprobe")
|
||||||
|
|
||||||
|
monkeypatch.setattr(stream.subprocess, "run", boom)
|
||||||
|
assert stream._probe_video_kind(Path("/x")) == stream._VK_HWSAFE
|
||||||
|
|
||||||
|
|
||||||
|
class TestOpenclPresent:
|
||||||
|
"""The OpenCL-runtime probe (_opencl_present) — the precondition for the HW HDR tonemap graph. Drives
|
||||||
|
the REAL glob against a temp ICD dir instead of monkeypatching the function, so a regression to the
|
||||||
|
ICD path / glob / OSError guard would be caught (this routes every HDR source on prod)."""
|
||||||
|
|
||||||
|
def test_true_when_an_icd_file_is_registered(self, tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setattr(stream, "_OPENCL_ICD_DIR", tmp_path)
|
||||||
|
(tmp_path / "intel.icd").write_text("libintelocl.so\n")
|
||||||
|
assert stream._opencl_present() is True
|
||||||
|
|
||||||
|
def test_false_when_the_dir_is_empty(self, tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setattr(stream, "_OPENCL_ICD_DIR", tmp_path)
|
||||||
|
assert stream._opencl_present() is False
|
||||||
|
|
||||||
|
def test_false_and_no_raise_when_the_dir_is_missing(self, tmp_path, monkeypatch):
|
||||||
|
# A missing dir: Path.glob yields nothing (no raise) → False.
|
||||||
|
monkeypatch.setattr(stream, "_OPENCL_ICD_DIR", tmp_path / "nope")
|
||||||
|
assert stream._opencl_present() is False
|
||||||
|
|
||||||
|
def test_false_when_glob_raises_oserror(self, monkeypatch):
|
||||||
|
# The `except OSError` guard proper: an unreadable ICD dir (permission denied) makes glob RAISE,
|
||||||
|
# not return empty. That must be swallowed to False, never propagated up through _select_encode
|
||||||
|
# into start_session (which would crash HDR routing).
|
||||||
|
class _BoomDir:
|
||||||
|
def glob(self, pattern):
|
||||||
|
raise OSError("permission denied")
|
||||||
|
|
||||||
|
monkeypatch.setattr(stream, "_OPENCL_ICD_DIR", _BoomDir())
|
||||||
|
assert stream._opencl_present() is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestWaitForProcFastFail:
|
||||||
|
"""wait_for(proc=…) must stop as soon as the ffmpeg proc exits (a VAAPI init refusal dies in ~1s
|
||||||
|
producing nothing) instead of blocking the whole timeout — that fast-fail is what keeps the HW→SW
|
||||||
|
fallback from turning into a 25s stall. Exercises the REAL wait_for (every other test monkeypatches
|
||||||
|
it wholesale, so this branch would otherwise never run)."""
|
||||||
|
|
||||||
|
class _DeadProc:
|
||||||
|
def poll(self):
|
||||||
|
return 1 # already exited non-zero
|
||||||
|
|
||||||
|
def test_returns_true_when_the_segment_exists_despite_a_dead_proc(self, tmp_path):
|
||||||
|
seg = tmp_path / "seg_0.ts"
|
||||||
|
seg.write_bytes(b"x")
|
||||||
|
# The proc exited but DID write a first segment → ready. (The final existence re-check.)
|
||||||
|
assert stream.wait_for(seg, timeout=5.0, proc=self._DeadProc()) is True
|
||||||
|
|
||||||
|
def test_returns_false_fast_when_a_dead_proc_wrote_nothing(self, tmp_path):
|
||||||
|
# Missing segment + dead proc → give up immediately, not after `timeout`. Assert it's fast by
|
||||||
|
# using a long timeout the test would visibly hang on if the fast-fail branch regressed.
|
||||||
|
seg = tmp_path / "seg_0.ts"
|
||||||
|
assert stream.wait_for(seg, timeout=30.0, proc=self._DeadProc()) is False
|
||||||
|
|
||||||
|
def test_empty_segment_from_a_dead_proc_is_not_ready(self, tmp_path):
|
||||||
|
# A zero-byte segment is not a real segment (size > 0 required).
|
||||||
|
seg = tmp_path / "seg_0.ts"
|
||||||
|
seg.write_bytes(b"")
|
||||||
|
assert stream.wait_for(seg, timeout=30.0, proc=self._DeadProc()) is False
|
||||||
|
|||||||
@@ -9,11 +9,12 @@ Two independent pieces:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
from app.models import PlexItem, PlexLibrary, PlexState, User
|
from app.models import PlexItem, PlexLibrary, PlexState, User
|
||||||
from app.plex.watch_sync import _pull_apply
|
from app.plex.watch_sync import _pull_apply, _reconcile_inprogress_movies
|
||||||
from app.routes.plex.playback import (
|
from app.routes.plex.playback import (
|
||||||
_PUSH_THROTTLE_S,
|
_PUSH_THROTTLE_S,
|
||||||
_resume_push_superseded,
|
_resume_push_superseded,
|
||||||
@@ -33,6 +34,16 @@ def _seed_item_and_user(db):
|
|||||||
return item.id, user.id
|
return item.id, user.id
|
||||||
|
|
||||||
|
|
||||||
|
class _FakePlex:
|
||||||
|
"""Minimal PlexClient stand-in for the movie-probe tests: metadata(rating_key) → a leaf dict."""
|
||||||
|
|
||||||
|
def __init__(self, by_rk: dict[str, dict]):
|
||||||
|
self._by_rk = by_rk
|
||||||
|
|
||||||
|
def metadata(self, rating_key: str, markers: bool = False):
|
||||||
|
return self._by_rk.get(str(rating_key))
|
||||||
|
|
||||||
|
|
||||||
def test_pull_plex_wins_over_diverging_synced_local_resume(db):
|
def test_pull_plex_wins_over_diverging_synced_local_resume(db):
|
||||||
"""The core S2 change: a SYNCED local row (its state already reached Plex) that later diverges from
|
"""The core S2 change: a SYNCED local row (its state already reached Plex) that later diverges from
|
||||||
Plex = a real external Plex-side change, so the incremental pull resolves to PLEX's position — Plex
|
Plex = a real external Plex-side change, so the incremental pull resolves to PLEX's position — Plex
|
||||||
@@ -198,6 +209,130 @@ def test_pull_settles_flag_on_matching_dirty_row(db):
|
|||||||
assert st.synced_to_plex is True, "the dirty flag is settled so it isn't re-pushed endlessly"
|
assert st.synced_to_plex is True, "the dirty flag is settled so it isn't re-pushed endlessly"
|
||||||
|
|
||||||
|
|
||||||
|
def test_movie_probe_flips_stale_in_progress_to_watched(db):
|
||||||
|
"""S1 core: a local in-progress MOVIE that Plex has since finished (viewCount>0) and dropped from
|
||||||
|
Continue-Watching flips to watched on the incremental pass — without waiting for the daily reconcile.
|
||||||
|
This is the "movie stuck in progress after scrubbing the credits" bug."""
|
||||||
|
item_id, user_id = _seed_item_and_user(db)
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
db.add(
|
||||||
|
PlexState(
|
||||||
|
user_id=user_id,
|
||||||
|
item_id=item_id,
|
||||||
|
status="new",
|
||||||
|
position_seconds=300,
|
||||||
|
progress_updated_at=now,
|
||||||
|
synced_to_plex=True, # was created by a prior pull; the user then watched it on Plex
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
# Plex now reports the movie watched (viewCount>0, no offset) and it is NOT on the deck.
|
||||||
|
plex = _FakePlex({"m1": {"viewCount": 1, "lastViewedAt": int(now.timestamp())}})
|
||||||
|
out = _reconcile_inprogress_movies(
|
||||||
|
db, plex, SimpleNamespace(user_id=user_id), on_deck_ids=set()
|
||||||
|
)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
assert out == {"watched": 1, "in_progress": 0, "probed": 1}
|
||||||
|
st = (
|
||||||
|
db.execute(
|
||||||
|
select(PlexState).where(PlexState.user_id == user_id, PlexState.item_id == item_id)
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.one()
|
||||||
|
)
|
||||||
|
assert st.status == "watched"
|
||||||
|
assert st.position_seconds == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_movie_probe_skips_ondeck_and_episodes(db):
|
||||||
|
"""The probe must NOT touch (a) a movie Plex still lists on the deck — it's legitimately in
|
||||||
|
progress — nor (b) an in-progress EPISODE (episodes ride onDeck/history + rollup). Only stale
|
||||||
|
in-progress movies are probed, and metadata() is called for those alone."""
|
||||||
|
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
||||||
|
db.add(lib)
|
||||||
|
db.flush()
|
||||||
|
on_deck_movie = PlexItem(rating_key="mDeck", library_id=lib.id, kind="movie", title="On deck")
|
||||||
|
episode = PlexItem(rating_key="e1", library_id=lib.id, kind="episode", title="An episode")
|
||||||
|
db.add_all([on_deck_movie, episode])
|
||||||
|
user = User(email="probe2@example.com")
|
||||||
|
db.add(user)
|
||||||
|
db.flush()
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
for iid in (on_deck_movie.id, episode.id):
|
||||||
|
db.add(
|
||||||
|
PlexState(
|
||||||
|
user_id=user.id,
|
||||||
|
item_id=iid,
|
||||||
|
status="new",
|
||||||
|
position_seconds=300,
|
||||||
|
progress_updated_at=now,
|
||||||
|
synced_to_plex=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
# Plex would report both watched — but neither should be probed at all, so the fake returning
|
||||||
|
# "watched" must have NO effect.
|
||||||
|
plex = _FakePlex(
|
||||||
|
{
|
||||||
|
"mDeck": {"viewCount": 1, "lastViewedAt": int(now.timestamp())},
|
||||||
|
"e1": {"viewCount": 1, "lastViewedAt": int(now.timestamp())},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
out = _reconcile_inprogress_movies(
|
||||||
|
db, plex, SimpleNamespace(user_id=user.id), on_deck_ids={on_deck_movie.id}
|
||||||
|
)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
assert out == {"watched": 0, "in_progress": 0, "probed": 0}, "neither item is probed"
|
||||||
|
for iid in (on_deck_movie.id, episode.id):
|
||||||
|
st = (
|
||||||
|
db.execute(
|
||||||
|
select(PlexState).where(PlexState.user_id == user.id, PlexState.item_id == iid)
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.one()
|
||||||
|
)
|
||||||
|
assert st.status == "new" and st.position_seconds == 300, "left untouched"
|
||||||
|
|
||||||
|
|
||||||
|
def test_movie_probe_leaves_row_when_plex_has_no_signal(db):
|
||||||
|
"""If Plex reports no watch signal (viewCount 0, no offset) for a probed movie, the incremental pass
|
||||||
|
must NOT clear the local in-progress row — un-watch is the daily reconcile's job, and a Siftlode-only
|
||||||
|
in-progress state must survive."""
|
||||||
|
item_id, user_id = _seed_item_and_user(db)
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
db.add(
|
||||||
|
PlexState(
|
||||||
|
user_id=user_id,
|
||||||
|
item_id=item_id,
|
||||||
|
status="new",
|
||||||
|
position_seconds=300,
|
||||||
|
progress_updated_at=now,
|
||||||
|
synced_to_plex=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
plex = _FakePlex({"m1": {"viewCount": 0}})
|
||||||
|
out = _reconcile_inprogress_movies(
|
||||||
|
db, plex, SimpleNamespace(user_id=user_id), on_deck_ids=set()
|
||||||
|
)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
assert out == {"watched": 0, "in_progress": 0, "probed": 1}
|
||||||
|
st = (
|
||||||
|
db.execute(
|
||||||
|
select(PlexState).where(PlexState.user_id == user_id, PlexState.item_id == item_id)
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.one()
|
||||||
|
)
|
||||||
|
assert st.status == "new" and st.position_seconds == 300, "in-progress row preserved"
|
||||||
|
|
||||||
|
|
||||||
def test_should_push_resume_scopes_by_user_and_item():
|
def test_should_push_resume_scopes_by_user_and_item():
|
||||||
"""The throttle window is keyed by the FULL (user, item) tuple: pushing for one key must not
|
"""The throttle window is keyed by the FULL (user, item) tuple: pushing for one key must not
|
||||||
suppress a distinct user or a distinct item within the same window."""
|
suppress a distinct user or a distinct item within the same window."""
|
||||||
|
|||||||
@@ -0,0 +1,503 @@
|
|||||||
|
"""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 the shared `probe.probe_media` so they don't shell out to ffprobe; the classifier's OWN
|
||||||
|
parsing (image → file, cover-art skip, dims, duration) is covered directly by `TestProbeClassifier`
|
||||||
|
below, which stubs one level down (`run_ffprobe_json`) and exercises the real `probe_media`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.downloads import mediakind, quota, storage
|
||||||
|
from app.downloads import probe as probemod
|
||||||
|
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):
|
||||||
|
# upload ingest now calls the shared `probe.probe_media` (same ground-truth ffprobe the download
|
||||||
|
# worker uses); patch it at that shared home.
|
||||||
|
monkeypatch.setattr(probemod, "probe_media", lambda _p: result)
|
||||||
|
|
||||||
|
|
||||||
|
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_media parsing (stubs ffprobe one level down) ------------------
|
||||||
|
# The ingest/route tests above stub probe_media wholesale; these exercise its OWN logic — image
|
||||||
|
# detection, the cover-art skip, dimension/duration parsing — by faking only run_ffprobe_json, so a
|
||||||
|
# regression in the classifier the force-download guarantee leans on is actually caught.
|
||||||
|
|
||||||
|
|
||||||
|
class TestProbeClassifier:
|
||||||
|
def _probe_with(self, monkeypatch, data):
|
||||||
|
monkeypatch.setattr(probemod.editmod, "run_ffprobe_json", lambda *a, **k: data)
|
||||||
|
return probemod.probe_media(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
|
||||||
|
|
||||||
|
|
||||||
|
# --- resolve_media_facts: probe-of-the-file wins over yt-dlp's info (the FB regression fix) ---------
|
||||||
|
# The download worker's finalize must classify from the ACTUAL file, not the extractor's `info` dict.
|
||||||
|
# yt-dlp omits vcodec/acodec/dims for some sources (Facebook), and deriving media_kind from those
|
||||||
|
# nulls turned a real H.264 video into a download-only 'file' (no in-app play). These pin that the
|
||||||
|
# probe is authoritative and `info` is only a gap-filler.
|
||||||
|
|
||||||
|
|
||||||
|
class TestResolveMediaFacts:
|
||||||
|
# These stub run_ffprobe_json (one level below probe_media) so the REAL probe parsing + the
|
||||||
|
# probe-vs-info precedence are exercised together — the precedence keys off whether ffprobe RAN.
|
||||||
|
@staticmethod
|
||||||
|
def _ffprobe(monkeypatch, data):
|
||||||
|
monkeypatch.setattr(probemod.editmod, "run_ffprobe_json", lambda *a, **k: data)
|
||||||
|
|
||||||
|
def test_probe_wins_when_info_lacks_codecs(self, monkeypatch):
|
||||||
|
# THE original regression: info has no vcodec/acodec/dims (Facebook), but the file probes as
|
||||||
|
# h264/aac.
|
||||||
|
self._ffprobe(
|
||||||
|
monkeypatch,
|
||||||
|
{
|
||||||
|
"format": {"format_name": "mov,mp4,m4a"},
|
||||||
|
"streams": [
|
||||||
|
{"codec_type": "video", "codec_name": "h264", "width": 720, "height": 1280},
|
||||||
|
{"codec_type": "audio", "codec_name": "aac"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
v, a, w, h = probemod.resolve_media_facts(Path("x"), {"width": None, "height": None})
|
||||||
|
assert (v, a, w, h) == ("h264", "aac", 720, 1280)
|
||||||
|
assert mediakind.derive(v, a) == mediakind.VIDEO # NOT 'file'
|
||||||
|
|
||||||
|
def test_probe_wins_over_a_DIFFERING_populated_info(self, monkeypatch):
|
||||||
|
# Both the probe AND info are populated but DISAGREE (info carries a pre-remux/pre-merge codec).
|
||||||
|
# The finished file on disk is ground truth, so the probe wins outright — pins the precedence a
|
||||||
|
# `info or probe` inversion would silently break.
|
||||||
|
self._ffprobe(
|
||||||
|
monkeypatch,
|
||||||
|
{
|
||||||
|
"format": {"format_name": "mov,mp4,m4a"},
|
||||||
|
"streams": [
|
||||||
|
{"codec_type": "video", "codec_name": "h264", "width": 1920, "height": 1080},
|
||||||
|
{"codec_type": "audio", "codec_name": "aac"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
v, a, w, h = probemod.resolve_media_facts(
|
||||||
|
Path("x"), {"vcodec": "vp9", "acodec": "opus", "width": 640, "height": 480}
|
||||||
|
)
|
||||||
|
assert (v, a, w, h) == ("h264", "aac", 1920, 1080) # probe, not info
|
||||||
|
|
||||||
|
def test_cover_art_audio_download_is_not_resurrected_to_video_by_info(self, monkeypatch):
|
||||||
|
# THE bug this fix targets: an audio-mode download from a progressive source embeds cover art,
|
||||||
|
# and yt-dlp's info still carries the source's video codec. The probe correctly finds only audio
|
||||||
|
# (the attached_pic is skipped); info's vcodec must NOT resurrect a video track → media_kind
|
||||||
|
# stays audio, not a broken in-app 'video'.
|
||||||
|
self._ffprobe(
|
||||||
|
monkeypatch,
|
||||||
|
{
|
||||||
|
"format": {"format_name": "mp3", "duration": "180.0"},
|
||||||
|
"streams": [
|
||||||
|
{
|
||||||
|
"codec_type": "video",
|
||||||
|
"codec_name": "mjpeg",
|
||||||
|
"disposition": {"attached_pic": 1},
|
||||||
|
},
|
||||||
|
{"codec_type": "audio", "codec_name": "mp3"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
v, a, w, h = probemod.resolve_media_facts(
|
||||||
|
Path("x"), {"vcodec": "avc1", "acodec": "mp4a", "width": 1280, "height": 720}
|
||||||
|
)
|
||||||
|
assert v is None and a == "mp3" # info's avc1 did NOT win
|
||||||
|
assert mediakind.derive(v, a) == mediakind.AUDIO # NOT VIDEO
|
||||||
|
|
||||||
|
def test_still_image_download_is_not_resurrected_to_video_by_info(self, monkeypatch):
|
||||||
|
# A downloaded still image: the probe returns no vcodec (image-format guard). info's image
|
||||||
|
# "vcodec" must not turn it into a broken 'video' → stays a generic file.
|
||||||
|
self._ffprobe(
|
||||||
|
monkeypatch,
|
||||||
|
{
|
||||||
|
"format": {"format_name": "png_pipe"},
|
||||||
|
"streams": [{"codec_type": "video", "codec_name": "png", "width": 8, "height": 8}],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
v, a, w, h = probemod.resolve_media_facts(
|
||||||
|
Path("x"), {"vcodec": "png", "width": 8, "height": 8}
|
||||||
|
)
|
||||||
|
assert v is None and mediakind.derive(v, a) == mediakind.FILE
|
||||||
|
|
||||||
|
def test_info_fills_in_only_when_ffprobe_could_not_read_the_file(self, monkeypatch):
|
||||||
|
# ffprobe failed entirely (None) → fall back to info so we never regress the working path.
|
||||||
|
self._ffprobe(monkeypatch, None)
|
||||||
|
v, a, w, h = probemod.resolve_media_facts(
|
||||||
|
Path("x"), {"vcodec": "vp9", "acodec": "opus", "width": 1920, "height": 1080}
|
||||||
|
)
|
||||||
|
assert (v, a, w, h) == ("vp9", "opus", 1920, 1080)
|
||||||
|
|
||||||
|
def test_info_none_codec_string_is_treated_as_absent(self, monkeypatch):
|
||||||
|
# yt-dlp writes the literal "none" for a missing stream — it must not become a codec name.
|
||||||
|
self._ffprobe(monkeypatch, None) # probe failed → info path, where the "none" scrub applies
|
||||||
|
v, a, w, h = probemod.resolve_media_facts(Path("x"), {"vcodec": "none", "acodec": "aac"})
|
||||||
|
assert (v, a) == (None, "aac")
|
||||||
|
assert mediakind.derive(v, a) == mediakind.AUDIO
|
||||||
@@ -34,6 +34,7 @@ import type { FeedView } from "../lib/feedView";
|
|||||||
import { channelYouTubeUrl, formatViews } from "../lib/format";
|
import { channelYouTubeUrl, formatViews } from "../lib/format";
|
||||||
import { ChannelTagsControl } from "./ChannelTags";
|
import { ChannelTagsControl } from "./ChannelTags";
|
||||||
import { useChannelTagActions } from "../lib/useChannelTagActions";
|
import { useChannelTagActions } from "../lib/useChannelTagActions";
|
||||||
|
import { Button } from "./ui/Button";
|
||||||
|
|
||||||
// A dedicated channel page: an "About"-style header (banner, avatar, stats, subscribe) over the
|
// A dedicated channel page: an "About"-style header (banner, avatar, stats, subscribe) over the
|
||||||
// channel's videos (the catalog filtered to this channel). For an un-subscribed channel it
|
// channel's videos (the catalog filtered to this channel). For an un-subscribed channel it
|
||||||
@@ -441,21 +442,18 @@ export default function ChannelPage({
|
|||||||
it) so a large channel's paced tail reads as visible progress rather than a stuck
|
it) so a large channel's paced tail reads as visible progress rather than a stuck
|
||||||
spinner. */}
|
spinner. */}
|
||||||
{ch?.subscribed && !ch.backfill_done && !me.is_demo && (
|
{ch?.subscribed && !ch.backfill_done && !me.is_demo && (
|
||||||
<button
|
<Button
|
||||||
|
variant="outline"
|
||||||
onClick={() => !ch.deep_requested && deepBackfill.mutate()}
|
onClick={() => !ch.deep_requested && deepBackfill.mutate()}
|
||||||
disabled={ch.deep_requested || deepBackfill.isPending}
|
disabled={ch.deep_requested || deepBackfill.isPending}
|
||||||
|
loading={ch.deep_requested}
|
||||||
|
icon={<History className="w-4 h-4" />}
|
||||||
title={
|
title={
|
||||||
ch.deep_requested
|
ch.deep_requested
|
||||||
? t("channel.fullHistoryLoadingHint")
|
? t("channel.fullHistoryLoadingHint")
|
||||||
: t("channel.fullHistory")
|
: t("channel.fullHistory")
|
||||||
}
|
}
|
||||||
className="inline-flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm border border-border text-muted hover:text-accent hover:border-accent disabled:opacity-60 disabled:hover:text-muted disabled:hover:border-border transition"
|
|
||||||
>
|
>
|
||||||
{ch.deep_requested ? (
|
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<History className="w-4 h-4" />
|
|
||||||
)}
|
|
||||||
<span className="hidden sm:inline whitespace-nowrap">
|
<span className="hidden sm:inline whitespace-nowrap">
|
||||||
{!ch.deep_requested
|
{!ch.deep_requested
|
||||||
? t("channel.fullHistory")
|
? t("channel.fullHistory")
|
||||||
@@ -466,7 +464,7 @@ export default function ChannelPage({
|
|||||||
})
|
})
|
||||||
: t("channel.fullHistoryLoading")}
|
: t("channel.fullHistoryLoading")}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{!me.is_demo && (
|
{!me.is_demo && (
|
||||||
<button
|
<button
|
||||||
@@ -504,24 +502,24 @@ export default function ChannelPage({
|
|||||||
{!me.is_demo &&
|
{!me.is_demo &&
|
||||||
!ch?.blocked &&
|
!ch?.blocked &&
|
||||||
(ch?.subscribed ? (
|
(ch?.subscribed ? (
|
||||||
<button
|
<Button
|
||||||
|
variant="outline"
|
||||||
onClick={onUnsubscribe}
|
onClick={onUnsubscribe}
|
||||||
disabled={unsubscribe.isPending}
|
disabled={unsubscribe.isPending}
|
||||||
title={t("channel.unsubscribe")}
|
title={t("channel.unsubscribe")}
|
||||||
className="inline-flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm border border-border text-muted hover:text-fg disabled:opacity-50"
|
icon={<Check className="w-4 h-4" />}
|
||||||
>
|
>
|
||||||
<Check className="w-4 h-4" />
|
|
||||||
{t("channel.subscribedState")}
|
{t("channel.subscribedState")}
|
||||||
</button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<Button
|
||||||
|
variant="outline"
|
||||||
onClick={() => subscribe.mutate()}
|
onClick={() => subscribe.mutate()}
|
||||||
disabled={subscribe.isPending}
|
disabled={subscribe.isPending}
|
||||||
className="inline-flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm btn-primary disabled:opacity-50"
|
icon={<Plus className="w-4 h-4" />}
|
||||||
>
|
>
|
||||||
<Plus className="w-4 h-4" />
|
|
||||||
{t("channel.subscribe")}
|
{t("channel.subscribe")}
|
||||||
</button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
{/* Tagging is a subscription-scoped action (attach_tag → _user_subscription 404s
|
{/* Tagging is a subscription-scoped action (attach_tag → _user_subscription 404s
|
||||||
otherwise), and personal tags are for your subscriptions — so only offer it on a
|
otherwise), and personal tags are for your subscriptions — so only offer it on a
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createContext, useCallback, useContext, useState, type ReactNode } from "react";
|
import { createContext, useCallback, useContext, useState, type ReactNode } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import Modal from "./Modal";
|
import Modal from "./Modal";
|
||||||
|
import { Button } from "./ui/Button";
|
||||||
|
|
||||||
// App-styled, promise-based replacement for window.confirm. Wrap the tree in
|
// App-styled, promise-based replacement for window.confirm. Wrap the tree in
|
||||||
// <ConfirmProvider> once, then anywhere: const confirm = useConfirm();
|
// <ConfirmProvider> once, then anywhere: const confirm = useConfirm();
|
||||||
@@ -100,26 +101,25 @@ export function ConfirmProvider({ children }: { children: ReactNode }) {
|
|||||||
<div
|
<div
|
||||||
className={multi ? "flex flex-col gap-2 mt-5" : "flex justify-end gap-2 mt-5 flex-wrap"}
|
className={multi ? "flex flex-col gap-2 mt-5" : "flex justify-end gap-2 mt-5 flex-wrap"}
|
||||||
>
|
>
|
||||||
<button
|
<Button
|
||||||
|
variant="outline"
|
||||||
autoFocus={anyDanger}
|
autoFocus={anyDanger}
|
||||||
onClick={() => close(null)}
|
onClick={() => close(null)}
|
||||||
className={`px-4 py-2 rounded-lg text-sm border border-border text-muted hover:text-fg hover:bg-surface transition ${
|
fullWidth={multi}
|
||||||
multi ? "w-full order-last" : ""
|
className={multi ? "order-last" : ""}
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{state.opts.cancelLabel ?? t("common.cancel")}
|
{state.opts.cancelLabel ?? t("common.cancel")}
|
||||||
</button>
|
</Button>
|
||||||
{choices.map((c, i) => (
|
{choices.map((c, i) => (
|
||||||
<button
|
<Button
|
||||||
key={c.id}
|
key={c.id}
|
||||||
|
variant={c.danger ? "danger" : "primary"}
|
||||||
autoFocus={!anyDanger && i === choices.length - 1}
|
autoFocus={!anyDanger && i === choices.length - 1}
|
||||||
onClick={() => close(c.id)}
|
onClick={() => close(c.id)}
|
||||||
className={`px-4 py-2 rounded-lg text-sm font-semibold transition ${
|
fullWidth={multi}
|
||||||
multi ? "w-full" : ""
|
|
||||||
} ${c.danger ? "btn-danger" : "btn-primary"}`}
|
|
||||||
>
|
>
|
||||||
{c.label || t("common.confirm")}
|
{c.label || t("common.confirm")}
|
||||||
</button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ import {
|
|||||||
Ban,
|
Ban,
|
||||||
ClipboardPaste,
|
ClipboardPaste,
|
||||||
Download,
|
Download,
|
||||||
|
Link2,
|
||||||
Pause,
|
Pause,
|
||||||
Play,
|
Play,
|
||||||
|
PlayCircle,
|
||||||
Plus,
|
Plus,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
Scissors,
|
Scissors,
|
||||||
@@ -15,6 +17,7 @@ import {
|
|||||||
Share2,
|
Share2,
|
||||||
Pencil,
|
Pencil,
|
||||||
Trash2,
|
Trash2,
|
||||||
|
UploadCloud,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Tabs from "./Tabs";
|
import Tabs from "./Tabs";
|
||||||
import { PageToolbar } from "./PageShell";
|
import { PageToolbar } from "./PageShell";
|
||||||
@@ -22,15 +25,19 @@ import { LoadingState, StateMessage } from "./QueryState";
|
|||||||
// Lazy: these dialogs/editors open on demand, so they stay out of the Downloads page chunk until
|
// 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).
|
// used (the video editor in particular is heavy — filmstrip + scrubber).
|
||||||
const DownloadDialog = lazy(() => import("./DownloadDialog"));
|
const DownloadDialog = lazy(() => import("./DownloadDialog"));
|
||||||
|
const UploadDialog = lazy(() => import("./UploadDialog"));
|
||||||
const ProfileEditor = lazy(() => import("./ProfileEditor"));
|
const ProfileEditor = lazy(() => import("./ProfileEditor"));
|
||||||
const VideoEditor = lazy(() => import("./VideoEditor"));
|
const VideoEditor = lazy(() => import("./VideoEditor"));
|
||||||
const ShareDialog = lazy(() => import("./ShareDialog"));
|
const ShareDialog = lazy(() => import("./ShareDialog"));
|
||||||
|
const LibraryPlayer = lazy(() => import("./LibraryPlayer"));
|
||||||
import { useConfirm } from "./ConfirmProvider";
|
import { useConfirm } from "./ConfirmProvider";
|
||||||
import { useLiveQuery } from "../lib/useLiveQuery";
|
import { useLiveQuery } from "../lib/useLiveQuery";
|
||||||
import { useMe } from "../lib/useMe";
|
import { useMe } from "../lib/useMe";
|
||||||
import { api, type DownloadJob } from "../lib/api";
|
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 { inputCls } from "./ui/form";
|
||||||
|
import { Button } from "./ui/Button";
|
||||||
import { DownloadRow, IconBtn, UsageBar } from "./downloadRow";
|
import { DownloadRow, IconBtn, UsageBar } from "./downloadRow";
|
||||||
import { MetaEditModal, ErrorModal } from "./downloadModals";
|
import { MetaEditModal, ErrorModal } from "./downloadModals";
|
||||||
import { AdminSystem } from "./downloadAdmin";
|
import { AdminSystem } from "./downloadAdmin";
|
||||||
@@ -45,13 +52,21 @@ export default function DownloadCenter() {
|
|||||||
const [tab, setTab] = useState("queue");
|
const [tab, setTab] = useState("queue");
|
||||||
const [addUrl, setAddUrl] = useState("");
|
const [addUrl, setAddUrl] = useState("");
|
||||||
const [addSource, setAddSource] = useState<string | null>(null);
|
const [addSource, setAddSource] = useState<string | null>(null);
|
||||||
|
const [upload, setUpload] = useState(false);
|
||||||
const [manage, setManage] = useState(false);
|
const [manage, setManage] = useState(false);
|
||||||
const [metaJob, setMetaJob] = useState<DownloadJob | null>(null);
|
const [metaJob, setMetaJob] = useState<DownloadJob | null>(null);
|
||||||
const [shareJob, setShareJob] = useState<DownloadJob | null>(null);
|
const [shareJob, setShareJob] = useState<DownloadJob | null>(null);
|
||||||
|
const [playJob, setPlayJob] = useState<DownloadJob | null>(null);
|
||||||
const [editJob, setEditJob] = useState<DownloadJob | null>(null);
|
const [editJob, setEditJob] = useState<DownloadJob | null>(null);
|
||||||
const [errorJob, setErrorJob] = useState<DownloadJob | null>(null);
|
const [errorJob, setErrorJob] = useState<DownloadJob | null>(null);
|
||||||
|
|
||||||
const jobsQ = useLiveQuery(qk.downloads(), api.downloads, { intervalMs: 2000 });
|
// Poll fast (2s) while work is actually in flight; back off to 15s once the queue is settled
|
||||||
|
// (only done/error/paused rows left, which don't advance on their own) so an idle Download Center
|
||||||
|
// isn't hitting the API — and adding to the shared box's load — every 2s for nothing.
|
||||||
|
const jobsQ = useLiveQuery(qk.downloads(), api.downloads, {
|
||||||
|
intervalMs: (data) =>
|
||||||
|
data?.some((j) => j.status === "queued" || j.status === "running") ? 2000 : 15000,
|
||||||
|
});
|
||||||
const sharedQ = useQuery({
|
const sharedQ = useQuery({
|
||||||
queryKey: qk.downloadsShared(),
|
queryKey: qk.downloadsShared(),
|
||||||
queryFn: api.downloadsShared,
|
queryFn: api.downloadsShared,
|
||||||
@@ -94,6 +109,21 @@ export default function DownloadCenter() {
|
|||||||
if (await confirm({ title, message: body, confirmLabel: title, danger: true })) fn();
|
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 +
|
// 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
|
// 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
|
// button is an explicit gesture (reliable), and the input's onFocus tries silently (ignored if
|
||||||
@@ -163,20 +193,34 @@ export default function DownloadCenter() {
|
|||||||
placeholder={t("downloads.page.addUrl")}
|
placeholder={t("downloads.page.addUrl")}
|
||||||
className={inputCls}
|
className={inputCls}
|
||||||
/>
|
/>
|
||||||
<button
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
iconOnly
|
||||||
|
icon={<ClipboardPaste className="w-4 h-4" />}
|
||||||
|
label={t("downloads.page.paste")}
|
||||||
onClick={() => pasteFromClipboard(true)}
|
onClick={() => pasteFromClipboard(true)}
|
||||||
title={t("downloads.page.paste")}
|
className="shrink-0"
|
||||||
aria-label={t("downloads.page.paste")}
|
/>
|
||||||
className="inline-flex items-center justify-center px-2.5 py-1.5 rounded-lg text-muted hover:text-fg hover:bg-surface transition shrink-0"
|
<Button
|
||||||
>
|
variant="primary"
|
||||||
<ClipboardPaste className="w-4 h-4" />
|
size="sm"
|
||||||
</button>
|
icon={<Plus className="w-4 h-4" />}
|
||||||
<button
|
|
||||||
onClick={() => addUrl.trim() && setAddSource(addUrl.trim())}
|
onClick={() => addUrl.trim() && setAddSource(addUrl.trim())}
|
||||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium btn-primary transition shrink-0"
|
className="shrink-0"
|
||||||
>
|
>
|
||||||
<Plus className="w-4 h-4" /> {t("downloads.page.add")}
|
{t("downloads.page.add")}
|
||||||
</button>
|
</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>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{queue.map((job) => (
|
{queue.map((job) => (
|
||||||
@@ -236,50 +280,69 @@ export default function DownloadCenter() {
|
|||||||
<>
|
<>
|
||||||
<UsageBar />
|
<UsageBar />
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{library.map((job) => (
|
{library.map((job) => {
|
||||||
<DownloadRow key={job.id} job={job}>
|
// Row actions gated on the stored media_kind + job_kind (see downloadActions).
|
||||||
{saveBtn(job)}
|
const a = libraryRowActions(job);
|
||||||
{job.can_download && (job.asset?.duration_s ?? 0) > 0 && (
|
return (
|
||||||
<IconBtn onClick={() => setEditJob(job)} title={t("downloads.actions.edit")}>
|
<DownloadRow key={job.id} job={job}>
|
||||||
<Scissors className="w-4 h-4" />
|
{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>
|
||||||
)}
|
<IconBtn
|
||||||
<IconBtn
|
onClick={() => setShareJob(job)}
|
||||||
onClick={() => setMetaJob(job)}
|
title={t("downloads.actions.shareManage")}
|
||||||
title={t("downloads.actions.editDetails")}
|
>
|
||||||
>
|
<Link2 className="w-4 h-4" />
|
||||||
<Pencil className="w-4 h-4" />
|
</IconBtn>
|
||||||
</IconBtn>
|
{a.redownload && (
|
||||||
<IconBtn onClick={() => setShareJob(job)} title={t("downloads.actions.share")}>
|
<IconBtn
|
||||||
<Share2 className="w-4 h-4" />
|
onClick={() =>
|
||||||
</IconBtn>
|
confirmThen(
|
||||||
<IconBtn
|
t("downloads.confirm.redownloadTitle"),
|
||||||
onClick={() =>
|
t("downloads.confirm.redownloadBody"),
|
||||||
confirmThen(
|
() => act.mutate({ id: job.id, op: "redownload" }),
|
||||||
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" />
|
||||||
title={t("downloads.actions.redownload")}
|
</IconBtn>
|
||||||
>
|
)}
|
||||||
<RotateCcw className="w-4 h-4" />
|
<IconBtn
|
||||||
</IconBtn>
|
onClick={() =>
|
||||||
<IconBtn
|
confirmThen(
|
||||||
onClick={() =>
|
t("downloads.confirm.deleteTitle"),
|
||||||
confirmThen(
|
t("downloads.confirm.deleteBody"),
|
||||||
t("downloads.confirm.deleteTitle"),
|
() => act.mutate({ id: job.id, op: "delete" }),
|
||||||
t("downloads.confirm.deleteBody"),
|
)
|
||||||
() => act.mutate({ id: job.id, op: "delete" }),
|
}
|
||||||
)
|
title={t("downloads.actions.delete")}
|
||||||
}
|
danger
|
||||||
title={t("downloads.actions.delete")}
|
>
|
||||||
danger
|
<Trash2 className="w-4 h-4" />
|
||||||
>
|
</IconBtn>
|
||||||
<Trash2 className="w-4 h-4" />
|
</DownloadRow>
|
||||||
</IconBtn>
|
);
|
||||||
</DownloadRow>
|
})}
|
||||||
))}
|
|
||||||
{library.length === 0 &&
|
{library.length === 0 &&
|
||||||
(jobsQ.isError && !jobsQ.data ? (
|
(jobsQ.isError && !jobsQ.data ? (
|
||||||
<StateMessage tone="error" onRetry={() => jobsQ.refetch()}>
|
<StateMessage tone="error" onRetry={() => jobsQ.refetch()}>
|
||||||
@@ -298,34 +361,50 @@ export default function DownloadCenter() {
|
|||||||
|
|
||||||
{tab === "shared" && (
|
{tab === "shared" && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{(sharedQ.data ?? []).map((job) => (
|
{(sharedQ.data ?? []).map((job) => {
|
||||||
<DownloadRow key={job.id} job={job}>
|
// Same media_kind + can_download gate as the library, via the shared helper (S5b).
|
||||||
{job.can_download && saveBtn(job)}
|
const a = sharedRowActions(job);
|
||||||
{job.can_download && (job.asset?.duration_s ?? 0) > 0 && (
|
return (
|
||||||
<IconBtn onClick={() => setEditJob(job)} title={t("downloads.actions.edit")}>
|
<DownloadRow key={job.id} job={job}>
|
||||||
<Scissors className="w-4 h-4" />
|
{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>
|
||||||
)}
|
</DownloadRow>
|
||||||
<IconBtn
|
);
|
||||||
onClick={() =>
|
})}
|
||||||
confirmThen(
|
{(sharedQ.data ?? []).length === 0 &&
|
||||||
t("downloads.confirm.removeSharedTitle"),
|
(sharedQ.isError && !sharedQ.data ? (
|
||||||
t("downloads.confirm.removeSharedBody"),
|
<StateMessage tone="error" onRetry={() => sharedQ.refetch()}>
|
||||||
() => removeShared.mutate(job.id),
|
{t("common.loadError")}
|
||||||
)
|
</StateMessage>
|
||||||
}
|
) : sharedQ.isLoading ? (
|
||||||
title={t("downloads.actions.removeShared")}
|
<LoadingState />
|
||||||
danger
|
) : (
|
||||||
>
|
<div className="text-sm text-muted py-10 text-center">
|
||||||
<Trash2 className="w-4 h-4" />
|
{t("downloads.empty.shared")}
|
||||||
</IconBtn>
|
</div>
|
||||||
</DownloadRow>
|
))}
|
||||||
))}
|
|
||||||
{sharedQ.data && sharedQ.data.length === 0 && (
|
|
||||||
<div className="text-sm text-muted py-10 text-center">
|
|
||||||
{t("downloads.empty.shared")}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -341,9 +420,11 @@ export default function DownloadCenter() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{upload && <UploadDialog onClose={() => setUpload(false)} />}
|
||||||
{manage && <ProfileEditor onClose={() => setManage(false)} />}
|
{manage && <ProfileEditor onClose={() => setManage(false)} />}
|
||||||
{metaJob && <MetaEditModal job={metaJob} onClose={() => setMetaJob(null)} />}
|
{metaJob && <MetaEditModal job={metaJob} onClose={() => setMetaJob(null)} />}
|
||||||
{shareJob && <ShareDialog job={shareJob} onClose={() => setShareJob(null)} />}
|
{shareJob && <ShareDialog job={shareJob} onClose={() => setShareJob(null)} />}
|
||||||
|
{playJob && <LibraryPlayer job={playJob} onClose={() => setPlayJob(null)} />}
|
||||||
{editJob && <VideoEditor job={editJob} onClose={() => setEditJob(null)} />}
|
{editJob && <VideoEditor job={editJob} onClose={() => setEditJob(null)} />}
|
||||||
{errorJob && (
|
{errorJob && (
|
||||||
<ErrorModal
|
<ErrorModal
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { qk } from "../lib/queryKeys";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import Modal from "./Modal";
|
import Modal from "./Modal";
|
||||||
|
import { Button } from "./ui/Button";
|
||||||
import { api } from "../lib/api";
|
import { api } from "../lib/api";
|
||||||
import { invalidateDownloads } from "../lib/downloads";
|
import { invalidateDownloads } from "../lib/downloads";
|
||||||
|
|
||||||
@@ -89,19 +90,12 @@ export default function DownloadDialog({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
<button
|
<Button variant="ghost" size="sm" onClick={onClose}>
|
||||||
onClick={onClose}
|
|
||||||
className="px-3 py-1.5 rounded-lg text-sm text-muted hover:text-fg hover:bg-surface transition"
|
|
||||||
>
|
|
||||||
{t("downloads.actions.cancel")}
|
{t("downloads.actions.cancel")}
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button variant="primary" size="sm" onClick={submit} disabled={busy || !chosen}>
|
||||||
onClick={submit}
|
|
||||||
disabled={busy || !chosen}
|
|
||||||
className="px-3 py-1.5 rounded-lg text-sm font-medium btn-primary disabled:opacity-50 transition"
|
|
||||||
>
|
|
||||||
{t("downloads.dialog.submit")}
|
{t("downloads.dialog.submit")}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{manage && (
|
{manage && (
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useSyncExternalStore } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { dismissError, getError, subscribeError } from "../lib/errorDialog";
|
import { dismissError, getError, subscribeError } from "../lib/errorDialog";
|
||||||
import Modal from "./Modal";
|
import Modal from "./Modal";
|
||||||
|
import { Button } from "./ui/Button";
|
||||||
|
|
||||||
// Renders the current app error (if any) as a modal the user must acknowledge.
|
// Renders the current app error (if any) as a modal the user must acknowledge.
|
||||||
export default function ErrorDialog() {
|
export default function ErrorDialog() {
|
||||||
@@ -12,12 +13,7 @@ export default function ErrorDialog() {
|
|||||||
<Modal title={err.title} onClose={dismissError} maxWidth="max-w-md">
|
<Modal title={err.title} onClose={dismissError} maxWidth="max-w-md">
|
||||||
<p className="text-sm leading-relaxed text-fg/90">{err.message}</p>
|
<p className="text-sm leading-relaxed text-fg/90">{err.message}</p>
|
||||||
<div className="flex justify-end mt-5">
|
<div className="flex justify-end mt-5">
|
||||||
<button
|
<Button onClick={dismissError}>{t("errors.ok")}</Button>
|
||||||
onClick={dismissError}
|
|
||||||
className="px-4 py-2 rounded-lg btn-primary text-sm font-medium transition"
|
|
||||||
>
|
|
||||||
{t("errors.ok")}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
import { CSS } from "@dnd-kit/utilities";
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
import { coercePlexFilters, type FeedFilters, type PlexFilters, type SavedView } from "../lib/api";
|
import { coercePlexFilters, type FeedFilters, type PlexFilters, type SavedView } from "../lib/api";
|
||||||
import Modal from "./Modal";
|
import Modal from "./Modal";
|
||||||
|
import { Button } from "./ui/Button";
|
||||||
import { useSavedViews, asFilters } from "./SavedViewsProvider";
|
import { useSavedViews, asFilters } from "./SavedViewsProvider";
|
||||||
import { useActiveFilters } from "../lib/useActiveFilters";
|
import { useActiveFilters } from "../lib/useActiveFilters";
|
||||||
import { buildPlexActiveFilters } from "./usePlexActiveFilters";
|
import { buildPlexActiveFilters } from "./usePlexActiveFilters";
|
||||||
@@ -202,14 +203,16 @@ export default function PersonalViewsModal() {
|
|||||||
placeholder={t("views.namePlaceholder")}
|
placeholder={t("views.namePlaceholder")}
|
||||||
className="min-w-0 flex-1 rounded-lg border border-border bg-surface px-2 py-1.5 text-sm focus:border-accent focus:outline-hidden focus:ring-2 focus:ring-accent/30"
|
className="min-w-0 flex-1 rounded-lg border border-border bg-surface px-2 py-1.5 text-sm focus:border-accent focus:outline-hidden focus:ring-2 focus:ring-accent/30"
|
||||||
/>
|
/>
|
||||||
<button
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
iconOnly
|
||||||
|
icon={<BookmarkPlus className="h-4 w-4" />}
|
||||||
|
label={t("views.save")}
|
||||||
onClick={onCreate}
|
onClick={onCreate}
|
||||||
disabled={!createName.trim()}
|
disabled={!createName.trim()}
|
||||||
title={t("views.save")}
|
className="shrink-0"
|
||||||
className="inline-flex shrink-0 items-center gap-1 rounded-lg btn-primary px-2.5 py-1.5 text-sm font-semibold disabled:opacity-40"
|
/>
|
||||||
>
|
|
||||||
<BookmarkPlus className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -272,13 +275,14 @@ export default function PersonalViewsModal() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-auto flex flex-wrap items-center gap-2 border-t border-border/60 pt-3">
|
<div className="mt-auto flex flex-wrap items-center gap-2 border-t border-border/60 pt-3">
|
||||||
<button
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
icon={<Play className="h-3.5 w-3.5" />}
|
||||||
onClick={() => apply(selected)}
|
onClick={() => apply(selected)}
|
||||||
className="inline-flex items-center gap-1.5 rounded-lg btn-primary px-3 py-1.5 text-sm font-semibold"
|
|
||||||
>
|
>
|
||||||
<Play className="h-3.5 w-3.5" />
|
|
||||||
{t("views.apply")}
|
{t("views.apply")}
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<button
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
const saved = await saveCurrent(selected.id);
|
const saved = await saveCurrent(selected.id);
|
||||||
|
|||||||
@@ -362,7 +362,15 @@ export default function Playlists() {
|
|||||||
setPushing(true);
|
setPushing(true);
|
||||||
try {
|
try {
|
||||||
const plan = await api.playlistPushPlan(selectedId);
|
const plan = await api.playlistPushPlan(selectedId);
|
||||||
if (plan.action === "update" && !plan.to_insert && !plan.to_delete && !plan.to_reorder) {
|
const nothingToDo =
|
||||||
|
plan.action === "update" &&
|
||||||
|
!plan.add_yt &&
|
||||||
|
!plan.add_local &&
|
||||||
|
!plan.remove_yt &&
|
||||||
|
!plan.remove_local &&
|
||||||
|
!plan.to_reorder &&
|
||||||
|
!plan.rename;
|
||||||
|
if (nothingToDo) {
|
||||||
notify({ level: "info", message: t("playlists.pushUpToDate", { name: plName(detail) }) });
|
notify({ level: "info", message: t("playlists.pushUpToDate", { name: plName(detail) }) });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -376,21 +384,37 @@ export default function Playlists() {
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let message =
|
let message: string;
|
||||||
plan.action === "create"
|
if (plan.action === "create") {
|
||||||
? t("playlists.pushPlanCreate", {
|
message = t("playlists.pushPlanCreate", {
|
||||||
count: plan.to_insert,
|
count: plan.add_yt,
|
||||||
units: plan.units_estimate,
|
units: plan.units_estimate,
|
||||||
left: plan.remaining_today,
|
left: plan.remaining_today,
|
||||||
})
|
});
|
||||||
: t("playlists.pushPlanUpdate", {
|
} else {
|
||||||
insert: plan.to_insert,
|
// One sentence per side that actually changes; the untouched side gets a short "stays
|
||||||
del: plan.to_delete,
|
// unchanged" reassurance instead of a "+0 / −0" the reader has to decode.
|
||||||
reorder: plan.to_reorder,
|
const here: string[] = [];
|
||||||
units: plan.units_estimate,
|
if (plan.add_local) here.push(t("playlists.mergeAddLocal", { count: plan.add_local }));
|
||||||
left: plan.remaining_today,
|
if (plan.remove_local)
|
||||||
});
|
here.push(t("playlists.mergeRemoveLocal", { count: plan.remove_local }));
|
||||||
if (plan.yt_extra > 0) message += " " + t("playlists.pushDiverged", { count: plan.yt_extra });
|
const yt: string[] = [];
|
||||||
|
if (plan.add_yt) yt.push(t("playlists.mergeAddYt", { count: plan.add_yt }));
|
||||||
|
if (plan.remove_yt) yt.push(t("playlists.mergeRemoveYt", { count: plan.remove_yt }));
|
||||||
|
if (plan.to_reorder) yt.push(t("playlists.mergeReorder", { count: plan.to_reorder }));
|
||||||
|
if (plan.rename) yt.push(t("playlists.mergeRename"));
|
||||||
|
const sentences = [...here, ...yt];
|
||||||
|
if (here.length && !yt.length) sentences.push(t("playlists.mergeYtUnchanged"));
|
||||||
|
if (yt.length && !here.length) sentences.push(t("playlists.mergeLocalUnchanged"));
|
||||||
|
const quota =
|
||||||
|
plan.units_estimate > 0
|
||||||
|
? t("playlists.mergeQuota", {
|
||||||
|
units: plan.units_estimate,
|
||||||
|
left: plan.remaining_today,
|
||||||
|
})
|
||||||
|
: t("playlists.mergeQuotaFree");
|
||||||
|
message = sentences.join(" ") + quota;
|
||||||
|
}
|
||||||
const ok = await confirm({
|
const ok = await confirm({
|
||||||
title: t("playlists.pushTitle"),
|
title: t("playlists.pushTitle"),
|
||||||
message,
|
message,
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import i18n from "../i18n";
|
|||||||
import { Carousel } from "./Carousel";
|
import { Carousel } from "./Carousel";
|
||||||
import PlexCollectionEditor from "./PlexCollectionEditor";
|
import PlexCollectionEditor from "./PlexCollectionEditor";
|
||||||
import PlexPlaylistAdd from "./PlexPlaylistAdd";
|
import PlexPlaylistAdd from "./PlexPlaylistAdd";
|
||||||
|
import { PLAYABLE_TINT, playableClass } from "./plexCards";
|
||||||
|
|
||||||
// The rich "media info" surface for a Plex item — poster/art, IMDb score + link, content rating,
|
// The rich "media info" surface for a Plex item — poster/art, IMDb score + link, content rating,
|
||||||
// genres, director, cast (with photos), summary. Reused in TWO places: a full-page view opened from
|
// genres, director, cast (with photos), summary. Reused in TWO places: a full-page view opened from
|
||||||
@@ -253,6 +254,33 @@ function CrewPanel({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The playback-class chip (dot + Plex-native label), with a muted codec · bit-depth subtitle when
|
||||||
|
// known. Class + colour are derived through the SAME shared helper the browse-card corner dot uses,
|
||||||
|
// so the two surfaces can't drift. `overlay` tints the label for the dark player background.
|
||||||
|
function PlaybackChip({ detail, overlay }: { detail: PlexItemDetail; overlay: boolean }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
if (!detail.playable) return null;
|
||||||
|
const cls = playableClass(detail.playable, detail.transcode_hw);
|
||||||
|
const sub = [
|
||||||
|
detail.codec_video ? detail.codec_video.toUpperCase() : null,
|
||||||
|
detail.video_bit_depth != null ? `${detail.video_bit_depth}-bit` : null,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" · ");
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-xs ${
|
||||||
|
overlay ? "bg-white/10 text-white/90" : "bg-surface text-muted"
|
||||||
|
}`}
|
||||||
|
title={t(`plex.playable.${cls}`)}
|
||||||
|
>
|
||||||
|
<span className={`h-2 w-2 shrink-0 rounded-full ${PLAYABLE_TINT[cls] ?? "bg-gray-400"}`} />
|
||||||
|
<span className="font-medium">{t(`plex.filter.playableOpt.${cls}`)}</span>
|
||||||
|
{sub && <span className="opacity-70">{sub}</span>}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
detail: PlexItemDetail;
|
detail: PlexItemDetail;
|
||||||
variant: "page" | "overlay";
|
variant: "page" | "overlay";
|
||||||
@@ -425,7 +453,7 @@ export default function PlexInfo({
|
|||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="shrink-0 p-1.5 rounded-lg text-white/80 hover:bg-white/15"
|
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" />
|
<X className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
@@ -479,6 +507,7 @@ export default function PlexInfo({
|
|||||||
{detail.studio}
|
{detail.studio}
|
||||||
</Filterable>
|
</Filterable>
|
||||||
)}
|
)}
|
||||||
|
<PlaybackChip detail={detail} overlay={overlay} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Multi-source ratings row (E5 S5): IMDb / TMDb / audience, whichever are present. */}
|
{/* Multi-source ratings row (E5 S5): IMDb / TMDb / audience, whichever are present. */}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Clock,
|
Clock,
|
||||||
Download,
|
Download,
|
||||||
|
Gauge,
|
||||||
Info,
|
Info,
|
||||||
Keyboard,
|
Keyboard,
|
||||||
Languages,
|
Languages,
|
||||||
@@ -38,8 +39,10 @@ import {
|
|||||||
subShadowCss,
|
subShadowCss,
|
||||||
type PlexPlayerPrefs,
|
type PlexPlayerPrefs,
|
||||||
} from "../lib/plexPlayerPrefs";
|
} from "../lib/plexPlayerPrefs";
|
||||||
import { Ctrl, PanelHead } from "./plexPlayerControls";
|
import { Ctrl, PanelHead, Skip10 } from "./plexPlayerControls";
|
||||||
import { PlexPlayerSettings } from "./PlexPlayerSettings";
|
import { PlexPlayerSettings } from "./PlexPlayerSettings";
|
||||||
|
import { PlexStatsOverlay } from "./PlexStatsOverlay";
|
||||||
|
import { UpNextCard } from "./UpNextCard";
|
||||||
import {
|
import {
|
||||||
useAutoSkip,
|
useAutoSkip,
|
||||||
useControlsVisibility,
|
useControlsVisibility,
|
||||||
@@ -95,6 +98,12 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
|||||||
const aliveRef = useRef(true);
|
const aliveRef = useRef(true);
|
||||||
const wrapRef = useRef<HTMLDivElement>(null);
|
const wrapRef = useRef<HTMLDivElement>(null);
|
||||||
const sessionStartRef = useRef(0); // absolute offset the current HLS/direct session begins at
|
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);
|
||||||
|
// Whether the current HW transcode tone-mapped a 10-bit/HDR source on the GPU (OpenCL). Feeds the
|
||||||
|
// stats mode row's "· HDR→SDR" tag; null/false for an 8-bit HW, software, or non-transcode session.
|
||||||
|
const tonemapRef = useRef<boolean | null>(null);
|
||||||
const durationRef = useRef(0);
|
const durationRef = useRef(0);
|
||||||
// ≥2 audio tracks → multi-rendition HLS: all tracks ship in one session and hls.js switches audio
|
// ≥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.
|
// client-side (no restart). Ref so the stable loadSession/changeAudio callbacks read it fresh.
|
||||||
@@ -114,6 +123,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
|
// 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).
|
// brief cold-ffmpeg rebuffer on auto-advance, now largely prevented by the server-side segment warm-up).
|
||||||
const [buffering, setBuffering] = useState(false);
|
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);
|
const [loadError, setLoadError] = useState<string | null>(null);
|
||||||
// The current session's real media-start K (reactive mirror of sessionStartRef). Subtitles are
|
// 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
|
// absolute-time VTT shifted by K to the session's zero-based clock, so the <track> src/key keys off
|
||||||
@@ -171,6 +184,16 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
|||||||
const [infoOpen, setInfoOpen] = useState(false);
|
const [infoOpen, setInfoOpen] = useState(false);
|
||||||
const infoOpenRef = useRef(false);
|
const infoOpenRef = useRef(false);
|
||||||
infoOpenRef.current = infoOpen;
|
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();
|
const now = useWallClock();
|
||||||
|
|
||||||
durationRef.current = duration;
|
durationRef.current = duration;
|
||||||
@@ -186,6 +209,10 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
|||||||
setReady(false);
|
setReady(false);
|
||||||
setBuffering(false);
|
setBuffering(false);
|
||||||
setLoadError(null);
|
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;
|
let sess;
|
||||||
try {
|
try {
|
||||||
// Multi-audio → request the multi-rendition session (no single `audio` ordinal); the track is
|
// Multi-audio → request the multi-rendition session (no single `audio` ordinal); the track is
|
||||||
@@ -206,6 +233,12 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
|||||||
);
|
);
|
||||||
return;
|
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;
|
||||||
|
tonemapRef.current = isTranscode ? (sess.tonemap ?? false) : null;
|
||||||
|
setSessionIsTranscode(isTranscode);
|
||||||
// hls.js plays our non-zero-start copyts stream ~1s BEHIND the media clock (see HLS_SUB_LAG);
|
// 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
|
// 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.
|
// content-accurate. Direct-play (start_s 0) uses the file's real clock — no compensation.
|
||||||
@@ -514,7 +547,11 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
|||||||
// (absRef 0) — its stray 'ended' must not mark anything watched or cascade another advance.
|
// (absRef 0) — its stray 'ended' must not mark anything watched or cascade another advance.
|
||||||
if (absRef.current <= 0) return;
|
if (absRef.current <= 0) return;
|
||||||
api.plexSetState(id, "watched").catch(() => {});
|
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),
|
// Nothing queued next → dismiss the player back to the previous page (same as Stop/Escape),
|
||||||
// instead of stranding a black, ended <video> on screen.
|
// instead of stranding a black, ended <video> on screen.
|
||||||
else onClose();
|
else onClose();
|
||||||
@@ -523,6 +560,14 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
|||||||
return () => video.removeEventListener("ended", onEnded);
|
return () => video.removeEventListener("ended", onEnded);
|
||||||
}, [id, nextId, go, onClose]);
|
}, [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
|
// "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
|
// 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
|
// overlay) before leaving the player. The arrow used to call onClose() unconditionally, so Back
|
||||||
@@ -533,6 +578,8 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
|||||||
setTracksOpen(false);
|
setTracksOpen(false);
|
||||||
} else if (helpOpenRef.current) setHelpOpen(false);
|
} else if (helpOpenRef.current) setHelpOpen(false);
|
||||||
else if (infoOpenRef.current) setInfoOpen(false);
|
else if (infoOpenRef.current) setInfoOpen(false);
|
||||||
|
else if (statsOpenRef.current) setStatsOpen(false);
|
||||||
|
else if (upNextOpenRef.current) setUpNextOpen(false);
|
||||||
else onClose();
|
else onClose();
|
||||||
}, [onClose]);
|
}, [onClose]);
|
||||||
|
|
||||||
@@ -618,6 +665,10 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
|||||||
case "I":
|
case "I":
|
||||||
setInfoOpen((v) => !v);
|
setInfoOpen((v) => !v);
|
||||||
break;
|
break;
|
||||||
|
case "d":
|
||||||
|
case "D":
|
||||||
|
setStatsOpen((v) => !v);
|
||||||
|
break;
|
||||||
case "Backspace":
|
case "Backspace":
|
||||||
// HTPC-style "stop & back to the feed" — mirrors the mouse Back button (shared cascade).
|
// HTPC-style "stop & back to the feed" — mirrors the mouse Back button (shared cascade).
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -639,6 +690,8 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
|||||||
setTracksOpen(false);
|
setTracksOpen(false);
|
||||||
} else if (helpOpenRef.current) setHelpOpen(false);
|
} else if (helpOpenRef.current) setHelpOpen(false);
|
||||||
else if (infoOpenRef.current) setInfoOpen(false);
|
else if (infoOpenRef.current) setInfoOpen(false);
|
||||||
|
else if (statsOpenRef.current) setStatsOpen(false);
|
||||||
|
else if (upNextOpenRef.current) setUpNextOpen(false);
|
||||||
else if (!fullscreenElement()) onClose();
|
else if (!fullscreenElement()) onClose();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -774,7 +827,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
|
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. */}
|
aria-live region so a screen reader distinguishes "buffering" from a frozen frame. */}
|
||||||
{!ready ? (
|
{!ready ? (
|
||||||
<span>{t("plex.player.loading")}</span>
|
<span>
|
||||||
|
{t(sessionIsTranscode ? "plex.player.preparing" : "plex.player.loading")}
|
||||||
|
</span>
|
||||||
) : buffering ? (
|
) : buffering ? (
|
||||||
<span className="sr-only">{t("plex.player.buffering")}</span>
|
<span className="sr-only">{t("plex.player.buffering")}</span>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -785,9 +840,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
|
{/* 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. */}
|
Back button (history popstate) so it closes/cancels that instead of dropping to the grid. */}
|
||||||
{(menuOpen || tracksOpen || helpOpen || infoOpen || skipProgress != null) && (
|
{(menuOpen ||
|
||||||
<BackClose onBack={backIntent} />
|
tracksOpen ||
|
||||||
)}
|
helpOpen ||
|
||||||
|
infoOpen ||
|
||||||
|
statsOpen ||
|
||||||
|
upNextOpen ||
|
||||||
|
skipProgress != null) && <BackClose onBack={backIntent} />}
|
||||||
|
|
||||||
{/* Top bar */}
|
{/* Top bar */}
|
||||||
<div
|
<div
|
||||||
@@ -936,9 +995,19 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3 text-white">
|
<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" />}
|
{playing ? <Pause className="w-6 h-6" /> : <Play className="w-6 h-6" />}
|
||||||
</Ctrl>
|
</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)}>
|
<Ctrl label={t("plex.player.restart")} onClick={() => seekTo(0)}>
|
||||||
<RotateCcw className="w-5 h-5" />
|
<RotateCcw className="w-5 h-5" />
|
||||||
</Ctrl>
|
</Ctrl>
|
||||||
@@ -1114,6 +1183,9 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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)}>
|
<Ctrl label={t("plex.player.infoBtn")} onClick={() => setInfoOpen((v) => !v)}>
|
||||||
<Info className="w-5 h-5" />
|
<Info className="w-5 h-5" />
|
||||||
</Ctrl>
|
</Ctrl>
|
||||||
@@ -1130,6 +1202,32 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</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}
|
||||||
|
tonemapRef={tonemapRef}
|
||||||
|
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). */}
|
{/* Keyboard-shortcut cheat sheet (toggle with "h" or the keyboard button). */}
|
||||||
{helpOpen && (
|
{helpOpen && (
|
||||||
<div
|
<div
|
||||||
@@ -1150,7 +1248,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
|||||||
<button
|
<button
|
||||||
onClick={() => setHelpOpen(false)}
|
onClick={() => setHelpOpen(false)}
|
||||||
className="p-1 rounded-lg hover:bg-white/10"
|
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" />
|
<X className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
@@ -1170,6 +1268,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
|||||||
[["S"], "plex.player.keys.subtitle"],
|
[["S"], "plex.player.keys.subtitle"],
|
||||||
[["F"], "plex.player.keys.fullscreen"],
|
[["F"], "plex.player.keys.fullscreen"],
|
||||||
[["I"], "plex.player.keys.info"],
|
[["I"], "plex.player.keys.info"],
|
||||||
|
[["D"], "plex.player.keys.stats"],
|
||||||
[["⌫", "Esc"], "plex.player.keys.back"],
|
[["⌫", "Esc"], "plex.player.keys.back"],
|
||||||
[["H"], "plex.player.keys.help"],
|
[["H"], "plex.player.keys.help"],
|
||||||
] as [string[], string][]
|
] as [string[], string][]
|
||||||
|
|||||||
@@ -113,6 +113,11 @@ export function PlexPlayerSettings(props: {
|
|||||||
checked={prefs.autoSkipCredits}
|
checked={prefs.autoSkipCredits}
|
||||||
onChange={(v) => patchPrefs({ autoSkipCredits: v })}
|
onChange={(v) => patchPrefs({ autoSkipCredits: v })}
|
||||||
/>
|
/>
|
||||||
|
<Toggle
|
||||||
|
label={t("plex.player.upNext.pref")}
|
||||||
|
checked={prefs.upNext}
|
||||||
|
onChange={(v) => patchPrefs({ upNext: v })}
|
||||||
|
/>
|
||||||
{(prefs.autoSkipIntro || prefs.autoSkipCredits) && (
|
{(prefs.autoSkipIntro || prefs.autoSkipCredits) && (
|
||||||
<Slider
|
<Slider
|
||||||
label={t("plex.player.autoSkipDelay")}
|
label={t("plex.player.autoSkipDelay")}
|
||||||
|
|||||||
@@ -296,6 +296,28 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
|
// Playback class (Direct Play / Direct Stream / Transcode HW/SW) — movie-only (shows have no
|
||||||
|
// class), and dropped when the facet is empty. Labels are localized (the facet ships class keys).
|
||||||
|
...(scope === "movie" && facets && facets.playable.length > 0
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
id: "playable",
|
||||||
|
title: t("plex.filter.playable"),
|
||||||
|
body: (
|
||||||
|
<Combobox
|
||||||
|
label={t("plex.filter.playable")}
|
||||||
|
options={facets.playable.map((p) => ({
|
||||||
|
value: p.value,
|
||||||
|
label: t(`plex.filter.playableOpt.${p.value}`),
|
||||||
|
count: p.count,
|
||||||
|
}))}
|
||||||
|
selected={filters.playable}
|
||||||
|
onChange={(next) => patch({ playable: next })}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
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,
|
||||||
|
tonemapRef,
|
||||||
|
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
|
||||||
|
tonemapRef: RefObject<boolean | null>; // HW HDR→SDR tonemap engaged (adds a "· HDR→SDR" mode tag)
|
||||||
|
sessionStartRef: RefObject<number>;
|
||||||
|
absRef: RefObject<number>;
|
||||||
|
durationRef: RefObject<number>;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
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,
|
||||||
|
tonemap: tonemapRef.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, tonemapRef, 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import Modal from "./Modal";
|
|||||||
import { useConfirm } from "./ConfirmProvider";
|
import { useConfirm } from "./ConfirmProvider";
|
||||||
import { api, type DownloadProfile, type DownloadSpec } from "../lib/api";
|
import { api, type DownloadProfile, type DownloadSpec } from "../lib/api";
|
||||||
import { inputCls } from "./ui/form";
|
import { inputCls } from "./ui/form";
|
||||||
|
import { Button } from "./ui/Button";
|
||||||
|
|
||||||
const HEIGHTS = [null, 2160, 1440, 1080, 720, 480, 360];
|
const HEIGHTS = [null, 2160, 1440, 1080, 720, 480, 360];
|
||||||
|
|
||||||
@@ -145,12 +146,9 @@ export default function ProfileEditor({ onClose }: { onClose: () => void }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!formOpen ? (
|
{!formOpen ? (
|
||||||
<button
|
<Button variant="primary" size="sm" icon={<Plus className="w-4 h-4" />} onClick={startNew}>
|
||||||
onClick={startNew}
|
{t("downloads.profiles.new")}
|
||||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium btn-primary transition"
|
</Button>
|
||||||
>
|
|
||||||
<Plus className="w-4 h-4" /> {t("downloads.profiles.new")}
|
|
||||||
</button>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-xl border border-border p-4 space-y-3">
|
<div className="rounded-xl border border-border p-4 space-y-3">
|
||||||
<div>
|
<div>
|
||||||
@@ -197,6 +195,8 @@ export default function ProfileEditor({ onClose }: { onClose: () => void }) {
|
|||||||
<label className="block text-sm font-medium mb-1">
|
<label className="block text-sm font-medium mb-1">
|
||||||
{t("downloads.profiles.audioFormat")}
|
{t("downloads.profiles.audioFormat")}
|
||||||
</label>
|
</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
|
<select
|
||||||
value={spec.audio_format ?? "m4a"}
|
value={spec.audio_format ?? "m4a"}
|
||||||
onChange={set("audio_format")}
|
onChange={set("audio_format")}
|
||||||
@@ -204,39 +204,12 @@ export default function ProfileEditor({ onClose }: { onClose: () => void }) {
|
|||||||
>
|
>
|
||||||
<option value="m4a">M4A</option>
|
<option value="m4a">M4A</option>
|
||||||
<option value="mp3">MP3</option>
|
<option value="mp3">MP3</option>
|
||||||
<option value="opus">Opus</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{spec.mode !== "a" && (
|
{/* 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
|
||||||
<div>
|
shared link plays everywhere, iOS/Safari included. Enforced in backend `normalize`. */}
|
||||||
<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>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5 pt-1">
|
<div className="space-y-1.5 pt-1">
|
||||||
{spec.mode !== "a" && (
|
{spec.mode !== "a" && (
|
||||||
@@ -263,19 +236,17 @@ export default function ProfileEditor({ onClose }: { onClose: () => void }) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end gap-2 pt-1">
|
<div className="flex justify-end gap-2 pt-1">
|
||||||
<button
|
<Button variant="ghost" size="sm" onClick={() => setFormOpen(false)}>
|
||||||
onClick={() => setFormOpen(false)}
|
|
||||||
className="px-3 py-1.5 rounded-lg text-sm text-muted hover:text-fg hover:bg-surface transition"
|
|
||||||
>
|
|
||||||
{t("downloads.actions.cancel")}
|
{t("downloads.actions.cancel")}
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
onClick={() => save.mutate()}
|
onClick={() => save.mutate()}
|
||||||
disabled={!name.trim() || save.isPending}
|
disabled={!name.trim() || save.isPending}
|
||||||
className="px-3 py-1.5 rounded-lg text-sm font-medium btn-primary disabled:opacity-50 transition"
|
|
||||||
>
|
>
|
||||||
{editing ? t("downloads.profiles.save") : t("downloads.profiles.create")}
|
{editing ? t("downloads.profiles.save") : t("downloads.profiles.create")}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
//
|
||||||
|
// S6 — the per-user download-cookies section is WRITE-ONLY: the stored blob is never fetched or
|
||||||
|
// shown back, so opening "Set"/"Update" always reveals an EMPTY field, and saving posts exactly the
|
||||||
|
// pasted value to the matching host. This drives the real DownloadAuth against a mocked api so we can
|
||||||
|
// assert the field never carries a prior value and the right host is written.
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { render, screen, cleanup, fireEvent, waitFor } from "@testing-library/react";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
const h = vi.hoisted(() => ({
|
||||||
|
downloadCredentials: vi.fn(),
|
||||||
|
setDownloadCredential: vi.fn(),
|
||||||
|
deleteDownloadCredential: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("react-i18next", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
vi.mock("../lib/api", () => ({ api: h }));
|
||||||
|
// Partial mock: SettingsPanel's module graph (PrefsProvider) also pulls getNotifSettings from here.
|
||||||
|
vi.mock("../lib/notifications", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("../lib/notifications")>();
|
||||||
|
return { ...actual, notify: vi.fn() };
|
||||||
|
});
|
||||||
|
vi.mock("./ConfirmProvider", () => ({ useConfirm: () => vi.fn(async () => true) }));
|
||||||
|
|
||||||
|
import { DownloadAuth } from "./SettingsPanel";
|
||||||
|
|
||||||
|
const HOSTS = [
|
||||||
|
{ host_key: "facebook", label: "Facebook", configured: false, updated_at: null },
|
||||||
|
{
|
||||||
|
host_key: "instagram",
|
||||||
|
label: "Instagram",
|
||||||
|
configured: true,
|
||||||
|
updated_at: "2026-08-01T00:00:00Z",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function renderPanel() {
|
||||||
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<DownloadAuth />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("DownloadAuth (write-only cookies)", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
h.downloadCredentials.mockResolvedValue({ hosts: HOSTS });
|
||||||
|
h.setDownloadCredential.mockResolvedValue({ ok: true, hosts: HOSTS });
|
||||||
|
h.deleteDownloadCredential.mockResolvedValue({ ok: true, hosts: HOSTS });
|
||||||
|
});
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
it("lists every supported host with its configured state", async () => {
|
||||||
|
renderPanel();
|
||||||
|
await screen.findByTestId("cookie-host-facebook");
|
||||||
|
expect(screen.getByTestId("cookie-status-facebook").textContent).toContain(
|
||||||
|
"settings.downloadAuth.notSet",
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId("cookie-status-instagram").textContent).toContain(
|
||||||
|
"settings.downloadAuth.configured",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens an EMPTY field on Set and posts the pasted value to that host", async () => {
|
||||||
|
renderPanel();
|
||||||
|
await screen.findByTestId("cookie-host-facebook");
|
||||||
|
// The Set button is the outline action on the facebook row.
|
||||||
|
const setBtn = screen.getByText("settings.downloadAuth.set");
|
||||||
|
fireEvent.click(setBtn);
|
||||||
|
const field = (await screen.findByTestId("cookie-input-facebook")) as HTMLTextAreaElement;
|
||||||
|
expect(field.value).toBe(""); // write-only: never prefilled
|
||||||
|
|
||||||
|
fireEvent.change(field, {
|
||||||
|
target: { value: "# Netscape\n.facebook.com\tTRUE\t/\tT\t0\tc\t1\n" },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByText("settings.downloadAuth.save"));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(h.setDownloadCredential).toHaveBeenCalledWith(
|
||||||
|
"facebook",
|
||||||
|
"# Netscape\n.facebook.com\tTRUE\t/\tT\t0\tc\t1\n",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-opening Update on a configured host still shows an empty field", async () => {
|
||||||
|
renderPanel();
|
||||||
|
await screen.findByTestId("cookie-host-instagram");
|
||||||
|
fireEvent.click(screen.getByText("settings.downloadAuth.update"));
|
||||||
|
const field = (await screen.findByTestId("cookie-input-instagram")) as HTMLTextAreaElement;
|
||||||
|
expect(field.value).toBe(""); // the stored blob is never fetched into the field
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders an honest error state with retry when the fetch fails, not an empty section", async () => {
|
||||||
|
// Honest-states: a failed load must NOT render as a bare empty section (no host rows).
|
||||||
|
h.downloadCredentials.mockRejectedValueOnce(new Error("boom"));
|
||||||
|
h.downloadCredentials.mockResolvedValue({ hosts: HOSTS });
|
||||||
|
renderPanel();
|
||||||
|
// The error message shows and no host rows render.
|
||||||
|
await screen.findByText("settings.downloadAuth.loadError");
|
||||||
|
expect(screen.queryByTestId("cookie-host-facebook")).toBeNull();
|
||||||
|
// Retry refetches → the list appears.
|
||||||
|
fireEvent.click(screen.getByText("common.retry"));
|
||||||
|
await screen.findByTestId("cookie-host-facebook");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -11,8 +11,10 @@ import Avatar from "./Avatar";
|
|||||||
import { notify, type NotifSettings } from "../lib/notifications";
|
import { notify, type NotifSettings } from "../lib/notifications";
|
||||||
import Tooltip from "./Tooltip";
|
import Tooltip from "./Tooltip";
|
||||||
import { inputCls, Section, SettingRow, Switch } from "./ui/form";
|
import { inputCls, Section, SettingRow, Switch } from "./ui/form";
|
||||||
|
import { Button } from "./ui/Button";
|
||||||
import { type SaveState } from "./ui/DraftSaveBar";
|
import { type SaveState } from "./ui/DraftSaveBar";
|
||||||
import { useConfirm } from "./ConfirmProvider";
|
import { useConfirm } from "./ConfirmProvider";
|
||||||
|
import { StateMessage } from "./QueryState";
|
||||||
import { useWizard } from "./WizardProvider";
|
import { useWizard } from "./WizardProvider";
|
||||||
import { usePrefs } from "./PrefsProvider";
|
import { usePrefs } from "./PrefsProvider";
|
||||||
import Tabs from "./Tabs";
|
import Tabs from "./Tabs";
|
||||||
@@ -472,13 +474,9 @@ function SignInMethods({ me }: { me: Me }) {
|
|||||||
className={inputCls}
|
className={inputCls}
|
||||||
/>
|
/>
|
||||||
{err && <p className="text-xs text-danger">{err}</p>}
|
{err && <p className="text-xs text-danger">{err}</p>}
|
||||||
<button
|
<Button type="submit" variant="primary" size="sm" disabled={busy || !next}>
|
||||||
type="submit"
|
|
||||||
disabled={busy || !next}
|
|
||||||
className="px-3 py-1.5 rounded-lg text-sm btn-primary font-medium disabled:opacity-40 transition"
|
|
||||||
>
|
|
||||||
{busy ? t("settings.account.password.saving") : t("settings.account.password.save")}
|
{busy ? t("settings.account.password.saving") : t("settings.account.password.save")}
|
||||||
</button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -586,6 +584,161 @@ function PlexWatchSync() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Per-user download authentication (S6): store your own login cookies for an auth-walled host
|
||||||
|
// (e.g. a private Facebook group you belong to) so downloads from it use YOUR login. Write-only —
|
||||||
|
// the stored blob is never shown back; each row shows only the host + whether it's set + when.
|
||||||
|
// A cookie-authed download is kept private to your account (never cache-shared with another user).
|
||||||
|
// Exported for a focused DOM test (write-only behaviour); rendered inside Account below.
|
||||||
|
export function DownloadAuth() {
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const confirm = useConfirm();
|
||||||
|
const q = useQuery({
|
||||||
|
queryKey: qk.downloadCredentials(),
|
||||||
|
queryFn: () => api.downloadCredentials(),
|
||||||
|
});
|
||||||
|
const [openHost, setOpenHost] = useState<string | null>(null);
|
||||||
|
const [draft, setDraft] = useState("");
|
||||||
|
|
||||||
|
const save = useMutation({
|
||||||
|
mutationFn: ({ host, cookies }: { host: string; cookies: string }) =>
|
||||||
|
api.setDownloadCredential(host, cookies),
|
||||||
|
onSuccess: (res) => {
|
||||||
|
qc.setQueryData(qk.downloadCredentials(), res);
|
||||||
|
setOpenHost(null);
|
||||||
|
setDraft("");
|
||||||
|
notify({ level: "success", message: t("settings.downloadAuth.saved") });
|
||||||
|
},
|
||||||
|
// Validation errors (empty / not-a-cookies.txt) surface via the global error modal, which
|
||||||
|
// localizes the structured { code: "cookies" } detail (see lib/api core localizeDetail).
|
||||||
|
});
|
||||||
|
const remove = useMutation({
|
||||||
|
mutationFn: (host: string) => api.deleteDownloadCredential(host),
|
||||||
|
onSuccess: (res) => {
|
||||||
|
qc.setQueryData(qk.downloadCredentials(), res);
|
||||||
|
notify({ level: "success", message: t("settings.downloadAuth.removed") });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const hosts = q.data?.hosts ?? [];
|
||||||
|
const onRemove = async (host: string) => {
|
||||||
|
const ok = await confirm({
|
||||||
|
title: t("settings.downloadAuth.removeConfirmTitle"),
|
||||||
|
message: t("settings.downloadAuth.removeConfirmBody"),
|
||||||
|
confirmLabel: t("settings.downloadAuth.removeConfirmAction"),
|
||||||
|
danger: true,
|
||||||
|
});
|
||||||
|
if (ok) remove.mutate(host);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Section title={t("settings.downloadAuth.title")}>
|
||||||
|
<p className="text-xs text-muted leading-relaxed mb-2">{t("settings.downloadAuth.intro")}</p>
|
||||||
|
{/* The host list is server-provided (always non-empty on success), so an empty `hosts` means
|
||||||
|
the fetch failed or is still loading — render an honest state, never a bare empty section
|
||||||
|
that reads as "nothing to configure here". */}
|
||||||
|
{q.isError && hosts.length === 0 ? (
|
||||||
|
<StateMessage tone="error" onRetry={() => q.refetch()}>
|
||||||
|
{t("settings.downloadAuth.loadError")}
|
||||||
|
</StateMessage>
|
||||||
|
) : q.isLoading ? (
|
||||||
|
<StateMessage>{t("common.loading")}</StateMessage>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-border">
|
||||||
|
{hosts.map((h) => {
|
||||||
|
const when = h.updated_at
|
||||||
|
? new Date(h.updated_at).toLocaleDateString(i18n.language)
|
||||||
|
: "";
|
||||||
|
const open = openHost === h.host_key;
|
||||||
|
return (
|
||||||
|
<div key={h.host_key} className="py-2" data-testid={`cookie-host-${h.host_key}`}>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-sm font-medium">{h.label}</div>
|
||||||
|
<p
|
||||||
|
className="text-xs text-muted mt-0.5"
|
||||||
|
data-testid={`cookie-status-${h.host_key}`}
|
||||||
|
>
|
||||||
|
{h.configured
|
||||||
|
? t("settings.downloadAuth.configured", { when })
|
||||||
|
: t("settings.downloadAuth.notSet")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
{h.configured && (
|
||||||
|
<Button
|
||||||
|
variant="danger-outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onRemove(h.host_key)}
|
||||||
|
disabled={remove.isPending}
|
||||||
|
>
|
||||||
|
{t("settings.downloadAuth.remove")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setOpenHost(open ? null : h.host_key);
|
||||||
|
setDraft("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{h.configured
|
||||||
|
? t("settings.downloadAuth.update")
|
||||||
|
: t("settings.downloadAuth.set")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{open && (
|
||||||
|
<div className="mt-2 space-y-2">
|
||||||
|
{/* Write-only: the field is ALWAYS empty on open — the stored blob is never
|
||||||
|
fetched or prefilled. */}
|
||||||
|
<textarea
|
||||||
|
value={draft}
|
||||||
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
|
rows={5}
|
||||||
|
placeholder={t("settings.downloadAuth.placeholder")}
|
||||||
|
className={`${inputCls} font-mono text-xs resize-y`}
|
||||||
|
data-testid={`cookie-input-${h.host_key}`}
|
||||||
|
spellCheck={false}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
<p className="text-2xs text-muted leading-relaxed">
|
||||||
|
{t("settings.downloadAuth.help")}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
disabled={!draft.trim() || save.isPending}
|
||||||
|
onClick={() => save.mutate({ host: h.host_key, cookies: draft })}
|
||||||
|
>
|
||||||
|
{save.isPending
|
||||||
|
? t("settings.downloadAuth.saving")
|
||||||
|
: t("settings.downloadAuth.save")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setOpenHost(null);
|
||||||
|
setDraft("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("settings.downloadAuth.cancel")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) {
|
function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const confirm = useConfirm();
|
const confirm = useConfirm();
|
||||||
@@ -628,6 +781,8 @@ function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) {
|
|||||||
|
|
||||||
{me.role === "admin" && me.plex_enabled && <PlexWatchSync />}
|
{me.role === "admin" && me.plex_enabled && <PlexWatchSync />}
|
||||||
|
|
||||||
|
{!me.is_demo && <DownloadAuth />}
|
||||||
|
|
||||||
{me.is_demo ? (
|
{me.is_demo ? (
|
||||||
<Section title={t("settings.account.youtubeAccess")}>
|
<Section title={t("settings.account.youtubeAccess")}>
|
||||||
<p className="text-xs text-muted leading-relaxed">{t("settings.account.demoNotice")}</p>
|
<p className="text-xs text-muted leading-relaxed">{t("settings.account.demoNotice")}</p>
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ import { useConfirm } from "./ConfirmProvider";
|
|||||||
import { notify } from "../lib/notifications";
|
import { notify } from "../lib/notifications";
|
||||||
import { copyText } from "../lib/clipboard";
|
import { copyText } from "../lib/clipboard";
|
||||||
import { api, type DownloadJob, type ShareLink } from "../lib/api";
|
import { api, type DownloadJob, type ShareLink } from "../lib/api";
|
||||||
import { inputCls, btnCls } from "./ui/form";
|
import { inputCls } from "./ui/form";
|
||||||
|
import { Button } from "./ui/Button";
|
||||||
const EXPIRY_DAYS = [null, 1, 7, 30] as const;
|
const EXPIRY_DAYS = [null, 1, 7, 30] as const;
|
||||||
|
|
||||||
// --- share with a registered user (ACL) -----------------------------------------------------
|
// --- share with a registered user (ACL) -----------------------------------------------------
|
||||||
@@ -253,7 +254,9 @@ function LinkShare({ job }: { job: DownloadJob }) {
|
|||||||
queryFn: () => api.downloadLinks(job.id),
|
queryFn: () => api.downloadLinks(job.id),
|
||||||
});
|
});
|
||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
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 [expiryDays, setExpiryDays] = useState<number | null>(null);
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const invalidate = () => qc.invalidateQueries({ queryKey: qk.downloadLinks(job.id) });
|
const invalidate = () => qc.invalidateQueries({ queryKey: qk.downloadLinks(job.id) });
|
||||||
@@ -275,8 +278,8 @@ function LinkShare({ job }: { job: DownloadJob }) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Open with a link already generated: sharing is almost always the goal, so mint a default
|
// Open with a link already generated: sharing is almost always the goal, so mint a default
|
||||||
// (no password, no expiry, view-only) link the moment the dialog finds none — as if the user
|
// (no password, no expiry, downloadable per D1) link the moment the dialog finds none — as if the
|
||||||
// had pressed "New link" → "Create". All further controls (password, download toggle, expiry,
|
// user had pressed "New link" → "Create". All further controls (password, download toggle, expiry,
|
||||||
// extra links, revoke) stay available on the created link and via the "New link" button.
|
// extra links, revoke) stay available on the created link and via the "New link" button.
|
||||||
const autoTried = useRef(false);
|
const autoTried = useRef(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -343,31 +346,29 @@ function LinkShare({ job }: { job: DownloadJob }) {
|
|||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
/>
|
/>
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
<button
|
<Button variant="ghost" size="sm" onClick={() => setCreating(false)}>
|
||||||
onClick={() => setCreating(false)}
|
|
||||||
className={clsx(btnCls, "text-muted hover:text-fg hover:bg-surface")}
|
|
||||||
>
|
|
||||||
{t("common.cancel")}
|
{t("common.cancel")}
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
onClick={() => create.mutate()}
|
onClick={() => create.mutate()}
|
||||||
disabled={create.isPending}
|
disabled={create.isPending}
|
||||||
className={clsx(btnCls, "btn-primary")}
|
|
||||||
>
|
>
|
||||||
{t("downloads.share.createLink")}
|
{t("downloads.share.createLink")}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
onClick={() => setCreating(true)}
|
onClick={() => setCreating(true)}
|
||||||
className={clsx(
|
icon={<Link2 className="w-4 h-4" />}
|
||||||
btnCls,
|
className="mt-2"
|
||||||
"mt-2 border border-border text-muted hover:text-fg hover:border-muted inline-flex items-center gap-1.5",
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<Link2 className="w-4 h-4" /> {t("downloads.share.newLink")}
|
{t("downloads.share.newLink")}
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ export default function SortControl({
|
|||||||
const summary = activeOpt?.label ?? defaultLabel ?? options[0]?.label ?? "";
|
const summary = activeOpt?.label ?? defaultLabel ?? options[0]?.label ?? "";
|
||||||
|
|
||||||
const segCls = (on: boolean) =>
|
const segCls = (on: boolean) =>
|
||||||
`flex flex-1 items-center justify-center gap-1 rounded-md border px-2 py-1 text-2xs transition ${
|
`flex flex-1 items-center justify-center gap-1 whitespace-nowrap rounded-md border px-2 py-1 text-2xs transition ${
|
||||||
on
|
on
|
||||||
? "border-accent bg-accent/15 text-accent"
|
? "border-accent bg-accent/15 text-accent"
|
||||||
: "border-border text-muted hover:border-accent/50 hover:text-fg"
|
: "border-border text-muted hover:border-accent/50 hover:text-fg"
|
||||||
@@ -92,7 +92,7 @@ export default function SortControl({
|
|||||||
<PopoverSurface
|
<PopoverSurface
|
||||||
popover={pop}
|
popover={pop}
|
||||||
ariaLabel={label}
|
ariaLabel={label}
|
||||||
className="glass-menu min-w-52 rounded-xl p-1.5 shadow-lg animate-[popIn_0.16s_ease]"
|
className="glass-menu min-w-56 rounded-xl p-1.5 shadow-lg animate-[popIn_0.16s_ease]"
|
||||||
>
|
>
|
||||||
{/* One exclusive choice of sort key (radios), scoped by a group so AT announces which is
|
{/* One exclusive choice of sort key (radios), scoped by a group so AT announces which is
|
||||||
checked — the surface is a menu (see the mode above), never a listbox with orphaned options. */}
|
checked — the surface is a menu (see the mode above), never a listbox with orphaned options. */}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,22 +2,14 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|||||||
import { qk } from "../lib/queryKeys";
|
import { qk } from "../lib/queryKeys";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import { Crop, Eye, EyeOff, Pause, Play, Scissors, SplitSquareHorizontal, X } from "lucide-react";
|
||||||
Crop,
|
|
||||||
Eye,
|
|
||||||
EyeOff,
|
|
||||||
Pause,
|
|
||||||
Play,
|
|
||||||
Scissors,
|
|
||||||
SplitSquareHorizontal,
|
|
||||||
Loader2,
|
|
||||||
X,
|
|
||||||
} from "lucide-react";
|
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import Modal from "./Modal";
|
import Modal from "./Modal";
|
||||||
import { notify } from "../lib/notifications";
|
import { notify } from "../lib/notifications";
|
||||||
import { api, type DownloadJob, type EditSpec } from "../lib/api";
|
import { api, type DownloadJob, type EditSpec } from "../lib/api";
|
||||||
|
import { contentRect, cropToSourcePx, type Frac } from "../lib/cropGeometry";
|
||||||
import { inputCls, btnCls } from "./ui/form";
|
import { inputCls, btnCls } from "./ui/form";
|
||||||
|
import { Button } from "./ui/Button";
|
||||||
|
|
||||||
// mm:ss.d timecode <-> seconds.
|
// mm:ss.d timecode <-> seconds.
|
||||||
function tc(s: number): string {
|
function tc(s: number): string {
|
||||||
@@ -40,7 +32,6 @@ function parseTc(v: string): number | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Seg = { id: number; start: number; end: number; keep: boolean };
|
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 };
|
type Drag = { type: "seek" } | { type: "boundary"; bi: number };
|
||||||
|
|
||||||
const numCls =
|
const numCls =
|
||||||
@@ -60,6 +51,11 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
|||||||
const [current, setCurrent] = useState(0);
|
const [current, setCurrent] = useState(0);
|
||||||
const [playing, setPlaying] = useState(false);
|
const [playing, setPlaying] = useState(false);
|
||||||
const [trackW, setTrackW] = useState(0);
|
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.
|
// 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 }]);
|
const [segments, setSegments] = useState<Seg[]>([{ id: 0, start: 0, end: srcDur, keep: true }]);
|
||||||
@@ -92,11 +88,28 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
|||||||
return () => ro.disconnect();
|
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 onLoaded = () => {
|
||||||
const v = videoRef.current;
|
const v = videoRef.current;
|
||||||
if (!v) return;
|
if (!v) return;
|
||||||
const d = v.duration && isFinite(v.duration) ? v.duration : srcDur;
|
const d = v.duration && isFinite(v.duration) ? v.duration : srcDur;
|
||||||
setDuration(d);
|
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
|
// 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
|
// 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
|
// the true length (e.g. 213.44 vs 213). Without this the untouched last segment stays short, so
|
||||||
@@ -267,16 +280,12 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
|||||||
|
|
||||||
const cropPx = (): { x: number; y: number; w: number; h: number } | undefined => {
|
const cropPx = (): { x: number; y: number; w: number; h: number } | undefined => {
|
||||||
const v = videoRef.current;
|
const v = videoRef.current;
|
||||||
const vw = v?.videoWidth || job.asset?.width || 0;
|
const vw = v?.videoWidth || vdim.w;
|
||||||
const vh = v?.videoHeight || job.asset?.height || 0;
|
const vh = v?.videoHeight || vdim.h;
|
||||||
if (!cropOn || !vw || !vh) return undefined;
|
if (!cropOn) return undefined;
|
||||||
const even = (n: number) => Math.max(2, Math.round(n / 2) * 2);
|
// crop fractions are relative to the picture (the overlay is anchored to it), so this is a
|
||||||
return {
|
// straight fraction→source-px scale; see cropGeometry for the even-rounding + in-bounds clamp.
|
||||||
x: even(crop.x * vw),
|
return cropToSourcePx(crop, vw, vh) ?? undefined;
|
||||||
y: even(crop.y * vh),
|
|
||||||
w: even(crop.w * vw),
|
|
||||||
h: even(crop.h * vh),
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const create = useMutation({
|
const create = useMutation({
|
||||||
@@ -331,11 +340,20 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
|||||||
onTimeUpdate={onTimeUpdate}
|
onTimeUpdate={onTimeUpdate}
|
||||||
onPlay={() => setPlaying(true)}
|
onPlay={() => setPlaying(true)}
|
||||||
onPause={() => setPlaying(false)}
|
onPause={() => setPlaying(false)}
|
||||||
className="w-full max-h-[42vh] mx-auto block"
|
className="w-full max-h-[42vh] mx-auto block object-contain"
|
||||||
playsInline
|
playsInline
|
||||||
/>
|
/>
|
||||||
{cropOn && (
|
{cropOn && picRect && (
|
||||||
<div ref={overlayRef} className="absolute inset-0">
|
<div
|
||||||
|
ref={overlayRef}
|
||||||
|
className="absolute"
|
||||||
|
style={{
|
||||||
|
left: picRect.left,
|
||||||
|
top: picRect.top,
|
||||||
|
width: picRect.width,
|
||||||
|
height: picRect.height,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
className="absolute border-2 border-accent cursor-move"
|
className="absolute border-2 border-accent cursor-move"
|
||||||
style={{
|
style={{
|
||||||
@@ -621,28 +639,23 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
|||||||
{reencode ? t("editor.willReencode") : t("editor.willCopy")}
|
{reencode ? t("editor.willReencode") : t("editor.willCopy")}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<Button variant="ghost" size="sm" onClick={onClose}>
|
||||||
onClick={onClose}
|
|
||||||
className={clsx(btnCls, "text-muted hover:text-fg hover:bg-surface")}
|
|
||||||
>
|
|
||||||
{t("common.cancel")}
|
{t("common.cancel")}
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
onClick={() => create.mutate()}
|
onClick={() => create.mutate()}
|
||||||
disabled={!valid || create.isPending}
|
disabled={!valid}
|
||||||
className={clsx(btnCls, "btn-primary inline-flex items-center gap-1.5")}
|
loading={create.isPending}
|
||||||
|
icon={<Scissors className="w-4 h-4" />}
|
||||||
>
|
>
|
||||||
{create.isPending ? (
|
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Scissors className="w-4 h-4" />
|
|
||||||
)}
|
|
||||||
{willJoin
|
{willJoin
|
||||||
? t("editor.createJoin")
|
? t("editor.createJoin")
|
||||||
: kept.length > 1
|
: kept.length > 1
|
||||||
? t("editor.createN", { count: kept.length })
|
? t("editor.createN", { count: kept.length })
|
||||||
: t("editor.create")}
|
: t("editor.create")}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
//
|
||||||
|
// S3 item 5a: the public watch page must render a native <audio> element for an audio-only asset
|
||||||
|
// (mp3) and a <video> for everything else. WebKit (all iOS browsers) refuses to play an audio-only
|
||||||
|
// mp3 inside a <video>, which is exactly why the branch exists — this locks it so a refactor can't
|
||||||
|
// silently drop the audio player and reintroduce the iPad-broken behavior.
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { render, waitFor, cleanup } from "@testing-library/react";
|
||||||
|
import WatchPage from "./WatchPage";
|
||||||
|
|
||||||
|
function mockMeta(meta: Record<string, unknown>) {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(async () => ({ ok: true, json: async () => meta })),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("WatchPage media element (S3 · 5a)", () => {
|
||||||
|
it("renders a native <audio> for an audio-only asset", async () => {
|
||||||
|
window.history.replaceState({}, "", "/watch/tok-audio");
|
||||||
|
mockMeta({ needs_password: false, audio: true, file_url: "/f.mp3", title: "A song" });
|
||||||
|
const { container } = render(<WatchPage />);
|
||||||
|
await waitFor(() => expect(container.querySelector("audio")).not.toBeNull());
|
||||||
|
expect(container.querySelector("video")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a <video> for a normal video asset", async () => {
|
||||||
|
window.history.replaceState({}, "", "/watch/tok-video");
|
||||||
|
mockMeta({ needs_password: false, audio: false, file_url: "/f.mp4", title: "A clip" });
|
||||||
|
const { container } = render(<WatchPage />);
|
||||||
|
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,5 +1,7 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
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
|
// 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
|
// tree (no auth, no providers) — like the /privacy and /terms pages. Uses plain fetch and a tiny
|
||||||
@@ -11,10 +13,11 @@ import { Download, Link2, Lock, PlayCircle } from "lucide-react";
|
|||||||
// playing in the background on Brave iOS. We deliberately do NOT route the audio through the Web
|
// playing in the background on Brave iOS. We deliberately do NOT route the audio through the Web
|
||||||
// Audio API: `createMediaElementSource` pulls the element out of the normal media pipeline that the
|
// Audio API: `createMediaElementSource` pulls the element out of the normal media pipeline that the
|
||||||
// background feature (and iOS) hooks into, and iOS then suspends the AudioContext on background —
|
// background feature (and iOS) hooks into, and iOS then suspends the AudioContext on background —
|
||||||
// which STOPPED playback instead of keeping it alive (measured on iPad Brave). A plain <video> +
|
// which STOPPED playback instead of keeping it alive (measured on iPad Brave). A plain <video>/<audio>
|
||||||
// MediaSession leaves the browser's native background handling in charge.
|
// + MediaSession leaves the browser's native background handling in charge. Typed on HTMLMediaElement
|
||||||
|
// so it drives either the video or the audio-only player.
|
||||||
function useMediaSession(
|
function useMediaSession(
|
||||||
videoRef: React.RefObject<HTMLVideoElement | null>,
|
videoRef: React.RefObject<HTMLMediaElement | null>,
|
||||||
meta: Meta | null,
|
meta: Meta | null,
|
||||||
ready: boolean,
|
ready: boolean,
|
||||||
) {
|
) {
|
||||||
@@ -45,6 +48,10 @@ type Meta = {
|
|||||||
source_url?: string | null;
|
source_url?: string | null;
|
||||||
extra_links?: string[];
|
extra_links?: string[];
|
||||||
duration_s?: number | null;
|
duration_s?: number | null;
|
||||||
|
audio?: boolean;
|
||||||
|
media_kind?: "video" | "audio" | "file" | null;
|
||||||
|
size_bytes?: number | null;
|
||||||
|
container?: string | null;
|
||||||
allow_download?: boolean;
|
allow_download?: boolean;
|
||||||
file_url?: string;
|
file_url?: string;
|
||||||
poster_url?: string | null;
|
poster_url?: string | null;
|
||||||
@@ -70,6 +77,7 @@ const STR = {
|
|||||||
watch: "Watch",
|
watch: "Watch",
|
||||||
wrong: "Wrong password.",
|
wrong: "Wrong password.",
|
||||||
download: "Download",
|
download: "Download",
|
||||||
|
file: "File",
|
||||||
links: "Links",
|
links: "Links",
|
||||||
brand: "Shared via Siftlode",
|
brand: "Shared via Siftlode",
|
||||||
},
|
},
|
||||||
@@ -82,6 +90,7 @@ const STR = {
|
|||||||
watch: "Megnézem",
|
watch: "Megnézem",
|
||||||
wrong: "Hibás jelszó.",
|
wrong: "Hibás jelszó.",
|
||||||
download: "Letöltés",
|
download: "Letöltés",
|
||||||
|
file: "Fájl",
|
||||||
links: "Linkek",
|
links: "Linkek",
|
||||||
brand: "Megosztva a Siftlode-dal",
|
brand: "Megosztva a Siftlode-dal",
|
||||||
},
|
},
|
||||||
@@ -94,6 +103,7 @@ const STR = {
|
|||||||
watch: "Ansehen",
|
watch: "Ansehen",
|
||||||
wrong: "Falsches Passwort.",
|
wrong: "Falsches Passwort.",
|
||||||
download: "Herunterladen",
|
download: "Herunterladen",
|
||||||
|
file: "Datei",
|
||||||
links: "Links",
|
links: "Links",
|
||||||
brand: "Geteilt über Siftlode",
|
brand: "Geteilt über Siftlode",
|
||||||
},
|
},
|
||||||
@@ -112,8 +122,13 @@ export default function WatchPage() {
|
|||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [unlocking, setUnlocking] = useState(false);
|
const [unlocking, setUnlocking] = useState(false);
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
// One ref for whichever element renders (video or audio-only) — a callback ref so it can bind to
|
||||||
useMediaSession(videoRef, meta, status === "ready");
|
// both without a per-element typed ref.
|
||||||
|
const mediaRef = useRef<HTMLMediaElement | null>(null);
|
||||||
|
const setMedia = (el: HTMLMediaElement | null) => {
|
||||||
|
mediaRef.current = el;
|
||||||
|
};
|
||||||
|
useMediaSession(mediaRef, meta, status === "ready");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!token) {
|
if (!token) {
|
||||||
@@ -193,31 +208,73 @@ export default function WatchPage() {
|
|||||||
className="w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm outline-hidden focus:ring-2 focus:ring-accent/40"
|
className="w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm outline-hidden focus:ring-2 focus:ring-accent/40"
|
||||||
/>
|
/>
|
||||||
{error && <div className="text-sm text-danger mt-2">{error}</div>}
|
{error && <div className="text-sm text-danger mt-2">{error}</div>}
|
||||||
<button
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="md"
|
||||||
|
fullWidth
|
||||||
onClick={unlock}
|
onClick={unlock}
|
||||||
disabled={!password || unlocking}
|
disabled={!password || unlocking}
|
||||||
className="mt-4 w-full rounded-lg btn-primary disabled:opacity-50 py-2 text-sm font-medium transition"
|
className="mt-4"
|
||||||
>
|
>
|
||||||
{T.watch}
|
{T.watch}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{status === "ready" && meta && (
|
{status === "ready" && meta && meta.media_kind === "file" && (
|
||||||
<div>
|
/* A generic uploaded file has no player — just an info card (name/type/size) and, if the
|
||||||
{/* Shrink-wrap the player to the video so a vertical/short clip renders as a narrow
|
link allows it, a Download. The backend serves the file force-download regardless, so
|
||||||
player instead of a wide box with black bars; a landscape clip fills the width. */}
|
gating the button on allow_download only mirrors the owner's ACL, matching the media
|
||||||
<div className="rounded-xl overflow-hidden bg-black border border-border w-fit max-w-full mx-auto">
|
branch's download affordance below. */
|
||||||
<video
|
<div className="flex-1 grid place-items-center">
|
||||||
ref={videoRef}
|
<div className="w-full max-w-md rounded-2xl glass p-6 text-center">
|
||||||
controls
|
<div className="mx-auto w-16 h-16 rounded-xl bg-surface grid place-items-center mb-4">
|
||||||
playsInline
|
<FileIcon className="w-8 h-8 text-accent" />
|
||||||
src={meta.file_url}
|
</div>
|
||||||
poster={meta.poster_url || undefined}
|
<h1 className="text-lg font-semibold leading-snug break-words">
|
||||||
className="max-h-[80vh] max-w-full mx-auto block"
|
{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>
|
||||||
|
</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
|
||||||
|
<audio>. No poster exists for these, so present it as a deliberate padded bar on
|
||||||
|
the surface (not the black video box) so it looks intentional without artwork. */
|
||||||
|
<div className="rounded-xl border border-border bg-surface p-4">
|
||||||
|
<audio ref={setMedia} controls src={meta.file_url} className="w-full block" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* Shrink-wrap the player to the video so a vertical/short clip renders as a narrow
|
||||||
|
player instead of a wide box with black bars; a landscape clip fills the width. */
|
||||||
|
<div className="rounded-xl overflow-hidden bg-black border border-border w-fit max-w-full mx-auto">
|
||||||
|
<video
|
||||||
|
ref={setMedia}
|
||||||
|
controls
|
||||||
|
playsInline
|
||||||
|
src={meta.file_url}
|
||||||
|
poster={meta.poster_url || undefined}
|
||||||
|
className="max-h-[80vh] max-w-full mx-auto block"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="mt-3 flex items-start gap-3">
|
<div className="mt-3 flex items-start gap-3">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<h1 className="text-lg font-semibold leading-snug">{meta.title || "Video"}</h1>
|
<h1 className="text-lg font-semibold leading-snug">{meta.title || "Video"}</h1>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { useLiveQuery } from "../lib/useLiveQuery";
|
|||||||
import { api } from "../lib/api";
|
import { api } from "../lib/api";
|
||||||
import { formatBytes } from "../lib/format";
|
import { formatBytes } from "../lib/format";
|
||||||
import { inputCls } from "./ui/form";
|
import { inputCls } from "./ui/form";
|
||||||
|
import { Button } from "./ui/Button";
|
||||||
import { StatusPill, jobTitle } from "./downloadRow";
|
import { StatusPill, jobTitle } from "./downloadRow";
|
||||||
|
|
||||||
// The admin "System" tab — storage overview, per-user footprint, quota editor, and the global job
|
// The admin "System" tab — storage overview, per-user footprint, quota editor, and the global job
|
||||||
@@ -110,19 +111,17 @@ function QuotaModal({
|
|||||||
{t("downloads.admin.unlimited")}
|
{t("downloads.admin.unlimited")}
|
||||||
</label>
|
</label>
|
||||||
<div className="flex justify-between pt-1">
|
<div className="flex justify-between pt-1">
|
||||||
<button
|
<Button variant="ghost" size="sm" onClick={() => reset.mutate()}>
|
||||||
onClick={() => reset.mutate()}
|
|
||||||
className="px-3 py-1.5 rounded-lg text-sm text-muted hover:text-fg hover:bg-surface transition"
|
|
||||||
>
|
|
||||||
{t("downloads.admin.reset")}
|
{t("downloads.admin.reset")}
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
onClick={() => save.mutate()}
|
onClick={() => save.mutate()}
|
||||||
disabled={save.isPending}
|
disabled={save.isPending}
|
||||||
className="px-3 py-1.5 rounded-lg text-sm font-medium btn-primary disabled:opacity-50 transition"
|
|
||||||
>
|
>
|
||||||
{t("downloads.admin.save")}
|
{t("downloads.admin.save")}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Plus, Trash2 } from "lucide-react";
|
|||||||
import Modal from "./Modal";
|
import Modal from "./Modal";
|
||||||
import { api, type DownloadJob } from "../lib/api";
|
import { api, type DownloadJob } from "../lib/api";
|
||||||
import { inputCls } from "./ui/form";
|
import { inputCls } from "./ui/form";
|
||||||
|
import { Button } from "./ui/Button";
|
||||||
import { IconBtn } from "./downloadRow";
|
import { IconBtn } from "./downloadRow";
|
||||||
|
|
||||||
// Per-item user modals split out of DownloadCenter (self-contained, prop-driven): rename/metadata
|
// Per-item user modals split out of DownloadCenter (self-contained, prop-driven): rename/metadata
|
||||||
@@ -101,22 +102,26 @@ export function MetaEditModal({ job, onClose }: { job: DownloadJob; onClose: ()
|
|||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted">{t("downloads.edit.hint")}</p>
|
<p className="text-xs text-muted">{t("downloads.edit.hint")}</p>
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<button
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
onClick={() => save.mutate()}
|
onClick={() => save.mutate()}
|
||||||
disabled={!name.trim() || save.isPending}
|
disabled={!name.trim() || save.isPending}
|
||||||
className="px-3 py-1.5 rounded-lg text-sm font-medium btn-primary disabled:opacity-50 transition"
|
|
||||||
>
|
>
|
||||||
{t("downloads.edit.save")}
|
{t("downloads.edit.save")}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Transient failures worth an in-modal Retry button; the rest are permanent (private/geo/DRM/…),
|
// Transient failures worth an in-modal Retry button; the rest are permanent (private/geo/DRM/
|
||||||
// where a retry can't help — the queue row still keeps its own retry control regardless.
|
// sign-in/unsupported-host/…), where a retry can't help — the queue row still keeps its own retry
|
||||||
const RETRYABLE_ERRORS = ["postprocess", "network", "unknown"];
|
// control regardless. `unknown` is deliberately NOT here: an unclassified failure is most often an
|
||||||
|
// auth wall we couldn't parse (a login-walled Facebook post reads as a bare "no format"), so a
|
||||||
|
// prominent Retry would mislead; force-retrying from the row stays available for genuine transients.
|
||||||
|
const RETRYABLE_ERRORS = ["postprocess", "network"];
|
||||||
|
|
||||||
// A readable "why did this fail" window for a failed job: a category headline + plain explanation,
|
// A readable "why did this fail" window for a failed job: a category headline + plain explanation,
|
||||||
// the raw yt-dlp/ffmpeg text behind a disclosure, and a Retry when the failure may be transient.
|
// the raw yt-dlp/ffmpeg text behind a disclosure, and a Retry when the failure may be transient.
|
||||||
@@ -155,22 +160,20 @@ export function ErrorModal({
|
|||||||
)}
|
)}
|
||||||
<div className="flex justify-end gap-2 pt-1">
|
<div className="flex justify-end gap-2 pt-1">
|
||||||
{retryable && (
|
{retryable && (
|
||||||
<button
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onRetry();
|
onRetry();
|
||||||
onClose();
|
onClose();
|
||||||
}}
|
}}
|
||||||
className="px-3 py-1.5 rounded-lg text-sm font-medium btn-primary transition"
|
|
||||||
>
|
>
|
||||||
{t("downloads.actions.retry")}
|
{t("downloads.actions.retry")}
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<button
|
<Button variant="ghost" size="sm" onClick={onClose}>
|
||||||
onClick={onClose}
|
|
||||||
className="px-3 py-1.5 rounded-lg text-sm text-muted hover:text-fg hover:bg-surface transition"
|
|
||||||
>
|
|
||||||
{t("common.close")}
|
{t("common.close")}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -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 { useEffect, useState, type ReactNode } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
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 clsx from "clsx";
|
||||||
import { qk } from "../lib/queryKeys";
|
import { qk } from "../lib/queryKeys";
|
||||||
import { useLiveQuery } from "../lib/useLiveQuery";
|
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.
|
// confused by the bar resetting between the separate video and audio streams.
|
||||||
if (job.status === "running" && job.phase && PHASE_KEYS.includes(job.phase)) {
|
if (job.status === "running" && job.phase && PHASE_KEYS.includes(job.phase)) {
|
||||||
return (
|
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;
|
const key = job.expired ? "expired" : job.status;
|
||||||
return (
|
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}`)}
|
{t(`downloads.status.${key}`)}
|
||||||
</span>
|
</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.
|
// On load failure (a genuinely missing poster file) fall back to the remote source thumbnail.
|
||||||
const [src, setSrc] = useState(primary);
|
const [src, setSrc] = useState(primary);
|
||||||
useEffect(() => setSrc(primary), [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 (
|
return (
|
||||||
<div className="w-28 aspect-video shrink-0 rounded-lg overflow-hidden bg-surface border border-border">
|
<div className="w-28 aspect-video shrink-0 rounded-lg overflow-hidden bg-surface border border-border">
|
||||||
{src ? (
|
{src ? (
|
||||||
@@ -145,8 +154,8 @@ function SourceRef({ url }: { url: string }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1 mt-1 text-xs text-muted min-w-0">
|
<div className="flex items-center gap-1 mt-1 text-sm text-muted min-w-0">
|
||||||
<Link2 className="w-3 h-3 shrink-0" />
|
<Link2 className="w-3.5 h-3.5 shrink-0" />
|
||||||
<a
|
<a
|
||||||
href={url}
|
href={url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@@ -162,7 +171,7 @@ function SourceRef({ url }: { url: string }) {
|
|||||||
title={t("downloads.source.copy")}
|
title={t("downloads.source.copy")}
|
||||||
className="shrink-0 p-0.5 rounded-sm hover:bg-surface hover:text-fg transition"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -173,7 +182,7 @@ function ChannelLine({ job }: { job: DownloadJob }) {
|
|||||||
const { name, url } = jobChannel(job);
|
const { name, url } = jobChannel(job);
|
||||||
if (url) {
|
if (url) {
|
||||||
return (
|
return (
|
||||||
<div className="text-xs mt-0.5 truncate">
|
<div className="text-sm mt-0.5 truncate">
|
||||||
<a
|
<a
|
||||||
href={url}
|
href={url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@@ -186,7 +195,7 @@ function ChannelLine({ job }: { job: DownloadJob }) {
|
|||||||
</div>
|
</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({
|
export function DownloadRow({
|
||||||
@@ -206,15 +215,20 @@ export function DownloadRow({
|
|||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="font-medium leading-snug line-clamp-1 flex items-center gap-1.5">
|
<div className="font-medium leading-snug line-clamp-1 flex items-center gap-1.5">
|
||||||
{job.job_kind === "edit" && (
|
{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")}
|
<Scissors className="w-3 h-3" /> {t("downloads.clipBadge")}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span className="truncate">{jobTitle(job)}</span>
|
<span className="truncate">{jobTitle(job)}</span>
|
||||||
</div>
|
</div>
|
||||||
<ChannelLine job={job} />
|
<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} />
|
<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 ? (
|
{job.asset?.size_bytes ? (
|
||||||
<span className="text-muted">· {formatBytes(job.asset.size_bytes)}</span>
|
<span className="text-muted">· {formatBytes(job.asset.size_bytes)}</span>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -226,7 +240,7 @@ export function DownloadRow({
|
|||||||
className="text-muted inline-flex items-center gap-1"
|
className="text-muted inline-flex items-center gap-1"
|
||||||
title={new Date(job.created_at).toLocaleString()}
|
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>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
{job.status === "error" ? (
|
{job.status === "error" ? (
|
||||||
@@ -259,7 +273,7 @@ export function DownloadRow({
|
|||||||
style={{ width: `${job.progress}%` }}
|
style={{ width: `${job.progress}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
<span>{job.progress}%</span>
|
||||||
{job.speed_bps ? <span>{formatSpeed(job.speed_bps)}</span> : null}
|
{job.speed_bps ? <span>{formatSpeed(job.speed_bps)}</span> : null}
|
||||||
{job.eta_s ? <span>{formatEta(job.eta_s)}</span> : null}
|
{job.eta_s ? <span>{formatEta(job.eta_s)}</span> : null}
|
||||||
@@ -269,7 +283,12 @@ export function DownloadRow({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -278,8 +297,12 @@ export function UsageBar() {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
// Poll live (like the library list) so the footprint updates the moment the worker finishes an
|
// Poll live (like the library list) so the footprint updates the moment the worker finishes an
|
||||||
// edit/download — enqueue only creates a queued job (0 bytes), the size lands asynchronously, so
|
// edit/download — enqueue only creates a queued job (0 bytes), the size lands asynchronously, so
|
||||||
// a one-shot fetch at enqueue time would show a stale 0 B until a manual reload.
|
// a one-shot fetch at enqueue time would show a stale 0 B until a manual reload. The footprint
|
||||||
const usage = useLiveQuery(qk.downloadUsage(), api.downloadUsage, { intervalMs: 2000 });
|
// only moves while a job is in flight, so poll fast (2s) then and idle-back-off to 15s otherwise
|
||||||
|
// (running_jobs counts queued+running — it flips back to 2s on the first poll after a new enqueue).
|
||||||
|
const usage = useLiveQuery(qk.downloadUsage(), api.downloadUsage, {
|
||||||
|
intervalMs: (u) => (u?.running_jobs ? 2000 : 15000),
|
||||||
|
});
|
||||||
const u = usage.data;
|
const u = usage.data;
|
||||||
if (!u) return null;
|
if (!u) return null;
|
||||||
const pct =
|
const pct =
|
||||||
|
|||||||
@@ -8,14 +8,23 @@ import type { PlexCard, PlexCastMember, PlexFilters, PlexSeasonDetail } from "..
|
|||||||
// Presentational Plex cards + strips, split out of PlexBrowse (all purely prop-driven, no shared
|
// Presentational Plex cards + strips, split out of PlexBrowse (all purely prop-driven, no shared
|
||||||
// state). Rendered by the browse grid, the person page, and the series show/season views.
|
// state). Rendered by the browse grid, the person page, and the series show/season views.
|
||||||
|
|
||||||
// Playback cost, escalating good→heavy: direct (green) → remux, a light repackage
|
// Playback cost, escalating good→heavy: Direct Play (green) → Direct Stream / remux, a light
|
||||||
// (amber) → transcode, a full re-encode (red). Reuses the status trio as a gradient;
|
// repackage (blue) → Transcode · HW, a hardware re-encode (amber) → Transcode · SW, a software
|
||||||
// all three stay visually distinct.
|
// re-encode (red). transcode splits HW vs SW by card.transcode_hw. Reuses the semantic tokens as a
|
||||||
const PLAYABLE_TINT: Record<string, string> = {
|
// gradient; all four stay visually distinct in light + dark.
|
||||||
|
export const PLAYABLE_TINT: Record<string, string> = {
|
||||||
direct: "bg-success",
|
direct: "bg-success",
|
||||||
remux: "bg-warn",
|
remux: "bg-info",
|
||||||
transcode: "bg-danger",
|
transcode_hw: "bg-warn",
|
||||||
|
transcode_sw: "bg-danger",
|
||||||
};
|
};
|
||||||
|
// Collapse the wire `playable` (direct|remux|transcode) + transcode_hw flag into one of the 4 display
|
||||||
|
// classes, so the corner dot's colour + tooltip can't drift from the sidebar filter's option keys.
|
||||||
|
// Shared with the info-page playback chip (PlexInfo) so both surfaces derive the class identically.
|
||||||
|
export function playableClass(playable: string, transcodeHw?: boolean | null): string {
|
||||||
|
if (playable === "transcode") return transcodeHw ? "transcode_hw" : "transcode_sw";
|
||||||
|
return playable; // direct | remux
|
||||||
|
}
|
||||||
|
|
||||||
// The reveal class for a status pill (watched / in-progress) that shares a corner with a hover-reveal
|
// The reveal class for a status pill (watched / in-progress) that shares a corner with a hover-reveal
|
||||||
// control (the watched-toggle). When that control is present it takes over on touch, so the pill yields
|
// control (the watched-toggle). When that control is present it takes over on touch, so the pill yields
|
||||||
@@ -82,13 +91,22 @@ export function PlexPosterCard({
|
|||||||
decoding="async"
|
decoding="async"
|
||||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
|
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
|
||||||
/>
|
/>
|
||||||
{/* Playable-cost tint (always shown — a quick "will it direct-play" cue). */}
|
{/* Playable-cost tint (always shown — a quick "will it direct-play" cue). z-20 keeps the dot
|
||||||
{card.playable && (
|
ABOVE the full-card hover-reveal overlay below (absolute inset-0, no pointer-events-none):
|
||||||
<span
|
without it the overlay sits on top and swallows the cursor, so the dot's native `title`
|
||||||
className={`absolute top-1.5 left-1.5 w-2 h-2 rounded-full ring-2 ring-black/40 ${PLAYABLE_TINT[card.playable] ?? "bg-gray-400"}`}
|
tooltip never fired on hover (a regression once the reveal overlay was added). */}
|
||||||
title={t(`plex.playable.${card.playable}`)}
|
{card.playable &&
|
||||||
/>
|
(() => {
|
||||||
)}
|
const cls = playableClass(card.playable, card.transcode_hw);
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
role="img"
|
||||||
|
aria-label={t(`plex.playable.${cls}`)}
|
||||||
|
className={`absolute top-1.5 left-1.5 z-20 w-2 h-2 rounded-full ring-2 ring-black/40 ${PLAYABLE_TINT[cls] ?? "bg-gray-400"}`}
|
||||||
|
title={t(`plex.playable.${cls}`)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
{/* Rest-state watched badge (top-right). Yields to the hover quick-actions on a playable card
|
{/* Rest-state watched badge (top-right). Yields to the hover quick-actions on a playable card
|
||||||
(reveal-idle: gone on touch where the toggle is always shown); a watched show keeps it. */}
|
(reveal-idle: gone on touch where the toggle is always shown); a watched show keeps it. */}
|
||||||
{card.status === "watched" && (
|
{card.status === "watched" && (
|
||||||
|
|||||||
@@ -1,6 +1,49 @@
|
|||||||
import { type ReactNode } from "react";
|
import { type ReactNode } from "react";
|
||||||
import { RotateCcw } from "lucide-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 ------------------------------------------------------------
|
// --- settings-panel building blocks ------------------------------------------------------------
|
||||||
export function PanelHead({ children }: { children: ReactNode }) {
|
export function PanelHead({ children }: { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
//
|
||||||
|
// S5: the shared <Button> primitive. These lock the load-bearing contract the 120+ migrated call
|
||||||
|
// sites rely on: variant/size paint, icon-only accessibility (name must survive when the text is
|
||||||
|
// dropped), the loading state (disabled + spinner + aria-busy), and the type="button" default that
|
||||||
|
// stops an action button from submitting a surrounding form.
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { render, screen, cleanup, fireEvent } from "@testing-library/react";
|
||||||
|
import { Button } from "./Button";
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
describe("Button primitive (S5)", () => {
|
||||||
|
it("defaults to type=button so it never submits a form", () => {
|
||||||
|
render(<Button>Go</Button>);
|
||||||
|
expect(screen.getByRole("button", { name: "Go" }).getAttribute("type")).toBe("button");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies the variant paint recipe", () => {
|
||||||
|
render(
|
||||||
|
<Button variant="danger" size="sm">
|
||||||
|
Delete
|
||||||
|
</Button>,
|
||||||
|
);
|
||||||
|
const btn = screen.getByRole("button", { name: "Delete" });
|
||||||
|
expect(btn.className).toContain("btn-danger");
|
||||||
|
expect(btn.className).toContain("py-1.5");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exposes an accessible name for an icon-only button while dropping the text", () => {
|
||||||
|
render(
|
||||||
|
<Button iconOnly icon={<svg data-testid="ic" />} label="Block channel">
|
||||||
|
this text is dropped
|
||||||
|
</Button>,
|
||||||
|
);
|
||||||
|
const btn = screen.getByRole("button", { name: "Block channel" });
|
||||||
|
expect(btn.getAttribute("title")).toBe("Block channel");
|
||||||
|
expect(btn.textContent).not.toContain("this text is dropped");
|
||||||
|
expect(btn.querySelector("[data-testid=ic]")).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("loading disables the button, marks aria-busy, and blocks clicks", () => {
|
||||||
|
const onClick = vi.fn();
|
||||||
|
render(
|
||||||
|
<Button loading icon={<svg data-testid="ic" />} onClick={onClick}>
|
||||||
|
Save
|
||||||
|
</Button>,
|
||||||
|
);
|
||||||
|
const btn = screen.getByRole("button", { name: "Save" }) as HTMLButtonElement;
|
||||||
|
expect(btn.disabled).toBe(true);
|
||||||
|
expect(btn.getAttribute("aria-busy")).toBe("true");
|
||||||
|
// the leading icon is swapped for a spinner
|
||||||
|
expect(btn.querySelector("[data-testid=ic]")).toBeNull();
|
||||||
|
fireEvent.click(btn);
|
||||||
|
expect(onClick).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("loading disables even when an explicit disabled={false} is passed", () => {
|
||||||
|
// Regression: `disabled ?? loading` let an explicit boolean disabled shadow loading, so a
|
||||||
|
// call-site with `disabled={!valid} loading={pending}` (valid → disabled=false) stayed
|
||||||
|
// clickable mid-mutation and double-submitted. The contract is: loading always disables.
|
||||||
|
const onClick = vi.fn();
|
||||||
|
render(
|
||||||
|
<Button disabled={false} loading onClick={onClick}>
|
||||||
|
Create
|
||||||
|
</Button>,
|
||||||
|
);
|
||||||
|
const btn = screen.getByRole("button", { name: "Create" }) as HTMLButtonElement;
|
||||||
|
expect(btn.disabled).toBe(true);
|
||||||
|
fireEvent.click(btn);
|
||||||
|
expect(onClick).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fullWidth adds the w-full utility", () => {
|
||||||
|
render(<Button fullWidth>Wide</Button>);
|
||||||
|
expect(screen.getByRole("button", { name: "Wide" }).className).toContain("w-full");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { forwardRef } from "react";
|
||||||
|
import type { ButtonHTMLAttributes, ReactNode } from "react";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
|
||||||
|
// The ONE action-button primitive. Before this there were 385 raw <button>s styling themselves with
|
||||||
|
// ad-hoc utility strings over the CSS paint-recipes (.btn-primary/.btn-danger, index.css) + the btnCls
|
||||||
|
// constant. This centralises the geometry+paint so an "action button" (submit/cancel/subscribe/delete…)
|
||||||
|
// is one component with named variants. It deliberately does NOT cover the app's OTHER button families,
|
||||||
|
// which keep their own primitives: aria-pressed segmented pills (SegmentedToggle/SortControl), the
|
||||||
|
// Switch toggle, .chip removers, dropdown/popover trigger pills, and slider/overlay/card-click buttons.
|
||||||
|
//
|
||||||
|
// No focus-visible here on purpose: @layer base in index.css already rings every <button> for keyboard
|
||||||
|
// users. Adding one would double the ring.
|
||||||
|
|
||||||
|
type ButtonVariant =
|
||||||
|
| "primary" // solid accent — the main affirmative action
|
||||||
|
| "danger" // solid danger — destructive confirm
|
||||||
|
| "outline" // bordered, quiet — secondary action (Full history, Subscribe, Cancel-with-border)
|
||||||
|
| "ghost" // borderless, hover-fill — tertiary/dismiss (dialog Cancel, close)
|
||||||
|
| "glass" // glass card — the Plex/overlay action buttons
|
||||||
|
| "danger-outline"; // bordered danger — a destructive action that shouldn't shout
|
||||||
|
|
||||||
|
type ButtonSize =
|
||||||
|
| "xs" // dense inline action
|
||||||
|
| "sm" // the btnCls geometry (px-3 py-1.5)
|
||||||
|
| "md" // default action button (px-3 py-2)
|
||||||
|
| "lg" // prominent CTA
|
||||||
|
| "icon"; // square icon-only (w-9 h-9)
|
||||||
|
|
||||||
|
const VARIANTS: Record<ButtonVariant, string> = {
|
||||||
|
// .btn-primary / .btn-danger own the fill + one hover-dim (index.css @layer components).
|
||||||
|
primary: "btn-primary",
|
||||||
|
danger: "btn-danger",
|
||||||
|
outline: "border border-border text-muted hover:text-fg hover:border-fg/40",
|
||||||
|
ghost: "text-muted hover:text-fg hover:bg-surface",
|
||||||
|
glass: "glass-card glass-hover hover:text-accent",
|
||||||
|
"danger-outline": "border border-danger/60 text-danger hover:bg-danger/10 hover:border-danger",
|
||||||
|
};
|
||||||
|
|
||||||
|
const SIZES: Record<ButtonSize, string> = {
|
||||||
|
xs: "px-2 py-1 text-xs rounded-md gap-1",
|
||||||
|
sm: "px-3 py-1.5 text-sm rounded-lg gap-1.5",
|
||||||
|
md: "px-3 py-2 text-sm rounded-lg gap-1.5",
|
||||||
|
lg: "px-4 py-2.5 text-base rounded-lg gap-2",
|
||||||
|
icon: "w-9 h-9 rounded-lg justify-center",
|
||||||
|
};
|
||||||
|
|
||||||
|
const BASE =
|
||||||
|
"inline-flex items-center justify-center font-medium transition disabled:opacity-50 disabled:cursor-not-allowed";
|
||||||
|
|
||||||
|
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
variant?: ButtonVariant;
|
||||||
|
size?: ButtonSize;
|
||||||
|
/** Leading icon (an lucide element). Replaced by a spinner while `loading`. */
|
||||||
|
icon?: ReactNode;
|
||||||
|
/** Render only the icon: children are dropped and `label` supplies the accessible name. */
|
||||||
|
iconOnly?: boolean;
|
||||||
|
/** Accessible name + tooltip — required for `iconOnly`, optional otherwise. */
|
||||||
|
label?: string;
|
||||||
|
/** Disable + swap the icon for a spinner + mark aria-busy. */
|
||||||
|
loading?: boolean;
|
||||||
|
fullWidth?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The shared action button. `type` defaults to "button" so it never accidentally submits a form. */
|
||||||
|
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
|
||||||
|
{
|
||||||
|
variant = "primary",
|
||||||
|
size = "md",
|
||||||
|
icon,
|
||||||
|
iconOnly = false,
|
||||||
|
label,
|
||||||
|
loading = false,
|
||||||
|
fullWidth = false,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
disabled,
|
||||||
|
type,
|
||||||
|
...rest
|
||||||
|
},
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
const leading = loading ? <Loader2 className="w-4 h-4 animate-spin" /> : icon;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
ref={ref}
|
||||||
|
type={type ?? "button"}
|
||||||
|
// `loading` must always disable (see the prop's contract above), even when a call-site also
|
||||||
|
// passes an explicit boolean `disabled` — so `||`, not `??`. With `??`, `disabled={false}`
|
||||||
|
// would shadow `loading` and leave the button clickable mid-mutation (double-submit).
|
||||||
|
disabled={disabled || loading}
|
||||||
|
aria-busy={loading || undefined}
|
||||||
|
aria-label={label}
|
||||||
|
title={label}
|
||||||
|
className={clsx(BASE, VARIANTS[variant], SIZES[size], fullWidth && "w-full", className)}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
{leading}
|
||||||
|
{!iconOnly && children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { Check, RotateCcw, Save } from "lucide-react";
|
import { Check, RotateCcw, Save } from "lucide-react";
|
||||||
|
import { Button } from "./Button";
|
||||||
|
|
||||||
// The Save / Discard bar shown while an edited draft has unsaved changes (Settings prefs,
|
// The Save / Discard bar shown while an edited draft has unsaved changes (Settings prefs,
|
||||||
// admin Configuration). Two visual variants share one state machine + markup:
|
// admin Configuration). Two visual variants share one state machine + markup:
|
||||||
@@ -58,24 +59,27 @@ export function DraftSaveBar({
|
|||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<Button
|
||||||
data-testid="draft-discard"
|
data-testid="draft-discard"
|
||||||
|
variant="glass"
|
||||||
|
size="sm"
|
||||||
|
icon={<RotateCcw className="w-4 h-4" />}
|
||||||
onClick={onDiscard}
|
onClick={onDiscard}
|
||||||
disabled={saving || !dirty}
|
disabled={saving || !dirty}
|
||||||
className="glass-card glass-hover flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm disabled:opacity-40 transition"
|
|
||||||
>
|
>
|
||||||
<RotateCcw className="w-4 h-4" />
|
|
||||||
{labels.discard}
|
{labels.discard}
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
data-testid="draft-save"
|
data-testid="draft-save"
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
icon={<Save className="w-4 h-4" />}
|
||||||
onClick={onSave}
|
onClick={onSave}
|
||||||
disabled={saving || !dirty}
|
disabled={saving || !dirty}
|
||||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm btn-primary font-medium shadow-md shadow-accent/20 disabled:opacity-40 transition"
|
className="shadow-md shadow-accent/20"
|
||||||
>
|
>
|
||||||
<Save className="w-4 h-4" />
|
|
||||||
{saving ? labels.saving : labels.save}
|
{saving ? labels.saving : labels.save}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -110,6 +110,13 @@ export function buildPlexActiveFilters(
|
|||||||
remove: () => patch({ studios: filters.studios.filter((x) => x !== s) }),
|
remove: () => patch({ studios: filters.studios.filter((x) => x !== s) }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
for (const p of filters.playable) {
|
||||||
|
out.push({
|
||||||
|
key: `playable:${p}`,
|
||||||
|
label: t(`plex.filter.playableOpt.${p}`),
|
||||||
|
remove: () => patch({ playable: filters.playable.filter((x) => x !== p) }),
|
||||||
|
});
|
||||||
|
}
|
||||||
for (const k of filters.keywords) {
|
for (const k of filters.keywords) {
|
||||||
out.push({
|
out.push({
|
||||||
key: `keyword:${k}`,
|
key: `keyword:${k}`,
|
||||||
|
|||||||
@@ -17,5 +17,7 @@
|
|||||||
"sync_pause": "Sync paused",
|
"sync_pause": "Sync paused",
|
||||||
"sync_resume": "Sync resumed",
|
"sync_resume": "Sync resumed",
|
||||||
"job_run": "Scheduler job run",
|
"job_run": "Scheduler job run",
|
||||||
"audit_clear": "Audit log cleared"
|
"audit_clear": "Audit log cleared",
|
||||||
|
"download_credential_set": "Download cookies set",
|
||||||
|
"download_credential_delete": "Download cookies removed"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -166,7 +166,23 @@
|
|||||||
},
|
},
|
||||||
"plex_max_transcodes": {
|
"plex_max_transcodes": {
|
||||||
"label": "Max concurrent 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",
|
"save": "Save",
|
||||||
|
|||||||
@@ -21,8 +21,21 @@
|
|||||||
"addUrl": "Paste a YouTube link or video id…",
|
"addUrl": "Paste a YouTube link or video id…",
|
||||||
"add": "Add",
|
"add": "Add",
|
||||||
"paste": "Paste link from clipboard",
|
"paste": "Paste link from clipboard",
|
||||||
|
"upload": "Upload",
|
||||||
"manage": "Manage formats"
|
"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": {
|
"tabs": {
|
||||||
"queue": "Queue",
|
"queue": "Queue",
|
||||||
"done": "Library",
|
"done": "Library",
|
||||||
@@ -56,13 +69,18 @@
|
|||||||
"delete": "Remove",
|
"delete": "Remove",
|
||||||
"retry": "Retry",
|
"retry": "Retry",
|
||||||
"download": "Save to my device",
|
"download": "Save to my device",
|
||||||
|
"play": "Play",
|
||||||
"rename": "Rename",
|
"rename": "Rename",
|
||||||
"editDetails": "Edit details",
|
"editDetails": "Edit details",
|
||||||
"share": "Share",
|
"share": "Share (copy link)",
|
||||||
|
"shareManage": "Manage sharing",
|
||||||
"edit": "Edit / trim",
|
"edit": "Edit / trim",
|
||||||
"redownload": "Re-download (rebuild file)",
|
"redownload": "Re-download (rebuild file)",
|
||||||
"removeShared": "Remove from my list"
|
"removeShared": "Remove from my list"
|
||||||
},
|
},
|
||||||
|
"player": {
|
||||||
|
"unsupported": "Your browser can't play this file."
|
||||||
|
},
|
||||||
"empty": {
|
"empty": {
|
||||||
"queue": "Nothing in the queue.",
|
"queue": "Nothing in the queue.",
|
||||||
"done": "No downloads yet.",
|
"done": "No downloads yet.",
|
||||||
@@ -104,6 +122,9 @@
|
|||||||
"noUsers": "No other users yet.",
|
"noUsers": "No other users yet.",
|
||||||
"linkSection": "Share a link",
|
"linkSection": "Share a link",
|
||||||
"linkHint": "Anyone with the link can watch — no account needed.",
|
"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.",
|
"noLinks": "No links yet.",
|
||||||
"newLink": "Create a link",
|
"newLink": "Create a link",
|
||||||
"createLink": "Create link",
|
"createLink": "Create link",
|
||||||
@@ -151,10 +172,7 @@
|
|||||||
"modeA": "Audio only",
|
"modeA": "Audio only",
|
||||||
"quality": "Max quality",
|
"quality": "Max quality",
|
||||||
"best": "Best",
|
"best": "Best",
|
||||||
"container": "Container",
|
|
||||||
"audioFormat": "Audio format",
|
"audioFormat": "Audio format",
|
||||||
"vcodec": "Video codec",
|
|
||||||
"any": "Any",
|
|
||||||
"embedSubs": "Embed subtitles",
|
"embedSubs": "Embed subtitles",
|
||||||
"embedChapters": "Embed chapters",
|
"embedChapters": "Embed chapters",
|
||||||
"embedThumbnail": "Embed thumbnail",
|
"embedThumbnail": "Embed thumbnail",
|
||||||
@@ -207,6 +225,10 @@
|
|||||||
"source_not_ready": "The source download isn't ready to edit.",
|
"source_not_ready": "The source download isn't ready to edit.",
|
||||||
"generic": "That edit couldn't be created."
|
"generic": "That edit couldn't be created."
|
||||||
},
|
},
|
||||||
|
"upload": {
|
||||||
|
"tooLarge": "That file is too large (max {{size}}).",
|
||||||
|
"empty": "That file is empty."
|
||||||
|
},
|
||||||
"fail": {
|
"fail": {
|
||||||
"title": "Download failed",
|
"title": "Download failed",
|
||||||
"detailsLink": "details",
|
"detailsLink": "details",
|
||||||
@@ -218,7 +240,11 @@
|
|||||||
},
|
},
|
||||||
"login_required": {
|
"login_required": {
|
||||||
"short": "Sign-in required",
|
"short": "Sign-in required",
|
||||||
"body": "This video needs a signed-in, age-verified, or members-only account to view, so it can't be downloaded here."
|
"body": "This video needs a signed-in account (age-restricted, members-only, or a private group) to view. Add your cookies for this site under Settings → Account → Download authentication to download it."
|
||||||
|
},
|
||||||
|
"unsupported_host": {
|
||||||
|
"short": "Site not supported",
|
||||||
|
"body": "This site isn't supported for downloading, so nothing could be fetched from this link."
|
||||||
},
|
},
|
||||||
"geo_blocked": {
|
"geo_blocked": {
|
||||||
"short": "Region-blocked",
|
"short": "Region-blocked",
|
||||||
@@ -249,6 +275,11 @@
|
|||||||
"body": "Something went wrong while downloading this item. The technical details below may help."
|
"body": "Something went wrong while downloading this item. The technical details below may help."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"cookies": {
|
||||||
|
"empty": "Paste your cookies first.",
|
||||||
|
"format": "That doesn't look like a cookies.txt file (Netscape format).",
|
||||||
|
"generic": "Those cookies couldn't be saved."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,8 +35,21 @@
|
|||||||
"pushTitle": "Sync to YouTube",
|
"pushTitle": "Sync to YouTube",
|
||||||
"pushConfirm": "Sync",
|
"pushConfirm": "Sync",
|
||||||
"pushPlanCreate": "Create a new YouTube playlist with {{count}} videos? (~{{units}} quota units; {{left}} left today.)",
|
"pushPlanCreate": "Create a new YouTube playlist with {{count}} videos? (~{{units}} quota units; {{left}} left today.)",
|
||||||
"pushPlanUpdate": "Update on YouTube — add {{insert}}, remove {{del}}, reorder {{reorder}}? (~{{units}} quota units; {{left}} left today.)",
|
"mergeAddLocal_one": "{{count}} video added to Siftlode from YouTube.",
|
||||||
"pushDiverged": "{{count}} video(s) currently on YouTube will be removed.",
|
"mergeAddLocal_other": "{{count}} videos added to Siftlode from YouTube.",
|
||||||
|
"mergeRemoveLocal_one": "{{count}} video removed from Siftlode.",
|
||||||
|
"mergeRemoveLocal_other": "{{count}} videos removed from Siftlode.",
|
||||||
|
"mergeAddYt_one": "{{count}} video added to YouTube.",
|
||||||
|
"mergeAddYt_other": "{{count}} videos added to YouTube.",
|
||||||
|
"mergeRemoveYt_one": "{{count}} video removed from YouTube.",
|
||||||
|
"mergeRemoveYt_other": "{{count}} videos removed from YouTube.",
|
||||||
|
"mergeReorder_one": "{{count}} video reordered on YouTube.",
|
||||||
|
"mergeReorder_other": "{{count}} videos reordered on YouTube.",
|
||||||
|
"mergeRename": "The playlist will be renamed on YouTube.",
|
||||||
|
"mergeYtUnchanged": "Your YouTube playlist stays unchanged.",
|
||||||
|
"mergeLocalUnchanged": "Your Siftlode playlist stays unchanged.",
|
||||||
|
"mergeQuota": " (~{{units}} quota units; {{left}} left today.)",
|
||||||
|
"mergeQuotaFree": " (~0 quota units.)",
|
||||||
"pushNoQuota": "Not enough YouTube quota left today ({{left}}) for this sync (~{{units}} needed). Try again tomorrow.",
|
"pushNoQuota": "Not enough YouTube quota left today ({{left}}) for this sync (~{{units}} needed). Try again tomorrow.",
|
||||||
"pushUpToDate": "“{{name}}” is already in sync with YouTube.",
|
"pushUpToDate": "“{{name}}” is already in sync with YouTube.",
|
||||||
"pushDone": "“{{name}}” synced to YouTube ✓",
|
"pushDone": "“{{name}}” synced to YouTube ✓",
|
||||||
|
|||||||
@@ -104,6 +104,13 @@
|
|||||||
"collectionEmpty": "No collections",
|
"collectionEmpty": "No collections",
|
||||||
"matchLabel": "Match",
|
"matchLabel": "Match",
|
||||||
"studio": "Studio",
|
"studio": "Studio",
|
||||||
|
"playable": "Playback",
|
||||||
|
"playableOpt": {
|
||||||
|
"direct": "Direct Play",
|
||||||
|
"remux": "Direct Stream",
|
||||||
|
"transcode_hw": "Transcode · HW",
|
||||||
|
"transcode_sw": "Transcode · SW"
|
||||||
|
},
|
||||||
"moreRefine": "{{count}} more — refine with search"
|
"moreRefine": "{{count}} more — refine with search"
|
||||||
},
|
},
|
||||||
"count": "{{count}} titles",
|
"count": "{{count}} titles",
|
||||||
@@ -155,11 +162,15 @@
|
|||||||
"player": {
|
"player": {
|
||||||
"loading": "Loading…",
|
"loading": "Loading…",
|
||||||
"buffering": "Buffering…",
|
"buffering": "Buffering…",
|
||||||
|
"preparing": "Preparing stream…",
|
||||||
"back": "Back",
|
"back": "Back",
|
||||||
|
"close": "Close",
|
||||||
"skipIntro": "Skip intro",
|
"skipIntro": "Skip intro",
|
||||||
"skipCredits": "Skip credits",
|
"skipCredits": "Skip credits",
|
||||||
"playPause": "Play / pause (space)",
|
"playPause": "Play / pause (space)",
|
||||||
"restart": "Restart from beginning",
|
"restart": "Restart from beginning",
|
||||||
|
"rewind10": "Back 10 seconds",
|
||||||
|
"forward10": "Forward 10 seconds",
|
||||||
"prev": "Previous episode",
|
"prev": "Previous episode",
|
||||||
"next": "Next episode",
|
"next": "Next episode",
|
||||||
"mute": "Mute (m)",
|
"mute": "Mute (m)",
|
||||||
@@ -187,7 +198,27 @@
|
|||||||
"help": "Show this help",
|
"help": "Show this help",
|
||||||
"audio": "Cycle audio track",
|
"audio": "Cycle audio track",
|
||||||
"subtitle": "Cycle subtitle",
|
"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",
|
"settings": "Settings",
|
||||||
"sync": "Offsets",
|
"sync": "Offsets",
|
||||||
@@ -261,8 +292,9 @@
|
|||||||
},
|
},
|
||||||
"playable": {
|
"playable": {
|
||||||
"direct": "Plays directly in the browser",
|
"direct": "Plays directly in the browser",
|
||||||
"remux": "Needs a light remux (no video re-encode)",
|
"remux": "Direct Stream — a light remux (no video re-encode)",
|
||||||
"transcode": "Needs transcoding (heavier)"
|
"transcode_hw": "Transcoded on the hardware encoder",
|
||||||
|
"transcode_sw": "Transcoded in software (heaviest)"
|
||||||
},
|
},
|
||||||
"playlistAdd": {
|
"playlistAdd": {
|
||||||
"manage": "Add to playlist",
|
"manage": "Add to playlist",
|
||||||
|
|||||||
@@ -104,6 +104,27 @@
|
|||||||
"failed": "Couldn't sign out other sessions. Please try again."
|
"failed": "Couldn't sign out other sessions. Please try again."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"downloadAuth": {
|
||||||
|
"title": "Download authentication",
|
||||||
|
"intro": "Some sites (e.g. a private Facebook group you belong to) only let logged-in members view a video. Paste your browser cookies for a site here and your downloads from it will use your login. Cookies are encrypted, kept private to your account, and only ever sent to the matching site.",
|
||||||
|
"configured": "Set · updated {{when}}",
|
||||||
|
"notSet": "Not set",
|
||||||
|
"set": "Set cookies",
|
||||||
|
"update": "Update",
|
||||||
|
"remove": "Remove",
|
||||||
|
"save": "Save",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"saving": "Saving…",
|
||||||
|
"placeholder": "Paste the contents of your cookies.txt (Netscape format) here",
|
||||||
|
"help": "Export a cookies.txt for this site with a browser extension, then paste it here. It's write-only — we never show it back.",
|
||||||
|
"saved": "Cookies saved.",
|
||||||
|
"removed": "Cookies removed.",
|
||||||
|
"failed": "Couldn't save those cookies.",
|
||||||
|
"removeConfirmTitle": "Remove cookies?",
|
||||||
|
"removeConfirmBody": "Downloads from this site will stop using your login.",
|
||||||
|
"removeConfirmAction": "Remove",
|
||||||
|
"loadError": "Couldn't load your download authentication settings."
|
||||||
|
},
|
||||||
"demo": {
|
"demo": {
|
||||||
"title": "Demo access",
|
"title": "Demo access",
|
||||||
"intro": "Emails here can enter the shared demo account straight from the login page — no Google sign-in. They just type the address into the field and are let in after a moment.",
|
"intro": "Emails here can enter the shared demo account straight from the login page — no Google sign-in. They just type the address into the field and are let in after a moment.",
|
||||||
|
|||||||
@@ -17,5 +17,7 @@
|
|||||||
"sync_pause": "Szinkron szüneteltetve",
|
"sync_pause": "Szinkron szüneteltetve",
|
||||||
"sync_resume": "Szinkron folytatva",
|
"sync_resume": "Szinkron folytatva",
|
||||||
"job_run": "Ütemező job lefutott",
|
"job_run": "Ütemező job lefutott",
|
||||||
"audit_clear": "Napló ürítve"
|
"audit_clear": "Napló ürítve",
|
||||||
|
"download_credential_set": "Letöltési cookie-k beállítva",
|
||||||
|
"download_credential_delete": "Letöltési cookie-k eltávolítva"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -166,7 +166,23 @@
|
|||||||
},
|
},
|
||||||
"plex_max_transcodes": {
|
"plex_max_transcodes": {
|
||||||
"label": "Max párhuzamos transcode",
|
"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",
|
"save": "Mentés",
|
||||||
|
|||||||
@@ -21,8 +21,21 @@
|
|||||||
"addUrl": "Illessz be egy YouTube linket vagy videó azonosítót…",
|
"addUrl": "Illessz be egy YouTube linket vagy videó azonosítót…",
|
||||||
"add": "Hozzáad",
|
"add": "Hozzáad",
|
||||||
"paste": "Link beillesztése a vágólapról",
|
"paste": "Link beillesztése a vágólapról",
|
||||||
|
"upload": "Feltöltés",
|
||||||
"manage": "Formátumok kezelése"
|
"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": {
|
"tabs": {
|
||||||
"queue": "Sor",
|
"queue": "Sor",
|
||||||
"done": "Könyvtár",
|
"done": "Könyvtár",
|
||||||
@@ -56,13 +69,18 @@
|
|||||||
"delete": "Eltávolítás",
|
"delete": "Eltávolítás",
|
||||||
"retry": "Újra",
|
"retry": "Újra",
|
||||||
"download": "Mentés a gépemre",
|
"download": "Mentés a gépemre",
|
||||||
|
"play": "Lejátszás",
|
||||||
"rename": "Átnevezés",
|
"rename": "Átnevezés",
|
||||||
"editDetails": "Adatok szerkesztése",
|
"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",
|
"edit": "Szerkesztés / vágás",
|
||||||
"redownload": "Újraletöltés (fájl újraépítése)",
|
"redownload": "Újraletöltés (fájl újraépítése)",
|
||||||
"removeShared": "Eltávolítás a listámból"
|
"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": {
|
"empty": {
|
||||||
"queue": "A sor üres.",
|
"queue": "A sor üres.",
|
||||||
"done": "Még nincs letöltés.",
|
"done": "Még nincs letöltés.",
|
||||||
@@ -104,6 +122,9 @@
|
|||||||
"noUsers": "Nincs más felhasználó.",
|
"noUsers": "Nincs más felhasználó.",
|
||||||
"linkSection": "Megosztás linkkel",
|
"linkSection": "Megosztás linkkel",
|
||||||
"linkHint": "A link birtokában bárki megnézheti — fiók nélkül is.",
|
"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.",
|
"noLinks": "Még nincs link.",
|
||||||
"newLink": "Link létrehozása",
|
"newLink": "Link létrehozása",
|
||||||
"createLink": "Létrehozás",
|
"createLink": "Létrehozás",
|
||||||
@@ -151,10 +172,7 @@
|
|||||||
"modeA": "Csak hang",
|
"modeA": "Csak hang",
|
||||||
"quality": "Max. minőség",
|
"quality": "Max. minőség",
|
||||||
"best": "Legjobb",
|
"best": "Legjobb",
|
||||||
"container": "Konténer",
|
|
||||||
"audioFormat": "Hangformátum",
|
"audioFormat": "Hangformátum",
|
||||||
"vcodec": "Videó codec",
|
|
||||||
"any": "Bármelyik",
|
|
||||||
"embedSubs": "Feliratok beágyazása",
|
"embedSubs": "Feliratok beágyazása",
|
||||||
"embedChapters": "Fejezetek beágyazása",
|
"embedChapters": "Fejezetek beágyazása",
|
||||||
"embedThumbnail": "Bélyegkép 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ő.",
|
"source_not_ready": "A forrásletöltés még nem szerkeszthető.",
|
||||||
"generic": "Ezt a vágást nem sikerült létrehozni."
|
"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": {
|
"fail": {
|
||||||
"title": "A letöltés nem sikerült",
|
"title": "A letöltés nem sikerült",
|
||||||
"detailsLink": "részletek",
|
"detailsLink": "részletek",
|
||||||
@@ -218,7 +240,11 @@
|
|||||||
},
|
},
|
||||||
"login_required": {
|
"login_required": {
|
||||||
"short": "Bejelentkezés szükséges",
|
"short": "Bejelentkezés szükséges",
|
||||||
"body": "Ehhez a videóhoz bejelentkezett, kor-ellenőrzött vagy tagsági fiók kell, ezért itt nem tölthető le."
|
"body": "Ehhez a videóhoz bejelentkezett fiók kell (korhatáros, csak tagoknak szóló vagy zárt csoport). Töltsd fel a cookie-kat ehhez az oldalhoz a Beállítások → Fiók → Letöltés-hitelesítés alatt a letöltéshez."
|
||||||
|
},
|
||||||
|
"unsupported_host": {
|
||||||
|
"short": "Nem támogatott oldal",
|
||||||
|
"body": "Ez az oldal nem támogatott a letöltéshez, ezért erről a linkről semmit sem sikerült lekérni."
|
||||||
},
|
},
|
||||||
"geo_blocked": {
|
"geo_blocked": {
|
||||||
"short": "Régió szerint tiltva",
|
"short": "Régió szerint tiltva",
|
||||||
@@ -249,6 +275,11 @@
|
|||||||
"body": "Valami hiba történt az elem letöltése közben. Az alábbi technikai részletek segíthetnek."
|
"body": "Valami hiba történt az elem letöltése közben. Az alábbi technikai részletek segíthetnek."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"cookies": {
|
||||||
|
"empty": "Előbb illeszd be a cookie-kat.",
|
||||||
|
"format": "Ez nem tűnik cookies.txt fájlnak (Netscape formátum).",
|
||||||
|
"generic": "A cookie-kat nem sikerült menteni."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,8 +35,21 @@
|
|||||||
"pushTitle": "Szinkron YouTube-ra",
|
"pushTitle": "Szinkron YouTube-ra",
|
||||||
"pushConfirm": "Szinkron",
|
"pushConfirm": "Szinkron",
|
||||||
"pushPlanCreate": "Létrehozol egy új YouTube-listát {{count}} videóval? (~{{units}} kvótaegység; ma még {{left}} maradt.)",
|
"pushPlanCreate": "Létrehozol egy új YouTube-listát {{count}} videóval? (~{{units}} kvótaegység; ma még {{left}} maradt.)",
|
||||||
"pushPlanUpdate": "Frissítés a YouTube-on — {{insert}} hozzáadása, {{del}} eltávolítása, {{reorder}} átrendezése? (~{{units}} kvótaegység; ma még {{left}} maradt.)",
|
"mergeAddLocal_one": "{{count}} videó bekerül a Siftlode-listádba a YouTube-ról.",
|
||||||
"pushDiverged": "{{count}} videó, amely jelenleg a YouTube-on van, el lesz távolítva.",
|
"mergeAddLocal_other": "{{count}} videó bekerül a Siftlode-listádba a YouTube-ról.",
|
||||||
|
"mergeRemoveLocal_one": "{{count}} videó törlődik a Siftlode-listádból.",
|
||||||
|
"mergeRemoveLocal_other": "{{count}} videó törlődik a Siftlode-listádból.",
|
||||||
|
"mergeAddYt_one": "{{count}} videó felkerül a YouTube-ra.",
|
||||||
|
"mergeAddYt_other": "{{count}} videó felkerül a YouTube-ra.",
|
||||||
|
"mergeRemoveYt_one": "{{count}} videó törlődik a YouTube-ról.",
|
||||||
|
"mergeRemoveYt_other": "{{count}} videó törlődik a YouTube-ról.",
|
||||||
|
"mergeReorder_one": "{{count}} videó átrendeződik a YouTube-on.",
|
||||||
|
"mergeReorder_other": "{{count}} videó átrendeződik a YouTube-on.",
|
||||||
|
"mergeRename": "A lista átnevezésre kerül a YouTube-on.",
|
||||||
|
"mergeYtUnchanged": "A YouTube-listád nem változik.",
|
||||||
|
"mergeLocalUnchanged": "A Siftlode-listád nem változik.",
|
||||||
|
"mergeQuota": " (~{{units}} kvótaegység; ma még {{left}} maradt.)",
|
||||||
|
"mergeQuotaFree": " (~0 kvótaegység.)",
|
||||||
"pushNoQuota": "Nincs elég YouTube-kvóta mára ({{left}}) ehhez a szinkronhoz (~{{units}} kell). Próbáld újra holnap.",
|
"pushNoQuota": "Nincs elég YouTube-kvóta mára ({{left}}) ehhez a szinkronhoz (~{{units}} kell). Próbáld újra holnap.",
|
||||||
"pushUpToDate": "A(z) „{{name}}” már szinkronban van a YouTube-bal.",
|
"pushUpToDate": "A(z) „{{name}}” már szinkronban van a YouTube-bal.",
|
||||||
"pushDone": "„{{name}}” szinkronizálva a YouTube-ra ✓",
|
"pushDone": "„{{name}}” szinkronizálva a YouTube-ra ✓",
|
||||||
|
|||||||
@@ -104,6 +104,13 @@
|
|||||||
"collectionEmpty": "Nincs kollekció",
|
"collectionEmpty": "Nincs kollekció",
|
||||||
"matchLabel": "Egyezés",
|
"matchLabel": "Egyezés",
|
||||||
"studio": "Stúdió",
|
"studio": "Stúdió",
|
||||||
|
"playable": "Lejátszás",
|
||||||
|
"playableOpt": {
|
||||||
|
"direct": "Közvetlen lejátszás",
|
||||||
|
"remux": "Közvetlen streamelés",
|
||||||
|
"transcode_hw": "Átkódolás · HW",
|
||||||
|
"transcode_sw": "Átkódolás · SW"
|
||||||
|
},
|
||||||
"moreRefine": "még {{count}} — finomíts kereséssel"
|
"moreRefine": "még {{count}} — finomíts kereséssel"
|
||||||
},
|
},
|
||||||
"count": "{{count}} cím",
|
"count": "{{count}} cím",
|
||||||
@@ -155,11 +162,15 @@
|
|||||||
"player": {
|
"player": {
|
||||||
"loading": "Betöltés…",
|
"loading": "Betöltés…",
|
||||||
"buffering": "Pufferelés…",
|
"buffering": "Pufferelés…",
|
||||||
|
"preparing": "Stream előkészítése…",
|
||||||
"back": "Vissza",
|
"back": "Vissza",
|
||||||
|
"close": "Bezárás",
|
||||||
"skipIntro": "Intro átugrása",
|
"skipIntro": "Intro átugrása",
|
||||||
"skipCredits": "Stáblista átugrása",
|
"skipCredits": "Stáblista átugrása",
|
||||||
"playPause": "Lejátszás / szünet (szóköz)",
|
"playPause": "Lejátszás / szünet (szóköz)",
|
||||||
"restart": "Újra az elejéről",
|
"restart": "Újra az elejéről",
|
||||||
|
"rewind10": "10 másodperc vissza",
|
||||||
|
"forward10": "10 másodperc előre",
|
||||||
"prev": "Előző rész",
|
"prev": "Előző rész",
|
||||||
"next": "Következő rész",
|
"next": "Következő rész",
|
||||||
"mute": "Némítás (m)",
|
"mute": "Némítás (m)",
|
||||||
@@ -187,7 +198,27 @@
|
|||||||
"help": "Súgó megjelenítése",
|
"help": "Súgó megjelenítése",
|
||||||
"audio": "Hangsáv váltása",
|
"audio": "Hangsáv váltása",
|
||||||
"subtitle": "Felirat 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",
|
"settings": "Beállítások",
|
||||||
"sync": "Eltolások",
|
"sync": "Eltolások",
|
||||||
@@ -261,8 +292,9 @@
|
|||||||
},
|
},
|
||||||
"playable": {
|
"playable": {
|
||||||
"direct": "Közvetlenül játszható a böngészőben",
|
"direct": "Közvetlenül játszható a böngészőben",
|
||||||
"remux": "Könnyű remux kell (nincs videó-újrakódolás)",
|
"remux": "Közvetlen streamelés — könnyű remux (nincs videó-újrakódolás)",
|
||||||
"transcode": "Transcode kell (nehezebb)"
|
"transcode_hw": "Hardveres kódolóval átkódolva",
|
||||||
|
"transcode_sw": "Szoftveresen átkódolva (legnehezebb)"
|
||||||
},
|
},
|
||||||
"playlistAdd": {
|
"playlistAdd": {
|
||||||
"manage": "Listához adás",
|
"manage": "Listához adás",
|
||||||
|
|||||||
@@ -104,6 +104,27 @@
|
|||||||
"failed": "Nem sikerült kijelentkeztetni a többi munkamenetet. Próbáld újra."
|
"failed": "Nem sikerült kijelentkeztetni a többi munkamenetet. Próbáld újra."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"downloadAuth": {
|
||||||
|
"title": "Letöltés-hitelesítés",
|
||||||
|
"intro": "Egyes oldalak (pl. egy zárt Facebook-csoport, aminek tagja vagy) csak bejelentkezett tagoknak mutatják a videót. Illeszd be ide a böngésződ cookie-jait egy oldalhoz, és az onnan induló letöltések a te bejelentkezésedet használják. A cookie-k titkosítva, csak a te fiókodhoz kötve tárolódnak, és kizárólag az adott oldalnak küldjük el őket.",
|
||||||
|
"configured": "Beállítva · frissítve: {{when}}",
|
||||||
|
"notSet": "Nincs beállítva",
|
||||||
|
"set": "Cookie-k beállítása",
|
||||||
|
"update": "Frissítés",
|
||||||
|
"remove": "Eltávolítás",
|
||||||
|
"save": "Mentés",
|
||||||
|
"cancel": "Mégse",
|
||||||
|
"saving": "Mentés…",
|
||||||
|
"placeholder": "Illeszd be ide a cookies.txt tartalmát (Netscape formátum)",
|
||||||
|
"help": "Exportálj cookies.txt-t ehhez az oldalhoz egy böngészőbővítménnyel, majd illeszd be ide. Csak írható — sosem mutatjuk vissza.",
|
||||||
|
"saved": "Cookie-k mentve.",
|
||||||
|
"removed": "Cookie-k eltávolítva.",
|
||||||
|
"failed": "A cookie-kat nem sikerült menteni.",
|
||||||
|
"removeConfirmTitle": "Cookie-k eltávolítása?",
|
||||||
|
"removeConfirmBody": "Az erről az oldalról induló letöltések nem használják tovább a bejelentkezésedet.",
|
||||||
|
"removeConfirmAction": "Eltávolítás",
|
||||||
|
"loadError": "Nem sikerült betölteni a letöltési hitelesítési beállításokat."
|
||||||
|
},
|
||||||
"demo": {
|
"demo": {
|
||||||
"title": "Demo hozzáférés",
|
"title": "Demo hozzáférés",
|
||||||
"intro": "Az itt szereplő e-mailek a bejelentkező oldalról közvetlenül beléphetnek a közös demo fiókba — Google-bejelentkezés nélkül. Csak beírják a címet a mezőbe, és pár pillanat múlva bekerülnek.",
|
"intro": "Az itt szereplő e-mailek a bejelentkező oldalról közvetlenül beléphetnek a közös demo fiókba — Google-bejelentkezés nélkül. Csak beírják a címet a mezőbe, és pár pillanat múlva bekerülnek.",
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user