Compare commits
24
Commits
v0.73.0
...
9b87838e3a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b87838e3a | ||
|
|
f014416e31 | ||
|
|
3369587412 | ||
|
|
9af4e8f4ff | ||
|
|
a0b725d779 | ||
|
|
bc33b2ed16 | ||
|
|
5280949667 | ||
|
|
4892c38466 | ||
|
|
9ae6c47d6c | ||
|
|
b7a6451dba | ||
|
|
11e32163b8 | ||
|
|
257dc5741d | ||
|
|
f52bc5898b | ||
|
|
d0033617e2 | ||
|
|
f80ed115ad | ||
|
|
7fbb996297 | ||
|
|
9cd4ae09d4 | ||
|
|
00527d0cf3 | ||
|
|
8dc4976c2c | ||
|
|
56a2ecdec3 | ||
|
|
8299ea72b7 | ||
|
|
6c918d1dff | ||
|
|
3f8257059d | ||
|
|
12d9561ba1 |
@@ -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")
|
||||
@@ -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"
|
||||
|
||||
|
||||
+18
-1
@@ -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
|
||||
@@ -232,7 +247,9 @@ 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.
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -510,11 +523,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 +548,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"),
|
||||
|
||||
@@ -98,12 +98,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 +137,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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -83,6 +83,10 @@ def public_meta(
|
||||
from app.downloads import service
|
||||
|
||||
source_url = service.reference_url(job, asset)
|
||||
# Audio-only assets (mp3 rips, extracted audio) carry a codec but no video stream. WebKit (all
|
||||
# iOS browsers) refuses to play them inside a <video> element, so the watch page must render a
|
||||
# native <audio> instead — flag it here from the recorded codecs (a real video always has vcodec).
|
||||
is_audio = asset.vcodec is None and asset.acodec is not None
|
||||
return {
|
||||
"title": (job and job.display_name) or asset.title,
|
||||
"uploader": (job and job.display_uploader) or asset.uploader,
|
||||
@@ -92,6 +96,7 @@ def public_meta(
|
||||
"duration_s": asset.duration_s,
|
||||
"width": asset.width,
|
||||
"height": asset.height,
|
||||
"audio": is_audio,
|
||||
"allow_download": link.allow_download,
|
||||
"file_url": file_url,
|
||||
"poster_url": poster_url,
|
||||
|
||||
@@ -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, 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)
|
||||
@@ -515,6 +545,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)
|
||||
|
||||
|
||||
|
||||
@@ -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,6 +15,7 @@ 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.status import HOLDING, JobStatus
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
@@ -867,6 +871,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))
|
||||
|
||||
@@ -38,6 +38,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
|
||||
@@ -296,16 +307,27 @@ def start_session(
|
||||
# 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",
|
||||
key,
|
||||
|
||||
+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,
|
||||
)
|
||||
|
||||
|
||||
@@ -16,10 +16,11 @@ 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
|
||||
@@ -247,6 +248,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]:
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
+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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,44 @@ 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",
|
||||
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 (codec present, no video stream) must report audio=True."""
|
||||
|
||||
_link = SimpleNamespace(allow_download=True)
|
||||
|
||||
def test_audio_only_asset_is_flagged_audio(self):
|
||||
asset = _asset(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_silent_video_is_not_audio(self):
|
||||
# Video present but no audio track — still a video player, not the audio bar.
|
||||
meta = public_meta(self._link, None, _asset(acodec=None), "/f.mp4")
|
||||
assert meta["audio"] 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"}
|
||||
@@ -154,3 +154,74 @@ 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"):
|
||||
self.rating_key = rating_key
|
||||
self.file_path = file_path
|
||||
self.playable = "remux"
|
||||
|
||||
|
||||
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"]
|
||||
|
||||
@@ -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
|
||||
@@ -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>
|
||||
|
||||
@@ -31,6 +31,7 @@ import { useMe } from "../lib/useMe";
|
||||
import { api, type DownloadJob } from "../lib/api";
|
||||
import { invalidateDownloads } from "../lib/downloads";
|
||||
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";
|
||||
@@ -51,7 +52,13 @@ export default function DownloadCenter() {
|
||||
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,
|
||||
@@ -163,20 +170,24 @@ 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>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{queue.map((job) => (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
Info,
|
||||
Keyboard,
|
||||
Languages,
|
||||
Loader2,
|
||||
Maximize,
|
||||
Minimize,
|
||||
Pause,
|
||||
@@ -108,6 +109,11 @@ 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);
|
||||
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
|
||||
@@ -178,6 +184,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
setReady(false);
|
||||
setBuffering(false);
|
||||
setLoadError(null);
|
||||
let sess;
|
||||
try {
|
||||
@@ -362,12 +369,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 +394,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]);
|
||||
|
||||
@@ -730,12 +756,29 @@ 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("plex.player.loading")}</span>
|
||||
) : buffering ? (
|
||||
<span className="sr-only">{t("plex.player.buffering")}</span>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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,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>
|
||||
@@ -263,19 +261,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) -----------------------------------------------------
|
||||
@@ -343,31 +344,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. */}
|
||||
|
||||
@@ -2,22 +2,13 @@ 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 { inputCls, btnCls } from "./ui/form";
|
||||
import { Button } from "./ui/Button";
|
||||
|
||||
// mm:ss.d timecode <-> seconds.
|
||||
function tc(s: number): string {
|
||||
@@ -621,28 +612,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,39 @@
|
||||
// @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();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Download, Link2, Lock, PlayCircle } from "lucide-react";
|
||||
import { Button } from "./ui/Button";
|
||||
|
||||
// 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 +12,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 +47,7 @@ type Meta = {
|
||||
source_url?: string | null;
|
||||
extra_links?: string[];
|
||||
duration_s?: number | null;
|
||||
audio?: boolean;
|
||||
allow_download?: boolean;
|
||||
file_url?: string;
|
||||
poster_url?: string | null;
|
||||
@@ -112,8 +115,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 +201,43 @@ 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"
|
||||
/>
|
||||
</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>
|
||||
|
||||
@@ -278,8 +278,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 =
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -218,7 +218,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 +253,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,6 +154,7 @@
|
||||
},
|
||||
"player": {
|
||||
"loading": "Loading…",
|
||||
"buffering": "Buffering…",
|
||||
"back": "Back",
|
||||
"skipIntro": "Skip intro",
|
||||
"skipCredits": "Skip credits",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -19,6 +19,9 @@
|
||||
"capturedFilters": "Captured filters",
|
||||
"capturedNone": "No filters — matches every video.",
|
||||
"default": "Default",
|
||||
"saveCurrent": "Save current",
|
||||
"saveCurrentHint": "Overwrite this view with the filters currently applied",
|
||||
"saveCurrentUpToDate": "This view already matches the current filters",
|
||||
"selectPrompt": "Select a view to see and edit it.",
|
||||
"modified": "Modified — the current filters differ from this saved view."
|
||||
}
|
||||
|
||||
@@ -17,5 +17,7 @@
|
||||
"sync_pause": "Szinkron szüneteltetve",
|
||||
"sync_resume": "Szinkron folytatva",
|
||||
"job_run": "Ütemező job lefutott",
|
||||
"audit_clear": "Napló ürítve"
|
||||
"audit_clear": "Napló ürítve",
|
||||
"download_credential_set": "Letöltési cookie-k beállítva",
|
||||
"download_credential_delete": "Letöltési cookie-k eltávolítva"
|
||||
}
|
||||
|
||||
@@ -22,6 +22,10 @@
|
||||
"block": "Csatorna tiltása",
|
||||
"unblock": "Tiltás feloldása",
|
||||
"blockedBadge": "Tiltva",
|
||||
"hide": "Elrejtés a hírfolyamból",
|
||||
"unhide": "Megjelenítés",
|
||||
"hideHint": "A csatorna videóinak elrejtése a hírfolyamodból. Feliratkozva marad; ez nem iratkoztat le a YouTube-on.",
|
||||
"hiddenHint": "Elrejtve — a csatorna videói nem jelennek meg a hírfolyamodban. Kattints, hogy újra láthatóak legyenek.",
|
||||
"country": "Ország",
|
||||
"language": "Nyelv",
|
||||
"topics": "Témák",
|
||||
|
||||
@@ -47,6 +47,7 @@
|
||||
},
|
||||
"tags": {
|
||||
"label": "Címkék",
|
||||
"add": "Címke",
|
||||
"yourTagsHint": "Saját személyes címkéid. Csatold őket az alábbi csatornákhoz, majd szűrd a hírfolyamot címke szerint az oldalsávból. (Az automatikus nyelvi/téma címkéktől függetlenül.)",
|
||||
"noneYet": "Még nincs címke — hozz létre egyet lent.",
|
||||
"create": "Címke létrehozása",
|
||||
|
||||
@@ -218,7 +218,11 @@
|
||||
},
|
||||
"login_required": {
|
||||
"short": "Bejelentkezés szükséges",
|
||||
"body": "Ehhez a videóhoz bejelentkezett, kor-ellenőrzött vagy tagsági fiók kell, ezért itt nem tölthető le."
|
||||
"body": "Ehhez a videóhoz bejelentkezett fiók kell (korhatáros, csak tagoknak szóló vagy zárt csoport). Töltsd fel a cookie-kat ehhez az oldalhoz a Beállítások → Fiók → Letöltés-hitelesítés alatt a letöltéshez."
|
||||
},
|
||||
"unsupported_host": {
|
||||
"short": "Nem támogatott oldal",
|
||||
"body": "Ez az oldal nem támogatott a letöltéshez, ezért erről a linkről semmit sem sikerült lekérni."
|
||||
},
|
||||
"geo_blocked": {
|
||||
"short": "Régió szerint tiltva",
|
||||
@@ -249,6 +253,11 @@
|
||||
"body": "Valami hiba történt az elem letöltése közben. Az alábbi technikai részletek segíthetnek."
|
||||
}
|
||||
}
|
||||
},
|
||||
"cookies": {
|
||||
"empty": "Előbb illeszd be a cookie-kat.",
|
||||
"format": "Ez nem tűnik cookies.txt fájlnak (Netscape formátum).",
|
||||
"generic": "A cookie-kat nem sikerült menteni."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,8 +35,21 @@
|
||||
"pushTitle": "Szinkron YouTube-ra",
|
||||
"pushConfirm": "Szinkron",
|
||||
"pushPlanCreate": "Létrehozol egy új YouTube-listát {{count}} videóval? (~{{units}} kvótaegység; ma még {{left}} maradt.)",
|
||||
"pushPlanUpdate": "Frissítés a YouTube-on — {{insert}} hozzáadása, {{del}} eltávolítása, {{reorder}} átrendezése? (~{{units}} kvótaegység; ma még {{left}} maradt.)",
|
||||
"pushDiverged": "{{count}} videó, amely jelenleg a YouTube-on van, el lesz távolítva.",
|
||||
"mergeAddLocal_one": "{{count}} videó bekerül a Siftlode-listádba a YouTube-ról.",
|
||||
"mergeAddLocal_other": "{{count}} videó bekerül a Siftlode-listádba a YouTube-ról.",
|
||||
"mergeRemoveLocal_one": "{{count}} videó törlődik a Siftlode-listádból.",
|
||||
"mergeRemoveLocal_other": "{{count}} videó törlődik a Siftlode-listádból.",
|
||||
"mergeAddYt_one": "{{count}} videó felkerül a YouTube-ra.",
|
||||
"mergeAddYt_other": "{{count}} videó felkerül a YouTube-ra.",
|
||||
"mergeRemoveYt_one": "{{count}} videó törlődik a YouTube-ról.",
|
||||
"mergeRemoveYt_other": "{{count}} videó törlődik a YouTube-ról.",
|
||||
"mergeReorder_one": "{{count}} videó átrendeződik a YouTube-on.",
|
||||
"mergeReorder_other": "{{count}} videó átrendeződik a YouTube-on.",
|
||||
"mergeRename": "A lista átnevezésre kerül a YouTube-on.",
|
||||
"mergeYtUnchanged": "A YouTube-listád nem változik.",
|
||||
"mergeLocalUnchanged": "A Siftlode-listád nem változik.",
|
||||
"mergeQuota": " (~{{units}} kvótaegység; ma még {{left}} maradt.)",
|
||||
"mergeQuotaFree": " (~0 kvótaegység.)",
|
||||
"pushNoQuota": "Nincs elég YouTube-kvóta mára ({{left}}) ehhez a szinkronhoz (~{{units}} kell). Próbáld újra holnap.",
|
||||
"pushUpToDate": "A(z) „{{name}}” már szinkronban van a YouTube-bal.",
|
||||
"pushDone": "„{{name}}” szinkronizálva a YouTube-ra ✓",
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
},
|
||||
"sort": {
|
||||
"added": "Nemrég hozzáadott",
|
||||
"lastwatched": "Utoljára nézve",
|
||||
"title": "Cím",
|
||||
"year": "Év",
|
||||
"rating": "IMDb pont",
|
||||
@@ -153,6 +154,7 @@
|
||||
},
|
||||
"player": {
|
||||
"loading": "Betöltés…",
|
||||
"buffering": "Pufferelés…",
|
||||
"back": "Vissza",
|
||||
"skipIntro": "Intro átugrása",
|
||||
"skipCredits": "Stáblista átugrása",
|
||||
|
||||
@@ -104,6 +104,27 @@
|
||||
"failed": "Nem sikerült kijelentkeztetni a többi munkamenetet. Próbáld újra."
|
||||
}
|
||||
},
|
||||
"downloadAuth": {
|
||||
"title": "Letöltés-hitelesítés",
|
||||
"intro": "Egyes oldalak (pl. egy zárt Facebook-csoport, aminek tagja vagy) csak bejelentkezett tagoknak mutatják a videót. Illeszd be ide a böngésződ cookie-jait egy oldalhoz, és az onnan induló letöltések a te bejelentkezésedet használják. A cookie-k titkosítva, csak a te fiókodhoz kötve tárolódnak, és kizárólag az adott oldalnak küldjük el őket.",
|
||||
"configured": "Beállítva · frissítve: {{when}}",
|
||||
"notSet": "Nincs beállítva",
|
||||
"set": "Cookie-k beállítása",
|
||||
"update": "Frissítés",
|
||||
"remove": "Eltávolítás",
|
||||
"save": "Mentés",
|
||||
"cancel": "Mégse",
|
||||
"saving": "Mentés…",
|
||||
"placeholder": "Illeszd be ide a cookies.txt tartalmát (Netscape formátum)",
|
||||
"help": "Exportálj cookies.txt-t ehhez az oldalhoz egy böngészőbővítménnyel, majd illeszd be ide. Csak írható — sosem mutatjuk vissza.",
|
||||
"saved": "Cookie-k mentve.",
|
||||
"removed": "Cookie-k eltávolítva.",
|
||||
"failed": "A cookie-kat nem sikerült menteni.",
|
||||
"removeConfirmTitle": "Cookie-k eltávolítása?",
|
||||
"removeConfirmBody": "Az erről az oldalról induló letöltések nem használják tovább a bejelentkezésedet.",
|
||||
"removeConfirmAction": "Eltávolítás",
|
||||
"loadError": "Nem sikerült betölteni a letöltési hitelesítési beállításokat."
|
||||
},
|
||||
"demo": {
|
||||
"title": "Demo hozzáférés",
|
||||
"intro": "Az itt szereplő e-mailek a bejelentkező oldalról közvetlenül beléphetnek a közös demo fiókba — Google-bejelentkezés nélkül. Csak beírják a címet a mezőbe, és pár pillanat múlva bekerülnek.",
|
||||
|
||||
@@ -19,6 +19,9 @@
|
||||
"capturedFilters": "Rögzített szűrők",
|
||||
"capturedNone": "Nincs szűrő — minden videóra illeszkedik.",
|
||||
"default": "Alapértelmezett",
|
||||
"saveCurrent": "Aktuális mentése",
|
||||
"saveCurrentHint": "Felülírja ezt a nézetet a jelenleg alkalmazott szűrőkkel",
|
||||
"saveCurrentUpToDate": "Ez a nézet már megegyezik a jelenlegi szűrőkkel",
|
||||
"selectPrompt": "Válassz egy nézetet a megtekintéshez és szerkesztéshez.",
|
||||
"modified": "Módosítva — a jelenlegi szűrők eltérnek a mentett nézettől."
|
||||
}
|
||||
|
||||
@@ -62,6 +62,8 @@ export {
|
||||
plexFilterCount,
|
||||
coercePlexFilters,
|
||||
plexFiltersSignature,
|
||||
plexFiltersToParams,
|
||||
parsePlexFilters,
|
||||
isIntrinsicReadOnly,
|
||||
} from "./api/plex";
|
||||
import { messagingApi } from "./api/messaging";
|
||||
|
||||
@@ -32,6 +32,7 @@ export interface ChannelDetail {
|
||||
count_live: number;
|
||||
deep_requested: boolean;
|
||||
priority: number;
|
||||
hidden: boolean;
|
||||
backfill_done: boolean;
|
||||
from_explore: boolean;
|
||||
details_synced: boolean;
|
||||
|
||||
@@ -43,6 +43,8 @@ function localizeDetail(d: unknown): string {
|
||||
}
|
||||
if (o?.code === "edit")
|
||||
return t(`downloads.errors.edit.${o.reason}`, t("downloads.errors.edit.generic"));
|
||||
if (o?.code === "cookies")
|
||||
return t(`downloads.errors.cookies.${o.reason}`, t("downloads.errors.cookies.generic"));
|
||||
return t("errors.generic");
|
||||
}
|
||||
|
||||
|
||||
@@ -137,7 +137,31 @@ export interface AdminDownloadQuota {
|
||||
unlimited: boolean;
|
||||
}
|
||||
|
||||
// Per-user download authentication (cookies). Write-only: the API never returns the stored blob,
|
||||
// only which supported hosts are configured + when. See backend downloads.credentials.
|
||||
export interface DownloadCredentialHost {
|
||||
host_key: string;
|
||||
label: string;
|
||||
configured: boolean;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
export const downloadsApi = {
|
||||
downloadCredentials: (): Promise<{ hosts: DownloadCredentialHost[] }> =>
|
||||
req("/api/downloads/credentials"),
|
||||
setDownloadCredential: (
|
||||
hostKey: string,
|
||||
cookies: string,
|
||||
): Promise<{ ok: true; hosts: DownloadCredentialHost[] }> =>
|
||||
req(`/api/downloads/credentials/${encodeURIComponent(hostKey)}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ cookies }),
|
||||
}),
|
||||
deleteDownloadCredential: (
|
||||
hostKey: string,
|
||||
): Promise<{ ok: true; hosts: DownloadCredentialHost[] }> =>
|
||||
req(`/api/downloads/credentials/${encodeURIComponent(hostKey)}`, { method: "DELETE" }),
|
||||
|
||||
downloadProfiles: (): Promise<DownloadProfile[]> => req("/api/downloads/profiles"),
|
||||
createDownloadProfile: (p: { name: string; spec: DownloadSpec }): Promise<DownloadProfile> =>
|
||||
req("/api/downloads/profiles", { method: "POST", body: JSON.stringify(p) }),
|
||||
|
||||
@@ -31,12 +31,16 @@ export interface PlaylistDetail {
|
||||
items: Video[];
|
||||
}
|
||||
|
||||
// Two-way sync plan: what each side gains/loses under the 3-way merge (neither side wins
|
||||
// wholesale). Local edits are free; only the YouTube writes cost quota.
|
||||
export interface PushPlan {
|
||||
action: "create" | "update";
|
||||
to_insert: number;
|
||||
to_delete: number;
|
||||
add_yt: number; // local-adds to push out to YouTube
|
||||
add_local: number; // YouTube-adds to pull into Siftlode
|
||||
remove_yt: number; // genuine local deletes propagated to YouTube
|
||||
remove_local: number; // genuine YouTube deletes propagated to Siftlode
|
||||
to_reorder: number;
|
||||
yt_extra: number; // items on YouTube that the push would remove (divergence)
|
||||
rename: boolean;
|
||||
units_estimate: number;
|
||||
remaining_today: number;
|
||||
affordable: boolean;
|
||||
@@ -44,9 +48,11 @@ export interface PushPlan {
|
||||
|
||||
export interface PushResult {
|
||||
created: number;
|
||||
inserted: number;
|
||||
deleted: number;
|
||||
inserted: number; // added on YouTube
|
||||
deleted: number; // removed on YouTube
|
||||
reordered: number;
|
||||
pulled: number; // added into Siftlode from YouTube
|
||||
removed_local: number; // removed from Siftlode (deleted on YouTube)
|
||||
failures: string[];
|
||||
yt_playlist_id: string | null;
|
||||
}
|
||||
|
||||
@@ -334,6 +334,48 @@ function appendPlexFilters(u: URLSearchParams, f: PlexFilters): void {
|
||||
if (f.collections?.length) u.set("collections", f.collections.join(","));
|
||||
if (f.collectionMode === "all") u.set("collection_mode", "all");
|
||||
}
|
||||
|
||||
// Public wrapper over the private appendPlexFilters so the URL mirror (plexUrlState) serializes the
|
||||
// filter set through the SAME single source the API request uses — the two can never drift. Display-only
|
||||
// titles (collectionTitles) stay out, exactly like the wire params and the saved-view signature.
|
||||
export function plexFiltersToParams(f: PlexFilters): URLSearchParams {
|
||||
const u = new URLSearchParams();
|
||||
appendPlexFilters(u, f);
|
||||
return u;
|
||||
}
|
||||
|
||||
// The inverse of appendPlexFilters: parse a filter sub-querystring back into a full PlexFilters (missing
|
||||
// keys → EMPTY defaults). Kept next to its encoder so a new wire key can't be added on one side only.
|
||||
export function parsePlexFilters(p: URLSearchParams): PlexFilters {
|
||||
const csv = (k: string): string[] => {
|
||||
const v = p.get(k);
|
||||
return v ? v.split(",").filter(Boolean) : [];
|
||||
};
|
||||
const num = (k: string): number | undefined => {
|
||||
if (!p.has(k)) return undefined;
|
||||
const n = Number(p.get(k));
|
||||
return Number.isFinite(n) ? n : undefined;
|
||||
};
|
||||
const f: PlexFilters = { ...EMPTY_PLEX_FILTERS };
|
||||
f.genres = csv("genres");
|
||||
if (p.get("genre_mode") === "all") f.genreMode = "all";
|
||||
f.contentRatings = csv("content_ratings");
|
||||
f.yearMin = num("year_min");
|
||||
f.yearMax = num("year_max");
|
||||
f.ratingMin = num("rating_min");
|
||||
f.durationMin = num("duration_min");
|
||||
f.durationMax = num("duration_max");
|
||||
const aw = p.get("added_within");
|
||||
if (aw) f.addedWithin = aw;
|
||||
f.directors = csv("directors");
|
||||
f.actors = csv("actors");
|
||||
if (p.get("actor_mode") === "all") f.actorMode = "all";
|
||||
f.studios = csv("studios");
|
||||
f.keywords = csv("keywords");
|
||||
f.collections = csv("collections");
|
||||
if (p.get("collection_mode") === "all") f.collectionMode = "all";
|
||||
return f;
|
||||
}
|
||||
export interface PlexFacets {
|
||||
genres: { value: string; count: number }[];
|
||||
content_ratings: { value: string; count: number }[];
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// S8 — the Channels-manager content state (active tab + column sort + the column-filter map) round-trips
|
||||
// through namespaced URL keys (ctab/csort/cf) so a manager view is shareable, the S4/plexUrlState
|
||||
// analogue. `cf` carries the mixed scalar/array FilterMap as a JSON blob.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
channelsParamsToState,
|
||||
channelsParamUpdates,
|
||||
hasChannelsParams,
|
||||
type ChannelsUrlState,
|
||||
} from "./channelsUrlState";
|
||||
import type { FilterMap } from "../components/tableHeader";
|
||||
|
||||
// A concrete non-default filter map exercising both wire shapes: scalars (the sync chip, a select) and
|
||||
// an array (a multi column).
|
||||
const RICH_FILTERS: FilterMap = {
|
||||
sync: "needsFull",
|
||||
priority: "3",
|
||||
tags: ["12", "34"],
|
||||
};
|
||||
|
||||
const BASE: ChannelsUrlState = { view: "subscribed", sort: null, filters: {} };
|
||||
|
||||
// Apply an updates delta onto a base URLSearchParams the way urlWithParams would (null deletes).
|
||||
function applyUpdates(updates: Record<string, string | null>): URLSearchParams {
|
||||
const p = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(updates)) {
|
||||
if (v != null) p.set(k, v);
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
describe("channelsParamUpdates", () => {
|
||||
it("emits nothing for the all-default state", () => {
|
||||
expect(channelsParamUpdates(BASE)).toEqual({ ctab: null, csort: null, cf: null });
|
||||
});
|
||||
|
||||
it("clears every key when passed null (off-manager page)", () => {
|
||||
expect(channelsParamUpdates(null)).toEqual({ ctab: null, csort: null, cf: null });
|
||||
});
|
||||
|
||||
it("emits only non-default keys", () => {
|
||||
const u = channelsParamUpdates({
|
||||
view: "hidden",
|
||||
sort: { key: "subscribers", dir: "desc" },
|
||||
filters: { sync: "fully" },
|
||||
});
|
||||
expect(u.ctab).toBe("hidden");
|
||||
expect(u.csort).toBe("subscribers:desc");
|
||||
expect(u.cf).toBe(JSON.stringify({ sync: "fully" }));
|
||||
});
|
||||
|
||||
it("drops empty filter entries so a cleared funnel emits no cf", () => {
|
||||
expect(
|
||||
channelsParamUpdates({ view: "subscribed", sort: null, filters: { tags: [], sync: "" } }).cf,
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("round-trip", () => {
|
||||
it("restores a rich state (scalar + array filters) from the emitted URL", () => {
|
||||
const state: ChannelsUrlState = {
|
||||
view: "blocked",
|
||||
sort: { key: "priority", dir: "asc" },
|
||||
filters: RICH_FILTERS,
|
||||
};
|
||||
const params = applyUpdates(channelsParamUpdates(state));
|
||||
const back = channelsParamsToState(params, BASE);
|
||||
expect(back.view).toBe("blocked");
|
||||
expect(back.sort).toEqual({ key: "priority", dir: "asc" });
|
||||
expect(back.filters).toEqual(RICH_FILTERS);
|
||||
// the array shape survives the JSON round-trip, not collapsed to a scalar
|
||||
expect(Array.isArray(back.filters.tags)).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps base values for absent keys (partial link)", () => {
|
||||
const params = new URLSearchParams("csort=title:asc");
|
||||
const back = channelsParamsToState(params, {
|
||||
view: "discovery",
|
||||
sort: null,
|
||||
filters: { sync: "needsFull" },
|
||||
});
|
||||
expect(back.view).toBe("discovery"); // from base, no ctab
|
||||
expect(back.filters).toEqual({ sync: "needsFull" }); // from base, no cf
|
||||
expect(back.sort).toEqual({ key: "title", dir: "asc" });
|
||||
});
|
||||
|
||||
it("ignores an out-of-vocabulary tab / bad sort / corrupt cf and falls back to base", () => {
|
||||
const params = new URLSearchParams("ctab=bogus&csort=key:sideways&cf=notjson");
|
||||
const back = channelsParamsToState(params, { view: "subscribed", sort: null, filters: {} });
|
||||
expect(back.view).toBe("subscribed"); // bogus tab → base
|
||||
expect(back.sort).toBeNull(); // bad dir → parseSortState null → base (null)
|
||||
expect(back.filters).toEqual({}); // corrupt cf → decodeFilters heals to empty
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasChannelsParams", () => {
|
||||
it("detects any namespaced key", () => {
|
||||
expect(hasChannelsParams(new URLSearchParams("ctab=hidden"))).toBe(true);
|
||||
expect(hasChannelsParams(new URLSearchParams("cf=%7B%22sync%22%3A%22fully%22%7D"))).toBe(true);
|
||||
});
|
||||
it("ignores the feed's and plex's keys", () => {
|
||||
expect(hasChannelsParams(new URLSearchParams("scope=all&pscope=movie&sort=oldest"))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
// Serialize the Channels-manager content state (active tab + column sort + the column-filter map) to/
|
||||
// from the address bar so a manager view is bookmarkable/shareable — the S8 analogue of the feed's
|
||||
// urlState and Plex's plexUrlState (S4). Keys are NAMESPACED (`ctab`/`csort`/`cf`) so they're
|
||||
// channels-unique (like `?v=`/`?pl`/`pscope`): no other provider ever reads or clears them, and the
|
||||
// feed's URL-stripping can't delete them out from under the manager. Only non-default values are
|
||||
// emitted, so URLs stay clean. The manager's name search is deliberately EPHEMERAL (like the feed/plex
|
||||
// rail search) and stays OUT of the shared contract.
|
||||
import { decodeSortParam, encodeSortParam, type SortState } from "./columnSort";
|
||||
import { type FilterMap } from "../components/tableHeader";
|
||||
|
||||
// The content state S8 mirrors. `view` is the raw tab string (the provider clamps it); `sort` is the
|
||||
// raw provider SortState (healed against live columns only at the point of use), so the user's real
|
||||
// preference round-trips through the URL.
|
||||
export interface ChannelsUrlState {
|
||||
view: string;
|
||||
sort: SortState;
|
||||
filters: FilterMap;
|
||||
}
|
||||
|
||||
const KEYS = ["ctab", "csort", "cf"] as const;
|
||||
const VIEWS = ["subscribed", "discovery", "hidden", "blocked"];
|
||||
|
||||
// Sort ⇄ `key:dir` (e.g. `subscribers:desc`) via the shared codec in columnSort. There is no default
|
||||
// sort here (fallback null), so any sort emits — no default short-circuit needed.
|
||||
// The FilterMap mixes scalar column filters (priority=select, name=text, the sync chip) with array ones
|
||||
// (tags=multi), so it's carried as a compact JSON blob rather than a nested query string — JSON round-
|
||||
// trips both shapes exactly (and future multi columns for free), and the values are opaque enum/id
|
||||
// tokens where a readable querystring buys little. Empty entries are dropped so a cleared funnel emits
|
||||
// no `cf`.
|
||||
function encodeFilters(map: FilterMap): string | null {
|
||||
const clean: FilterMap = {};
|
||||
for (const [k, v] of Object.entries(map)) {
|
||||
if (Array.isArray(v) ? v.length : v) clean[k] = v;
|
||||
}
|
||||
return Object.keys(clean).length ? JSON.stringify(clean) : null;
|
||||
}
|
||||
// A URL blob is untrusted input: accept only a plain object of string / string[] entries, anything else
|
||||
// heals to empty (same discipline as useFilterState's storage hydrate).
|
||||
function decodeFilters(raw: string): FilterMap {
|
||||
try {
|
||||
const v = JSON.parse(raw);
|
||||
if (!v || typeof v !== "object" || Array.isArray(v)) return {};
|
||||
const out: FilterMap = {};
|
||||
for (const [k, val] of Object.entries(v)) {
|
||||
if (typeof val === "string") out[k] = val;
|
||||
else if (Array.isArray(val) && val.every((x) => typeof x === "string"))
|
||||
out[k] = val as string[];
|
||||
}
|
||||
return out;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/** The address-bar delta for the manager content state: each namespaced key mapped to its emitted
|
||||
* value, or null to delete it (so a control back at its default clears the key). Pass `null` to clear
|
||||
* ALL manager keys — used when the app is off the Channels page (localStorage still holds the state; it
|
||||
* re-mirrors on return). Feed the result into urlWithParams(). */
|
||||
export function channelsParamUpdates(
|
||||
state: ChannelsUrlState | null,
|
||||
): Record<string, string | null> {
|
||||
if (!state) return { ctab: null, csort: null, cf: null };
|
||||
return {
|
||||
ctab: state.view && state.view !== "subscribed" ? state.view : null,
|
||||
csort: encodeSortParam(state.sort),
|
||||
cf: encodeFilters(state.filters),
|
||||
};
|
||||
}
|
||||
|
||||
/** True if the query string carries any manager content key we recognize (a shared manager link). */
|
||||
export function hasChannelsParams(params: URLSearchParams): boolean {
|
||||
return KEYS.some((k) => params.has(k));
|
||||
}
|
||||
|
||||
/** Merge the manager content keys from the URL over a base state (the localStorage-seeded values); an
|
||||
* absent or invalid key keeps the base value, so a partial link (only `?ctab=`) is honoured. */
|
||||
export function channelsParamsToState(
|
||||
params: URLSearchParams,
|
||||
base: ChannelsUrlState,
|
||||
): ChannelsUrlState {
|
||||
const tab = params.get("ctab");
|
||||
const csort = params.get("csort");
|
||||
return {
|
||||
view: tab && VIEWS.includes(tab) ? tab : base.view,
|
||||
sort: csort != null ? (decodeSortParam(csort) ?? base.sort) : base.sort,
|
||||
filters: params.has("cf") ? decodeFilters(params.get("cf") ?? "") : base.filters,
|
||||
};
|
||||
}
|
||||
@@ -34,6 +34,22 @@ export function parseSortState(raw: unknown): SortState {
|
||||
return typeof key === "string" && (dir === "asc" || dir === "desc") ? { key, dir } : null;
|
||||
}
|
||||
|
||||
/** Encode a SortState to the compact `key:dir` wire form the shareable-URL modules (plex/channels)
|
||||
* put in the address bar. Sort keys are simple words (no colon), so the plain join round-trips. Returns
|
||||
* null for no-sort. A module with a default sort short-circuits that to null at its own call site. */
|
||||
export function encodeSortParam(s: SortState): string | null {
|
||||
return s ? `${s.key}:${s.dir}` : null;
|
||||
}
|
||||
|
||||
/** Inverse of `encodeSortParam`: parse `key:dir` (or a bare key → asc) from the URL back into a
|
||||
* SortState, healing anything malformed to null. One codec for both URL-state modules — no drift. */
|
||||
export function decodeSortParam(raw: string): SortState {
|
||||
const i = raw.indexOf(":");
|
||||
const key = i === -1 ? raw : raw.slice(0, i);
|
||||
const dir = i === -1 ? "asc" : raw.slice(i + 1);
|
||||
return parseSortState({ key, dir });
|
||||
}
|
||||
|
||||
/** The header-click cycle: unsorted → ascending → descending → unsorted. */
|
||||
export function cycleSort(current: SortState, key: string): SortState {
|
||||
if (!current || current.key !== key) return { key, dir: "asc" };
|
||||
|
||||
@@ -102,6 +102,16 @@ export function usePlexProgress(opts: {
|
||||
const onPauseFlush = () => flush(true);
|
||||
const video = videoRef.current;
|
||||
video?.addEventListener("pause", onPauseFlush);
|
||||
// A seek is a settled position too (S2: Plex is the source of truth, so it must see the new spot
|
||||
// right away, not up to 10s later). Debounced so scrubbing — many `seeked` events in a row — pushes
|
||||
// just ONCE, once the user lands. `final=true` bypasses the backend's ≥10s push-throttle, which is
|
||||
// exactly why the debounce lives here: it bounds a scrub to one immediate Plex timeline call.
|
||||
let seekTimer: number | undefined;
|
||||
const onSeekFlush = () => {
|
||||
window.clearTimeout(seekTimer);
|
||||
seekTimer = window.setTimeout(() => flush(true), 800);
|
||||
};
|
||||
video?.addEventListener("seeked", onSeekFlush);
|
||||
// Save on page unload too (F5/close/navigate): React effect cleanup does NOT run on a full reload,
|
||||
// so without this the resume point lags up to 10s — e.g. a seek right before F5 was lost. Uses a
|
||||
// keepalive beacon so the POST survives the unload (a final checkpoint — the user is leaving).
|
||||
@@ -112,8 +122,10 @@ export function usePlexProgress(opts: {
|
||||
window.addEventListener("pagehide", onHide);
|
||||
return () => {
|
||||
window.clearInterval(iv);
|
||||
window.clearTimeout(seekTimer);
|
||||
window.removeEventListener("pagehide", onHide);
|
||||
video?.removeEventListener("pause", onPauseFlush);
|
||||
video?.removeEventListener("seeked", onSeekFlush);
|
||||
flush(true);
|
||||
// Back (unmount) persists the position server-side via flush(), but reopening the same item in
|
||||
// the SAME session read react-query's CACHED detail — whose position_seconds predated this
|
||||
|
||||
@@ -4,8 +4,16 @@ import { healSort, type SortOption, type SortState } from "./columnSort";
|
||||
// SortControl. Direction lives in the SortState (no more sibling `sortDir` on PlexFilters); the wire
|
||||
// call (`plexLibrary`) still splits it into `sort` + `sort_dir` at the server boundary.
|
||||
|
||||
const MOVIE_SORTS = ["added", "release", "year", "rating", "duration", "title"] as const;
|
||||
const SHOW_SORTS = ["added", "release", "year", "rating", "title"] as const;
|
||||
const MOVIE_SORTS = [
|
||||
"added",
|
||||
"lastwatched",
|
||||
"release",
|
||||
"year",
|
||||
"rating",
|
||||
"duration",
|
||||
"title",
|
||||
] as const;
|
||||
const SHOW_SORTS = ["added", "lastwatched", "release", "year", "rating", "title"] as const;
|
||||
|
||||
/** The sort keys a scope offers — movie libraries add `duration`; shows/both don't. */
|
||||
function plexSortKeys(scope: string): string[] {
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { EMPTY_PLEX_FILTERS, type PlexFilters } from "./api";
|
||||
import { PLEX_SORT_FALLBACK } from "./plexSort";
|
||||
import {
|
||||
hasPlexParams,
|
||||
plexParamsToState,
|
||||
plexParamUpdates,
|
||||
type PlexUrlState,
|
||||
} from "./plexUrlState";
|
||||
|
||||
// A concrete non-default filter set exercising every wire shape (csv arrays, scalars, modes).
|
||||
const RICH_FILTERS: PlexFilters = {
|
||||
genres: ["Drama", "Comedy"],
|
||||
genreMode: "all",
|
||||
contentRatings: ["PG-13"],
|
||||
yearMin: 2000,
|
||||
yearMax: 2020,
|
||||
ratingMin: 7,
|
||||
durationMin: 600,
|
||||
durationMax: 7200,
|
||||
addedWithin: "1m",
|
||||
directors: ["Nolan"],
|
||||
actors: ["Bale", "Caine"],
|
||||
actorMode: "all",
|
||||
studios: ["Warner"],
|
||||
keywords: ["heist"],
|
||||
collections: ["1234"],
|
||||
collectionMode: "all",
|
||||
collectionTitles: { "1234": "Marvel" },
|
||||
};
|
||||
|
||||
// Apply an updates delta onto a base URLSearchParams the way urlWithParams would (null deletes).
|
||||
function applyUpdates(updates: Record<string, string | null>): URLSearchParams {
|
||||
const p = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(updates)) {
|
||||
if (v != null) p.set(k, v);
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
const BASE: PlexUrlState = {
|
||||
scope: "both",
|
||||
sort: PLEX_SORT_FALLBACK,
|
||||
show: "all",
|
||||
filters: EMPTY_PLEX_FILTERS,
|
||||
};
|
||||
|
||||
describe("plexParamUpdates", () => {
|
||||
it("emits nothing for the all-default state", () => {
|
||||
expect(plexParamUpdates(BASE)).toEqual({
|
||||
pscope: null,
|
||||
psort: null,
|
||||
pshow: null,
|
||||
pf: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("clears every key when passed null (off-plex page)", () => {
|
||||
expect(plexParamUpdates(null)).toEqual({
|
||||
pscope: null,
|
||||
psort: null,
|
||||
pshow: null,
|
||||
pf: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("emits only non-default keys", () => {
|
||||
const u = plexParamUpdates({
|
||||
scope: "movie",
|
||||
sort: { key: "year", dir: "desc" },
|
||||
show: "in_progress",
|
||||
filters: EMPTY_PLEX_FILTERS,
|
||||
});
|
||||
expect(u.pscope).toBe("movie");
|
||||
expect(u.psort).toBe("year:desc");
|
||||
expect(u.pshow).toBe("in_progress");
|
||||
expect(u.pf).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("round-trip", () => {
|
||||
it("restores a rich state from the emitted URL", () => {
|
||||
const state: PlexUrlState = {
|
||||
scope: "show",
|
||||
sort: { key: "rating", dir: "desc" },
|
||||
show: "watched",
|
||||
filters: RICH_FILTERS,
|
||||
};
|
||||
const params = applyUpdates(plexParamUpdates(state));
|
||||
const back = plexParamsToState(params, BASE);
|
||||
expect(back.scope).toBe("show");
|
||||
expect(back.sort).toEqual({ key: "rating", dir: "desc" });
|
||||
expect(back.show).toBe("watched");
|
||||
// collectionTitles is display-only (excluded from the wire), so compare the rest.
|
||||
const { collectionTitles: _drop, ...wire } = RICH_FILTERS;
|
||||
expect(back.filters).toEqual(wire);
|
||||
});
|
||||
|
||||
it("keeps base values for absent keys (partial link)", () => {
|
||||
const params = new URLSearchParams("psort=year:asc");
|
||||
const back = plexParamsToState(params, {
|
||||
scope: "movie",
|
||||
sort: PLEX_SORT_FALLBACK,
|
||||
show: "unwatched",
|
||||
filters: EMPTY_PLEX_FILTERS,
|
||||
});
|
||||
expect(back.scope).toBe("movie"); // from base, no pscope
|
||||
expect(back.show).toBe("unwatched"); // from base, no pshow
|
||||
expect(back.sort).toEqual({ key: "year", dir: "asc" });
|
||||
});
|
||||
|
||||
it("ignores an out-of-vocabulary scope/show/sort and falls back to base", () => {
|
||||
const params = new URLSearchParams("pscope=bogus&pshow=nope&psort=key:sideways");
|
||||
const back = plexParamsToState(params, BASE);
|
||||
expect(back.scope).toBe("both");
|
||||
expect(back.show).toBe("all");
|
||||
expect(back.sort).toEqual(PLEX_SORT_FALLBACK); // bad dir → parseSortState null → base
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasPlexParams", () => {
|
||||
it("detects any namespaced key", () => {
|
||||
expect(hasPlexParams(new URLSearchParams("pf=genres%3DDrama"))).toBe(true);
|
||||
expect(hasPlexParams(new URLSearchParams("pscope=movie"))).toBe(true);
|
||||
});
|
||||
it("ignores the feed's bare keys", () => {
|
||||
expect(hasPlexParams(new URLSearchParams("scope=all&sort=oldest&show=watched"))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
// Serialize the Plex-browse content state (library scope + watch-state + sort + metadata filters) to/
|
||||
// from the address bar so a Plex-browse view is bookmarkable/shareable — the S4 analogue of the feed's
|
||||
// urlState. Keys are NAMESPACED (`pscope`/`psort`/`pshow`/`pf`) rather than reusing the feed's bare
|
||||
// `scope`/`sort`/`show`: FeedFiltersProvider actively STRIPS its own KEYS from the URL whenever the app
|
||||
// is off the feed page, so a shared key would be deleted out from under the Plex page. The namespaced
|
||||
// keys are Plex-unique (like `?v=`/`?pl`), so no other provider ever reads or clears them. Only non-
|
||||
// default values are emitted, so URLs stay clean.
|
||||
import { parsePlexFilters, plexFiltersToParams, type PlexFilters } from "./api";
|
||||
import { decodeSortParam, encodeSortParam, type SortState } from "./columnSort";
|
||||
import { PLEX_SORT_FALLBACK } from "./plexSort";
|
||||
|
||||
// The content state S4 mirrors. `sort` is the RAW provider SortState (scope-healed only at the point of
|
||||
// use), so the user's real preference round-trips through the URL.
|
||||
export interface PlexUrlState {
|
||||
scope: string;
|
||||
sort: SortState;
|
||||
show: string;
|
||||
filters: PlexFilters;
|
||||
}
|
||||
|
||||
const KEYS = ["pscope", "psort", "pshow", "pf"] as const;
|
||||
const SCOPES = ["movie", "show", "both"];
|
||||
const SHOWS = ["all", "unwatched", "in_progress", "watched"];
|
||||
|
||||
// Sort ⇄ `key:dir` (e.g. `year:desc`) via the shared codec in columnSort; the Plex default (`title:asc`)
|
||||
// emits nothing.
|
||||
function encodeSort(s: SortState): string | null {
|
||||
if (!s || (s.key === PLEX_SORT_FALLBACK?.key && s.dir === PLEX_SORT_FALLBACK?.dir)) return null;
|
||||
return encodeSortParam(s);
|
||||
}
|
||||
|
||||
/** The address-bar delta for the Plex content state: each namespaced key mapped to its emitted value,
|
||||
* or null to delete it (so a control back at its default clears the key). Pass `null` to clear ALL Plex
|
||||
* keys — used when the app is off the Plex page (localStorage still holds the state; it re-mirrors on
|
||||
* return). Feed the result into urlWithParams(). */
|
||||
export function plexParamUpdates(state: PlexUrlState | null): Record<string, string | null> {
|
||||
if (!state) return { pscope: null, psort: null, pshow: null, pf: null };
|
||||
const pf = plexFiltersToParams(state.filters).toString();
|
||||
return {
|
||||
pscope: state.scope && state.scope !== "both" ? state.scope : null,
|
||||
psort: encodeSort(state.sort),
|
||||
pshow: state.show && state.show !== "all" ? state.show : null,
|
||||
pf: pf || null,
|
||||
};
|
||||
}
|
||||
|
||||
/** True if the query string carries any Plex content key we recognize (a shared Plex-browse link). */
|
||||
export function hasPlexParams(params: URLSearchParams): boolean {
|
||||
return KEYS.some((k) => params.has(k));
|
||||
}
|
||||
|
||||
/** Merge the Plex content keys from the URL over a base state (the localStorage-seeded defaults); an
|
||||
* absent or invalid key keeps the base value, so a partial link (only `?psort=`) is honoured. */
|
||||
export function plexParamsToState(params: URLSearchParams, base: PlexUrlState): PlexUrlState {
|
||||
const scope = params.get("pscope");
|
||||
const show = params.get("pshow");
|
||||
const psort = params.get("psort");
|
||||
const sort = psort != null ? (decodeSortParam(psort) ?? base.sort) : base.sort;
|
||||
return {
|
||||
scope: scope && SCOPES.includes(scope) ? scope : base.scope,
|
||||
sort,
|
||||
show: show && SHOWS.includes(show) ? show : base.show,
|
||||
filters: params.has("pf")
|
||||
? parsePlexFilters(new URLSearchParams(params.get("pf") ?? ""))
|
||||
: base.filters,
|
||||
};
|
||||
}
|
||||
@@ -78,6 +78,7 @@ export const qk = {
|
||||
downloadIndex: () => ["download-index"] as const,
|
||||
downloadUsage: () => ["download-usage"] as const,
|
||||
downloadProfiles: () => ["download-profiles"] as const,
|
||||
downloadCredentials: () => ["download-credentials"] as const,
|
||||
downloadLinks: (jobId: number) => ["download-links", jobId] as const,
|
||||
dlQuota: (userId: number) => ["dl-quota", userId] as const,
|
||||
storyboard: (jobId: number) => ["storyboard", jobId] as const,
|
||||
|
||||
@@ -14,6 +14,46 @@ export interface ReleaseEntry {
|
||||
}
|
||||
|
||||
export const RELEASE_NOTES: ReleaseEntry[] = [
|
||||
{
|
||||
version: "0.75.0",
|
||||
date: "2026-08-29",
|
||||
summary:
|
||||
"A grab-bag batch across Plex, downloads and playlists: your watch state stays truer to Plex, shared audio plays on iPad, downloads from a site you're logged into work with your own cookies, and syncing a playlist to YouTube is now a true two-way merge with a preview.",
|
||||
features: [
|
||||
"Syncing a playlist to YouTube is now a true two-way merge: a video you added on YouTube and one you added in Siftlode both survive, genuine removals carry across, and you always see a preview of exactly what will change on each side before anything is written.",
|
||||
"Download from a site that only shows a video to logged-in members (like a private Facebook group you belong to): paste your own browser cookies for that site under Settings → Account, and downloads from it use your login. Cookies are encrypted, kept private to your account, and only ever sent to the matching site.",
|
||||
"Shared audio files (mp3) now play on iPhone and iPad, in a proper audio player instead of a broken video box.",
|
||||
],
|
||||
fixes: [
|
||||
'A movie you finish on Plex no longer lingers as "in progress" in Siftlode until the nightly reconcile — scrubbing through the end credits now marks it watched within the next sync.',
|
||||
"A download that fails because the site needs a login you haven't set up now says so clearly and doesn't offer a pointless Retry; a genuinely temporary failure still offers one.",
|
||||
"The Plex sort control's direction toggle no longer wraps onto two lines on the narrower popover, so it matches the feed's.",
|
||||
"Adding a download no longer makes the app feel sluggish while it's converted in the background — the conversion is de-prioritised so the rest of the app and your share links stay responsive.",
|
||||
],
|
||||
chores: [
|
||||
"Introduced one shared action-button primitive across the app (the channel Subscribe button now matches Full history), and put the whole batch through several multi-agent deep-review rounds — findings fixed and re-reviewed each round until it came back clean, including data-integrity fixes to the new playlist merge.",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.74.0",
|
||||
date: "2026-08-28",
|
||||
summary:
|
||||
"A grab-bag batch across the Plex player, Plex browsing, and the Channel Manager: smoother episode auto-advance, your watch progress kept in step with Plex, shareable browse links, and an editable Personal Views panel.",
|
||||
features: [
|
||||
"Share a Plex browse view — or a Channel Manager view — just by copying its link. The scope, filters, sort, and active tab all live in the address bar now, so the link opens (and reloads) to exactly what you were looking at.",
|
||||
"New “Last watched” sort for Plex, applied automatically when you switch to the In-progress view so the thing you were most recently watching is right at the top — and your previous sort comes back when you leave.",
|
||||
"Personal Views are editable in place: remove a captured filter as a chip (with undo), or save your currently-applied filters into an existing view — no need to delete and recreate it.",
|
||||
"The channel detail page gained an Unhide button, and the tag “+” now sits at a fixed spot so it no longer drifts to the side as you add tags.",
|
||||
],
|
||||
fixes: [
|
||||
"Fixed the Plex player briefly stalling a few seconds into each auto-advanced episode; a loading indicator now shows while the stream warms up or while you seek.",
|
||||
"Your Plex watch progress now stays in step with Plex as the source of truth — resuming, finishing, or rewinding on one side carries across to the other far more promptly.",
|
||||
"Redesigned the channel priority control into a cleaner −/+ stepper with click-to-type, on both the manager table and the channel page (no more “popping” wider when you edit it).",
|
||||
],
|
||||
chores: [
|
||||
"The batch went through a multi-agent deep-review pass — findings fixed and re-reviewed until clean, including two data-integrity fixes to the new watch-sync and shareable-link work.",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.73.0",
|
||||
date: "2026-08-26",
|
||||
|
||||
Reference in New Issue
Block a user