Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a84f2db310 | ||
|
|
872d91f540 | ||
|
|
4eaa68ceeb | ||
|
|
00539a4d97 | ||
|
|
dc24d353e0 | ||
|
|
ce96032edb | ||
|
|
0658f9550b | ||
|
|
f162788135 | ||
|
|
968674bc6a | ||
|
|
90ae9f6e88 | ||
|
|
3682e6a01f | ||
|
|
aca355f43b | ||
|
|
f41c81eb56 | ||
|
|
614c5528ad | ||
|
|
104bd1e3a6 | ||
|
|
584e9ced0c | ||
|
|
b4ad59274b | ||
|
|
fe0b34cd00 | ||
|
|
9eaa01617c | ||
|
|
fdca12968b | ||
|
|
94fcc40bde | ||
|
|
fb2c843fa9 | ||
|
|
bb6cb6e335 | ||
|
|
bdbff89507 | ||
|
|
9b87838e3a | ||
|
|
f014416e31 | ||
|
|
3369587412 | ||
|
|
9af4e8f4ff | ||
|
|
a0b725d779 | ||
|
|
bc33b2ed16 | ||
|
|
5280949667 | ||
|
|
4892c38466 | ||
|
|
9ae6c47d6c | ||
|
|
b7a6451dba | ||
|
|
11e32163b8 | ||
|
|
257dc5741d | ||
|
|
f52bc5898b | ||
|
|
d0033617e2 | ||
|
|
f80ed115ad | ||
|
|
7fbb996297 | ||
|
|
9cd4ae09d4 | ||
|
|
00527d0cf3 | ||
|
|
8dc4976c2c | ||
|
|
56a2ecdec3 | ||
|
|
8299ea72b7 | ||
|
|
6c918d1dff | ||
|
|
3f8257059d | ||
|
|
12d9561ba1 |
+7
-4
@@ -45,12 +45,15 @@ RUN adduser --disabled-password --gecos "" appuser \
|
||||
&& mkdir -p /downloads \
|
||||
&& chown appuser /app /downloads
|
||||
|
||||
# ffmpeg: merge separate video+audio streams + postprocessing. Deno: the JavaScript runtime
|
||||
# yt-dlp uses to solve YouTube's "n" signature challenge for the web player clients (required
|
||||
# alongside the PO token, else only storyboards are offered). yt-dlp auto-detects deno on PATH.
|
||||
# ffmpeg: merge separate video+audio streams + postprocessing, and the Plex VAAPI transcode (E6 S1).
|
||||
# intel-media-va-driver (iHD): the VA driver ffmpeg's h264_vaapi needs — libva2 ships in the base but
|
||||
# no driver, so without this `vainfo`/`h264_vaapi` fail and transcode silently falls back to libx264.
|
||||
# Harmless on hosts with no /dev/dri (the driver just goes unused). Deno: the JavaScript runtime yt-dlp
|
||||
# uses to solve YouTube's "n" signature challenge for the web player clients (required alongside the PO
|
||||
# token, else only storyboards are offered). yt-dlp auto-detects deno on PATH.
|
||||
ARG DENO_VERSION=v2.9.1
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ffmpeg unzip curl ca-certificates \
|
||||
&& apt-get install -y --no-install-recommends ffmpeg intel-media-va-driver unzip curl ca-certificates \
|
||||
&& curl -fsSL "https://github.com/denoland/deno/releases/download/${DENO_VERSION}/deno-x86_64-unknown-linux-gnu.zip" -o /tmp/deno.zip \
|
||||
&& unzip -o /tmp/deno.zip -d /usr/local/bin \
|
||||
&& chmod +x /usr/local/bin/deno \
|
||||
|
||||
@@ -0,0 +1,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")
|
||||
@@ -44,6 +44,9 @@ class AuditAction:
|
||||
SYNC_RESUME = "sync_resume"
|
||||
# Scheduler job outcome / run-summary (actor_id set = a manual "run now"; NULL = automatic)
|
||||
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)
|
||||
AUDIT_CLEAR = "audit_clear"
|
||||
|
||||
|
||||
+37
-3
@@ -186,6 +186,21 @@ class Settings(BaseSettings):
|
||||
download_supervisor_poll_seconds: int = 3
|
||||
# Grace between SIGTERM and SIGKILL when stopping a child (matches run_ffmpeg's escalation).
|
||||
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
|
||||
# least-recently-accessed ready assets until under it. 0 = unlimited (disk is the limit).
|
||||
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_concurrent: int = 2
|
||||
download_default_max_jobs: int = 50
|
||||
# Per-file cap for local uploads (E7). Unlike a download (whose size is unknown until the worker
|
||||
# finishes), an upload's size is known at ingest, so this bounds a single file directly; the
|
||||
# user's overall byte footprint is still governed by download_default_max_bytes.
|
||||
download_upload_max_bytes: int = 524_288_000 # 500 MiB
|
||||
# Retention: an asset expires this many days after its last access; the GC job then
|
||||
# deletes it (once no job references it) after an additional grace window.
|
||||
download_retention_days: int = 30
|
||||
@@ -232,14 +251,29 @@ class Settings(BaseSettings):
|
||||
plex_sync_interval_min: int = 360
|
||||
# How often the incremental Plex→Siftlode watch-state reconcile runs, in minutes (Phase C):
|
||||
# a cheap history + on-deck pull plus a re-push of any states that failed their immediate push.
|
||||
plex_watch_sync_interval_min: int = 30
|
||||
# 15 min (S2): with Plex now the source of truth (prompt write-back on the ~10s checkpoint +
|
||||
# Plex-authoritative pull), a faster incremental pull keeps Siftlode close to Plex-side changes.
|
||||
plex_watch_sync_interval_min: int = 15
|
||||
# How often the FULL watch-state reconcile runs, in minutes (Phase C). A whole-section rescan
|
||||
# that catches what the incremental history feed can't — notably an un-watch done on the Plex
|
||||
# side — so it runs far less often (daily) than the incremental pass.
|
||||
plex_watch_reconcile_interval_min: int = 1440
|
||||
# Max concurrent transcodes for the P3 fallback. Low by default — CPU-only transcode is
|
||||
# expensive; direct-serve (browser-compatible files) has no such limit.
|
||||
# Max concurrent transcode sessions (playable="transcode"). Low by default — the software-fallback
|
||||
# re-encode is CPU-heavy (the VAAPI hardware path is far cheaper and could sustain more); direct-serve
|
||||
# and remux (stream-copy) are not capped by this.
|
||||
plex_max_transcodes: int = 4
|
||||
# --- Transcode (playable="transcode") re-encode tuning (E6 S1) ---
|
||||
# Hardware-encoder policy: "auto" = VAAPI (h264_vaapi) when the iGPU render node is present AND the
|
||||
# source codec is hardware-decodable, else libx264 software; "on" = force VAAPI wherever the render
|
||||
# node exists (skip the decodability gate); "off" = always software. See plex/stream.py `_want_hw`.
|
||||
plex_transcode_hw: str = "auto"
|
||||
# Software-fallback libx264 tuning (IGNORED on the VAAPI path). `preset` trades CPU for size; `crf`
|
||||
# trades quality for size (lower = better + bigger). `max_height` caps the SOFTWARE transcode's output
|
||||
# height (0 = keep source) so a CPU re-encode of a tall source stays affordable — the GPU path always
|
||||
# encodes at source resolution.
|
||||
plex_transcode_preset: str = "veryfast"
|
||||
plex_transcode_crf: int = 20
|
||||
plex_transcode_max_height: int = 1080
|
||||
# Where on-the-fly HLS remux segments are written (transient, reaped). Empty → a `.plex-hls`
|
||||
# dir under download_root. On localdev the download_root is a slow Windows bind-mount, so point
|
||||
# this at a fast container-local path (e.g. /var/tmp/plex-hls); prod's download_root is fast
|
||||
|
||||
@@ -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 logging
|
||||
import math
|
||||
import os
|
||||
import queue
|
||||
import shutil
|
||||
import subprocess
|
||||
@@ -32,9 +33,21 @@ import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from app.config import settings
|
||||
|
||||
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):
|
||||
"""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")
|
||||
|
||||
|
||||
def parse_probe_streams(payload: str) -> tuple[str | None, str | None]:
|
||||
"""(video_codec, audio_codec) out of ffprobe's JSON stream list, lowercased.
|
||||
|
||||
JSON, not the flat `default=nw=1` output: there the fields arrive in ffprobe's own struct
|
||||
order, which puts `codec_name` BEFORE `codec_type`, so a line-by-line "remember the last
|
||||
codec_type" parser attributes every name to the PREVIOUS stream. Measured on real downloads:
|
||||
an ordinary H.264+AAC mp4 probed as `v=aac a=None` — the browser-playability check then
|
||||
believed the video stream was AAC and re-encoded a perfectly fine file on every download."""
|
||||
def run_ffprobe_json(
|
||||
path: Path,
|
||||
*,
|
||||
show_streams: bool = False,
|
||||
show_format: bool = False,
|
||||
entries: str | None = None,
|
||||
) -> dict | None:
|
||||
"""One ffprobe call → its parsed JSON dict, or None on any failure (timeout, non-zero exit,
|
||||
missing binary, unparseable output). The SINGLE ffprobe-JSON-invocation point in the downloads
|
||||
package: the browser-playability codec probe here and the upload-ingest probe (`upload._probe`)
|
||||
both go through it, so the subprocess flags/timeout/error handling live in one place instead of
|
||||
two drifting copies."""
|
||||
args = ["ffprobe", "-v", "error"]
|
||||
if show_streams:
|
||||
args.append("-show_streams")
|
||||
if show_format:
|
||||
args.append("-show_format")
|
||||
if entries:
|
||||
args += ["-show_entries", entries]
|
||||
args += ["-of", "json", str(path)]
|
||||
try:
|
||||
streams = json.loads(payload or "{}").get("streams") or []
|
||||
except ValueError:
|
||||
return None, None
|
||||
out = subprocess.run(args, capture_output=True, text=True, timeout=30, check=True).stdout
|
||||
return json.loads(out)
|
||||
except (subprocess.TimeoutExpired, subprocess.CalledProcessError, OSError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _codecs_from_streams(streams: list) -> tuple[str | None, str | None]:
|
||||
"""(video_codec, audio_codec) lowercased from an ffprobe JSON stream list — the first of each kind.
|
||||
|
||||
Parsed from the JSON `streams` list, NOT ffprobe's flat `default=nw=1` output: there the fields
|
||||
arrive in ffprobe's own struct order, which puts `codec_name` BEFORE `codec_type`, so a line-by-line
|
||||
"remember the last codec_type" parser attributes every name to the PREVIOUS stream. Measured on real
|
||||
downloads: an ordinary H.264+AAC mp4 probed as `v=aac a=None` — the browser-playability check then
|
||||
believed the video stream was AAC and re-encoded a perfectly fine file on every download. Reading each
|
||||
stream dict by its own `codec_type` is immune to that ordering."""
|
||||
vcodec = acodec = None
|
||||
for s in streams:
|
||||
name = (s.get("codec_name") or "").lower() or None
|
||||
@@ -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]:
|
||||
"""Return (video_codec, audio_codec) lowercased via one ffprobe call; None for a missing
|
||||
stream or if ffprobe fails (a failed probe is treated as 'leave it alone' upstream)."""
|
||||
try:
|
||||
out = subprocess.run(
|
||||
[
|
||||
"ffprobe",
|
||||
"-v",
|
||||
"error",
|
||||
"-show_entries",
|
||||
"stream=codec_type,codec_name",
|
||||
"-of",
|
||||
"json",
|
||||
str(path),
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
check=True,
|
||||
).stdout
|
||||
except (subprocess.TimeoutExpired, subprocess.CalledProcessError, OSError):
|
||||
data = run_ffprobe_json(path, entries="stream=codec_type,codec_name")
|
||||
if data is None:
|
||||
return None, None
|
||||
return parse_probe_streams(out)
|
||||
return _codecs_from_streams(data.get("streams") or [])
|
||||
|
||||
|
||||
def probe_duration(path: Path) -> int:
|
||||
@@ -510,11 +531,12 @@ def browser_transcode_flags(
|
||||
|
||||
|
||||
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]:
|
||||
"""ffmpeg command that lands `src` as a browser-safe mp4 (faststart): re-encode only the
|
||||
stream(s) that need it (libx264 / aac), stream-copy the rest, always into mp4. `-progress
|
||||
pipe:1` streams machine-readable progress for run_ffmpeg."""
|
||||
pipe:1` streams machine-readable progress for run_ffmpeg. `threads` caps the encoder so a
|
||||
re-encode can't claim every core on the shared box (see the worker-child de-prioritization)."""
|
||||
cmd = [
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
@@ -534,6 +556,8 @@ def build_browser_safe_cmd(
|
||||
else ["-c:v", "copy"]
|
||||
)
|
||||
cmd += ["-c:a", "aac", "-b:a", "192k"] if reencode_audio else ["-c:a", "copy"]
|
||||
if threads and threads > 0:
|
||||
cmd += ["-threads", str(threads)]
|
||||
cmd += ["-movflags", "+faststart", str(dest)]
|
||||
return cmd
|
||||
|
||||
|
||||
@@ -24,6 +24,16 @@ _RULES: tuple[tuple[str, str], ...] = (
|
||||
# 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", "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.
|
||||
("in your country", "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"),
|
||||
("this video is not available", "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 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.
|
||||
`build_ydl_opts` turns a spec into a yt-dlp options dict (format selector + postprocessors).
|
||||
|
||||
spec shape (all keys optional; defaults applied by `normalize`):
|
||||
spec shape (all keys optional; defaults + browser-playability clamp applied by `normalize`):
|
||||
mode "av" (audio+video) | "v" (video-only) | "a" (audio-only)
|
||||
max_height int | null (null = best)
|
||||
container "mp4" | "mkv" | "webm" | null (merge/remux target for av/v)
|
||||
audio_format "m4a" | "mp3" | "opus" | null (extract target for mode "a")
|
||||
vcodec "h264" | "vp9" | "av1" | null (preference, best-effort)
|
||||
container always coerced to "mp4" (the only universally browser-playable container)
|
||||
audio_format "m4a" | "mp3" (mode "a" extract target; anything else — e.g. opus — → "m4a")
|
||||
vcodec always coerced to null → H.264 selected (see `normalize`); no opt-out
|
||||
embed_subs bool embed_chapters bool embed_thumbnail bool sponsorblock bool
|
||||
|
||||
`normalize` clamps container/vcodec/audio_format so EVERY download plays in-browser (E7); the
|
||||
`container`/`vcodec` keys survive only so an old stored profile deserializes without error.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
@@ -50,10 +53,12 @@ _DEFAULTS = {
|
||||
# prefer H.264+AAC (browser/iOS-compatible) rather than yt-dlp's default VP9/Opus best.
|
||||
# v3: mp4 compat now *selects* H.264 (avc1) with a progressive fallback + re-encodes the rare
|
||||
# AV1/VP9-only result to H.264, so cached AV1 assets must re-derive (fixes iOS broken-player).
|
||||
_SIG_VERSION = 3
|
||||
# v4: EVERY download is clamped to the mp4/H.264/AAC compat profile (no more mkv/webm/VP9/AV1/Opus
|
||||
# opt-out), so any asset cached under an old non-compat spec — or a stale non-H.264 mp4 that slipped
|
||||
# the v3 net — must re-derive through the current browser-safe guarantee.
|
||||
_SIG_VERSION = 4
|
||||
|
||||
_SPONSORBLOCK_CATEGORIES = ["sponsor", "selfpromo", "interaction"]
|
||||
_VCODEC_SORT = {"h264": "vcodec:h264", "vp9": "vcodec:vp9", "av1": "vcodec:av01"}
|
||||
|
||||
|
||||
def normalize(spec: dict) -> dict:
|
||||
@@ -76,6 +81,18 @@ def normalize(spec: dict) -> dict:
|
||||
# Audio-only files can't embed subtitles/thumbnails as video; keep the flags meaningful.
|
||||
if s["mode"] == "a":
|
||||
s["embed_subs"] = False
|
||||
# Browser-playability guarantee (E7): the Download module exists to download → share → PLAY
|
||||
# in-browser, so every output must play in EVERY browser, iOS/Safari/WebKit included. The only
|
||||
# universally-safe combo is mp4 + H.264 + AAC/MP3, so we CLAMP the spec to it HERE — the single
|
||||
# point every path (picker, stored profile, raw API) flows through — instead of only hiding
|
||||
# options in the UI. Non-mp4 containers (mkv doesn't play at all; webm/VP9/AV1 fail iOS) and any
|
||||
# explicit vcodec are coerced to the compat profile; Opus audio (Safari-flaky as a bare file)
|
||||
# falls back to AAC/m4a. A source that exposes no H.264 is still caught by the worker's
|
||||
# browser-safe re-encode (`_ensure_browser_playable`), so playback is guaranteed end-to-end.
|
||||
s["container"] = "mp4"
|
||||
s["vcodec"] = None
|
||||
if s["audio_format"] not in ("m4a", "mp3"):
|
||||
s["audio_format"] = "m4a"
|
||||
return s
|
||||
|
||||
|
||||
@@ -98,12 +115,17 @@ def build_ydl_opts(
|
||||
outtmpl: str,
|
||||
progress_hook,
|
||||
extra: dict | None = None,
|
||||
cookiefile: str | None = None,
|
||||
) -> dict:
|
||||
"""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
|
||||
`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)
|
||||
h = s["max_height"]
|
||||
opts: dict = {
|
||||
@@ -132,6 +154,11 @@ def build_ydl_opts(
|
||||
if 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":
|
||||
opts["format"] = "bestaudio/best"
|
||||
opts["postprocessors"].append(
|
||||
@@ -143,51 +170,33 @@ def build_ydl_opts(
|
||||
)
|
||||
else:
|
||||
hcap = f"[height<=?{h}]" if h else ""
|
||||
# Compatibility-first for mp4 (the universal container): produce H.264 video + AAC audio
|
||||
# so shared/downloaded files play in EVERY browser, including iOS/Safari. WebKit (which
|
||||
# every iOS browser, Brave included, is forced to use) decodes only H.264/H.265 + AAC
|
||||
# inside mp4 — a VP9/AV1-in-mp4 file shows a broken player there, even though Chromium
|
||||
# plays it fine. A custom profile that explicitly sets `vcodec` opts out.
|
||||
compat = s["container"] == "mp4" and s["vcodec"] is None
|
||||
# Every video download is the mp4/H.264 compat profile (normalize() clamps container→mp4,
|
||||
# vcodec→None), so shared/downloaded files play in EVERY browser, iOS/Safari included. WebKit
|
||||
# (which every iOS browser, Brave included, is forced to use) decodes only H.264/H.265 + AAC
|
||||
# inside mp4 — a VP9/AV1-in-mp4 file shows a broken player there — so H.264 is selected at the
|
||||
# SELECTION level, not merely preferred: a sort key can't conjure an H.264 stream a source
|
||||
# doesn't expose as video-only. A source that ships only non-H.264 is caught after download by
|
||||
# the worker's browser-safe re-encode (`_ensure_browser_playable`).
|
||||
if s["mode"] == "v":
|
||||
if compat:
|
||||
# Video-only: keep it audio-free. Prefer an H.264 *video-only* stream, then any
|
||||
# video-only, and only fall to a progressive (audio-bearing) `best` as a last
|
||||
# resort — same audio-free-when-possible guarantee as the old `bestvideo/best`.
|
||||
opts["format"] = (
|
||||
f"bestvideo[vcodec^=avc1]{hcap}/bestvideo{hcap}"
|
||||
f"/best[vcodec^=avc1]{hcap}/best{hcap}"
|
||||
)
|
||||
else:
|
||||
opts["format"] = f"bestvideo{hcap}/best{hcap}"
|
||||
# Video-only: keep it audio-free. Prefer an H.264 video-only stream, then any video-only,
|
||||
# and only fall to a progressive (audio-bearing) `best` as a last resort.
|
||||
opts["format"] = (
|
||||
f"bestvideo[vcodec^=avc1]{hcap}/bestvideo{hcap}/best[vcodec^=avc1]{hcap}/best{hcap}"
|
||||
)
|
||||
else: # av
|
||||
if compat:
|
||||
# Prefer H.264 at the SELECTION level, not just via format_sort: a sort key is only
|
||||
# a *preference* and can't conjure an H.264 stream a source doesn't expose as
|
||||
# video-only. A non-YouTube source (e.g. Facebook) may ship AV1 as the best
|
||||
# video-only stream and H.264 only as a *progressive* (a+v) format — which
|
||||
# `bestvideo` skips — so `bestvideo+bestaudio` picked AV1 and iOS broke. This chain
|
||||
# tries avc1 video-only, then the avc1 progressive, then anything.
|
||||
opts["format"] = (
|
||||
f"bestvideo[vcodec^=avc1]{hcap}+bestaudio[acodec^=mp4a]"
|
||||
f"/best[vcodec^=avc1]{hcap}"
|
||||
f"/bestvideo{hcap}+bestaudio/best{hcap}"
|
||||
)
|
||||
else:
|
||||
opts["format"] = f"bestvideo{hcap}+bestaudio/best{hcap}"
|
||||
if s["container"]:
|
||||
opts["merge_output_format"] = s["container"]
|
||||
sort = []
|
||||
# Keep the codec preference as a tiebreaker (ranks H.264/AAC above resolution among the
|
||||
# selected candidates); the selector above is the hard guarantee, this is belt-and-braces.
|
||||
if compat:
|
||||
sort += ["vcodec:h264", "acodec:aac"]
|
||||
# avc1 video-only + m4a audio, then the avc1 progressive, then anything (re-encoded).
|
||||
opts["format"] = (
|
||||
f"bestvideo[vcodec^=avc1]{hcap}+bestaudio[acodec^=mp4a]"
|
||||
f"/best[vcodec^=avc1]{hcap}"
|
||||
f"/bestvideo{hcap}+bestaudio/best{hcap}"
|
||||
)
|
||||
opts["merge_output_format"] = "mp4"
|
||||
# Codec preference tiebreaker (ranks H.264/AAC among the selected candidates); the selector
|
||||
# above is the hard guarantee, this is belt-and-braces.
|
||||
sort = ["vcodec:h264", "acodec:aac"]
|
||||
if h:
|
||||
sort.append(f"res:{h}")
|
||||
if s["vcodec"] in _VCODEC_SORT:
|
||||
sort.append(_VCODEC_SORT[s["vcodec"]])
|
||||
if sort:
|
||||
opts["format_sort"] = sort
|
||||
opts["format_sort"] = sort
|
||||
|
||||
# SponsorBlock first (marks segments), then strip them from the media + chapters.
|
||||
if s["sponsorblock"]:
|
||||
|
||||
@@ -86,11 +86,25 @@ def _sweep_orphan_files(db: Session, download_root: str) -> int:
|
||||
|
||||
cutoff = time.time() - _ORPHAN_MIN_AGE_S
|
||||
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():
|
||||
if entry.name.startswith("."): # .edits / .plex-* / .staging / .storyboards + any dotfile
|
||||
continue
|
||||
files = [entry] if entry.is_file() else [p for p in entry.rglob("*") if p.is_file()]
|
||||
for f in files:
|
||||
_throttle()
|
||||
try:
|
||||
if f.resolve() in keep or f.stat().st_mtime > cutoff:
|
||||
continue
|
||||
|
||||
@@ -80,9 +80,13 @@ def public_meta(
|
||||
|
||||
Resolves the owner's per-download display overrides (custom title/channel) over the asset's
|
||||
auto-extracted values, and surfaces the source + any extra reference links as clickable URLs."""
|
||||
from app.downloads import service
|
||||
from app.downloads import mediakind, service
|
||||
|
||||
source_url = service.reference_url(job, asset)
|
||||
# Audio-only assets (mp3 rips, extracted audio) can't play inside a <video> element on WebKit
|
||||
# (all iOS browsers), so the watch page renders a native <audio> instead. A generic uploaded
|
||||
# file (media_kind='file') has no player at all — the watch page shows file info + a download.
|
||||
# Both branches read the stored media_kind (the single source), not a re-derived codec check.
|
||||
return {
|
||||
"title": (job and job.display_name) or asset.title,
|
||||
"uploader": (job and job.display_uploader) or asset.uploader,
|
||||
@@ -92,6 +96,10 @@ def public_meta(
|
||||
"duration_s": asset.duration_s,
|
||||
"width": asset.width,
|
||||
"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,
|
||||
"file_url": file_url,
|
||||
"poster_url": poster_url,
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
"""The media-kind discriminator for a library asset — the single home of one invariant.
|
||||
|
||||
`MediaAsset.media_kind` is `"video" | "audio" | "file"`. It decides which library actions apply:
|
||||
media (video/audio) gets play/edit/share; a generic uploaded file gets download + share only, and
|
||||
is never rendered inline (see the force-download serving in the file/watch routes). Every finalize
|
||||
path computes it HERE — the download worker + the upload ingest via `derive`, the editor by
|
||||
carrying the source's kind over — so the rule lives in code, not in a comment that can drift (an
|
||||
earlier ad-hoc `vcodec is None and acodec is not None` audio check lived only in links.py).
|
||||
"""
|
||||
|
||||
VIDEO = "video"
|
||||
AUDIO = "audio"
|
||||
FILE = "file"
|
||||
|
||||
|
||||
def derive(vcodec: str | None, acodec: str | None) -> str:
|
||||
"""Media kind from the probed codecs: a video stream -> video, audio-only -> audio, neither
|
||||
(a non-media upload: pdf/zip/docx/…) -> file."""
|
||||
if vcodec:
|
||||
return VIDEO
|
||||
if acodec:
|
||||
return AUDIO
|
||||
return FILE
|
||||
@@ -8,13 +8,14 @@ from datetime import date, datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import yt_dlp
|
||||
from sqlalchemy import select
|
||||
|
||||
from app import sysconfig
|
||||
from app.config import settings
|
||||
from app.db import SessionLocal
|
||||
from app.downloads import edit as editmod
|
||||
from app.downloads import errors as dlerrors
|
||||
from app.downloads import adapters, formats, service, storage
|
||||
from app.downloads import adapters, credentials, formats, mediakind, service, storage
|
||||
from app.downloads.jobdb import (
|
||||
_edit_staging_dir,
|
||||
_job_status,
|
||||
@@ -304,10 +305,13 @@ def _media_info(info: dict) -> dict:
|
||||
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
|
||||
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()
|
||||
outtmpl = str(staging / "%(id)s.%(ext)s")
|
||||
last_exc: Exception | None = None
|
||||
@@ -317,6 +321,7 @@ def _extract_with_fallback(job_id, asset_id, url, spec, staging, adapter):
|
||||
outtmpl,
|
||||
_make_progress_hook(job_id, asset_id),
|
||||
extra=adapter.ydl_opts_extra(clients),
|
||||
cookiefile=cookiefile,
|
||||
)
|
||||
opts["postprocessor_hooks"] = [_make_pp_hook(job_id)]
|
||||
_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%.
|
||||
total_s = int(info["duration"]) if info.get("duration") else editmod.probe_duration(produced)
|
||||
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,
|
||||
on_progress=_ffmpeg_progress_writer(job_id, "processing"),
|
||||
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
|
||||
|
||||
|
||||
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)
|
||||
cookiefile: Path | None = None
|
||||
try:
|
||||
if staging.exists():
|
||||
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)
|
||||
adapter = adapters.resolve_adapter(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
|
||||
# identifies post-extraction) so the .nfo uniqueid type + uploader_url agree on one source.
|
||||
adapter = adapters.resolve_adapter(source_kind, source_ref, info)
|
||||
@@ -454,6 +484,7 @@ def _download(job_id: int, asset_id: int, source_kind: str, source_ref: str, spe
|
||||
asset.acodec = (
|
||||
info.get("acodec") if info.get("acodec") not in (None, "none") else None
|
||||
)
|
||||
asset.media_kind = mediakind.derive(asset.vcodec, asset.acodec)
|
||||
asset.title = meta.title
|
||||
asset.uploader = meta.uploader
|
||||
asset.uploader_url = adapter.uploader_url(info)
|
||||
@@ -515,6 +546,10 @@ def _download(job_id: int, asset_id: int, source_kind: str, source_ref: str, spe
|
||||
db.commit()
|
||||
log.warning("job %s failed: %s", job_id, msg)
|
||||
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)
|
||||
|
||||
|
||||
|
||||
@@ -122,6 +122,22 @@ def check_enqueue(db: Session, user_id: int) -> None:
|
||||
raise QuotaExceeded("max_bytes", lim.max_bytes, used)
|
||||
|
||||
|
||||
def check_upload(db: Session, user_id: int, add_bytes: int) -> None:
|
||||
"""Like check_enqueue, but for an upload whose size is KNOWN at ingest: also reject when adding
|
||||
this file would push the footprint over max_bytes. A download can't pre-check its size (unknown
|
||||
until the worker finishes) so check_enqueue only compares the current footprint; an upload can,
|
||||
so it's charged against the cap before it lands. Raises QuotaExceeded(max_jobs|max_bytes)."""
|
||||
lim = resolve(db, user_id)
|
||||
if lim.unlimited:
|
||||
return
|
||||
n = active_jobs(db, user_id)
|
||||
if n >= lim.max_jobs:
|
||||
raise QuotaExceeded("max_jobs", lim.max_jobs, n)
|
||||
projected = footprint(db, user_id) + add_bytes
|
||||
if projected > lim.max_bytes:
|
||||
raise QuotaExceeded("max_bytes", lim.max_bytes, projected)
|
||||
|
||||
|
||||
def usage(db: Session, user_id: int) -> dict:
|
||||
"""Compact usage snapshot for the UI (footprint + counts vs limits)."""
|
||||
lim = resolve(db, user_id)
|
||||
|
||||
@@ -12,16 +12,48 @@ progress hook), then exits 0. An unexpected crash exits non-zero and the parent
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from app.config import settings
|
||||
from app.db import SessionLocal
|
||||
from app.downloads import edit as editmod
|
||||
from app.models import DownloadJob
|
||||
|
||||
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:
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
_deprioritize()
|
||||
if len(sys.argv) != 2:
|
||||
print("usage: python -m app.downloads.runner <job_id>", file=sys.stderr)
|
||||
raise SystemExit(2)
|
||||
@@ -35,6 +67,7 @@ def main() -> None:
|
||||
job_kind = job.job_kind
|
||||
spec = dict(job.profile_snapshot or {})
|
||||
source_kind, source_ref = job.source_kind, job.source_ref
|
||||
user_id = job.user_id
|
||||
if asset_id is None:
|
||||
return
|
||||
|
||||
@@ -45,7 +78,7 @@ def main() -> None:
|
||||
if job_kind == "edit":
|
||||
_process_edit(job_id, asset_id)
|
||||
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__":
|
||||
|
||||
@@ -15,8 +15,9 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import settings
|
||||
from app.downloads import adapters
|
||||
from app.downloads import credentials
|
||||
from app.downloads import edit as editmod
|
||||
from app.downloads import formats, quota, storage
|
||||
from app.downloads import formats, mediakind, quota, storage
|
||||
from app.downloads.status import HOLDING, JobStatus
|
||||
from app.models import Channel, DownloadJob, MediaAsset, Video
|
||||
|
||||
@@ -155,7 +156,12 @@ def enqueue(
|
||||
Raises quota.QuotaExceeded if the user is at their job-count or footprint cap."""
|
||||
quota.check_enqueue(db, user_id)
|
||||
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)
|
||||
# Show a real title + thumbnail the moment it's queued (feed downloads are catalog videos).
|
||||
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
|
||||
if src_asset is None or src_asset.status != "ready" or not src_asset.rel_path:
|
||||
raise EditError("source_not_ready")
|
||||
# A generic uploaded file (media_kind='file') has no timeline to trim/crop — the editor path is
|
||||
# media-only. Reject here so an upload's file never reaches the ffmpeg edit worker.
|
||||
if src_asset.media_kind == mediakind.FILE:
|
||||
raise EditError("not_media")
|
||||
|
||||
spec = editmod.normalize_edit_spec(edit_spec)
|
||||
if not spec:
|
||||
@@ -226,6 +236,9 @@ def enqueue_edit(
|
||||
asset.thumbnail_url = src_asset.thumbnail_url
|
||||
asset.upload_date = src_asset.upload_date
|
||||
asset.duration_s = editmod.clip_duration(spec, src_asset.duration_s)
|
||||
# A clip inherits its source's kind (a video edit stays video, an audio trim stays audio);
|
||||
# the ffmpeg edit worker doesn't re-probe codecs, so set it here where the source is known.
|
||||
asset.media_kind = src_asset.media_kind or mediakind.VIDEO
|
||||
|
||||
job = DownloadJob(
|
||||
user_id=user_id,
|
||||
|
||||
@@ -19,6 +19,8 @@ from datetime import date
|
||||
from pathlib import Path
|
||||
from xml.sax.saxutils import escape
|
||||
|
||||
from app.downloads import mediakind
|
||||
|
||||
# Reserve room for the "_id.ext" suffix + parent dirs within the 255-char component limit.
|
||||
_MAX_TITLE = 120
|
||||
# User-facing (Content-Disposition) names can be longer than an on-disk component.
|
||||
@@ -147,6 +149,81 @@ def edit_rel_path(user_id: int, asset_id: int, ext: str) -> str:
|
||||
return f".edits/{user_id}/clip_{asset_id}.{ext}"
|
||||
|
||||
|
||||
def _clean_upload_ext(filename: str) -> str:
|
||||
"""The upload's extension reduced to a shell-safe, lowercased, bounded token (``""`` when the
|
||||
upload has no extension). The SINGLE definition feeding both the on-disk suffix and the stored
|
||||
`container` — they must agree (the served Content-Disposition suffix has to match the container),
|
||||
so the sanitize/truncation rule lives in one place, not two drifting copies."""
|
||||
return _UNSAFE.sub("", Path(filename).suffix.lstrip(".")).lower()[:16]
|
||||
|
||||
|
||||
def upload_rel_path(user_id: int, asset_id: int, filename: str) -> str:
|
||||
"""On-disk path (relative to DOWNLOAD_ROOT) for a locally-uploaded file.
|
||||
|
||||
Kept in a per-user `.uploads/` tree — device-downloadable via the file endpoint, but NOT part of
|
||||
the Plex library (an upload is arbitrary user content, not a canonical episode; no .nfo/poster
|
||||
episode sidecars). The name is system-decided and bound to the asset id (`_a{id}` keeps two
|
||||
same-named uploads apart); the original filename is display metadata only. The extension is
|
||||
preserved (shell-safe, bounded) so the served file + its Content-Disposition carry a correct
|
||||
suffix; an extensionless upload stays extensionless."""
|
||||
stem = sanitize(Path(filename).stem, 80)
|
||||
ext = _clean_upload_ext(filename)
|
||||
suffix = f".{ext}" if ext else ""
|
||||
return f".uploads/{user_id}/{stem}_a{asset_id}{suffix}"
|
||||
|
||||
|
||||
def upload_ext(filename: str) -> str | None:
|
||||
"""The stored `container` for an uploaded file: its shell-safe lowercased extension, or None when
|
||||
the upload has no extension (a bare name). Shares `_clean_upload_ext` with `upload_rel_path` so the
|
||||
stored container and the on-disk suffix can never diverge."""
|
||||
return _clean_upload_ext(filename) or None
|
||||
|
||||
|
||||
# The Content-Type used to serve an inline-playable media asset, keyed by its stored `container`.
|
||||
# DELIBERATELY a strict allow-list of known audio/video containers: the served type is chosen from
|
||||
# the asset's ffprobe-verified media_kind + this table, NEVER guessed from the raw (user-controlled)
|
||||
# upload extension. Without this, an uploaded media/HTML polyglot named "evil.html" — a real MP3/MP4
|
||||
# that ffprobe classifies as media, so it dodges the generic-file force-download — would be served
|
||||
# with a Content-Type Starlette guesses from ".html" (text/html) and execute as stored XSS on the
|
||||
# serving origin. A media asset whose container isn't in this table falls back to a neutral
|
||||
# force-download attachment (see `served_media_type`), so nothing script-executable is ever inline.
|
||||
_MEDIA_MIME: dict[str, str] = {
|
||||
# video containers
|
||||
"mp4": "video/mp4",
|
||||
"m4v": "video/mp4",
|
||||
"mov": "video/quicktime",
|
||||
"webm": "video/webm",
|
||||
"mkv": "video/x-matroska",
|
||||
"ogv": "video/ogg",
|
||||
"avi": "video/x-msvideo",
|
||||
# audio containers
|
||||
"m4a": "audio/mp4",
|
||||
"mp3": "audio/mpeg",
|
||||
"aac": "audio/aac",
|
||||
"ogg": "audio/ogg",
|
||||
"oga": "audio/ogg",
|
||||
"opus": "audio/ogg",
|
||||
"weba": "audio/webm",
|
||||
"wav": "audio/wav",
|
||||
"flac": "audio/flac",
|
||||
}
|
||||
|
||||
|
||||
def served_media_type(media_kind: str | None, container: str | None) -> str | None:
|
||||
"""The Content-Type a library file MUST be served with — a real audio/video MIME for a recognised
|
||||
media container, else ``None`` meaning "serve as a neutral force-download attachment".
|
||||
|
||||
Returns None for a generic ``file`` kind AND for any media whose container we don't positively
|
||||
recognise as safe. Callers serve None as ``application/octet-stream`` + ``attachment`` so the
|
||||
served Content-Type is NEVER derived from the (attacker-controlled) file extension — the guard
|
||||
that stops uploaded HTML/SVG or a media/HTML polyglot from executing inline on the serving
|
||||
origin. A ``None`` media_kind (a legacy asset before the discriminator was backfilled) is treated
|
||||
as media so an old download keeps its inline player."""
|
||||
if media_kind == mediakind.FILE:
|
||||
return None
|
||||
return _MEDIA_MIME.get((container or "").lower())
|
||||
|
||||
|
||||
def download_filename(display_name: str, container: str | None, path: Path) -> str:
|
||||
"""The Content-Disposition filename for a served download: the cleaned display name carrying a single
|
||||
correct extension — the asset container, else the file's own suffix — without doubling it when the
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
"""Local file ingest for the download center (E7 S5) — the non-yt-dlp path into the media library.
|
||||
|
||||
A user-uploaded file joins the SAME library as a downloaded one, so it inherits the whole function
|
||||
set already built (metadata chips, share links, and — for media — in-app play/edit). Unlike a
|
||||
download it bypasses yt-dlp AND the worker entirely: the file is already on the host, so ingest
|
||||
probes it, places it on disk, and creates an already-`ready` MediaAsset + `done` DownloadJob in one
|
||||
step — the same "the file exists, mark it done now" shortcut `service.enqueue` takes on a cache hit.
|
||||
|
||||
Kinds (the invariant lives in `mediakind`): a probe with a real video stream -> video, audio-only
|
||||
-> audio, neither -> a generic 'file' (pdf/zip/docx/…) that is download-only and never rendered
|
||||
inline (see the force-download serving in the file/watch routes). This module is the ingest seam a
|
||||
future sub-function (e.g. convert) plugs a new adapter into, mirroring how it sidesteps the worker.
|
||||
|
||||
An upload does NOT get an `expires_at`: it's user-provided and may be irreplaceable (there's no
|
||||
source to re-download), so it doesn't age out on the retention clock the way a re-fetchable download
|
||||
does — it lives until the user deletes it.
|
||||
"""
|
||||
|
||||
import secrets
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import settings
|
||||
from app.downloads import edit as editmod
|
||||
from app.downloads import mediakind, quota, service, storage
|
||||
from app.models import DownloadJob, MediaAsset
|
||||
|
||||
# ffprobe container formats that mean "this is a still image / image sequence", NOT playable media.
|
||||
# A standalone jpg/png/gif/webp/bmp reports a real video stream (codec mjpeg/png/…) with no
|
||||
# `attached_pic` flag, so without this guard it would classify as media_kind='video' and get a Play
|
||||
# button + a broken in-app <video> player (and inline serving on the public share). Keyed on the
|
||||
# container format so an animated gif/webp also lands as a generic 'file' (no browser plays those in
|
||||
# a <video> either). SVG and anything else ffprobe can't decode already fails the probe → 'file'.
|
||||
_IMAGE_FORMATS = {
|
||||
"image2",
|
||||
"image2pipe",
|
||||
"png_pipe",
|
||||
"mjpeg",
|
||||
"mjpeg_pipe",
|
||||
"jpeg_pipe",
|
||||
"gif",
|
||||
"webp_pipe",
|
||||
"bmp_pipe",
|
||||
"tiff_pipe",
|
||||
"ico",
|
||||
"svg_pipe",
|
||||
}
|
||||
|
||||
|
||||
def _probe(path: Path) -> tuple[str | None, str | None, int | None, int | None, int | None]:
|
||||
"""(vcodec, acodec, width, height, duration_s) from one ffprobe call; all None if ffprobe fails
|
||||
or the file isn't playable media (an unprobeable file — or a still image — is treated as a generic
|
||||
non-media upload, so it becomes a download-only 'file' rather than broken 'video').
|
||||
|
||||
A video stream flagged `attached_pic` (embedded cover art in an mp3/m4a) is skipped — it's a
|
||||
thumbnail, not a real video track, so an audio file with album art still classifies as audio."""
|
||||
data = editmod.run_ffprobe_json(path, show_streams=True, show_format=True)
|
||||
if data is None:
|
||||
return None, None, None, None, None
|
||||
|
||||
fmt = data.get("format") or {}
|
||||
fmt_names = {n.strip() for n in (fmt.get("format_name") or "").split(",")}
|
||||
if fmt_names & _IMAGE_FORMATS:
|
||||
# A still image / image sequence is a generic file, not video — no vcodec means media_kind='file'.
|
||||
return None, None, None, None, None
|
||||
|
||||
vcodec = acodec = None
|
||||
width = height = None
|
||||
for stream in data.get("streams", []):
|
||||
kind = stream.get("codec_type")
|
||||
if kind == "video" and vcodec is None:
|
||||
if (stream.get("disposition") or {}).get("attached_pic"):
|
||||
continue # cover art, not a video track
|
||||
vcodec = stream.get("codec_name")
|
||||
width = stream.get("width")
|
||||
height = stream.get("height")
|
||||
elif kind == "audio" and acodec is None:
|
||||
acodec = stream.get("codec_name")
|
||||
|
||||
duration = None
|
||||
try:
|
||||
raw = fmt.get("duration")
|
||||
if raw is not None:
|
||||
duration = int(float(raw))
|
||||
except (TypeError, ValueError):
|
||||
duration = None
|
||||
return vcodec, acodec, width, height, duration
|
||||
|
||||
|
||||
def _title_from_filename(filename: str) -> str:
|
||||
"""A human display title from the original upload name: the stem with separators tidied to
|
||||
spaces (kept as-is otherwise — this is display metadata, not the shell-safe on-disk name)."""
|
||||
stem = Path(filename).stem.replace("_", " ").replace(".", " ").strip()
|
||||
return (stem or "Upload")[:255]
|
||||
|
||||
|
||||
def ingest_upload(
|
||||
db: Session,
|
||||
user_id: int,
|
||||
*,
|
||||
filename: str,
|
||||
src_path: Path,
|
||||
size: int,
|
||||
display_name: str | None = None,
|
||||
) -> DownloadJob:
|
||||
"""Ingest an already-uploaded file at `src_path` into `user_id`'s library and return the `done`
|
||||
job. `src_path` is MOVED into place on success (the caller stages it to a temp file first).
|
||||
|
||||
Raises quota.QuotaExceeded (holdings/footprint cap) — checked BEFORE any disk write, so a
|
||||
rejected upload leaves nothing behind and the caller just cleans up its temp file."""
|
||||
quota.check_upload(db, user_id, size) # the file's size is known → charge it up front
|
||||
|
||||
vcodec, acodec, width, height, duration = _probe(src_path)
|
||||
kind = mediakind.derive(vcodec, acodec)
|
||||
|
||||
# An upload never dedups (each file is its own asset): a random ref makes the cache key unique,
|
||||
# so `format_sig` is a constant marker rather than a format hash.
|
||||
ref = f"{user_id}:{secrets.token_hex(8)}"[:512]
|
||||
asset = MediaAsset(
|
||||
source_kind="upload", source_ref=ref, format_sig="upload", status="pending", ref_count=0
|
||||
)
|
||||
db.add(asset)
|
||||
db.flush() # need asset.id for the on-disk path
|
||||
|
||||
rel = storage.upload_rel_path(user_id, asset.id, filename)
|
||||
storage.place_file(src_path, settings.download_root, rel)
|
||||
# From here the file is on disk under `.uploads/`. If the transaction fails before it commits, the
|
||||
# asset/job rows roll back but the placed file would be ORPHANED — nothing references it, the
|
||||
# per-user delete path keys off asset.rel_path (which is gone), and the GC orphan sweep skips the
|
||||
# dot-prefixed `.uploads/` tree. So roll back the on-disk side too on any failure.
|
||||
try:
|
||||
final = storage.abs_path(settings.download_root, rel)
|
||||
actual_size = final.stat().st_size if final.exists() else size
|
||||
|
||||
asset.status = "ready"
|
||||
asset.rel_path = rel
|
||||
asset.size_bytes = actual_size
|
||||
asset.container = storage.upload_ext(filename)
|
||||
asset.vcodec = vcodec
|
||||
asset.acodec = acodec
|
||||
asset.width = width
|
||||
asset.height = height
|
||||
asset.duration_s = duration
|
||||
asset.media_kind = kind
|
||||
asset.title = (display_name or "").strip() or _title_from_filename(filename)
|
||||
asset.last_access_at = datetime.now(timezone.utc)
|
||||
# No expires_at — see the module docstring (an upload is irreplaceable, so it doesn't age out).
|
||||
if kind == mediakind.VIDEO:
|
||||
# A frame poster gives the library row a thumbnail + the share link an og:image. Audio has
|
||||
# no frame and a generic file no player, so neither gets one. Best-effort (ffmpeg decodes
|
||||
# server side even for a codec the browser can't play).
|
||||
asset.poster_path = editmod.ensure_poster(asset, settings.download_root)
|
||||
|
||||
job = DownloadJob(
|
||||
user_id=user_id,
|
||||
asset_id=asset.id,
|
||||
job_kind="upload",
|
||||
source_kind="upload",
|
||||
source_ref=ref,
|
||||
profile_snapshot={},
|
||||
display_name=(display_name or "").strip() or None,
|
||||
status="done",
|
||||
progress=100,
|
||||
queue_pos=service.next_queue_pos(db),
|
||||
)
|
||||
db.add(job)
|
||||
service.incr_ref_count(db, asset.id)
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
# The rows are gone; remove the now-unreferenced file so no bytes leak. Clean the poster by its
|
||||
# DETERMINISTIC path (rel with a .jpg suffix, the same path ensure_poster writes to) rather than
|
||||
# ensure_poster's return value: it writes the frame directly to that path and returns None on an
|
||||
# ffmpeg failure, so a partial poster can be on disk while the return is None — gating on the
|
||||
# return would skip that orphan. unlink is missing_ok, so the poster path is a harmless no-op
|
||||
# when none was generated (audio/file, or a video whose poster never started).
|
||||
for leaked in (rel, str(Path(rel).with_suffix(".jpg"))):
|
||||
storage.abs_path(settings.download_root, leaked).unlink(missing_ok=True)
|
||||
raise
|
||||
db.refresh(job)
|
||||
return job
|
||||
@@ -551,6 +551,10 @@ class Playlist(Base, TimestampMixin, UpdatedAtMixin):
|
||||
# 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).
|
||||
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")
|
||||
|
||||
items: Mapped[list["PlaylistItem"]] = relationship(
|
||||
@@ -704,6 +708,12 @@ class MediaAsset(Base, TimestampMixin):
|
||||
container: Mapped[str | None] = mapped_column(String(16))
|
||||
vcodec: Mapped[str | None] = mapped_column(String(32))
|
||||
acodec: Mapped[str | None] = mapped_column(String(32))
|
||||
# "video" | "audio" | "file" — the media-kind discriminator that drives which library actions
|
||||
# apply (play/edit for media; download-only for a generic uploaded file). Null while an asset is
|
||||
# still pending (kind is known once the file is probed at finalize). It is the single stored home
|
||||
# of the invariant `downloads.mediakind.derive` used to compute — never re-derived in prose (an
|
||||
# earlier ad-hoc `vcodec is None and acodec` check lived only in links.py; consolidated here).
|
||||
media_kind: Mapped[str | None] = mapped_column(String(16))
|
||||
nfo_written: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||
# Set once the GC has warned holders of the upcoming expiry, so it doesn't warn every run.
|
||||
gc_notified: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||
@@ -867,6 +877,35 @@ class DownloadLink(Base, TimestampMixin):
|
||||
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):
|
||||
"""A user's end-to-end-encryption key material for direct messaging (phase 2).
|
||||
|
||||
|
||||
@@ -43,8 +43,10 @@ def rollup(cards: list[dict]) -> dict:
|
||||
def agg_subquery(db: Session, user_id: int, include_resumed: bool = False):
|
||||
"""Per-show episode counts for a user: total, watched, in-progress. Reused to (a) filter the show
|
||||
grid by aggregate watch-state, (b) compute each card's badge, and (c) drive the Discover
|
||||
continue-watching rail. `include_resumed` adds `resumed_at` = max(progress_updated_at) across the
|
||||
show's episodes, so the rail can order shows by how recently they were last progressed."""
|
||||
continue-watching rail, and (d) the browse "last watched" sort. `include_resumed` adds `resumed_at`
|
||||
= max(coalesce(progress_updated_at, watched_at)) across the show's episodes — i.e. how recently ANY
|
||||
episode was last touched (resumed OR marked watched) — so callers can order shows by recency. A
|
||||
fully-watched show (no resume progress) still gets a timestamp from its episodes' watched_at."""
|
||||
ps = aliased(PlexState)
|
||||
cols = [
|
||||
PlexItem.show_id.label("show_id"),
|
||||
@@ -63,7 +65,9 @@ def agg_subquery(db: Session, user_id: int, include_resumed: bool = False):
|
||||
).label("inprog"),
|
||||
]
|
||||
if include_resumed:
|
||||
cols.append(func.max(ps.progress_updated_at).label("resumed_at"))
|
||||
cols.append(
|
||||
func.max(func.coalesce(ps.progress_updated_at, ps.watched_at)).label("resumed_at")
|
||||
)
|
||||
return (
|
||||
select(*cols)
|
||||
.outerjoin(ps, and_(ps.item_id == PlexItem.id, ps.user_id == user_id))
|
||||
|
||||
+220
-25
@@ -3,7 +3,10 @@
|
||||
Playback is from the LOCAL physical file. Browser-compatible files (playable="direct") are served
|
||||
raw with HTTP range requests. Everything else that is h264 video (playable="remux") is remuxed on
|
||||
the fly to HLS with **video stream-copy** (cheap, I/O-bound — no video re-encode) and audio to AAC
|
||||
when needed. HEVC/VP9 (playable="transcode") needs a full re-encode and is deferred to P3.
|
||||
when needed. Non-h264 video (playable="transcode": HEVC/VP9/AV1/MPEG-2/XviD…) needs a full re-encode
|
||||
to H.264: on the prod host that runs on the Intel iGPU via VAAPI (`h264_vaapi`, near-zero CPU), with
|
||||
software libx264 as the automatic fallback where the render node is absent or the source codec isn't
|
||||
hardware-decodable. Same HLS session/seek machinery either way — only the video encoder differs.
|
||||
|
||||
Seek model (Jellyfin-style): one ffmpeg session per item, started at a given offset via `-ss`
|
||||
(fast keyframe seek). A seek beyond the generated region restarts the session at the new offset,
|
||||
@@ -16,6 +19,7 @@ CPU-only prod host; only P3's full transcode is CPU-heavy.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
@@ -27,6 +31,7 @@ from sqlalchemy.orm import Session as DbSession
|
||||
|
||||
from app import sysconfig
|
||||
from app.config import settings
|
||||
from app.downloads.edit import encode_thread_cap
|
||||
from app.models import PlexItem
|
||||
from app.plex import paths
|
||||
|
||||
@@ -38,6 +43,17 @@ _HLS_ROOT = (
|
||||
else Path(settings.download_root) / ".plex-hls"
|
||||
)
|
||||
_SEG_SECONDS = 6
|
||||
# Cold-start warm-up. A freshly-spawned ffmpeg has produced only the first segment (or two) by the
|
||||
# time we could report the session ready; hls.js parses the EVENT playlist, drains that tiny buffer,
|
||||
# then STALLS a few seconds into playback waiting for the next segment the still-warming ffmpeg
|
||||
# hasn't written yet. Very visible on auto-advance to the next episode (start_s==0, which previously
|
||||
# returned with ZERO buffer). So before reporting ready we wait for the first few segments to exist:
|
||||
# the client then starts with ~_WARMUP_SEGMENTS×_SEG_SECONDS of buffer and, since stream-copy runs
|
||||
# faster than realtime, ffmpeg stays ahead from there and never stalls again. Best-effort and bounded
|
||||
# — we stop at the first segment that isn't ready within the per-segment budget (a slow miss is no
|
||||
# worse than the old zero-buffer behaviour).
|
||||
_WARMUP_SEGMENTS = 3
|
||||
_WARMUP_SEG_TIMEOUT_S = 8.0
|
||||
_SESSION_IDLE_S = 600 # reap a session with no access for this long
|
||||
# A session directory is named `{rating_key}_{int(start_s)}_{tag}_{aoff:+.2f}` (see start_session);
|
||||
# before multi-audio it was just `{rating_key}_{int(start_s)}`. `sweep_orphans` deletes ONLY names
|
||||
@@ -72,11 +88,20 @@ _sessions: dict[str, "HlsSession"] = {}
|
||||
|
||||
class HlsSession:
|
||||
def __init__(
|
||||
self, key: str, directory: Path, proc: subprocess.Popen, start_s: float, entry: str
|
||||
self,
|
||||
key: str,
|
||||
directory: Path,
|
||||
proc: subprocess.Popen,
|
||||
start_s: float,
|
||||
entry: str,
|
||||
transcode: bool = False,
|
||||
hw: bool = False,
|
||||
):
|
||||
self.key = key
|
||||
self.dir = directory
|
||||
self.proc = proc
|
||||
self.transcode = transcode # video re-encoded (non-h264 source), not stream-copied
|
||||
self.hw = hw # transcode ran on the VAAPI hardware encoder (else libx264 software)
|
||||
self.start_s = start_s # the REQUESTED seek offset
|
||||
# The REAL media start: with `-ss X -c:v copy` ffmpeg can only cut at the nearest earlier
|
||||
# keyframe K<=X, and hls.js zero-bases the timeline to K (so video.currentTime=0 is K, not X).
|
||||
@@ -116,6 +141,118 @@ _HLS_TAIL = [
|
||||
]
|
||||
|
||||
|
||||
# --- Transcode (playable="transcode") video re-encode -----------------------------------------
|
||||
# The remux path stream-copies the video (`-c:v copy`); a non-h264 source can't be copied and must be
|
||||
# re-encoded to H.264. Two encoders, chosen per-session by `_want_hw`:
|
||||
# * VAAPI hardware (`h264_vaapi`) — the prod default once the iGPU is passed in. A full GPU pipeline
|
||||
# (decode + scale + encode all on-device) offloads the CPU almost entirely, so several concurrent
|
||||
# 1080p transcodes are fine and the API stays responsive.
|
||||
# * libx264 software — the automatic fallback where `/dev/dri` is absent (dev box, or prod before the
|
||||
# iGPU enablement) or the source codec isn't hardware-decodable (AV1/XviD on Gen 9.5). CPU-bound, so
|
||||
# it reserves a core via `-threads` (see encode_thread_cap).
|
||||
# Everything else — the `-copyts`/`_HLS_TAIL`/segment/seek-restart model and the AAC audio path — is
|
||||
# unchanged, so hls.js, subtitles, multi-audio and seeking keep working exactly as for remux.
|
||||
_VAAPI_DEVICE = "/dev/dri/renderD128"
|
||||
# Codecs the prod iGPU (UHD 630 / Quick Sync Gen 9.5) can hardware-DECODE — the precondition for the
|
||||
# full-GPU VAAPI pipeline. A source outside this set (AV1, MPEG-4 ASP/XviD…) falls back to software even
|
||||
# when the render node is present, because the GPU can't decode it to feed its own encoder.
|
||||
_HW_DECODABLE = {"h264", "hevc", "h265", "vp9", "vc1", "mpeg2video"}
|
||||
|
||||
|
||||
def _hwaccel_in_opts(hw: bool) -> list[str]:
|
||||
"""VAAPI decode options, placed before `-i` so the source is decoded (and kept) on the GPU.
|
||||
`-hwaccel_output_format vaapi` keeps frames as GPU surfaces all the way to `h264_vaapi` — no
|
||||
round-trip to system memory. Empty for the software path."""
|
||||
if not hw:
|
||||
return []
|
||||
return ["-hwaccel", "vaapi", "-hwaccel_output_format", "vaapi", "-vaapi_device", _VAAPI_DEVICE]
|
||||
|
||||
|
||||
def _venc_opts(
|
||||
transcode: bool,
|
||||
hw: bool,
|
||||
preset: str = "veryfast",
|
||||
crf: int = 20,
|
||||
max_height: int = 0,
|
||||
) -> list[str]:
|
||||
"""The video-encoder args that replace `-c:v copy` for a transcode session (returns the copy args
|
||||
when not transcoding). A re-encode must MINT its own keyframes at the segment boundaries — the copy
|
||||
path leaned on the source's keyframes, but a re-encoded stream has none the HLS muxer can cut on
|
||||
unless we force them (`-force_key_frames` every `_SEG_SECONDS`), else segments aren't independent and
|
||||
seeking breaks. `preset`/`crf`/`max_height` (software only) come from admin sysconfig."""
|
||||
if not transcode:
|
||||
return ["-c:v", "copy"]
|
||||
force_kf = ["-force_key_frames", f"expr:gte(t,n_forced*{_SEG_SECONDS})"]
|
||||
if hw:
|
||||
# scale_vaapi with format=nv12 downconverts a 10-bit (p010) HEVC source to 8-bit nv12 so
|
||||
# h264_vaapi (8-bit High) can encode it; for an 8-bit source it's a cheap format pass-through.
|
||||
# The GPU encode is cheap, so it runs at source resolution (max_height is a software-only knob).
|
||||
return ["-vf", "scale_vaapi=format=nv12", "-c:v", "h264_vaapi", *force_kf]
|
||||
# Software fallback: same browser-safe recipe as downloads/edit.py (libx264). yuv420p forces 8-bit
|
||||
# High (not High10) so any browser decodes it; -threads reserves a core so the CPU-bound encode can't
|
||||
# starve request handling / playback on the shared box.
|
||||
args: list[str] = []
|
||||
if max_height and max_height > 0:
|
||||
# Downscale-ONLY to the height cap (min(ih,H)), keeping aspect with an even width (-2), so a tall
|
||||
# source doesn't make the software re-encode ruinously expensive. The comma in min() is escaped
|
||||
# (\,) so ffmpeg's filter parser reads one filter, not two. Never upscales a smaller source.
|
||||
args += ["-vf", f"scale=-2:min(ih\\,{max_height})"]
|
||||
args += [
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-preset",
|
||||
preset,
|
||||
"-crf",
|
||||
str(crf),
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-threads",
|
||||
str(encode_thread_cap()),
|
||||
*force_kf,
|
||||
]
|
||||
return args
|
||||
|
||||
|
||||
def _render_node_present() -> bool:
|
||||
"""Whether the VAAPI render node is available — i.e. `/dev/dri` was passed into the container.
|
||||
Present on prod after the iGPU enablement; absent on the CPU-only dev box and in the localdev
|
||||
Docker. Its own function so the selection is monkeypatchable in tests and one place for S1c's knob."""
|
||||
return Path(_VAAPI_DEVICE).exists()
|
||||
|
||||
|
||||
def _want_hw(db: DbSession, item: PlexItem, transcode: bool) -> bool:
|
||||
"""Whether to use the VAAPI hardware encoder for this transcode session, honouring the admin
|
||||
`plex_transcode_hw` policy: "off" → always software; "on" → VAAPI wherever the render node exists
|
||||
(trusting the admin, skipping the source-decodability gate); "auto" (default) → VAAPI only when the
|
||||
render node is present AND the iGPU can hardware-decode the source (else the full-GPU pipeline can't
|
||||
feed itself → software libx264)."""
|
||||
if not transcode:
|
||||
return False
|
||||
mode = (sysconfig.get_str(db, "plex_transcode_hw") or "auto").lower()
|
||||
if mode == "off" or not _render_node_present():
|
||||
return False
|
||||
if mode == "on":
|
||||
return True
|
||||
return (item.codec_video or "").lower() in _HW_DECODABLE
|
||||
|
||||
|
||||
# CPU-nice increment for the SOFTWARE transcode child. The Plex ffmpeg is a Popen in the long-lived API
|
||||
# process (which must NOT itself be niced), so — unlike the download worker's _deprioritize (run once in
|
||||
# its own main()) — we lower priority per-child via preexec_fn in the fork. The GPU path needs none of
|
||||
# this. Reuses the download child's nice default so all co-located re-encoders share one handicap.
|
||||
_SW_TRANSCODE_NICE = settings.download_child_nice
|
||||
|
||||
|
||||
def _sw_child_preexec() -> None:
|
||||
"""Runs in the forked child (POSIX) before exec: renice this software-transcode ffmpeg down so it
|
||||
can't starve the co-located API / Postgres / playback. Best-effort — a failed renice must not fail
|
||||
playback."""
|
||||
try:
|
||||
os.nice(_SW_TRANSCODE_NICE)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _probe_audio_langs(src: Path) -> list[str]:
|
||||
"""One entry per audio stream (its language tag, or "" if untagged). len() = audio-track count.
|
||||
Used to build the multi-rendition var_stream_map. Empty on probe failure → single-audio path."""
|
||||
@@ -149,8 +286,17 @@ def _ffmpeg_cmd(
|
||||
audio_ord: int | None,
|
||||
aoff: float = 0.0,
|
||||
audio_langs: list[str] | None = None,
|
||||
transcode: bool = False,
|
||||
hw: bool = False,
|
||||
preset: str = "veryfast",
|
||||
crf: int = 20,
|
||||
max_height: int = 0,
|
||||
) -> list[str]:
|
||||
args = ["ffmpeg", "-nostdin", "-loglevel", "error"]
|
||||
# For a transcode session the video is re-encoded (VAAPI or libx264) instead of stream-copied; the
|
||||
# hwaccel decode options go before -i, the encoder args replace `-c:v copy` below. Everything else is
|
||||
# identical to remux.
|
||||
venc = _venc_opts(transcode, hw, preset, crf, max_height)
|
||||
args = ["ffmpeg", "-nostdin", "-loglevel", "error", *_hwaccel_in_opts(hw)]
|
||||
# Keep ORIGINAL timestamps on the output so the first segment carries the true absolute PTS of the
|
||||
# keyframe ffmpeg actually seeked to. hls.js still zero-bases playback to that PTS; we read it back
|
||||
# (see _probe_first_pts) to learn the real content offset. Without -copyts the segment PTS is
|
||||
@@ -171,7 +317,7 @@ def _ffmpeg_cmd(
|
||||
args += ["-map", "0:v:0"]
|
||||
for i in range(len(audio_langs)):
|
||||
args += ["-map", f"{ai}:a:{i}?"]
|
||||
args += ["-sn", "-c:v", "copy", "-c:a", "aac", "-ac", "2", "-b:a", "192k"]
|
||||
args += ["-sn", *venc, "-c:a", "aac", "-ac", "2", "-b:a", "192k"]
|
||||
var = ["v:0,agroup:aud"]
|
||||
for i, lang in enumerate(audio_langs):
|
||||
entry = f"a:{i},agroup:aud,name:a{i}"
|
||||
@@ -192,7 +338,7 @@ def _ffmpeg_cmd(
|
||||
else:
|
||||
ao = audio_ord if audio_ord is not None else 0
|
||||
args += ["-map", "0:v:0", "-map", f"{ai}:a:{ao}?", "-sn"]
|
||||
args += ["-c:v", "copy", "-c:a", "aac", "-ac", "2", "-b:a", "192k"]
|
||||
args += [*venc, "-c:a", "aac", "-ac", "2", "-b:a", "192k"]
|
||||
args += _HLS_TAIL + [
|
||||
"-hls_segment_filename",
|
||||
str(out_dir / "seg_%d.ts"),
|
||||
@@ -247,12 +393,14 @@ def _kill(s: HlsSession) -> None:
|
||||
|
||||
|
||||
def _enforce_cap(cap: int) -> None:
|
||||
# Caller holds _lock. Drop the least-recently-accessed sessions over the cap.
|
||||
if len(_sessions) <= cap:
|
||||
# Caller holds _lock. Only TRANSCODE sessions count toward plex_max_transcodes — remux
|
||||
# (stream-copy) and direct-serve are cheap and deliberately uncapped (see config.py:
|
||||
# plex_max_transcodes, and the user-facing config hint). Drop the least-recently-accessed
|
||||
# transcode sessions over the cap, leaving any concurrent remux streams untouched.
|
||||
tx = [(key, s) for key, s in _sessions.items() if s.transcode]
|
||||
if len(tx) <= cap:
|
||||
return
|
||||
for key, s in sorted(_sessions.items(), key=lambda kv: kv[1].last_access)[
|
||||
: len(_sessions) - cap
|
||||
]:
|
||||
for key, s in sorted(tx, key=lambda kv: kv[1].last_access)[: len(tx) - cap]:
|
||||
_kill(s)
|
||||
_sessions.pop(key, None)
|
||||
|
||||
@@ -264,18 +412,33 @@ def start_session(
|
||||
audio_ord: int | None = None,
|
||||
aoff: float = 0.0,
|
||||
multi: bool = False,
|
||||
transcode: bool = False,
|
||||
) -> HlsSession | None:
|
||||
"""(Re)start the HLS remux for an item at the given offset. `multi` (item has >1 audio track) maps
|
||||
every audio track as an HLS rendition so the client switches audio without a restart; otherwise a
|
||||
single audio track (`audio_ord`) is muxed. Subtitles are separate WebVTT tracks (not muxed here).
|
||||
Returns None if the local file can't be read."""
|
||||
"""(Re)start the HLS session for an item at the given offset. `transcode` (item is non-h264,
|
||||
playable="transcode") re-encodes the video to H.264 (VAAPI or libx264) instead of stream-copying it;
|
||||
otherwise the video is copied (remux). `multi` (item has >1 audio track) maps every audio track as an
|
||||
HLS rendition so the client switches audio without a restart; otherwise a single audio track
|
||||
(`audio_ord`) is muxed. Subtitles are separate WebVTT tracks (not muxed here). Returns None if the
|
||||
local file can't be read."""
|
||||
src = paths.local_media_path(db, item.file_path)
|
||||
if src is None:
|
||||
return None
|
||||
key = item.rating_key
|
||||
start_s = max(0.0, float(start_s))
|
||||
hw = _want_hw(db, item, transcode)
|
||||
# Software-fallback encode tuning — read only on the SW transcode path (the VAAPI path and remux
|
||||
# ignore preset/crf/max_height, and remux must not touch the DB for them).
|
||||
preset, crf, max_height = "veryfast", 20, 0
|
||||
if transcode and not hw:
|
||||
preset = sysconfig.get_str(db, "plex_transcode_preset") or "veryfast"
|
||||
crf = sysconfig.get_int(db, "plex_transcode_crf")
|
||||
max_height = sysconfig.get_int(db, "plex_transcode_max_height")
|
||||
audio_langs = _probe_audio_langs(src) if multi else []
|
||||
is_multi = len(audio_langs) > 1 # only worth a master playlist when there really are ≥2 tracks
|
||||
# Nice the SOFTWARE transcode child (not the HW path, and not remux) so a CPU-bound re-encode can't
|
||||
# starve the co-located API/playback. preexec_fn runs in the forked child (POSIX); on a platform
|
||||
# without fork (the API always runs in a Linux container in prod) it's simply omitted.
|
||||
preexec = _sw_child_preexec if (transcode and not hw and os.name == "posix") else None
|
||||
with _lock:
|
||||
old = _sessions.pop(key, None)
|
||||
if old is not None:
|
||||
@@ -286,33 +449,65 @@ def start_session(
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
proc = subprocess.Popen(
|
||||
_ffmpeg_cmd(
|
||||
src, start_s, directory, audio_ord, aoff, audio_langs if is_multi else None
|
||||
src,
|
||||
start_s,
|
||||
directory,
|
||||
audio_ord,
|
||||
aoff,
|
||||
audio_langs if is_multi else None,
|
||||
transcode=transcode,
|
||||
hw=hw,
|
||||
preset=preset,
|
||||
crf=crf,
|
||||
max_height=max_height,
|
||||
),
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
preexec_fn=preexec,
|
||||
)
|
||||
s = HlsSession(
|
||||
key,
|
||||
directory,
|
||||
proc,
|
||||
start_s,
|
||||
"master.m3u8" if is_multi else "index.m3u8",
|
||||
transcode=transcode,
|
||||
hw=hw,
|
||||
)
|
||||
s = HlsSession(key, directory, proc, start_s, "master.m3u8" if is_multi else "index.m3u8")
|
||||
_sessions[key] = s
|
||||
# Admin-configurable concurrency cap (Configuration → Plex → Max concurrent transcodes);
|
||||
# at least 1 so playback can't be capped to zero.
|
||||
_enforce_cap(max(1, sysconfig.get_int(db, "plex_max_transcodes")))
|
||||
# Learn the REAL start offset K from the first VIDEO segment (outside the lock — this blocks on
|
||||
# ffmpeg producing it, and we must not hold up other sessions). The client reads `media_start_s`, so
|
||||
# its absolute clock + subtitle shift key off the true keyframe, not the requested offset. On
|
||||
# timeout/probe failure we keep the provisional requested offset (old behaviour, small drift).
|
||||
if start_s > 0:
|
||||
seg0 = directory / ("seg_0_0.ts" if is_multi else "seg_0.ts") # multi: video variant = v0
|
||||
if wait_for(seg0, timeout=25.0):
|
||||
k = _probe_first_pts(seg0)
|
||||
|
||||
# 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:
|
||||
# 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")
|
||||
|
||||
if wait_for(_seg(0), timeout=25.0):
|
||||
# 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
|
||||
# offset. On probe failure we keep the provisional requested offset (old behaviour, small drift).
|
||||
if start_s > 0:
|
||||
k = _probe_first_pts(_seg(0))
|
||||
if k is not None and k >= 0:
|
||||
s.media_start_s = k
|
||||
# Warm up the buffer (see _WARMUP_SEGMENTS) so the client doesn't outrun the cold ffmpeg. Stop
|
||||
# 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.
|
||||
for i in range(1, _WARMUP_SEGMENTS):
|
||||
if not wait_for(_seg(i), timeout=_WARMUP_SEG_TIMEOUT_S):
|
||||
break
|
||||
log.info(
|
||||
"plex hls session start key=%s req=%.1f real=%.3f multi=%s audio=%s",
|
||||
"plex hls session start key=%s req=%.1f real=%.3f multi=%s audio=%s transcode=%s hw=%s",
|
||||
key,
|
||||
start_s,
|
||||
s.media_start_s,
|
||||
is_multi,
|
||||
audio_ord,
|
||||
transcode,
|
||||
hw,
|
||||
)
|
||||
return s
|
||||
|
||||
|
||||
+109
-22
@@ -11,11 +11,14 @@ already pages) and upsert them into the owner's `plex_states`. Plex wins on over
|
||||
exist only in Siftlode are left untouched (union, Plex-authoritative on the intersection).
|
||||
|
||||
Phases B (Siftlode→Plex push via scrobble/timeline) and C (incremental Plex→Siftlode via the watch
|
||||
history + onDeck, last-write-wins) build on this and land in later ships.
|
||||
history + onDeck) build on this and land in later ships. As of S2 Plex is the source of truth: the
|
||||
player pushes the resume position back to Plex on the periodic checkpoint (not just the final flush),
|
||||
and the incremental pull resolves a genuine divergence to Plex's state (see `_pull_apply`) rather than
|
||||
running a last-write-wins comparison.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
@@ -293,12 +296,6 @@ def push_bulk_state_to_plex(user_id: int, changes: list[tuple[int, str, str]]) -
|
||||
|
||||
# --- Phase C: incremental + full Plex ↔ Siftlode reconcile (scheduler jobs) ------------------------
|
||||
|
||||
# Clock-skew / round-trip slack when comparing a Plex timestamp to a Siftlode one. A state we just
|
||||
# pushed (Plex stamps viewedAt a beat after our watched_at) must not read as "newer on Plex" and get
|
||||
# pulled back with a different shape. Genuine Plex-side edits are minutes from our writes — far
|
||||
# outside this — so the window only absorbs skew, never real changes.
|
||||
_LWW_TOLERANCE_S = 90
|
||||
|
||||
|
||||
def _active_sync_links(db: Session) -> list[PlexLink]:
|
||||
"""Every link with two-way sync actually running (owner MVP = one row). Iterated uniformly so
|
||||
@@ -338,11 +335,6 @@ def _resolve_account_id(db: Session, plex: PlexClient, link: PlexLink) -> int:
|
||||
return acct_id
|
||||
|
||||
|
||||
def _sift_ts(st: PlexState) -> datetime | None:
|
||||
"""The freshest Siftlode-side timestamp on a row, for last-write-wins."""
|
||||
return max([t for t in (st.watched_at, st.progress_updated_at) if t], default=None)
|
||||
|
||||
|
||||
def _same_state(st: PlexState | None, status: str, pos: int) -> bool:
|
||||
"""Whether a row already matches a target (status, pos) — the core ping-pong guard: when nothing
|
||||
would change we skip the write entirely (no row churn, no timestamp bump, no re-pull)."""
|
||||
@@ -370,9 +362,20 @@ def _pull_apply(
|
||||
if st is not None and not st.synced_to_plex:
|
||||
st.synced_to_plex = True # already matches Plex → just settle the flag, no churn
|
||||
return None
|
||||
# Conflict: keep Siftlode when it's newer than Plex (plus skew slack).
|
||||
sts = _sift_ts(st) if st else None
|
||||
if sts and plex_ts and plex_ts <= sts + timedelta(seconds=_LWW_TOLERANCE_S):
|
||||
# Plex-authoritative (S2): on a genuine divergence for an item Plex knows about, Plex wins — no
|
||||
# last-write-wins tug-of-war. This is safe because prompt write-back (playback.py pushes the resume
|
||||
# position to Plex on the ~10s checkpoint, not just on the final flush) keeps Plex current, so a
|
||||
# local edit that's "newer" than Plex has almost always already been pushed and matched above by
|
||||
# `_same_state`; a row that reaches here really is out of step with the source of truth. The
|
||||
# `_same_state` + `synced_to_plex` short-circuit above still absorbs the freshly-pushed case (Plex
|
||||
# echoes the same shape a beat later) so a just-pushed row never bounces.
|
||||
if st is not None and not st.synced_to_plex:
|
||||
# Durability guard: this row holds a local change that never reached Plex — its immediate push
|
||||
# failed, so it's still dirty. Do NOT let the Plex-authoritative pull overwrite it and clear the
|
||||
# flag: that would strand the local value forever, since `_repush_dirty` only retries dirty rows.
|
||||
# Leave it dirty; `_repush_dirty` (later in this same run) pushes the local value up to Plex. This
|
||||
# closes the "Plex flapped mid-session" lost-update — the one case the Plex-wins comment's
|
||||
# "almost always already pushed" assumption above excludes.
|
||||
return None
|
||||
if st is None:
|
||||
st = PlexState(user_id=user_id, item_id=item_id)
|
||||
@@ -436,14 +439,89 @@ def _repush_dirty(db: Session, plex: PlexClient, link: PlexLink) -> int:
|
||||
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:
|
||||
"""Scheduler job (default 30 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 under
|
||||
last-write-wins, then re-pushes any still-unsynced local states. Does NOT propagate un-watches
|
||||
(the history feed only carries positive views) — that's `run_plex_watch_reconcile`'s job."""
|
||||
"""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
|
||||
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
|
||||
`run_plex_watch_reconcile`'s job."""
|
||||
if not sysconfig.get_bool(db, "plex_enabled"):
|
||||
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):
|
||||
total["links"] += 1
|
||||
try:
|
||||
@@ -463,10 +541,14 @@ def run_plex_watch_sync(db: Session) -> dict:
|
||||
if ch:
|
||||
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():
|
||||
iid = rk_to_id.get(str(row.get("ratingKey") or ""))
|
||||
if iid is None:
|
||||
continue
|
||||
on_deck_ids.add(iid)
|
||||
off = int(row.get("viewOffset") or 0) // 1000
|
||||
if off < _PROGRESS_MIN_S:
|
||||
continue
|
||||
@@ -476,6 +558,11 @@ def run_plex_watch_sync(db: Session) -> dict:
|
||||
if ch:
|
||||
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)
|
||||
link.last_watch_sync_at = now
|
||||
db.commit()
|
||||
@@ -491,7 +578,7 @@ def run_plex_watch_sync(db: Session) -> dict:
|
||||
def run_plex_watch_reconcile(db: Session) -> dict:
|
||||
"""Scheduler job (default daily) — the authoritative full reconcile. Rescans every section and,
|
||||
per item, uses `synced_to_plex` to settle what the incremental feed can't:
|
||||
- Plex has a state, Siftlode differs → last-write-wins (Plex usually the newer here).
|
||||
- Plex has a state, Siftlode differs → Plex wins (the source of truth — see `_pull_apply`).
|
||||
- Siftlode has a state, Plex has NO record:
|
||||
· synced_to_plex=True → Plex un-watched it since our last sync → clear it (un-watch).
|
||||
· synced_to_plex=False → a local change that never reached Plex → re-push it (via
|
||||
|
||||
@@ -229,6 +229,7 @@ def _channel_detail_dict(
|
||||
count_live: int,
|
||||
deep_requested: bool,
|
||||
priority: int,
|
||||
hidden: bool,
|
||||
tag_ids: list[int],
|
||||
) -> dict:
|
||||
return {
|
||||
@@ -262,6 +263,9 @@ def _channel_detail_dict(
|
||||
# This user's per-subscription feed priority (0 when not subscribed) so the channel page can
|
||||
# offer the same priority stepper as the manager. Editable only while subscribed.
|
||||
"priority": priority,
|
||||
# This user's per-subscription "hide from feed" flag (False when not subscribed), so the
|
||||
# channel page can offer the same Unhide toggle the manager has. Editable only while subscribed.
|
||||
"hidden": hidden,
|
||||
"backfill_done": channel.backfill_done,
|
||||
"from_explore": channel.from_explore,
|
||||
"details_synced": channel.details_synced_at is not None,
|
||||
@@ -344,6 +348,7 @@ def channel_detail(
|
||||
count_live=count_live,
|
||||
deep_requested=bool(sub.deep_requested) if sub else False,
|
||||
priority=int(sub.priority) if sub else 0,
|
||||
hidden=bool(sub.hidden) if sub else False,
|
||||
tag_ids=tag_ids,
|
||||
)
|
||||
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import sysconfig
|
||||
from app import audit, sysconfig
|
||||
from app.auth import admin_user, require_human
|
||||
from app.config import settings
|
||||
from app.db import get_db
|
||||
from app.downloads import credentials as dlcreds
|
||||
from app.downloads import edit as editmod
|
||||
from app.downloads import links as linksmod
|
||||
from app.downloads import quota, service, storage
|
||||
from app.downloads import upload as uploadmod
|
||||
from app.models import (
|
||||
DownloadJob,
|
||||
DownloadLink,
|
||||
@@ -122,6 +127,9 @@ def _serialize(job: DownloadJob, asset: MediaAsset | None) -> dict:
|
||||
"width": asset.width,
|
||||
"height": asset.height,
|
||||
"container": asset.container,
|
||||
# "video" | "audio" | "file" — the library gates play/edit on this (media only); a
|
||||
# generic uploaded file is download-only. Null while still in flight (set at finalize).
|
||||
"media_kind": asset.media_kind,
|
||||
"uploader_url": asset.uploader_url,
|
||||
"thumbnail_url": asset.thumbnail_url,
|
||||
"expires_at": iso(asset.expires_at),
|
||||
@@ -247,6 +255,65 @@ def delete_profile(
|
||||
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(
|
||||
db: Session, user: User, profile_id: int | None, spec: dict | None
|
||||
) -> tuple[dict, int | None]:
|
||||
@@ -294,6 +361,61 @@ def enqueue_download(
|
||||
return _serialize_job(db, job)
|
||||
|
||||
|
||||
# Read the upload in 1 MiB chunks so a large file never sits fully in memory, and the size cap is
|
||||
# enforced on the ACTUAL bytes read (a client-set Content-Length can't be trusted to gate it).
|
||||
_UPLOAD_CHUNK = 1024 * 1024
|
||||
|
||||
|
||||
@router.post("/upload")
|
||||
def upload_download(
|
||||
file: UploadFile = File(...),
|
||||
display_name: str | None = Form(None),
|
||||
user: User = Depends(require_human),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Ingest a local file into the caller's library (E7 S5). Any file type is accepted; media
|
||||
(video/audio) gets the full function set, a generic file is download-only. The file is streamed
|
||||
to a temp path under the download volume (enforcing the per-file cap as it goes), then handed to
|
||||
the non-yt-dlp ingest which probes + places it and returns an already-`done` job.
|
||||
|
||||
A sync (threadpool) handler on purpose: the ingest does blocking work — an ffprobe of up to 30s
|
||||
plus sync DB writes — so it must NOT run on the event loop. `file.file` is the sync file object
|
||||
Starlette spools the multipart body into."""
|
||||
cap = sysconfig.get_int(db, "download_upload_max_bytes")
|
||||
staging = Path(settings.download_root) / ".staging" / "uploads"
|
||||
staging.mkdir(parents=True, exist_ok=True)
|
||||
fd, tmp = tempfile.mkstemp(dir=str(staging))
|
||||
tmp_path = Path(tmp)
|
||||
try:
|
||||
size = 0
|
||||
with os.fdopen(fd, "wb") as out:
|
||||
while chunk := file.file.read(_UPLOAD_CHUNK):
|
||||
size += len(chunk)
|
||||
if cap and size > cap:
|
||||
raise HTTPException(
|
||||
status_code=422, detail={"code": "upload_too_large", "limit": cap}
|
||||
)
|
||||
out.write(chunk)
|
||||
if size == 0:
|
||||
raise HTTPException(status_code=400, detail={"code": "upload_empty"})
|
||||
try:
|
||||
job = uploadmod.ingest_upload(
|
||||
db,
|
||||
user.id,
|
||||
filename=file.filename or "upload",
|
||||
src_path=tmp_path,
|
||||
size=size,
|
||||
display_name=(display_name or "").strip() or None,
|
||||
)
|
||||
except quota.QuotaExceeded as e:
|
||||
raise HTTPException(status_code=422, detail=_quota_detail(e))
|
||||
return _serialize_job(db, job)
|
||||
finally:
|
||||
# Ingest MOVES the temp file into place on success (unlink is then a no-op); on any early exit
|
||||
# — cap hit, empty, quota, probe/move error — this removes the abandoned staging file.
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
class EnqueueEditIn(BaseModel):
|
||||
source_job_id: int = 0
|
||||
edit_spec: dict
|
||||
@@ -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
|
||||
was its last holder."""
|
||||
job = _own_job(db, user, job_id)
|
||||
if job.job_kind == "edit":
|
||||
raise HTTPException(status_code=409, detail="Editor clips can't be re-downloaded.")
|
||||
if job.job_kind in ("edit", "upload"):
|
||||
# Neither has a re-fetchable source: an editor clip is derived locally, an upload IS the
|
||||
# source. Re-running yt-dlp against source_kind='upload'/'edit' would just fail.
|
||||
raise HTTPException(status_code=409, detail="This item can't be re-downloaded.")
|
||||
if job.status in ("queued", "running", "paused"):
|
||||
raise HTTPException(status_code=409, detail="This download is still in progress.")
|
||||
service.redownload(db, job)
|
||||
@@ -550,8 +674,21 @@ def download_file(
|
||||
|
||||
asset.last_access_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
# Starlette's FileResponse honours the Range header (206 partial content) for seek/resume.
|
||||
return FileResponse(path, filename=filename)
|
||||
# The served Content-Type comes from the ffprobe-verified media_kind + a container allow-list
|
||||
# (storage.served_media_type), NEVER guessed from the file's own extension. A generic 'file' (and
|
||||
# any media whose container we don't recognise — e.g. a media/HTML polyglot named "evil.html")
|
||||
# returns None → a neutral force-download attachment, so uploaded HTML/SVG can't execute inline on
|
||||
# this origin. Recognised media is served with its real type so the in-app player streams it. Both
|
||||
# are range-aware (FileResponse → 206).
|
||||
media_type = storage.served_media_type(asset.media_kind, asset.container)
|
||||
if media_type is None:
|
||||
return FileResponse(
|
||||
path,
|
||||
filename=filename,
|
||||
media_type="application/octet-stream",
|
||||
content_disposition_type="attachment",
|
||||
)
|
||||
return FileResponse(path, filename=filename, media_type=media_type)
|
||||
|
||||
|
||||
# --- editor filmstrip (scrub track) --------------------------------------------------------
|
||||
@@ -763,7 +900,12 @@ def list_links(
|
||||
|
||||
class CreateLinkIn(BaseModel):
|
||||
password: str = ""
|
||||
allow_download: bool = False
|
||||
# D1 (E7 S3): a newly shared download is downloadable by default — the common intent is "let the
|
||||
# recipient have the file", not stream-only. Forward-only: this only sets the default for links
|
||||
# minted from now on; existing links keep whatever `allow_download` they were created with (the
|
||||
# column default stays False, so no back-catalogue link silently flips). The owner can still
|
||||
# uncheck it in the Share dialog, and toggle any link stream-only afterwards.
|
||||
allow_download: bool = True
|
||||
expires_days: int | None = None
|
||||
|
||||
|
||||
|
||||
@@ -14,8 +14,8 @@ from app.db import get_db
|
||||
from app.feedquery import feed_columns, serialize
|
||||
from app.models import Channel, Playlist, PlaylistItem, User, Video, VideoState
|
||||
from app.sync.playlists import (
|
||||
plan_push,
|
||||
push_playlist,
|
||||
merge_playlist,
|
||||
plan_merge,
|
||||
recompute_dirty,
|
||||
repull_playlist,
|
||||
sync_user_playlists,
|
||||
@@ -329,13 +329,13 @@ def push_plan(
|
||||
user: User = Depends(require_write_scope),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Dry run: what 'Sync to YouTube' would do + the quota estimate, so the UI can show a
|
||||
confirm. For a linked playlist this reads the live YouTube state (cheap, 1 unit/page).
|
||||
Write scope is required (require_write_scope) — syncing is a YouTube write."""
|
||||
"""Dry run: what a two-way 'Sync to YouTube' merge would do (what each side gains/loses) +
|
||||
the quota estimate, so the UI can show a confirm. For a linked playlist this reads the live
|
||||
YouTube state (cheap, 1 unit/page). Write scope is required — syncing is a YouTube write."""
|
||||
pl = _pushable(db, user, playlist_id)
|
||||
try:
|
||||
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PUSH):
|
||||
plan = plan_push(db, user, pl)
|
||||
plan = plan_merge(db, user, pl)
|
||||
except YouTubeError:
|
||||
raise HTTPException(status_code=422, detail="Couldn't read the playlist on YouTube.")
|
||||
plan["remaining_today"] = quota.remaining_today(db)
|
||||
@@ -349,14 +349,14 @@ def push(
|
||||
user: User = Depends(require_write_scope),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Push a local playlist to YouTube (create it on first push, else reconcile to match
|
||||
local — local wins). 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)."""
|
||||
"""Two-way sync a local playlist with YouTube (create it on first push, else 3-way merge so
|
||||
neither side loses an item). Refuses if the estimated quota exceeds what's left today, so a
|
||||
big sync can't strand half-applied. Write scope required (require_write_scope)."""
|
||||
pl = _pushable(db, user, playlist_id)
|
||||
try:
|
||||
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PUSH):
|
||||
# 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.
|
||||
live = None
|
||||
if pl.yt_playlist_id:
|
||||
@@ -365,13 +365,13 @@ def push(
|
||||
list(yt.iter_playlist_items_with_ids(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):
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
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:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=422, detail="YouTube playlist sync failed.")
|
||||
|
||||
@@ -138,6 +138,9 @@ def unified_library(
|
||||
cast(null(), Integer).label("season_count"),
|
||||
func.coalesce(st.position_seconds, 0).label("position_seconds"),
|
||||
m_status.label("status"),
|
||||
# "last watched" sort key: when the movie was last resumed, else when it was marked
|
||||
# watched. NULL for never-touched movies → nullslast sinks them (see the sort dict).
|
||||
func.coalesce(st.progress_updated_at, st.watched_at).label("last_watched"),
|
||||
(
|
||||
func.ts_rank(vector_col(PlexItem, lang), tsq)
|
||||
if tsq is not None
|
||||
@@ -159,7 +162,7 @@ def unified_library(
|
||||
mq = mq.where(recheck(PlexItem, field, tsq, lang))
|
||||
selects.append(mq)
|
||||
if want_shows and grid_field:
|
||||
agg = rollup.agg_subquery(db, user.id).subquery()
|
||||
agg = rollup.agg_subquery(db, user.id, include_resumed=True).subquery()
|
||||
tot = func.coalesce(agg.c.total, 0)
|
||||
wat = func.coalesce(agg.c.watched, 0)
|
||||
inp = func.coalesce(agg.c.inprog, 0)
|
||||
@@ -179,6 +182,9 @@ def unified_library(
|
||||
PlexShow.child_count.label("season_count"),
|
||||
literal(0).label("position_seconds"),
|
||||
s_status.label("status"),
|
||||
# Show leg's "last watched": max(resume/watched time) across the show's episodes (see
|
||||
# rollup.agg_subquery include_resumed). NULL for a never-watched show → nullslast.
|
||||
agg.c.resumed_at.label("last_watched"),
|
||||
(
|
||||
func.ts_rank(vector_col(PlexShow, lang), tsq)
|
||||
if tsq is not None
|
||||
@@ -212,6 +218,7 @@ def unified_library(
|
||||
"rating": u.c.rating,
|
||||
"duration": u.c.duration_s,
|
||||
"release": u.c.rel,
|
||||
"lastwatched": u.c.last_watched,
|
||||
}.get(sort, u.c.added_at)
|
||||
order = [(col.asc() if sort_dir == "asc" else col.desc()).nullslast()]
|
||||
order.append(u.c.rk.desc())
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
import hashlib
|
||||
import os
|
||||
import tempfile
|
||||
from datetime import datetime, timezone
|
||||
import threading
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import httpx
|
||||
from fastapi import (
|
||||
@@ -155,6 +156,47 @@ _PROGRESS_MIN_S = 5
|
||||
|
||||
_FINISH_MARGIN_S = 30
|
||||
|
||||
# S2 write-back throttle: the resume position is now pushed to Plex on the periodic (non-final)
|
||||
# checkpoint too — not only on the final pause/unmount flush — so Plex (the source of truth) stays
|
||||
# current in near-real-time. To keep a single watch from spraying `/:/timeline` calls, a push is
|
||||
# rate-limited to at most one per item per user every _PUSH_THROTTLE_S seconds; a `final` checkpoint
|
||||
# (pause / seek / stop) always pushes immediately and resets the window. This is an in-memory, single-
|
||||
# process rate-limiter (the deployment is one FastAPI process): after a restart the map is empty, so at
|
||||
# most one extra push happens on the first checkpoint — harmless. It is NOT durable domain state.
|
||||
_PUSH_THROTTLE_S = 10
|
||||
_last_push_at: dict[tuple[int, int], datetime] = {}
|
||||
_push_throttle_lock = threading.Lock()
|
||||
|
||||
|
||||
def _should_push_resume(user_id: int, item_id: int, now: datetime, final: bool) -> bool:
|
||||
"""Whether to mirror this resume checkpoint to Plex now. `final` always pushes; a periodic tick
|
||||
pushes only when ≥_PUSH_THROTTLE_S has elapsed since the last push for this (user, item). Records
|
||||
`now` as the last-push time whenever it returns True so the window is measured from the real push
|
||||
(and so `_resume_push_superseded` can drop an older, still-pending push — see there)."""
|
||||
with _push_throttle_lock:
|
||||
last = _last_push_at.get((user_id, item_id))
|
||||
if final or last is None or (now - last).total_seconds() >= _PUSH_THROTTLE_S:
|
||||
_last_push_at[(user_id, item_id)] = now
|
||||
# Opportunistic eviction so the map can't grow unbounded over a long-lived process (one
|
||||
# entry per item ever checkpointed). Anything older than a few windows is dead — a push
|
||||
# completes in seconds, so a stale entry protects nothing.
|
||||
if len(_last_push_at) > 2048:
|
||||
cutoff = now - timedelta(seconds=_PUSH_THROTTLE_S * 6)
|
||||
for k in [k for k, t in _last_push_at.items() if t < cutoff]:
|
||||
del _last_push_at[k]
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _resume_push_superseded(user_id: int, item_id: int, intent: datetime) -> bool:
|
||||
"""True if a newer resume push for this (user, item) was scheduled after `intent`. Called at the
|
||||
HTTP boundary of a background resume push: because periodic and seek/pause pushes are independent
|
||||
background tasks with no ordering guard, an older still-pending periodic push could otherwise land
|
||||
at Plex AFTER a newer backward-seek push and — with the Plex-authoritative pull — durably revive the
|
||||
stale position. Skipping a superseded push keeps the last-scheduled position the one Plex settles on."""
|
||||
with _push_throttle_lock:
|
||||
return _last_push_at.get((user_id, item_id), intent) > intent
|
||||
|
||||
|
||||
@router.get("/person-image")
|
||||
def person_image(u: str, request: Request) -> Response:
|
||||
@@ -178,6 +220,26 @@ def person_image(u: str, request: Request) -> Response:
|
||||
return imgcache.response(r.content, ct)
|
||||
|
||||
|
||||
def _push_resume_guarded(
|
||||
user_id: int,
|
||||
item_id: int,
|
||||
rating_key: str,
|
||||
time_ms: int,
|
||||
duration_ms: int,
|
||||
expect_ts: datetime | None,
|
||||
intent: datetime,
|
||||
) -> None:
|
||||
"""Background entrypoint for a resume push that drops itself if a newer resume push for the same
|
||||
(user, item) was scheduled after it (see `_resume_push_superseded`), then delegates to the real
|
||||
push. This narrows the periodic-vs-seek write-ordering race to the (rare) case where the older push
|
||||
has already left the process before the newer one is scheduled."""
|
||||
if _resume_push_superseded(user_id, item_id, intent):
|
||||
return
|
||||
plex_watch.push_state_to_plex(
|
||||
user_id, item_id, rating_key, "resume", time_ms, duration_ms, expect_ts
|
||||
)
|
||||
|
||||
|
||||
def _push_watch(
|
||||
background: BackgroundTasks,
|
||||
db: Session,
|
||||
@@ -187,10 +249,14 @@ def _push_watch(
|
||||
action: str,
|
||||
time_ms: int = 0,
|
||||
duration_ms: int = 0,
|
||||
intent: datetime | None = None,
|
||||
) -> None:
|
||||
"""Schedule a best-effort Siftlode→Plex watch push, but only for a user whose owner link has
|
||||
push enabled — non-owner users never touch Plex. Guarding here (with the request session) avoids
|
||||
spinning up a throwaway background session on every state change for the common no-link case."""
|
||||
spinning up a throwaway background session on every state change for the common no-link case.
|
||||
|
||||
A `resume` push with an `intent` timestamp is routed through `_push_resume_guarded` so a superseded
|
||||
(older) push can't land out of order; `watched`/`unwatched` scrobbles go straight through."""
|
||||
if plex_watch.link_for_push(db, user_id) is None:
|
||||
return
|
||||
# Capture the row's freshest timestamp NOW so the background push can tell if a newer local edit
|
||||
@@ -202,6 +268,12 @@ def _push_watch(
|
||||
.scalars()
|
||||
.first()
|
||||
)
|
||||
marker = plex_watch._state_marker(st)
|
||||
if action == "resume" and intent is not None:
|
||||
background.add_task(
|
||||
_push_resume_guarded, user_id, item_id, rating_key, time_ms, duration_ms, marker, intent
|
||||
)
|
||||
return
|
||||
background.add_task(
|
||||
plex_watch.push_state_to_plex,
|
||||
user_id,
|
||||
@@ -210,7 +282,7 @@ def _push_watch(
|
||||
action,
|
||||
time_ms,
|
||||
duration_ms,
|
||||
plex_watch._state_marker(st),
|
||||
marker,
|
||||
)
|
||||
|
||||
|
||||
@@ -232,9 +304,10 @@ def item_progress(
|
||||
) -> dict:
|
||||
"""Checkpoint the resume position while playing (near the end → mark watched + clear it).
|
||||
|
||||
`final` (sent on the pause/pagehide/unmount flush, not the periodic 10s checkpoint) is what
|
||||
debounces the Plex resume push: we mirror the settled position to Plex only on a final flush, so
|
||||
a single watch doesn't spray `/:/timeline` calls at the server every 10 seconds."""
|
||||
S2 write-back: the resume position is mirrored to Plex (the source of truth) on the periodic
|
||||
checkpoint too, not only on the final flush — throttled to ≥_PUSH_THROTTLE_S per item (see
|
||||
`_should_push_resume`). `final` (sent on pause / seek / pagehide / unmount) pushes immediately and
|
||||
resets the throttle window, so Plex sees a settled position the instant the user pauses or seeks."""
|
||||
it = item_or_404(db, rating_key)
|
||||
position = max(0, int(body.position_seconds))
|
||||
duration = int(body.duration_seconds)
|
||||
@@ -259,6 +332,10 @@ def item_progress(
|
||||
st.position_seconds = 0
|
||||
st.progress_updated_at = now
|
||||
st.synced_to_plex = False
|
||||
# Terminal state for resume write-back: drop the throttle/ordering entry so the map doesn't
|
||||
# retain a dead key for every finished item.
|
||||
with _push_throttle_lock:
|
||||
_last_push_at.pop((user.id, it.id), None)
|
||||
db.commit()
|
||||
# Immediate on the watched transition; skip a repeat scrobble if it was already watched
|
||||
# (a later checkpoint of the same finished item would otherwise bump Plex's viewCount again).
|
||||
@@ -280,10 +357,13 @@ def item_progress(
|
||||
db.add(st)
|
||||
st.position_seconds = position
|
||||
st.progress_updated_at = now
|
||||
if final:
|
||||
push_now = _should_push_resume(user.id, it.id, now, final)
|
||||
if push_now:
|
||||
# Mark dirty before the (background) push so the reconcile knows to re-push if it fails, and so
|
||||
# `push_state_to_plex` can settle synced_to_plex once Plex confirms.
|
||||
st.synced_to_plex = False
|
||||
db.commit()
|
||||
if final:
|
||||
if push_now:
|
||||
_push_watch(
|
||||
background,
|
||||
db,
|
||||
@@ -293,6 +373,7 @@ def item_progress(
|
||||
"resume",
|
||||
position * 1000,
|
||||
duration * 1000,
|
||||
intent=now,
|
||||
)
|
||||
return {"position_seconds": position}
|
||||
|
||||
@@ -451,15 +532,13 @@ def stream_session(
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Prepare playback from `start` seconds. Direct-playable files are served raw; remux files
|
||||
(re)start an HLS session at the offset (seek-restart); transcode (HEVC/VP9) is P3. Selecting a
|
||||
non-default audio track (`audio` = stream ordinal) forces the HLS path so the chosen audio can be
|
||||
muxed in. Subtitles are independent WebVTT tracks (GET /subtitle) — no session restart."""
|
||||
(re)start an HLS session at the offset (seek-restart, video stream-copied); transcode files (non-h264:
|
||||
HEVC/VP9/AV1/…) take the same HLS path but with the video re-encoded to H.264 (VAAPI or libx264).
|
||||
Selecting a non-default audio track (`audio` = stream ordinal) forces the HLS path so the chosen audio
|
||||
can be muxed in. Subtitles are independent WebVTT tracks (GET /subtitle) — no session restart."""
|
||||
plex_stream.reap_idle() # opportunistic cleanup of idle sessions on each new playback
|
||||
it = item_or_404(db, rating_key)
|
||||
if it.playable == "transcode":
|
||||
raise HTTPException(
|
||||
status_code=501, detail="This format needs transcoding (a later phase)."
|
||||
)
|
||||
transcode = it.playable == "transcode"
|
||||
# `multi` (item has ≥2 audio tracks) forces the HLS path so ALL audio tracks ship as renditions and
|
||||
# the client can switch audio without a restart — even for otherwise direct-playable files.
|
||||
if it.playable == "direct" and audio is None and not aoff and not multi:
|
||||
@@ -470,15 +549,19 @@ def stream_session(
|
||||
}
|
||||
if plex_paths.local_media_path(db, it.file_path) is None:
|
||||
raise HTTPException(status_code=404, detail="Media file not found on disk")
|
||||
s = plex_stream.start_session(db, it, start, audio, aoff, multi)
|
||||
s = plex_stream.start_session(db, it, start, audio, aoff, multi, transcode=transcode)
|
||||
if s is None:
|
||||
raise HTTPException(status_code=404, detail="Media file not found on disk")
|
||||
# Report the REAL media start (the keyframe ffmpeg landed on, which hls.js zero-bases to), not the
|
||||
# requested offset — the client's absolute clock and the subtitle cue-shift both key off this.
|
||||
# `transcode`/`hw` let the player show an honest cold-start ("Preparing stream…") and surface the
|
||||
# active mode + encoder in the stats overlay; both segment streams are otherwise identical.
|
||||
return {
|
||||
"mode": "hls",
|
||||
"start_s": s.media_start_s,
|
||||
"url": f"/api/plex/stream/{it.rating_key}/hls/{s.entry}",
|
||||
"transcode": s.transcode,
|
||||
"hw": s.hw,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -122,9 +122,25 @@ def watch_file(token: str, g: str | None = None, db: Session = Depends(get_db)):
|
||||
raise HTTPException(status_code=404, detail="This video is no longer available.")
|
||||
|
||||
filename = storage.download_filename(asset.title or "video", asset.container, path)
|
||||
# The served Content-Type comes from the asset's ffprobe-verified media_kind + a container
|
||||
# allow-list (storage.served_media_type) — NEVER guessed from the file's own extension. A generic
|
||||
# 'file' (and any media whose container we don't positively recognise, e.g. a media/HTML polyglot
|
||||
# named "evil.html") returns None → a neutral force-download attachment, so arbitrary uploaded
|
||||
# HTML/SVG can't execute inline on this login-free public origin. A recognised media container is
|
||||
# served with its real audio/video type, inline unless the link forces a download so it can play
|
||||
# in the watch page. Both are range-aware (FileResponse → 206) for seeking/resume.
|
||||
media_type = storage.served_media_type(asset.media_kind, asset.container)
|
||||
if media_type is None:
|
||||
return FileResponse(
|
||||
path,
|
||||
filename=filename,
|
||||
media_type="application/octet-stream",
|
||||
content_disposition_type="attachment",
|
||||
)
|
||||
disposition = "attachment" if link.allow_download else "inline"
|
||||
# Starlette's FileResponse honours the Range header (206) for seeking/scrubbing.
|
||||
return FileResponse(path, filename=filename, content_disposition_type=disposition)
|
||||
return FileResponse(
|
||||
path, filename=filename, media_type=media_type, content_disposition_type=disposition
|
||||
)
|
||||
|
||||
|
||||
@router.get("/watch/{token}/poster.jpg")
|
||||
|
||||
+240
-71
@@ -1,14 +1,16 @@
|
||||
"""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
|
||||
edits out to YouTube (`plan_push` / `push_playlist`), behind the optional write scope.
|
||||
into local `source='youtube'` playlists (kept fresh by the scheduler). Write-back: two-way sync
|
||||
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
|
||||
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."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
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))
|
||||
|
||||
|
||||
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:
|
||||
"""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)."""
|
||||
@@ -116,12 +169,18 @@ def _replace_items_from_youtube(
|
||||
if v in present and v not in seen:
|
||||
seen.add(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))
|
||||
for pos, vid in enumerate(ordered):
|
||||
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:
|
||||
@@ -216,22 +275,91 @@ def _reorder_moves(current: list[str], desired: list[str]) -> int:
|
||||
return moves
|
||||
|
||||
|
||||
def plan_push(db: Session, user: User, pl: Playlist, *, live=None) -> dict:
|
||||
"""Compute (without writing) what pushing this local playlist to YouTube would do:
|
||||
create vs update, how many inserts/deletes/reorders, the quota estimate, and whether
|
||||
YouTube has diverged (items present there that local will remove). Costs read units
|
||||
(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."""
|
||||
desired = _local_video_ids(db, pl)
|
||||
@dataclass
|
||||
class MergePlan:
|
||||
"""The 3-way merge of a linked playlist: what each side gains and loses so neither side
|
||||
loses an item, plus the merged order. `final` is the id set both sides end up with."""
|
||||
|
||||
final: list[str] # merged order (local order, YouTube-only adds appended)
|
||||
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:
|
||||
ops = 1 + len(desired) # create the playlist + one insert per video
|
||||
ops = 1 + len(local) # create the playlist + one insert per video
|
||||
return {
|
||||
"action": "create",
|
||||
"to_insert": len(desired),
|
||||
"to_delete": 0,
|
||||
"add_yt": len(local),
|
||||
"add_local": 0,
|
||||
"remove_yt": 0,
|
||||
"remove_local": 0,
|
||||
"to_reorder": 0,
|
||||
"rename": False,
|
||||
"yt_extra": 0,
|
||||
"units_estimate": ops * WRITE_UNIT_COST,
|
||||
}
|
||||
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))
|
||||
snippet = yt.get_playlist_snippet(pl.yt_playlist_id)
|
||||
rename = bool(snippet) and (snippet.get("title") or "") != pl.name
|
||||
cur_vids = [it["video_id"] for it in current]
|
||||
cur_set = set(cur_vids)
|
||||
desired_set = set(desired)
|
||||
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)
|
||||
yt_ids = [it["video_id"] for it in current]
|
||||
mp = merge_plan(parse_baseline(pl), local, yt_ids, parse_unavailable(pl))
|
||||
ops = len(mp.add_to_yt) + len(mp.remove_from_yt) + mp.reorders + (1 if rename else 0)
|
||||
return {
|
||||
"action": "update",
|
||||
"to_insert": len(to_insert),
|
||||
"to_delete": len(to_delete),
|
||||
"to_reorder": reorders,
|
||||
"add_yt": len(mp.add_to_yt),
|
||||
"add_local": len(mp.add_to_local),
|
||||
"remove_yt": len(mp.remove_from_yt),
|
||||
"remove_local": len(mp.remove_from_local),
|
||||
"to_reorder": mp.reorders,
|
||||
"rename": rename,
|
||||
"yt_extra": len(to_delete),
|
||||
"units_estimate": ops * WRITE_UNIT_COST,
|
||||
}
|
||||
|
||||
@@ -327,13 +449,16 @@ class _Breaker:
|
||||
return out
|
||||
|
||||
|
||||
def push_playlist(db: Session, user: User, pl: Playlist, *, live=None) -> dict:
|
||||
"""Push a local playlist to YouTube: create it if needed, then reconcile membership
|
||||
and order so YouTube matches local (local wins). Marks the playlist clean on success.
|
||||
def merge_playlist(db: Session, user: User, pl: Playlist, *, live=None) -> dict:
|
||||
"""Two-way sync a local playlist with YouTube: create it on first push, else 3-way MERGE
|
||||
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
|
||||
(current_items, snippet) tuple (from plan_push's read) to avoid a second live page-through."""
|
||||
desired = _local_video_ids(db, pl)
|
||||
inserted = deleted = reordered = created = 0
|
||||
(current_items, snippet) tuple (from plan_merge's read) to avoid a second page-through."""
|
||||
local = _local_video_ids(db, pl)
|
||||
inserted = deleted = reordered = created = pulled = removed_local = 0
|
||||
failures: list[str] = []
|
||||
breaker = _Breaker()
|
||||
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)
|
||||
db.commit()
|
||||
created = 1
|
||||
# Brand-new playlist: just append every video in order.
|
||||
for vid in desired:
|
||||
# Brand-new playlist: just append every local video in order.
|
||||
for vid in local:
|
||||
added = breaker.run(
|
||||
lambda v=vid: yt.add_playlist_item(pl.yt_playlist_id, 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):
|
||||
inserted += 1
|
||||
if not failures:
|
||||
pl.synced_fingerprint = fingerprint(pl.name, desired)
|
||||
pl.dirty = False
|
||||
_set_baseline(pl, local)
|
||||
db.commit()
|
||||
return {
|
||||
"created": created,
|
||||
"inserted": inserted,
|
||||
"deleted": deleted,
|
||||
"reordered": reordered,
|
||||
"pulled": pulled,
|
||||
"removed_local": removed_local,
|
||||
"failures": failures,
|
||||
"aborted": breaker.tripped,
|
||||
"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:
|
||||
current, snippet = live
|
||||
else:
|
||||
snippet = yt.get_playlist_snippet(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:
|
||||
try:
|
||||
yt.update_playlist_title(pl.yt_playlist_id, pl.name)
|
||||
except YouTubeError:
|
||||
failures.append("title")
|
||||
item_id_by_vid = {it["video_id"]: it["item_id"] for it in current}
|
||||
cur_vids = [it["video_id"] for it in current]
|
||||
desired_set = set(desired)
|
||||
# Delete extras (local is authoritative).
|
||||
for it in current:
|
||||
if it["video_id"] not in desired_set:
|
||||
dropped = breaker.run(
|
||||
lambda i=it: yt.delete_playlist_item(i["item_id"]),
|
||||
lambda i=it: failures.append(i["video_id"]),
|
||||
)
|
||||
if not isinstance(dropped, _Failed):
|
||||
deleted += 1
|
||||
# Insert missing (append; the reorder pass below fixes positions).
|
||||
cur_set = set(cur_vids)
|
||||
for vid in desired:
|
||||
if vid not in cur_set:
|
||||
item_id = breaker.run(
|
||||
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
|
||||
inserted += 1
|
||||
# Reorder to match `desired` via insertion-sort moves over a local model.
|
||||
model = [v for v in cur_vids if v in desired_set] + [
|
||||
v for v in desired if v not in cur_set and v in item_id_by_vid
|
||||
# Propagate genuine local deletes.
|
||||
for vid in mp.remove_from_yt:
|
||||
item_id = item_id_by_vid.get(vid)
|
||||
if item_id is None:
|
||||
continue
|
||||
dropped = breaker.run(
|
||||
lambda i=item_id: yt.delete_playlist_item(i),
|
||||
lambda v=vid: failures.append(v),
|
||||
)
|
||||
if not isinstance(dropped, _Failed):
|
||||
deleted += 1
|
||||
item_id_by_vid.pop(vid, None)
|
||||
# Insert local-adds (append; the reorder pass below fixes positions).
|
||||
for vid in mp.add_to_yt:
|
||||
item_id = breaker.run(
|
||||
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
|
||||
inserted += 1
|
||||
# Reorder YouTube to match `final` via insertion-sort moves over a local model.
|
||||
final_set = set(mp.final)
|
||||
model = [v for v in yt_ids if v in final_set and v in item_id_by_vid] + [
|
||||
v for v in mp.add_to_yt if 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:
|
||||
continue # failed insert; skip
|
||||
continue # deleted-from-YT or failed insert; skip
|
||||
if i < len(model) and model[i] == want:
|
||||
continue
|
||||
moved = breaker.run(
|
||||
@@ -419,14 +579,23 @@ def push_playlist(db: Session, user: User, pl: Playlist, *, live=None) -> dict:
|
||||
model.pop(j)
|
||||
model.insert(i, want)
|
||||
if not failures:
|
||||
pl.synced_fingerprint = fingerprint(pl.name, desired)
|
||||
pl.dirty = False
|
||||
# Carry forward un-ingestable-but-live YouTube ids so the next merge skips them
|
||||
# (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()
|
||||
return {
|
||||
"created": created,
|
||||
"inserted": inserted,
|
||||
"deleted": deleted,
|
||||
"reordered": reordered,
|
||||
"pulled": pulled,
|
||||
"removed_local": removed_local,
|
||||
"failures": failures,
|
||||
"aborted": breaker.tripped,
|
||||
"yt_playlist_id": pl.yt_playlist_id,
|
||||
|
||||
@@ -127,6 +127,10 @@ SPECS: tuple[ConfigSpec, ...] = (
|
||||
min=1,
|
||||
max=100_000,
|
||||
),
|
||||
# min=1, NOT 0: the per-file cap is enforced as `if cap and size > cap`, so a stored 0 would read
|
||||
# as falsy and SILENTLY disable the limit (an unbounded streamed write to the volume). Floor it at
|
||||
# 1 so the cap is always active; the overall footprint is separately bounded by the byte quota.
|
||||
ConfigSpec("download_upload_max_bytes", "int", "downloads", "download_upload_max_bytes", min=1),
|
||||
ConfigSpec(
|
||||
"download_retention_days", "int", "downloads", "download_retention_days", min=0, max=3_650
|
||||
),
|
||||
@@ -155,6 +159,14 @@ SPECS: tuple[ConfigSpec, ...] = (
|
||||
ConfigSpec("plex_overlay_map", "str", "plex", "plex_overlay_map"),
|
||||
# (plex_sync_interval_min is tuned from the Scheduler dashboard, not here — it's a scheduler job.)
|
||||
ConfigSpec("plex_max_transcodes", "int", "plex", "plex_max_transcodes", min=0, max=16),
|
||||
# Transcode re-encode tuning (E6 S1). hw = "auto"|"on"|"off" (free-text, like search_source); the
|
||||
# libx264 knobs apply only to the software fallback. crf 0–51 (x264 range); max_height 0 = source.
|
||||
ConfigSpec("plex_transcode_hw", "str", "plex", "plex_transcode_hw"),
|
||||
ConfigSpec("plex_transcode_preset", "str", "plex", "plex_transcode_preset"),
|
||||
ConfigSpec("plex_transcode_crf", "int", "plex", "plex_transcode_crf", min=0, max=51),
|
||||
ConfigSpec(
|
||||
"plex_transcode_max_height", "int", "plex", "plex_transcode_max_height", min=0, max=4320
|
||||
),
|
||||
# NOTE: plex_watch_sync_interval_min is a config.py default read by the watch-sync scheduler job
|
||||
# (scheduler.py); it is intentionally NOT a runtime admin knob, so it stays out of SPECS.
|
||||
# --- TMDB enrichment (E5 S2; optional) — the API key is the only secret ---
|
||||
|
||||
@@ -43,6 +43,7 @@ pydantic-settings==2.15.0
|
||||
pydantic==2.13.4
|
||||
pydantic_core==2.46.4
|
||||
python-dotenv==1.2.3
|
||||
python-multipart==0.0.32
|
||||
requests==2.34.2
|
||||
starlette==1.6.0
|
||||
typing-inspection==0.4.4
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
fastapi>=0.115,<1.0
|
||||
# Multipart form parsing — required by Starlette/FastAPI to accept file uploads (UploadFile/Form).
|
||||
# The download center's local-file Upload (E7) is the only multipart endpoint in the app.
|
||||
python-multipart>=0.0.9,<1.0
|
||||
uvicorn[standard]>=0.30,<1.0
|
||||
sqlalchemy>=2.0,<2.1
|
||||
psycopg[binary]>=3.2,<4.0
|
||||
|
||||
@@ -55,6 +55,49 @@ def test_channel_detail_returns_normal_shorts_live_counts(db, monkeypatch):
|
||||
assert body["count_normal"] == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize("hidden", [True, False])
|
||||
def test_channel_detail_surfaces_subscription_hidden_flag(db, monkeypatch, hidden):
|
||||
# S6: the detail GET must expose the per-subscription `hidden` flag so the channel page can offer
|
||||
# the same Unhide toggle the manager has (the flag was previously omitted from the detail dict).
|
||||
ch = Channel(id="UC_hid", title="Hidden?", published_at=_PUB)
|
||||
db.add(ch)
|
||||
me = User(email="hid@example.com")
|
||||
db.add(me)
|
||||
db.flush()
|
||||
db.add(Subscription(user_id=me.id, channel_id=ch.id, hidden=hidden))
|
||||
db.commit()
|
||||
|
||||
monkeypatch.setattr(state, "_configured_cache", True)
|
||||
app.dependency_overrides[get_db] = lambda: db
|
||||
app.dependency_overrides[current_user] = lambda: me
|
||||
try:
|
||||
resp = TestClient(app).get(f"/api/channels/{ch.id}")
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["hidden"] is hidden
|
||||
|
||||
|
||||
def test_channel_detail_hidden_defaults_false_when_not_subscribed(db, monkeypatch):
|
||||
# No subscription row → the flag can't be True; it must serialize as False, not raise.
|
||||
ch = Channel(id="UC_hid_ns", title="Unsub", published_at=_PUB)
|
||||
me = User(email="hidns@example.com")
|
||||
db.add_all([ch, me])
|
||||
db.commit()
|
||||
|
||||
monkeypatch.setattr(state, "_configured_cache", True)
|
||||
app.dependency_overrides[get_db] = lambda: db
|
||||
app.dependency_overrides[current_user] = lambda: me
|
||||
try:
|
||||
resp = TestClient(app).get(f"/api/channels/{ch.id}")
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["hidden"] is False
|
||||
|
||||
|
||||
def _setup_channel_and_sub(db, *, email, is_demo=False):
|
||||
ch = Channel(
|
||||
id="UC_wire",
|
||||
|
||||
@@ -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: Join this channel to get access to members-only content": "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: Requested format is not available": "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
|
||||
|
||||
from app.downloads.edit import _concat_quote, parse_probe_streams
|
||||
from app.downloads.edit import _codecs_from_streams, _concat_quote, run_ffprobe_json
|
||||
from app.downloads.pipeline import _media_info, _produced_file
|
||||
from pathlib import Path, PurePosixPath
|
||||
|
||||
@@ -105,39 +105,76 @@ class TestMediaInfo:
|
||||
assert _media_info(info)["id"] == "vid"
|
||||
|
||||
|
||||
class TestParseProbeStreams:
|
||||
class TestCodecsFromStreams:
|
||||
# ffprobe emits codec_name BEFORE codec_type, so the old line parser shifted every name onto
|
||||
# the previous stream and an H.264+AAC mp4 came back as (aac, None) → a needless re-encode.
|
||||
# _codecs_from_streams reads each stream dict by its own codec_type, immune to that ordering.
|
||||
def test_reads_each_stream_by_its_own_type(self):
|
||||
payload = '{"streams":[{"codec_name":"h264","codec_type":"video"},{"codec_name":"aac","codec_type":"audio"}]}'
|
||||
assert parse_probe_streams(payload) == ("h264", "aac")
|
||||
streams = [
|
||||
{"codec_name": "h264", "codec_type": "video"},
|
||||
{"codec_name": "aac", "codec_type": "audio"},
|
||||
]
|
||||
assert _codecs_from_streams(streams) == ("h264", "aac")
|
||||
|
||||
def test_audio_first_ordering_is_handled(self):
|
||||
payload = '{"streams":[{"codec_name":"AAC","codec_type":"audio"},{"codec_name":"H264","codec_type":"video"}]}'
|
||||
assert parse_probe_streams(payload) == ("h264", "aac")
|
||||
streams = [
|
||||
{"codec_name": "AAC", "codec_type": "audio"},
|
||||
{"codec_name": "H264", "codec_type": "video"},
|
||||
]
|
||||
assert _codecs_from_streams(streams) == ("h264", "aac")
|
||||
|
||||
def test_extra_streams_do_not_win_over_the_first_of_their_kind(self):
|
||||
payload = (
|
||||
'{"streams":[{"codec_name":"h264","codec_type":"video"},'
|
||||
'{"codec_name":"vp9","codec_type":"video"},'
|
||||
'{"codec_name":"mov_text","codec_type":"subtitle"},'
|
||||
'{"codec_name":"opus","codec_type":"audio"}]}'
|
||||
)
|
||||
assert parse_probe_streams(payload) == ("h264", "opus")
|
||||
streams = [
|
||||
{"codec_name": "h264", "codec_type": "video"},
|
||||
{"codec_name": "vp9", "codec_type": "video"},
|
||||
{"codec_name": "mov_text", "codec_type": "subtitle"},
|
||||
{"codec_name": "opus", "codec_type": "audio"},
|
||||
]
|
||||
assert _codecs_from_streams(streams) == ("h264", "opus")
|
||||
|
||||
def test_audio_only_and_video_only(self):
|
||||
assert parse_probe_streams('{"streams":[{"codec_name":"opus","codec_type":"audio"}]}') == (
|
||||
assert _codecs_from_streams([{"codec_name": "opus", "codec_type": "audio"}]) == (
|
||||
None,
|
||||
"opus",
|
||||
)
|
||||
assert parse_probe_streams('{"streams":[{"codec_name":"av1","codec_type":"video"}]}') == (
|
||||
"av1",
|
||||
None,
|
||||
)
|
||||
assert _codecs_from_streams([{"codec_name": "av1", "codec_type": "video"}]) == ("av1", None)
|
||||
|
||||
def test_garbage_is_not_a_crash(self):
|
||||
assert parse_probe_streams("") == (None, None)
|
||||
assert parse_probe_streams("not json") == (None, None)
|
||||
def test_empty_stream_list(self):
|
||||
assert _codecs_from_streams([]) == (None, None)
|
||||
|
||||
|
||||
class TestRunFfprobeJson:
|
||||
"""The single ffprobe-JSON invocation point (shared by probe_codecs + upload._probe). A failed
|
||||
probe or unparseable output must degrade to None, not raise — the malformed-input safety the old
|
||||
parse_probe_streams string parser used to own now lives here."""
|
||||
|
||||
def test_unparseable_output_returns_none(self, monkeypatch):
|
||||
import subprocess
|
||||
|
||||
class _R:
|
||||
stdout = "not json"
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", lambda *a, **k: _R())
|
||||
assert run_ffprobe_json(Path("x")) is None
|
||||
|
||||
def test_ffprobe_failure_returns_none(self, monkeypatch):
|
||||
import subprocess
|
||||
|
||||
def _boom(*a, **k):
|
||||
raise subprocess.CalledProcessError(1, "ffprobe")
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", _boom)
|
||||
assert run_ffprobe_json(Path("x")) is None
|
||||
|
||||
def test_parses_streams_from_valid_json(self, monkeypatch):
|
||||
import subprocess
|
||||
|
||||
class _R:
|
||||
stdout = '{"streams":[{"codec_name":"h264","codec_type":"video"}]}'
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", lambda *a, **k: _R())
|
||||
data = run_ffprobe_json(Path("x"), entries="stream=codec_type,codec_name")
|
||||
assert _codecs_from_streams(data["streams"]) == ("h264", None)
|
||||
|
||||
|
||||
class TestConcatQuote:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""build_ydl_opts: the mp4 compat profile must SELECT H.264 (not merely prefer it), so shared
|
||||
downloads play on iOS/Safari, while custom vcodec / non-mp4 profiles opt out. See the iPad AV1
|
||||
broken-player fix."""
|
||||
"""build_ydl_opts: EVERY download is clamped to the mp4/H.264 compat profile (E7 browser-
|
||||
playability guarantee) and must SELECT H.264 (not merely prefer it), so shared downloads play in
|
||||
every browser incl. iOS/Safari. Non-mp4 containers and non-H.264 vcodecs are coerced, not honoured.
|
||||
See the iPad AV1 broken-player fix + E7 format narrowing."""
|
||||
|
||||
from app.downloads import edit as editmod
|
||||
from app.downloads import formats
|
||||
@@ -56,16 +57,37 @@ def test_max_height_caps_every_branch():
|
||||
assert fmt.count("[height<=?720]") >= 3 # applied to each avc1/fallback branch
|
||||
|
||||
|
||||
def test_explicit_vcodec_opts_out_of_avc1_selector():
|
||||
def test_explicit_vcodec_is_clamped_to_the_compat_selector():
|
||||
# E7 browser-playability guarantee: a non-H.264 vcodec no longer opts out — normalize() coerces
|
||||
# vcodec→None, so the avc1 selector (and mp4 merge) is used regardless of what was requested.
|
||||
opts = _opts({"mode": "av", "container": "mp4", "vcodec": "vp9"})
|
||||
assert "vcodec^=avc1" not in opts["format"]
|
||||
assert opts["format"] == "bestvideo+bestaudio/best"
|
||||
assert "vcodec:vp9" in opts["format_sort"]
|
||||
assert opts["format"].startswith("bestvideo[vcodec^=avc1]")
|
||||
assert opts["merge_output_format"] == "mp4"
|
||||
assert "vcodec:vp9" not in opts["format_sort"]
|
||||
|
||||
|
||||
def test_non_mp4_container_opts_out():
|
||||
fmt = _opts({"mode": "av", "container": "webm"})["format"]
|
||||
assert "vcodec^=avc1" not in fmt
|
||||
def test_non_mp4_container_is_clamped_to_mp4():
|
||||
# A webm/mkv request is coerced to the mp4/H.264 compat profile (nothing plays mkv in-browser;
|
||||
# webm/VP9 fails iOS), so the avc1 selector is used and the merge target is mp4.
|
||||
opts = _opts({"mode": "av", "container": "webm"})
|
||||
assert opts["format"].startswith("bestvideo[vcodec^=avc1]")
|
||||
assert opts["merge_output_format"] == "mp4"
|
||||
|
||||
|
||||
def test_normalize_clamps_to_browser_playable():
|
||||
n = formats.normalize({"container": "mkv", "vcodec": "av1", "audio_format": "opus"})
|
||||
assert n["container"] == "mp4"
|
||||
assert n["vcodec"] is None
|
||||
assert n["audio_format"] == "m4a"
|
||||
# A playable audio choice is preserved.
|
||||
assert formats.normalize({"audio_format": "mp3"})["audio_format"] == "mp3"
|
||||
|
||||
|
||||
def test_non_playable_specs_dedup_to_the_compat_identity():
|
||||
# Two requests that differ only in (now-clamped) non-playable fields share ONE cache asset.
|
||||
a = formats.format_sig({"mode": "av", "max_height": 720, "container": "webm", "vcodec": "av1"})
|
||||
b = formats.format_sig({"mode": "av", "max_height": 720, "container": "mp4"})
|
||||
assert a == b
|
||||
|
||||
|
||||
def test_audio_only_unaffected():
|
||||
|
||||
@@ -5,6 +5,8 @@ import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from app.config import settings
|
||||
from app.downloads import gc as gcmod
|
||||
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 sysfile.exists() # system tree untouched
|
||||
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 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():
|
||||
@@ -48,6 +55,67 @@ class TestGrant:
|
||||
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:
|
||||
def test_none_expiry_never_expires(self):
|
||||
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,158 @@
|
||||
"""S3: the "last watched" browse sort (`sort=lastwatched`). Movies rank by
|
||||
coalesce(progress_updated_at, watched_at); shows by max(coalesce(...)) across their episodes
|
||||
(rollup.resumed_at). Never-watched titles sink to the bottom (nullslast). DB lane (`db` fixture) —
|
||||
skipped without Postgres.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import app.state as state
|
||||
from app.auth import current_user
|
||||
from app.db import get_db
|
||||
from app.main import app
|
||||
from app.models import PlexItem, PlexLibrary, PlexShow, PlexState, User
|
||||
|
||||
NOW = datetime(2024, 6, 1, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def _seed(db):
|
||||
lib_m = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
||||
lib_s = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True)
|
||||
db.add_all([lib_m, lib_s])
|
||||
db.flush()
|
||||
|
||||
# Movies: newest resume, a watched-only (coalesce falls back to watched_at), an older resume, and a
|
||||
# never-touched one.
|
||||
mo_new = PlexItem(rating_key="mo_new", library_id=lib_m.id, kind="movie", title="Newest Resume")
|
||||
mo_watched = PlexItem(
|
||||
rating_key="mo_watched", library_id=lib_m.id, kind="movie", title="Watched Only"
|
||||
)
|
||||
mo_old = PlexItem(rating_key="mo_old", library_id=lib_m.id, kind="movie", title="Older Resume")
|
||||
mo_never = PlexItem(rating_key="mo_never", library_id=lib_m.id, kind="movie", title="Never")
|
||||
|
||||
# Shows: a recently-resumed episode, a watched-only episode, and a never-watched show.
|
||||
sh_recent = PlexShow(
|
||||
rating_key="sh_recent", library_id=lib_s.id, title="Recent Show", child_count=1
|
||||
)
|
||||
sh_watched = PlexShow(
|
||||
rating_key="sh_watched", library_id=lib_s.id, title="Watched Show", child_count=1
|
||||
)
|
||||
sh_never = PlexShow(
|
||||
rating_key="sh_never", library_id=lib_s.id, title="Never Show", child_count=1
|
||||
)
|
||||
db.add_all([mo_new, mo_watched, mo_old, mo_never, sh_recent, sh_watched, sh_never])
|
||||
db.flush()
|
||||
|
||||
ep_recent = PlexItem(
|
||||
rating_key="sh_recent_e1",
|
||||
library_id=lib_s.id,
|
||||
kind="episode",
|
||||
title="E1",
|
||||
show_id=sh_recent.id,
|
||||
season_number=1,
|
||||
episode_number=1,
|
||||
)
|
||||
ep_watched = PlexItem(
|
||||
rating_key="sh_watched_e1",
|
||||
library_id=lib_s.id,
|
||||
kind="episode",
|
||||
title="E1",
|
||||
show_id=sh_watched.id,
|
||||
season_number=1,
|
||||
episode_number=1,
|
||||
)
|
||||
db.add_all([ep_recent, ep_watched])
|
||||
|
||||
user = User(email="lastwatched@example.com")
|
||||
db.add(user)
|
||||
db.flush()
|
||||
|
||||
db.add_all(
|
||||
[
|
||||
PlexState(
|
||||
user_id=user.id,
|
||||
item_id=mo_new.id,
|
||||
status="new",
|
||||
position_seconds=100,
|
||||
progress_updated_at=NOW,
|
||||
),
|
||||
PlexState(
|
||||
user_id=user.id,
|
||||
item_id=mo_watched.id,
|
||||
status="watched",
|
||||
watched_at=NOW - timedelta(hours=1),
|
||||
),
|
||||
PlexState(
|
||||
user_id=user.id,
|
||||
item_id=mo_old.id,
|
||||
status="new",
|
||||
position_seconds=100,
|
||||
progress_updated_at=NOW - timedelta(hours=2),
|
||||
),
|
||||
# Show episodes.
|
||||
PlexState(
|
||||
user_id=user.id,
|
||||
item_id=ep_recent.id,
|
||||
status="new",
|
||||
position_seconds=50,
|
||||
progress_updated_at=NOW - timedelta(minutes=30),
|
||||
),
|
||||
PlexState(
|
||||
user_id=user.id,
|
||||
item_id=ep_watched.id,
|
||||
status="watched",
|
||||
watched_at=NOW - timedelta(minutes=90),
|
||||
),
|
||||
]
|
||||
)
|
||||
db.commit()
|
||||
return user
|
||||
|
||||
|
||||
def _get(db, user, monkeypatch, path):
|
||||
monkeypatch.setattr(state, "_configured_cache", True)
|
||||
app.dependency_overrides[get_db] = lambda: db
|
||||
app.dependency_overrides[current_user] = lambda: user
|
||||
try:
|
||||
return TestClient(app).get(path)
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def _ids(resp):
|
||||
return [i["id"] for i in resp.json()["items"]]
|
||||
|
||||
|
||||
def test_movies_ordered_by_last_watched_desc(db, monkeypatch):
|
||||
user = _seed(db)
|
||||
resp = _get(
|
||||
db, user, monkeypatch, "/api/plex/library?scope=movie&sort=lastwatched&sort_dir=desc"
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
# Newest resume, then the watched-only (coalesce → watched_at), then the older resume, then never.
|
||||
assert _ids(resp) == ["mo_new", "mo_watched", "mo_old", "mo_never"]
|
||||
|
||||
|
||||
def test_shows_ordered_by_last_watched_desc(db, monkeypatch):
|
||||
user = _seed(db)
|
||||
resp = _get(
|
||||
db, user, monkeypatch, "/api/plex/library?scope=show&sort=lastwatched&sort_dir=desc"
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
# Recent resume first, then the watched-only show, then the never-watched one (nullslast).
|
||||
assert _ids(resp) == ["sh_recent", "sh_watched", "sh_never"]
|
||||
|
||||
|
||||
def test_last_watched_mixed_scope_nullslast(db, monkeypatch):
|
||||
user = _seed(db)
|
||||
resp = _get(
|
||||
db, user, monkeypatch, "/api/plex/library?scope=both&sort=lastwatched&sort_dir=desc"
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
ids = _ids(resp)
|
||||
# Global recency order across both legs: mo_new (NOW) > sh_recent (−30m) > mo_watched (−1h) >
|
||||
# sh_watched (−90m) > mo_old (−2h); the two never-watched titles sink to the bottom.
|
||||
assert ids[:5] == ["mo_new", "sh_recent", "mo_watched", "sh_watched", "mo_old"]
|
||||
assert set(ids[5:]) == {"mo_never", "sh_never"}
|
||||
@@ -0,0 +1,107 @@
|
||||
"""The playback-session route's mode routing (E6 S1b).
|
||||
|
||||
`POST /stream/{rk}/session` is the single switch that decides how an item plays: a browser-native
|
||||
file is served raw (`mode:"direct"`), everything else (re)starts an HLS session — and a
|
||||
`playable="transcode"` item (non-h264: HEVC/VP9/AV1/…) must take that HLS path with the video
|
||||
RE-ENCODED, not stream-copied. That routing replaced a former `HTTP 501`, and the only thing that
|
||||
tells `start_session` to re-encode is the `transcode=` flag this route derives from `it.playable`.
|
||||
The command-builder itself is unit-tested in `test_plex_stream.py`; here we pin the ROUTE decision
|
||||
(no ffmpeg spawned — `start_session` is stubbed to echo the flag it was handed).
|
||||
|
||||
DB lane (`db` fixture) — skipped when no Postgres is reachable.
|
||||
"""
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import app.state as state
|
||||
from app.auth import current_user
|
||||
from app.db import get_db
|
||||
from app.main import app
|
||||
from app.models import PlexItem, PlexLibrary, User
|
||||
from app.plex import paths as plex_paths
|
||||
from app.plex import stream as plex_stream
|
||||
|
||||
|
||||
def _item(db, *, playable, rating_key="m1"):
|
||||
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
||||
db.add(lib)
|
||||
db.flush()
|
||||
it = PlexItem(
|
||||
rating_key=rating_key,
|
||||
library_id=lib.id,
|
||||
kind="movie",
|
||||
title="A Movie",
|
||||
file_path="/media/a.mkv",
|
||||
playable=playable,
|
||||
)
|
||||
db.add(it)
|
||||
db.commit()
|
||||
return it
|
||||
|
||||
|
||||
def _user(db, email="play@example.com"):
|
||||
u = User(email=email)
|
||||
db.add(u)
|
||||
db.flush()
|
||||
return u
|
||||
|
||||
|
||||
def _post(db, user, monkeypatch, rating_key, *, captured):
|
||||
"""Drive the endpoint with ffmpeg stubbed out. `start_session` records the `transcode` flag it
|
||||
was called with into `captured` and returns a stand-in session echoing it back."""
|
||||
monkeypatch.setattr(state, "_configured_cache", True) # pass the request gate
|
||||
monkeypatch.setattr(plex_stream, "reap_idle", lambda: None)
|
||||
monkeypatch.setattr(plex_paths, "local_media_path", lambda db_, fp: "/local/a.mkv")
|
||||
|
||||
def fake_start(db_, it, start, audio, aoff, multi, transcode=False):
|
||||
captured["transcode"] = transcode
|
||||
return SimpleNamespace(
|
||||
media_start_s=start, entry="index.m3u8", transcode=transcode, hw=False
|
||||
)
|
||||
|
||||
monkeypatch.setattr(plex_stream, "start_session", fake_start)
|
||||
app.dependency_overrides[get_db] = lambda: db
|
||||
app.dependency_overrides[current_user] = lambda: user
|
||||
try:
|
||||
return TestClient(app).post(f"/api/plex/stream/{rating_key}/session")
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_transcode_item_routes_to_an_hls_reencode_session(db, monkeypatch):
|
||||
# A non-h264 item (formerly HTTP 501) now plays: mode=hls, transcode=True threaded to start_session.
|
||||
it = _item(db, playable="transcode")
|
||||
user = _user(db)
|
||||
captured: dict = {}
|
||||
resp = _post(db, user, monkeypatch, it.rating_key, captured=captured)
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["mode"] == "hls"
|
||||
assert body["transcode"] is True
|
||||
assert captured["transcode"] is True # start_session was told to re-encode
|
||||
|
||||
|
||||
def test_remux_item_stays_a_stream_copy_session(db, monkeypatch):
|
||||
# A remux (h264) item takes the SAME HLS path but must NOT re-encode.
|
||||
it = _item(db, playable="remux", rating_key="m2")
|
||||
user = _user(db, "remux@example.com")
|
||||
captured: dict = {}
|
||||
resp = _post(db, user, monkeypatch, it.rating_key, captured=captured)
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["mode"] == "hls"
|
||||
assert body["transcode"] is False
|
||||
assert captured["transcode"] is False
|
||||
|
||||
|
||||
def test_direct_item_never_opens_a_session(db, monkeypatch):
|
||||
# A browser-native file is served raw — start_session is never reached, so no transcode decision.
|
||||
it = _item(db, playable="direct", rating_key="m3")
|
||||
user = _user(db, "direct@example.com")
|
||||
captured: dict = {}
|
||||
resp = _post(db, user, monkeypatch, it.rating_key, captured=captured)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["mode"] == "direct"
|
||||
assert "transcode" not in captured # start_session not called
|
||||
@@ -8,6 +8,7 @@ Since the sweep stopped being awaited at startup it also runs CONCURRENTLY with
|
||||
second half of this file pins what keeps it off a LIVE session's segments."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -154,3 +155,291 @@ class TestSweepOrphans:
|
||||
os.utime(f, (f.stat().st_mtime - 3600,) * 2) # old enough that only is_dir() saves it
|
||||
assert stream.sweep_orphans(older_than=_cutoff(hls_root)) == 0
|
||||
assert f.exists()
|
||||
|
||||
|
||||
class _FakeProc:
|
||||
"""Stand-in for the ffmpeg Popen — start_session only stores it (and _kill/reap poll it later)."""
|
||||
|
||||
def poll(self):
|
||||
return None
|
||||
|
||||
|
||||
class _FakeItem:
|
||||
def __init__(self, rating_key="12345", file_path="/media/movie.mkv", codec_video="h264"):
|
||||
self.rating_key = rating_key
|
||||
self.file_path = file_path
|
||||
self.playable = "remux"
|
||||
self.codec_video = codec_video
|
||||
|
||||
|
||||
class _CapturingProc:
|
||||
"""Records the argv + kwargs each Popen was called with, then behaves like the ffmpeg proc."""
|
||||
|
||||
calls: list[tuple] = []
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
_CapturingProc.calls.append((args, kwargs))
|
||||
|
||||
def poll(self):
|
||||
return None
|
||||
|
||||
|
||||
def _venc(cmd):
|
||||
"""The video codec ffmpeg was told to use (the value after the first `-c:v`)."""
|
||||
return cmd[cmd.index("-c:v") + 1]
|
||||
|
||||
|
||||
class TestTranscodeCommand:
|
||||
"""The transcode video re-encode (playable='transcode'): the SAME HLS command as remux with the
|
||||
video encoder swapped in for `-c:v copy`. The HW path can only be exercised on prod (VAAPI needs
|
||||
Linux + Intel /dev/dri), so its SHAPE is pinned here; the software path is the one UAT'd locally."""
|
||||
|
||||
SRC = Path("/media/movie.mkv")
|
||||
OUT = Path("/out")
|
||||
|
||||
def test_remux_still_stream_copies_the_video(self):
|
||||
cmd = stream._ffmpeg_cmd(self.SRC, 0.0, self.OUT, None)
|
||||
assert _venc(cmd) == "copy"
|
||||
assert "libx264" not in cmd and "h264_vaapi" not in cmd
|
||||
assert "-hwaccel" not in cmd # no GPU decode on the copy path
|
||||
assert "-force_key_frames" not in cmd # copy leans on the source's own keyframes
|
||||
|
||||
def test_software_transcode_uses_libx264_and_mints_keyframes(self):
|
||||
cmd = stream._ffmpeg_cmd(self.SRC, 0.0, self.OUT, None, transcode=True, hw=False)
|
||||
assert _venc(cmd) == "libx264"
|
||||
assert "copy" not in cmd # the video is no longer stream-copied
|
||||
# 8-bit High (yuv420p), not High10, so any browser decodes a 10-bit HEVC source.
|
||||
assert cmd[cmd.index("-pix_fmt") + 1] == "yuv420p"
|
||||
assert "-threads" in cmd # a core reserved for request handling / playback
|
||||
assert "-hwaccel" not in cmd # software path: CPU decode, no render node needed
|
||||
# A forced keyframe at every segment boundary so the HLS muxer cuts independent segments.
|
||||
assert (
|
||||
cmd[cmd.index("-force_key_frames") + 1] == f"expr:gte(t,n_forced*{stream._SEG_SECONDS})"
|
||||
)
|
||||
|
||||
def test_hardware_transcode_uses_the_full_vaapi_pipeline(self):
|
||||
cmd = stream._ffmpeg_cmd(self.SRC, 0.0, self.OUT, None, transcode=True, hw=True)
|
||||
assert _venc(cmd) == "h264_vaapi"
|
||||
assert "libx264" not in cmd
|
||||
# GPU decode options must precede the input so the source is decoded on-device.
|
||||
assert cmd[cmd.index("-hwaccel") + 1] == "vaapi"
|
||||
assert "-hwaccel_output_format" in cmd
|
||||
assert cmd[cmd.index("-vaapi_device") + 1] == stream._VAAPI_DEVICE
|
||||
assert cmd.index("-hwaccel") < cmd.index("-i")
|
||||
# scale_vaapi=format=nv12 downconverts a 10-bit source to 8-bit nv12 for h264_vaapi.
|
||||
assert "scale_vaapi=format=nv12" in cmd
|
||||
assert (
|
||||
cmd[cmd.index("-force_key_frames") + 1] == f"expr:gte(t,n_forced*{stream._SEG_SECONDS})"
|
||||
)
|
||||
|
||||
def test_multi_audio_transcode_reencodes_the_video_variant(self):
|
||||
cmd = stream._ffmpeg_cmd(
|
||||
self.SRC, 0.0, self.OUT, None, audio_langs=["eng", "hun"], transcode=True, hw=False
|
||||
)
|
||||
assert _venc(cmd) == "libx264" # video re-encoded…
|
||||
assert "-var_stream_map" in cmd # …but still multi-rendition (client-side audio switch)
|
||||
assert "copy" not in cmd
|
||||
|
||||
def test_software_preset_and_crf_come_from_the_knobs(self):
|
||||
cmd = stream._ffmpeg_cmd(
|
||||
self.SRC, 0.0, self.OUT, None, transcode=True, hw=False, preset="slow", crf=18
|
||||
)
|
||||
assert cmd[cmd.index("-preset") + 1] == "slow"
|
||||
assert cmd[cmd.index("-crf") + 1] == "18"
|
||||
|
||||
def test_software_max_height_caps_with_a_downscale_only_filter(self):
|
||||
cmd = stream._ffmpeg_cmd(
|
||||
self.SRC, 0.0, self.OUT, None, transcode=True, hw=False, max_height=720
|
||||
)
|
||||
# Downscale-only, aspect-preserving, even width; the comma in min() escaped so it's ONE filter.
|
||||
assert cmd[cmd.index("-vf") + 1] == "scale=-2:min(ih\\,720)"
|
||||
|
||||
def test_software_max_height_zero_means_no_scale_filter(self):
|
||||
cmd = stream._ffmpeg_cmd(
|
||||
self.SRC, 0.0, self.OUT, None, transcode=True, hw=False, max_height=0
|
||||
)
|
||||
assert "-vf" not in cmd # source resolution kept
|
||||
|
||||
def test_hardware_ignores_max_height(self):
|
||||
# The GPU encode is cheap, so it runs at source resolution; max_height is a software-only knob.
|
||||
cmd = stream._ffmpeg_cmd(
|
||||
self.SRC, 0.0, self.OUT, None, transcode=True, hw=True, max_height=720
|
||||
)
|
||||
assert cmd[cmd.index("-vf") + 1] == "scale_vaapi=format=nv12" # not a software scale
|
||||
|
||||
|
||||
class TestWantHw:
|
||||
"""VAAPI is used only where the render node was passed in AND (in auto) the source is HW-decodable,
|
||||
with an admin `plex_transcode_hw` policy (auto/on/off) on top."""
|
||||
|
||||
@staticmethod
|
||||
def _policy(monkeypatch, mode):
|
||||
monkeypatch.setattr(stream.sysconfig, "get_str", lambda db, k: mode)
|
||||
|
||||
def test_no_hw_when_not_transcoding(self, monkeypatch):
|
||||
self._policy(monkeypatch, "auto")
|
||||
monkeypatch.setattr(stream, "_render_node_present", lambda: True)
|
||||
assert stream._want_hw(None, _FakeItem(codec_video="hevc"), transcode=False) is False
|
||||
|
||||
def test_no_hw_without_a_render_node(self, monkeypatch):
|
||||
self._policy(monkeypatch, "auto")
|
||||
monkeypatch.setattr(stream, "_render_node_present", lambda: False)
|
||||
assert stream._want_hw(None, _FakeItem(codec_video="hevc"), transcode=True) is False
|
||||
|
||||
def test_auto_uses_hw_for_a_decodable_source_with_the_render_node(self, monkeypatch):
|
||||
self._policy(monkeypatch, "auto")
|
||||
monkeypatch.setattr(stream, "_render_node_present", lambda: True)
|
||||
assert stream._want_hw(None, _FakeItem(codec_video="hevc"), transcode=True) is True
|
||||
|
||||
def test_auto_falls_back_to_software_for_a_non_decodable_source(self, monkeypatch):
|
||||
# AV1 / MPEG-4 ASP (XviD) aren't decodable on Gen 9.5 → the GPU can't feed its own encoder.
|
||||
self._policy(monkeypatch, "auto")
|
||||
monkeypatch.setattr(stream, "_render_node_present", lambda: True)
|
||||
assert stream._want_hw(None, _FakeItem(codec_video="av1"), transcode=True) is False
|
||||
assert stream._want_hw(None, _FakeItem(codec_video="mpeg4"), transcode=True) is False
|
||||
|
||||
def test_off_forces_software_even_for_a_decodable_source(self, monkeypatch):
|
||||
self._policy(monkeypatch, "off")
|
||||
monkeypatch.setattr(stream, "_render_node_present", lambda: True)
|
||||
assert stream._want_hw(None, _FakeItem(codec_video="hevc"), transcode=True) is False
|
||||
|
||||
def test_on_forces_hw_even_for_a_non_decodable_source(self, monkeypatch):
|
||||
# The admin override trusts the GPU to decode it; skips the decodability gate (render node still
|
||||
# required — HW is impossible without it).
|
||||
self._policy(monkeypatch, "on")
|
||||
monkeypatch.setattr(stream, "_render_node_present", lambda: True)
|
||||
assert stream._want_hw(None, _FakeItem(codec_video="av1"), transcode=True) is True
|
||||
monkeypatch.setattr(stream, "_render_node_present", lambda: False)
|
||||
assert stream._want_hw(None, _FakeItem(codec_video="hevc"), transcode=True) is False
|
||||
|
||||
|
||||
class TestColdStartWarmup:
|
||||
"""The cold-start buffer warm-up: before reporting a session ready, start_session waits for the
|
||||
first _WARMUP_SEGMENTS video segments so hls.js doesn't drain a one-segment buffer and stall a
|
||||
few seconds in (the auto-advance-to-next-episode stall). We spy on wait_for instead of running
|
||||
ffmpeg."""
|
||||
|
||||
@pytest.fixture
|
||||
def fake_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)
|
||||
monkeypatch.setattr(stream.sysconfig, "get_int", lambda db, k: 2)
|
||||
monkeypatch.setattr(stream.subprocess, "Popen", lambda *a, **k: _FakeProc())
|
||||
return tmp_path
|
||||
|
||||
def test_warmup_waits_for_the_first_few_segments(self, fake_env, monkeypatch):
|
||||
waited = []
|
||||
|
||||
def spy(path, timeout=20.0):
|
||||
waited.append(path.name)
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(stream, "wait_for", spy)
|
||||
s = stream.start_session(db=None, item=_FakeItem(), start_s=0.0)
|
||||
assert s is not None
|
||||
# seg_0 (unconditional now) + the warm-up run up to _WARMUP_SEGMENTS.
|
||||
assert waited == [f"seg_{i}.ts" for i in range(stream._WARMUP_SEGMENTS)]
|
||||
|
||||
def test_warmup_stops_at_the_first_segment_that_isnt_ready(self, fake_env, monkeypatch):
|
||||
waited = []
|
||||
|
||||
def spy(path, timeout=20.0):
|
||||
waited.append(path.name)
|
||||
return path.name != "seg_1.ts" # seg_1 never appears → warm-up gives up there
|
||||
|
||||
monkeypatch.setattr(stream, "wait_for", spy)
|
||||
s = stream.start_session(db=None, item=_FakeItem(), start_s=0.0)
|
||||
assert s is not None
|
||||
assert waited == ["seg_0.ts", "seg_1.ts"] # never asked for seg_2
|
||||
|
||||
def test_no_warmup_wait_when_the_first_segment_never_arrives(self, fake_env, monkeypatch):
|
||||
# If seg_0 itself times out we don't warm up at all (nothing to build on) — and the session is
|
||||
# still returned so the client's own segment requests can drive it, as before.
|
||||
waited = []
|
||||
|
||||
def spy(path, timeout=20.0):
|
||||
waited.append(path.name)
|
||||
return False
|
||||
|
||||
monkeypatch.setattr(stream, "wait_for", spy)
|
||||
s = stream.start_session(db=None, item=_FakeItem(), start_s=0.0)
|
||||
assert s is not None
|
||||
assert waited == ["seg_0.ts"]
|
||||
|
||||
|
||||
class TestTranscodeStartSession:
|
||||
"""The seam TestTranscodeCommand can't reach: start_session reading the three admin knobs
|
||||
(plex_transcode_preset/crf/max_height) and threading them + transcode/hw into the ffmpeg argv and
|
||||
the HlsSession. A swapped knob here is a SILENT mis-encode (still plays), so pin the whole wiring
|
||||
end-to-end with a real (captured) Popen argv — not just _ffmpeg_cmd called with literals."""
|
||||
|
||||
@pytest.fixture
|
||||
def capturing_env(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(stream, "_HLS_ROOT", tmp_path)
|
||||
monkeypatch.setattr(stream, "_sessions", {})
|
||||
src = tmp_path / "movie.mkv"
|
||||
src.write_bytes(b"x")
|
||||
monkeypatch.setattr(stream.paths, "local_media_path", lambda db, fp: src)
|
||||
# Distinct sentinels per knob so a crf<->max_height swap (both ints, both via get_int) fails.
|
||||
monkeypatch.setattr(
|
||||
stream.sysconfig,
|
||||
"get_int",
|
||||
lambda db, k: {
|
||||
"plex_transcode_crf": 18,
|
||||
"plex_transcode_max_height": 480,
|
||||
"plex_max_transcodes": 4,
|
||||
}[k],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
stream.sysconfig,
|
||||
"get_str",
|
||||
lambda db, k: {"plex_transcode_preset": "slow"}.get(k, "auto"),
|
||||
)
|
||||
monkeypatch.setattr(stream, "wait_for", lambda path, timeout=20.0: True)
|
||||
_CapturingProc.calls = []
|
||||
monkeypatch.setattr(stream.subprocess, "Popen", _CapturingProc)
|
||||
return tmp_path
|
||||
|
||||
def test_software_transcode_threads_the_knobs_into_the_command(
|
||||
self, capturing_env, monkeypatch
|
||||
):
|
||||
monkeypatch.setattr(stream, "_want_hw", lambda db, item, transcode: False)
|
||||
s = stream.start_session(
|
||||
db=None, item=_FakeItem(codec_video="mpeg4"), start_s=0.0, transcode=True
|
||||
)
|
||||
assert s is not None and s.transcode is True and s.hw is False
|
||||
(cmd, *_), kwargs = _CapturingProc.calls[0]
|
||||
assert _venc(cmd) == "libx264"
|
||||
assert cmd[cmd.index("-preset") + 1] == "slow" # plex_transcode_preset
|
||||
assert cmd[cmd.index("-crf") + 1] == "18" # plex_transcode_crf, NOT max_height
|
||||
assert cmd[cmd.index("-vf") + 1] == "scale=-2:min(ih\\,480)" # plex_transcode_max_height
|
||||
# The CPU-bound child is niced via preexec_fn on POSIX (the API process itself must not be).
|
||||
if os.name == "posix":
|
||||
assert kwargs.get("preexec_fn") is stream._sw_child_preexec
|
||||
|
||||
def test_hardware_transcode_uses_the_gpu_and_never_nices(self, capturing_env, monkeypatch):
|
||||
monkeypatch.setattr(stream, "_want_hw", lambda db, item, transcode: True)
|
||||
s = stream.start_session(
|
||||
db=None, item=_FakeItem(codec_video="hevc"), start_s=0.0, transcode=True
|
||||
)
|
||||
assert s is not None and s.transcode is True and s.hw is True
|
||||
(cmd, *_), kwargs = _CapturingProc.calls[0]
|
||||
assert _venc(cmd) == "h264_vaapi"
|
||||
assert "libx264" not in cmd
|
||||
assert (
|
||||
"-vf" not in cmd or cmd[cmd.index("-vf") + 1] == "scale_vaapi=format=nv12"
|
||||
) # no SW cap
|
||||
assert kwargs.get("preexec_fn") is None # GPU encode is cheap — never niced
|
||||
|
||||
def test_remux_reads_no_transcode_knobs_and_stream_copies(self, capturing_env, monkeypatch):
|
||||
# A remux start must stream-copy the video and spawn no niced child (the transcode knobs and
|
||||
# the preexec nice are gated behind `transcode=True`).
|
||||
monkeypatch.setattr(stream, "_want_hw", lambda db, item, transcode: False)
|
||||
s = stream.start_session(db=None, item=_FakeItem(), start_s=0.0) # transcode defaults False
|
||||
assert s is not None and s.transcode is False
|
||||
(cmd, *_), kwargs = _CapturingProc.calls[0]
|
||||
assert _venc(cmd) == "copy"
|
||||
assert kwargs.get("preexec_fn") is None
|
||||
|
||||
@@ -0,0 +1,386 @@
|
||||
"""S2 watch-sync semantics: Plex is the source of truth.
|
||||
|
||||
Two independent pieces:
|
||||
- `_pull_apply` (DB lane): on a genuine divergence for an item Plex knows about, Plex now WINS —
|
||||
the old "keep Siftlode when it's newer within a tolerance" tug-of-war is gone. The anti-bounce
|
||||
short-circuit (`_same_state` + `synced_to_plex`) must still keep a freshly-pushed row from bouncing.
|
||||
- `_should_push_resume` (pure): the in-memory write-back throttle — a `final` checkpoint always pushes,
|
||||
a periodic one pushes at most once per _PUSH_THROTTLE_S seconds per (user, item).
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from types import SimpleNamespace
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models import PlexItem, PlexLibrary, PlexState, User
|
||||
from app.plex.watch_sync import _pull_apply, _reconcile_inprogress_movies
|
||||
from app.routes.plex.playback import (
|
||||
_PUSH_THROTTLE_S,
|
||||
_resume_push_superseded,
|
||||
_should_push_resume,
|
||||
)
|
||||
|
||||
|
||||
def _seed_item_and_user(db):
|
||||
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
||||
db.add(lib)
|
||||
db.flush()
|
||||
item = PlexItem(rating_key="m1", library_id=lib.id, kind="movie", title="A Movie")
|
||||
db.add(item)
|
||||
user = User(email="watch@example.com")
|
||||
db.add(user)
|
||||
db.flush()
|
||||
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):
|
||||
"""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
|
||||
is authoritative, no LWW. (A DIRTY local row is protected instead — see the dirty-guard test.)"""
|
||||
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, # local touched just now — "newer" than Plex
|
||||
synced_to_plex=True, # but already pushed → a later Plex divergence is an external change
|
||||
)
|
||||
)
|
||||
db.flush()
|
||||
|
||||
# Plex reports a DIFFERENT (older) resume position for the same item.
|
||||
changed = _pull_apply(db, user_id, item_id, "new", 120, plex_ts=now - timedelta(seconds=60))
|
||||
db.flush()
|
||||
|
||||
assert changed == "in_progress"
|
||||
st = (
|
||||
db.execute(
|
||||
select(PlexState).where(PlexState.user_id == user_id, PlexState.item_id == item_id)
|
||||
)
|
||||
.scalars()
|
||||
.one()
|
||||
)
|
||||
assert st.position_seconds == 120, "Plex's position must win, even though local was newer"
|
||||
assert st.synced_to_plex is True
|
||||
|
||||
|
||||
def test_pull_plex_watched_wins_over_synced_local_in_progress(db):
|
||||
"""Plex marking the item watched propagates over a SYNCED local in-progress row (external change)."""
|
||||
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()
|
||||
|
||||
changed = _pull_apply(db, user_id, item_id, "watched", 0, plex_ts=now - timedelta(seconds=60))
|
||||
db.flush()
|
||||
|
||||
assert changed == "watched"
|
||||
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_pull_no_bounce_on_matching_row(db):
|
||||
"""Anti-bounce short-circuit: when the row already matches what Plex reports (a just-pushed
|
||||
resume), the pull makes NO change and only settles the synced flag — no churn, no re-pull."""
|
||||
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=100,
|
||||
progress_updated_at=now,
|
||||
synced_to_plex=True, # freshly pushed
|
||||
)
|
||||
)
|
||||
db.flush()
|
||||
|
||||
# Plex echoes the same position a beat later (well within the _PROGRESS_MIN_S fuzz).
|
||||
changed = _pull_apply(db, user_id, item_id, "new", 102, plex_ts=now + timedelta(seconds=2))
|
||||
db.flush()
|
||||
|
||||
assert changed is None, "a matching row must not bounce"
|
||||
st = (
|
||||
db.execute(
|
||||
select(PlexState).where(PlexState.user_id == user_id, PlexState.item_id == item_id)
|
||||
)
|
||||
.scalars()
|
||||
.one()
|
||||
)
|
||||
assert st.position_seconds == 100, "no churn to the stored position"
|
||||
|
||||
|
||||
def test_pull_keeps_dirty_local_on_divergence(db):
|
||||
"""Durability guard: a DIRTY row (a local change whose immediate push failed → synced_to_plex=False)
|
||||
must NOT be overwritten by a diverging Plex signal, and must stay dirty so `_repush_dirty` can push
|
||||
the local value up. Overwriting-and-clearing here would strand the local change forever."""
|
||||
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="watched", # user just marked watched locally; the scrobble to Plex failed
|
||||
position_seconds=0,
|
||||
watched_at=now,
|
||||
progress_updated_at=now,
|
||||
synced_to_plex=False, # dirty — never reached Plex
|
||||
)
|
||||
)
|
||||
db.flush()
|
||||
|
||||
# Plex still lists it in on_deck at an old in-progress offset (it never got the watched scrobble).
|
||||
changed = _pull_apply(db, user_id, item_id, "new", 240, plex_ts=now - timedelta(seconds=60))
|
||||
db.flush()
|
||||
|
||||
assert changed is None, "a dirty local row must not be clobbered by the pull"
|
||||
st = (
|
||||
db.execute(
|
||||
select(PlexState).where(PlexState.user_id == user_id, PlexState.item_id == item_id)
|
||||
)
|
||||
.scalars()
|
||||
.one()
|
||||
)
|
||||
assert st.status == "watched", "local watched preserved"
|
||||
assert st.position_seconds == 0
|
||||
assert st.synced_to_plex is False, "stays dirty so _repush_dirty retries the push"
|
||||
|
||||
|
||||
def test_pull_settles_flag_on_matching_dirty_row(db):
|
||||
"""Anti-bounce, dirty branch: a row that already MATCHES Plex but is still dirty gets its
|
||||
synced_to_plex flag settled to True (no churn), so `_repush_dirty` stops re-pushing it forever."""
|
||||
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=100,
|
||||
progress_updated_at=now,
|
||||
synced_to_plex=False, # dirty, but already agrees with Plex
|
||||
)
|
||||
)
|
||||
db.flush()
|
||||
|
||||
changed = _pull_apply(db, user_id, item_id, "new", 102, plex_ts=now + timedelta(seconds=2))
|
||||
db.flush()
|
||||
|
||||
assert changed is None, "a matching row makes no change"
|
||||
st = (
|
||||
db.execute(
|
||||
select(PlexState).where(PlexState.user_id == user_id, PlexState.item_id == item_id)
|
||||
)
|
||||
.scalars()
|
||||
.one()
|
||||
)
|
||||
assert st.position_seconds == 100, "no churn to the stored position"
|
||||
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():
|
||||
"""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."""
|
||||
now = datetime.now(timezone.utc)
|
||||
assert _should_push_resume(9101, 9201, now, final=False) is True # (u1, i1) first tick
|
||||
# Same window, different item for the same user → independent, still pushes.
|
||||
assert _should_push_resume(9101, 9202, now, final=False) is True
|
||||
# Same window, different user, same item → independent, still pushes.
|
||||
assert _should_push_resume(9102, 9201, now, final=False) is True
|
||||
# And the original key is still throttled within its window (proves the above weren't just "always").
|
||||
assert _should_push_resume(9101, 9201, now + timedelta(seconds=1), final=False) is False
|
||||
|
||||
|
||||
def test_resume_push_superseded_drops_older_pending_push():
|
||||
"""The write-ordering guard: an older still-pending resume push is dropped once a newer push for the
|
||||
same (user, item) has been scheduled, so a reordered stale position can't land at Plex last."""
|
||||
key = (9301, 9301)
|
||||
t0 = datetime.now(timezone.utc)
|
||||
# Schedule an initial push at t0 (records t0 as the last-push time for this key).
|
||||
assert _should_push_resume(*key, t0, final=False) is True
|
||||
# Nothing newer yet → the t0 push is current, not superseded.
|
||||
assert _resume_push_superseded(*key, t0) is False
|
||||
# A newer push (e.g. a backward seek) is scheduled at t1 > t0.
|
||||
t1 = t0 + timedelta(seconds=1)
|
||||
assert _should_push_resume(*key, t1, final=True) is True
|
||||
# Now the still-pending t0 push is superseded and must drop; the t1 push is current.
|
||||
assert _resume_push_superseded(*key, t0) is True
|
||||
assert _resume_push_superseded(*key, t1) is False
|
||||
|
||||
|
||||
def test_should_push_resume_final_always_pushes():
|
||||
"""A final checkpoint (pause / seek / stop) always pushes, bypassing the throttle."""
|
||||
now = datetime.now(timezone.utc)
|
||||
key = (9001, 9001)
|
||||
assert _should_push_resume(*key, now, final=True) is True
|
||||
# A second final a fraction of a second later still pushes.
|
||||
assert _should_push_resume(*key, now + timedelta(seconds=1), final=True) is True
|
||||
|
||||
|
||||
def test_should_push_resume_periodic_is_throttled():
|
||||
"""Periodic (non-final) checkpoints push at most once per _PUSH_THROTTLE_S per (user, item)."""
|
||||
now = datetime.now(timezone.utc)
|
||||
key = (9002, 9002)
|
||||
assert _should_push_resume(*key, now, final=False) is True # first tick pushes
|
||||
# A tick within the window is suppressed.
|
||||
assert (
|
||||
_should_push_resume(*key, now + timedelta(seconds=_PUSH_THROTTLE_S - 1), final=False)
|
||||
is False
|
||||
)
|
||||
# Once the window elapses, the next tick pushes again.
|
||||
assert _should_push_resume(*key, now + timedelta(seconds=_PUSH_THROTTLE_S), final=False) is True
|
||||
@@ -0,0 +1,394 @@
|
||||
"""E7 S5a — local file upload ingest (the non-yt-dlp path into the media library).
|
||||
|
||||
Covers the ingest spine: media vs generic-file classification, the download-only serving of a
|
||||
generic file (force-download so uploaded HTML/SVG can't execute inline), quota accounting with the
|
||||
size known up front, the per-file cap enforced while streaming, filename sanitization, and the two
|
||||
guards that keep an upload out of paths that assume a re-fetchable source (edit / redownload).
|
||||
|
||||
DB-backed (real Postgres) — skipped when no database is reachable. `settings.download_root` is
|
||||
pointed at a per-test tmp dir so ingest's on-disk moves are isolated. The ingest/route tests
|
||||
monkeypatch `_probe` so they don't shell out to ffprobe; the classifier's OWN parsing (image →
|
||||
file, cover-art skip, dims, duration) is covered directly by `TestProbeClassifier` below, which
|
||||
stubs one level down (`run_ffprobe_json`) and exercises the real `_probe`.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.config import settings
|
||||
from app.downloads import mediakind, quota, storage
|
||||
from app.downloads import upload as uploadmod
|
||||
from app.models import DownloadQuota, MediaAsset
|
||||
|
||||
|
||||
# --- pure: the kind invariant ---------------------------------------------------------------
|
||||
|
||||
|
||||
def test_derive_media_kind():
|
||||
assert uploadmod.mediakind.derive("h264", "aac") == mediakind.VIDEO
|
||||
assert uploadmod.mediakind.derive("vp9", None) == mediakind.VIDEO
|
||||
assert uploadmod.mediakind.derive(None, "aac") == mediakind.AUDIO
|
||||
assert uploadmod.mediakind.derive(None, None) == mediakind.FILE
|
||||
|
||||
|
||||
# --- ingest (direct) ------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _root(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(settings, "download_root", str(tmp_path))
|
||||
return tmp_path
|
||||
|
||||
|
||||
def _probe(monkeypatch, result):
|
||||
monkeypatch.setattr(uploadmod, "_probe", lambda _p: result)
|
||||
|
||||
|
||||
def test_ingest_generic_file_is_download_only(db, make_user, _root, monkeypatch):
|
||||
_probe(monkeypatch, (None, None, None, None, None))
|
||||
u = make_user()
|
||||
src = _root / "src.bin"
|
||||
src.write_bytes(b"%PDF-1.4 hello")
|
||||
job = uploadmod.ingest_upload(
|
||||
db, u.id, filename="My Notes.pdf", src_path=src, size=src.stat().st_size
|
||||
)
|
||||
asset = db.get(MediaAsset, job.asset_id)
|
||||
assert asset.media_kind == mediakind.FILE
|
||||
assert asset.status == "ready" and job.status == "done" and job.progress == 100
|
||||
assert asset.container == "pdf"
|
||||
assert asset.poster_path is None # a generic file has no player and no poster
|
||||
assert asset.title == "My Notes" # display title from the original name
|
||||
assert job.job_kind == "upload" and job.source_kind == "upload"
|
||||
# The file was MOVED into the per-user uploads tree, inside the root, and the temp is gone.
|
||||
assert not src.exists()
|
||||
assert asset.rel_path.startswith(".uploads/")
|
||||
assert storage.safe_abs_path(str(_root), asset.rel_path) is not None
|
||||
|
||||
|
||||
def test_ingest_audio_is_media_no_poster(db, make_user, _root, monkeypatch):
|
||||
_probe(monkeypatch, (None, "aac", None, None, 200))
|
||||
u = make_user()
|
||||
src = _root / "song.m4a"
|
||||
src.write_bytes(b"fake-audio")
|
||||
job = uploadmod.ingest_upload(db, u.id, filename="song.m4a", src_path=src, size=10)
|
||||
asset = db.get(MediaAsset, job.asset_id)
|
||||
assert asset.media_kind == mediakind.AUDIO
|
||||
assert asset.duration_s == 200
|
||||
assert asset.poster_path is None # audio has no frame to poster
|
||||
|
||||
|
||||
def test_ingest_video_records_dims_and_poster(db, make_user, _root, monkeypatch):
|
||||
_probe(monkeypatch, ("h264", "aac", 640, 480, 60))
|
||||
# Stub the frame-cut so the test doesn't shell out to ffmpeg.
|
||||
monkeypatch.setattr(uploadmod.editmod, "ensure_poster", lambda _a, _root: "p/x.jpg")
|
||||
u = make_user()
|
||||
src = _root / "clip.mp4"
|
||||
src.write_bytes(b"fake-video")
|
||||
job = uploadmod.ingest_upload(db, u.id, filename="clip.mp4", src_path=src, size=10)
|
||||
asset = db.get(MediaAsset, job.asset_id)
|
||||
assert asset.media_kind == mediakind.VIDEO
|
||||
assert (asset.width, asset.height) == (640, 480)
|
||||
assert asset.poster_path == "p/x.jpg"
|
||||
|
||||
|
||||
def test_ingest_filename_is_sanitized_no_traversal(db, make_user, _root, monkeypatch):
|
||||
_probe(monkeypatch, (None, None, None, None, None))
|
||||
u = make_user()
|
||||
src = _root / "src"
|
||||
src.write_bytes(b"x")
|
||||
job = uploadmod.ingest_upload(db, u.id, filename="../../etc/passwd", src_path=src, size=1)
|
||||
asset = db.get(MediaAsset, job.asset_id)
|
||||
assert ".." not in asset.rel_path
|
||||
assert asset.rel_path.startswith(f".uploads/{u.id}/")
|
||||
assert storage.safe_abs_path(str(_root), asset.rel_path) is not None
|
||||
|
||||
|
||||
def test_ingest_rejects_over_footprint_cap_before_writing(db, make_user, _root, monkeypatch):
|
||||
_probe(monkeypatch, (None, None, None, None, None))
|
||||
u = make_user()
|
||||
db.add(
|
||||
DownloadQuota(user_id=u.id, max_bytes=1000, max_concurrent=2, max_jobs=50, unlimited=False)
|
||||
)
|
||||
db.commit()
|
||||
src = _root / "big.bin"
|
||||
src.write_bytes(b"x" * 10)
|
||||
with pytest.raises(quota.QuotaExceeded) as e:
|
||||
uploadmod.ingest_upload(db, u.id, filename="big.bin", src_path=src, size=2000)
|
||||
assert e.value.reason == "max_bytes"
|
||||
assert src.exists() # rejected before any disk move — nothing landed
|
||||
assert db.query(MediaAsset).count() == 0 # and no orphan asset row
|
||||
|
||||
|
||||
# --- route ----------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_upload_route_generic_forces_download(as_user, make_user, _root, monkeypatch):
|
||||
_probe(monkeypatch, (None, None, None, None, None))
|
||||
c = as_user(make_user())
|
||||
r = c.post(
|
||||
"/api/downloads/upload",
|
||||
files={"file": ("report.html", b"<script>alert(1)</script>", "text/html")},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
job = r.json()
|
||||
assert job["asset"]["media_kind"] == "file"
|
||||
assert job["can_download"] is True
|
||||
# The served file must be a neutral force-download attachment — never inline text/html.
|
||||
fr = c.get(f"/api/downloads/{job['id']}/file")
|
||||
assert fr.status_code == 200
|
||||
assert fr.headers["content-type"].startswith("application/octet-stream")
|
||||
assert "attachment" in fr.headers["content-disposition"].lower()
|
||||
|
||||
|
||||
def test_upload_route_media_html_extension_not_served_as_html(
|
||||
as_user, make_user, _root, monkeypatch
|
||||
):
|
||||
"""The XSS guard: a MEDIA-classified upload (real codecs) named with an executable extension must
|
||||
NOT be served with a guessed text/html type. The served Content-Type comes from the verified
|
||||
media_kind + a container allow-list ('html' isn't one), so it force-downloads as octet-stream —
|
||||
on both the authenticated file endpoint AND the login-free public watch endpoint. Without the fix
|
||||
the public inline branch let a media/HTML polyglot named evil.html run as stored XSS."""
|
||||
_probe(monkeypatch, ("h264", "aac", 320, 240, 5)) # ffprobe says it's video…
|
||||
c = as_user(make_user())
|
||||
r = c.post(
|
||||
"/api/downloads/upload",
|
||||
files={"file": ("evil.html", b"<script>alert(1)</script>", "text/html")},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
job = r.json()
|
||||
assert job["asset"]["media_kind"] == "video" and job["asset"]["container"] == "html"
|
||||
|
||||
# Authenticated endpoint: never text/html.
|
||||
fr = c.get(f"/api/downloads/{job['id']}/file")
|
||||
assert fr.status_code == 200
|
||||
assert fr.headers["content-type"].startswith("application/octet-stream")
|
||||
assert "attachment" in fr.headers["content-disposition"].lower()
|
||||
|
||||
# Public login-free endpoint via a STREAM-ONLY link (the inline branch) — still not text/html.
|
||||
link = c.post(f"/api/downloads/{job['id']}/links", json={"allow_download": False}).json()
|
||||
pr = c.get(f"/api/public/watch/{link['token']}/file")
|
||||
assert pr.status_code == 200
|
||||
assert not pr.headers["content-type"].startswith("text/html")
|
||||
assert pr.headers["content-type"].startswith("application/octet-stream")
|
||||
assert "attachment" in pr.headers["content-disposition"].lower()
|
||||
|
||||
|
||||
def test_public_watch_file_forces_download_for_generic_file(as_user, make_user, _root, monkeypatch):
|
||||
"""The login-free public serve of a generic upload (media_kind='file') must be a neutral
|
||||
force-download attachment — the branch that stops uploaded HTML/SVG executing on the public
|
||||
origin. This is a SEPARATE code path from the authenticated /file route, so it needs its own
|
||||
route-level assertion (previously only the authenticated branch was tested)."""
|
||||
_probe(monkeypatch, (None, None, None, None, None))
|
||||
c = as_user(make_user())
|
||||
job = c.post(
|
||||
"/api/downloads/upload",
|
||||
files={"file": ("notes.html", b"<script>alert(1)</script>", "text/html")},
|
||||
).json()
|
||||
assert job["asset"]["media_kind"] == "file"
|
||||
link = c.post(f"/api/downloads/{job['id']}/links", json={"allow_download": True}).json()
|
||||
pr = c.get(f"/api/public/watch/{link['token']}/file")
|
||||
assert pr.status_code == 200
|
||||
assert pr.headers["content-type"].startswith("application/octet-stream")
|
||||
assert "attachment" in pr.headers["content-disposition"].lower()
|
||||
|
||||
|
||||
def test_public_watch_file_serves_media_inline_with_real_type(
|
||||
as_user, make_user, _root, monkeypatch
|
||||
):
|
||||
"""A recognised media container IS still served inline with its real audio/video type (so it
|
||||
plays in the watch page) — the fix must not break legitimate playback. A stream-only link →
|
||||
inline; the type is video/mp4 (from the container allow-list), never text/* and never a guess."""
|
||||
_probe(monkeypatch, ("h264", "aac", 640, 360, 12))
|
||||
c = as_user(make_user())
|
||||
job = c.post(
|
||||
"/api/downloads/upload",
|
||||
files={"file": ("clip.mp4", b"fake-mp4-bytes", "video/mp4")},
|
||||
).json()
|
||||
link = c.post(f"/api/downloads/{job['id']}/links", json={"allow_download": False}).json()
|
||||
pr = c.get(f"/api/public/watch/{link['token']}/file")
|
||||
assert pr.status_code == 200
|
||||
assert pr.headers["content-type"].startswith("video/mp4")
|
||||
assert "inline" in pr.headers["content-disposition"].lower()
|
||||
|
||||
|
||||
def test_upload_route_enforces_per_file_cap(as_user, make_user, db, _root, monkeypatch):
|
||||
from app import sysconfig
|
||||
|
||||
sysconfig.set_value(db, "download_upload_max_bytes", 5)
|
||||
db.commit()
|
||||
c = as_user(make_user())
|
||||
r = c.post(
|
||||
"/api/downloads/upload",
|
||||
files={"file": ("big.bin", b"way over the cap", "application/octet-stream")},
|
||||
)
|
||||
assert r.status_code == 422
|
||||
assert r.json()["detail"]["code"] == "upload_too_large"
|
||||
|
||||
|
||||
def test_upload_cap_cannot_be_disabled_with_zero(db):
|
||||
"""download_upload_max_bytes floors at 1: a stored 0 would read falsy in the route's
|
||||
`if cap and size > cap` guard and SILENTLY disable the per-file cap, so set_value must reject it.
|
||||
Pins the min=1 invariant so a revert to min=0 can't pass the gate."""
|
||||
from app import sysconfig
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
sysconfig.set_value(db, "download_upload_max_bytes", 0)
|
||||
|
||||
|
||||
def test_upload_route_rejects_empty_file(as_user, make_user, _root, monkeypatch):
|
||||
c = as_user(make_user())
|
||||
r = c.post(
|
||||
"/api/downloads/upload", files={"file": ("empty.bin", b"", "application/octet-stream")}
|
||||
)
|
||||
assert r.status_code == 400
|
||||
assert r.json()["detail"]["code"] == "upload_empty"
|
||||
|
||||
|
||||
def test_redownload_rejects_an_upload(as_user, make_user, _root, monkeypatch):
|
||||
_probe(monkeypatch, (None, None, None, None, None))
|
||||
c = as_user(make_user())
|
||||
job = c.post(
|
||||
"/api/downloads/upload", files={"file": ("a.bin", b"data", "application/octet-stream")}
|
||||
).json()
|
||||
r = c.post(f"/api/downloads/{job['id']}/redownload")
|
||||
assert r.status_code == 409
|
||||
|
||||
|
||||
def test_edit_rejects_a_generic_upload_source(as_user, make_user, _root, monkeypatch):
|
||||
_probe(monkeypatch, (None, None, None, None, None))
|
||||
c = as_user(make_user())
|
||||
job = c.post(
|
||||
"/api/downloads/upload", files={"file": ("a.bin", b"data", "application/octet-stream")}
|
||||
).json()
|
||||
r = c.post(
|
||||
"/api/downloads/edit",
|
||||
json={"source_job_id": job["id"], "edit_spec": {"trim": {"start_s": 0, "end_s": 1}}},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
assert r.json()["detail"]["reason"] == "not_media"
|
||||
|
||||
|
||||
def test_ingest_rejects_over_max_jobs(db, make_user, _root, monkeypatch):
|
||||
"""The other check_upload rejection branch (holdings count, not bytes): once the user is at
|
||||
max_jobs, a further upload is refused before any disk write."""
|
||||
_probe(monkeypatch, (None, None, None, None, None))
|
||||
u = make_user()
|
||||
db.add(
|
||||
DownloadQuota(user_id=u.id, max_bytes=10**12, max_concurrent=2, max_jobs=1, unlimited=False)
|
||||
)
|
||||
db.commit()
|
||||
src1 = _root / "a.bin"
|
||||
src1.write_bytes(b"a")
|
||||
uploadmod.ingest_upload(db, u.id, filename="a.bin", src_path=src1, size=1) # now 1 holding job
|
||||
src2 = _root / "b.bin"
|
||||
src2.write_bytes(b"b")
|
||||
with pytest.raises(quota.QuotaExceeded) as e:
|
||||
uploadmod.ingest_upload(db, u.id, filename="b.bin", src_path=src2, size=1)
|
||||
assert e.value.reason == "max_jobs"
|
||||
assert src2.exists() # rejected before the move — nothing landed
|
||||
|
||||
|
||||
def test_ingest_cleans_up_placed_files_on_commit_failure(db, make_user, _root, monkeypatch):
|
||||
"""A failure AFTER storage.place_file has moved the file onto disk must roll back AND unlink the
|
||||
placed file + any (even partial) poster — otherwise the orphan is permanent (the per-user delete
|
||||
keys off the rolled-back asset.rel_path, and the GC orphan sweep skips the dot-prefixed .uploads/
|
||||
tree). Exercises the rollback/cleanup branch that IS the leak remediation, incl. the poster case
|
||||
where ensure_poster left a partial file but returned None."""
|
||||
_probe(monkeypatch, ("h264", "aac", 320, 240, 5)) # video → the poster path runs
|
||||
u = make_user()
|
||||
|
||||
# Simulate ffmpeg leaving a partial poster on disk, then failing (ensure_poster returns None).
|
||||
def _partial_poster(asset, root):
|
||||
storage.abs_path(root, str(Path(asset.rel_path).with_suffix(".jpg"))).write_bytes(
|
||||
b"partial"
|
||||
)
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(uploadmod.editmod, "ensure_poster", _partial_poster)
|
||||
|
||||
# Force the commit to fail after the file (and partial poster) are on disk.
|
||||
def _boom():
|
||||
raise RuntimeError("db down")
|
||||
|
||||
monkeypatch.setattr(db, "commit", _boom)
|
||||
|
||||
src = _root / "clip.mp4"
|
||||
src.write_bytes(b"fake-video-bytes")
|
||||
with pytest.raises(RuntimeError):
|
||||
uploadmod.ingest_upload(db, u.id, filename="clip.mp4", src_path=src, size=16)
|
||||
|
||||
db.rollback() # drop the monkeypatched-commit session state before querying
|
||||
uploads = _root / ".uploads"
|
||||
leftover = [p for p in uploads.rglob("*") if p.is_file()] if uploads.exists() else []
|
||||
assert leftover == [], f"orphaned files left on disk: {leftover}"
|
||||
assert db.query(MediaAsset).count() == 0 # the flushed asset rolled back — no dangling row
|
||||
|
||||
|
||||
# --- classifier: the REAL _probe parsing (stubs ffprobe one level down) ----------------------
|
||||
# The ingest/route tests above stub _probe wholesale; these exercise _probe's OWN logic — image
|
||||
# detection, the cover-art skip, dimension/duration parsing — by faking only run_ffprobe_json, so a
|
||||
# regression in the classifier the force-download guarantee leans on is actually caught.
|
||||
|
||||
|
||||
class TestProbeClassifier:
|
||||
def _probe_with(self, monkeypatch, data):
|
||||
monkeypatch.setattr(uploadmod.editmod, "run_ffprobe_json", lambda *a, **k: data)
|
||||
return uploadmod._probe(Path("x"))
|
||||
|
||||
def test_probe_failure_is_a_generic_file(self, monkeypatch):
|
||||
# run_ffprobe_json returns None (ffprobe failed / not media) → all None → derive() == FILE.
|
||||
v, a, w, h, d = self._probe_with(monkeypatch, None)
|
||||
assert (v, a, w, h, d) == (None, None, None, None, None)
|
||||
assert mediakind.derive(v, a) == mediakind.FILE
|
||||
|
||||
@pytest.mark.parametrize("fmt", ["image2", "png_pipe", "jpeg_pipe", "webp_pipe", "gif"])
|
||||
def test_still_image_classifies_as_file_not_video(self, monkeypatch, fmt):
|
||||
# A standalone image exposes a real video stream, but its container format is an image demuxer
|
||||
# — it must NOT become media_kind='video' (broken player + inline serving). Stays a file.
|
||||
data = {
|
||||
"format": {"format_name": fmt},
|
||||
"streams": [{"codec_type": "video", "codec_name": "png", "width": 8, "height": 8}],
|
||||
}
|
||||
v, a, w, h, d = self._probe_with(monkeypatch, data)
|
||||
assert v is None and mediakind.derive(v, a) == mediakind.FILE
|
||||
|
||||
def test_cover_art_video_stream_is_skipped_audio_stays_audio(self, monkeypatch):
|
||||
# Album art embedded in an audio file is a video stream flagged attached_pic — skip it so the
|
||||
# asset classifies as audio, not video.
|
||||
data = {
|
||||
"format": {"format_name": "mp3", "duration": "212.5"},
|
||||
"streams": [
|
||||
{
|
||||
"codec_type": "video",
|
||||
"codec_name": "mjpeg",
|
||||
"disposition": {"attached_pic": 1},
|
||||
"width": 600,
|
||||
"height": 600,
|
||||
},
|
||||
{"codec_type": "audio", "codec_name": "mp3"},
|
||||
],
|
||||
}
|
||||
v, a, w, h, d = self._probe_with(monkeypatch, data)
|
||||
assert (v, a, d) == (None, "mp3", 212)
|
||||
assert mediakind.derive(v, a) == mediakind.AUDIO
|
||||
|
||||
def test_real_video_records_codecs_dims_duration(self, monkeypatch):
|
||||
data = {
|
||||
"format": {"format_name": "mov,mp4,m4a", "duration": "60.0"},
|
||||
"streams": [
|
||||
{"codec_type": "video", "codec_name": "h264", "width": 1920, "height": 1080},
|
||||
{"codec_type": "audio", "codec_name": "aac"},
|
||||
],
|
||||
}
|
||||
v, a, w, h, d = self._probe_with(monkeypatch, data)
|
||||
assert (v, a, w, h, d) == ("h264", "aac", 1920, 1080, 60)
|
||||
assert mediakind.derive(v, a) == mediakind.VIDEO
|
||||
|
||||
def test_unparseable_duration_is_none(self, monkeypatch):
|
||||
data = {
|
||||
"format": {"format_name": "wav", "duration": "N/A"},
|
||||
"streams": [{"codec_type": "audio", "codec_name": "pcm_s16le"}],
|
||||
}
|
||||
v, a, w, h, d = self._probe_with(monkeypatch, data)
|
||||
assert d is None and mediakind.derive(v, a) == mediakind.AUDIO
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
Ban,
|
||||
Check,
|
||||
ExternalLink,
|
||||
Eye,
|
||||
EyeOff,
|
||||
History,
|
||||
Loader2,
|
||||
Plus,
|
||||
@@ -32,6 +34,7 @@ import type { FeedView } from "../lib/feedView";
|
||||
import { channelYouTubeUrl, formatViews } from "../lib/format";
|
||||
import { ChannelTagsControl } from "./ChannelTags";
|
||||
import { useChannelTagActions } from "../lib/useChannelTagActions";
|
||||
import { Button } from "./ui/Button";
|
||||
|
||||
// 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
|
||||
@@ -137,6 +140,17 @@ export default function ChannelPage({
|
||||
// a caller toast here would just double it.
|
||||
});
|
||||
|
||||
// "Hide from feed": the same per-subscription flag the manager toggles (updateChannel {hidden}).
|
||||
// Local DB op — keeps the subscription, only drops the channel's videos from the feed/explore.
|
||||
const toggleHidden = useMutation({
|
||||
mutationFn: () => api.updateChannel(channelId, { hidden: !ch?.hidden }),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: qk.channel(channelId) });
|
||||
qc.invalidateQueries({ queryKey: qk.feed() });
|
||||
qc.invalidateQueries({ queryKey: qk.channels() });
|
||||
},
|
||||
});
|
||||
|
||||
const subscribe = useMutation({
|
||||
mutationFn: () => api.subscribeChannel(channelId),
|
||||
onSuccess: () => {
|
||||
@@ -418,6 +432,7 @@ export default function ChannelPage({
|
||||
priority={ch.priority}
|
||||
onPriority={(d) => bumpPriority(channelId, d)}
|
||||
onSet={(v) => setPriority(channelId, v)}
|
||||
size="md"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -427,21 +442,18 @@ export default function ChannelPage({
|
||||
it) so a large channel's paced tail reads as visible progress rather than a stuck
|
||||
spinner. */}
|
||||
{ch?.subscribed && !ch.backfill_done && !me.is_demo && (
|
||||
<button
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => !ch.deep_requested && deepBackfill.mutate()}
|
||||
disabled={ch.deep_requested || deepBackfill.isPending}
|
||||
loading={ch.deep_requested}
|
||||
icon={<History className="w-4 h-4" />}
|
||||
title={
|
||||
ch.deep_requested
|
||||
? t("channel.fullHistoryLoadingHint")
|
||||
: 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">
|
||||
{!ch.deep_requested
|
||||
? t("channel.fullHistory")
|
||||
@@ -452,7 +464,7 @@ export default function ChannelPage({
|
||||
})
|
||||
: t("channel.fullHistoryLoading")}
|
||||
</span>
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
{!me.is_demo && (
|
||||
<button
|
||||
@@ -469,27 +481,45 @@ export default function ChannelPage({
|
||||
<Ban className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
{/* Hide-from-feed toggle — subscription-scoped, so offered only on a followed channel
|
||||
(mirrors the manager's Eye/EyeOff). Keeps the subscription; only drops its videos
|
||||
from the feed. EyeOff = currently hidden (click to unhide). */}
|
||||
{ch?.subscribed && !me.is_demo && (
|
||||
<button
|
||||
onClick={() => toggleHidden.mutate()}
|
||||
disabled={toggleHidden.isPending}
|
||||
title={ch.hidden ? t("channel.hiddenHint") : t("channel.hideHint")}
|
||||
aria-label={ch.hidden ? t("channel.unhide") : t("channel.hide")}
|
||||
className={`inline-flex items-center justify-center w-9 h-9 rounded-lg border disabled:opacity-50 transition ${
|
||||
ch.hidden
|
||||
? "border-accent text-accent"
|
||||
: "border-border text-muted hover:text-fg hover:border-fg/40"
|
||||
}`}
|
||||
>
|
||||
{ch.hidden ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
)}
|
||||
{!me.is_demo &&
|
||||
!ch?.blocked &&
|
||||
(ch?.subscribed ? (
|
||||
<button
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onUnsubscribe}
|
||||
disabled={unsubscribe.isPending}
|
||||
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")}
|
||||
</button>
|
||||
</Button>
|
||||
) : (
|
||||
<button
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => subscribe.mutate()}
|
||||
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")}
|
||||
</button>
|
||||
</Button>
|
||||
))}
|
||||
{/* 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
|
||||
|
||||
@@ -9,8 +9,8 @@ import { useChipOverflowFit } from "../lib/useChipOverflowFit";
|
||||
|
||||
// The channel tag surface, shared by the channel manager (table + cards) and the channel detail
|
||||
// page. Built on the R8 popover platform. Two design goals it fixes over the old inline TagsCell:
|
||||
// 1. Stable edit anchor — the "+" edit trigger sits in a FIXED slot at the end, so toggling tags
|
||||
// (which grows the chip row) never moves it, and the editor popover doesn't drift.
|
||||
// 1. Stable edit anchor — the "+" edit trigger LEADS the row (fixed left slot), so toggling tags
|
||||
// (which grows the chip row to its right) never moves it, and the editor popover doesn't drift.
|
||||
// 2. Fixed footprint — the chip row lives in a width-bounded, single-line, overflow-hidden box and
|
||||
// collapses the excess into a "+N" pill (hover reveals the full set). The cell therefore never
|
||||
// wraps to a 2nd line, so it can't widen its table column and reflow every other row.
|
||||
@@ -281,9 +281,9 @@ function TagEditorMenu({
|
||||
);
|
||||
}
|
||||
|
||||
/** The full tag control for a channel: a width-bounded chip row with "+N" overflow (hover reveals
|
||||
* all), followed by a stable "+" trigger that opens the shared editor. Fills its container width,
|
||||
* so a table cell bounds it via the column width and a card bounds it via the card. */
|
||||
/** The full tag control for a channel: a stable leading "+" trigger (opens the shared editor),
|
||||
* followed by a width-bounded chip row with "+N" overflow (hover reveals all). Fills its container
|
||||
* width, so a table cell bounds it via the column width and a card bounds it via the card. */
|
||||
export function ChannelTagsControl({
|
||||
userTags,
|
||||
attachedIds,
|
||||
@@ -376,6 +376,25 @@ export function ChannelTagsControl({
|
||||
<div
|
||||
className={`relative flex items-center gap-1 min-w-0 ${bounded ? "w-48 max-w-full" : "w-full"}`}
|
||||
>
|
||||
{/* The add trigger LEADS the row — a stable left anchor that never moves as chips are added
|
||||
(they grow to its right), so its editor popover can't drift. Compact icon in the width-
|
||||
bounded manager cell; a labelled "+ Tag" pill where there's free width (the detail header). */}
|
||||
<button
|
||||
ref={editPop.refs.setReference}
|
||||
type="button"
|
||||
title={t("channels.row.editTags")}
|
||||
aria-label={t("channels.row.editTags")}
|
||||
className={
|
||||
bounded
|
||||
? "shrink-0 w-5 h-5 inline-flex items-center justify-center rounded-full border border-dashed border-border text-muted hover:text-accent hover:border-accent transition"
|
||||
: "shrink-0 inline-flex items-center gap-1 h-6 pl-1.5 pr-2 rounded-full border border-dashed border-border text-2xs font-medium text-muted hover:text-accent hover:border-accent transition"
|
||||
}
|
||||
{...editPop.getReferenceProps()}
|
||||
>
|
||||
<Plus className="w-3 h-3 shrink-0" />
|
||||
{!bounded && <span className="whitespace-nowrap">{t("channels.tags.add")}</span>}
|
||||
</button>
|
||||
|
||||
{/* Untagged + has suggestions → the ghost-chip lane (its own overflow "+N"); otherwise the
|
||||
attached-chip lane. The two are mutually exclusive: suggestions are computed for untagged
|
||||
channels only, and accepting one removes the channel from the suggestion set — so a channel
|
||||
@@ -437,17 +456,6 @@ export function ChannelTagsControl({
|
||||
</>
|
||||
)}
|
||||
|
||||
<button
|
||||
ref={editPop.refs.setReference}
|
||||
type="button"
|
||||
title={t("channels.row.editTags")}
|
||||
aria-label={t("channels.row.editTags")}
|
||||
className="shrink-0 w-5 h-5 inline-flex items-center justify-center rounded-full border border-dashed border-border text-muted hover:text-accent hover:border-accent transition"
|
||||
{...editPop.getReferenceProps()}
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
</button>
|
||||
|
||||
{/* Hover: reveal every attached tag when some are collapsed into "+N". */}
|
||||
<PopoverSurface
|
||||
popover={overflowPop}
|
||||
|
||||
@@ -17,6 +17,7 @@ vi.mock("./NavigationProvider", () => ({
|
||||
}));
|
||||
|
||||
import { ChannelsProvider, useChannels, useChannelsActions } from "./ChannelsProvider";
|
||||
import { setActiveAccount } from "../lib/api/core";
|
||||
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<ChannelsProvider>{children}</ChannelsProvider>
|
||||
@@ -27,7 +28,10 @@ const VIEW_KEY = "siftlode.channelsView.1"; // account-scoped (accountKey suffix
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
sessionStorage.setItem("siftlode.activeAccount", "1"); // pin the tab's account
|
||||
window.history.replaceState({ sfPage: "channels" }, ""); // setView only owns an entry on this page
|
||||
// Reset BOTH the history state and the query string: setView only owns an entry on the channels page,
|
||||
// and the S8 URL mirror writes ctab/csort/cf — a stale key left by a prior test would be read back by
|
||||
// this provider's mount-time URL seed and change the starting tab.
|
||||
window.history.replaceState({ sfPage: "channels" }, "", "/");
|
||||
});
|
||||
|
||||
describe("ChannelsProvider tab history", () => {
|
||||
@@ -71,3 +75,58 @@ describe("ChannelsProvider tab history", () => {
|
||||
expect(result.current.view).toBe("hidden"); // unchanged
|
||||
});
|
||||
});
|
||||
|
||||
describe("ChannelsProvider — S8 shareable URL", () => {
|
||||
it("seeds tab + sort + filters from a shared manager link at mount (URL wins over stored)", () => {
|
||||
// Stored view says "blocked"; the shared link says "hidden" + a sort + a filter → the URL wins.
|
||||
localStorage.setItem(VIEW_KEY, "blocked");
|
||||
const cf = encodeURIComponent(JSON.stringify({ sync: "needsFull" }));
|
||||
window.history.replaceState(
|
||||
{ sfPage: "channels" },
|
||||
"",
|
||||
`/?ctab=hidden&csort=priority:desc&cf=${cf}`,
|
||||
);
|
||||
const { result } = render();
|
||||
expect(result.current.view).toBe("hidden");
|
||||
expect(result.current.sort).toEqual({ key: "priority", dir: "desc" });
|
||||
expect(result.current.filters).toEqual({ sync: "needsFull" });
|
||||
});
|
||||
|
||||
it("mirrors the tab into the address bar and clears it back to the default", () => {
|
||||
const { result } = render();
|
||||
act(() => result.current.setView("blocked"));
|
||||
expect(new URLSearchParams(window.location.search).get("ctab")).toBe("blocked");
|
||||
// Back to the default tab clears the key (only non-defaults are emitted).
|
||||
act(() => result.current.setView("subscribed"));
|
||||
expect(new URLSearchParams(window.location.search).has("ctab")).toBe(false);
|
||||
});
|
||||
|
||||
it("a PARTIAL shared link (fresh tab) does not clobber the account's stored sort/filters", () => {
|
||||
// Regression: on a fresh tab the account pins AFTER mount, so the seed's base for the OMITTED
|
||||
// dimensions was read as defaults. Persisting the whole seed used to overwrite the account's real
|
||||
// stored sort + filter map. The fix persists ONLY the URL-present dimensions.
|
||||
localStorage.setItem(
|
||||
"siftlode.channelSort.1",
|
||||
JSON.stringify({ key: "subscribers", dir: "desc" }),
|
||||
);
|
||||
localStorage.setItem("siftlode.channelsFilterMap.1", JSON.stringify({ sync: "needsFull" }));
|
||||
sessionStorage.removeItem("siftlode.activeAccount"); // account not pinned yet at mount
|
||||
window.history.replaceState({ sfPage: "channels" }, "", "/?ctab=hidden"); // only the tab in the URL
|
||||
|
||||
const { result } = render();
|
||||
act(() => setActiveAccount(1)); // App pins the account after /api/me
|
||||
|
||||
// The present dim (tab) is adopted from the link...
|
||||
expect(result.current.view).toBe("hidden");
|
||||
// ...but the omitted sort + filters keep the account's real stored values, in state AND storage.
|
||||
expect(result.current.sort).toEqual({ key: "subscribers", dir: "desc" });
|
||||
expect(result.current.filters).toEqual({ sync: "needsFull" });
|
||||
expect(JSON.parse(localStorage.getItem("siftlode.channelSort.1")!)).toEqual({
|
||||
key: "subscribers",
|
||||
dir: "desc",
|
||||
});
|
||||
expect(JSON.parse(localStorage.getItem("siftlode.channelsFilterMap.1")!)).toEqual({
|
||||
sync: "needsFull",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,16 +3,25 @@ import {
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { getAccountRaw, LS } from "../lib/storage";
|
||||
import { getAccountRaw, setAccountRaw, LS } from "../lib/storage";
|
||||
import { parseChannelLayout, type ChannelLayout } from "../lib/channelLayout";
|
||||
import { type SortState } from "../lib/columnSort";
|
||||
import { hydrateSort, type SortState } from "../lib/columnSort";
|
||||
import { useSortState } from "../lib/useSortState";
|
||||
import { useFilterState } from "../lib/useFilterState";
|
||||
import { useActiveAccountId } from "../lib/activeAccount";
|
||||
import { useUrlMirror } from "../lib/useUrlMirror";
|
||||
import {
|
||||
channelsParamsToState,
|
||||
channelsParamUpdates,
|
||||
hasChannelsParams,
|
||||
type ChannelsUrlState,
|
||||
} from "../lib/channelsUrlState";
|
||||
import { usePersistedTab } from "./Tabs";
|
||||
import type { FilterMap } from "./tableHeader";
|
||||
import { useNavigation, useNavigationActions } from "./NavigationProvider";
|
||||
@@ -44,6 +53,47 @@ function migrateSyncChip(): FilterMap | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
// The manager's stored column-filter map, healed to a plain string/string[] object (same discipline as
|
||||
// useFilterState's hydrate). Used only to build the seed's base — the URL keeps whatever the stored map
|
||||
// has for any dimension a partial shared link omits.
|
||||
function readStoredFilters(): FilterMap {
|
||||
const raw = getAccountRaw(LS.channelsFilterMap);
|
||||
if (raw) {
|
||||
try {
|
||||
const v = JSON.parse(raw);
|
||||
if (v && typeof v === "object" && !Array.isArray(v)) return v as FilterMap;
|
||||
} catch {
|
||||
/* fall through to the migrate seed */
|
||||
}
|
||||
}
|
||||
return migrateSyncChip() ?? {};
|
||||
}
|
||||
|
||||
// S8: a shared manager link carries the content state in namespaced URL keys (ctab/csort/cf). When
|
||||
// present, the URL wins over this account's stored view — read once at mount and applied over the slice
|
||||
// hooks (see the provider). The keys are channels-unique, so their mere presence identifies a shared
|
||||
// manager view; no ?page check is needed (the mirror below is page-gated so it never pollutes others).
|
||||
// The seed = the URL-merged state PLUS which dimensions the URL actually carried. `present` matters
|
||||
// because the base below is read at MOUNT, before the account is pinned (getAccountRaw returns null),
|
||||
// so an OMITTED dimension's base is a default, not this account's stored value. The provider seeds and
|
||||
// persists only the PRESENT dimensions and lets the absent ones re-hydrate from storage normally —
|
||||
// otherwise a partial shared link silently resets the omitted stored sort / filter map.
|
||||
type ChannelsSeedPresent = { view: boolean; sort: boolean; filters: boolean };
|
||||
type ChannelsUrlSeed = { state: ChannelsUrlState; present: ChannelsSeedPresent };
|
||||
function readChannelsUrlSeed(): ChannelsUrlSeed | null {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (!hasChannelsParams(params)) return null;
|
||||
const state = channelsParamsToState(params, {
|
||||
view: clampView(getAccountRaw(LS.channelsView)),
|
||||
sort: hydrateSort(getAccountRaw(LS.channelSort), [], null),
|
||||
filters: readStoredFilters(),
|
||||
});
|
||||
return {
|
||||
state,
|
||||
present: { view: params.has("ctab"), sort: params.has("csort"), filters: params.has("cf") },
|
||||
};
|
||||
}
|
||||
|
||||
type ChannelsState = {
|
||||
search: string;
|
||||
// The subscribed table's column filters, lifted out of DataTable so the top status chips, the Sync
|
||||
@@ -132,6 +182,45 @@ export function ChannelsProvider({ children }: { children: ReactNode }) {
|
||||
const [focusName, setFocusName] = useState<string | null>(null);
|
||||
const [focusToken, setFocusToken] = useState(0);
|
||||
|
||||
// S8 — shareable manager view. Captured once at mount (or null when no manager URL keys are present).
|
||||
const [urlSeed] = useState(readChannelsUrlSeed);
|
||||
const accountId = useActiveAccountId();
|
||||
const seededRef = useRef(false);
|
||||
const persistedRef = useRef(false);
|
||||
|
||||
// Apply the shared-link seed once, BEFORE paint, through the slice setters. Going through the setters
|
||||
// (not raw state) is deliberate: each one calls its hook's markDirty, so the account-pin re-hydrate
|
||||
// (which fires later on a fresh tab) sees the slice as user-edited and won't reload the stored view
|
||||
// over the shared one. setViewRaw (not setView) restores the tab without pushing a phantom history
|
||||
// entry. A normal load has urlSeed === null and this is a no-op.
|
||||
useLayoutEffect(() => {
|
||||
if (!urlSeed || seededRef.current) return;
|
||||
seededRef.current = true;
|
||||
// Only apply the dimensions the URL actually carried. An omitted dimension must NOT go through its
|
||||
// setter (which would markDirty and block the account-pin re-hydrate from loading the stored value)
|
||||
// — leave it to hydrate from storage normally. Fixes a partial link wiping the stored sort/filters.
|
||||
const { state, present } = urlSeed;
|
||||
if (present.view && state.view !== view) setViewRaw(state.view);
|
||||
if (present.filters) setFilters(state.filters);
|
||||
if (present.sort) setSort(state.sort);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// On a fresh tab the account pins AFTER mount, so the seed's setter writes above no-op'd (no account
|
||||
// yet). Persist the seed to the account once it's known, so the shared view also survives a later
|
||||
// PLAIN reload (no URL) — mirrors PlexProvider's rehydrate-persist. The slice hooks' own re-hydrate
|
||||
// already skips (marked dirty above), so this only touches storage, not state.
|
||||
useEffect(() => {
|
||||
if (!urlSeed || accountId == null || persistedRef.current) return;
|
||||
persistedRef.current = true;
|
||||
// Persist ONLY the URL-present dimensions — the omitted ones were seeded from mount-time defaults
|
||||
// (no account yet), so writing them here would overwrite the account's real stored sort/filter map.
|
||||
const { state, present } = urlSeed;
|
||||
if (present.view) setAccountRaw(LS.channelsView, state.view);
|
||||
if (present.filters) setAccountRaw(LS.channelsFilterMap, JSON.stringify(state.filters));
|
||||
if (present.sort) setAccountRaw(LS.channelSort, JSON.stringify(state.sort));
|
||||
}, [urlSeed, accountId]);
|
||||
|
||||
// Latest-value ref so the STABLE setView can compare against the current tab without depending on
|
||||
// it (a dep would recreate the action on every switch and re-render every actions consumer).
|
||||
// Written during render — the sanctioned pattern NavigationProvider uses for `pageRef`.
|
||||
@@ -218,6 +307,12 @@ export function ChannelsProvider({ children }: { children: ReactNode }) {
|
||||
[setSearch, setFilters, setView, setLayout, setSort, focusChannel, goToFullHistory],
|
||||
);
|
||||
|
||||
// S8: mirror the content state (tab + sort + column filters) into the address bar under the namespaced
|
||||
// keys so a manager view is shareable. Gated on page===channels: off the manager the keys are cleared
|
||||
// (localStorage still holds the state; it re-mirrors on return). The name `search` stays OUT — it's a
|
||||
// deliberately transient rail-search, like the feed's and plex's.
|
||||
useUrlMirror(channelsParamUpdates(page === "channels" ? { view, sort, filters } : null));
|
||||
|
||||
return (
|
||||
<ActionsContext.Provider value={actions}>
|
||||
<LayoutContext.Provider value={layout}>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createContext, useCallback, useContext, useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Modal from "./Modal";
|
||||
import { Button } from "./ui/Button";
|
||||
|
||||
// App-styled, promise-based replacement for window.confirm. Wrap the tree in
|
||||
// <ConfirmProvider> once, then anywhere: const confirm = useConfirm();
|
||||
@@ -100,26 +101,25 @@ export function ConfirmProvider({ children }: { children: ReactNode }) {
|
||||
<div
|
||||
className={multi ? "flex flex-col gap-2 mt-5" : "flex justify-end gap-2 mt-5 flex-wrap"}
|
||||
>
|
||||
<button
|
||||
<Button
|
||||
variant="outline"
|
||||
autoFocus={anyDanger}
|
||||
onClick={() => close(null)}
|
||||
className={`px-4 py-2 rounded-lg text-sm border border-border text-muted hover:text-fg hover:bg-surface transition ${
|
||||
multi ? "w-full order-last" : ""
|
||||
}`}
|
||||
fullWidth={multi}
|
||||
className={multi ? "order-last" : ""}
|
||||
>
|
||||
{state.opts.cancelLabel ?? t("common.cancel")}
|
||||
</button>
|
||||
</Button>
|
||||
{choices.map((c, i) => (
|
||||
<button
|
||||
<Button
|
||||
key={c.id}
|
||||
variant={c.danger ? "danger" : "primary"}
|
||||
autoFocus={!anyDanger && i === choices.length - 1}
|
||||
onClick={() => close(c.id)}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-semibold transition ${
|
||||
multi ? "w-full" : ""
|
||||
} ${c.danger ? "btn-danger" : "btn-primary"}`}
|
||||
fullWidth={multi}
|
||||
>
|
||||
{c.label || t("common.confirm")}
|
||||
</button>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@@ -6,8 +6,10 @@ import {
|
||||
Ban,
|
||||
ClipboardPaste,
|
||||
Download,
|
||||
Link2,
|
||||
Pause,
|
||||
Play,
|
||||
PlayCircle,
|
||||
Plus,
|
||||
RotateCcw,
|
||||
Scissors,
|
||||
@@ -15,6 +17,7 @@ import {
|
||||
Share2,
|
||||
Pencil,
|
||||
Trash2,
|
||||
UploadCloud,
|
||||
} from "lucide-react";
|
||||
import Tabs from "./Tabs";
|
||||
import { PageToolbar } from "./PageShell";
|
||||
@@ -22,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
|
||||
// used (the video editor in particular is heavy — filmstrip + scrubber).
|
||||
const DownloadDialog = lazy(() => import("./DownloadDialog"));
|
||||
const UploadDialog = lazy(() => import("./UploadDialog"));
|
||||
const ProfileEditor = lazy(() => import("./ProfileEditor"));
|
||||
const VideoEditor = lazy(() => import("./VideoEditor"));
|
||||
const ShareDialog = lazy(() => import("./ShareDialog"));
|
||||
const LibraryPlayer = lazy(() => import("./LibraryPlayer"));
|
||||
import { useConfirm } from "./ConfirmProvider";
|
||||
import { useLiveQuery } from "../lib/useLiveQuery";
|
||||
import { useMe } from "../lib/useMe";
|
||||
import { api, type DownloadJob } from "../lib/api";
|
||||
import { invalidateDownloads } from "../lib/downloads";
|
||||
import { invalidateDownloads, quickShareLink } from "../lib/downloads";
|
||||
import { libraryRowActions, sharedRowActions } from "../lib/downloadActions";
|
||||
import { inputCls } from "./ui/form";
|
||||
import { Button } from "./ui/Button";
|
||||
import { DownloadRow, IconBtn, UsageBar } from "./downloadRow";
|
||||
import { MetaEditModal, ErrorModal } from "./downloadModals";
|
||||
import { AdminSystem } from "./downloadAdmin";
|
||||
@@ -45,13 +52,21 @@ export default function DownloadCenter() {
|
||||
const [tab, setTab] = useState("queue");
|
||||
const [addUrl, setAddUrl] = useState("");
|
||||
const [addSource, setAddSource] = useState<string | null>(null);
|
||||
const [upload, setUpload] = useState(false);
|
||||
const [manage, setManage] = useState(false);
|
||||
const [metaJob, setMetaJob] = useState<DownloadJob | null>(null);
|
||||
const [shareJob, setShareJob] = useState<DownloadJob | null>(null);
|
||||
const [playJob, setPlayJob] = useState<DownloadJob | null>(null);
|
||||
const [editJob, setEditJob] = useState<DownloadJob | null>(null);
|
||||
const [errorJob, setErrorJob] = useState<DownloadJob | null>(null);
|
||||
|
||||
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({
|
||||
queryKey: qk.downloadsShared(),
|
||||
queryFn: api.downloadsShared,
|
||||
@@ -94,6 +109,21 @@ export default function DownloadCenter() {
|
||||
if (await confirm({ title, message: body, confirmLabel: title, danger: true })) fn();
|
||||
};
|
||||
|
||||
// One-click share (E7 S3 · D2-A): ensure a downloadable link, copy it, toast with "open in new tab"
|
||||
// (see quickShareLink). Re-clicking reuses the existing link; a per-job in-flight guard also stops a
|
||||
// double-click from racing two POSTs into two links before the cache is primed. Advanced controls
|
||||
// (password, expiry, revoke, share-with-a-user) live in the Share dialog, via the manage-share button.
|
||||
const [sharingId, setSharingId] = useState<number | null>(null);
|
||||
const quickShare = async (job: DownloadJob) => {
|
||||
if (sharingId !== null) return;
|
||||
setSharingId(job.id);
|
||||
try {
|
||||
await quickShareLink(qc, job, t);
|
||||
} finally {
|
||||
setSharingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Pull a link off the clipboard into the add-URL box. Clipboard reads need a secure context +
|
||||
// a user gesture/permission (iOS Safari/Brave are strict) — so this is best-effort: the paste
|
||||
// button is an explicit gesture (reliable), and the input's onFocus tries silently (ignored if
|
||||
@@ -163,20 +193,34 @@ export default function DownloadCenter() {
|
||||
placeholder={t("downloads.page.addUrl")}
|
||||
className={inputCls}
|
||||
/>
|
||||
<button
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
iconOnly
|
||||
icon={<ClipboardPaste className="w-4 h-4" />}
|
||||
label={t("downloads.page.paste")}
|
||||
onClick={() => pasteFromClipboard(true)}
|
||||
title={t("downloads.page.paste")}
|
||||
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"
|
||||
>
|
||||
<ClipboardPaste className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
className="shrink-0"
|
||||
/>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
icon={<Plus className="w-4 h-4" />}
|
||||
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")}
|
||||
</button>
|
||||
{t("downloads.page.add")}
|
||||
</Button>
|
||||
{/* Local upload is a peer to "add a URL": a general file → the same library (E7 S5c). */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
icon={<UploadCloud className="w-4 h-4" />}
|
||||
onClick={() => setUpload(true)}
|
||||
className="shrink-0"
|
||||
>
|
||||
{t("downloads.page.upload")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{queue.map((job) => (
|
||||
@@ -236,50 +280,69 @@ export default function DownloadCenter() {
|
||||
<>
|
||||
<UsageBar />
|
||||
<div className="space-y-2">
|
||||
{library.map((job) => (
|
||||
<DownloadRow key={job.id} job={job}>
|
||||
{saveBtn(job)}
|
||||
{job.can_download && (job.asset?.duration_s ?? 0) > 0 && (
|
||||
<IconBtn onClick={() => setEditJob(job)} title={t("downloads.actions.edit")}>
|
||||
<Scissors className="w-4 h-4" />
|
||||
{library.map((job) => {
|
||||
// Row actions gated on the stored media_kind + job_kind (see downloadActions).
|
||||
const a = libraryRowActions(job);
|
||||
return (
|
||||
<DownloadRow key={job.id} job={job}>
|
||||
{a.play && (
|
||||
<IconBtn onClick={() => setPlayJob(job)} title={t("downloads.actions.play")}>
|
||||
<PlayCircle className="w-4 h-4" />
|
||||
</IconBtn>
|
||||
)}
|
||||
{saveBtn(job)}
|
||||
{a.edit && (
|
||||
<IconBtn onClick={() => setEditJob(job)} title={t("downloads.actions.edit")}>
|
||||
<Scissors className="w-4 h-4" />
|
||||
</IconBtn>
|
||||
)}
|
||||
{a.editDetails && (
|
||||
<IconBtn
|
||||
onClick={() => setMetaJob(job)}
|
||||
title={t("downloads.actions.editDetails")}
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</IconBtn>
|
||||
)}
|
||||
<IconBtn onClick={() => quickShare(job)} title={t("downloads.actions.share")}>
|
||||
<Share2 className="w-4 h-4" />
|
||||
</IconBtn>
|
||||
)}
|
||||
<IconBtn
|
||||
onClick={() => setMetaJob(job)}
|
||||
title={t("downloads.actions.editDetails")}
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</IconBtn>
|
||||
<IconBtn onClick={() => setShareJob(job)} title={t("downloads.actions.share")}>
|
||||
<Share2 className="w-4 h-4" />
|
||||
</IconBtn>
|
||||
<IconBtn
|
||||
onClick={() =>
|
||||
confirmThen(
|
||||
t("downloads.confirm.redownloadTitle"),
|
||||
t("downloads.confirm.redownloadBody"),
|
||||
() => act.mutate({ id: job.id, op: "redownload" }),
|
||||
)
|
||||
}
|
||||
title={t("downloads.actions.redownload")}
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
</IconBtn>
|
||||
<IconBtn
|
||||
onClick={() =>
|
||||
confirmThen(
|
||||
t("downloads.confirm.deleteTitle"),
|
||||
t("downloads.confirm.deleteBody"),
|
||||
() => act.mutate({ id: job.id, op: "delete" }),
|
||||
)
|
||||
}
|
||||
title={t("downloads.actions.delete")}
|
||||
danger
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</IconBtn>
|
||||
</DownloadRow>
|
||||
))}
|
||||
<IconBtn
|
||||
onClick={() => setShareJob(job)}
|
||||
title={t("downloads.actions.shareManage")}
|
||||
>
|
||||
<Link2 className="w-4 h-4" />
|
||||
</IconBtn>
|
||||
{a.redownload && (
|
||||
<IconBtn
|
||||
onClick={() =>
|
||||
confirmThen(
|
||||
t("downloads.confirm.redownloadTitle"),
|
||||
t("downloads.confirm.redownloadBody"),
|
||||
() => act.mutate({ id: job.id, op: "redownload" }),
|
||||
)
|
||||
}
|
||||
title={t("downloads.actions.redownload")}
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
</IconBtn>
|
||||
)}
|
||||
<IconBtn
|
||||
onClick={() =>
|
||||
confirmThen(
|
||||
t("downloads.confirm.deleteTitle"),
|
||||
t("downloads.confirm.deleteBody"),
|
||||
() => act.mutate({ id: job.id, op: "delete" }),
|
||||
)
|
||||
}
|
||||
title={t("downloads.actions.delete")}
|
||||
danger
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</IconBtn>
|
||||
</DownloadRow>
|
||||
);
|
||||
})}
|
||||
{library.length === 0 &&
|
||||
(jobsQ.isError && !jobsQ.data ? (
|
||||
<StateMessage tone="error" onRetry={() => jobsQ.refetch()}>
|
||||
@@ -298,34 +361,50 @@ export default function DownloadCenter() {
|
||||
|
||||
{tab === "shared" && (
|
||||
<div className="space-y-2">
|
||||
{(sharedQ.data ?? []).map((job) => (
|
||||
<DownloadRow key={job.id} job={job}>
|
||||
{job.can_download && saveBtn(job)}
|
||||
{job.can_download && (job.asset?.duration_s ?? 0) > 0 && (
|
||||
<IconBtn onClick={() => setEditJob(job)} title={t("downloads.actions.edit")}>
|
||||
<Scissors className="w-4 h-4" />
|
||||
{(sharedQ.data ?? []).map((job) => {
|
||||
// Same media_kind + can_download gate as the library, via the shared helper (S5b).
|
||||
const a = sharedRowActions(job);
|
||||
return (
|
||||
<DownloadRow key={job.id} job={job}>
|
||||
{a.play && (
|
||||
<IconBtn onClick={() => setPlayJob(job)} title={t("downloads.actions.play")}>
|
||||
<PlayCircle className="w-4 h-4" />
|
||||
</IconBtn>
|
||||
)}
|
||||
{a.save && saveBtn(job)}
|
||||
{a.edit && (
|
||||
<IconBtn onClick={() => setEditJob(job)} title={t("downloads.actions.edit")}>
|
||||
<Scissors className="w-4 h-4" />
|
||||
</IconBtn>
|
||||
)}
|
||||
<IconBtn
|
||||
onClick={() =>
|
||||
confirmThen(
|
||||
t("downloads.confirm.removeSharedTitle"),
|
||||
t("downloads.confirm.removeSharedBody"),
|
||||
() => removeShared.mutate(job.id),
|
||||
)
|
||||
}
|
||||
title={t("downloads.actions.removeShared")}
|
||||
danger
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</IconBtn>
|
||||
)}
|
||||
<IconBtn
|
||||
onClick={() =>
|
||||
confirmThen(
|
||||
t("downloads.confirm.removeSharedTitle"),
|
||||
t("downloads.confirm.removeSharedBody"),
|
||||
() => removeShared.mutate(job.id),
|
||||
)
|
||||
}
|
||||
title={t("downloads.actions.removeShared")}
|
||||
danger
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</IconBtn>
|
||||
</DownloadRow>
|
||||
))}
|
||||
{sharedQ.data && sharedQ.data.length === 0 && (
|
||||
<div className="text-sm text-muted py-10 text-center">
|
||||
{t("downloads.empty.shared")}
|
||||
</div>
|
||||
)}
|
||||
</DownloadRow>
|
||||
);
|
||||
})}
|
||||
{(sharedQ.data ?? []).length === 0 &&
|
||||
(sharedQ.isError && !sharedQ.data ? (
|
||||
<StateMessage tone="error" onRetry={() => sharedQ.refetch()}>
|
||||
{t("common.loadError")}
|
||||
</StateMessage>
|
||||
) : sharedQ.isLoading ? (
|
||||
<LoadingState />
|
||||
) : (
|
||||
<div className="text-sm text-muted py-10 text-center">
|
||||
{t("downloads.empty.shared")}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -341,9 +420,11 @@ export default function DownloadCenter() {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{upload && <UploadDialog onClose={() => setUpload(false)} />}
|
||||
{manage && <ProfileEditor onClose={() => setManage(false)} />}
|
||||
{metaJob && <MetaEditModal job={metaJob} onClose={() => setMetaJob(null)} />}
|
||||
{shareJob && <ShareDialog job={shareJob} onClose={() => setShareJob(null)} />}
|
||||
{playJob && <LibraryPlayer job={playJob} onClose={() => setPlayJob(null)} />}
|
||||
{editJob && <VideoEditor job={editJob} onClose={() => setEditJob(null)} />}
|
||||
{errorJob && (
|
||||
<ErrorModal
|
||||
|
||||
@@ -3,6 +3,7 @@ import { qk } from "../lib/queryKeys";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import Modal from "./Modal";
|
||||
import { Button } from "./ui/Button";
|
||||
import { api } from "../lib/api";
|
||||
import { invalidateDownloads } from "../lib/downloads";
|
||||
|
||||
@@ -89,19 +90,12 @@ export default function DownloadDialog({
|
||||
/>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-3 py-1.5 rounded-lg text-sm text-muted hover:text-fg hover:bg-surface transition"
|
||||
>
|
||||
<Button variant="ghost" size="sm" onClick={onClose}>
|
||||
{t("downloads.actions.cancel")}
|
||||
</button>
|
||||
<button
|
||||
onClick={submit}
|
||||
disabled={busy || !chosen}
|
||||
className="px-3 py-1.5 rounded-lg text-sm font-medium btn-primary disabled:opacity-50 transition"
|
||||
>
|
||||
</Button>
|
||||
<Button variant="primary" size="sm" onClick={submit} disabled={busy || !chosen}>
|
||||
{t("downloads.dialog.submit")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{manage && (
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useSyncExternalStore } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { dismissError, getError, subscribeError } from "../lib/errorDialog";
|
||||
import Modal from "./Modal";
|
||||
import { Button } from "./ui/Button";
|
||||
|
||||
// Renders the current app error (if any) as a modal the user must acknowledge.
|
||||
export default function ErrorDialog() {
|
||||
@@ -12,12 +13,7 @@ export default function ErrorDialog() {
|
||||
<Modal title={err.title} onClose={dismissError} maxWidth="max-w-md">
|
||||
<p className="text-sm leading-relaxed text-fg/90">{err.message}</p>
|
||||
<div className="flex justify-end mt-5">
|
||||
<button
|
||||
onClick={dismissError}
|
||||
className="px-4 py-2 rounded-lg btn-primary text-sm font-medium transition"
|
||||
>
|
||||
{t("errors.ok")}
|
||||
</button>
|
||||
<Button onClick={dismissError}>{t("errors.ok")}</Button>
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -126,12 +126,25 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
|
||||
// Push an in-app history entry so browser/mouse Back steps through pages instead of leaving the
|
||||
// app. A fresh { sfPage } (no carried-over _yt/_chan markers) so each page starts at its root, and
|
||||
// the address bar reflects the page (feed is implicit) while clearing the view-specific params
|
||||
// (chan/v/pl) — a page switch resets to that module's root. Feed filters (owned by
|
||||
// FeedFiltersProvider) are left intact.
|
||||
// (chan/v/pl and the plex content keys pscope/psort/pshow/pf) — a page switch resets to that
|
||||
// module's root. Feed filters (owned by FeedFiltersProvider) are left intact; the plex/channels
|
||||
// keys re-mirror from PlexProvider / ChannelsProvider on return to their page.
|
||||
window.history.pushState(
|
||||
{ sfPage: next, sfScroll: nextNavId() },
|
||||
"",
|
||||
urlWithParams({ page: next === "feed" ? null : next, chan: null, v: null, pl: null }),
|
||||
urlWithParams({
|
||||
page: next === "feed" ? null : next,
|
||||
chan: null,
|
||||
v: null,
|
||||
pl: null,
|
||||
pscope: null,
|
||||
psort: null,
|
||||
pshow: null,
|
||||
pf: null,
|
||||
ctab: null,
|
||||
csort: null,
|
||||
cf: null,
|
||||
}),
|
||||
);
|
||||
notifyNav();
|
||||
},
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// S7 — the Personal-views manager gains an EDITABLE detail pane: captured filters are now removable
|
||||
// chips that write the pruned set back to the stored view (undoable), an explicit Save-current button
|
||||
// stores the live filters into the view, and rename has a visible commit affordance. This drives the
|
||||
// real modal against a mocked SavedViewsProvider so we can assert exactly which mutation fires; the
|
||||
// captured-filter row uses the REAL useActiveFilters (fed a tag from a seeded react-query cache), so
|
||||
// the prune snapshots are the genuine ones the live chip row computes.
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, waitFor, cleanup, fireEvent } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
const h = vi.hoisted(() => ({
|
||||
updateFilters: vi.fn(async (_id: number, _filters: Record<string, unknown>) => true),
|
||||
saveCurrent: vi.fn(async (_id: number) => ({ saved: true }) as Record<string, unknown> | null),
|
||||
rename: vi.fn(async (_id: number, _name: string) => true),
|
||||
activeViewId: null as number | null,
|
||||
}));
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
|
||||
});
|
||||
|
||||
// A full feed filter set that differs from the sidebar default ONLY in `tags`, so useActiveFilters
|
||||
// surfaces exactly one chip (the tag) — a partial object would read missing show/sort/content as
|
||||
// "active" and spray spurious chips.
|
||||
const VIEW_FILTERS = {
|
||||
tags: [1],
|
||||
tagModes: { language: "or", topic: "or", other: "or" },
|
||||
untagged: false,
|
||||
channelIds: [],
|
||||
sort: "newest",
|
||||
includeNormal: true,
|
||||
includeShorts: false,
|
||||
includeLive: false,
|
||||
show: "unwatched",
|
||||
scope: "my",
|
||||
q: "",
|
||||
};
|
||||
|
||||
const CTX = {
|
||||
source: "feed" as const,
|
||||
views: [{ id: 5, name: "Docs", filters: VIEW_FILTERS, position: 0, is_default: false }],
|
||||
loading: false,
|
||||
displayView: null,
|
||||
isDirty: false,
|
||||
modalOpen: true,
|
||||
openModal: vi.fn(),
|
||||
closeModal: vi.fn(),
|
||||
apply: vi.fn(),
|
||||
clearActive: vi.fn(),
|
||||
create: vi.fn(async () => true),
|
||||
reorder: vi.fn(async () => {}),
|
||||
remove: vi.fn(async () => true),
|
||||
toggleDefault: vi.fn(async () => {}),
|
||||
};
|
||||
|
||||
vi.mock("./SavedViewsProvider", () => ({
|
||||
useSavedViews: () => ({
|
||||
...CTX,
|
||||
activeViewId: h.activeViewId,
|
||||
updateFilters: h.updateFilters,
|
||||
saveCurrent: h.saveCurrent,
|
||||
rename: h.rename,
|
||||
}),
|
||||
// Passthrough coercion — the modal only needs tags/channelIds to be arrays before the chip builder.
|
||||
asFilters: (raw: Record<string, unknown>) => ({
|
||||
...raw,
|
||||
tags: (raw.tags as unknown[]) ?? [],
|
||||
channelIds: (raw.channelIds as unknown[]) ?? [],
|
||||
}),
|
||||
}));
|
||||
|
||||
import PersonalViewsModal from "./PersonalViewsModal";
|
||||
import { api } from "../lib/api";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
|
||||
function mount() {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
qc.setQueryData(qk.tags(), [{ id: 1, name: "Docu" }]);
|
||||
vi.spyOn(api, "tags").mockResolvedValue([{ id: 1, name: "Docu" }] as never);
|
||||
const utils = render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<PersonalViewsModal />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
return { ...utils, qc };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
h.updateFilters.mockClear();
|
||||
h.saveCurrent.mockClear();
|
||||
h.rename.mockClear();
|
||||
h.activeViewId = null;
|
||||
// Fresh baseline each test — one test mutates CTX.views to simulate a refetch.
|
||||
CTX.views = [{ id: 5, name: "Docs", filters: VIEW_FILTERS, position: 0, is_default: false }];
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("PersonalViewsModal — S7 editable detail pane", () => {
|
||||
it("removing a captured-filter chip writes the pruned filters back to the stored view", async () => {
|
||||
mount();
|
||||
const removeBtn = await screen.findByLabelText("feed.chips.remove");
|
||||
fireEvent.click(removeBtn);
|
||||
await waitFor(() => expect(h.updateFilters).toHaveBeenCalledTimes(1));
|
||||
expect(h.updateFilters.mock.calls[0]![0]).toBe(5);
|
||||
expect(h.updateFilters.mock.calls[0]![1]).toMatchObject({ tags: [] });
|
||||
});
|
||||
|
||||
it("the prune is undoable — Undo re-persists the original filters", async () => {
|
||||
mount();
|
||||
fireEvent.click(await screen.findByLabelText("feed.chips.remove"));
|
||||
await waitFor(() => expect(h.updateFilters).toHaveBeenCalledTimes(1));
|
||||
// The undo control only appears once there is history.
|
||||
const undo = await screen.findByLabelText("common.undo");
|
||||
fireEvent.click(undo);
|
||||
await waitFor(() => expect(h.updateFilters).toHaveBeenCalledTimes(2));
|
||||
expect(h.updateFilters.mock.calls[1]![1]).toMatchObject({ tags: [1] });
|
||||
});
|
||||
|
||||
it("a same-view refetch (new-identity filters) does not wipe the undo history", async () => {
|
||||
// Guards the [selectedId]-only re-seed effect (its exhaustive-deps disable): after a chip prune the
|
||||
// real provider refetches and hands back the SAME view id with a fresh-identity filters object. The
|
||||
// draft must NOT re-seed on that (it would destroy the undo history and the Undo control). A deps
|
||||
// regression that added selected.filters would make this fail.
|
||||
const { rerender, qc } = mount();
|
||||
fireEvent.click(await screen.findByLabelText("feed.chips.remove"));
|
||||
await waitFor(() => expect(h.updateFilters).toHaveBeenCalledTimes(1));
|
||||
expect(await screen.findByLabelText("common.undo")).toBeTruthy();
|
||||
|
||||
// Simulate the post-write refetch: same id 5, new object identity, carrying the pruned value.
|
||||
CTX.views = [
|
||||
{
|
||||
id: 5,
|
||||
name: "Docs",
|
||||
filters: { ...VIEW_FILTERS, tags: [] },
|
||||
position: 0,
|
||||
is_default: false,
|
||||
},
|
||||
];
|
||||
rerender(
|
||||
<QueryClientProvider client={qc}>
|
||||
<PersonalViewsModal />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
// Undo history survived the refetch — the control is still there and still re-persists on click.
|
||||
const undo = await screen.findByLabelText("common.undo");
|
||||
fireEvent.click(undo);
|
||||
await waitFor(() => expect(h.updateFilters).toHaveBeenCalledTimes(2));
|
||||
expect(h.updateFilters.mock.calls[1]![1]).toMatchObject({ tags: [1] });
|
||||
});
|
||||
|
||||
it("Save-current stores the live filters into the view when it isn't already the active one", async () => {
|
||||
mount();
|
||||
fireEvent.click(await screen.findByText("views.saveCurrent"));
|
||||
await waitFor(() => expect(h.saveCurrent).toHaveBeenCalledWith(5));
|
||||
});
|
||||
|
||||
it("Save-current is disabled when the selected view is already the active one", async () => {
|
||||
h.activeViewId = 5;
|
||||
mount();
|
||||
const btn = (await screen.findByText("views.saveCurrent")).closest(
|
||||
"button",
|
||||
) as HTMLButtonElement;
|
||||
expect(btn.disabled).toBe(true);
|
||||
fireEvent.click(btn);
|
||||
expect(h.saveCurrent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rename commits via the visible check button once the name changes", async () => {
|
||||
mount();
|
||||
const input = await screen.findByDisplayValue("Docs");
|
||||
// No change yet → the commit button is disabled.
|
||||
const commit = screen.getByLabelText("views.rename").closest("button") as HTMLButtonElement;
|
||||
expect(commit.disabled).toBe(true);
|
||||
fireEvent.change(input, { target: { value: "Documentaries" } });
|
||||
expect(commit.disabled).toBe(false);
|
||||
fireEvent.click(commit);
|
||||
await waitFor(() => expect(h.rename).toHaveBeenCalledWith(5, "Documentaries"));
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { BookmarkPlus, GripVertical, Play, Star, Trash2 } from "lucide-react";
|
||||
import { BookmarkPlus, Check, GripVertical, Play, Save, Star, Trash2, X } from "lucide-react";
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
@@ -16,15 +16,19 @@ import {
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { coercePlexFilters, type FeedFilters, type SavedView } from "../lib/api";
|
||||
import { coercePlexFilters, type FeedFilters, type PlexFilters, type SavedView } from "../lib/api";
|
||||
import Modal from "./Modal";
|
||||
import { Button } from "./ui/Button";
|
||||
import { useSavedViews, asFilters } from "./SavedViewsProvider";
|
||||
import { useActiveFilters } from "../lib/useActiveFilters";
|
||||
import { buildPlexActiveFilters } from "./usePlexActiveFilters";
|
||||
import { useUndoable } from "../lib/useUndoable";
|
||||
import UndoToolbar from "./UndoToolbar";
|
||||
|
||||
// Master–detail manager for saved ("personal") views: the left column lists the views (drag to
|
||||
// reorder) plus a "save the current filters" input; the right column shows the selected view's name,
|
||||
// what filters it captures, its default flag, apply, and delete. Opened from the filter panel's title
|
||||
// reorder) plus a "save the current filters" input; the right column shows the selected view's name
|
||||
// (renamable), its captured filters as REMOVABLE chips (pruning writes back to the stored view,
|
||||
// undoable), its default flag, apply, save-current, and delete. Opened from the filter panel's title
|
||||
// or the saved-views popover; all mutations flow through SavedViewsProvider.
|
||||
function ViewRow({
|
||||
view,
|
||||
@@ -80,6 +84,8 @@ export default function PersonalViewsModal() {
|
||||
apply,
|
||||
create,
|
||||
rename,
|
||||
updateFilters,
|
||||
saveCurrent,
|
||||
toggleDefault,
|
||||
remove,
|
||||
reorder,
|
||||
@@ -89,21 +95,55 @@ export default function PersonalViewsModal() {
|
||||
|
||||
const [selectedId, setSelectedId] = useState<number | null>(activeViewId ?? views[0]?.id ?? null);
|
||||
const selected = views.find((v) => v.id === selectedId) ?? null;
|
||||
const [draftName, setDraftName] = useState("");
|
||||
// Two independent name fields: the left one names a NEW view; the right one renames the selected one.
|
||||
const [createName, setCreateName] = useState("");
|
||||
const [nameDraft, setNameDraft] = useState("");
|
||||
|
||||
// The selected view's captured filters, as the same human labels its module's chip row shows. The
|
||||
// feed hook runs unconditionally (fed empty filters off-feed); Plex uses the pure builder (no-op
|
||||
// removes — this is display-only). A plex view captures its rail filters, not the watch-state.
|
||||
// A local, undoable draft of the SELECTED view's captured filters. Removing a chip prunes the draft
|
||||
// and — via onApply — writes the pruned set straight back to the stored view; Undo/Redo re-persist
|
||||
// the same way. The draft is the single source of truth for the chip row, so a chip-prune refetch
|
||||
// (which returns the value we just wrote) never clobbers the undo history. We only re-seed it on an
|
||||
// external change of the visible filters: switching the selection, or Save-current below.
|
||||
const filtersDraft = useUndoable<Record<string, unknown>>(selected?.filters ?? {}, {
|
||||
onApply: (f) => {
|
||||
if (selected) void updateFilters(selected.id, f);
|
||||
},
|
||||
});
|
||||
const resetDraft = filtersDraft.reset;
|
||||
useEffect(() => {
|
||||
resetDraft(selected?.filters ?? {});
|
||||
setNameDraft(selected?.name ?? "");
|
||||
// Re-seed only when the selection changes (not on every same-view refetch), so pruning keeps its
|
||||
// undo history. Save-current re-seeds explicitly in its own handler.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedId]);
|
||||
|
||||
// The selected view's captured filters, as the same human labels its module's chip row shows, read
|
||||
// from the undoable draft. The feed hook runs unconditionally (fed empty filters off-feed); Plex
|
||||
// uses the pure builder. A plex view captures its rail filters, not the watch-state (show="all").
|
||||
const noop = () => {};
|
||||
const setDraftFeed = (f: FeedFilters) =>
|
||||
filtersDraft.set(f as unknown as Record<string, unknown>);
|
||||
const feedCaptured = useActiveFilters(
|
||||
source === "feed" && selected
|
||||
? asFilters(selected.filters as unknown as FeedFilters)
|
||||
? asFilters(filtersDraft.value as unknown as FeedFilters)
|
||||
: ({ tags: [], channelIds: [] } as never),
|
||||
noop,
|
||||
source === "feed" && selected ? setDraftFeed : noop,
|
||||
);
|
||||
const patchDraftPlex = (p: Partial<PlexFilters>) =>
|
||||
filtersDraft.set({
|
||||
...coercePlexFilters(filtersDraft.value),
|
||||
...p,
|
||||
} as unknown as Record<string, unknown>);
|
||||
const captured =
|
||||
source === "plex" && selected
|
||||
? buildPlexActiveFilters(coercePlexFilters(selected.filters), "all", noop, noop, t)
|
||||
? buildPlexActiveFilters(
|
||||
coercePlexFilters(filtersDraft.value),
|
||||
"all",
|
||||
patchDraftPlex,
|
||||
noop,
|
||||
t,
|
||||
)
|
||||
: feedCaptured;
|
||||
|
||||
async function onDragEnd(e: DragEndEvent) {
|
||||
@@ -115,11 +155,20 @@ export default function PersonalViewsModal() {
|
||||
}
|
||||
|
||||
async function onCreate() {
|
||||
const name = draftName.trim();
|
||||
const name = createName.trim();
|
||||
if (!name) return;
|
||||
if (await create(name)) setDraftName("");
|
||||
if (await create(name)) setCreateName("");
|
||||
}
|
||||
|
||||
const nameChanged = !!selected && nameDraft.trim() !== "" && nameDraft.trim() !== selected.name;
|
||||
async function onRename() {
|
||||
if (!selected || !nameChanged) return;
|
||||
await rename(selected.id, nameDraft.trim());
|
||||
}
|
||||
|
||||
// Save-current is a no-op when the live filters already equal this exact view (it's the active one).
|
||||
const saveCurrentDisabled = selected != null && selected.id === activeViewId;
|
||||
|
||||
return (
|
||||
<Modal title={t("views.manageTitle")} onClose={closeModal} maxWidth="max-w-2xl">
|
||||
<div className="grid min-h-[20rem] grid-cols-[minmax(0,14rem)_1fr] gap-4">
|
||||
@@ -148,38 +197,59 @@ export default function PersonalViewsModal() {
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-1.5 border-t border-border/60 pt-2">
|
||||
<input
|
||||
value={draftName}
|
||||
onChange={(e) => setDraftName(e.target.value)}
|
||||
value={createName}
|
||||
onChange={(e) => setCreateName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && onCreate()}
|
||||
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"
|
||||
/>
|
||||
<button
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
iconOnly
|
||||
icon={<BookmarkPlus className="h-4 w-4" />}
|
||||
label={t("views.save")}
|
||||
onClick={onCreate}
|
||||
disabled={!draftName.trim()}
|
||||
title={t("views.save")}
|
||||
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>
|
||||
disabled={!createName.trim()}
|
||||
className="shrink-0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: the selected view's detail */}
|
||||
{selected ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
<input
|
||||
key={selected.id}
|
||||
defaultValue={selected.name}
|
||||
onBlur={(e) => {
|
||||
const next = e.target.value.trim();
|
||||
if (next && next !== selected.name) void rename(selected.id, next);
|
||||
}}
|
||||
className="rounded-lg border border-border bg-surface px-3 py-2 text-sm font-medium focus:border-accent focus:outline-hidden focus:ring-2 focus:ring-accent/30"
|
||||
/>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
value={nameDraft}
|
||||
onChange={(e) => setNameDraft(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && void onRename()}
|
||||
onBlur={() => void onRename()}
|
||||
className="min-w-0 flex-1 rounded-lg border border-border bg-surface px-3 py-2 text-sm font-medium focus:border-accent focus:outline-hidden focus:ring-2 focus:ring-accent/30"
|
||||
/>
|
||||
<button
|
||||
onClick={() => void onRename()}
|
||||
disabled={!nameChanged}
|
||||
title={t("views.rename")}
|
||||
aria-label={t("views.rename")}
|
||||
className="inline-flex shrink-0 items-center rounded-lg border border-border p-2 text-muted transition enabled:hover:border-accent enabled:hover:text-accent disabled:opacity-30"
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1.5 text-2xs font-semibold uppercase tracking-wider text-muted">
|
||||
{t("views.capturedFilters")}
|
||||
<div className="mb-1.5 flex items-center justify-between gap-2">
|
||||
<span className="text-2xs font-semibold uppercase tracking-wider text-muted">
|
||||
{t("views.capturedFilters")}
|
||||
</span>
|
||||
{(filtersDraft.canUndo || filtersDraft.canRedo) && (
|
||||
<UndoToolbar
|
||||
canUndo={filtersDraft.canUndo}
|
||||
canRedo={filtersDraft.canRedo}
|
||||
onUndo={filtersDraft.undo}
|
||||
onRedo={filtersDraft.redo}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{captured.length === 0 ? (
|
||||
<p className="text-xs text-muted">{t("views.capturedNone")}</p>
|
||||
@@ -188,21 +258,46 @@ export default function PersonalViewsModal() {
|
||||
{captured.map((f) => (
|
||||
<span
|
||||
key={f.key}
|
||||
className="rounded-full bg-accent/10 px-2 py-0.5 text-2xs text-accent"
|
||||
className="group inline-flex items-center gap-1 rounded-full bg-accent/10 py-0.5 pl-2 pr-1 text-2xs text-accent"
|
||||
>
|
||||
{f.label}
|
||||
<button
|
||||
onClick={f.remove}
|
||||
title={t("feed.chips.remove", { name: f.label })}
|
||||
aria-label={t("feed.chips.remove", { name: f.label })}
|
||||
className="rounded-full p-0.5 opacity-60 transition hover:bg-accent/20 hover:opacity-100"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-auto flex items-center gap-2 border-t border-border/60 pt-3">
|
||||
<button
|
||||
<div className="mt-auto flex flex-wrap items-center gap-2 border-t border-border/60 pt-3">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
icon={<Play className="h-3.5 w-3.5" />}
|
||||
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")}
|
||||
</Button>
|
||||
<button
|
||||
onClick={async () => {
|
||||
const saved = await saveCurrent(selected.id);
|
||||
// Re-seed the draft to what we just stored (the live filters) so the chip row
|
||||
// reflects the save; clears the now-stale undo history.
|
||||
if (saved) resetDraft(saved);
|
||||
}}
|
||||
disabled={saveCurrentDisabled}
|
||||
title={
|
||||
saveCurrentDisabled ? t("views.saveCurrentUpToDate") : t("views.saveCurrentHint")
|
||||
}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-sm text-muted transition enabled:hover:border-accent enabled:hover:text-accent disabled:opacity-40"
|
||||
>
|
||||
<Save className="h-3.5 w-3.5" />
|
||||
{t("views.saveCurrent")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => void toggleDefault(selected)}
|
||||
|
||||
@@ -362,7 +362,15 @@ export default function Playlists() {
|
||||
setPushing(true);
|
||||
try {
|
||||
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) }) });
|
||||
return;
|
||||
}
|
||||
@@ -376,21 +384,37 @@ export default function Playlists() {
|
||||
});
|
||||
return;
|
||||
}
|
||||
let message =
|
||||
plan.action === "create"
|
||||
? t("playlists.pushPlanCreate", {
|
||||
count: plan.to_insert,
|
||||
units: plan.units_estimate,
|
||||
left: plan.remaining_today,
|
||||
})
|
||||
: t("playlists.pushPlanUpdate", {
|
||||
insert: plan.to_insert,
|
||||
del: plan.to_delete,
|
||||
reorder: plan.to_reorder,
|
||||
units: plan.units_estimate,
|
||||
left: plan.remaining_today,
|
||||
});
|
||||
if (plan.yt_extra > 0) message += " " + t("playlists.pushDiverged", { count: plan.yt_extra });
|
||||
let message: string;
|
||||
if (plan.action === "create") {
|
||||
message = t("playlists.pushPlanCreate", {
|
||||
count: plan.add_yt,
|
||||
units: plan.units_estimate,
|
||||
left: plan.remaining_today,
|
||||
});
|
||||
} else {
|
||||
// One sentence per side that actually changes; the untouched side gets a short "stays
|
||||
// unchanged" reassurance instead of a "+0 / −0" the reader has to decode.
|
||||
const here: string[] = [];
|
||||
if (plan.add_local) here.push(t("playlists.mergeAddLocal", { count: plan.add_local }));
|
||||
if (plan.remove_local)
|
||||
here.push(t("playlists.mergeRemoveLocal", { count: plan.remove_local }));
|
||||
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({
|
||||
title: t("playlists.pushTitle"),
|
||||
message,
|
||||
|
||||
@@ -11,6 +11,7 @@ import { User, X } from "lucide-react";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { healPlexField } from "../lib/plexSearch";
|
||||
import { healPlexSort, plexSortOptions, PLEX_SORT_FALLBACK } from "../lib/plexSort";
|
||||
import type { SortState } from "../lib/columnSort";
|
||||
import {
|
||||
api,
|
||||
EMPTY_PLEX_FILTERS,
|
||||
@@ -52,6 +53,13 @@ const PlexCollectionView = lazy(() => import("./PlexCollectionView"));
|
||||
|
||||
const PAGE = 40;
|
||||
|
||||
// D3: the "last watched" sort auto-applied when you enter the in-progress watch-state view (most
|
||||
// recently watched first). Frozen for the same reason as PLEX_SORT_FALLBACK (handed out by reference).
|
||||
const LASTWATCHED_SORT: NonNullable<SortState> = Object.freeze({
|
||||
key: "lastwatched",
|
||||
dir: "desc" as const,
|
||||
});
|
||||
|
||||
// Watch-state options for the toolbar pill (moved out of the filter rail — it's a top-level "what am
|
||||
// I looking at" control now, like the feed's Watch-state pill, not a rail widget).
|
||||
const SHOW_OPTS = ["all", "unwatched", "in_progress", "watched"] as const;
|
||||
@@ -185,6 +193,25 @@ export default function PlexBrowse() {
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [sub]);
|
||||
|
||||
// D3: entering the in-progress watch-state view auto-applies the "last watched" sort (most recently
|
||||
// watched first); leaving restores the sort that was active before entering. Only the ENTER edge
|
||||
// forces the sort, so a manual sort change while in the in-progress view still wins — it's just not
|
||||
// carried back out (exit returns to the pre-entry preference). We stash the raw provider `sort`, not
|
||||
// the scope-healed one, so the user's real preference round-trips.
|
||||
const prevShow = useRef(show);
|
||||
const preInProgressSort = useRef<SortState | null>(null);
|
||||
useEffect(() => {
|
||||
const was = prevShow.current;
|
||||
if (was !== "in_progress" && show === "in_progress") {
|
||||
preInProgressSort.current = sort;
|
||||
setSort(LASTWATCHED_SORT);
|
||||
} else if (was === "in_progress" && show !== "in_progress") {
|
||||
if (preInProgressSort.current) setSort(preInProgressSort.current);
|
||||
preInProgressSort.current = null;
|
||||
}
|
||||
prevShow.current = show;
|
||||
}, [show, sort, setSort]);
|
||||
|
||||
const browseQ = useInfiniteQuery({
|
||||
queryKey: qk.plexLibrary(scope, dq, sortState, show, filters, fieldState, lang),
|
||||
// Gate on committedLang so a language switch fires ONE fetch (after the genre-clear effect
|
||||
|
||||
@@ -425,7 +425,7 @@ export default function PlexInfo({
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="shrink-0 p-1.5 rounded-lg text-white/80 hover:bg-white/15"
|
||||
aria-label={t("plex.player.back")}
|
||||
aria-label={t("plex.player.close")}
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
@@ -9,9 +9,11 @@ import {
|
||||
ArrowLeft,
|
||||
Clock,
|
||||
Download,
|
||||
Gauge,
|
||||
Info,
|
||||
Keyboard,
|
||||
Languages,
|
||||
Loader2,
|
||||
Maximize,
|
||||
Minimize,
|
||||
Pause,
|
||||
@@ -37,8 +39,10 @@ import {
|
||||
subShadowCss,
|
||||
type PlexPlayerPrefs,
|
||||
} from "../lib/plexPlayerPrefs";
|
||||
import { Ctrl, PanelHead } from "./plexPlayerControls";
|
||||
import { Ctrl, PanelHead, Skip10 } from "./plexPlayerControls";
|
||||
import { PlexPlayerSettings } from "./PlexPlayerSettings";
|
||||
import { PlexStatsOverlay } from "./PlexStatsOverlay";
|
||||
import { UpNextCard } from "./UpNextCard";
|
||||
import {
|
||||
useAutoSkip,
|
||||
useControlsVisibility,
|
||||
@@ -94,6 +98,9 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
const aliveRef = useRef(true);
|
||||
const wrapRef = useRef<HTMLDivElement>(null);
|
||||
const sessionStartRef = useRef(0); // absolute offset the current HLS/direct session begins at
|
||||
// Which encoder the current transcode session ran: true=VAAPI hardware, false=libx264 software,
|
||||
// null=not a transcode. Feeds the stats overlay's mode row ("transcode · GPU" / "· CPU").
|
||||
const hwRef = useRef<boolean | null>(null);
|
||||
const durationRef = useRef(0);
|
||||
// ≥2 audio tracks → multi-rendition HLS: all tracks ship in one session and hls.js switches audio
|
||||
// client-side (no restart). Ref so the stable loadSession/changeAudio callbacks read it fresh.
|
||||
@@ -108,6 +115,15 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
const [seekableEnd, setSeekableEnd] = useState(0); // absolute end of the loaded region
|
||||
const { fs, toggleFs } = useFullscreen(wrapRef);
|
||||
const [ready, setReady] = useState(false);
|
||||
// True while playback is stalled waiting for data (mid-stream rebuffer) or a seek is in flight — drives
|
||||
// a spinner so a stall reads as "loading, hang on" instead of a frozen frame. Distinct from `ready`,
|
||||
// which only covers the INITIAL session load; this covers stalls AFTER playback has started (e.g. the
|
||||
// brief cold-ffmpeg rebuffer on auto-advance, now largely prevented by the server-side segment warm-up).
|
||||
const [buffering, setBuffering] = useState(false);
|
||||
// The current session re-encodes the video (playable="transcode"). Its cold-start (the first transcode
|
||||
// segment) is genuinely slower than a remux stall, so the initial-load overlay says "Preparing stream…"
|
||||
// instead of the generic "Loading…" — honest, and it stops a warm-up reading as a hang.
|
||||
const [sessionIsTranscode, setSessionIsTranscode] = useState(false);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
// The current session's real media-start K (reactive mirror of sessionStartRef). Subtitles are
|
||||
// absolute-time VTT shifted by K to the session's zero-based clock, so the <track> src/key keys off
|
||||
@@ -165,6 +181,16 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
const [infoOpen, setInfoOpen] = useState(false);
|
||||
const infoOpenRef = useRef(false);
|
||||
infoOpenRef.current = infoOpen;
|
||||
// "D" toggles the stats-for-nerds debug overlay (active mode, codec, dropped frames, buffer…).
|
||||
const [statsOpen, setStatsOpen] = useState(false);
|
||||
const statsOpenRef = useRef(false);
|
||||
statsOpenRef.current = statsOpen;
|
||||
// Up-next end-card: shown at episode end (with a next item) instead of an instant jump (S4).
|
||||
const [upNextOpen, setUpNextOpen] = useState(false);
|
||||
const upNextOpenRef = useRef(false);
|
||||
upNextOpenRef.current = upNextOpen;
|
||||
const upNextPrefRef = useRef(prefs.upNext);
|
||||
upNextPrefRef.current = prefs.upNext;
|
||||
const now = useWallClock();
|
||||
|
||||
durationRef.current = duration;
|
||||
@@ -178,7 +204,12 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
setReady(false);
|
||||
setBuffering(false);
|
||||
setLoadError(null);
|
||||
// Clear the prior item's transcode flag up front so the cold-start overlay can't show a stale
|
||||
// "Preparing stream…" for a not-yet-known session while the plexSession request is in flight —
|
||||
// it's re-set honestly from the response below.
|
||||
setSessionIsTranscode(false);
|
||||
let sess;
|
||||
try {
|
||||
// Multi-audio → request the multi-rendition session (no single `audio` ordinal); the track is
|
||||
@@ -199,6 +230,11 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Surface the active mode for the stats overlay + the honest cold-start label: a transcode session
|
||||
// re-encodes the video (hw = which encoder), and its first segment is genuinely slower than remux.
|
||||
const isTranscode = sess.mode === "hls" && !!sess.transcode;
|
||||
hwRef.current = isTranscode ? (sess.hw ?? false) : null;
|
||||
setSessionIsTranscode(isTranscode);
|
||||
// hls.js plays our non-zero-start copyts stream ~1s BEHIND the media clock (see HLS_SUB_LAG);
|
||||
// fold that into the session start so the seekbar clock, seeking AND the subtitle shift are all
|
||||
// content-accurate. Direct-play (start_s 0) uses the file's real clock — no compensation.
|
||||
@@ -362,12 +398,24 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
// button lie (⏸ shown while actually paused). Resync the flag from reality on the settling events
|
||||
// so the icon can never contradict the video.
|
||||
const syncPlaying = () => setPlaying(!video.paused);
|
||||
// Buffering spinner: `waiting`/`stalled` = playback stopped for lack of data; `seeking` = a seek is
|
||||
// in flight. Cleared once data flows again (`playing`/`canplay`/`seeked`) or the user pauses (a
|
||||
// deliberate pause isn't a stall, and would otherwise leave the spinner stuck).
|
||||
const onWaiting = () => setBuffering(true);
|
||||
const onResumed = () => setBuffering(false);
|
||||
video.addEventListener("timeupdate", onTime);
|
||||
video.addEventListener("play", onPlay);
|
||||
video.addEventListener("pause", onPause);
|
||||
video.addEventListener("emptied", syncPlaying);
|
||||
video.addEventListener("canplay", syncPlaying);
|
||||
video.addEventListener("seeked", syncPlaying);
|
||||
video.addEventListener("waiting", onWaiting);
|
||||
video.addEventListener("stalled", onWaiting);
|
||||
video.addEventListener("seeking", onWaiting);
|
||||
video.addEventListener("playing", onResumed);
|
||||
video.addEventListener("canplay", onResumed);
|
||||
video.addEventListener("seeked", onResumed);
|
||||
video.addEventListener("pause", onResumed);
|
||||
return () => {
|
||||
video.removeEventListener("timeupdate", onTime);
|
||||
video.removeEventListener("play", onPlay);
|
||||
@@ -375,6 +423,13 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
video.removeEventListener("emptied", syncPlaying);
|
||||
video.removeEventListener("canplay", syncPlaying);
|
||||
video.removeEventListener("seeked", syncPlaying);
|
||||
video.removeEventListener("waiting", onWaiting);
|
||||
video.removeEventListener("stalled", onWaiting);
|
||||
video.removeEventListener("seeking", onWaiting);
|
||||
video.removeEventListener("playing", onResumed);
|
||||
video.removeEventListener("canplay", onResumed);
|
||||
video.removeEventListener("seeked", onResumed);
|
||||
video.removeEventListener("pause", onResumed);
|
||||
};
|
||||
}, [id]);
|
||||
|
||||
@@ -488,7 +543,11 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
// (absRef 0) — its stray 'ended' must not mark anything watched or cascade another advance.
|
||||
if (absRef.current <= 0) return;
|
||||
api.plexSetState(id, "watched").catch(() => {});
|
||||
if (nextId) go(nextId);
|
||||
// With a next item and the up-next pref on, surface the end-card (poster + countdown + Cancel)
|
||||
// and let IT advance — binge stays in the viewer's control (S4/D6-A). The card only appears here,
|
||||
// on play-to-the-end; credits-auto-skip advances earlier and this handler never fires for it.
|
||||
if (nextId && upNextPrefRef.current) setUpNextOpen(true);
|
||||
else if (nextId) go(nextId);
|
||||
// Nothing queued next → dismiss the player back to the previous page (same as Stop/Escape),
|
||||
// instead of stranding a black, ended <video> on screen.
|
||||
else onClose();
|
||||
@@ -497,6 +556,14 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
return () => video.removeEventListener("ended", onEnded);
|
||||
}, [id, nextId, go, onClose]);
|
||||
|
||||
// Up-next card actions: advance = go to the next item (it was already marked watched on 'ended');
|
||||
// cancel = dismiss and stay on the finished frame. Loading a new item closes any lingering card.
|
||||
const advanceUpNext = useCallback(() => {
|
||||
setUpNextOpen(false);
|
||||
if (nextId) go(nextId);
|
||||
}, [nextId, go]);
|
||||
useEffect(() => setUpNextOpen(false), [id]);
|
||||
|
||||
// "Back" cascade shared by the on-screen Back arrow AND the keyboard/remote Backspace·Escape: peel
|
||||
// off whatever is layered on top (a tuning/tracks menu, then the shortcuts sheet, then the info
|
||||
// overlay) before leaving the player. The arrow used to call onClose() unconditionally, so Back
|
||||
@@ -507,6 +574,8 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
setTracksOpen(false);
|
||||
} else if (helpOpenRef.current) setHelpOpen(false);
|
||||
else if (infoOpenRef.current) setInfoOpen(false);
|
||||
else if (statsOpenRef.current) setStatsOpen(false);
|
||||
else if (upNextOpenRef.current) setUpNextOpen(false);
|
||||
else onClose();
|
||||
}, [onClose]);
|
||||
|
||||
@@ -592,6 +661,10 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
case "I":
|
||||
setInfoOpen((v) => !v);
|
||||
break;
|
||||
case "d":
|
||||
case "D":
|
||||
setStatsOpen((v) => !v);
|
||||
break;
|
||||
case "Backspace":
|
||||
// HTPC-style "stop & back to the feed" — mirrors the mouse Back button (shared cascade).
|
||||
e.preventDefault();
|
||||
@@ -613,6 +686,8 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
setTracksOpen(false);
|
||||
} else if (helpOpenRef.current) setHelpOpen(false);
|
||||
else if (infoOpenRef.current) setInfoOpen(false);
|
||||
else if (statsOpenRef.current) setStatsOpen(false);
|
||||
else if (upNextOpenRef.current) setUpNextOpen(false);
|
||||
else if (!fullscreenElement()) onClose();
|
||||
break;
|
||||
}
|
||||
@@ -730,21 +805,44 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
)}
|
||||
</video>
|
||||
|
||||
{!ready && (
|
||||
<div className="absolute inset-0 grid place-items-center px-6 text-center text-sm pointer-events-none">
|
||||
{/* Loading / buffering / error overlay. `!ready` = initial session load; `buffering` = a
|
||||
mid-playback stall or in-flight seek (see the state); `loadError` surfaces a fatal failure so
|
||||
it can't hide behind a running video. Spinner on load/stall, error text otherwise. */}
|
||||
{(!ready || buffering || loadError) && (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className="absolute inset-0 grid place-items-center px-6 text-center text-sm pointer-events-none"
|
||||
>
|
||||
{loadError ? (
|
||||
<span className="max-w-md text-danger">{loadError}</span>
|
||||
) : (
|
||||
<span className="text-white/70">{t("plex.player.loading")}</span>
|
||||
<div className="flex flex-col items-center gap-3 text-white/80">
|
||||
<Loader2 className="w-10 h-10 animate-spin" aria-hidden />
|
||||
{/* Announce the stall to assistive tech. Initial load shows visible text; a mid-playback
|
||||
rebuffer/seek (ready, no error) has no visible label, so give it an sr-only one via the
|
||||
aria-live region so a screen reader distinguishes "buffering" from a frozen frame. */}
|
||||
{!ready ? (
|
||||
<span>
|
||||
{t(sessionIsTranscode ? "plex.player.preparing" : "plex.player.loading")}
|
||||
</span>
|
||||
) : buffering ? (
|
||||
<span className="sr-only">{t("plex.player.buffering")}</span>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* While a panel/menu is open or an auto-skip countdown is running, take over the browser/mouse
|
||||
Back button (history popstate) so it closes/cancels that instead of dropping to the grid. */}
|
||||
{(menuOpen || tracksOpen || helpOpen || infoOpen || skipProgress != null) && (
|
||||
<BackClose onBack={backIntent} />
|
||||
)}
|
||||
{(menuOpen ||
|
||||
tracksOpen ||
|
||||
helpOpen ||
|
||||
infoOpen ||
|
||||
statsOpen ||
|
||||
upNextOpen ||
|
||||
skipProgress != null) && <BackClose onBack={backIntent} />}
|
||||
|
||||
{/* Top bar */}
|
||||
<div
|
||||
@@ -893,9 +991,19 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 text-white">
|
||||
<Ctrl label={t("plex.player.playPause")} onClick={togglePlay} align="start">
|
||||
<Ctrl
|
||||
label={t("plex.player.rewind10")}
|
||||
onClick={() => seekTo(absRef.current - 10)}
|
||||
align="start"
|
||||
>
|
||||
<Skip10 dir="back" />
|
||||
</Ctrl>
|
||||
<Ctrl label={t("plex.player.playPause")} onClick={togglePlay}>
|
||||
{playing ? <Pause className="w-6 h-6" /> : <Play className="w-6 h-6" />}
|
||||
</Ctrl>
|
||||
<Ctrl label={t("plex.player.forward10")} onClick={() => seekTo(absRef.current + 10)}>
|
||||
<Skip10 dir="forward" />
|
||||
</Ctrl>
|
||||
<Ctrl label={t("plex.player.restart")} onClick={() => seekTo(0)}>
|
||||
<RotateCcw className="w-5 h-5" />
|
||||
</Ctrl>
|
||||
@@ -1071,6 +1179,9 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<Ctrl label={t("plex.player.stats.title")} onClick={() => setStatsOpen((v) => !v)}>
|
||||
<Gauge className="w-5 h-5" />
|
||||
</Ctrl>
|
||||
<Ctrl label={t("plex.player.infoBtn")} onClick={() => setInfoOpen((v) => !v)}>
|
||||
<Info className="w-5 h-5" />
|
||||
</Ctrl>
|
||||
@@ -1087,6 +1198,31 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats-for-nerds debug overlay (toggle with "d" or the gauge button). Non-modal corner panel —
|
||||
the video keeps playing and stays interactive behind it. */}
|
||||
{statsOpen && (
|
||||
<PlexStatsOverlay
|
||||
videoRef={videoRef}
|
||||
hlsRef={hlsRef}
|
||||
playable={detail?.playable ?? ""}
|
||||
hwRef={hwRef}
|
||||
sessionStartRef={sessionStartRef}
|
||||
absRef={absRef}
|
||||
durationRef={durationRef}
|
||||
onClose={() => setStatsOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Up-next end-card (episode end, next item queued, pref on). Non-modal — the finished frame
|
||||
stays behind it; the card's own countdown advances unless the viewer cancels. */}
|
||||
{upNextOpen && nextId && (
|
||||
<UpNextCard
|
||||
nextId={nextId}
|
||||
onAdvance={advanceUpNext}
|
||||
onCancel={() => setUpNextOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Keyboard-shortcut cheat sheet (toggle with "h" or the keyboard button). */}
|
||||
{helpOpen && (
|
||||
<div
|
||||
@@ -1107,7 +1243,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
<button
|
||||
onClick={() => setHelpOpen(false)}
|
||||
className="p-1 rounded-lg hover:bg-white/10"
|
||||
aria-label={t("plex.player.back")}
|
||||
aria-label={t("plex.player.close")}
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
@@ -1127,6 +1263,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
[["S"], "plex.player.keys.subtitle"],
|
||||
[["F"], "plex.player.keys.fullscreen"],
|
||||
[["I"], "plex.player.keys.info"],
|
||||
[["D"], "plex.player.keys.stats"],
|
||||
[["⌫", "Esc"], "plex.player.keys.back"],
|
||||
[["H"], "plex.player.keys.help"],
|
||||
] as [string[], string][]
|
||||
|
||||
@@ -113,6 +113,11 @@ export function PlexPlayerSettings(props: {
|
||||
checked={prefs.autoSkipCredits}
|
||||
onChange={(v) => patchPrefs({ autoSkipCredits: v })}
|
||||
/>
|
||||
<Toggle
|
||||
label={t("plex.player.upNext.pref")}
|
||||
checked={prefs.upNext}
|
||||
onChange={(v) => patchPrefs({ upNext: v })}
|
||||
/>
|
||||
{(prefs.autoSkipIntro || prefs.autoSkipCredits) && (
|
||||
<Slider
|
||||
label={t("plex.player.autoSkipDelay")}
|
||||
|
||||
@@ -3,8 +3,16 @@ import { coercePlexFilters, EMPTY_PLEX_FILTERS, type PlexFilters } from "../lib/
|
||||
import { parseSortState, type SortState } from "../lib/columnSort";
|
||||
import { parsePlexField, type PlexSearchField } from "../lib/plexSearch";
|
||||
import { PLEX_SORT_FALLBACK } from "../lib/plexSort";
|
||||
import {
|
||||
hasPlexParams,
|
||||
plexParamsToState,
|
||||
plexParamUpdates,
|
||||
type PlexUrlState,
|
||||
} from "../lib/plexUrlState";
|
||||
import { getAccountRaw, LS, setAccountRaw } from "../lib/storage";
|
||||
import { useAccountRehydrate } from "../lib/useAccountRehydrate";
|
||||
import { useUrlMirror } from "../lib/useUrlMirror";
|
||||
import { useNavigation } from "./NavigationProvider";
|
||||
|
||||
// Owns the Plex module's shared local state — the left-rail library scope / watch-state / sort /
|
||||
// expanded filters, the header search term, and the "open this playlist" hand-off — lifted out of
|
||||
@@ -112,14 +120,57 @@ function readPlexSort(): SortState {
|
||||
return { key: raw, dir };
|
||||
}
|
||||
|
||||
// S4: a shared Plex-browse link carries the content state in namespaced URL keys (pscope/psort/pshow/
|
||||
// pf). When present, the URL wins over this account's stored view (like the feed's URL-over-localStorage
|
||||
// seed) — read once at mount. The keys are Plex-unique, so their mere presence identifies a shared Plex
|
||||
// view; no ?page check is needed (and the mirror below is page-gated so it never pollutes other pages).
|
||||
function readPlexFilters(): PlexFilters {
|
||||
try {
|
||||
return coercePlexFilters(JSON.parse(readPlexFiltersRaw()) as Record<string, unknown>);
|
||||
} catch {
|
||||
return EMPTY_PLEX_FILTERS;
|
||||
}
|
||||
}
|
||||
// A shared-link seed = the URL-merged state PLUS which dimensions the URL actually carried. `present`
|
||||
// matters because the base below is read at MOUNT, before the account is pinned (getAccountRaw returns
|
||||
// null then), so the base for an OMITTED dimension is a hard default, NOT this account's stored value.
|
||||
// The provider must therefore persist/keep only the PRESENT dimensions on account-pin and re-read the
|
||||
// account's real stored value for the absent ones — otherwise a partial link silently resets them.
|
||||
type PlexSeedPresent = { scope: boolean; sort: boolean; show: boolean; filters: boolean };
|
||||
type PlexUrlSeed = { state: PlexUrlState; present: PlexSeedPresent };
|
||||
function readPlexUrlSeed(): PlexUrlSeed | null {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (!hasPlexParams(params)) return null;
|
||||
const state = plexParamsToState(params, {
|
||||
scope: readPlexScope(),
|
||||
sort: readPlexSort(),
|
||||
show: readPlexShow(),
|
||||
filters: readPlexFilters(),
|
||||
});
|
||||
return {
|
||||
state,
|
||||
present: {
|
||||
scope: params.has("pscope"),
|
||||
sort: params.has("psort"),
|
||||
show: params.has("pshow"),
|
||||
filters: params.has("pf"),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function PlexProvider({ children }: { children: ReactNode }) {
|
||||
// Captured once: the shared-link content state (or null). Seeds the slices below and, on account pin,
|
||||
// is persisted to the account instead of being clobbered by the stored view (see the rehydrate).
|
||||
const [urlSeed] = useState(readPlexUrlSeed);
|
||||
// The former per-library picker is now a cross-library SCOPE: movie | show | both (unified library).
|
||||
// (Reuses the old LS.plexLibrary key; an old stored library-id value falls back to "both".)
|
||||
const [scope, setScopeState] = useState<string>(readPlexScope);
|
||||
const [show, setShowState] = useState<string>(readPlexShow);
|
||||
const [sort, setSortState] = useState<SortState>(readPlexSort);
|
||||
const [scope, setScopeState] = useState<string>(() => urlSeed?.state.scope ?? readPlexScope());
|
||||
const [show, setShowState] = useState<string>(() => urlSeed?.state.show ?? readPlexShow());
|
||||
const [sort, setSortState] = useState<SortState>(() => urlSeed?.state.sort ?? readPlexSort());
|
||||
// The expanded Plex filters live as one JSON blob (the string-only account store serializes it).
|
||||
const [filtersRaw, setFiltersRaw] = useState<string>(readPlexFiltersRaw);
|
||||
const [filtersRaw, setFiltersRaw] = useState<string>(() =>
|
||||
urlSeed ? JSON.stringify(urlSeed.state.filters) : readPlexFiltersRaw(),
|
||||
);
|
||||
const filters = useMemo<PlexFilters>(() => {
|
||||
try {
|
||||
// coercePlexFilters fills defaults, drops the legacy `sortDir` (direction now lives in the sort
|
||||
@@ -140,7 +191,29 @@ export function PlexProvider({ children }: { children: ReactNode }) {
|
||||
const [jumpTo, setJumpTo] = useState<PlexJumpTarget | null>(null);
|
||||
|
||||
// Re-read every per-account slice once the account is pinned after mount (first load on a fresh tab).
|
||||
// A shared-link seed is the exception: instead of reloading the account's stored view, PERSIST the URL
|
||||
// state to this account (so the shared view wins and survives a later plain reload), and adopt only the
|
||||
// non-shared slices (q/field) from storage. Mirrors FeedFiltersProvider's initialHadUrlFilters branch.
|
||||
const markDirty = useAccountRehydrate(() => {
|
||||
if (urlSeed) {
|
||||
// Per dimension: a dimension the URL carried (present) is the shared view — adopt it and persist
|
||||
// it to this now-pinned account (so it wins and survives a later plain reload). A dimension the
|
||||
// URL OMITTED was seeded from a mount-time default (no account yet), so do NOT persist that
|
||||
// default over the account's real stored value — re-read the stored value instead, exactly like a
|
||||
// normal load. This is the fix for a partial shared link silently resetting the omitted slices.
|
||||
const { state, present } = urlSeed;
|
||||
if (present.scope) setAccountRaw(LS.plexLibrary, state.scope);
|
||||
else setScopeState(readPlexScope());
|
||||
if (present.show) setAccountRaw(LS.plexShow, state.show);
|
||||
else setShowState(readPlexShow());
|
||||
if (present.sort) setAccountRaw(LS.plexSort, JSON.stringify(state.sort));
|
||||
else setSortState(readPlexSort());
|
||||
if (present.filters) setAccountRaw(LS.plexFilters, JSON.stringify(state.filters));
|
||||
else setFiltersRaw(readPlexFiltersRaw());
|
||||
setQState(readPlexQ());
|
||||
setFieldState(readPlexField());
|
||||
return;
|
||||
}
|
||||
setScopeState(readPlexScope());
|
||||
setShowState(readPlexShow());
|
||||
setSortState(readPlexSort());
|
||||
@@ -208,6 +281,13 @@ export function PlexProvider({ children }: { children: ReactNode }) {
|
||||
[setScope, setShow, setSort, setFilters, setQ, setField],
|
||||
);
|
||||
|
||||
// S4: mirror the content state (scope + sort + watch-state + filters) into the address bar under the
|
||||
// namespaced keys so a Plex-browse view is shareable. Gated on page===plex: off the Plex page the keys
|
||||
// are cleared (localStorage still holds the state; it re-mirrors on return). `q`/`field` stay OUT — the
|
||||
// search term is deliberately transient-per-view and not part of the shared four-key contract.
|
||||
const { page } = useNavigation();
|
||||
useUrlMirror(plexParamUpdates(page === "plex" ? { scope, sort, show, filters } : null));
|
||||
|
||||
return (
|
||||
<ActionsContext.Provider value={actions}>
|
||||
<StateContext.Provider value={state}>{children}</StateContext.Provider>
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { type RefObject, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type Hls from "hls.js";
|
||||
import { X } from "lucide-react";
|
||||
import { buildStatRows, type HlsLevelInfo, type StatRow } from "../lib/plexStats";
|
||||
|
||||
// "Stats for nerds" debug overlay (E6 S2). Samples the live HTML5 <video> + hls.js state once a second
|
||||
// and renders the active mode, resolution, codec/bitrate, dropped frames, buffer, hls level and session
|
||||
// offset. No data layer — the refs are already in hand. Doubles as the S1 verification surface (it shows
|
||||
// the transcode mode + that the re-encode is engaged). Toggled with "d" / the gauge button / its X.
|
||||
export function PlexStatsOverlay({
|
||||
videoRef,
|
||||
hlsRef,
|
||||
playable,
|
||||
hwRef,
|
||||
sessionStartRef,
|
||||
absRef,
|
||||
durationRef,
|
||||
onClose,
|
||||
}: {
|
||||
videoRef: RefObject<HTMLVideoElement | null>;
|
||||
hlsRef: RefObject<Hls | null>;
|
||||
playable: string;
|
||||
hwRef: RefObject<boolean | null>; // active transcode encoder: true=GPU, false=CPU, null=not transcode
|
||||
sessionStartRef: RefObject<number>;
|
||||
absRef: RefObject<number>;
|
||||
durationRef: RefObject<number>;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [rows, setRows] = useState<StatRow[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const sample = () => {
|
||||
const v = videoRef.current;
|
||||
const hls = hlsRef.current;
|
||||
|
||||
let dropped = 0;
|
||||
let total = 0;
|
||||
try {
|
||||
const q = v?.getVideoPlaybackQuality?.();
|
||||
if (q) {
|
||||
dropped = q.droppedVideoFrames;
|
||||
total = q.totalVideoFrames;
|
||||
}
|
||||
} catch {
|
||||
/* getVideoPlaybackQuality unsupported — leave 0/0 (renders as a dash) */
|
||||
}
|
||||
|
||||
let bufferAhead = 0;
|
||||
if (v) {
|
||||
const now = v.currentTime;
|
||||
for (let i = 0; i < v.buffered.length; i++) {
|
||||
if (now >= v.buffered.start(i) && now <= v.buffered.end(i)) {
|
||||
bufferAhead = v.buffered.end(i) - now;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let level: HlsLevelInfo | null = null;
|
||||
if (hls) {
|
||||
// currentLevel is -1 while auto-ABR hasn't settled; fall back to loadLevel for the res/bitrate.
|
||||
const idx = hls.currentLevel >= 0 ? hls.currentLevel : hls.loadLevel;
|
||||
const lv = idx >= 0 ? hls.levels?.[idx] : undefined;
|
||||
level = {
|
||||
index: hls.currentLevel,
|
||||
total: hls.levels?.length ?? 0,
|
||||
width: lv?.width,
|
||||
height: lv?.height,
|
||||
bitrate: lv?.bitrate,
|
||||
videoCodec: lv?.videoCodec,
|
||||
audioCodec: lv?.audioCodec,
|
||||
};
|
||||
}
|
||||
|
||||
setRows(
|
||||
buildStatRows({
|
||||
playable,
|
||||
hwEncoder: hwRef.current,
|
||||
transport: hls ? "hls" : "direct",
|
||||
videoWidth: v?.videoWidth ?? 0,
|
||||
videoHeight: v?.videoHeight ?? 0,
|
||||
droppedFrames: dropped,
|
||||
totalFrames: total,
|
||||
bufferAheadS: bufferAhead,
|
||||
hlsLevel: level,
|
||||
sessionStartS: sessionStartRef.current ?? 0,
|
||||
abs: absRef.current ?? 0,
|
||||
duration: durationRef.current ?? 0,
|
||||
}),
|
||||
);
|
||||
};
|
||||
sample();
|
||||
const iv = setInterval(sample, 1000);
|
||||
return () => clearInterval(iv);
|
||||
}, [videoRef, hlsRef, playable, hwRef, sessionStartRef, absRef, durationRef]);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="region"
|
||||
aria-label={t("plex.player.stats.title")}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="absolute left-4 top-16 z-menu w-64 rounded-xl border border-white/15 bg-black/80 p-3 text-white shadow-2xl backdrop-blur-sm"
|
||||
>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-white/70">
|
||||
{t("plex.player.stats.title")}
|
||||
</span>
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label={t("plex.player.close")}
|
||||
className="rounded p-0.5 text-white/60 hover:bg-white/10 hover:text-white"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
{/* No aria-live here: the rows re-sample every second, so a live region would make a screen
|
||||
reader announce the changing values continuously and drown out real player announcements.
|
||||
The overlay is a role="region" (labelled above) a user reads on demand, not a status feed. */}
|
||||
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-2xs">
|
||||
{rows.map((r) => (
|
||||
<div key={r.key} className="contents">
|
||||
<dt className="text-white/55">{t(r.labelKey)}</dt>
|
||||
<dd className="text-right font-mono tabular-nums text-white/90">{r.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Interaction contract of the shared PriorityStepper (channel manager cell + detail header). i18n is
|
||||
// mocked to a key-passthrough so aria-label lookups read stable keys, not translations. The visual
|
||||
// size/border split (`sm` vs `md`) is CSS-only, so we assert behaviour, not styling.
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
import { PriorityStepper } from "./PriorityStepper";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers(); // any fake-timer test resets here so it can't leak into the next
|
||||
});
|
||||
|
||||
const raise = () => screen.getByRole("button", { name: "channels.row.raisePriority" });
|
||||
const lower = () => screen.getByRole("button", { name: "channels.row.lowerPriority" });
|
||||
const valueBtn = () => screen.getByRole("button", { name: "channels.row.editPriority" });
|
||||
|
||||
describe("PriorityStepper", () => {
|
||||
it("shows the current priority and both step buttons", () => {
|
||||
render(<PriorityStepper priority={12} onPriority={vi.fn()} onSet={vi.fn()} />);
|
||||
expect(valueBtn().textContent).toBe("12");
|
||||
expect(raise()).toBeTruthy();
|
||||
expect(lower()).toBeTruthy();
|
||||
});
|
||||
|
||||
it("tapping + / − nudges by ±1", () => {
|
||||
const onPriority = vi.fn();
|
||||
render(<PriorityStepper priority={0} onPriority={onPriority} onSet={vi.fn()} />);
|
||||
fireEvent.click(raise());
|
||||
fireEvent.click(lower());
|
||||
expect(onPriority.mock.calls).toEqual([[1], [-1]]);
|
||||
});
|
||||
|
||||
it("clicking the value opens an inline editor seeded with the current value", () => {
|
||||
render(<PriorityStepper priority={7} onPriority={vi.fn()} onSet={vi.fn()} />);
|
||||
fireEvent.click(valueBtn());
|
||||
const input = screen.getByRole("spinbutton") as HTMLInputElement;
|
||||
expect(input.value).toBe("7");
|
||||
});
|
||||
|
||||
it("typing a new value and pressing Enter commits it via onSet (negatives allowed)", () => {
|
||||
const onSet = vi.fn();
|
||||
render(<PriorityStepper priority={7} onPriority={vi.fn()} onSet={onSet} />);
|
||||
fireEvent.click(valueBtn());
|
||||
const input = screen.getByRole("spinbutton");
|
||||
fireEvent.change(input, { target: { value: "-3" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
expect(onSet).toHaveBeenCalledWith(-3);
|
||||
});
|
||||
|
||||
it("Escape cancels the edit without committing", () => {
|
||||
const onSet = vi.fn();
|
||||
render(<PriorityStepper priority={7} onPriority={vi.fn()} onSet={onSet} />);
|
||||
fireEvent.click(valueBtn());
|
||||
const input = screen.getByRole("spinbutton");
|
||||
fireEvent.change(input, { target: { value: "99" } });
|
||||
fireEvent.keyDown(input, { key: "Escape" });
|
||||
expect(onSet).not.toHaveBeenCalled();
|
||||
// editor closed → the value button is back
|
||||
expect(valueBtn().textContent).toBe("7");
|
||||
});
|
||||
|
||||
it("committing an unchanged value does not call onSet", () => {
|
||||
const onSet = vi.fn();
|
||||
render(<PriorityStepper priority={7} onPriority={vi.fn()} onSet={onSet} />);
|
||||
fireEvent.click(valueBtn());
|
||||
fireEvent.keyDown(screen.getByRole("spinbutton"), { key: "Enter" });
|
||||
expect(onSet).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders the same interactive contract at size=md", () => {
|
||||
const onPriority = vi.fn();
|
||||
render(<PriorityStepper priority={3} onPriority={onPriority} onSet={vi.fn()} size="md" />);
|
||||
expect(valueBtn().textContent).toBe("3");
|
||||
fireEvent.click(raise());
|
||||
expect(onPriority).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it("holding a step button auto-repeats (accelerating) and suppresses the trailing click", () => {
|
||||
vi.useFakeTimers();
|
||||
const onPriority = vi.fn();
|
||||
render(<PriorityStepper priority={0} onPriority={onPriority} onSet={vi.fn()} />);
|
||||
|
||||
fireEvent.pointerDown(raise());
|
||||
vi.advanceTimersByTime(349);
|
||||
expect(onPriority).not.toHaveBeenCalled(); // a tap below the 350ms threshold doesn't repeat
|
||||
vi.advanceTimersByTime(1); // cross the threshold → first auto-repeat tick
|
||||
expect(onPriority).toHaveBeenCalledTimes(1);
|
||||
|
||||
vi.advanceTimersByTime(1000); // several accelerating repeats while held
|
||||
const heldCount = onPriority.mock.calls.length;
|
||||
expect(heldCount).toBeGreaterThan(3);
|
||||
expect(onPriority.mock.calls.every(([d]) => d === 1)).toBe(true);
|
||||
|
||||
fireEvent.pointerUp(raise());
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(onPriority.mock.calls.length).toBe(heldCount); // stops repeating on release
|
||||
|
||||
fireEvent.click(raise()); // the click that trails a hold is swallowed (no double-count)
|
||||
expect(onPriority.mock.calls.length).toBe(heldCount);
|
||||
});
|
||||
|
||||
it("a quick tap (released before the hold threshold) still nudges exactly once", () => {
|
||||
vi.useFakeTimers();
|
||||
const onPriority = vi.fn();
|
||||
render(<PriorityStepper priority={0} onPriority={onPriority} onSet={vi.fn()} />);
|
||||
|
||||
fireEvent.pointerDown(lower());
|
||||
vi.advanceTimersByTime(100); // below the threshold → no auto-repeat armed
|
||||
fireEvent.pointerUp(lower());
|
||||
fireEvent.click(lower()); // the tap's click is NOT suppressed
|
||||
expect(onPriority.mock.calls).toEqual([[-1]]);
|
||||
});
|
||||
|
||||
it("clears the auto-repeat timer on unmount (no fire after the row is gone)", () => {
|
||||
vi.useFakeTimers();
|
||||
const onPriority = vi.fn();
|
||||
const { unmount } = render(
|
||||
<PriorityStepper priority={0} onPriority={onPriority} onSet={vi.fn()} />,
|
||||
);
|
||||
|
||||
fireEvent.pointerDown(raise());
|
||||
vi.advanceTimersByTime(350); // repeat armed + first tick
|
||||
const before = onPriority.mock.calls.length;
|
||||
expect(before).toBeGreaterThan(0);
|
||||
|
||||
unmount();
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(onPriority.mock.calls.length).toBe(before); // no post-unmount timer fire
|
||||
});
|
||||
});
|
||||
@@ -1,19 +1,28 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { ArrowDown, ArrowUp } from "lucide-react";
|
||||
import { Minus, Plus } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Tooltip from "./Tooltip";
|
||||
|
||||
type Size = "sm" | "md";
|
||||
|
||||
const SIZED = {
|
||||
sm: { btn: "w-6 h-6", icon: "w-3.5 h-3.5", value: "w-8 h-6 text-sm" },
|
||||
md: { btn: "w-7 h-7", icon: "w-4 h-4", value: "w-10 h-7 text-sm" },
|
||||
} as const;
|
||||
|
||||
function PriorityStepButton({
|
||||
delta,
|
||||
Icon,
|
||||
label,
|
||||
sizeCls,
|
||||
onHoldStart,
|
||||
onStop,
|
||||
onTap,
|
||||
}: {
|
||||
delta: number;
|
||||
Icon: typeof ArrowUp;
|
||||
Icon: typeof Plus;
|
||||
label: string;
|
||||
sizeCls: { btn: string; icon: string };
|
||||
onHoldStart: (delta: number) => void;
|
||||
onStop: () => void;
|
||||
onTap: (delta: number) => void;
|
||||
@@ -32,28 +41,35 @@ function PriorityStepButton({
|
||||
onPointerUp={onStop}
|
||||
onPointerCancel={onStop}
|
||||
onClick={() => onTap(delta)}
|
||||
className="text-muted hover:text-accent leading-none"
|
||||
className={`inline-flex items-center justify-center text-muted hover:text-accent ${sizeCls.btn}`}
|
||||
aria-label={label}
|
||||
>
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
<Icon className={sizeCls.icon} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A press-and-hold-repeat priority control with an inline-editable value (negatives allowed).
|
||||
* Shared by the channel manager table (a cell) and the channel detail page (a header control), so
|
||||
* the two never drift. The caller owns persistence: `onPriority(delta)` nudges by ±1 (with hold
|
||||
* auto-repeat), `onSet(value)` writes a typed-in absolute value.
|
||||
* A horizontal −/+ priority stepper with the value centred between the buttons and click-to-type
|
||||
* inline editing (negatives allowed). Shared by the channel manager table (a cell, `size="sm"`) and
|
||||
* the channel detail page (a header control, `size="md"`), so the two never drift. The caller owns
|
||||
* persistence: `onPriority(delta)` nudges by ±1 (with hold auto-repeat), `onSet(value)` writes a
|
||||
* typed-in absolute value.
|
||||
*
|
||||
* The `size="sm"` variant draws its own bordered pill (it stands alone in a table cell); `size="md"`
|
||||
* drops the outer border and keeps only the segment dividers, so it blends into the detail header's
|
||||
* surrounding lozenge instead of double-bordering it.
|
||||
*/
|
||||
export function PriorityStepper({
|
||||
priority,
|
||||
onPriority,
|
||||
onSet,
|
||||
size = "sm",
|
||||
}: {
|
||||
priority: number;
|
||||
onPriority: (delta: number) => void;
|
||||
onSet: (value: number) => void;
|
||||
size?: Size;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [editing, setEditing] = useState(false);
|
||||
@@ -61,6 +77,7 @@ export function PriorityStepper({
|
||||
const holdDelay = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const holdRepeat = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const suppressClick = useRef(false);
|
||||
const cls = SIZED[size];
|
||||
|
||||
const stop = useCallback(() => {
|
||||
if (holdDelay.current) clearTimeout(holdDelay.current);
|
||||
@@ -103,21 +120,40 @@ export function PriorityStepper({
|
||||
setEditing(true);
|
||||
};
|
||||
|
||||
// The value sits to the LEFT of the arrows (not between them) so the cursor — which rests on the
|
||||
// arrows while you press-and-hold — never covers the live value you're setting. Clicking the value
|
||||
// opens an inline edit to type any integer directly (negatives allowed).
|
||||
// Segmented control: [−] value [+]. The value slot keeps a FIXED width (`cls.value`) whether it's
|
||||
// showing the number or the edit input, so opening the editor never resizes the control or shifts
|
||||
// the buttons — the old absolutely-positioned overlay "popped" wider, this doesn't. While editing
|
||||
// the whole pill borders in `accent` to read as focused.
|
||||
const framed =
|
||||
size === "sm"
|
||||
? `border rounded-md overflow-hidden ${editing ? "border-accent" : "border-border"}`
|
||||
: "";
|
||||
return (
|
||||
<Tooltip hint={t("channels.row.priorityHint")}>
|
||||
<div className="inline-flex items-center gap-1.5 cursor-help">
|
||||
{/* The number keeps its footprint (w-6) whether displayed or editing; the input is absolutely
|
||||
positioned OVER it (out of flow) so opening the editor never changes the row height/width —
|
||||
it just overlays, extending left for a longer number. */}
|
||||
<span className="relative inline-block w-6 h-5 text-right leading-5">
|
||||
<div
|
||||
role="group"
|
||||
aria-label={t("channels.row.priorityHint")}
|
||||
className={`inline-flex items-center cursor-help ${framed}`}
|
||||
>
|
||||
<PriorityStepButton
|
||||
delta={-1}
|
||||
Icon={Minus}
|
||||
label={t("channels.row.lowerPriority")}
|
||||
sizeCls={cls}
|
||||
onHoldStart={start}
|
||||
onStop={stop}
|
||||
onTap={onTap}
|
||||
/>
|
||||
<span
|
||||
className={`relative inline-flex items-center justify-center border-x ${
|
||||
editing ? "border-accent" : "border-border"
|
||||
} ${cls.value}`}
|
||||
>
|
||||
<button
|
||||
onClick={beginEdit}
|
||||
aria-label={t("channels.row.editPriority")}
|
||||
title={t("channels.row.editPriority")}
|
||||
className={`text-sm font-medium tabular-nums hover:text-accent ${editing ? "invisible" : ""}`}
|
||||
className={`w-full text-center font-medium tabular-nums hover:text-accent ${editing ? "invisible" : ""}`}
|
||||
>
|
||||
{priority}
|
||||
</button>
|
||||
@@ -134,28 +170,19 @@ export function PriorityStepper({
|
||||
else if (e.key === "Escape") setEditing(false);
|
||||
}}
|
||||
aria-label={t("channels.row.priorityHint")}
|
||||
className="absolute top-1/2 right-0 -translate-y-1/2 w-14 h-6 text-sm font-medium tabular-nums text-right bg-card border border-accent rounded-sm px-1 outline-hidden z-popover [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none"
|
||||
className="absolute inset-0 w-full h-full text-center font-medium tabular-nums text-accent bg-card outline-hidden [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none"
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
<span className="inline-flex flex-col">
|
||||
<PriorityStepButton
|
||||
delta={1}
|
||||
Icon={ArrowUp}
|
||||
label={t("channels.row.raisePriority")}
|
||||
onHoldStart={start}
|
||||
onStop={stop}
|
||||
onTap={onTap}
|
||||
/>
|
||||
<PriorityStepButton
|
||||
delta={-1}
|
||||
Icon={ArrowDown}
|
||||
label={t("channels.row.lowerPriority")}
|
||||
onHoldStart={start}
|
||||
onStop={stop}
|
||||
onTap={onTap}
|
||||
/>
|
||||
</span>
|
||||
<PriorityStepButton
|
||||
delta={1}
|
||||
Icon={Plus}
|
||||
label={t("channels.row.raisePriority")}
|
||||
sizeCls={cls}
|
||||
onHoldStart={start}
|
||||
onStop={stop}
|
||||
onTap={onTap}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
@@ -7,6 +7,7 @@ import Modal from "./Modal";
|
||||
import { useConfirm } from "./ConfirmProvider";
|
||||
import { api, type DownloadProfile, type DownloadSpec } from "../lib/api";
|
||||
import { inputCls } from "./ui/form";
|
||||
import { Button } from "./ui/Button";
|
||||
|
||||
const HEIGHTS = [null, 2160, 1440, 1080, 720, 480, 360];
|
||||
|
||||
@@ -145,12 +146,9 @@ export default function ProfileEditor({ onClose }: { onClose: () => void }) {
|
||||
</div>
|
||||
|
||||
{!formOpen ? (
|
||||
<button
|
||||
onClick={startNew}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium btn-primary transition"
|
||||
>
|
||||
<Plus className="w-4 h-4" /> {t("downloads.profiles.new")}
|
||||
</button>
|
||||
<Button variant="primary" size="sm" icon={<Plus className="w-4 h-4" />} onClick={startNew}>
|
||||
{t("downloads.profiles.new")}
|
||||
</Button>
|
||||
) : (
|
||||
<div className="rounded-xl border border-border p-4 space-y-3">
|
||||
<div>
|
||||
@@ -197,6 +195,8 @@ export default function ProfileEditor({ onClose }: { onClose: () => void }) {
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
{t("downloads.profiles.audioFormat")}
|
||||
</label>
|
||||
{/* Only browser-playable audio: AAC (m4a) + MP3. Opus is dropped — Safari plays it
|
||||
unreliably as a bare file, and the module's purpose is in-browser playback. */}
|
||||
<select
|
||||
value={spec.audio_format ?? "m4a"}
|
||||
onChange={set("audio_format")}
|
||||
@@ -204,39 +204,12 @@ export default function ProfileEditor({ onClose }: { onClose: () => void }) {
|
||||
>
|
||||
<option value="m4a">M4A</option>
|
||||
<option value="mp3">MP3</option>
|
||||
<option value="opus">Opus</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
{spec.mode !== "a" && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
{t("downloads.profiles.container")}
|
||||
</label>
|
||||
<select
|
||||
value={spec.container ?? "mp4"}
|
||||
onChange={set("container")}
|
||||
className={inputCls}
|
||||
>
|
||||
<option value="mp4">MP4</option>
|
||||
<option value="mkv">MKV</option>
|
||||
<option value="webm">WebM</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
{t("downloads.profiles.vcodec")}
|
||||
</label>
|
||||
<select value={spec.vcodec ?? ""} onChange={set("vcodec")} className={inputCls}>
|
||||
<option value="">{t("downloads.profiles.any")}</option>
|
||||
<option value="h264">H.264</option>
|
||||
<option value="vp9">VP9</option>
|
||||
<option value="av1">AV1</option>
|
||||
</select>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{/* Container/codec are no longer user-selectable: every download is clamped to the
|
||||
mp4/H.264/AAC compat profile (the only universally browser-playable combo) so a
|
||||
shared link plays everywhere, iOS/Safari included. Enforced in backend `normalize`. */}
|
||||
</div>
|
||||
<div className="space-y-1.5 pt-1">
|
||||
{spec.mode !== "a" && (
|
||||
@@ -263,19 +236,17 @@ export default function ProfileEditor({ onClose }: { onClose: () => void }) {
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<button
|
||||
onClick={() => setFormOpen(false)}
|
||||
className="px-3 py-1.5 rounded-lg text-sm text-muted hover:text-fg hover:bg-surface transition"
|
||||
>
|
||||
<Button variant="ghost" size="sm" onClick={() => setFormOpen(false)}>
|
||||
{t("downloads.actions.cancel")}
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => save.mutate()}
|
||||
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")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -103,6 +103,11 @@ export interface SavedViewsContext {
|
||||
clearActive: () => void;
|
||||
create: (name: string) => Promise<boolean>;
|
||||
rename: (id: number, name: string) => Promise<boolean>;
|
||||
/** Overwrite a view's captured filters (used by the modal's removable chips). */
|
||||
updateFilters: (id: number, filters: Record<string, unknown>) => Promise<boolean>;
|
||||
/** Save the module's CURRENTLY-applied filters into a view (the live→stored inverse of `apply`);
|
||||
* resolves to the saved snapshot on success (so the caller can resync its editor) or null. */
|
||||
saveCurrent: (id: number) => Promise<Record<string, unknown> | null>;
|
||||
toggleDefault: (v: SavedView) => Promise<void>;
|
||||
remove: (v: SavedView) => Promise<boolean>;
|
||||
reorder: (nextIds: number[]) => Promise<void>;
|
||||
@@ -258,6 +263,16 @@ export function SavedViewsProvider({ children }: { children: ReactNode }) {
|
||||
if (!next) return false;
|
||||
return runViewMutation(() => api.updateView(id, { name: next }), t("views.actionFailed"));
|
||||
},
|
||||
updateFilters: (id, filters) =>
|
||||
runViewMutation(() => api.updateView(id, { filters }), t("views.actionFailed")),
|
||||
saveCurrent: async (id) => {
|
||||
const filters = cfg.stripForSave(cfg.filters);
|
||||
const ok = await runViewMutation(
|
||||
() => api.updateView(id, { filters }),
|
||||
t("views.actionFailed"),
|
||||
);
|
||||
return ok ? filters : null;
|
||||
},
|
||||
toggleDefault: (v) =>
|
||||
runViewMutation(
|
||||
() => api.updateView(v.id, { is_default: !v.is_default }),
|
||||
|
||||
@@ -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 Tooltip from "./Tooltip";
|
||||
import { inputCls, Section, SettingRow, Switch } from "./ui/form";
|
||||
import { Button } from "./ui/Button";
|
||||
import { type SaveState } from "./ui/DraftSaveBar";
|
||||
import { useConfirm } from "./ConfirmProvider";
|
||||
import { StateMessage } from "./QueryState";
|
||||
import { useWizard } from "./WizardProvider";
|
||||
import { usePrefs } from "./PrefsProvider";
|
||||
import Tabs from "./Tabs";
|
||||
@@ -472,13 +474,9 @@ function SignInMethods({ me }: { me: Me }) {
|
||||
className={inputCls}
|
||||
/>
|
||||
{err && <p className="text-xs text-danger">{err}</p>}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy || !next}
|
||||
className="px-3 py-1.5 rounded-lg text-sm btn-primary font-medium disabled:opacity-40 transition"
|
||||
>
|
||||
<Button type="submit" variant="primary" size="sm" disabled={busy || !next}>
|
||||
{busy ? t("settings.account.password.saving") : t("settings.account.password.save")}
|
||||
</button>
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</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 }) {
|
||||
const { t } = useTranslation();
|
||||
const confirm = useConfirm();
|
||||
@@ -628,6 +781,8 @@ function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) {
|
||||
|
||||
{me.role === "admin" && me.plex_enabled && <PlexWatchSync />}
|
||||
|
||||
{!me.is_demo && <DownloadAuth />}
|
||||
|
||||
{me.is_demo ? (
|
||||
<Section title={t("settings.account.youtubeAccess")}>
|
||||
<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 { copyText } from "../lib/clipboard";
|
||||
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;
|
||||
|
||||
// --- share with a registered user (ACL) -----------------------------------------------------
|
||||
@@ -253,7 +254,9 @@ function LinkShare({ job }: { job: DownloadJob }) {
|
||||
queryFn: () => api.downloadLinks(job.id),
|
||||
});
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [allowDownload, setAllowDownload] = useState(false);
|
||||
// D1 (E7 S3): downloadable by default — matches the one-click Share (which relies on the backend
|
||||
// default) and the common "let them have the file" intent. The owner can uncheck it here.
|
||||
const [allowDownload, setAllowDownload] = useState(true);
|
||||
const [expiryDays, setExpiryDays] = useState<number | null>(null);
|
||||
const [password, setPassword] = useState("");
|
||||
const invalidate = () => qc.invalidateQueries({ queryKey: qk.downloadLinks(job.id) });
|
||||
@@ -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
|
||||
// (no password, no expiry, view-only) link the moment the dialog finds none — as if the user
|
||||
// had pressed "New link" → "Create". All further controls (password, download toggle, expiry,
|
||||
// (no password, no expiry, downloadable per D1) link the moment the dialog finds none — as if the
|
||||
// user had pressed "New link" → "Create". All further controls (password, download toggle, expiry,
|
||||
// extra links, revoke) stay available on the created link and via the "New link" button.
|
||||
const autoTried = useRef(false);
|
||||
useEffect(() => {
|
||||
@@ -343,31 +346,29 @@ function LinkShare({ job }: { job: DownloadJob }) {
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
onClick={() => setCreating(false)}
|
||||
className={clsx(btnCls, "text-muted hover:text-fg hover:bg-surface")}
|
||||
>
|
||||
<Button variant="ghost" size="sm" onClick={() => setCreating(false)}>
|
||||
{t("common.cancel")}
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => create.mutate()}
|
||||
disabled={create.isPending}
|
||||
className={clsx(btnCls, "btn-primary")}
|
||||
>
|
||||
{t("downloads.share.createLink")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCreating(true)}
|
||||
className={clsx(
|
||||
btnCls,
|
||||
"mt-2 border border-border text-muted hover:text-fg hover:border-muted inline-flex items-center gap-1.5",
|
||||
)}
|
||||
icon={<Link2 className="w-4 h-4" />}
|
||||
className="mt-2"
|
||||
>
|
||||
<Link2 className="w-4 h-4" /> {t("downloads.share.newLink")}
|
||||
</button>
|
||||
{t("downloads.share.newLink")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -55,7 +55,7 @@ export default function SortControl({
|
||||
const summary = activeOpt?.label ?? defaultLabel ?? options[0]?.label ?? "";
|
||||
|
||||
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
|
||||
? "border-accent bg-accent/15 text-accent"
|
||||
: "border-border text-muted hover:border-accent/50 hover:text-fg"
|
||||
@@ -92,7 +92,7 @@ export default function SortControl({
|
||||
<PopoverSurface
|
||||
popover={pop}
|
||||
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
|
||||
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 { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Crop,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Pause,
|
||||
Play,
|
||||
Scissors,
|
||||
SplitSquareHorizontal,
|
||||
Loader2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { Crop, Eye, EyeOff, Pause, Play, Scissors, SplitSquareHorizontal, X } from "lucide-react";
|
||||
import clsx from "clsx";
|
||||
import Modal from "./Modal";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { api, type DownloadJob, type EditSpec } from "../lib/api";
|
||||
import { contentRect, cropToSourcePx, type Frac } from "../lib/cropGeometry";
|
||||
import { inputCls, btnCls } from "./ui/form";
|
||||
import { Button } from "./ui/Button";
|
||||
|
||||
// mm:ss.d timecode <-> seconds.
|
||||
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 Frac = { x: number; y: number; w: number; h: number };
|
||||
type Drag = { type: "seek" } | { type: "boundary"; bi: number };
|
||||
|
||||
const numCls =
|
||||
@@ -60,6 +51,11 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
||||
const [current, setCurrent] = useState(0);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [trackW, setTrackW] = useState(0);
|
||||
// Intrinsic (source) video dims and the video element's rendered box, both needed to map the
|
||||
// crop marquee onto the letterboxed picture. `vdim` starts from asset metadata so the overlay
|
||||
// can size before the media decodes; `box` is measured off the element (max-h can clamp it).
|
||||
const [vdim, setVdim] = useState({ w: job.asset?.width ?? 0, h: job.asset?.height ?? 0 });
|
||||
const [box, setBox] = useState({ w: 0, h: 0 });
|
||||
|
||||
// The cut-list: contiguous segments covering [0, duration]. Starts as the whole video, kept.
|
||||
const [segments, setSegments] = useState<Seg[]>([{ id: 0, start: 0, end: srcDur, keep: true }]);
|
||||
@@ -92,11 +88,28 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
// Measure the video element's rendered box (max-h can shrink it below its intrinsic size, which
|
||||
// then pillarboxes the picture). The crop overlay is anchored to the picture, not this box.
|
||||
useEffect(() => {
|
||||
const v = videoRef.current;
|
||||
if (!v || typeof ResizeObserver === "undefined") return;
|
||||
const ro = new ResizeObserver(() => setBox({ w: v.clientWidth, h: v.clientHeight }));
|
||||
ro.observe(v);
|
||||
setBox({ w: v.clientWidth, h: v.clientHeight });
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
// The picture's rendered rectangle inside the (possibly letterboxed) video box — the overlay's
|
||||
// frame, so a crop fraction is a fraction of the real picture, not of the black bars.
|
||||
const va = vdim.w && vdim.h ? vdim.w / vdim.h : aspect;
|
||||
const picRect = contentRect(box.w, box.h, va);
|
||||
|
||||
const onLoaded = () => {
|
||||
const v = videoRef.current;
|
||||
if (!v) return;
|
||||
const d = v.duration && isFinite(v.duration) ? v.duration : srcDur;
|
||||
setDuration(d);
|
||||
if (v.videoWidth && v.videoHeight) setVdim({ w: v.videoWidth, h: v.videoHeight });
|
||||
// Reconcile the still-pristine full-length segment to the REAL decoded duration. The initial
|
||||
// segment ends at the (integer, often-rounded) metadata `srcDur`; once the media loads we know
|
||||
// the true length (e.g. 213.44 vs 213). Without this the untouched last segment stays short, so
|
||||
@@ -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 v = videoRef.current;
|
||||
const vw = v?.videoWidth || job.asset?.width || 0;
|
||||
const vh = v?.videoHeight || job.asset?.height || 0;
|
||||
if (!cropOn || !vw || !vh) return undefined;
|
||||
const even = (n: number) => Math.max(2, Math.round(n / 2) * 2);
|
||||
return {
|
||||
x: even(crop.x * vw),
|
||||
y: even(crop.y * vh),
|
||||
w: even(crop.w * vw),
|
||||
h: even(crop.h * vh),
|
||||
};
|
||||
const vw = v?.videoWidth || vdim.w;
|
||||
const vh = v?.videoHeight || vdim.h;
|
||||
if (!cropOn) return undefined;
|
||||
// crop fractions are relative to the picture (the overlay is anchored to it), so this is a
|
||||
// straight fraction→source-px scale; see cropGeometry for the even-rounding + in-bounds clamp.
|
||||
return cropToSourcePx(crop, vw, vh) ?? undefined;
|
||||
};
|
||||
|
||||
const create = useMutation({
|
||||
@@ -331,11 +340,20 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
||||
onTimeUpdate={onTimeUpdate}
|
||||
onPlay={() => setPlaying(true)}
|
||||
onPause={() => setPlaying(false)}
|
||||
className="w-full max-h-[42vh] mx-auto block"
|
||||
className="w-full max-h-[42vh] mx-auto block object-contain"
|
||||
playsInline
|
||||
/>
|
||||
{cropOn && (
|
||||
<div ref={overlayRef} className="absolute inset-0">
|
||||
{cropOn && picRect && (
|
||||
<div
|
||||
ref={overlayRef}
|
||||
className="absolute"
|
||||
style={{
|
||||
left: picRect.left,
|
||||
top: picRect.top,
|
||||
width: picRect.width,
|
||||
height: picRect.height,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute border-2 border-accent cursor-move"
|
||||
style={{
|
||||
@@ -621,28 +639,23 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
||||
{reencode ? t("editor.willReencode") : t("editor.willCopy")}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className={clsx(btnCls, "text-muted hover:text-fg hover:bg-surface")}
|
||||
>
|
||||
<Button variant="ghost" size="sm" onClick={onClose}>
|
||||
{t("common.cancel")}
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => create.mutate()}
|
||||
disabled={!valid || create.isPending}
|
||||
className={clsx(btnCls, "btn-primary inline-flex items-center gap-1.5")}
|
||||
disabled={!valid}
|
||||
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
|
||||
? t("editor.createJoin")
|
||||
: kept.length > 1
|
||||
? t("editor.createN", { count: kept.length })
|
||||
: t("editor.create")}
|
||||
</button>
|
||||
</Button>
|
||||
</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 { Download, Link2, Lock, PlayCircle } from "lucide-react";
|
||||
import { Download, File as FileIcon, Link2, Lock, PlayCircle } from "lucide-react";
|
||||
import { Button } from "./ui/Button";
|
||||
import { formatBytes } from "../lib/format";
|
||||
|
||||
// Standalone, login-free video page for a shared /watch/<token> link. Rendered OUTSIDE the app
|
||||
// tree (no auth, no providers) — like the /privacy and /terms pages. Uses plain fetch and a tiny
|
||||
@@ -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
|
||||
// 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 —
|
||||
// which STOPPED playback instead of keeping it alive (measured on iPad Brave). A plain <video> +
|
||||
// MediaSession leaves the browser's native background handling in charge.
|
||||
// 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. Typed on HTMLMediaElement
|
||||
// so it drives either the video or the audio-only player.
|
||||
function useMediaSession(
|
||||
videoRef: React.RefObject<HTMLVideoElement | null>,
|
||||
videoRef: React.RefObject<HTMLMediaElement | null>,
|
||||
meta: Meta | null,
|
||||
ready: boolean,
|
||||
) {
|
||||
@@ -45,6 +48,10 @@ type Meta = {
|
||||
source_url?: string | null;
|
||||
extra_links?: string[];
|
||||
duration_s?: number | null;
|
||||
audio?: boolean;
|
||||
media_kind?: "video" | "audio" | "file" | null;
|
||||
size_bytes?: number | null;
|
||||
container?: string | null;
|
||||
allow_download?: boolean;
|
||||
file_url?: string;
|
||||
poster_url?: string | null;
|
||||
@@ -70,6 +77,7 @@ const STR = {
|
||||
watch: "Watch",
|
||||
wrong: "Wrong password.",
|
||||
download: "Download",
|
||||
file: "File",
|
||||
links: "Links",
|
||||
brand: "Shared via Siftlode",
|
||||
},
|
||||
@@ -82,6 +90,7 @@ const STR = {
|
||||
watch: "Megnézem",
|
||||
wrong: "Hibás jelszó.",
|
||||
download: "Letöltés",
|
||||
file: "Fájl",
|
||||
links: "Linkek",
|
||||
brand: "Megosztva a Siftlode-dal",
|
||||
},
|
||||
@@ -94,6 +103,7 @@ const STR = {
|
||||
watch: "Ansehen",
|
||||
wrong: "Falsches Passwort.",
|
||||
download: "Herunterladen",
|
||||
file: "Datei",
|
||||
links: "Links",
|
||||
brand: "Geteilt über Siftlode",
|
||||
},
|
||||
@@ -112,8 +122,13 @@ export default function WatchPage() {
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [unlocking, setUnlocking] = useState(false);
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
useMediaSession(videoRef, meta, status === "ready");
|
||||
// One ref for whichever element renders (video or audio-only) — a callback ref so it can bind to
|
||||
// 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(() => {
|
||||
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"
|
||||
/>
|
||||
{error && <div className="text-sm text-danger mt-2">{error}</div>}
|
||||
<button
|
||||
<Button
|
||||
variant="primary"
|
||||
size="md"
|
||||
fullWidth
|
||||
onClick={unlock}
|
||||
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}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === "ready" && meta && (
|
||||
<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={videoRef}
|
||||
controls
|
||||
playsInline
|
||||
src={meta.file_url}
|
||||
poster={meta.poster_url || undefined}
|
||||
className="max-h-[80vh] max-w-full mx-auto block"
|
||||
/>
|
||||
{status === "ready" && meta && meta.media_kind === "file" && (
|
||||
/* A generic uploaded file has no player — just an info card (name/type/size) and, if the
|
||||
link allows it, a Download. The backend serves the file force-download regardless, so
|
||||
gating the button on allow_download only mirrors the owner's ACL, matching the media
|
||||
branch's download affordance below. */
|
||||
<div className="flex-1 grid place-items-center">
|
||||
<div className="w-full max-w-md rounded-2xl glass p-6 text-center">
|
||||
<div className="mx-auto w-16 h-16 rounded-xl bg-surface grid place-items-center mb-4">
|
||||
<FileIcon className="w-8 h-8 text-accent" />
|
||||
</div>
|
||||
<h1 className="text-lg font-semibold leading-snug break-words">
|
||||
{meta.title || T.file}
|
||||
</h1>
|
||||
<div className="text-sm text-muted mt-1">
|
||||
{[meta.container?.toUpperCase(), formatBytes(meta.size_bytes)]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
</div>
|
||||
{meta.allow_download && (
|
||||
<a
|
||||
href={meta.file_url}
|
||||
className="mt-5 inline-flex items-center gap-1.5 rounded-lg bg-surface hover:bg-card px-4 py-2 text-sm font-medium transition"
|
||||
>
|
||||
<Download className="w-4 h-4" /> {T.download}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === "ready" && meta && meta.media_kind !== "file" && (
|
||||
<div>
|
||||
{meta.audio ? (
|
||||
/* Audio-only asset (mp3): WebKit won't play it in a <video>, so render a native
|
||||
<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="min-w-0 flex-1">
|
||||
<h1 className="text-lg font-semibold leading-snug">{meta.title || "Video"}</h1>
|
||||
|
||||
@@ -198,7 +198,7 @@ export function buildChannelColumns(deps: ChannelColumnDeps): Column<ManagedChan
|
||||
key: "priority",
|
||||
header: t("channels.cols.priority"),
|
||||
align: "center",
|
||||
width: "3.5rem",
|
||||
width: "5.5rem",
|
||||
sortable: true,
|
||||
hideInCard: true,
|
||||
sortValue: (c) => c.priority,
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useLiveQuery } from "../lib/useLiveQuery";
|
||||
import { api } from "../lib/api";
|
||||
import { formatBytes } from "../lib/format";
|
||||
import { inputCls } from "./ui/form";
|
||||
import { Button } from "./ui/Button";
|
||||
import { StatusPill, jobTitle } from "./downloadRow";
|
||||
|
||||
// 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")}
|
||||
</label>
|
||||
<div className="flex justify-between pt-1">
|
||||
<button
|
||||
onClick={() => reset.mutate()}
|
||||
className="px-3 py-1.5 rounded-lg text-sm text-muted hover:text-fg hover:bg-surface transition"
|
||||
>
|
||||
<Button variant="ghost" size="sm" onClick={() => reset.mutate()}>
|
||||
{t("downloads.admin.reset")}
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => save.mutate()}
|
||||
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")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Plus, Trash2 } from "lucide-react";
|
||||
import Modal from "./Modal";
|
||||
import { api, type DownloadJob } from "../lib/api";
|
||||
import { inputCls } from "./ui/form";
|
||||
import { Button } from "./ui/Button";
|
||||
import { IconBtn } from "./downloadRow";
|
||||
|
||||
// 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>
|
||||
<p className="text-xs text-muted">{t("downloads.edit.hint")}</p>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => save.mutate()}
|
||||
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")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// 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.
|
||||
const RETRYABLE_ERRORS = ["postprocess", "network", "unknown"];
|
||||
// Transient failures worth an in-modal Retry button; the rest are permanent (private/geo/DRM/
|
||||
// sign-in/unsupported-host/…), where a retry can't help — the queue row still keeps its own retry
|
||||
// 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,
|
||||
// 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">
|
||||
{retryable && (
|
||||
<button
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
onRetry();
|
||||
onClose();
|
||||
}}
|
||||
className="px-3 py-1.5 rounded-lg text-sm font-medium btn-primary transition"
|
||||
>
|
||||
{t("downloads.actions.retry")}
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-3 py-1.5 rounded-lg text-sm text-muted hover:text-fg hover:bg-surface transition"
|
||||
>
|
||||
<Button variant="ghost" size="sm" onClick={onClose}>
|
||||
{t("common.close")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</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 { useTranslation } from "react-i18next";
|
||||
import { CalendarClock, Copy, Link2, Scissors } from "lucide-react";
|
||||
import { CalendarClock, Copy, File, Link2, Scissors } from "lucide-react";
|
||||
import clsx from "clsx";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { useLiveQuery } from "../lib/useLiveQuery";
|
||||
@@ -43,12 +43,12 @@ export function StatusPill({ job }: { job: DownloadJob }) {
|
||||
// confused by the bar resetting between the separate video and audio streams.
|
||||
if (job.status === "running" && job.phase && PHASE_KEYS.includes(job.phase)) {
|
||||
return (
|
||||
<span className="text-xs font-medium text-accent">{t(`downloads.phase.${job.phase}`)}</span>
|
||||
<span className="text-sm font-medium text-accent">{t(`downloads.phase.${job.phase}`)}</span>
|
||||
);
|
||||
}
|
||||
const key = job.expired ? "expired" : job.status;
|
||||
return (
|
||||
<span className={clsx("text-xs font-medium", STATUS_CLS[job.status] ?? "text-muted")}>
|
||||
<span className={clsx("text-sm font-medium", STATUS_CLS[job.status] ?? "text-muted")}>
|
||||
{t(`downloads.status.${key}`)}
|
||||
</span>
|
||||
);
|
||||
@@ -69,6 +69,15 @@ function Thumb({ job }: { job: DownloadJob }) {
|
||||
// On load failure (a genuinely missing poster file) fall back to the remote source thumbnail.
|
||||
const [src, setSrc] = useState(primary);
|
||||
useEffect(() => setSrc(primary), [primary]);
|
||||
// A generic uploaded file (media_kind='file') has no video frame to poster — it's a document/
|
||||
// archive, not playable media. Show a neutral file glyph instead of an (always-empty) thumbnail.
|
||||
if (job.asset?.media_kind === "file") {
|
||||
return (
|
||||
<div className="w-28 aspect-video shrink-0 rounded-lg overflow-hidden bg-surface border border-border flex items-center justify-center text-muted">
|
||||
<File className="w-8 h-8" aria-hidden="true" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="w-28 aspect-video shrink-0 rounded-lg overflow-hidden bg-surface border border-border">
|
||||
{src ? (
|
||||
@@ -145,8 +154,8 @@ function SourceRef({ url }: { url: string }) {
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className="flex items-center gap-1 mt-1 text-xs text-muted min-w-0">
|
||||
<Link2 className="w-3 h-3 shrink-0" />
|
||||
<div className="flex items-center gap-1 mt-1 text-sm text-muted min-w-0">
|
||||
<Link2 className="w-3.5 h-3.5 shrink-0" />
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
@@ -162,7 +171,7 @@ function SourceRef({ url }: { url: string }) {
|
||||
title={t("downloads.source.copy")}
|
||||
className="shrink-0 p-0.5 rounded-sm hover:bg-surface hover:text-fg transition"
|
||||
>
|
||||
<Copy className="w-3 h-3" />
|
||||
<Copy className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
@@ -173,7 +182,7 @@ function ChannelLine({ job }: { job: DownloadJob }) {
|
||||
const { name, url } = jobChannel(job);
|
||||
if (url) {
|
||||
return (
|
||||
<div className="text-xs mt-0.5 truncate">
|
||||
<div className="text-sm mt-0.5 truncate">
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
@@ -186,7 +195,7 @@ function ChannelLine({ job }: { job: DownloadJob }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <div className="text-xs text-muted truncate mt-0.5">{name}</div>;
|
||||
return <div className="text-sm text-muted truncate mt-0.5">{name}</div>;
|
||||
}
|
||||
|
||||
export function DownloadRow({
|
||||
@@ -206,15 +215,20 @@ export function DownloadRow({
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-medium leading-snug line-clamp-1 flex items-center gap-1.5">
|
||||
{job.job_kind === "edit" && (
|
||||
<span className="inline-flex items-center gap-1 shrink-0 px-1.5 py-0.5 rounded-sm text-3xs font-semibold uppercase tracking-wide bg-accent/15 text-accent">
|
||||
<span className="inline-flex items-center gap-1 shrink-0 px-1.5 py-0.5 rounded-sm text-2xs font-semibold uppercase tracking-wide bg-accent/15 text-accent">
|
||||
<Scissors className="w-3 h-3" /> {t("downloads.clipBadge")}
|
||||
</span>
|
||||
)}
|
||||
<span className="truncate">{jobTitle(job)}</span>
|
||||
</div>
|
||||
<ChannelLine job={job} />
|
||||
<div className="flex items-center gap-2 mt-1 text-xs">
|
||||
<div className="flex items-center gap-2 mt-1 text-sm">
|
||||
<StatusPill job={job} />
|
||||
{job.asset?.container ? (
|
||||
// Concrete container (mp4 / m4a / webm …) — reads as the file format, and its audio-vs-video
|
||||
// extension is the video/audio distinction the running-only phase pill can't show at rest.
|
||||
<span className="text-muted uppercase">· {job.asset.container}</span>
|
||||
) : null}
|
||||
{job.asset?.size_bytes ? (
|
||||
<span className="text-muted">· {formatBytes(job.asset.size_bytes)}</span>
|
||||
) : null}
|
||||
@@ -226,7 +240,7 @@ export function DownloadRow({
|
||||
className="text-muted inline-flex items-center gap-1"
|
||||
title={new Date(job.created_at).toLocaleString()}
|
||||
>
|
||||
· <CalendarClock className="w-3 h-3" /> {relativeTime(job.created_at)}
|
||||
· <CalendarClock className="w-3.5 h-3.5" /> {relativeTime(job.created_at)}
|
||||
</span>
|
||||
) : null}
|
||||
{job.status === "error" ? (
|
||||
@@ -259,7 +273,7 @@ export function DownloadRow({
|
||||
style={{ width: `${job.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-2xs text-muted mt-0.5 flex gap-2">
|
||||
<div className="text-xs text-muted mt-0.5 flex gap-2">
|
||||
<span>{job.progress}%</span>
|
||||
{job.speed_bps ? <span>{formatSpeed(job.speed_bps)}</span> : null}
|
||||
{job.eta_s ? <span>{formatEta(job.eta_s)}</span> : null}
|
||||
@@ -269,7 +283,12 @@ export function DownloadRow({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-start gap-0.5 shrink-0">{children}</div>
|
||||
{/* Row action buttons: grown a step here (bigger tap target + icon) without touching the shared
|
||||
IconBtn — scoped so the same primitive stays smaller in the modals. `shrink-0` + no wrap keeps
|
||||
them on the title's line; the text column shrinks instead. */}
|
||||
<div className="flex items-start gap-1 shrink-0 [&_button]:p-2 [&_svg]:size-[18px]">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -278,8 +297,12 @@ export function UsageBar() {
|
||||
const { t } = useTranslation();
|
||||
// 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
|
||||
// a one-shot fetch at enqueue time would show a stale 0 B until a manual reload.
|
||||
const usage = useLiveQuery(qk.downloadUsage(), api.downloadUsage, { intervalMs: 2000 });
|
||||
// a one-shot fetch at enqueue time would show a stale 0 B until a manual reload. The footprint
|
||||
// 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;
|
||||
if (!u) return null;
|
||||
const pct =
|
||||
|
||||
@@ -1,6 +1,49 @@
|
||||
import { type ReactNode } from "react";
|
||||
import { RotateCcw } from "lucide-react";
|
||||
|
||||
// ±10s skip glyph: an outline circular arrow with a "10" in the centre — the YouTube/Plex-native
|
||||
// muscle-memory shape. Deliberately distinct from the episode-level SkipBack/SkipForward (double
|
||||
// triangles) and from Restart's plain RotateCcw. The arrowhead is an L-corner at the arc's open end,
|
||||
// the same construction lucide uses for RotateCcw/RotateCw, so it sits consistently in the bar.
|
||||
export function Skip10({ dir }: { dir: "back" | "forward" }) {
|
||||
const back = dir === "back";
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="w-6 h-6"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{back ? (
|
||||
<>
|
||||
<path d="M4 8.5 A8 8 0 1 1 4 12" />
|
||||
<path d="M4 8.5 L4 4 M4 8.5 L8.5 8.5" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<path d="M20 8.5 A8 8 0 1 0 20 12" />
|
||||
<path d="M20 8.5 L20 4 M20 8.5 L15.5 8.5" />
|
||||
</>
|
||||
)}
|
||||
<text
|
||||
x="12"
|
||||
y="14.5"
|
||||
textAnchor="middle"
|
||||
fontSize="8"
|
||||
fontWeight={700}
|
||||
fill="currentColor"
|
||||
stroke="none"
|
||||
>
|
||||
10
|
||||
</text>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// --- settings-panel building blocks ------------------------------------------------------------
|
||||
export function PanelHead({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
|
||||
@@ -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 { Check, RotateCcw, Save } from "lucide-react";
|
||||
import { Button } from "./Button";
|
||||
|
||||
// The Save / Discard bar shown while an edited draft has unsaved changes (Settings prefs,
|
||||
// admin Configuration). Two visual variants share one state machine + markup:
|
||||
@@ -58,24 +59,27 @@ export function DraftSaveBar({
|
||||
)}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
<Button
|
||||
data-testid="draft-discard"
|
||||
variant="glass"
|
||||
size="sm"
|
||||
icon={<RotateCcw className="w-4 h-4" />}
|
||||
onClick={onDiscard}
|
||||
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}
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button
|
||||
data-testid="draft-save"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
icon={<Save className="w-4 h-4" />}
|
||||
onClick={onSave}
|
||||
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}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -17,5 +17,7 @@
|
||||
"sync_pause": "Sync paused",
|
||||
"sync_resume": "Sync resumed",
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -22,6 +22,10 @@
|
||||
"block": "Block channel",
|
||||
"unblock": "Unblock channel",
|
||||
"blockedBadge": "Blocked",
|
||||
"hide": "Hide from feed",
|
||||
"unhide": "Unhide",
|
||||
"hideHint": "Hide this channel's videos from your feed. It stays subscribed; this doesn't unsubscribe on YouTube.",
|
||||
"hiddenHint": "Hidden — this channel's videos are kept out of your feed. Click to show them again.",
|
||||
"country": "Country",
|
||||
"language": "Language",
|
||||
"topics": "Topics",
|
||||
|
||||
@@ -47,6 +47,7 @@
|
||||
},
|
||||
"tags": {
|
||||
"label": "Tags",
|
||||
"add": "Tag",
|
||||
"yourTagsHint": "Your personal labels. Attach them to channels below, then filter the feed by tag from the sidebar. (Separate from the automatic language/topic tags.)",
|
||||
"noneYet": "No tags yet — create one below.",
|
||||
"create": "Create tag",
|
||||
|
||||
@@ -166,7 +166,23 @@
|
||||
},
|
||||
"plex_max_transcodes": {
|
||||
"label": "Max concurrent transcodes",
|
||||
"hint": "Cap on simultaneous CPU transcodes for incompatible files (a later fallback). Direct-playable files aren't limited."
|
||||
"hint": "Cap on simultaneous transcode sessions for non-H.264 files. Direct-play and remux (stream-copy) files aren't limited."
|
||||
},
|
||||
"plex_transcode_hw": {
|
||||
"label": "Hardware transcode",
|
||||
"hint": "GPU (VAAPI) encoding policy: auto (GPU when available and the source is decodable, else software), on (force GPU), off (always software)."
|
||||
},
|
||||
"plex_transcode_preset": {
|
||||
"label": "Software preset",
|
||||
"hint": "libx264 preset for the software fallback (e.g. veryfast, faster, medium). Slower presets shrink the stream but cost more CPU. Ignored on the GPU path."
|
||||
},
|
||||
"plex_transcode_crf": {
|
||||
"label": "Software quality (CRF)",
|
||||
"hint": "libx264 CRF for the software fallback (0–51; lower = better quality, larger). 20 is a good default. Ignored on the GPU path."
|
||||
},
|
||||
"plex_transcode_max_height": {
|
||||
"label": "Software max height",
|
||||
"hint": "Cap the software transcode's output height in pixels (e.g. 1080; 0 = keep source) so a tall source stays affordable to re-encode. The GPU path keeps source resolution."
|
||||
}
|
||||
},
|
||||
"save": "Save",
|
||||
|
||||
@@ -21,8 +21,21 @@
|
||||
"addUrl": "Paste a YouTube link or video id…",
|
||||
"add": "Add",
|
||||
"paste": "Paste link from clipboard",
|
||||
"upload": "Upload",
|
||||
"manage": "Manage formats"
|
||||
},
|
||||
"upload": {
|
||||
"title": "Upload a file",
|
||||
"hint": "Any file type. Video and audio can be played and edited; other files are download-only.",
|
||||
"drop": "Drag a file here, or click to choose",
|
||||
"dropActive": "Drop to upload",
|
||||
"nameLabel": "Name (optional)",
|
||||
"namePlaceholder": "Leave blank to use the file name",
|
||||
"submit": "Upload",
|
||||
"uploading": "Uploading… {{pct}}%",
|
||||
"uploadProgress": "Upload progress",
|
||||
"added": "File uploaded"
|
||||
},
|
||||
"tabs": {
|
||||
"queue": "Queue",
|
||||
"done": "Library",
|
||||
@@ -56,13 +69,18 @@
|
||||
"delete": "Remove",
|
||||
"retry": "Retry",
|
||||
"download": "Save to my device",
|
||||
"play": "Play",
|
||||
"rename": "Rename",
|
||||
"editDetails": "Edit details",
|
||||
"share": "Share",
|
||||
"share": "Share (copy link)",
|
||||
"shareManage": "Manage sharing",
|
||||
"edit": "Edit / trim",
|
||||
"redownload": "Re-download (rebuild file)",
|
||||
"removeShared": "Remove from my list"
|
||||
},
|
||||
"player": {
|
||||
"unsupported": "Your browser can't play this file."
|
||||
},
|
||||
"empty": {
|
||||
"queue": "Nothing in the queue.",
|
||||
"done": "No downloads yet.",
|
||||
@@ -104,6 +122,9 @@
|
||||
"noUsers": "No other users yet.",
|
||||
"linkSection": "Share a link",
|
||||
"linkHint": "Anyone with the link can watch — no account needed.",
|
||||
"linkCopied": "Share link copied to clipboard",
|
||||
"openNewTab": "Open in new tab",
|
||||
"linkFailed": "Couldn’t create the share link",
|
||||
"noLinks": "No links yet.",
|
||||
"newLink": "Create a link",
|
||||
"createLink": "Create link",
|
||||
@@ -151,10 +172,7 @@
|
||||
"modeA": "Audio only",
|
||||
"quality": "Max quality",
|
||||
"best": "Best",
|
||||
"container": "Container",
|
||||
"audioFormat": "Audio format",
|
||||
"vcodec": "Video codec",
|
||||
"any": "Any",
|
||||
"embedSubs": "Embed subtitles",
|
||||
"embedChapters": "Embed chapters",
|
||||
"embedThumbnail": "Embed thumbnail",
|
||||
@@ -207,6 +225,10 @@
|
||||
"source_not_ready": "The source download isn't ready to edit.",
|
||||
"generic": "That edit couldn't be created."
|
||||
},
|
||||
"upload": {
|
||||
"tooLarge": "That file is too large (max {{size}}).",
|
||||
"empty": "That file is empty."
|
||||
},
|
||||
"fail": {
|
||||
"title": "Download failed",
|
||||
"detailsLink": "details",
|
||||
@@ -218,7 +240,11 @@
|
||||
},
|
||||
"login_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": {
|
||||
"short": "Region-blocked",
|
||||
@@ -249,6 +275,11 @@
|
||||
"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",
|
||||
"pushConfirm": "Sync",
|
||||
"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.)",
|
||||
"pushDiverged": "{{count}} video(s) currently on YouTube will be removed.",
|
||||
"mergeAddLocal_one": "{{count}} video added to Siftlode from YouTube.",
|
||||
"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.",
|
||||
"pushUpToDate": "“{{name}}” is already in sync with YouTube.",
|
||||
"pushDone": "“{{name}}” synced to YouTube ✓",
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
},
|
||||
"sort": {
|
||||
"added": "Recently added",
|
||||
"lastwatched": "Last watched",
|
||||
"title": "Title",
|
||||
"year": "Year",
|
||||
"rating": "IMDb rating",
|
||||
@@ -153,11 +154,16 @@
|
||||
},
|
||||
"player": {
|
||||
"loading": "Loading…",
|
||||
"buffering": "Buffering…",
|
||||
"preparing": "Preparing stream…",
|
||||
"back": "Back",
|
||||
"close": "Close",
|
||||
"skipIntro": "Skip intro",
|
||||
"skipCredits": "Skip credits",
|
||||
"playPause": "Play / pause (space)",
|
||||
"restart": "Restart from beginning",
|
||||
"rewind10": "Back 10 seconds",
|
||||
"forward10": "Forward 10 seconds",
|
||||
"prev": "Previous episode",
|
||||
"next": "Next episode",
|
||||
"mute": "Mute (m)",
|
||||
@@ -185,7 +191,27 @@
|
||||
"help": "Show this help",
|
||||
"audio": "Cycle audio track",
|
||||
"subtitle": "Cycle subtitle",
|
||||
"fineSeek": "Fine seek"
|
||||
"fineSeek": "Fine seek",
|
||||
"stats": "Stats for nerds"
|
||||
},
|
||||
"upNext": {
|
||||
"title": "Up next",
|
||||
"playNow": "Play now",
|
||||
"cancel": "Cancel",
|
||||
"pref": "Up-next card"
|
||||
},
|
||||
"stats": {
|
||||
"title": "Stats for nerds",
|
||||
"mode": "Mode",
|
||||
"transport": "Transport",
|
||||
"resolution": "Resolution",
|
||||
"codec": "Codec",
|
||||
"bitrate": "Bitrate",
|
||||
"level": "HLS level",
|
||||
"frames": "Dropped frames",
|
||||
"buffer": "Buffer ahead",
|
||||
"offset": "Session offset",
|
||||
"position": "Position"
|
||||
},
|
||||
"settings": "Settings",
|
||||
"sync": "Offsets",
|
||||
|
||||
@@ -104,6 +104,27 @@
|
||||
"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": {
|
||||
"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.",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user