Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3682e6a01f | ||
|
|
aca355f43b | ||
|
|
f41c81eb56 | ||
|
|
614c5528ad | ||
|
|
104bd1e3a6 | ||
|
|
584e9ced0c | ||
|
|
b4ad59274b | ||
|
|
fe0b34cd00 | ||
|
|
9eaa01617c | ||
|
|
fdca12968b | ||
|
|
94fcc40bde | ||
|
|
fb2c843fa9 | ||
|
|
bb6cb6e335 | ||
|
|
bdbff89507 | ||
|
|
9b87838e3a | ||
|
|
f014416e31 | ||
|
|
3369587412 | ||
|
|
9af4e8f4ff | ||
|
|
a0b725d779 | ||
|
|
bc33b2ed16 | ||
|
|
5280949667 | ||
|
|
4892c38466 | ||
|
|
9ae6c47d6c | ||
|
|
b7a6451dba | ||
|
|
11e32163b8 | ||
|
|
257dc5741d |
@@ -0,0 +1,46 @@
|
|||||||
|
"""download_credentials (S6: per-user, per-host yt-dlp cookie/auth store)
|
||||||
|
|
||||||
|
Each user stores their OWN login cookies (Netscape cookies.txt) for an auth-walled download host
|
||||||
|
(Facebook, Instagram, …), Fernet-encrypted at rest. A download of a yt-dlp-supported, auth-walled
|
||||||
|
link on that host is retried with the acting user's cookie. Unique per (user_id, host_key) so a
|
||||||
|
user holds at most one credential per host. Empty for every existing install; forward-only in
|
||||||
|
practice (a downgrade just drops the table + its secrets).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0074_download_credentials"
|
||||||
|
down_revision: Union[str, None] = "0073_saved_view_source"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"download_credentials",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column(
|
||||||
|
"user_id",
|
||||||
|
sa.Integer(),
|
||||||
|
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column("host_key", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("cookies_enc", sa.Text(), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||||
|
),
|
||||||
|
sa.UniqueConstraint("user_id", "host_key", name="uq_download_credential"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_download_credentials_user_id", "download_credentials", ["user_id"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_download_credentials_user_id", table_name="download_credentials")
|
||||||
|
op.drop_table("download_credentials")
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"""playlist synced_video_ids (S7: 3-way merge baseline for two-way YouTube sync)
|
||||||
|
|
||||||
|
The one-directional "local wins" push is replaced by a 3-way merge that classifies each item
|
||||||
|
against the last-synced state. That needs the raw ordered id set as a baseline, not just the
|
||||||
|
`synced_fingerprint` hash. Add a nullable `synced_video_ids` (JSON list) column; NULL on every
|
||||||
|
existing row means "no baseline yet" — the first merge then unions losslessly and records one.
|
||||||
|
Forward-only in practice (a downgrade just drops the column).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0075_playlist_sync_baseline"
|
||||||
|
down_revision: Union[str, None] = "0074_download_credentials"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("playlists", sa.Column("synced_video_ids", sa.Text(), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("playlists", "synced_video_ids")
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"""media_assets.media_kind (E7 S5a: the video|audio|file discriminator for the media library)
|
||||||
|
|
||||||
|
Adds a stored media-kind to each asset so the library can gate its actions on it (play/edit for
|
||||||
|
media, download-only for a generic uploaded file) instead of re-deriving from codecs in prose. The
|
||||||
|
column is the single home of the invariant `downloads.mediakind.derive` computes at finalize.
|
||||||
|
|
||||||
|
Backfill for existing rows: a stored video codec -> 'video', an audio-only codec -> 'audio', an
|
||||||
|
editor derivative (source_kind='edit', which carries no codecs but is always a video clip) ->
|
||||||
|
'video'. Rows still pending/errored (no codecs, not an edit) stay NULL — kind is set when they
|
||||||
|
finalize. Empty `media_kind` never reaches the UI: only `ready`/`done` rows render actions, and
|
||||||
|
those are all backfilled here.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0076_media_asset_kind"
|
||||||
|
down_revision: Union[str, None] = "0075_playlist_sync_baseline"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("media_assets", sa.Column("media_kind", sa.String(length=16), nullable=True))
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
UPDATE media_assets
|
||||||
|
SET media_kind = CASE
|
||||||
|
WHEN vcodec IS NOT NULL THEN 'video'
|
||||||
|
WHEN acodec IS NOT NULL THEN 'audio'
|
||||||
|
WHEN source_kind = 'edit' THEN 'video'
|
||||||
|
ELSE NULL
|
||||||
|
END
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("media_assets", "media_kind")
|
||||||
@@ -44,6 +44,9 @@ class AuditAction:
|
|||||||
SYNC_RESUME = "sync_resume"
|
SYNC_RESUME = "sync_resume"
|
||||||
# Scheduler job outcome / run-summary (actor_id set = a manual "run now"; NULL = automatic)
|
# Scheduler job outcome / run-summary (actor_id set = a manual "run now"; NULL = automatic)
|
||||||
JOB_RUN = "job_run"
|
JOB_RUN = "job_run"
|
||||||
|
# Per-user download cookie/auth credential set or removed (the value is NEVER recorded)
|
||||||
|
DOWNLOAD_CREDENTIAL_SET = "download_credential_set"
|
||||||
|
DOWNLOAD_CREDENTIAL_DELETE = "download_credential_delete"
|
||||||
# The log itself was cleared (a single row survives the clear as tamper-evidence)
|
# The log itself was cleared (a single row survives the clear as tamper-evidence)
|
||||||
AUDIT_CLEAR = "audit_clear"
|
AUDIT_CLEAR = "audit_clear"
|
||||||
|
|
||||||
|
|||||||
@@ -186,6 +186,21 @@ class Settings(BaseSettings):
|
|||||||
download_supervisor_poll_seconds: int = 3
|
download_supervisor_poll_seconds: int = 3
|
||||||
# Grace between SIGTERM and SIGKILL when stopping a child (matches run_ffmpeg's escalation).
|
# Grace between SIGTERM and SIGKILL when stopping a child (matches run_ffmpeg's escalation).
|
||||||
download_kill_grace_seconds: int = 5
|
download_kill_grace_seconds: int = 5
|
||||||
|
# --- worker-child resource de-prioritization ---
|
||||||
|
# The download/edit child (yt-dlp + ffmpeg) shares ONE LAN box with the API, Postgres, and Plex
|
||||||
|
# playback. Without a priority handicap a CPU re-encode pegs every core for ~30s and starves web
|
||||||
|
# requests + playback until it finishes. The child lowers its OWN priority at startup — inherited
|
||||||
|
# by every ffmpeg/yt-dlp descendant — so any foreground work always wins. Each knob is best-effort
|
||||||
|
# and independently disable-able with 0.
|
||||||
|
download_child_nice: int = 10 # CPU nice increment applied to the child; 0 = don't renice
|
||||||
|
download_child_ionice_class: int = 3 # best-effort IO class (3 = idle); 0 = leave IO untouched
|
||||||
|
# Cap the browser-safe re-encode's encoder threads so a transcode can't claim every core. 0 =
|
||||||
|
# reserve one core for foreground work (max(1, cpu_count - 1)); >0 pins exactly that many.
|
||||||
|
download_ffmpeg_threads: int = 0
|
||||||
|
# Throttle the retention GC's whole-tree orphan file sweep so its rglob can't monopolize disk IO
|
||||||
|
# in the API process: sleep `pause_ms` after every `batch` files touched. pause_ms=0 disables.
|
||||||
|
download_gc_sweep_pause_ms: int = 20
|
||||||
|
download_gc_sweep_batch: int = 400
|
||||||
# Total cache size cap across ALL users (bytes). When >0 and exceeded, the GC evicts the
|
# Total cache size cap across ALL users (bytes). When >0 and exceeded, the GC evicts the
|
||||||
# least-recently-accessed ready assets until under it. 0 = unlimited (disk is the limit).
|
# least-recently-accessed ready assets until under it. 0 = unlimited (disk is the limit).
|
||||||
download_total_max_bytes: int = 0
|
download_total_max_bytes: int = 0
|
||||||
@@ -193,6 +208,10 @@ class Settings(BaseSettings):
|
|||||||
download_default_max_bytes: int = 5_368_709_120 # 5 GiB
|
download_default_max_bytes: int = 5_368_709_120 # 5 GiB
|
||||||
download_default_max_concurrent: int = 2
|
download_default_max_concurrent: int = 2
|
||||||
download_default_max_jobs: int = 50
|
download_default_max_jobs: int = 50
|
||||||
|
# Per-file cap for local uploads (E7). Unlike a download (whose size is unknown until the worker
|
||||||
|
# finishes), an upload's size is known at ingest, so this bounds a single file directly; the
|
||||||
|
# user's overall byte footprint is still governed by download_default_max_bytes.
|
||||||
|
download_upload_max_bytes: int = 524_288_000 # 500 MiB
|
||||||
# Retention: an asset expires this many days after its last access; the GC job then
|
# Retention: an asset expires this many days after its last access; the GC job then
|
||||||
# deletes it (once no job references it) after an additional grace window.
|
# deletes it (once no job references it) after an additional grace window.
|
||||||
download_retention_days: int = 30
|
download_retention_days: int = 30
|
||||||
|
|||||||
@@ -0,0 +1,258 @@
|
|||||||
|
"""Per-user, per-host download cookies — the single source of truth for "does the acting user's
|
||||||
|
cookie apply to this download, and how does that keep the result private".
|
||||||
|
|
||||||
|
Two call sites depend on ONE consistent answer (house rule: an invariant lives in shared code, not
|
||||||
|
in prose):
|
||||||
|
* `enqueue` salts the MediaAsset cache signature with the user id WHEN a cookie applies, so the
|
||||||
|
authed download becomes a PRIVATE asset that is never cache-served to another user.
|
||||||
|
* the worker attaches the cookie file to yt-dlp ONLY when a cookie still applies AND the asset it
|
||||||
|
is downloading into is that private, salted identity — so a cookie can never poison a shared
|
||||||
|
asset (the load-bearing cross-user privacy fix).
|
||||||
|
|
||||||
|
Both go through `cookie_salt` / `authed_format_sig` / `host_key_for_url` here, so they can't drift.
|
||||||
|
|
||||||
|
Security controls enforced here:
|
||||||
|
* **host scope (SEC-12)** — `host_key_for_url` matches the download URL's netloc against a curated
|
||||||
|
registry via the netloc-scoped `_host_is`, so a cookie is only ever offered to the exact site it
|
||||||
|
belongs to; an arbitrary `?url=` (or a look-alike host) resolves to no host_key and gets no
|
||||||
|
cookie.
|
||||||
|
* **secret handling** — cookies are Fernet-encrypted at rest; the blob is only ever decrypted
|
||||||
|
in-worker into a 0600 file that is unlinked in a `finally`, and is never returned by a route,
|
||||||
|
logged, or written to a job's error/snapshot.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.downloads import formats
|
||||||
|
from app.downloads.adapters import _host_is
|
||||||
|
from app.models import DownloadCredential
|
||||||
|
from app.security import decrypt_optional, encrypt
|
||||||
|
|
||||||
|
log = logging.getLogger("siftlode.downloads.credentials")
|
||||||
|
|
||||||
|
|
||||||
|
class HostSpec:
|
||||||
|
"""A supported cookie host: a short canonical `key` (stored on the credential + folded into the
|
||||||
|
cache salt), a human `label` for the UI, and the netloc `domains` it matches."""
|
||||||
|
|
||||||
|
__slots__ = ("key", "label", "domains")
|
||||||
|
|
||||||
|
def __init__(self, key: str, label: str, domains: tuple[str, ...]):
|
||||||
|
self.key = key
|
||||||
|
self.label = label
|
||||||
|
self.domains = domains
|
||||||
|
|
||||||
|
|
||||||
|
# Curated registry (user's call: seed the common auth-walled hosts now). One `key` can cover several
|
||||||
|
# domains of the same site. Add a host by adding a row — the FE dropdown, host-scope guard, and salt
|
||||||
|
# all derive from this, so there is nothing else to wire up.
|
||||||
|
SUPPORTED_HOSTS: tuple[HostSpec, ...] = (
|
||||||
|
HostSpec("facebook", "Facebook", ("facebook.com", "fb.watch")),
|
||||||
|
HostSpec("instagram", "Instagram", ("instagram.com",)),
|
||||||
|
HostSpec("twitter", "X (Twitter)", ("twitter.com", "x.com")),
|
||||||
|
HostSpec("tiktok", "TikTok", ("tiktok.com",)),
|
||||||
|
HostSpec("reddit", "Reddit", ("reddit.com",)),
|
||||||
|
)
|
||||||
|
|
||||||
|
_BY_KEY: dict[str, HostSpec] = {h.key: h for h in SUPPORTED_HOSTS}
|
||||||
|
|
||||||
|
|
||||||
|
def is_supported_host(host_key: str) -> bool:
|
||||||
|
return host_key in _BY_KEY
|
||||||
|
|
||||||
|
|
||||||
|
def host_label(host_key: str) -> str:
|
||||||
|
spec = _BY_KEY.get(host_key)
|
||||||
|
return spec.label if spec else host_key
|
||||||
|
|
||||||
|
|
||||||
|
def host_key_for_url(url: str | None) -> str | None:
|
||||||
|
"""The supported host_key whose domains own `url`'s netloc, or None. Netloc-scoped (via
|
||||||
|
`_host_is`), so a look-alike host ('myfacebook.com', 'facebook.com.evil') never matches. When
|
||||||
|
several specs could match, the most-specific (longest matched domain) wins so the result is
|
||||||
|
deterministic across the enqueue and worker call sites."""
|
||||||
|
if not url:
|
||||||
|
return None
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
host = urlparse(url).netloc.lower()
|
||||||
|
if not host:
|
||||||
|
return None
|
||||||
|
best_key: str | None = None
|
||||||
|
best_len = -1
|
||||||
|
for spec in SUPPORTED_HOSTS:
|
||||||
|
for dom in spec.domains:
|
||||||
|
if _host_is(host, dom) and len(dom) > best_len:
|
||||||
|
best_key, best_len = spec.key, len(dom)
|
||||||
|
return best_key
|
||||||
|
|
||||||
|
|
||||||
|
def cookie_salt(user_id: int, host_key: str) -> str:
|
||||||
|
"""The cache-signature salt for a cookie-authed download. Keyed by user id + host so the authed
|
||||||
|
asset is private to that user (never dedup-shared) and stable across that user's own re-adds."""
|
||||||
|
return f"cookie:{user_id}:{host_key}"
|
||||||
|
|
||||||
|
|
||||||
|
def authed_format_sig(spec: dict, user_id: int, host_key: str) -> str:
|
||||||
|
"""The salted `format_sig` an authed download for (user, host) resolves to — the private asset
|
||||||
|
identity. Both enqueue (to create it) and the worker (to recognise it) compute it HERE."""
|
||||||
|
return formats.format_sig(spec, salt=cookie_salt(user_id, host_key))
|
||||||
|
|
||||||
|
|
||||||
|
def salt_for_enqueue(db: Session, user_id: int, url: str | None) -> str | None:
|
||||||
|
"""At enqueue: the cache salt to apply if this user has a cookie for `url`'s host, else None (a
|
||||||
|
normal shared download). Returns the salt string so the caller passes it straight to
|
||||||
|
`format_sig(spec, salt=...)`."""
|
||||||
|
host_key = host_key_for_url(url)
|
||||||
|
if host_key is None:
|
||||||
|
return None
|
||||||
|
if get_credential(db, user_id, host_key) is None:
|
||||||
|
return None
|
||||||
|
return cookie_salt(user_id, host_key)
|
||||||
|
|
||||||
|
|
||||||
|
# --- storage (CRUD) ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def get_credential(db: Session, user_id: int, host_key: str) -> DownloadCredential | None:
|
||||||
|
return db.execute(
|
||||||
|
select(DownloadCredential).where(
|
||||||
|
DownloadCredential.user_id == user_id, DownloadCredential.host_key == host_key
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidCookies(ValueError):
|
||||||
|
"""The pasted cookies aren't a usable Netscape cookies.txt — surfaced as a 400 by the route."""
|
||||||
|
|
||||||
|
|
||||||
|
def validate_cookies(raw: str) -> str:
|
||||||
|
"""Sanity-check a pasted cookies.txt (Netscape format) and return the cleaned text. yt-dlp reads
|
||||||
|
the Netscape format: comment/`#` lines plus TAB-separated cookie lines (domain, flag, path,
|
||||||
|
secure, expiry, name, value). We don't fully parse — just reject obvious non-cookie paste (empty,
|
||||||
|
or no tab-separated data line) so a mistaken paste fails HERE with a clear message rather than
|
||||||
|
silently producing a cookie file yt-dlp ignores."""
|
||||||
|
text = (raw or "").strip()
|
||||||
|
if not text:
|
||||||
|
raise InvalidCookies("empty")
|
||||||
|
has_data_line = any(
|
||||||
|
line.strip() and not line.lstrip().startswith("#") and "\t" in line
|
||||||
|
for line in text.splitlines()
|
||||||
|
)
|
||||||
|
if not has_data_line:
|
||||||
|
raise InvalidCookies("format")
|
||||||
|
# Netscape cookie files must end with a newline for some parsers; normalise.
|
||||||
|
return text if text.endswith("\n") else text + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def upsert_credential(db: Session, user_id: int, host_key: str, raw: str) -> DownloadCredential:
|
||||||
|
"""Create or replace this user's cookie blob for `host_key`. Caller has validated host_key is
|
||||||
|
supported and `raw` via `validate_cookies`. Does NOT commit (the route commits with the audit
|
||||||
|
row so the trail can't drift). The blob is encrypted here; the plaintext never leaves this call."""
|
||||||
|
cred = get_credential(db, user_id, host_key)
|
||||||
|
enc = encrypt(raw)
|
||||||
|
if cred is None:
|
||||||
|
cred = DownloadCredential(user_id=user_id, host_key=host_key, cookies_enc=enc)
|
||||||
|
db.add(cred)
|
||||||
|
else:
|
||||||
|
cred.cookies_enc = enc
|
||||||
|
return cred
|
||||||
|
|
||||||
|
|
||||||
|
def delete_credential(db: Session, user_id: int, host_key: str) -> bool:
|
||||||
|
"""Delete this user's cookie for `host_key`. Returns whether a row existed. Does NOT commit."""
|
||||||
|
cred = get_credential(db, user_id, host_key)
|
||||||
|
if cred is None:
|
||||||
|
return False
|
||||||
|
db.delete(cred)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def status_list(db: Session, user_id: int) -> list[dict]:
|
||||||
|
"""The supported hosts with this user's configured/updated state — the write-only Settings view.
|
||||||
|
Never includes the blob."""
|
||||||
|
have = {
|
||||||
|
c.host_key: c
|
||||||
|
for c in db.execute(
|
||||||
|
select(DownloadCredential).where(DownloadCredential.user_id == user_id)
|
||||||
|
).scalars()
|
||||||
|
}
|
||||||
|
out = []
|
||||||
|
for spec in SUPPORTED_HOSTS:
|
||||||
|
cred = have.get(spec.key)
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"host_key": spec.key,
|
||||||
|
"label": spec.label,
|
||||||
|
"configured": cred is not None,
|
||||||
|
"updated_at": cred.updated_at.isoformat() if cred is not None else None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# --- worker: resolve + write the cookie file --------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_cookiefile(
|
||||||
|
db: Session,
|
||||||
|
user_id: int,
|
||||||
|
url: str | None,
|
||||||
|
asset_format_sig: str,
|
||||||
|
spec: dict,
|
||||||
|
staging: Path,
|
||||||
|
) -> Path | None:
|
||||||
|
"""In the worker child: the cookies.txt to hand yt-dlp for this download, or None.
|
||||||
|
|
||||||
|
The double gate that prevents cross-user leakage:
|
||||||
|
1. a credential must still exist for `url`'s host (host-scoped via `host_key_for_url`), AND
|
||||||
|
2. the asset being downloaded into must BE this user's private, salted identity
|
||||||
|
(`asset_format_sig == authed_format_sig(...)`).
|
||||||
|
|
||||||
|
If (2) fails the asset is the SHARED (unsalted) one — attaching the cookie would poison the
|
||||||
|
global cache with a private-group video, so we attach nothing. An unreadable blob (rotated key →
|
||||||
|
InvalidToken) also yields None; the download then fails honestly on the login wall (S3's
|
||||||
|
`login_required`)."""
|
||||||
|
host_key = host_key_for_url(url)
|
||||||
|
if host_key is None:
|
||||||
|
return None
|
||||||
|
cred = get_credential(db, user_id, host_key)
|
||||||
|
if cred is None:
|
||||||
|
return None
|
||||||
|
if asset_format_sig != authed_format_sig(spec, user_id, host_key):
|
||||||
|
return None # shared asset — never attach a cookie to a non-private identity
|
||||||
|
raw = decrypt_optional(cred.cookies_enc)
|
||||||
|
if not raw:
|
||||||
|
log.warning(
|
||||||
|
"download cookie for user %s host %s could not be decrypted (rotated key?) — "
|
||||||
|
"proceeding without it",
|
||||||
|
user_id,
|
||||||
|
host_key,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
return _write_cookiefile(raw, staging)
|
||||||
|
|
||||||
|
|
||||||
|
def _write_cookiefile(raw: str, staging: Path) -> Path:
|
||||||
|
"""Write the cookie blob to a private 0600 file under `staging/.auth/` and return its path.
|
||||||
|
|
||||||
|
Deliberately NOT in the staging root: the download retry loop unlinks every file it finds there,
|
||||||
|
and `_produced_file` picks the largest file there as the output — either would corrupt a
|
||||||
|
cookies.txt in the root. A `.auth` SUBDIR survives both (they skip directories) and is still
|
||||||
|
removed by the parent worker's staging cleanup on a hard kill. The caller unlinks it in a
|
||||||
|
`finally` on the normal/exception paths."""
|
||||||
|
auth_dir = staging / ".auth"
|
||||||
|
auth_dir.mkdir(mode=0o700, exist_ok=True)
|
||||||
|
path = auth_dir / "cookies.txt"
|
||||||
|
# Open with 0600 from the start (don't create world-readable then chmod).
|
||||||
|
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||||
|
try:
|
||||||
|
os.write(fd, raw.encode("utf-8"))
|
||||||
|
finally:
|
||||||
|
os.close(fd)
|
||||||
|
return path
|
||||||
@@ -25,6 +25,7 @@ import hashlib
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import math
|
import math
|
||||||
|
import os
|
||||||
import queue
|
import queue
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -32,9 +33,21 @@ import threading
|
|||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
log = logging.getLogger("siftlode.edit")
|
log = logging.getLogger("siftlode.edit")
|
||||||
|
|
||||||
|
|
||||||
|
def encode_thread_cap() -> int:
|
||||||
|
"""How many encoder threads a browser-safe re-encode may use. `download_ffmpeg_threads` when the
|
||||||
|
admin pinned one (>0), else reserve a core for foreground work (max(1, cpu_count - 1)). Shared by
|
||||||
|
the ffmpeg `-threads` flag and the child's `OMP_NUM_THREADS` so both honour the same budget."""
|
||||||
|
n = settings.download_ffmpeg_threads
|
||||||
|
if n > 0:
|
||||||
|
return n
|
||||||
|
return max(1, (os.cpu_count() or 2) - 1)
|
||||||
|
|
||||||
|
|
||||||
class EditAborted(Exception):
|
class EditAborted(Exception):
|
||||||
"""Raised by run_ffmpeg when the job was paused/canceled mid-encode."""
|
"""Raised by run_ffmpeg when the job was paused/canceled mid-encode."""
|
||||||
|
|
||||||
@@ -425,18 +438,42 @@ _BROWSER_SAFE_VCODEC = ("h264",)
|
|||||||
_BROWSER_SAFE_ACODEC = ("aac", "mp3")
|
_BROWSER_SAFE_ACODEC = ("aac", "mp3")
|
||||||
|
|
||||||
|
|
||||||
def parse_probe_streams(payload: str) -> tuple[str | None, str | None]:
|
def run_ffprobe_json(
|
||||||
"""(video_codec, audio_codec) out of ffprobe's JSON stream list, lowercased.
|
path: Path,
|
||||||
|
*,
|
||||||
JSON, not the flat `default=nw=1` output: there the fields arrive in ffprobe's own struct
|
show_streams: bool = False,
|
||||||
order, which puts `codec_name` BEFORE `codec_type`, so a line-by-line "remember the last
|
show_format: bool = False,
|
||||||
codec_type" parser attributes every name to the PREVIOUS stream. Measured on real downloads:
|
entries: str | None = None,
|
||||||
an ordinary H.264+AAC mp4 probed as `v=aac a=None` — the browser-playability check then
|
) -> dict | None:
|
||||||
believed the video stream was AAC and re-encoded a perfectly fine file on every download."""
|
"""One ffprobe call → its parsed JSON dict, or None on any failure (timeout, non-zero exit,
|
||||||
|
missing binary, unparseable output). The SINGLE ffprobe-JSON-invocation point in the downloads
|
||||||
|
package: the browser-playability codec probe here and the upload-ingest probe (`upload._probe`)
|
||||||
|
both go through it, so the subprocess flags/timeout/error handling live in one place instead of
|
||||||
|
two drifting copies."""
|
||||||
|
args = ["ffprobe", "-v", "error"]
|
||||||
|
if show_streams:
|
||||||
|
args.append("-show_streams")
|
||||||
|
if show_format:
|
||||||
|
args.append("-show_format")
|
||||||
|
if entries:
|
||||||
|
args += ["-show_entries", entries]
|
||||||
|
args += ["-of", "json", str(path)]
|
||||||
try:
|
try:
|
||||||
streams = json.loads(payload or "{}").get("streams") or []
|
out = subprocess.run(args, capture_output=True, text=True, timeout=30, check=True).stdout
|
||||||
except ValueError:
|
return json.loads(out)
|
||||||
return None, None
|
except (subprocess.TimeoutExpired, subprocess.CalledProcessError, OSError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _codecs_from_streams(streams: list) -> tuple[str | None, str | None]:
|
||||||
|
"""(video_codec, audio_codec) lowercased from an ffprobe JSON stream list — the first of each kind.
|
||||||
|
|
||||||
|
Parsed from the JSON `streams` list, NOT ffprobe's flat `default=nw=1` output: there the fields
|
||||||
|
arrive in ffprobe's own struct order, which puts `codec_name` BEFORE `codec_type`, so a line-by-line
|
||||||
|
"remember the last codec_type" parser attributes every name to the PREVIOUS stream. Measured on real
|
||||||
|
downloads: an ordinary H.264+AAC mp4 probed as `v=aac a=None` — the browser-playability check then
|
||||||
|
believed the video stream was AAC and re-encoded a perfectly fine file on every download. Reading each
|
||||||
|
stream dict by its own `codec_type` is immune to that ordering."""
|
||||||
vcodec = acodec = None
|
vcodec = acodec = None
|
||||||
for s in streams:
|
for s in streams:
|
||||||
name = (s.get("codec_name") or "").lower() or None
|
name = (s.get("codec_name") or "").lower() or None
|
||||||
@@ -451,26 +488,10 @@ def parse_probe_streams(payload: str) -> tuple[str | None, str | None]:
|
|||||||
def probe_codecs(path: Path) -> tuple[str | None, str | None]:
|
def probe_codecs(path: Path) -> tuple[str | None, str | None]:
|
||||||
"""Return (video_codec, audio_codec) lowercased via one ffprobe call; None for a missing
|
"""Return (video_codec, audio_codec) lowercased via one ffprobe call; None for a missing
|
||||||
stream or if ffprobe fails (a failed probe is treated as 'leave it alone' upstream)."""
|
stream or if ffprobe fails (a failed probe is treated as 'leave it alone' upstream)."""
|
||||||
try:
|
data = run_ffprobe_json(path, entries="stream=codec_type,codec_name")
|
||||||
out = subprocess.run(
|
if data is None:
|
||||||
[
|
|
||||||
"ffprobe",
|
|
||||||
"-v",
|
|
||||||
"error",
|
|
||||||
"-show_entries",
|
|
||||||
"stream=codec_type,codec_name",
|
|
||||||
"-of",
|
|
||||||
"json",
|
|
||||||
str(path),
|
|
||||||
],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=30,
|
|
||||||
check=True,
|
|
||||||
).stdout
|
|
||||||
except (subprocess.TimeoutExpired, subprocess.CalledProcessError, OSError):
|
|
||||||
return None, None
|
return None, None
|
||||||
return parse_probe_streams(out)
|
return _codecs_from_streams(data.get("streams") or [])
|
||||||
|
|
||||||
|
|
||||||
def probe_duration(path: Path) -> int:
|
def probe_duration(path: Path) -> int:
|
||||||
@@ -510,11 +531,12 @@ def browser_transcode_flags(
|
|||||||
|
|
||||||
|
|
||||||
def build_browser_safe_cmd(
|
def build_browser_safe_cmd(
|
||||||
src: Path, dest: Path, reencode_video: bool, reencode_audio: bool
|
src: Path, dest: Path, reencode_video: bool, reencode_audio: bool, threads: int | None = None
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
"""ffmpeg command that lands `src` as a browser-safe mp4 (faststart): re-encode only the
|
"""ffmpeg command that lands `src` as a browser-safe mp4 (faststart): re-encode only the
|
||||||
stream(s) that need it (libx264 / aac), stream-copy the rest, always into mp4. `-progress
|
stream(s) that need it (libx264 / aac), stream-copy the rest, always into mp4. `-progress
|
||||||
pipe:1` streams machine-readable progress for run_ffmpeg."""
|
pipe:1` streams machine-readable progress for run_ffmpeg. `threads` caps the encoder so a
|
||||||
|
re-encode can't claim every core on the shared box (see the worker-child de-prioritization)."""
|
||||||
cmd = [
|
cmd = [
|
||||||
"ffmpeg",
|
"ffmpeg",
|
||||||
"-y",
|
"-y",
|
||||||
@@ -534,6 +556,8 @@ def build_browser_safe_cmd(
|
|||||||
else ["-c:v", "copy"]
|
else ["-c:v", "copy"]
|
||||||
)
|
)
|
||||||
cmd += ["-c:a", "aac", "-b:a", "192k"] if reencode_audio else ["-c:a", "copy"]
|
cmd += ["-c:a", "aac", "-b:a", "192k"] if reencode_audio else ["-c:a", "copy"]
|
||||||
|
if threads and threads > 0:
|
||||||
|
cmd += ["-threads", str(threads)]
|
||||||
cmd += ["-movflags", "+faststart", str(dest)]
|
cmd += ["-movflags", "+faststart", str(dest)]
|
||||||
return cmd
|
return cmd
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,16 @@ _RULES: tuple[tuple[str, str], ...] = (
|
|||||||
# Some extractors (e.g. Reddit) gate content behind a logged-in account: "Account
|
# Some extractors (e.g. Reddit) gate content behind a logged-in account: "Account
|
||||||
# authentication is required." — an auth wall we deliberately don't cross, not a transient error.
|
# authentication is required." — an auth wall we deliberately don't cross, not a transient error.
|
||||||
("authentication is required", "login_required"),
|
("authentication is required", "login_required"),
|
||||||
|
# Closed-group / private-post auth walls (Facebook groups, Instagram private, …). yt-dlp phrases
|
||||||
|
# these many ways; the common thread is "you must be signed in" or its own suggestion to supply
|
||||||
|
# cookies. Treating the cookie suggestion as an auth signal is what makes a login-walled link read
|
||||||
|
# honestly (and stay non-retryable) when the acting user has no usable cookie for that host.
|
||||||
|
("log in to", "login_required"),
|
||||||
|
("login required", "login_required"),
|
||||||
|
("you must be logged in", "login_required"),
|
||||||
|
("only available for registered users", "login_required"),
|
||||||
|
("use --cookies", "login_required"),
|
||||||
|
("--cookies-from-browser", "login_required"),
|
||||||
# Geo-restricted.
|
# Geo-restricted.
|
||||||
("in your country", "geo_blocked"),
|
("in your country", "geo_blocked"),
|
||||||
("available in your location", "geo_blocked"),
|
("available in your location", "geo_blocked"),
|
||||||
@@ -42,6 +52,10 @@ _RULES: tuple[tuple[str, str], ...] = (
|
|||||||
("account associated with this video has been terminated", "unavailable"),
|
("account associated with this video has been terminated", "unavailable"),
|
||||||
("this video is not available", "unavailable"),
|
("this video is not available", "unavailable"),
|
||||||
("this video has been removed", "unavailable"),
|
("this video has been removed", "unavailable"),
|
||||||
|
# yt-dlp doesn't support this site at all ("Unsupported URL: …") — distinct from an auth wall on a
|
||||||
|
# supported site. Non-retryable: no cookie or retry can make an unsupported host downloadable.
|
||||||
|
("unsupported url", "unsupported_host"),
|
||||||
|
("no video could be found in this url", "unsupported_host"),
|
||||||
# DRM.
|
# DRM.
|
||||||
("drm protected", "drm"),
|
("drm protected", "drm"),
|
||||||
("drm-protected", "drm"),
|
("drm-protected", "drm"),
|
||||||
|
|||||||
@@ -5,13 +5,16 @@ of truth for what file gets produced. `format_sig` hashes exactly the fields tha
|
|||||||
OUTPUT BYTES, so two users picking equivalent settings share one cached MediaAsset.
|
OUTPUT BYTES, so two users picking equivalent settings share one cached MediaAsset.
|
||||||
`build_ydl_opts` turns a spec into a yt-dlp options dict (format selector + postprocessors).
|
`build_ydl_opts` turns a spec into a yt-dlp options dict (format selector + postprocessors).
|
||||||
|
|
||||||
spec shape (all keys optional; defaults applied by `normalize`):
|
spec shape (all keys optional; defaults + browser-playability clamp applied by `normalize`):
|
||||||
mode "av" (audio+video) | "v" (video-only) | "a" (audio-only)
|
mode "av" (audio+video) | "v" (video-only) | "a" (audio-only)
|
||||||
max_height int | null (null = best)
|
max_height int | null (null = best)
|
||||||
container "mp4" | "mkv" | "webm" | null (merge/remux target for av/v)
|
container always coerced to "mp4" (the only universally browser-playable container)
|
||||||
audio_format "m4a" | "mp3" | "opus" | null (extract target for mode "a")
|
audio_format "m4a" | "mp3" (mode "a" extract target; anything else — e.g. opus — → "m4a")
|
||||||
vcodec "h264" | "vp9" | "av1" | null (preference, best-effort)
|
vcodec always coerced to null → H.264 selected (see `normalize`); no opt-out
|
||||||
embed_subs bool embed_chapters bool embed_thumbnail bool sponsorblock bool
|
embed_subs bool embed_chapters bool embed_thumbnail bool sponsorblock bool
|
||||||
|
|
||||||
|
`normalize` clamps container/vcodec/audio_format so EVERY download plays in-browser (E7); the
|
||||||
|
`container`/`vcodec` keys survive only so an old stored profile deserializes without error.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
@@ -50,10 +53,12 @@ _DEFAULTS = {
|
|||||||
# prefer H.264+AAC (browser/iOS-compatible) rather than yt-dlp's default VP9/Opus best.
|
# prefer H.264+AAC (browser/iOS-compatible) rather than yt-dlp's default VP9/Opus best.
|
||||||
# v3: mp4 compat now *selects* H.264 (avc1) with a progressive fallback + re-encodes the rare
|
# v3: mp4 compat now *selects* H.264 (avc1) with a progressive fallback + re-encodes the rare
|
||||||
# AV1/VP9-only result to H.264, so cached AV1 assets must re-derive (fixes iOS broken-player).
|
# AV1/VP9-only result to H.264, so cached AV1 assets must re-derive (fixes iOS broken-player).
|
||||||
_SIG_VERSION = 3
|
# v4: EVERY download is clamped to the mp4/H.264/AAC compat profile (no more mkv/webm/VP9/AV1/Opus
|
||||||
|
# opt-out), so any asset cached under an old non-compat spec — or a stale non-H.264 mp4 that slipped
|
||||||
|
# the v3 net — must re-derive through the current browser-safe guarantee.
|
||||||
|
_SIG_VERSION = 4
|
||||||
|
|
||||||
_SPONSORBLOCK_CATEGORIES = ["sponsor", "selfpromo", "interaction"]
|
_SPONSORBLOCK_CATEGORIES = ["sponsor", "selfpromo", "interaction"]
|
||||||
_VCODEC_SORT = {"h264": "vcodec:h264", "vp9": "vcodec:vp9", "av1": "vcodec:av01"}
|
|
||||||
|
|
||||||
|
|
||||||
def normalize(spec: dict) -> dict:
|
def normalize(spec: dict) -> dict:
|
||||||
@@ -76,6 +81,18 @@ def normalize(spec: dict) -> dict:
|
|||||||
# Audio-only files can't embed subtitles/thumbnails as video; keep the flags meaningful.
|
# Audio-only files can't embed subtitles/thumbnails as video; keep the flags meaningful.
|
||||||
if s["mode"] == "a":
|
if s["mode"] == "a":
|
||||||
s["embed_subs"] = False
|
s["embed_subs"] = False
|
||||||
|
# Browser-playability guarantee (E7): the Download module exists to download → share → PLAY
|
||||||
|
# in-browser, so every output must play in EVERY browser, iOS/Safari/WebKit included. The only
|
||||||
|
# universally-safe combo is mp4 + H.264 + AAC/MP3, so we CLAMP the spec to it HERE — the single
|
||||||
|
# point every path (picker, stored profile, raw API) flows through — instead of only hiding
|
||||||
|
# options in the UI. Non-mp4 containers (mkv doesn't play at all; webm/VP9/AV1 fail iOS) and any
|
||||||
|
# explicit vcodec are coerced to the compat profile; Opus audio (Safari-flaky as a bare file)
|
||||||
|
# falls back to AAC/m4a. A source that exposes no H.264 is still caught by the worker's
|
||||||
|
# browser-safe re-encode (`_ensure_browser_playable`), so playback is guaranteed end-to-end.
|
||||||
|
s["container"] = "mp4"
|
||||||
|
s["vcodec"] = None
|
||||||
|
if s["audio_format"] not in ("m4a", "mp3"):
|
||||||
|
s["audio_format"] = "m4a"
|
||||||
return s
|
return s
|
||||||
|
|
||||||
|
|
||||||
@@ -98,12 +115,17 @@ def build_ydl_opts(
|
|||||||
outtmpl: str,
|
outtmpl: str,
|
||||||
progress_hook,
|
progress_hook,
|
||||||
extra: dict | None = None,
|
extra: dict | None = None,
|
||||||
|
cookiefile: str | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Assemble yt-dlp options for a single download to `outtmpl` (a full path template).
|
"""Assemble yt-dlp options for a single download to `outtmpl` (a full path template).
|
||||||
|
|
||||||
`extra` is source-specific options contributed by the SourceAdapter (e.g. YouTube's
|
`extra` is source-specific options contributed by the SourceAdapter (e.g. YouTube's
|
||||||
`extractor_args` with the player-client set + bgutil PO-token base_url); it is merged onto the
|
`extractor_args` with the player-client set + bgutil PO-token base_url); it is merged onto the
|
||||||
generic options below. Everything here is source-agnostic."""
|
generic options below. Everything here is source-agnostic.
|
||||||
|
|
||||||
|
`cookiefile` is the acting user's per-host cookies.txt path (S6), attached ONLY by the worker
|
||||||
|
after the host-scope + private-asset gate in `downloads.credentials`. It is set AFTER the `extra`
|
||||||
|
merge so a source adapter can never clobber it."""
|
||||||
s = normalize(spec)
|
s = normalize(spec)
|
||||||
h = s["max_height"]
|
h = s["max_height"]
|
||||||
opts: dict = {
|
opts: dict = {
|
||||||
@@ -132,6 +154,11 @@ def build_ydl_opts(
|
|||||||
if extra:
|
if extra:
|
||||||
opts.update(extra)
|
opts.update(extra)
|
||||||
|
|
||||||
|
# The acting user's per-host cookie (S6). AFTER the adapter merge on purpose: an adapter must not
|
||||||
|
# be able to override the authenticated session the worker resolved for this specific user.
|
||||||
|
if cookiefile:
|
||||||
|
opts["cookiefile"] = cookiefile
|
||||||
|
|
||||||
if s["mode"] == "a":
|
if s["mode"] == "a":
|
||||||
opts["format"] = "bestaudio/best"
|
opts["format"] = "bestaudio/best"
|
||||||
opts["postprocessors"].append(
|
opts["postprocessors"].append(
|
||||||
@@ -143,51 +170,33 @@ def build_ydl_opts(
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
hcap = f"[height<=?{h}]" if h else ""
|
hcap = f"[height<=?{h}]" if h else ""
|
||||||
# Compatibility-first for mp4 (the universal container): produce H.264 video + AAC audio
|
# Every video download is the mp4/H.264 compat profile (normalize() clamps container→mp4,
|
||||||
# so shared/downloaded files play in EVERY browser, including iOS/Safari. WebKit (which
|
# vcodec→None), so shared/downloaded files play in EVERY browser, iOS/Safari included. WebKit
|
||||||
# every iOS browser, Brave included, is forced to use) decodes only H.264/H.265 + AAC
|
# (which every iOS browser, Brave included, is forced to use) decodes only H.264/H.265 + AAC
|
||||||
# inside mp4 — a VP9/AV1-in-mp4 file shows a broken player there, even though Chromium
|
# inside mp4 — a VP9/AV1-in-mp4 file shows a broken player there — so H.264 is selected at the
|
||||||
# plays it fine. A custom profile that explicitly sets `vcodec` opts out.
|
# SELECTION level, not merely preferred: a sort key can't conjure an H.264 stream a source
|
||||||
compat = s["container"] == "mp4" and s["vcodec"] is None
|
# doesn't expose as video-only. A source that ships only non-H.264 is caught after download by
|
||||||
|
# the worker's browser-safe re-encode (`_ensure_browser_playable`).
|
||||||
if s["mode"] == "v":
|
if s["mode"] == "v":
|
||||||
if compat:
|
# Video-only: keep it audio-free. Prefer an H.264 video-only stream, then any video-only,
|
||||||
# Video-only: keep it audio-free. Prefer an H.264 *video-only* stream, then any
|
# and only fall to a progressive (audio-bearing) `best` as a last resort.
|
||||||
# video-only, and only fall to a progressive (audio-bearing) `best` as a last
|
opts["format"] = (
|
||||||
# resort — same audio-free-when-possible guarantee as the old `bestvideo/best`.
|
f"bestvideo[vcodec^=avc1]{hcap}/bestvideo{hcap}/best[vcodec^=avc1]{hcap}/best{hcap}"
|
||||||
opts["format"] = (
|
)
|
||||||
f"bestvideo[vcodec^=avc1]{hcap}/bestvideo{hcap}"
|
|
||||||
f"/best[vcodec^=avc1]{hcap}/best{hcap}"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
opts["format"] = f"bestvideo{hcap}/best{hcap}"
|
|
||||||
else: # av
|
else: # av
|
||||||
if compat:
|
# avc1 video-only + m4a audio, then the avc1 progressive, then anything (re-encoded).
|
||||||
# Prefer H.264 at the SELECTION level, not just via format_sort: a sort key is only
|
opts["format"] = (
|
||||||
# a *preference* and can't conjure an H.264 stream a source doesn't expose as
|
f"bestvideo[vcodec^=avc1]{hcap}+bestaudio[acodec^=mp4a]"
|
||||||
# video-only. A non-YouTube source (e.g. Facebook) may ship AV1 as the best
|
f"/best[vcodec^=avc1]{hcap}"
|
||||||
# video-only stream and H.264 only as a *progressive* (a+v) format — which
|
f"/bestvideo{hcap}+bestaudio/best{hcap}"
|
||||||
# `bestvideo` skips — so `bestvideo+bestaudio` picked AV1 and iOS broke. This chain
|
)
|
||||||
# tries avc1 video-only, then the avc1 progressive, then anything.
|
opts["merge_output_format"] = "mp4"
|
||||||
opts["format"] = (
|
# Codec preference tiebreaker (ranks H.264/AAC among the selected candidates); the selector
|
||||||
f"bestvideo[vcodec^=avc1]{hcap}+bestaudio[acodec^=mp4a]"
|
# above is the hard guarantee, this is belt-and-braces.
|
||||||
f"/best[vcodec^=avc1]{hcap}"
|
sort = ["vcodec:h264", "acodec:aac"]
|
||||||
f"/bestvideo{hcap}+bestaudio/best{hcap}"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
opts["format"] = f"bestvideo{hcap}+bestaudio/best{hcap}"
|
|
||||||
if s["container"]:
|
|
||||||
opts["merge_output_format"] = s["container"]
|
|
||||||
sort = []
|
|
||||||
# Keep the codec preference as a tiebreaker (ranks H.264/AAC above resolution among the
|
|
||||||
# selected candidates); the selector above is the hard guarantee, this is belt-and-braces.
|
|
||||||
if compat:
|
|
||||||
sort += ["vcodec:h264", "acodec:aac"]
|
|
||||||
if h:
|
if h:
|
||||||
sort.append(f"res:{h}")
|
sort.append(f"res:{h}")
|
||||||
if s["vcodec"] in _VCODEC_SORT:
|
opts["format_sort"] = sort
|
||||||
sort.append(_VCODEC_SORT[s["vcodec"]])
|
|
||||||
if sort:
|
|
||||||
opts["format_sort"] = sort
|
|
||||||
|
|
||||||
# SponsorBlock first (marks segments), then strip them from the media + chapters.
|
# SponsorBlock first (marks segments), then strip them from the media + chapters.
|
||||||
if s["sponsorblock"]:
|
if s["sponsorblock"]:
|
||||||
|
|||||||
@@ -86,11 +86,25 @@ def _sweep_orphan_files(db: Session, download_root: str) -> int:
|
|||||||
|
|
||||||
cutoff = time.time() - _ORPHAN_MIN_AGE_S
|
cutoff = time.time() - _ORPHAN_MIN_AGE_S
|
||||||
removed = 0
|
removed = 0
|
||||||
|
# Throttle the whole-tree walk: this runs in the API process, and an unpaced rglob+stat over a
|
||||||
|
# large download root monopolizes disk IO long enough to make web requests + playback stutter.
|
||||||
|
# Yield the IO channel after every `batch` files touched so foreground work interleaves.
|
||||||
|
pause_s = max(0, settings.download_gc_sweep_pause_ms) / 1000.0
|
||||||
|
batch = max(1, settings.download_gc_sweep_batch)
|
||||||
|
touched = 0
|
||||||
|
|
||||||
|
def _throttle() -> None:
|
||||||
|
nonlocal touched
|
||||||
|
touched += 1
|
||||||
|
if pause_s and touched % batch == 0:
|
||||||
|
time.sleep(pause_s)
|
||||||
|
|
||||||
for entry in root.iterdir():
|
for entry in root.iterdir():
|
||||||
if entry.name.startswith("."): # .edits / .plex-* / .staging / .storyboards + any dotfile
|
if entry.name.startswith("."): # .edits / .plex-* / .staging / .storyboards + any dotfile
|
||||||
continue
|
continue
|
||||||
files = [entry] if entry.is_file() else [p for p in entry.rglob("*") if p.is_file()]
|
files = [entry] if entry.is_file() else [p for p in entry.rglob("*") if p.is_file()]
|
||||||
for f in files:
|
for f in files:
|
||||||
|
_throttle()
|
||||||
try:
|
try:
|
||||||
if f.resolve() in keep or f.stat().st_mtime > cutoff:
|
if f.resolve() in keep or f.stat().st_mtime > cutoff:
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -80,9 +80,13 @@ def public_meta(
|
|||||||
|
|
||||||
Resolves the owner's per-download display overrides (custom title/channel) over the asset's
|
Resolves the owner's per-download display overrides (custom title/channel) over the asset's
|
||||||
auto-extracted values, and surfaces the source + any extra reference links as clickable URLs."""
|
auto-extracted values, and surfaces the source + any extra reference links as clickable URLs."""
|
||||||
from app.downloads import service
|
from app.downloads import mediakind, service
|
||||||
|
|
||||||
source_url = service.reference_url(job, asset)
|
source_url = service.reference_url(job, asset)
|
||||||
|
# Audio-only assets (mp3 rips, extracted audio) can't play inside a <video> element on WebKit
|
||||||
|
# (all iOS browsers), so the watch page renders a native <audio> instead. A generic uploaded
|
||||||
|
# file (media_kind='file') has no player at all — the watch page shows file info + a download.
|
||||||
|
# Both branches read the stored media_kind (the single source), not a re-derived codec check.
|
||||||
return {
|
return {
|
||||||
"title": (job and job.display_name) or asset.title,
|
"title": (job and job.display_name) or asset.title,
|
||||||
"uploader": (job and job.display_uploader) or asset.uploader,
|
"uploader": (job and job.display_uploader) or asset.uploader,
|
||||||
@@ -92,6 +96,10 @@ def public_meta(
|
|||||||
"duration_s": asset.duration_s,
|
"duration_s": asset.duration_s,
|
||||||
"width": asset.width,
|
"width": asset.width,
|
||||||
"height": asset.height,
|
"height": asset.height,
|
||||||
|
"audio": asset.media_kind == mediakind.AUDIO,
|
||||||
|
"media_kind": asset.media_kind,
|
||||||
|
"size_bytes": asset.size_bytes,
|
||||||
|
"container": asset.container,
|
||||||
"allow_download": link.allow_download,
|
"allow_download": link.allow_download,
|
||||||
"file_url": file_url,
|
"file_url": file_url,
|
||||||
"poster_url": poster_url,
|
"poster_url": poster_url,
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
"""The media-kind discriminator for a library asset — the single home of one invariant.
|
||||||
|
|
||||||
|
`MediaAsset.media_kind` is `"video" | "audio" | "file"`. It decides which library actions apply:
|
||||||
|
media (video/audio) gets play/edit/share; a generic uploaded file gets download + share only, and
|
||||||
|
is never rendered inline (see the force-download serving in the file/watch routes). Every finalize
|
||||||
|
path computes it HERE — the download worker + the upload ingest via `derive`, the editor by
|
||||||
|
carrying the source's kind over — so the rule lives in code, not in a comment that can drift (an
|
||||||
|
earlier ad-hoc `vcodec is None and acodec is not None` audio check lived only in links.py).
|
||||||
|
"""
|
||||||
|
|
||||||
|
VIDEO = "video"
|
||||||
|
AUDIO = "audio"
|
||||||
|
FILE = "file"
|
||||||
|
|
||||||
|
|
||||||
|
def derive(vcodec: str | None, acodec: str | None) -> str:
|
||||||
|
"""Media kind from the probed codecs: a video stream -> video, audio-only -> audio, neither
|
||||||
|
(a non-media upload: pdf/zip/docx/…) -> file."""
|
||||||
|
if vcodec:
|
||||||
|
return VIDEO
|
||||||
|
if acodec:
|
||||||
|
return AUDIO
|
||||||
|
return FILE
|
||||||
@@ -8,13 +8,14 @@ from datetime import date, datetime, timedelta, timezone
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import yt_dlp
|
import yt_dlp
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
from app import sysconfig
|
from app import sysconfig
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.db import SessionLocal
|
from app.db import SessionLocal
|
||||||
from app.downloads import edit as editmod
|
from app.downloads import edit as editmod
|
||||||
from app.downloads import errors as dlerrors
|
from app.downloads import errors as dlerrors
|
||||||
from app.downloads import adapters, formats, service, storage
|
from app.downloads import adapters, credentials, formats, mediakind, service, storage
|
||||||
from app.downloads.jobdb import (
|
from app.downloads.jobdb import (
|
||||||
_edit_staging_dir,
|
_edit_staging_dir,
|
||||||
_job_status,
|
_job_status,
|
||||||
@@ -304,10 +305,13 @@ def _media_info(info: dict) -> dict:
|
|||||||
return {**info, **chosen}
|
return {**info, **chosen}
|
||||||
|
|
||||||
|
|
||||||
def _extract_with_fallback(job_id, asset_id, url, spec, staging, adapter):
|
def _extract_with_fallback(job_id, asset_id, url, spec, staging, adapter, cookiefile=None):
|
||||||
"""Download via yt-dlp using the source `adapter`'s client sets + options. For YouTube the adapter
|
"""Download via yt-dlp using the source `adapter`'s client sets + options. For YouTube the adapter
|
||||||
supplies the primary player-client set then a POT-backed fallback (retried on a bot/DRM/format
|
supplies the primary player-client set then a POT-backed fallback (retried on a bot/DRM/format
|
||||||
error); other sources make a single attempt. Returns the info dict of the successful attempt."""
|
error); other sources make a single attempt. Returns the info dict of the successful attempt.
|
||||||
|
|
||||||
|
`cookiefile` (S6) is the acting user's per-host cookies.txt, already gated by
|
||||||
|
`credentials.resolve_cookiefile`; passed to every attempt so the fallback stays authenticated."""
|
||||||
client_sets = adapter.client_sets()
|
client_sets = adapter.client_sets()
|
||||||
outtmpl = str(staging / "%(id)s.%(ext)s")
|
outtmpl = str(staging / "%(id)s.%(ext)s")
|
||||||
last_exc: Exception | None = None
|
last_exc: Exception | None = None
|
||||||
@@ -317,6 +321,7 @@ def _extract_with_fallback(job_id, asset_id, url, spec, staging, adapter):
|
|||||||
outtmpl,
|
outtmpl,
|
||||||
_make_progress_hook(job_id, asset_id),
|
_make_progress_hook(job_id, asset_id),
|
||||||
extra=adapter.ydl_opts_extra(clients),
|
extra=adapter.ydl_opts_extra(clients),
|
||||||
|
cookiefile=cookiefile,
|
||||||
)
|
)
|
||||||
opts["postprocessor_hooks"] = [_make_pp_hook(job_id)]
|
opts["postprocessor_hooks"] = [_make_pp_hook(job_id)]
|
||||||
_touch_job(job_id) # stall-watchdog heartbeat: extraction writes no progress, so a slow /
|
_touch_job(job_id) # stall-watchdog heartbeat: extraction writes no progress, so a slow /
|
||||||
@@ -381,7 +386,9 @@ def _ensure_browser_playable(job_id: int, produced: Path, spec: dict, info: dict
|
|||||||
# re-encode still shows real progress instead of a frozen 0%.
|
# re-encode still shows real progress instead of a frozen 0%.
|
||||||
total_s = int(info["duration"]) if info.get("duration") else editmod.probe_duration(produced)
|
total_s = int(info["duration"]) if info.get("duration") else editmod.probe_duration(produced)
|
||||||
editmod.run_ffmpeg(
|
editmod.run_ffmpeg(
|
||||||
editmod.build_browser_safe_cmd(produced, dest, reenc_v, reenc_a),
|
editmod.build_browser_safe_cmd(
|
||||||
|
produced, dest, reenc_v, reenc_a, threads=editmod.encode_thread_cap()
|
||||||
|
),
|
||||||
total_s=total_s,
|
total_s=total_s,
|
||||||
on_progress=_ffmpeg_progress_writer(job_id, "processing"),
|
on_progress=_ffmpeg_progress_writer(job_id, "processing"),
|
||||||
should_cancel=_cancel_poll(job_id),
|
should_cancel=_cancel_poll(job_id),
|
||||||
@@ -395,8 +402,11 @@ def _ensure_browser_playable(job_id: int, produced: Path, spec: dict, info: dict
|
|||||||
return dest
|
return dest
|
||||||
|
|
||||||
|
|
||||||
def _download(job_id: int, asset_id: int, source_kind: str, source_ref: str, spec: dict) -> None:
|
def _download(
|
||||||
|
job_id: int, asset_id: int, source_kind: str, source_ref: str, spec: dict, user_id: int
|
||||||
|
) -> None:
|
||||||
staging = _staging_dir(asset_id)
|
staging = _staging_dir(asset_id)
|
||||||
|
cookiefile: Path | None = None
|
||||||
try:
|
try:
|
||||||
if staging.exists():
|
if staging.exists():
|
||||||
shutil.rmtree(staging, ignore_errors=True)
|
shutil.rmtree(staging, ignore_errors=True)
|
||||||
@@ -405,7 +415,27 @@ def _download(job_id: int, asset_id: int, source_kind: str, source_ref: str, spe
|
|||||||
_set_job(job_id, phase="downloading", progress=0)
|
_set_job(job_id, phase="downloading", progress=0)
|
||||||
adapter = adapters.resolve_adapter(source_kind, source_ref)
|
adapter = adapters.resolve_adapter(source_kind, source_ref)
|
||||||
url = service.source_url(source_kind, source_ref)
|
url = service.source_url(source_kind, source_ref)
|
||||||
info = _media_info(_extract_with_fallback(job_id, asset_id, url, spec, staging, adapter))
|
# S6: attach the acting user's per-host cookie ONLY when it's host-scoped to this URL AND
|
||||||
|
# this asset is that user's private, salted identity (the double gate lives in credentials).
|
||||||
|
with SessionLocal() as cdb:
|
||||||
|
asset_sig = cdb.execute(
|
||||||
|
select(MediaAsset.format_sig).where(MediaAsset.id == asset_id)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if asset_sig is not None:
|
||||||
|
cookiefile = credentials.resolve_cookiefile(
|
||||||
|
cdb, user_id, url, asset_sig, spec, staging
|
||||||
|
)
|
||||||
|
info = _media_info(
|
||||||
|
_extract_with_fallback(
|
||||||
|
job_id,
|
||||||
|
asset_id,
|
||||||
|
url,
|
||||||
|
spec,
|
||||||
|
staging,
|
||||||
|
adapter,
|
||||||
|
cookiefile=str(cookiefile) if cookiefile else None,
|
||||||
|
)
|
||||||
|
)
|
||||||
# Re-resolve now that yt-dlp's info reveals the true source (a bare/redirected url only
|
# Re-resolve now that yt-dlp's info reveals the true source (a bare/redirected url only
|
||||||
# identifies post-extraction) so the .nfo uniqueid type + uploader_url agree on one source.
|
# identifies post-extraction) so the .nfo uniqueid type + uploader_url agree on one source.
|
||||||
adapter = adapters.resolve_adapter(source_kind, source_ref, info)
|
adapter = adapters.resolve_adapter(source_kind, source_ref, info)
|
||||||
@@ -454,6 +484,7 @@ def _download(job_id: int, asset_id: int, source_kind: str, source_ref: str, spe
|
|||||||
asset.acodec = (
|
asset.acodec = (
|
||||||
info.get("acodec") if info.get("acodec") not in (None, "none") else None
|
info.get("acodec") if info.get("acodec") not in (None, "none") else None
|
||||||
)
|
)
|
||||||
|
asset.media_kind = mediakind.derive(asset.vcodec, asset.acodec)
|
||||||
asset.title = meta.title
|
asset.title = meta.title
|
||||||
asset.uploader = meta.uploader
|
asset.uploader = meta.uploader
|
||||||
asset.uploader_url = adapter.uploader_url(info)
|
asset.uploader_url = adapter.uploader_url(info)
|
||||||
@@ -515,6 +546,10 @@ def _download(job_id: int, asset_id: int, source_kind: str, source_ref: str, spe
|
|||||||
db.commit()
|
db.commit()
|
||||||
log.warning("job %s failed: %s", job_id, msg)
|
log.warning("job %s failed: %s", job_id, msg)
|
||||||
finally:
|
finally:
|
||||||
|
# Unlink the decrypted cookie file explicitly (the rmtree below removes the whole staging
|
||||||
|
# tree too, but never leave a plaintext session cookie on disk a moment longer than needed).
|
||||||
|
if cookiefile is not None:
|
||||||
|
cookiefile.unlink(missing_ok=True)
|
||||||
shutil.rmtree(staging, ignore_errors=True)
|
shutil.rmtree(staging, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -122,6 +122,22 @@ def check_enqueue(db: Session, user_id: int) -> None:
|
|||||||
raise QuotaExceeded("max_bytes", lim.max_bytes, used)
|
raise QuotaExceeded("max_bytes", lim.max_bytes, used)
|
||||||
|
|
||||||
|
|
||||||
|
def check_upload(db: Session, user_id: int, add_bytes: int) -> None:
|
||||||
|
"""Like check_enqueue, but for an upload whose size is KNOWN at ingest: also reject when adding
|
||||||
|
this file would push the footprint over max_bytes. A download can't pre-check its size (unknown
|
||||||
|
until the worker finishes) so check_enqueue only compares the current footprint; an upload can,
|
||||||
|
so it's charged against the cap before it lands. Raises QuotaExceeded(max_jobs|max_bytes)."""
|
||||||
|
lim = resolve(db, user_id)
|
||||||
|
if lim.unlimited:
|
||||||
|
return
|
||||||
|
n = active_jobs(db, user_id)
|
||||||
|
if n >= lim.max_jobs:
|
||||||
|
raise QuotaExceeded("max_jobs", lim.max_jobs, n)
|
||||||
|
projected = footprint(db, user_id) + add_bytes
|
||||||
|
if projected > lim.max_bytes:
|
||||||
|
raise QuotaExceeded("max_bytes", lim.max_bytes, projected)
|
||||||
|
|
||||||
|
|
||||||
def usage(db: Session, user_id: int) -> dict:
|
def usage(db: Session, user_id: int) -> dict:
|
||||||
"""Compact usage snapshot for the UI (footprint + counts vs limits)."""
|
"""Compact usage snapshot for the UI (footprint + counts vs limits)."""
|
||||||
lim = resolve(db, user_id)
|
lim = resolve(db, user_id)
|
||||||
|
|||||||
@@ -12,16 +12,48 @@ progress hook), then exits 0. An unexpected crash exits non-zero and the parent
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
from app.db import SessionLocal
|
from app.db import SessionLocal
|
||||||
|
from app.downloads import edit as editmod
|
||||||
from app.models import DownloadJob
|
from app.models import DownloadJob
|
||||||
|
|
||||||
log = logging.getLogger("siftlode.worker.runner")
|
log = logging.getLogger("siftlode.worker.runner")
|
||||||
|
|
||||||
|
|
||||||
|
def _deprioritize() -> None:
|
||||||
|
"""Lower THIS child's CPU + IO priority so its yt-dlp/ffmpeg work — every descendant inherits the
|
||||||
|
handicap — can never starve the co-located API, Postgres, or Plex playback. Run once at startup,
|
||||||
|
before any heavy work. Every step is best-effort: a failed renice must never fail a download."""
|
||||||
|
nice_inc = settings.download_child_nice
|
||||||
|
if nice_inc > 0:
|
||||||
|
try:
|
||||||
|
os.nice(nice_inc) # relative increment; the child starts at the worker's nice (0)
|
||||||
|
except OSError as exc:
|
||||||
|
log.info("could not renice download child: %s", str(exc)[:120])
|
||||||
|
cls = settings.download_child_ionice_class
|
||||||
|
if cls > 0 and shutil.which("ionice"): # util-linux; absent on some minimal images
|
||||||
|
try:
|
||||||
|
subprocess.run(
|
||||||
|
["ionice", "-c", str(cls), "-p", str(os.getpid())],
|
||||||
|
capture_output=True,
|
||||||
|
timeout=5,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
except (OSError, subprocess.SubprocessError) as exc:
|
||||||
|
log.info("could not ionice download child: %s", str(exc)[:120])
|
||||||
|
# Cap OpenMP-parallel ffmpeg filters to the same budget as the encoder threads, so a filter
|
||||||
|
# graph can't fan out across every core behind ffmpeg's own `-threads` cap.
|
||||||
|
os.environ.setdefault("OMP_NUM_THREADS", str(editmod.encode_thread_cap()))
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
logging.basicConfig(level=logging.INFO)
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
_deprioritize()
|
||||||
if len(sys.argv) != 2:
|
if len(sys.argv) != 2:
|
||||||
print("usage: python -m app.downloads.runner <job_id>", file=sys.stderr)
|
print("usage: python -m app.downloads.runner <job_id>", file=sys.stderr)
|
||||||
raise SystemExit(2)
|
raise SystemExit(2)
|
||||||
@@ -35,6 +67,7 @@ def main() -> None:
|
|||||||
job_kind = job.job_kind
|
job_kind = job.job_kind
|
||||||
spec = dict(job.profile_snapshot or {})
|
spec = dict(job.profile_snapshot or {})
|
||||||
source_kind, source_ref = job.source_kind, job.source_ref
|
source_kind, source_ref = job.source_kind, job.source_ref
|
||||||
|
user_id = job.user_id
|
||||||
if asset_id is None:
|
if asset_id is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -45,7 +78,7 @@ def main() -> None:
|
|||||||
if job_kind == "edit":
|
if job_kind == "edit":
|
||||||
_process_edit(job_id, asset_id)
|
_process_edit(job_id, asset_id)
|
||||||
else:
|
else:
|
||||||
_download(job_id, asset_id, source_kind, source_ref, spec)
|
_download(job_id, asset_id, source_kind, source_ref, spec, user_id)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -15,8 +15,9 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.downloads import adapters
|
from app.downloads import adapters
|
||||||
|
from app.downloads import credentials
|
||||||
from app.downloads import edit as editmod
|
from app.downloads import edit as editmod
|
||||||
from app.downloads import formats, quota, storage
|
from app.downloads import formats, mediakind, quota, storage
|
||||||
from app.downloads.status import HOLDING, JobStatus
|
from app.downloads.status import HOLDING, JobStatus
|
||||||
from app.models import Channel, DownloadJob, MediaAsset, Video
|
from app.models import Channel, DownloadJob, MediaAsset, Video
|
||||||
|
|
||||||
@@ -155,7 +156,12 @@ def enqueue(
|
|||||||
Raises quota.QuotaExceeded if the user is at their job-count or footprint cap."""
|
Raises quota.QuotaExceeded if the user is at their job-count or footprint cap."""
|
||||||
quota.check_enqueue(db, user_id)
|
quota.check_enqueue(db, user_id)
|
||||||
spec = formats.normalize(spec)
|
spec = formats.normalize(spec)
|
||||||
sig = formats.format_sig(spec)
|
# If this user has a stored cookie for the download's host, salt the cache signature with their
|
||||||
|
# id so the cookie-authed result is a PRIVATE asset — never dedup-shared with, or cache-served
|
||||||
|
# to, another user (the load-bearing privacy fix; the worker attaches the cookie to exactly this
|
||||||
|
# salted identity). A normal download (no matching cookie) gets salt=None and shares as before.
|
||||||
|
salt = credentials.salt_for_enqueue(db, user_id, source_url(source_kind, source_ref))
|
||||||
|
sig = formats.format_sig(spec, salt=salt)
|
||||||
asset = get_or_create_asset(db, source_kind, source_ref, sig)
|
asset = get_or_create_asset(db, source_kind, source_ref, sig)
|
||||||
# Show a real title + thumbnail the moment it's queued (feed downloads are catalog videos).
|
# Show a real title + thumbnail the moment it's queued (feed downloads are catalog videos).
|
||||||
populate_from_catalog(db, asset)
|
populate_from_catalog(db, asset)
|
||||||
@@ -210,6 +216,10 @@ def enqueue_edit(
|
|||||||
src_asset = db.get(MediaAsset, source_job.asset_id) if source_job.asset_id else None
|
src_asset = db.get(MediaAsset, source_job.asset_id) if source_job.asset_id else None
|
||||||
if src_asset is None or src_asset.status != "ready" or not src_asset.rel_path:
|
if src_asset is None or src_asset.status != "ready" or not src_asset.rel_path:
|
||||||
raise EditError("source_not_ready")
|
raise EditError("source_not_ready")
|
||||||
|
# A generic uploaded file (media_kind='file') has no timeline to trim/crop — the editor path is
|
||||||
|
# media-only. Reject here so an upload's file never reaches the ffmpeg edit worker.
|
||||||
|
if src_asset.media_kind == mediakind.FILE:
|
||||||
|
raise EditError("not_media")
|
||||||
|
|
||||||
spec = editmod.normalize_edit_spec(edit_spec)
|
spec = editmod.normalize_edit_spec(edit_spec)
|
||||||
if not spec:
|
if not spec:
|
||||||
@@ -226,6 +236,9 @@ def enqueue_edit(
|
|||||||
asset.thumbnail_url = src_asset.thumbnail_url
|
asset.thumbnail_url = src_asset.thumbnail_url
|
||||||
asset.upload_date = src_asset.upload_date
|
asset.upload_date = src_asset.upload_date
|
||||||
asset.duration_s = editmod.clip_duration(spec, src_asset.duration_s)
|
asset.duration_s = editmod.clip_duration(spec, src_asset.duration_s)
|
||||||
|
# A clip inherits its source's kind (a video edit stays video, an audio trim stays audio);
|
||||||
|
# the ffmpeg edit worker doesn't re-probe codecs, so set it here where the source is known.
|
||||||
|
asset.media_kind = src_asset.media_kind or mediakind.VIDEO
|
||||||
|
|
||||||
job = DownloadJob(
|
job = DownloadJob(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ from datetime import date
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from xml.sax.saxutils import escape
|
from xml.sax.saxutils import escape
|
||||||
|
|
||||||
|
from app.downloads import mediakind
|
||||||
|
|
||||||
# Reserve room for the "_id.ext" suffix + parent dirs within the 255-char component limit.
|
# Reserve room for the "_id.ext" suffix + parent dirs within the 255-char component limit.
|
||||||
_MAX_TITLE = 120
|
_MAX_TITLE = 120
|
||||||
# User-facing (Content-Disposition) names can be longer than an on-disk component.
|
# User-facing (Content-Disposition) names can be longer than an on-disk component.
|
||||||
@@ -147,6 +149,81 @@ def edit_rel_path(user_id: int, asset_id: int, ext: str) -> str:
|
|||||||
return f".edits/{user_id}/clip_{asset_id}.{ext}"
|
return f".edits/{user_id}/clip_{asset_id}.{ext}"
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_upload_ext(filename: str) -> str:
|
||||||
|
"""The upload's extension reduced to a shell-safe, lowercased, bounded token (``""`` when the
|
||||||
|
upload has no extension). The SINGLE definition feeding both the on-disk suffix and the stored
|
||||||
|
`container` — they must agree (the served Content-Disposition suffix has to match the container),
|
||||||
|
so the sanitize/truncation rule lives in one place, not two drifting copies."""
|
||||||
|
return _UNSAFE.sub("", Path(filename).suffix.lstrip(".")).lower()[:16]
|
||||||
|
|
||||||
|
|
||||||
|
def upload_rel_path(user_id: int, asset_id: int, filename: str) -> str:
|
||||||
|
"""On-disk path (relative to DOWNLOAD_ROOT) for a locally-uploaded file.
|
||||||
|
|
||||||
|
Kept in a per-user `.uploads/` tree — device-downloadable via the file endpoint, but NOT part of
|
||||||
|
the Plex library (an upload is arbitrary user content, not a canonical episode; no .nfo/poster
|
||||||
|
episode sidecars). The name is system-decided and bound to the asset id (`_a{id}` keeps two
|
||||||
|
same-named uploads apart); the original filename is display metadata only. The extension is
|
||||||
|
preserved (shell-safe, bounded) so the served file + its Content-Disposition carry a correct
|
||||||
|
suffix; an extensionless upload stays extensionless."""
|
||||||
|
stem = sanitize(Path(filename).stem, 80)
|
||||||
|
ext = _clean_upload_ext(filename)
|
||||||
|
suffix = f".{ext}" if ext else ""
|
||||||
|
return f".uploads/{user_id}/{stem}_a{asset_id}{suffix}"
|
||||||
|
|
||||||
|
|
||||||
|
def upload_ext(filename: str) -> str | None:
|
||||||
|
"""The stored `container` for an uploaded file: its shell-safe lowercased extension, or None when
|
||||||
|
the upload has no extension (a bare name). Shares `_clean_upload_ext` with `upload_rel_path` so the
|
||||||
|
stored container and the on-disk suffix can never diverge."""
|
||||||
|
return _clean_upload_ext(filename) or None
|
||||||
|
|
||||||
|
|
||||||
|
# The Content-Type used to serve an inline-playable media asset, keyed by its stored `container`.
|
||||||
|
# DELIBERATELY a strict allow-list of known audio/video containers: the served type is chosen from
|
||||||
|
# the asset's ffprobe-verified media_kind + this table, NEVER guessed from the raw (user-controlled)
|
||||||
|
# upload extension. Without this, an uploaded media/HTML polyglot named "evil.html" — a real MP3/MP4
|
||||||
|
# that ffprobe classifies as media, so it dodges the generic-file force-download — would be served
|
||||||
|
# with a Content-Type Starlette guesses from ".html" (text/html) and execute as stored XSS on the
|
||||||
|
# serving origin. A media asset whose container isn't in this table falls back to a neutral
|
||||||
|
# force-download attachment (see `served_media_type`), so nothing script-executable is ever inline.
|
||||||
|
_MEDIA_MIME: dict[str, str] = {
|
||||||
|
# video containers
|
||||||
|
"mp4": "video/mp4",
|
||||||
|
"m4v": "video/mp4",
|
||||||
|
"mov": "video/quicktime",
|
||||||
|
"webm": "video/webm",
|
||||||
|
"mkv": "video/x-matroska",
|
||||||
|
"ogv": "video/ogg",
|
||||||
|
"avi": "video/x-msvideo",
|
||||||
|
# audio containers
|
||||||
|
"m4a": "audio/mp4",
|
||||||
|
"mp3": "audio/mpeg",
|
||||||
|
"aac": "audio/aac",
|
||||||
|
"ogg": "audio/ogg",
|
||||||
|
"oga": "audio/ogg",
|
||||||
|
"opus": "audio/ogg",
|
||||||
|
"weba": "audio/webm",
|
||||||
|
"wav": "audio/wav",
|
||||||
|
"flac": "audio/flac",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def served_media_type(media_kind: str | None, container: str | None) -> str | None:
|
||||||
|
"""The Content-Type a library file MUST be served with — a real audio/video MIME for a recognised
|
||||||
|
media container, else ``None`` meaning "serve as a neutral force-download attachment".
|
||||||
|
|
||||||
|
Returns None for a generic ``file`` kind AND for any media whose container we don't positively
|
||||||
|
recognise as safe. Callers serve None as ``application/octet-stream`` + ``attachment`` so the
|
||||||
|
served Content-Type is NEVER derived from the (attacker-controlled) file extension — the guard
|
||||||
|
that stops uploaded HTML/SVG or a media/HTML polyglot from executing inline on the serving
|
||||||
|
origin. A ``None`` media_kind (a legacy asset before the discriminator was backfilled) is treated
|
||||||
|
as media so an old download keeps its inline player."""
|
||||||
|
if media_kind == mediakind.FILE:
|
||||||
|
return None
|
||||||
|
return _MEDIA_MIME.get((container or "").lower())
|
||||||
|
|
||||||
|
|
||||||
def download_filename(display_name: str, container: str | None, path: Path) -> str:
|
def download_filename(display_name: str, container: str | None, path: Path) -> str:
|
||||||
"""The Content-Disposition filename for a served download: the cleaned display name carrying a single
|
"""The Content-Disposition filename for a served download: the cleaned display name carrying a single
|
||||||
correct extension — the asset container, else the file's own suffix — without doubling it when the
|
correct extension — the asset container, else the file's own suffix — without doubling it when the
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
"""Local file ingest for the download center (E7 S5) — the non-yt-dlp path into the media library.
|
||||||
|
|
||||||
|
A user-uploaded file joins the SAME library as a downloaded one, so it inherits the whole function
|
||||||
|
set already built (metadata chips, share links, and — for media — in-app play/edit). Unlike a
|
||||||
|
download it bypasses yt-dlp AND the worker entirely: the file is already on the host, so ingest
|
||||||
|
probes it, places it on disk, and creates an already-`ready` MediaAsset + `done` DownloadJob in one
|
||||||
|
step — the same "the file exists, mark it done now" shortcut `service.enqueue` takes on a cache hit.
|
||||||
|
|
||||||
|
Kinds (the invariant lives in `mediakind`): a probe with a real video stream -> video, audio-only
|
||||||
|
-> audio, neither -> a generic 'file' (pdf/zip/docx/…) that is download-only and never rendered
|
||||||
|
inline (see the force-download serving in the file/watch routes). This module is the ingest seam a
|
||||||
|
future sub-function (e.g. convert) plugs a new adapter into, mirroring how it sidesteps the worker.
|
||||||
|
|
||||||
|
An upload does NOT get an `expires_at`: it's user-provided and may be irreplaceable (there's no
|
||||||
|
source to re-download), so it doesn't age out on the retention clock the way a re-fetchable download
|
||||||
|
does — it lives until the user deletes it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import secrets
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.downloads import edit as editmod
|
||||||
|
from app.downloads import mediakind, quota, service, storage
|
||||||
|
from app.models import DownloadJob, MediaAsset
|
||||||
|
|
||||||
|
# ffprobe container formats that mean "this is a still image / image sequence", NOT playable media.
|
||||||
|
# A standalone jpg/png/gif/webp/bmp reports a real video stream (codec mjpeg/png/…) with no
|
||||||
|
# `attached_pic` flag, so without this guard it would classify as media_kind='video' and get a Play
|
||||||
|
# button + a broken in-app <video> player (and inline serving on the public share). Keyed on the
|
||||||
|
# container format so an animated gif/webp also lands as a generic 'file' (no browser plays those in
|
||||||
|
# a <video> either). SVG and anything else ffprobe can't decode already fails the probe → 'file'.
|
||||||
|
_IMAGE_FORMATS = {
|
||||||
|
"image2",
|
||||||
|
"image2pipe",
|
||||||
|
"png_pipe",
|
||||||
|
"mjpeg",
|
||||||
|
"mjpeg_pipe",
|
||||||
|
"jpeg_pipe",
|
||||||
|
"gif",
|
||||||
|
"webp_pipe",
|
||||||
|
"bmp_pipe",
|
||||||
|
"tiff_pipe",
|
||||||
|
"ico",
|
||||||
|
"svg_pipe",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _probe(path: Path) -> tuple[str | None, str | None, int | None, int | None, int | None]:
|
||||||
|
"""(vcodec, acodec, width, height, duration_s) from one ffprobe call; all None if ffprobe fails
|
||||||
|
or the file isn't playable media (an unprobeable file — or a still image — is treated as a generic
|
||||||
|
non-media upload, so it becomes a download-only 'file' rather than broken 'video').
|
||||||
|
|
||||||
|
A video stream flagged `attached_pic` (embedded cover art in an mp3/m4a) is skipped — it's a
|
||||||
|
thumbnail, not a real video track, so an audio file with album art still classifies as audio."""
|
||||||
|
data = editmod.run_ffprobe_json(path, show_streams=True, show_format=True)
|
||||||
|
if data is None:
|
||||||
|
return None, None, None, None, None
|
||||||
|
|
||||||
|
fmt = data.get("format") or {}
|
||||||
|
fmt_names = {n.strip() for n in (fmt.get("format_name") or "").split(",")}
|
||||||
|
if fmt_names & _IMAGE_FORMATS:
|
||||||
|
# A still image / image sequence is a generic file, not video — no vcodec means media_kind='file'.
|
||||||
|
return None, None, None, None, None
|
||||||
|
|
||||||
|
vcodec = acodec = None
|
||||||
|
width = height = None
|
||||||
|
for stream in data.get("streams", []):
|
||||||
|
kind = stream.get("codec_type")
|
||||||
|
if kind == "video" and vcodec is None:
|
||||||
|
if (stream.get("disposition") or {}).get("attached_pic"):
|
||||||
|
continue # cover art, not a video track
|
||||||
|
vcodec = stream.get("codec_name")
|
||||||
|
width = stream.get("width")
|
||||||
|
height = stream.get("height")
|
||||||
|
elif kind == "audio" and acodec is None:
|
||||||
|
acodec = stream.get("codec_name")
|
||||||
|
|
||||||
|
duration = None
|
||||||
|
try:
|
||||||
|
raw = fmt.get("duration")
|
||||||
|
if raw is not None:
|
||||||
|
duration = int(float(raw))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
duration = None
|
||||||
|
return vcodec, acodec, width, height, duration
|
||||||
|
|
||||||
|
|
||||||
|
def _title_from_filename(filename: str) -> str:
|
||||||
|
"""A human display title from the original upload name: the stem with separators tidied to
|
||||||
|
spaces (kept as-is otherwise — this is display metadata, not the shell-safe on-disk name)."""
|
||||||
|
stem = Path(filename).stem.replace("_", " ").replace(".", " ").strip()
|
||||||
|
return (stem or "Upload")[:255]
|
||||||
|
|
||||||
|
|
||||||
|
def ingest_upload(
|
||||||
|
db: Session,
|
||||||
|
user_id: int,
|
||||||
|
*,
|
||||||
|
filename: str,
|
||||||
|
src_path: Path,
|
||||||
|
size: int,
|
||||||
|
display_name: str | None = None,
|
||||||
|
) -> DownloadJob:
|
||||||
|
"""Ingest an already-uploaded file at `src_path` into `user_id`'s library and return the `done`
|
||||||
|
job. `src_path` is MOVED into place on success (the caller stages it to a temp file first).
|
||||||
|
|
||||||
|
Raises quota.QuotaExceeded (holdings/footprint cap) — checked BEFORE any disk write, so a
|
||||||
|
rejected upload leaves nothing behind and the caller just cleans up its temp file."""
|
||||||
|
quota.check_upload(db, user_id, size) # the file's size is known → charge it up front
|
||||||
|
|
||||||
|
vcodec, acodec, width, height, duration = _probe(src_path)
|
||||||
|
kind = mediakind.derive(vcodec, acodec)
|
||||||
|
|
||||||
|
# An upload never dedups (each file is its own asset): a random ref makes the cache key unique,
|
||||||
|
# so `format_sig` is a constant marker rather than a format hash.
|
||||||
|
ref = f"{user_id}:{secrets.token_hex(8)}"[:512]
|
||||||
|
asset = MediaAsset(
|
||||||
|
source_kind="upload", source_ref=ref, format_sig="upload", status="pending", ref_count=0
|
||||||
|
)
|
||||||
|
db.add(asset)
|
||||||
|
db.flush() # need asset.id for the on-disk path
|
||||||
|
|
||||||
|
rel = storage.upload_rel_path(user_id, asset.id, filename)
|
||||||
|
storage.place_file(src_path, settings.download_root, rel)
|
||||||
|
# From here the file is on disk under `.uploads/`. If the transaction fails before it commits, the
|
||||||
|
# asset/job rows roll back but the placed file would be ORPHANED — nothing references it, the
|
||||||
|
# per-user delete path keys off asset.rel_path (which is gone), and the GC orphan sweep skips the
|
||||||
|
# dot-prefixed `.uploads/` tree. So roll back the on-disk side too on any failure.
|
||||||
|
try:
|
||||||
|
final = storage.abs_path(settings.download_root, rel)
|
||||||
|
actual_size = final.stat().st_size if final.exists() else size
|
||||||
|
|
||||||
|
asset.status = "ready"
|
||||||
|
asset.rel_path = rel
|
||||||
|
asset.size_bytes = actual_size
|
||||||
|
asset.container = storage.upload_ext(filename)
|
||||||
|
asset.vcodec = vcodec
|
||||||
|
asset.acodec = acodec
|
||||||
|
asset.width = width
|
||||||
|
asset.height = height
|
||||||
|
asset.duration_s = duration
|
||||||
|
asset.media_kind = kind
|
||||||
|
asset.title = (display_name or "").strip() or _title_from_filename(filename)
|
||||||
|
asset.last_access_at = datetime.now(timezone.utc)
|
||||||
|
# No expires_at — see the module docstring (an upload is irreplaceable, so it doesn't age out).
|
||||||
|
if kind == mediakind.VIDEO:
|
||||||
|
# A frame poster gives the library row a thumbnail + the share link an og:image. Audio has
|
||||||
|
# no frame and a generic file no player, so neither gets one. Best-effort (ffmpeg decodes
|
||||||
|
# server side even for a codec the browser can't play).
|
||||||
|
asset.poster_path = editmod.ensure_poster(asset, settings.download_root)
|
||||||
|
|
||||||
|
job = DownloadJob(
|
||||||
|
user_id=user_id,
|
||||||
|
asset_id=asset.id,
|
||||||
|
job_kind="upload",
|
||||||
|
source_kind="upload",
|
||||||
|
source_ref=ref,
|
||||||
|
profile_snapshot={},
|
||||||
|
display_name=(display_name or "").strip() or None,
|
||||||
|
status="done",
|
||||||
|
progress=100,
|
||||||
|
queue_pos=service.next_queue_pos(db),
|
||||||
|
)
|
||||||
|
db.add(job)
|
||||||
|
service.incr_ref_count(db, asset.id)
|
||||||
|
db.commit()
|
||||||
|
except Exception:
|
||||||
|
db.rollback()
|
||||||
|
# The rows are gone; remove the now-unreferenced file so no bytes leak. Clean the poster by its
|
||||||
|
# DETERMINISTIC path (rel with a .jpg suffix, the same path ensure_poster writes to) rather than
|
||||||
|
# ensure_poster's return value: it writes the frame directly to that path and returns None on an
|
||||||
|
# ffmpeg failure, so a partial poster can be on disk while the return is None — gating on the
|
||||||
|
# return would skip that orphan. unlink is missing_ok, so the poster path is a harmless no-op
|
||||||
|
# when none was generated (audio/file, or a video whose poster never started).
|
||||||
|
for leaked in (rel, str(Path(rel).with_suffix(".jpg"))):
|
||||||
|
storage.abs_path(settings.download_root, leaked).unlink(missing_ok=True)
|
||||||
|
raise
|
||||||
|
db.refresh(job)
|
||||||
|
return job
|
||||||
@@ -551,6 +551,10 @@ class Playlist(Base, TimestampMixin, UpdatedAtMixin):
|
|||||||
# SHA-256 of name + ordered video ids as last synced from / pushed to YouTube; the
|
# SHA-256 of name + ordered video ids as last synced from / pushed to YouTube; the
|
||||||
# baseline `dirty` is derived against. NULL = no baseline yet (treated as dirty).
|
# baseline `dirty` is derived against. NULL = no baseline yet (treated as dirty).
|
||||||
synced_fingerprint: Mapped[str | None] = mapped_column(String)
|
synced_fingerprint: Mapped[str | None] = mapped_column(String)
|
||||||
|
# JSON list of the ordered video ids as of the last sync — the 3-way-merge baseline the
|
||||||
|
# two-way YouTube sync classifies each item against (local-add vs YT-add vs real delete).
|
||||||
|
# NULL = no baseline yet; the first merge then does a lossless union (never deletes).
|
||||||
|
synced_video_ids: Mapped[str | None] = mapped_column(Text)
|
||||||
position: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
position: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||||||
|
|
||||||
items: Mapped[list["PlaylistItem"]] = relationship(
|
items: Mapped[list["PlaylistItem"]] = relationship(
|
||||||
@@ -704,6 +708,12 @@ class MediaAsset(Base, TimestampMixin):
|
|||||||
container: Mapped[str | None] = mapped_column(String(16))
|
container: Mapped[str | None] = mapped_column(String(16))
|
||||||
vcodec: Mapped[str | None] = mapped_column(String(32))
|
vcodec: Mapped[str | None] = mapped_column(String(32))
|
||||||
acodec: Mapped[str | None] = mapped_column(String(32))
|
acodec: Mapped[str | None] = mapped_column(String(32))
|
||||||
|
# "video" | "audio" | "file" — the media-kind discriminator that drives which library actions
|
||||||
|
# apply (play/edit for media; download-only for a generic uploaded file). Null while an asset is
|
||||||
|
# still pending (kind is known once the file is probed at finalize). It is the single stored home
|
||||||
|
# of the invariant `downloads.mediakind.derive` used to compute — never re-derived in prose (an
|
||||||
|
# earlier ad-hoc `vcodec is None and acodec` check lived only in links.py; consolidated here).
|
||||||
|
media_kind: Mapped[str | None] = mapped_column(String(16))
|
||||||
nfo_written: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
nfo_written: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||||
# Set once the GC has warned holders of the upcoming expiry, so it doesn't warn every run.
|
# Set once the GC has warned holders of the upcoming expiry, so it doesn't warn every run.
|
||||||
gc_notified: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
gc_notified: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||||
@@ -867,6 +877,35 @@ class DownloadLink(Base, TimestampMixin):
|
|||||||
view_count: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
view_count: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||||||
|
|
||||||
|
|
||||||
|
class DownloadCredential(Base, TimestampMixin, UpdatedAtMixin):
|
||||||
|
"""A user's stored login cookies for ONE auth-walled download host (Facebook, Instagram, …).
|
||||||
|
|
||||||
|
Per-user, per-host: each user pastes their OWN browser cookies.txt (Netscape format) for a host
|
||||||
|
where they're logged in (e.g. a closed Facebook group they belong to), and a download of a
|
||||||
|
yt-dlp-supported, auth-walled link on that host is retried with the ACTING user's cookie. The
|
||||||
|
blob is Fernet-encrypted at rest (`cookies_enc`) and is NEVER returned by any API/log/audit —
|
||||||
|
the routes are write-only (host + updated_at only). See `downloads.credentials`.
|
||||||
|
|
||||||
|
Two load-bearing security controls live around this row, NOT on it:
|
||||||
|
* **cache privacy** — a cookie-authed download SALTS its MediaAsset `format_sig` with the
|
||||||
|
user id, so the authed asset is private to that user and can never be cache-served to
|
||||||
|
another user (`downloads.credentials.cookie_salt`).
|
||||||
|
* **host scope (SEC-12)** — a cookie is attached ONLY when the download URL's netloc matches
|
||||||
|
this row's `host_key` (`downloads.credentials.host_key_for_url`), so an arbitrary `?url=`
|
||||||
|
can't exfiltrate the user's session to a look-alike host.
|
||||||
|
|
||||||
|
`host_key` is a short canonical key from the supported-host registry (e.g. "facebook"), not the
|
||||||
|
raw netloc — one credential can cover several domains of the same site (facebook.com/fb.watch)."""
|
||||||
|
|
||||||
|
__tablename__ = "download_credentials"
|
||||||
|
__table_args__ = (UniqueConstraint("user_id", "host_key", name="uq_download_credential"),)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||||
|
host_key: Mapped[str] = mapped_column(String(64))
|
||||||
|
cookies_enc: Mapped[str] = mapped_column(Text) # Fernet ciphertext — never returned/logged
|
||||||
|
|
||||||
|
|
||||||
class MessageKey(Base, TimestampMixin):
|
class MessageKey(Base, TimestampMixin):
|
||||||
"""A user's end-to-end-encryption key material for direct messaging (phase 2).
|
"""A user's end-to-end-encryption key material for direct messaging (phase 2).
|
||||||
|
|
||||||
|
|||||||
@@ -439,15 +439,89 @@ def _repush_dirty(db: Session, plex: PlexClient, link: PlexLink) -> int:
|
|||||||
return n
|
return n
|
||||||
|
|
||||||
|
|
||||||
|
# How many stale in-progress movies we probe per incremental run (one metadata() call each). The set
|
||||||
|
# is normally tiny (movies started but no longer on Plex's Continue-Watching); the daily full reconcile
|
||||||
|
# is the backstop for anything beyond the cap. Most-recently-touched are probed first.
|
||||||
|
_MOVIE_PROBE_CAP = 100
|
||||||
|
|
||||||
|
|
||||||
|
def _reconcile_inprogress_movies(
|
||||||
|
db: Session, plex: PlexClient, link: PlexLink, on_deck_ids: set[int]
|
||||||
|
) -> dict[str, int]:
|
||||||
|
"""Close the movie "stuck in progress" gap that the history + onDeck feeds structurally miss.
|
||||||
|
|
||||||
|
When a movie is finished on Plex (often by scrubbing through the end credits) Plex sets
|
||||||
|
``viewCount>0``, clears ``viewOffset`` and DROPS the movie from Continue-Watching. So onDeck no
|
||||||
|
longer lists it (and onDeck can only ever ADD an in-progress row, never resolve one to watched —
|
||||||
|
see `_pull_apply`), and a scrub-to-end frequently emits no `/status/sessions/history` session row,
|
||||||
|
so the history feed misses it too. The stale local ``status='new', position>0`` row then survives
|
||||||
|
until the *daily* full reconcile. Movies are hit hardest: a movie is a single leaf with no
|
||||||
|
episode-rollup to mask the stuck row, and once watched it leaves onDeck entirely.
|
||||||
|
|
||||||
|
Fix: for each local in-progress MOVIE that Plex's onDeck no longer lists, read the item's current
|
||||||
|
per-(token-)account ``viewCount``/``viewOffset`` with one cheap `metadata()` call and apply it via
|
||||||
|
the shared `_pull_apply` — flipping to watched when Plex says so. Returns ``{"watched", "in_progress",
|
||||||
|
"probed"}`` counts. Episodes are intentionally left to onDeck/history (they promote the next episode
|
||||||
|
and aren't affected). Note: like the rest of this job, `metadata()` reads the TOKEN account (the
|
||||||
|
owner) — a HTPC playing under a managed Plex user (accountID>1) is out of scope here as elsewhere."""
|
||||||
|
conds = [
|
||||||
|
PlexState.user_id == link.user_id,
|
||||||
|
PlexState.status.notin_(("watched", "hidden")),
|
||||||
|
PlexState.position_seconds > _PROGRESS_MIN_S,
|
||||||
|
PlexItem.kind == "movie",
|
||||||
|
]
|
||||||
|
if on_deck_ids:
|
||||||
|
conds.append(PlexState.item_id.notin_(on_deck_ids))
|
||||||
|
rows = db.execute(
|
||||||
|
select(PlexState, PlexItem.rating_key)
|
||||||
|
.join(PlexItem, PlexItem.id == PlexState.item_id)
|
||||||
|
.where(*conds)
|
||||||
|
.order_by(PlexState.progress_updated_at.desc().nullslast())
|
||||||
|
.limit(_MOVIE_PROBE_CAP + 1)
|
||||||
|
).all()
|
||||||
|
if len(rows) > _MOVIE_PROBE_CAP:
|
||||||
|
rows = rows[:_MOVIE_PROBE_CAP]
|
||||||
|
log.warning(
|
||||||
|
"in-progress movie probe hit the %d cap (user %s); reconcile catches the tail",
|
||||||
|
_MOVIE_PROBE_CAP,
|
||||||
|
link.user_id,
|
||||||
|
)
|
||||||
|
out = {"watched": 0, "in_progress": 0, "probed": 0}
|
||||||
|
for st, rk in rows:
|
||||||
|
try:
|
||||||
|
meta = plex.metadata(rk)
|
||||||
|
except PlexError as e:
|
||||||
|
log.warning(
|
||||||
|
"movie watch-probe failed (user %s, item %s): %s", link.user_id, st.item_id, e
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
out["probed"] += 1
|
||||||
|
if meta is None:
|
||||||
|
continue
|
||||||
|
res = _plex_watch_to_state(
|
||||||
|
meta.get("viewCount"), meta.get("viewOffset"), meta.get("lastViewedAt")
|
||||||
|
)
|
||||||
|
if res is None:
|
||||||
|
# Plex holds no watch signal now (viewCount 0, no offset) — don't clear it here; an un-watch
|
||||||
|
# is the daily reconcile's job, and a Siftlode-only in-progress row must be preserved.
|
||||||
|
continue
|
||||||
|
status, pos, watched_at, prog_at = res
|
||||||
|
ch = _pull_apply(db, link.user_id, st.item_id, status, pos, watched_at or prog_at)
|
||||||
|
if ch:
|
||||||
|
out[ch] += 1
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def run_plex_watch_sync(db: Session) -> dict:
|
def run_plex_watch_sync(db: Session) -> dict:
|
||||||
"""Scheduler job (default 15 min) — the cheap incremental pass. Pulls Plex-side changes since the
|
"""Scheduler job (default 15 min) — the cheap incremental pass. Pulls Plex-side changes since the
|
||||||
high-water mark (watch history + on-deck, filtered to the owner account) into Siftlode, resolving a
|
high-water mark (watch history + on-deck, filtered to the owner account) into Siftlode, resolving a
|
||||||
divergence to Plex (the source of truth — see `_pull_apply`), then re-pushes any still-unsynced
|
divergence to Plex (the source of truth — see `_pull_apply`), reconciles stale in-progress movies
|
||||||
|
Plex has since finished (see `_reconcile_inprogress_movies`), then re-pushes any still-unsynced
|
||||||
local states. Does NOT propagate un-watches (the history feed only carries positive views) — that's
|
local states. Does NOT propagate un-watches (the history feed only carries positive views) — that's
|
||||||
`run_plex_watch_reconcile`'s job."""
|
`run_plex_watch_reconcile`'s job."""
|
||||||
if not sysconfig.get_bool(db, "plex_enabled"):
|
if not sysconfig.get_bool(db, "plex_enabled"):
|
||||||
return {"skipped": "disabled"}
|
return {"skipped": "disabled"}
|
||||||
total = {"links": 0, "watched": 0, "in_progress": 0, "repushed": 0}
|
total = {"links": 0, "watched": 0, "in_progress": 0, "repushed": 0, "movie_probes": 0}
|
||||||
for link in _active_sync_links(db):
|
for link in _active_sync_links(db):
|
||||||
total["links"] += 1
|
total["links"] += 1
|
||||||
try:
|
try:
|
||||||
@@ -467,10 +541,14 @@ def run_plex_watch_sync(db: Session) -> dict:
|
|||||||
if ch:
|
if ch:
|
||||||
total[ch] += 1
|
total[ch] += 1
|
||||||
|
|
||||||
|
# onDeck resolves resume-position changes AND tells us which items Plex still considers
|
||||||
|
# in-progress (needed by the movie reconcile below to know which local rows are stale).
|
||||||
|
on_deck_ids: set[int] = set()
|
||||||
for row in plex.on_deck():
|
for row in plex.on_deck():
|
||||||
iid = rk_to_id.get(str(row.get("ratingKey") or ""))
|
iid = rk_to_id.get(str(row.get("ratingKey") or ""))
|
||||||
if iid is None:
|
if iid is None:
|
||||||
continue
|
continue
|
||||||
|
on_deck_ids.add(iid)
|
||||||
off = int(row.get("viewOffset") or 0) // 1000
|
off = int(row.get("viewOffset") or 0) // 1000
|
||||||
if off < _PROGRESS_MIN_S:
|
if off < _PROGRESS_MIN_S:
|
||||||
continue
|
continue
|
||||||
@@ -480,6 +558,11 @@ def run_plex_watch_sync(db: Session) -> dict:
|
|||||||
if ch:
|
if ch:
|
||||||
total[ch] += 1
|
total[ch] += 1
|
||||||
|
|
||||||
|
mv = _reconcile_inprogress_movies(db, plex, link, on_deck_ids)
|
||||||
|
total["watched"] += mv["watched"]
|
||||||
|
total["in_progress"] += mv["in_progress"]
|
||||||
|
total["movie_probes"] += mv["probed"]
|
||||||
|
|
||||||
total["repushed"] += _repush_dirty(db, plex, link)
|
total["repushed"] += _repush_dirty(db, plex, link)
|
||||||
link.last_watch_sync_at = now
|
link.last_watch_sync_at = now
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|||||||
@@ -6,23 +6,28 @@ worker is a separate process); this module never downloads — it enqueues, mana
|
|||||||
serves finished files (range-aware, with the user's custom display name), and shares them.
|
serves finished files (range-aware, with the user's custom display name), and shares them.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
|
import tempfile
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
from urllib.parse import parse_qs, urlparse
|
from urllib.parse import parse_qs, urlparse
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app import sysconfig
|
from app import audit, sysconfig
|
||||||
from app.auth import admin_user, require_human
|
from app.auth import admin_user, require_human
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
|
from app.downloads import credentials as dlcreds
|
||||||
from app.downloads import edit as editmod
|
from app.downloads import edit as editmod
|
||||||
from app.downloads import links as linksmod
|
from app.downloads import links as linksmod
|
||||||
from app.downloads import quota, service, storage
|
from app.downloads import quota, service, storage
|
||||||
|
from app.downloads import upload as uploadmod
|
||||||
from app.models import (
|
from app.models import (
|
||||||
DownloadJob,
|
DownloadJob,
|
||||||
DownloadLink,
|
DownloadLink,
|
||||||
@@ -122,6 +127,9 @@ def _serialize(job: DownloadJob, asset: MediaAsset | None) -> dict:
|
|||||||
"width": asset.width,
|
"width": asset.width,
|
||||||
"height": asset.height,
|
"height": asset.height,
|
||||||
"container": asset.container,
|
"container": asset.container,
|
||||||
|
# "video" | "audio" | "file" — the library gates play/edit on this (media only); a
|
||||||
|
# generic uploaded file is download-only. Null while still in flight (set at finalize).
|
||||||
|
"media_kind": asset.media_kind,
|
||||||
"uploader_url": asset.uploader_url,
|
"uploader_url": asset.uploader_url,
|
||||||
"thumbnail_url": asset.thumbnail_url,
|
"thumbnail_url": asset.thumbnail_url,
|
||||||
"expires_at": iso(asset.expires_at),
|
"expires_at": iso(asset.expires_at),
|
||||||
@@ -247,6 +255,65 @@ def delete_profile(
|
|||||||
return {"deleted": profile_id}
|
return {"deleted": profile_id}
|
||||||
|
|
||||||
|
|
||||||
|
# --- per-user download auth (cookies) ------------------------------------------------------
|
||||||
|
# Write-only surface: the stored blob is NEVER returned — only the supported hosts + whether each
|
||||||
|
# is configured + when. See downloads.credentials for the host-scope + cache-privacy guarantees.
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/credentials")
|
||||||
|
def list_credentials(user: User = Depends(require_human), db: Session = Depends(get_db)) -> dict:
|
||||||
|
return {"hosts": dlcreds.status_list(db, user.id)}
|
||||||
|
|
||||||
|
|
||||||
|
class SetCredentialIn(BaseModel):
|
||||||
|
cookies: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/credentials/{host_key}")
|
||||||
|
def set_credential(
|
||||||
|
host_key: str,
|
||||||
|
body: SetCredentialIn,
|
||||||
|
user: User = Depends(require_human),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
"""Store (encrypted) this user's cookies.txt for one supported host. Write-only: the value is
|
||||||
|
never echoed back, logged, or audited — only the fact that it changed."""
|
||||||
|
if not dlcreds.is_supported_host(host_key):
|
||||||
|
raise HTTPException(status_code=404, detail="Unknown host")
|
||||||
|
try:
|
||||||
|
cleaned = dlcreds.validate_cookies(body.cookies)
|
||||||
|
except dlcreds.InvalidCookies as e:
|
||||||
|
raise HTTPException(status_code=400, detail={"code": "cookies", "reason": str(e)})
|
||||||
|
dlcreds.upsert_credential(db, user.id, host_key, cleaned)
|
||||||
|
audit.record(
|
||||||
|
db,
|
||||||
|
user.id,
|
||||||
|
audit.AuditAction.DOWNLOAD_CREDENTIAL_SET,
|
||||||
|
target_type="download_credential",
|
||||||
|
target_id=host_key,
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True, "hosts": dlcreds.status_list(db, user.id)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/credentials/{host_key}")
|
||||||
|
def delete_credential(
|
||||||
|
host_key: str, user: User = Depends(require_human), db: Session = Depends(get_db)
|
||||||
|
) -> dict:
|
||||||
|
if not dlcreds.is_supported_host(host_key):
|
||||||
|
raise HTTPException(status_code=404, detail="Unknown host")
|
||||||
|
if dlcreds.delete_credential(db, user.id, host_key):
|
||||||
|
audit.record(
|
||||||
|
db,
|
||||||
|
user.id,
|
||||||
|
audit.AuditAction.DOWNLOAD_CREDENTIAL_DELETE,
|
||||||
|
target_type="download_credential",
|
||||||
|
target_id=host_key,
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True, "hosts": dlcreds.status_list(db, user.id)}
|
||||||
|
|
||||||
|
|
||||||
def _resolve_spec(
|
def _resolve_spec(
|
||||||
db: Session, user: User, profile_id: int | None, spec: dict | None
|
db: Session, user: User, profile_id: int | None, spec: dict | None
|
||||||
) -> tuple[dict, int | None]:
|
) -> tuple[dict, int | None]:
|
||||||
@@ -294,6 +361,61 @@ def enqueue_download(
|
|||||||
return _serialize_job(db, job)
|
return _serialize_job(db, job)
|
||||||
|
|
||||||
|
|
||||||
|
# Read the upload in 1 MiB chunks so a large file never sits fully in memory, and the size cap is
|
||||||
|
# enforced on the ACTUAL bytes read (a client-set Content-Length can't be trusted to gate it).
|
||||||
|
_UPLOAD_CHUNK = 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/upload")
|
||||||
|
def upload_download(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
display_name: str | None = Form(None),
|
||||||
|
user: User = Depends(require_human),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
"""Ingest a local file into the caller's library (E7 S5). Any file type is accepted; media
|
||||||
|
(video/audio) gets the full function set, a generic file is download-only. The file is streamed
|
||||||
|
to a temp path under the download volume (enforcing the per-file cap as it goes), then handed to
|
||||||
|
the non-yt-dlp ingest which probes + places it and returns an already-`done` job.
|
||||||
|
|
||||||
|
A sync (threadpool) handler on purpose: the ingest does blocking work — an ffprobe of up to 30s
|
||||||
|
plus sync DB writes — so it must NOT run on the event loop. `file.file` is the sync file object
|
||||||
|
Starlette spools the multipart body into."""
|
||||||
|
cap = sysconfig.get_int(db, "download_upload_max_bytes")
|
||||||
|
staging = Path(settings.download_root) / ".staging" / "uploads"
|
||||||
|
staging.mkdir(parents=True, exist_ok=True)
|
||||||
|
fd, tmp = tempfile.mkstemp(dir=str(staging))
|
||||||
|
tmp_path = Path(tmp)
|
||||||
|
try:
|
||||||
|
size = 0
|
||||||
|
with os.fdopen(fd, "wb") as out:
|
||||||
|
while chunk := file.file.read(_UPLOAD_CHUNK):
|
||||||
|
size += len(chunk)
|
||||||
|
if cap and size > cap:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422, detail={"code": "upload_too_large", "limit": cap}
|
||||||
|
)
|
||||||
|
out.write(chunk)
|
||||||
|
if size == 0:
|
||||||
|
raise HTTPException(status_code=400, detail={"code": "upload_empty"})
|
||||||
|
try:
|
||||||
|
job = uploadmod.ingest_upload(
|
||||||
|
db,
|
||||||
|
user.id,
|
||||||
|
filename=file.filename or "upload",
|
||||||
|
src_path=tmp_path,
|
||||||
|
size=size,
|
||||||
|
display_name=(display_name or "").strip() or None,
|
||||||
|
)
|
||||||
|
except quota.QuotaExceeded as e:
|
||||||
|
raise HTTPException(status_code=422, detail=_quota_detail(e))
|
||||||
|
return _serialize_job(db, job)
|
||||||
|
finally:
|
||||||
|
# Ingest MOVES the temp file into place on success (unlink is then a no-op); on any early exit
|
||||||
|
# — cap hit, empty, quota, probe/move error — this removes the abandoned staging file.
|
||||||
|
tmp_path.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
class EnqueueEditIn(BaseModel):
|
class EnqueueEditIn(BaseModel):
|
||||||
source_job_id: int = 0
|
source_job_id: int = 0
|
||||||
edit_spec: dict
|
edit_spec: dict
|
||||||
@@ -484,8 +606,10 @@ def redownload_download(
|
|||||||
never deletes a file another job/user still shares: the old asset is only removed if THIS job
|
never deletes a file another job/user still shares: the old asset is only removed if THIS job
|
||||||
was its last holder."""
|
was its last holder."""
|
||||||
job = _own_job(db, user, job_id)
|
job = _own_job(db, user, job_id)
|
||||||
if job.job_kind == "edit":
|
if job.job_kind in ("edit", "upload"):
|
||||||
raise HTTPException(status_code=409, detail="Editor clips can't be re-downloaded.")
|
# Neither has a re-fetchable source: an editor clip is derived locally, an upload IS the
|
||||||
|
# source. Re-running yt-dlp against source_kind='upload'/'edit' would just fail.
|
||||||
|
raise HTTPException(status_code=409, detail="This item can't be re-downloaded.")
|
||||||
if job.status in ("queued", "running", "paused"):
|
if job.status in ("queued", "running", "paused"):
|
||||||
raise HTTPException(status_code=409, detail="This download is still in progress.")
|
raise HTTPException(status_code=409, detail="This download is still in progress.")
|
||||||
service.redownload(db, job)
|
service.redownload(db, job)
|
||||||
@@ -550,8 +674,21 @@ def download_file(
|
|||||||
|
|
||||||
asset.last_access_at = datetime.now(timezone.utc)
|
asset.last_access_at = datetime.now(timezone.utc)
|
||||||
db.commit()
|
db.commit()
|
||||||
# Starlette's FileResponse honours the Range header (206 partial content) for seek/resume.
|
# The served Content-Type comes from the ffprobe-verified media_kind + a container allow-list
|
||||||
return FileResponse(path, filename=filename)
|
# (storage.served_media_type), NEVER guessed from the file's own extension. A generic 'file' (and
|
||||||
|
# any media whose container we don't recognise — e.g. a media/HTML polyglot named "evil.html")
|
||||||
|
# returns None → a neutral force-download attachment, so uploaded HTML/SVG can't execute inline on
|
||||||
|
# this origin. Recognised media is served with its real type so the in-app player streams it. Both
|
||||||
|
# are range-aware (FileResponse → 206).
|
||||||
|
media_type = storage.served_media_type(asset.media_kind, asset.container)
|
||||||
|
if media_type is None:
|
||||||
|
return FileResponse(
|
||||||
|
path,
|
||||||
|
filename=filename,
|
||||||
|
media_type="application/octet-stream",
|
||||||
|
content_disposition_type="attachment",
|
||||||
|
)
|
||||||
|
return FileResponse(path, filename=filename, media_type=media_type)
|
||||||
|
|
||||||
|
|
||||||
# --- editor filmstrip (scrub track) --------------------------------------------------------
|
# --- editor filmstrip (scrub track) --------------------------------------------------------
|
||||||
@@ -763,7 +900,12 @@ def list_links(
|
|||||||
|
|
||||||
class CreateLinkIn(BaseModel):
|
class CreateLinkIn(BaseModel):
|
||||||
password: str = ""
|
password: str = ""
|
||||||
allow_download: bool = False
|
# D1 (E7 S3): a newly shared download is downloadable by default — the common intent is "let the
|
||||||
|
# recipient have the file", not stream-only. Forward-only: this only sets the default for links
|
||||||
|
# minted from now on; existing links keep whatever `allow_download` they were created with (the
|
||||||
|
# column default stays False, so no back-catalogue link silently flips). The owner can still
|
||||||
|
# uncheck it in the Share dialog, and toggle any link stream-only afterwards.
|
||||||
|
allow_download: bool = True
|
||||||
expires_days: int | None = None
|
expires_days: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ from app.db import get_db
|
|||||||
from app.feedquery import feed_columns, serialize
|
from app.feedquery import feed_columns, serialize
|
||||||
from app.models import Channel, Playlist, PlaylistItem, User, Video, VideoState
|
from app.models import Channel, Playlist, PlaylistItem, User, Video, VideoState
|
||||||
from app.sync.playlists import (
|
from app.sync.playlists import (
|
||||||
plan_push,
|
merge_playlist,
|
||||||
push_playlist,
|
plan_merge,
|
||||||
recompute_dirty,
|
recompute_dirty,
|
||||||
repull_playlist,
|
repull_playlist,
|
||||||
sync_user_playlists,
|
sync_user_playlists,
|
||||||
@@ -329,13 +329,13 @@ def push_plan(
|
|||||||
user: User = Depends(require_write_scope),
|
user: User = Depends(require_write_scope),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Dry run: what 'Sync to YouTube' would do + the quota estimate, so the UI can show a
|
"""Dry run: what a two-way 'Sync to YouTube' merge would do (what each side gains/loses) +
|
||||||
confirm. For a linked playlist this reads the live YouTube state (cheap, 1 unit/page).
|
the quota estimate, so the UI can show a confirm. For a linked playlist this reads the live
|
||||||
Write scope is required (require_write_scope) — syncing is a YouTube write."""
|
YouTube state (cheap, 1 unit/page). Write scope is required — syncing is a YouTube write."""
|
||||||
pl = _pushable(db, user, playlist_id)
|
pl = _pushable(db, user, playlist_id)
|
||||||
try:
|
try:
|
||||||
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PUSH):
|
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PUSH):
|
||||||
plan = plan_push(db, user, pl)
|
plan = plan_merge(db, user, pl)
|
||||||
except YouTubeError:
|
except YouTubeError:
|
||||||
raise HTTPException(status_code=422, detail="Couldn't read the playlist on YouTube.")
|
raise HTTPException(status_code=422, detail="Couldn't read the playlist on YouTube.")
|
||||||
plan["remaining_today"] = quota.remaining_today(db)
|
plan["remaining_today"] = quota.remaining_today(db)
|
||||||
@@ -349,14 +349,14 @@ def push(
|
|||||||
user: User = Depends(require_write_scope),
|
user: User = Depends(require_write_scope),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Push a local playlist to YouTube (create it on first push, else reconcile to match
|
"""Two-way sync a local playlist with YouTube (create it on first push, else 3-way merge so
|
||||||
local — local wins). Refuses if the estimated quota exceeds what's left today, so a
|
neither side loses an item). Refuses if the estimated quota exceeds what's left today, so a
|
||||||
big playlist can't strand half-pushed. Write scope required (require_write_scope)."""
|
big sync can't strand half-applied. Write scope required (require_write_scope)."""
|
||||||
pl = _pushable(db, user, playlist_id)
|
pl = _pushable(db, user, playlist_id)
|
||||||
try:
|
try:
|
||||||
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PUSH):
|
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PUSH):
|
||||||
# Read the live playlist ONCE (linked playlists only) and share it with both the
|
# Read the live playlist ONCE (linked playlists only) and share it with both the
|
||||||
# plan and the push, halving read-quota and closing the second read→write TOCTOU
|
# plan and the merge, halving read-quota and closing the second read→write TOCTOU
|
||||||
# window. A fresh export (no yt_playlist_id) reads nothing.
|
# window. A fresh export (no yt_playlist_id) reads nothing.
|
||||||
live = None
|
live = None
|
||||||
if pl.yt_playlist_id:
|
if pl.yt_playlist_id:
|
||||||
@@ -365,13 +365,13 @@ def push(
|
|||||||
list(yt.iter_playlist_items_with_ids(pl.yt_playlist_id)),
|
list(yt.iter_playlist_items_with_ids(pl.yt_playlist_id)),
|
||||||
yt.get_playlist_snippet(pl.yt_playlist_id),
|
yt.get_playlist_snippet(pl.yt_playlist_id),
|
||||||
)
|
)
|
||||||
plan = plan_push(db, user, pl, live=live)
|
plan = plan_merge(db, user, pl, live=live)
|
||||||
if plan["units_estimate"] > quota.remaining_today(db):
|
if plan["units_estimate"] > quota.remaining_today(db):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=429,
|
status_code=429,
|
||||||
detail="Not enough YouTube quota left today for this sync.",
|
detail="Not enough YouTube quota left today for this sync.",
|
||||||
)
|
)
|
||||||
return push_playlist(db, user, pl, live=live)
|
return merge_playlist(db, user, pl, live=live)
|
||||||
except YouTubeError:
|
except YouTubeError:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
raise HTTPException(status_code=422, detail="YouTube playlist sync failed.")
|
raise HTTPException(status_code=422, detail="YouTube playlist sync failed.")
|
||||||
|
|||||||
@@ -122,9 +122,25 @@ def watch_file(token: str, g: str | None = None, db: Session = Depends(get_db)):
|
|||||||
raise HTTPException(status_code=404, detail="This video is no longer available.")
|
raise HTTPException(status_code=404, detail="This video is no longer available.")
|
||||||
|
|
||||||
filename = storage.download_filename(asset.title or "video", asset.container, path)
|
filename = storage.download_filename(asset.title or "video", asset.container, path)
|
||||||
|
# The served Content-Type comes from the asset's ffprobe-verified media_kind + a container
|
||||||
|
# allow-list (storage.served_media_type) — NEVER guessed from the file's own extension. A generic
|
||||||
|
# 'file' (and any media whose container we don't positively recognise, e.g. a media/HTML polyglot
|
||||||
|
# named "evil.html") returns None → a neutral force-download attachment, so arbitrary uploaded
|
||||||
|
# HTML/SVG can't execute inline on this login-free public origin. A recognised media container is
|
||||||
|
# served with its real audio/video type, inline unless the link forces a download so it can play
|
||||||
|
# in the watch page. Both are range-aware (FileResponse → 206) for seeking/resume.
|
||||||
|
media_type = storage.served_media_type(asset.media_kind, asset.container)
|
||||||
|
if media_type is None:
|
||||||
|
return FileResponse(
|
||||||
|
path,
|
||||||
|
filename=filename,
|
||||||
|
media_type="application/octet-stream",
|
||||||
|
content_disposition_type="attachment",
|
||||||
|
)
|
||||||
disposition = "attachment" if link.allow_download else "inline"
|
disposition = "attachment" if link.allow_download else "inline"
|
||||||
# Starlette's FileResponse honours the Range header (206) for seeking/scrubbing.
|
return FileResponse(
|
||||||
return FileResponse(path, filename=filename, content_disposition_type=disposition)
|
path, filename=filename, media_type=media_type, content_disposition_type=disposition
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/watch/{token}/poster.jpg")
|
@router.get("/watch/{token}/poster.jpg")
|
||||||
|
|||||||
+240
-71
@@ -1,14 +1,16 @@
|
|||||||
"""YouTube playlist sync, both directions. Read: mirror each user's own YouTube playlists
|
"""YouTube playlist sync, both directions. Read: mirror each user's own YouTube playlists
|
||||||
into local `source='youtube'` playlists (kept fresh by the scheduler). Write-back: push local
|
into local `source='youtube'` playlists (kept fresh by the scheduler). Write-back: two-way sync
|
||||||
edits out to YouTube (`plan_push` / `push_playlist`), behind the optional write scope.
|
local edits with YouTube (`plan_merge` / `merge_playlist`), behind the optional write scope.
|
||||||
|
|
||||||
YouTube's special Watch Later / History playlists are not exposed by the Data API and are
|
YouTube's special Watch Later / History playlists are not exposed by the Data API and are
|
||||||
never synced. Videos in a playlist that aren't in our shared catalog yet are fetched and
|
never synced. Videos in a playlist that aren't in our shared catalog yet are fetched and
|
||||||
ingested (with a stub channel) so the mirror is faithful."""
|
ingested (with a stub channel) so the mirror is faithful."""
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
|
from dataclasses import dataclass
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import TypeVar
|
from typing import TypeVar
|
||||||
|
|
||||||
@@ -42,6 +44,57 @@ def current_fingerprint(db: Session, pl: Playlist) -> str:
|
|||||||
return fingerprint(pl.name, _local_video_ids(db, pl))
|
return fingerprint(pl.name, _local_video_ids(db, pl))
|
||||||
|
|
||||||
|
|
||||||
|
def _dump_ids(ids: list[str]) -> str:
|
||||||
|
"""Serialize the ordered id list stored as the 3-way-merge baseline (`synced_video_ids`)."""
|
||||||
|
return json.dumps(ids)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_baseline(pl: Playlist) -> tuple[list[str] | None, set[str]]:
|
||||||
|
"""Parse `synced_video_ids` into (ordered id set, un-ingestable id set). Two on-disk forms:
|
||||||
|
a plain JSON list (legacy / no un-ingestable ids) or `{"ids": [...], "unavailable": [...]}`.
|
||||||
|
`unavailable` = YouTube-only videos that exist on YouTube but the Data API won't return
|
||||||
|
(private/deleted/region-locked), so they never became local rows — tracked so the merge can
|
||||||
|
skip them instead of forever re-classifying them as a YouTube-add (they'd never converge)."""
|
||||||
|
if not pl.synced_video_ids:
|
||||||
|
return None, set()
|
||||||
|
try:
|
||||||
|
val = json.loads(pl.synced_video_ids)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return None, set()
|
||||||
|
if isinstance(val, list):
|
||||||
|
return [str(v) for v in val], set()
|
||||||
|
if isinstance(val, dict):
|
||||||
|
raw_ids = val.get("ids")
|
||||||
|
ids = [str(v) for v in raw_ids] if isinstance(raw_ids, list) else None
|
||||||
|
raw_un = val.get("unavailable")
|
||||||
|
un = {str(v) for v in raw_un} if isinstance(raw_un, list) else set()
|
||||||
|
return ids, un
|
||||||
|
return None, set()
|
||||||
|
|
||||||
|
|
||||||
|
def parse_baseline(pl: Playlist) -> list[str] | None:
|
||||||
|
"""The last-synced ordered id set, or None when no baseline has been recorded yet (a fresh
|
||||||
|
export, or a link created before S7). None makes the first merge union losslessly."""
|
||||||
|
return _load_baseline(pl)[0]
|
||||||
|
|
||||||
|
|
||||||
|
def parse_unavailable(pl: Playlist) -> set[str]:
|
||||||
|
"""The last-synced set of un-ingestable-but-live YouTube ids (see `_load_baseline`)."""
|
||||||
|
return _load_baseline(pl)[1]
|
||||||
|
|
||||||
|
|
||||||
|
def _set_baseline(pl: Playlist, ids: list[str], unavailable: list[str] | None = None) -> None:
|
||||||
|
"""Record `ids` as both the hash (drives `dirty`, over the local ids only) and the raw ordered
|
||||||
|
set (drives merge). `unavailable` = un-ingestable-but-live YouTube ids to carry forward so the
|
||||||
|
next merge skips them; stored only when non-empty (keeps the common case a plain list)."""
|
||||||
|
pl.synced_fingerprint = fingerprint(pl.name, ids)
|
||||||
|
if unavailable:
|
||||||
|
pl.synced_video_ids = json.dumps({"ids": ids, "unavailable": list(unavailable)})
|
||||||
|
else:
|
||||||
|
pl.synced_video_ids = _dump_ids(ids)
|
||||||
|
pl.dirty = False
|
||||||
|
|
||||||
|
|
||||||
def recompute_dirty(db: Session, pl: Playlist) -> None:
|
def recompute_dirty(db: Session, pl: Playlist) -> None:
|
||||||
"""Set `dirty` by comparing the current state to the synced baseline. Unlinked lists
|
"""Set `dirty` by comparing the current state to the synced baseline. Unlinked lists
|
||||||
(and Watch later) are never dirty; a missing baseline counts as dirty (unknown YT state)."""
|
(and Watch later) are never dirty; a missing baseline counts as dirty (unknown YT state)."""
|
||||||
@@ -116,12 +169,18 @@ def _replace_items_from_youtube(
|
|||||||
if v in present and v not in seen:
|
if v in present and v not in seen:
|
||||||
seen.add(v)
|
seen.add(v)
|
||||||
ordered.append(v)
|
ordered.append(v)
|
||||||
|
_set_local_items(db, pl, ordered)
|
||||||
|
# After a pull/reset local == YouTube, so this ordered set IS the new merge baseline.
|
||||||
|
_set_baseline(pl, ordered)
|
||||||
|
return ordered
|
||||||
|
|
||||||
|
|
||||||
|
def _set_local_items(db: Session, pl: Playlist, ordered: list[str]) -> None:
|
||||||
|
"""Authoritatively replace `pl`'s local items with `ordered` (positions 0..n). The caller
|
||||||
|
is responsible for having ensured every id exists in the catalog."""
|
||||||
db.execute(delete(PlaylistItem).where(PlaylistItem.playlist_id == pl.id))
|
db.execute(delete(PlaylistItem).where(PlaylistItem.playlist_id == pl.id))
|
||||||
for pos, vid in enumerate(ordered):
|
for pos, vid in enumerate(ordered):
|
||||||
db.add(PlaylistItem(playlist_id=pl.id, video_id=vid, position=pos))
|
db.add(PlaylistItem(playlist_id=pl.id, video_id=vid, position=pos))
|
||||||
pl.synced_fingerprint = fingerprint(pl.name, ordered)
|
|
||||||
pl.dirty = False
|
|
||||||
return ordered
|
|
||||||
|
|
||||||
|
|
||||||
def sync_user_playlists(db: Session, user: User) -> dict:
|
def sync_user_playlists(db: Session, user: User) -> dict:
|
||||||
@@ -216,22 +275,91 @@ def _reorder_moves(current: list[str], desired: list[str]) -> int:
|
|||||||
return moves
|
return moves
|
||||||
|
|
||||||
|
|
||||||
def plan_push(db: Session, user: User, pl: Playlist, *, live=None) -> dict:
|
@dataclass
|
||||||
"""Compute (without writing) what pushing this local playlist to YouTube would do:
|
class MergePlan:
|
||||||
create vs update, how many inserts/deletes/reorders, the quota estimate, and whether
|
"""The 3-way merge of a linked playlist: what each side gains and loses so neither side
|
||||||
YouTube has diverged (items present there that local will remove). Costs read units
|
loses an item, plus the merged order. `final` is the id set both sides end up with."""
|
||||||
(1/page) for a linked playlist; nothing for a fresh export. `live` = a pre-fetched
|
|
||||||
(current_items, snippet) tuple so the caller can share one read with push_playlist."""
|
final: list[str] # merged order (local order, YouTube-only adds appended)
|
||||||
desired = _local_video_ids(db, pl)
|
add_to_yt: list[str] # local-adds to push out to YouTube
|
||||||
|
add_to_local: list[str] # YouTube-adds to pull into Siftlode
|
||||||
|
remove_from_yt: list[str] # items deleted locally that are still on YouTube
|
||||||
|
remove_from_local: list[str] # items deleted on YouTube that are still local
|
||||||
|
reorders: int # estimated YouTube playlistItems.update moves to reach `final`
|
||||||
|
baseline_known: bool
|
||||||
|
|
||||||
|
|
||||||
|
def merge_plan(
|
||||||
|
baseline: list[str] | None,
|
||||||
|
local: list[str],
|
||||||
|
youtube: list[str],
|
||||||
|
unavailable: set[str] | None = None,
|
||||||
|
) -> MergePlan:
|
||||||
|
"""Pure 3-way merge. Classify each id against the last-synced `baseline`:
|
||||||
|
- in local but not youtube, not deleted → add to YouTube (a local-add)
|
||||||
|
- in youtube but not local, not deleted → add to Siftlode (a YouTube-add)
|
||||||
|
- in baseline but gone from local → a genuine local delete → propagate to YouTube
|
||||||
|
- in baseline but gone from youtube → a genuine YouTube delete → propagate to Siftlode
|
||||||
|
With NO baseline (first-ever merge) we can't tell an add from a delete, so we UNION both
|
||||||
|
sides (never delete) — the lossless default. Merged order: local order first, YouTube-only
|
||||||
|
additions appended in their YouTube order.
|
||||||
|
`unavailable` = known un-ingestable-but-live YouTube ids: dropped from the YouTube side up
|
||||||
|
front so they're never re-classified as a YouTube-add (they can't ingest) nor as a local
|
||||||
|
delete (they were never local) — otherwise such a video would make the playlist never
|
||||||
|
converge. They stay in place on YouTube, untouched by this merge."""
|
||||||
|
un = set(unavailable or ())
|
||||||
|
L = list(dict.fromkeys(local))
|
||||||
|
Y = [v for v in dict.fromkeys(youtube) if v not in un]
|
||||||
|
Lset, Yset = set(L), set(Y)
|
||||||
|
if baseline is None:
|
||||||
|
add_to_yt = [v for v in L if v not in Yset]
|
||||||
|
add_to_local = [v for v in Y if v not in Lset]
|
||||||
|
remove_from_yt: list[str] = []
|
||||||
|
remove_from_local: list[str] = []
|
||||||
|
final = L + [v for v in Y if v not in Lset]
|
||||||
|
else:
|
||||||
|
B = set(baseline)
|
||||||
|
deleted_local = B - Lset # was synced, removed on our side
|
||||||
|
deleted_yt = B - Yset # was synced, removed on YouTube's side
|
||||||
|
gone = deleted_local | deleted_yt
|
||||||
|
remove_from_yt = [v for v in Y if v in deleted_local]
|
||||||
|
remove_from_local = [v for v in L if v in deleted_yt]
|
||||||
|
add_to_yt = [v for v in L if v not in Yset and v not in gone]
|
||||||
|
add_to_local = [v for v in Y if v not in Lset and v not in gone]
|
||||||
|
final = [v for v in L if v not in gone] + [v for v in Y if v not in Lset and v not in gone]
|
||||||
|
final_set = set(final)
|
||||||
|
# On YouTube, after membership reconciliation the order is current-kept + inserts appended;
|
||||||
|
# the reorder pass then moves items into `final`.
|
||||||
|
after_membership = [v for v in Y if v in final_set] + add_to_yt
|
||||||
|
reorders = _reorder_moves(after_membership, final)
|
||||||
|
return MergePlan(
|
||||||
|
final=final,
|
||||||
|
add_to_yt=add_to_yt,
|
||||||
|
add_to_local=add_to_local,
|
||||||
|
remove_from_yt=remove_from_yt,
|
||||||
|
remove_from_local=remove_from_local,
|
||||||
|
reorders=reorders,
|
||||||
|
baseline_known=baseline is not None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def plan_merge(db: Session, user: User, pl: Playlist, *, live=None) -> dict:
|
||||||
|
"""Compute (without writing) what a two-way sync of this playlist would do: create vs
|
||||||
|
update, how many items each side gains/loses, reorders, and the quota estimate (YouTube
|
||||||
|
writes only — local edits are free). Reads live YouTube state (1 unit/page) for a linked
|
||||||
|
playlist; nothing for a fresh export. `live` = a pre-fetched (current_items, snippet) tuple
|
||||||
|
so the caller can share one read with merge_playlist."""
|
||||||
|
local = _local_video_ids(db, pl)
|
||||||
if not pl.yt_playlist_id:
|
if not pl.yt_playlist_id:
|
||||||
ops = 1 + len(desired) # create the playlist + one insert per video
|
ops = 1 + len(local) # create the playlist + one insert per video
|
||||||
return {
|
return {
|
||||||
"action": "create",
|
"action": "create",
|
||||||
"to_insert": len(desired),
|
"add_yt": len(local),
|
||||||
"to_delete": 0,
|
"add_local": 0,
|
||||||
|
"remove_yt": 0,
|
||||||
|
"remove_local": 0,
|
||||||
"to_reorder": 0,
|
"to_reorder": 0,
|
||||||
"rename": False,
|
"rename": False,
|
||||||
"yt_extra": 0,
|
|
||||||
"units_estimate": ops * WRITE_UNIT_COST,
|
"units_estimate": ops * WRITE_UNIT_COST,
|
||||||
}
|
}
|
||||||
if live is not None:
|
if live is not None:
|
||||||
@@ -241,23 +369,17 @@ def plan_push(db: Session, user: User, pl: Playlist, *, live=None) -> dict:
|
|||||||
current = list(yt.iter_playlist_items_with_ids(pl.yt_playlist_id))
|
current = list(yt.iter_playlist_items_with_ids(pl.yt_playlist_id))
|
||||||
snippet = yt.get_playlist_snippet(pl.yt_playlist_id)
|
snippet = yt.get_playlist_snippet(pl.yt_playlist_id)
|
||||||
rename = bool(snippet) and (snippet.get("title") or "") != pl.name
|
rename = bool(snippet) and (snippet.get("title") or "") != pl.name
|
||||||
cur_vids = [it["video_id"] for it in current]
|
yt_ids = [it["video_id"] for it in current]
|
||||||
cur_set = set(cur_vids)
|
mp = merge_plan(parse_baseline(pl), local, yt_ids, parse_unavailable(pl))
|
||||||
desired_set = set(desired)
|
ops = len(mp.add_to_yt) + len(mp.remove_from_yt) + mp.reorders + (1 if rename else 0)
|
||||||
to_insert = [v for v in desired if v not in cur_set]
|
|
||||||
to_delete = [v for v in cur_vids if v not in desired_set]
|
|
||||||
# After membership reconciliation, the YT order is current-minus-deletes plus inserts
|
|
||||||
# appended; the reorder pass then moves items into the desired order.
|
|
||||||
after_membership = [v for v in cur_vids if v in desired_set] + to_insert
|
|
||||||
reorders = _reorder_moves(after_membership, desired)
|
|
||||||
ops = len(to_insert) + len(to_delete) + reorders + (1 if rename else 0)
|
|
||||||
return {
|
return {
|
||||||
"action": "update",
|
"action": "update",
|
||||||
"to_insert": len(to_insert),
|
"add_yt": len(mp.add_to_yt),
|
||||||
"to_delete": len(to_delete),
|
"add_local": len(mp.add_to_local),
|
||||||
"to_reorder": reorders,
|
"remove_yt": len(mp.remove_from_yt),
|
||||||
|
"remove_local": len(mp.remove_from_local),
|
||||||
|
"to_reorder": mp.reorders,
|
||||||
"rename": rename,
|
"rename": rename,
|
||||||
"yt_extra": len(to_delete),
|
|
||||||
"units_estimate": ops * WRITE_UNIT_COST,
|
"units_estimate": ops * WRITE_UNIT_COST,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -327,13 +449,16 @@ class _Breaker:
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def push_playlist(db: Session, user: User, pl: Playlist, *, live=None) -> dict:
|
def merge_playlist(db: Session, user: User, pl: Playlist, *, live=None) -> dict:
|
||||||
"""Push a local playlist to YouTube: create it if needed, then reconcile membership
|
"""Two-way sync a local playlist with YouTube: create it on first push, else 3-way MERGE
|
||||||
and order so YouTube matches local (local wins). Marks the playlist clean on success.
|
both sides so an item added (or deleted) on either side survives — no side wins wholesale.
|
||||||
|
Local edits are applied directly; YouTube edits go through the failure breaker. On full
|
||||||
|
success the merged set becomes the new baseline and the playlist is marked clean; on any
|
||||||
|
YouTube failure the baseline is left untouched so the next sync retries and converges.
|
||||||
Caller must have checked the write scope and the quota budget. `live` = a pre-fetched
|
Caller must have checked the write scope and the quota budget. `live` = a pre-fetched
|
||||||
(current_items, snippet) tuple (from plan_push's read) to avoid a second live page-through."""
|
(current_items, snippet) tuple (from plan_merge's read) to avoid a second page-through."""
|
||||||
desired = _local_video_ids(db, pl)
|
local = _local_video_ids(db, pl)
|
||||||
inserted = deleted = reordered = created = 0
|
inserted = deleted = reordered = created = pulled = removed_local = 0
|
||||||
failures: list[str] = []
|
failures: list[str] = []
|
||||||
breaker = _Breaker()
|
breaker = _Breaker()
|
||||||
with YouTubeClient(db, user) as yt:
|
with YouTubeClient(db, user) as yt:
|
||||||
@@ -341,8 +466,8 @@ def push_playlist(db: Session, user: User, pl: Playlist, *, live=None) -> dict:
|
|||||||
pl.yt_playlist_id = yt.create_playlist(pl.name)
|
pl.yt_playlist_id = yt.create_playlist(pl.name)
|
||||||
db.commit()
|
db.commit()
|
||||||
created = 1
|
created = 1
|
||||||
# Brand-new playlist: just append every video in order.
|
# Brand-new playlist: just append every local video in order.
|
||||||
for vid in desired:
|
for vid in local:
|
||||||
added = breaker.run(
|
added = breaker.run(
|
||||||
lambda v=vid: yt.add_playlist_item(pl.yt_playlist_id, v),
|
lambda v=vid: yt.add_playlist_item(pl.yt_playlist_id, v),
|
||||||
lambda v=vid: failures.append(v),
|
lambda v=vid: failures.append(v),
|
||||||
@@ -350,60 +475,95 @@ def push_playlist(db: Session, user: User, pl: Playlist, *, live=None) -> dict:
|
|||||||
if not isinstance(added, _Failed):
|
if not isinstance(added, _Failed):
|
||||||
inserted += 1
|
inserted += 1
|
||||||
if not failures:
|
if not failures:
|
||||||
pl.synced_fingerprint = fingerprint(pl.name, desired)
|
_set_baseline(pl, local)
|
||||||
pl.dirty = False
|
|
||||||
db.commit()
|
db.commit()
|
||||||
return {
|
return {
|
||||||
"created": created,
|
"created": created,
|
||||||
"inserted": inserted,
|
"inserted": inserted,
|
||||||
"deleted": deleted,
|
"deleted": deleted,
|
||||||
"reordered": reordered,
|
"reordered": reordered,
|
||||||
|
"pulled": pulled,
|
||||||
|
"removed_local": removed_local,
|
||||||
"failures": failures,
|
"failures": failures,
|
||||||
"aborted": breaker.tripped,
|
"aborted": breaker.tripped,
|
||||||
"yt_playlist_id": pl.yt_playlist_id,
|
"yt_playlist_id": pl.yt_playlist_id,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Existing link: diff against the live YouTube state (reuse plan_push's read if given).
|
# Existing link: 3-way merge against the live YouTube state (reuse plan_merge's read).
|
||||||
if live is not None:
|
if live is not None:
|
||||||
current, snippet = live
|
current, snippet = live
|
||||||
else:
|
else:
|
||||||
snippet = yt.get_playlist_snippet(pl.yt_playlist_id)
|
snippet = yt.get_playlist_snippet(pl.yt_playlist_id)
|
||||||
current = list(yt.iter_playlist_items_with_ids(pl.yt_playlist_id))
|
current = list(yt.iter_playlist_items_with_ids(pl.yt_playlist_id))
|
||||||
|
item_id_by_vid = {it["video_id"]: it["item_id"] for it in current}
|
||||||
|
yt_ids = [it["video_id"] for it in current]
|
||||||
|
# Re-attempt ingestion of ids previously marked un-ingestable that are still on YouTube: a
|
||||||
|
# video that has since become available (e.g. private→public) should re-enter the merge, not
|
||||||
|
# stay skipped forever. Those that still won't ingest remain filtered (`effective_unavailable`).
|
||||||
|
prev_unavailable = parse_unavailable(pl)
|
||||||
|
still_listed = [v for v in yt_ids if v in prev_unavailable]
|
||||||
|
if still_listed:
|
||||||
|
_ensure_videos(db, yt, still_listed)
|
||||||
|
healed = set(
|
||||||
|
db.execute(select(Video.id).where(Video.id.in_(still_listed))).scalars().all()
|
||||||
|
)
|
||||||
|
effective_unavailable = prev_unavailable - healed
|
||||||
|
else:
|
||||||
|
effective_unavailable = prev_unavailable
|
||||||
|
mp = merge_plan(parse_baseline(pl), local, yt_ids, effective_unavailable)
|
||||||
|
|
||||||
|
# --- Siftlode side (free, direct): pull YouTube-adds in, drop YouTube-deletes, land the
|
||||||
|
# merged order authoritatively. Do this first so local reflects the merge even if a later
|
||||||
|
# YouTube write trips the breaker.
|
||||||
|
_ensure_videos(db, yt, mp.add_to_local)
|
||||||
|
# Only the ids that actually exist in the catalog can become local rows (a private/deleted
|
||||||
|
# YouTube video won't ingest); keep the merged order, dropping any that didn't materialize.
|
||||||
|
present = (
|
||||||
|
set(db.execute(select(Video.id).where(Video.id.in_(mp.final))).scalars().all())
|
||||||
|
if mp.final
|
||||||
|
else set()
|
||||||
|
)
|
||||||
|
final_local = [v for v in mp.final if v in present]
|
||||||
|
pulled = len([v for v in mp.add_to_local if v in present])
|
||||||
|
removed_local = len(mp.remove_from_local)
|
||||||
|
_set_local_items(db, pl, final_local)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# --- YouTube side (metered, breaker-guarded).
|
||||||
if snippet is not None and (snippet.get("title") or "") != pl.name:
|
if snippet is not None and (snippet.get("title") or "") != pl.name:
|
||||||
try:
|
try:
|
||||||
yt.update_playlist_title(pl.yt_playlist_id, pl.name)
|
yt.update_playlist_title(pl.yt_playlist_id, pl.name)
|
||||||
except YouTubeError:
|
except YouTubeError:
|
||||||
failures.append("title")
|
failures.append("title")
|
||||||
item_id_by_vid = {it["video_id"]: it["item_id"] for it in current}
|
# Propagate genuine local deletes.
|
||||||
cur_vids = [it["video_id"] for it in current]
|
for vid in mp.remove_from_yt:
|
||||||
desired_set = set(desired)
|
item_id = item_id_by_vid.get(vid)
|
||||||
# Delete extras (local is authoritative).
|
if item_id is None:
|
||||||
for it in current:
|
continue
|
||||||
if it["video_id"] not in desired_set:
|
dropped = breaker.run(
|
||||||
dropped = breaker.run(
|
lambda i=item_id: yt.delete_playlist_item(i),
|
||||||
lambda i=it: yt.delete_playlist_item(i["item_id"]),
|
lambda v=vid: failures.append(v),
|
||||||
lambda i=it: failures.append(i["video_id"]),
|
)
|
||||||
)
|
if not isinstance(dropped, _Failed):
|
||||||
if not isinstance(dropped, _Failed):
|
deleted += 1
|
||||||
deleted += 1
|
item_id_by_vid.pop(vid, None)
|
||||||
# Insert missing (append; the reorder pass below fixes positions).
|
# Insert local-adds (append; the reorder pass below fixes positions).
|
||||||
cur_set = set(cur_vids)
|
for vid in mp.add_to_yt:
|
||||||
for vid in desired:
|
item_id = breaker.run(
|
||||||
if vid not in cur_set:
|
lambda v=vid: yt.add_playlist_item(pl.yt_playlist_id, v),
|
||||||
item_id = breaker.run(
|
lambda v=vid: failures.append(v),
|
||||||
lambda v=vid: yt.add_playlist_item(pl.yt_playlist_id, v),
|
)
|
||||||
lambda v=vid: failures.append(v),
|
if not isinstance(item_id, _Failed):
|
||||||
)
|
item_id_by_vid[vid] = item_id
|
||||||
if not isinstance(item_id, _Failed):
|
inserted += 1
|
||||||
item_id_by_vid[vid] = item_id
|
# Reorder YouTube to match `final` via insertion-sort moves over a local model.
|
||||||
inserted += 1
|
final_set = set(mp.final)
|
||||||
# Reorder to match `desired` via insertion-sort moves over a local model.
|
model = [v for v in yt_ids if v in final_set and v in item_id_by_vid] + [
|
||||||
model = [v for v in cur_vids if v in desired_set] + [
|
v for v in mp.add_to_yt if v in item_id_by_vid
|
||||||
v for v in desired if v not in cur_set and v in item_id_by_vid
|
|
||||||
]
|
]
|
||||||
for i, want in enumerate(desired):
|
for i, want in enumerate(mp.final):
|
||||||
if want not in item_id_by_vid:
|
if want not in item_id_by_vid:
|
||||||
continue # failed insert; skip
|
continue # deleted-from-YT or failed insert; skip
|
||||||
if i < len(model) and model[i] == want:
|
if i < len(model) and model[i] == want:
|
||||||
continue
|
continue
|
||||||
moved = breaker.run(
|
moved = breaker.run(
|
||||||
@@ -419,14 +579,23 @@ def push_playlist(db: Session, user: User, pl: Playlist, *, live=None) -> dict:
|
|||||||
model.pop(j)
|
model.pop(j)
|
||||||
model.insert(i, want)
|
model.insert(i, want)
|
||||||
if not failures:
|
if not failures:
|
||||||
pl.synced_fingerprint = fingerprint(pl.name, desired)
|
# Carry forward un-ingestable-but-live YouTube ids so the next merge skips them
|
||||||
pl.dirty = False
|
# (see merge_plan): this run's failed pulls, plus prior ones still on YouTube, minus
|
||||||
|
# any that have since left YouTube. Recomputed each success → self-healing.
|
||||||
|
yt_id_set = set(yt_ids)
|
||||||
|
unavailable = sorted(
|
||||||
|
(effective_unavailable | {v for v in mp.add_to_local if v not in present})
|
||||||
|
& yt_id_set
|
||||||
|
)
|
||||||
|
_set_baseline(pl, final_local, unavailable)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {
|
return {
|
||||||
"created": created,
|
"created": created,
|
||||||
"inserted": inserted,
|
"inserted": inserted,
|
||||||
"deleted": deleted,
|
"deleted": deleted,
|
||||||
"reordered": reordered,
|
"reordered": reordered,
|
||||||
|
"pulled": pulled,
|
||||||
|
"removed_local": removed_local,
|
||||||
"failures": failures,
|
"failures": failures,
|
||||||
"aborted": breaker.tripped,
|
"aborted": breaker.tripped,
|
||||||
"yt_playlist_id": pl.yt_playlist_id,
|
"yt_playlist_id": pl.yt_playlist_id,
|
||||||
|
|||||||
@@ -127,6 +127,10 @@ SPECS: tuple[ConfigSpec, ...] = (
|
|||||||
min=1,
|
min=1,
|
||||||
max=100_000,
|
max=100_000,
|
||||||
),
|
),
|
||||||
|
# min=1, NOT 0: the per-file cap is enforced as `if cap and size > cap`, so a stored 0 would read
|
||||||
|
# as falsy and SILENTLY disable the limit (an unbounded streamed write to the volume). Floor it at
|
||||||
|
# 1 so the cap is always active; the overall footprint is separately bounded by the byte quota.
|
||||||
|
ConfigSpec("download_upload_max_bytes", "int", "downloads", "download_upload_max_bytes", min=1),
|
||||||
ConfigSpec(
|
ConfigSpec(
|
||||||
"download_retention_days", "int", "downloads", "download_retention_days", min=0, max=3_650
|
"download_retention_days", "int", "downloads", "download_retention_days", min=0, max=3_650
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ pydantic-settings==2.15.0
|
|||||||
pydantic==2.13.4
|
pydantic==2.13.4
|
||||||
pydantic_core==2.46.4
|
pydantic_core==2.46.4
|
||||||
python-dotenv==1.2.3
|
python-dotenv==1.2.3
|
||||||
|
python-multipart==0.0.32
|
||||||
requests==2.34.2
|
requests==2.34.2
|
||||||
starlette==1.6.0
|
starlette==1.6.0
|
||||||
typing-inspection==0.4.4
|
typing-inspection==0.4.4
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
fastapi>=0.115,<1.0
|
fastapi>=0.115,<1.0
|
||||||
|
# Multipart form parsing — required by Starlette/FastAPI to accept file uploads (UploadFile/Form).
|
||||||
|
# The download center's local-file Upload (E7) is the only multipart endpoint in the app.
|
||||||
|
python-multipart>=0.0.9,<1.0
|
||||||
uvicorn[standard]>=0.30,<1.0
|
uvicorn[standard]>=0.30,<1.0
|
||||||
sqlalchemy>=2.0,<2.1
|
sqlalchemy>=2.0,<2.1
|
||||||
psycopg[binary]>=3.2,<4.0
|
psycopg[binary]>=3.2,<4.0
|
||||||
|
|||||||
@@ -0,0 +1,257 @@
|
|||||||
|
"""S6 — per-user, per-host download cookies: host-scope, cache privacy, encryption, write-only API.
|
||||||
|
|
||||||
|
The two load-bearing security properties are covered as behaviour, not prose:
|
||||||
|
* **cache privacy** — a cookie-authed download is a PRIVATE, salted asset; a second user without a
|
||||||
|
cookie enqueuing the same URL gets the shared (unsalted) asset and can never be cache-served the
|
||||||
|
authed one, and the worker attaches a cookie ONLY to that private identity.
|
||||||
|
* **host scope (SEC-12)** — a cookie is resolved ONLY for a URL whose netloc the credential owns; a
|
||||||
|
look-alike or unrelated host resolves to no host_key and gets no cookie.
|
||||||
|
|
||||||
|
Pure-logic parts run with no DB; the CRUD / enqueue-salting / API parts use the `db`/`as_user` lane.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from cryptography.fernet import Fernet
|
||||||
|
|
||||||
|
from app import security
|
||||||
|
from app.downloads import credentials, formats, service
|
||||||
|
from app.models import DownloadCredential, MediaAsset, User
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _fernet(monkeypatch):
|
||||||
|
# A throwaway key so encrypt/decrypt work without TOKEN_ENCRYPTION_KEY in the env (mirrors
|
||||||
|
# test_security). Autouse: every credential write in this module encrypts.
|
||||||
|
monkeypatch.setattr(security, "_fernet", Fernet(Fernet.generate_key()))
|
||||||
|
|
||||||
|
|
||||||
|
COOKIES = "# Netscape HTTP Cookie File\n.facebook.com\tTRUE\t/\tTRUE\t0\tc_user\t123\n"
|
||||||
|
|
||||||
|
|
||||||
|
# --- host scope (pure logic) ------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestHostKeyForUrl:
|
||||||
|
def test_matches_a_supported_host(self):
|
||||||
|
assert credentials.host_key_for_url("https://www.facebook.com/watch?v=1") == "facebook"
|
||||||
|
assert credentials.host_key_for_url("https://fb.watch/abc/") == "facebook"
|
||||||
|
assert credentials.host_key_for_url("https://x.com/i/status/1") == "twitter"
|
||||||
|
|
||||||
|
def test_look_alike_host_never_matches(self):
|
||||||
|
assert credentials.host_key_for_url("https://myfacebook.com/x") is None
|
||||||
|
assert credentials.host_key_for_url("https://facebook.com.evil.test/x") is None
|
||||||
|
|
||||||
|
def test_unrelated_or_empty_url_has_no_host_key(self):
|
||||||
|
assert credentials.host_key_for_url("https://www.youtube.com/watch?v=abc") is None
|
||||||
|
assert credentials.host_key_for_url("") is None
|
||||||
|
assert credentials.host_key_for_url(None) is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestValidateCookies:
|
||||||
|
def test_accepts_a_netscape_file(self):
|
||||||
|
assert credentials.validate_cookies(COOKIES).endswith("\n")
|
||||||
|
|
||||||
|
def test_rejects_empty(self):
|
||||||
|
with pytest.raises(credentials.InvalidCookies):
|
||||||
|
credentials.validate_cookies(" ")
|
||||||
|
|
||||||
|
def test_rejects_non_cookie_paste(self):
|
||||||
|
# Comments only, no tab-separated data line -> not a usable cookies.txt.
|
||||||
|
with pytest.raises(credentials.InvalidCookies):
|
||||||
|
credentials.validate_cookies("just some pasted text\n# a comment")
|
||||||
|
|
||||||
|
|
||||||
|
def test_authed_sig_differs_per_user_and_from_the_shared_sig():
|
||||||
|
spec = formats.normalize({})
|
||||||
|
shared = formats.format_sig(spec)
|
||||||
|
a = credentials.authed_format_sig(spec, 1, "facebook")
|
||||||
|
b = credentials.authed_format_sig(spec, 2, "facebook")
|
||||||
|
assert a != shared and b != shared and a != b
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_cookiefile_is_private_and_survives_the_retry_wipe(tmp_path):
|
||||||
|
staging = tmp_path / "staging"
|
||||||
|
staging.mkdir()
|
||||||
|
path = credentials._write_cookiefile(COOKIES, staging)
|
||||||
|
assert path.read_text() == COOKIES
|
||||||
|
# 0600 (owner-only) — POSIX perms only (the worker is Linux-only; Windows has no mode bits).
|
||||||
|
if os.name == "posix":
|
||||||
|
assert (path.stat().st_mode & 0o777) == 0o600
|
||||||
|
# It lives in a SUBDIR so the download retry loop (which unlinks files in the staging ROOT) and
|
||||||
|
# _produced_file (which scans the root) never see it.
|
||||||
|
assert path.parent == staging / ".auth"
|
||||||
|
assert not any(p.is_file() for p in staging.iterdir())
|
||||||
|
|
||||||
|
|
||||||
|
# --- CRUD + encryption at rest (DB) -----------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _user(db, email="cred@example.com") -> User:
|
||||||
|
u = User(email=email)
|
||||||
|
db.add(u)
|
||||||
|
db.flush()
|
||||||
|
return u
|
||||||
|
|
||||||
|
|
||||||
|
class TestCrud:
|
||||||
|
def test_upsert_stores_ciphertext_not_plaintext(self, db):
|
||||||
|
u = _user(db)
|
||||||
|
credentials.upsert_credential(db, u.id, "facebook", COOKIES)
|
||||||
|
db.commit()
|
||||||
|
row = db.get(DownloadCredential, 1)
|
||||||
|
assert row.cookies_enc != COOKIES # encrypted at rest
|
||||||
|
assert COOKIES not in row.cookies_enc
|
||||||
|
assert security.decrypt(row.cookies_enc) == COOKIES
|
||||||
|
|
||||||
|
def test_upsert_replaces_in_place(self, db):
|
||||||
|
u = _user(db)
|
||||||
|
credentials.upsert_credential(db, u.id, "facebook", COOKIES)
|
||||||
|
db.commit()
|
||||||
|
credentials.upsert_credential(db, u.id, "facebook", COOKIES.replace("123", "999"))
|
||||||
|
db.commit()
|
||||||
|
rows = db.query(DownloadCredential).filter_by(user_id=u.id, host_key="facebook").all()
|
||||||
|
assert len(rows) == 1
|
||||||
|
assert "999" in security.decrypt(rows[0].cookies_enc)
|
||||||
|
|
||||||
|
def test_status_list_never_leaks_the_blob(self, db):
|
||||||
|
u = _user(db)
|
||||||
|
credentials.upsert_credential(db, u.id, "facebook", COOKIES)
|
||||||
|
db.commit()
|
||||||
|
status = credentials.status_list(db, u.id)
|
||||||
|
fb = next(h for h in status if h["host_key"] == "facebook")
|
||||||
|
assert fb["configured"] is True and fb["updated_at"] is not None
|
||||||
|
ig = next(h for h in status if h["host_key"] == "instagram")
|
||||||
|
assert ig["configured"] is False and ig["updated_at"] is None
|
||||||
|
# No field anywhere carries the cookie value.
|
||||||
|
assert all(COOKIES not in str(v) for h in status for v in h.values())
|
||||||
|
|
||||||
|
def test_delete(self, db):
|
||||||
|
u = _user(db)
|
||||||
|
credentials.upsert_credential(db, u.id, "facebook", COOKIES)
|
||||||
|
db.commit()
|
||||||
|
assert credentials.delete_credential(db, u.id, "facebook") is True
|
||||||
|
db.commit()
|
||||||
|
assert credentials.get_credential(db, u.id, "facebook") is None
|
||||||
|
assert credentials.delete_credential(db, u.id, "facebook") is False
|
||||||
|
|
||||||
|
|
||||||
|
# --- the crux: enqueue salts privately, no cross-user cache serve (DB) -------------------------
|
||||||
|
|
||||||
|
FB_URL = "https://www.facebook.com/watch?v=100"
|
||||||
|
|
||||||
|
|
||||||
|
class TestCachePrivacy:
|
||||||
|
def test_enqueue_with_a_cookie_creates_a_private_salted_asset(self, db):
|
||||||
|
u = _user(db)
|
||||||
|
credentials.upsert_credential(db, u.id, "facebook", COOKIES)
|
||||||
|
db.commit()
|
||||||
|
job = service.enqueue(db, u.id, "url", FB_URL, {})
|
||||||
|
asset = db.get(MediaAsset, job.asset_id)
|
||||||
|
spec = formats.normalize({})
|
||||||
|
assert asset.format_sig == credentials.authed_format_sig(spec, u.id, "facebook")
|
||||||
|
assert asset.format_sig != formats.format_sig(spec)
|
||||||
|
|
||||||
|
def test_enqueue_without_a_cookie_uses_the_shared_sig(self, db):
|
||||||
|
u = _user(db)
|
||||||
|
job = service.enqueue(db, u.id, "url", FB_URL, {})
|
||||||
|
asset = db.get(MediaAsset, job.asset_id)
|
||||||
|
assert asset.format_sig == formats.format_sig(formats.normalize({}))
|
||||||
|
|
||||||
|
def test_two_users_never_share_the_authed_asset(self, db):
|
||||||
|
a = _user(db, "a@example.com")
|
||||||
|
b = _user(db, "b@example.com")
|
||||||
|
credentials.upsert_credential(db, a.id, "facebook", COOKIES)
|
||||||
|
db.commit()
|
||||||
|
ja = service.enqueue(db, a.id, "url", FB_URL, {}) # A: authed, private
|
||||||
|
jb = service.enqueue(db, b.id, "url", FB_URL, {}) # B: no cookie, shared
|
||||||
|
assert ja.asset_id != jb.asset_id
|
||||||
|
# B's asset is the shared identity — A's private group video can never be served to B.
|
||||||
|
assert db.get(MediaAsset, jb.asset_id).format_sig == formats.format_sig(
|
||||||
|
formats.normalize({})
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- the worker gate: attach only to the private identity, host-scoped (DB) --------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestResolveCookiefile:
|
||||||
|
def _asset_sig(self, sig: str) -> MediaAsset:
|
||||||
|
return MediaAsset(
|
||||||
|
source_kind="url", source_ref=FB_URL, format_sig=sig, status="downloading"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_attaches_to_the_private_salted_asset(self, db, tmp_path):
|
||||||
|
u = _user(db)
|
||||||
|
credentials.upsert_credential(db, u.id, "facebook", COOKIES)
|
||||||
|
db.commit()
|
||||||
|
spec = formats.normalize({})
|
||||||
|
salted = credentials.authed_format_sig(spec, u.id, "facebook")
|
||||||
|
path = credentials.resolve_cookiefile(db, u.id, FB_URL, salted, spec, tmp_path)
|
||||||
|
assert path is not None and Path(path).read_text() == COOKIES
|
||||||
|
|
||||||
|
def test_never_attaches_to_a_shared_asset(self, db, tmp_path):
|
||||||
|
# Cookie exists, but the asset is the UNSALTED shared identity -> attaching would poison the
|
||||||
|
# global cache with a private-group video. Must return None.
|
||||||
|
u = _user(db)
|
||||||
|
credentials.upsert_credential(db, u.id, "facebook", COOKIES)
|
||||||
|
db.commit()
|
||||||
|
spec = formats.normalize({})
|
||||||
|
shared = formats.format_sig(spec)
|
||||||
|
assert credentials.resolve_cookiefile(db, u.id, FB_URL, shared, spec, tmp_path) is None
|
||||||
|
|
||||||
|
def test_non_matching_host_gets_no_cookie(self, db, tmp_path):
|
||||||
|
u = _user(db)
|
||||||
|
credentials.upsert_credential(db, u.id, "facebook", COOKIES)
|
||||||
|
db.commit()
|
||||||
|
spec = formats.normalize({})
|
||||||
|
# A YouTube URL has no supported host_key -> no cookie regardless of what's stored.
|
||||||
|
yt = "https://www.youtube.com/watch?v=abc"
|
||||||
|
salted = credentials.authed_format_sig(spec, u.id, "facebook")
|
||||||
|
assert credentials.resolve_cookiefile(db, u.id, yt, salted, spec, tmp_path) is None
|
||||||
|
|
||||||
|
def test_no_credential_gets_no_cookie(self, db, tmp_path):
|
||||||
|
u = _user(db)
|
||||||
|
spec = formats.normalize({})
|
||||||
|
salted = credentials.authed_format_sig(spec, u.id, "facebook")
|
||||||
|
assert credentials.resolve_cookiefile(db, u.id, FB_URL, salted, spec, tmp_path) is None
|
||||||
|
|
||||||
|
|
||||||
|
# --- API: write-only, never echoes the blob (DB) ----------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestApi:
|
||||||
|
def test_put_get_delete_roundtrip_never_returns_the_blob(self, db, as_user):
|
||||||
|
u = _user(db)
|
||||||
|
db.commit()
|
||||||
|
client = as_user(u)
|
||||||
|
|
||||||
|
r = client.put("/api/downloads/credentials/facebook", json={"cookies": COOKIES})
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert body["ok"] is True
|
||||||
|
assert COOKIES not in r.text # the response must never carry the value
|
||||||
|
fb = next(h for h in body["hosts"] if h["host_key"] == "facebook")
|
||||||
|
assert fb["configured"] is True
|
||||||
|
|
||||||
|
r = client.get("/api/downloads/credentials")
|
||||||
|
assert r.status_code == 200 and COOKIES not in r.text
|
||||||
|
|
||||||
|
r = client.delete("/api/downloads/credentials/facebook")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert credentials.get_credential(db, u.id, "facebook") is None
|
||||||
|
|
||||||
|
def test_unknown_host_is_404(self, db, as_user):
|
||||||
|
u = _user(db)
|
||||||
|
db.commit()
|
||||||
|
r = as_user(u).put("/api/downloads/credentials/nope", json={"cookies": COOKIES})
|
||||||
|
assert r.status_code == 404
|
||||||
|
|
||||||
|
def test_bad_cookies_are_rejected(self, db, as_user):
|
||||||
|
u = _user(db)
|
||||||
|
db.commit()
|
||||||
|
r = as_user(u).put("/api/downloads/credentials/facebook", json={"cookies": " "})
|
||||||
|
assert r.status_code == 400
|
||||||
|
assert r.json()["detail"]["code"] == "cookies"
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
"""Worker-child resource de-prioritization (S4): the encoder thread cap, the `-threads` flag on the
|
||||||
|
browser-safe re-encode, and the child's startup renice/ionice/OMP handicap. These keep a CPU-heavy
|
||||||
|
transcode from pegging every core on the box it shares with the API, Postgres, and Plex playback."""
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.downloads import edit as editmod
|
||||||
|
from app.downloads import runner
|
||||||
|
|
||||||
|
|
||||||
|
class TestEncodeThreadCap:
|
||||||
|
def test_pinned_setting_wins(self, monkeypatch):
|
||||||
|
monkeypatch.setattr(settings, "download_ffmpeg_threads", 3)
|
||||||
|
assert editmod.encode_thread_cap() == 3
|
||||||
|
|
||||||
|
def test_zero_reserves_one_core(self, monkeypatch):
|
||||||
|
monkeypatch.setattr(settings, "download_ffmpeg_threads", 0)
|
||||||
|
monkeypatch.setattr(editmod.os, "cpu_count", lambda: 8)
|
||||||
|
assert editmod.encode_thread_cap() == 7
|
||||||
|
|
||||||
|
def test_single_core_box_never_caps_below_one(self, monkeypatch):
|
||||||
|
monkeypatch.setattr(settings, "download_ffmpeg_threads", 0)
|
||||||
|
monkeypatch.setattr(editmod.os, "cpu_count", lambda: 1)
|
||||||
|
assert editmod.encode_thread_cap() == 1
|
||||||
|
|
||||||
|
def test_unknown_cpu_count_falls_back(self, monkeypatch):
|
||||||
|
monkeypatch.setattr(settings, "download_ffmpeg_threads", 0)
|
||||||
|
monkeypatch.setattr(editmod.os, "cpu_count", lambda: None)
|
||||||
|
assert editmod.encode_thread_cap() == 1 # max(1, 2 - 1)
|
||||||
|
|
||||||
|
|
||||||
|
class TestBrowserSafeThreads:
|
||||||
|
def test_threads_flag_added_when_capped(self):
|
||||||
|
cmd = editmod.build_browser_safe_cmd(
|
||||||
|
"/in.mkv", "/out.mp4", reencode_video=True, reencode_audio=False, threads=2
|
||||||
|
)
|
||||||
|
assert "-threads" in cmd
|
||||||
|
assert cmd[cmd.index("-threads") + 1] == "2"
|
||||||
|
# The cap precedes the output file, so it binds the encoder (not input decoding).
|
||||||
|
assert cmd.index("-threads") < cmd.index("/out.mp4")
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("threads", [0, None])
|
||||||
|
def test_no_threads_flag_when_uncapped(self, threads):
|
||||||
|
cmd = editmod.build_browser_safe_cmd(
|
||||||
|
"/in.mkv", "/out.mp4", reencode_video=True, reencode_audio=False, threads=threads
|
||||||
|
)
|
||||||
|
assert "-threads" not in cmd
|
||||||
|
|
||||||
|
|
||||||
|
class TestDeprioritize:
|
||||||
|
def test_renices_by_configured_increment(self, monkeypatch):
|
||||||
|
monkeypatch.setattr(settings, "download_child_nice", 12)
|
||||||
|
monkeypatch.setattr(settings, "download_child_ionice_class", 0) # skip ionice branch
|
||||||
|
calls = []
|
||||||
|
monkeypatch.setattr(runner.os, "nice", lambda n: calls.append(n))
|
||||||
|
monkeypatch.setattr(runner.os, "environ", {})
|
||||||
|
runner._deprioritize()
|
||||||
|
assert calls == [12]
|
||||||
|
|
||||||
|
def test_nice_zero_does_not_renice(self, monkeypatch):
|
||||||
|
monkeypatch.setattr(settings, "download_child_nice", 0)
|
||||||
|
monkeypatch.setattr(settings, "download_child_ionice_class", 0)
|
||||||
|
calls = []
|
||||||
|
monkeypatch.setattr(runner.os, "nice", lambda n: calls.append(n))
|
||||||
|
monkeypatch.setattr(runner.os, "environ", {})
|
||||||
|
runner._deprioritize()
|
||||||
|
assert calls == []
|
||||||
|
|
||||||
|
def test_sets_omp_budget_to_thread_cap(self, monkeypatch):
|
||||||
|
monkeypatch.setattr(settings, "download_child_nice", 0)
|
||||||
|
monkeypatch.setattr(settings, "download_child_ionice_class", 0)
|
||||||
|
monkeypatch.setattr(settings, "download_ffmpeg_threads", 4)
|
||||||
|
env: dict = {}
|
||||||
|
monkeypatch.setattr(runner.os, "environ", env)
|
||||||
|
runner._deprioritize()
|
||||||
|
assert env["OMP_NUM_THREADS"] == "4"
|
||||||
|
|
||||||
|
def test_ionice_invoked_when_binary_present(self, monkeypatch):
|
||||||
|
monkeypatch.setattr(settings, "download_child_nice", 0)
|
||||||
|
monkeypatch.setattr(settings, "download_child_ionice_class", 3)
|
||||||
|
monkeypatch.setattr(runner.os, "environ", {})
|
||||||
|
monkeypatch.setattr(runner.shutil, "which", lambda name: "/usr/bin/ionice")
|
||||||
|
ran = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
runner.subprocess,
|
||||||
|
"run",
|
||||||
|
lambda cmd, **kw: ran.append(cmd) or subprocess.CompletedProcess(cmd, 0),
|
||||||
|
)
|
||||||
|
runner._deprioritize()
|
||||||
|
assert ran and ran[0][:3] == ["ionice", "-c", "3"]
|
||||||
|
|
||||||
|
def test_ionice_skipped_when_binary_absent(self, monkeypatch):
|
||||||
|
monkeypatch.setattr(settings, "download_child_nice", 0)
|
||||||
|
monkeypatch.setattr(settings, "download_child_ionice_class", 3)
|
||||||
|
monkeypatch.setattr(runner.os, "environ", {})
|
||||||
|
monkeypatch.setattr(runner.shutil, "which", lambda name: None)
|
||||||
|
ran = []
|
||||||
|
monkeypatch.setattr(runner.subprocess, "run", lambda cmd, **kw: ran.append(cmd))
|
||||||
|
runner._deprioritize()
|
||||||
|
assert ran == []
|
||||||
@@ -14,6 +14,13 @@ def test_real_ytdlp_messages_map_to_categories():
|
|||||||
"ERROR: [youtube] abc: Sign in to confirm you're not a bot": "login_required",
|
"ERROR: [youtube] abc: Sign in to confirm you're not a bot": "login_required",
|
||||||
"ERROR: [youtube] abc: Join this channel to get access to members-only content": "login_required",
|
"ERROR: [youtube] abc: Join this channel to get access to members-only content": "login_required",
|
||||||
"ERROR: [Reddit] 1vi1x56: Account authentication is required.": "login_required",
|
"ERROR: [Reddit] 1vi1x56: Account authentication is required.": "login_required",
|
||||||
|
# Closed-group / private-post auth walls: yt-dlp either says to sign in or suggests cookies.
|
||||||
|
"ERROR: [facebook] 123: You must be logged in to see this post": "login_required",
|
||||||
|
"ERROR: [facebook] 123: Login required. Use --cookies for the authentication.": "login_required",
|
||||||
|
"ERROR: [instagram] abc: This content is only available for registered users": "login_required",
|
||||||
|
"ERROR: [youtube] abc: Sign in to confirm... Use --cookies-from-browser or --cookies": "login_required",
|
||||||
|
# A site yt-dlp has no extractor for at all — distinct from an auth wall, still non-retryable.
|
||||||
|
"ERROR: Unsupported URL: https://example.com/whatever": "unsupported_host",
|
||||||
"ERROR: [youtube] abc: The uploader has not made this video available in your country": "geo_blocked",
|
"ERROR: [youtube] abc: The uploader has not made this video available in your country": "geo_blocked",
|
||||||
"ERROR: [youtube] abc: Requested format is not available": "format_unavailable",
|
"ERROR: [youtube] abc: Requested format is not available": "format_unavailable",
|
||||||
"ERROR: [youtube] abc: Only images are available for download": "format_unavailable",
|
"ERROR: [youtube] abc: Only images are available for download": "format_unavailable",
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ with "PosixPath('.') has an empty name" instead of downloading."""
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app.downloads.edit import _concat_quote, parse_probe_streams
|
from app.downloads.edit import _codecs_from_streams, _concat_quote, run_ffprobe_json
|
||||||
from app.downloads.pipeline import _media_info, _produced_file
|
from app.downloads.pipeline import _media_info, _produced_file
|
||||||
from pathlib import Path, PurePosixPath
|
from pathlib import Path, PurePosixPath
|
||||||
|
|
||||||
@@ -105,39 +105,76 @@ class TestMediaInfo:
|
|||||||
assert _media_info(info)["id"] == "vid"
|
assert _media_info(info)["id"] == "vid"
|
||||||
|
|
||||||
|
|
||||||
class TestParseProbeStreams:
|
class TestCodecsFromStreams:
|
||||||
# ffprobe emits codec_name BEFORE codec_type, so the old line parser shifted every name onto
|
# ffprobe emits codec_name BEFORE codec_type, so the old line parser shifted every name onto
|
||||||
# the previous stream and an H.264+AAC mp4 came back as (aac, None) → a needless re-encode.
|
# the previous stream and an H.264+AAC mp4 came back as (aac, None) → a needless re-encode.
|
||||||
|
# _codecs_from_streams reads each stream dict by its own codec_type, immune to that ordering.
|
||||||
def test_reads_each_stream_by_its_own_type(self):
|
def test_reads_each_stream_by_its_own_type(self):
|
||||||
payload = '{"streams":[{"codec_name":"h264","codec_type":"video"},{"codec_name":"aac","codec_type":"audio"}]}'
|
streams = [
|
||||||
assert parse_probe_streams(payload) == ("h264", "aac")
|
{"codec_name": "h264", "codec_type": "video"},
|
||||||
|
{"codec_name": "aac", "codec_type": "audio"},
|
||||||
|
]
|
||||||
|
assert _codecs_from_streams(streams) == ("h264", "aac")
|
||||||
|
|
||||||
def test_audio_first_ordering_is_handled(self):
|
def test_audio_first_ordering_is_handled(self):
|
||||||
payload = '{"streams":[{"codec_name":"AAC","codec_type":"audio"},{"codec_name":"H264","codec_type":"video"}]}'
|
streams = [
|
||||||
assert parse_probe_streams(payload) == ("h264", "aac")
|
{"codec_name": "AAC", "codec_type": "audio"},
|
||||||
|
{"codec_name": "H264", "codec_type": "video"},
|
||||||
|
]
|
||||||
|
assert _codecs_from_streams(streams) == ("h264", "aac")
|
||||||
|
|
||||||
def test_extra_streams_do_not_win_over_the_first_of_their_kind(self):
|
def test_extra_streams_do_not_win_over_the_first_of_their_kind(self):
|
||||||
payload = (
|
streams = [
|
||||||
'{"streams":[{"codec_name":"h264","codec_type":"video"},'
|
{"codec_name": "h264", "codec_type": "video"},
|
||||||
'{"codec_name":"vp9","codec_type":"video"},'
|
{"codec_name": "vp9", "codec_type": "video"},
|
||||||
'{"codec_name":"mov_text","codec_type":"subtitle"},'
|
{"codec_name": "mov_text", "codec_type": "subtitle"},
|
||||||
'{"codec_name":"opus","codec_type":"audio"}]}'
|
{"codec_name": "opus", "codec_type": "audio"},
|
||||||
)
|
]
|
||||||
assert parse_probe_streams(payload) == ("h264", "opus")
|
assert _codecs_from_streams(streams) == ("h264", "opus")
|
||||||
|
|
||||||
def test_audio_only_and_video_only(self):
|
def test_audio_only_and_video_only(self):
|
||||||
assert parse_probe_streams('{"streams":[{"codec_name":"opus","codec_type":"audio"}]}') == (
|
assert _codecs_from_streams([{"codec_name": "opus", "codec_type": "audio"}]) == (
|
||||||
None,
|
None,
|
||||||
"opus",
|
"opus",
|
||||||
)
|
)
|
||||||
assert parse_probe_streams('{"streams":[{"codec_name":"av1","codec_type":"video"}]}') == (
|
assert _codecs_from_streams([{"codec_name": "av1", "codec_type": "video"}]) == ("av1", None)
|
||||||
"av1",
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_garbage_is_not_a_crash(self):
|
def test_empty_stream_list(self):
|
||||||
assert parse_probe_streams("") == (None, None)
|
assert _codecs_from_streams([]) == (None, None)
|
||||||
assert parse_probe_streams("not json") == (None, None)
|
|
||||||
|
|
||||||
|
class TestRunFfprobeJson:
|
||||||
|
"""The single ffprobe-JSON invocation point (shared by probe_codecs + upload._probe). A failed
|
||||||
|
probe or unparseable output must degrade to None, not raise — the malformed-input safety the old
|
||||||
|
parse_probe_streams string parser used to own now lives here."""
|
||||||
|
|
||||||
|
def test_unparseable_output_returns_none(self, monkeypatch):
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
class _R:
|
||||||
|
stdout = "not json"
|
||||||
|
|
||||||
|
monkeypatch.setattr(subprocess, "run", lambda *a, **k: _R())
|
||||||
|
assert run_ffprobe_json(Path("x")) is None
|
||||||
|
|
||||||
|
def test_ffprobe_failure_returns_none(self, monkeypatch):
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
def _boom(*a, **k):
|
||||||
|
raise subprocess.CalledProcessError(1, "ffprobe")
|
||||||
|
|
||||||
|
monkeypatch.setattr(subprocess, "run", _boom)
|
||||||
|
assert run_ffprobe_json(Path("x")) is None
|
||||||
|
|
||||||
|
def test_parses_streams_from_valid_json(self, monkeypatch):
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
class _R:
|
||||||
|
stdout = '{"streams":[{"codec_name":"h264","codec_type":"video"}]}'
|
||||||
|
|
||||||
|
monkeypatch.setattr(subprocess, "run", lambda *a, **k: _R())
|
||||||
|
data = run_ffprobe_json(Path("x"), entries="stream=codec_type,codec_name")
|
||||||
|
assert _codecs_from_streams(data["streams"]) == ("h264", None)
|
||||||
|
|
||||||
|
|
||||||
class TestConcatQuote:
|
class TestConcatQuote:
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""build_ydl_opts: the mp4 compat profile must SELECT H.264 (not merely prefer it), so shared
|
"""build_ydl_opts: EVERY download is clamped to the mp4/H.264 compat profile (E7 browser-
|
||||||
downloads play on iOS/Safari, while custom vcodec / non-mp4 profiles opt out. See the iPad AV1
|
playability guarantee) and must SELECT H.264 (not merely prefer it), so shared downloads play in
|
||||||
broken-player fix."""
|
every browser incl. iOS/Safari. Non-mp4 containers and non-H.264 vcodecs are coerced, not honoured.
|
||||||
|
See the iPad AV1 broken-player fix + E7 format narrowing."""
|
||||||
|
|
||||||
from app.downloads import edit as editmod
|
from app.downloads import edit as editmod
|
||||||
from app.downloads import formats
|
from app.downloads import formats
|
||||||
@@ -56,16 +57,37 @@ def test_max_height_caps_every_branch():
|
|||||||
assert fmt.count("[height<=?720]") >= 3 # applied to each avc1/fallback branch
|
assert fmt.count("[height<=?720]") >= 3 # applied to each avc1/fallback branch
|
||||||
|
|
||||||
|
|
||||||
def test_explicit_vcodec_opts_out_of_avc1_selector():
|
def test_explicit_vcodec_is_clamped_to_the_compat_selector():
|
||||||
|
# E7 browser-playability guarantee: a non-H.264 vcodec no longer opts out — normalize() coerces
|
||||||
|
# vcodec→None, so the avc1 selector (and mp4 merge) is used regardless of what was requested.
|
||||||
opts = _opts({"mode": "av", "container": "mp4", "vcodec": "vp9"})
|
opts = _opts({"mode": "av", "container": "mp4", "vcodec": "vp9"})
|
||||||
assert "vcodec^=avc1" not in opts["format"]
|
assert opts["format"].startswith("bestvideo[vcodec^=avc1]")
|
||||||
assert opts["format"] == "bestvideo+bestaudio/best"
|
assert opts["merge_output_format"] == "mp4"
|
||||||
assert "vcodec:vp9" in opts["format_sort"]
|
assert "vcodec:vp9" not in opts["format_sort"]
|
||||||
|
|
||||||
|
|
||||||
def test_non_mp4_container_opts_out():
|
def test_non_mp4_container_is_clamped_to_mp4():
|
||||||
fmt = _opts({"mode": "av", "container": "webm"})["format"]
|
# A webm/mkv request is coerced to the mp4/H.264 compat profile (nothing plays mkv in-browser;
|
||||||
assert "vcodec^=avc1" not in fmt
|
# webm/VP9 fails iOS), so the avc1 selector is used and the merge target is mp4.
|
||||||
|
opts = _opts({"mode": "av", "container": "webm"})
|
||||||
|
assert opts["format"].startswith("bestvideo[vcodec^=avc1]")
|
||||||
|
assert opts["merge_output_format"] == "mp4"
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_clamps_to_browser_playable():
|
||||||
|
n = formats.normalize({"container": "mkv", "vcodec": "av1", "audio_format": "opus"})
|
||||||
|
assert n["container"] == "mp4"
|
||||||
|
assert n["vcodec"] is None
|
||||||
|
assert n["audio_format"] == "m4a"
|
||||||
|
# A playable audio choice is preserved.
|
||||||
|
assert formats.normalize({"audio_format": "mp3"})["audio_format"] == "mp3"
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_playable_specs_dedup_to_the_compat_identity():
|
||||||
|
# Two requests that differ only in (now-clamped) non-playable fields share ONE cache asset.
|
||||||
|
a = formats.format_sig({"mode": "av", "max_height": 720, "container": "webm", "vcodec": "av1"})
|
||||||
|
b = formats.format_sig({"mode": "av", "max_height": 720, "container": "mp4"})
|
||||||
|
assert a == b
|
||||||
|
|
||||||
|
|
||||||
def test_audio_only_unaffected():
|
def test_audio_only_unaffected():
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import os
|
|||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.downloads import gc as gcmod
|
||||||
from app.downloads.gc import _sweep_orphan_files
|
from app.downloads.gc import _sweep_orphan_files
|
||||||
|
|
||||||
|
|
||||||
@@ -69,3 +71,38 @@ def test_sweep_removes_orphans_but_keeps_owned_system_and_fresh(tmp_path: Path):
|
|||||||
assert not (root / "OldChan").exists()
|
assert not (root / "OldChan").exists()
|
||||||
assert sysfile.exists() # system tree untouched
|
assert sysfile.exists() # system tree untouched
|
||||||
assert fresh.exists() # too new to sweep
|
assert fresh.exists() # too new to sweep
|
||||||
|
|
||||||
|
|
||||||
|
def test_sweep_throttles_after_each_batch(tmp_path: Path, monkeypatch):
|
||||||
|
"""The whole-tree walk yields the IO channel after every `batch` files so it can't monopolize
|
||||||
|
disk IO in the API process. batch=2 over 5 touched files → 2 pauses (at file 2 and 4)."""
|
||||||
|
root = tmp_path
|
||||||
|
d = root / "Chan"
|
||||||
|
d.mkdir()
|
||||||
|
for i in range(5):
|
||||||
|
(d / f"orphan_{i}.dat").write_bytes(b"x")
|
||||||
|
for f in d.iterdir():
|
||||||
|
_aged(f, 5) # old enough that every file is "touched" by the walk
|
||||||
|
|
||||||
|
monkeypatch.setattr(settings, "download_gc_sweep_pause_ms", 10)
|
||||||
|
monkeypatch.setattr(settings, "download_gc_sweep_batch", 2)
|
||||||
|
pauses = []
|
||||||
|
monkeypatch.setattr(gcmod.time, "sleep", lambda s: pauses.append(s))
|
||||||
|
|
||||||
|
_sweep_orphan_files(_StubDB([]), str(root))
|
||||||
|
|
||||||
|
assert pauses == [0.01, 0.01] # 5 files, batch 2 → yield at #2 and #4
|
||||||
|
|
||||||
|
|
||||||
|
def test_sweep_no_throttle_when_pause_disabled(tmp_path: Path, monkeypatch):
|
||||||
|
(tmp_path / "Chan").mkdir()
|
||||||
|
f = tmp_path / "Chan" / "orphan.dat"
|
||||||
|
f.write_bytes(b"x")
|
||||||
|
_aged(f, 5)
|
||||||
|
monkeypatch.setattr(settings, "download_gc_sweep_pause_ms", 0)
|
||||||
|
pauses = []
|
||||||
|
monkeypatch.setattr(gcmod.time, "sleep", lambda s: pauses.append(s))
|
||||||
|
|
||||||
|
_sweep_orphan_files(_StubDB([]), str(tmp_path))
|
||||||
|
|
||||||
|
assert pauses == []
|
||||||
|
|||||||
@@ -4,7 +4,14 @@ token, rides in the file URL (a <video src> can't send a header), and expires.""
|
|||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
|
||||||
from app.downloads.links import _GRANT_TTL, check_grant, is_expired, make_grant, new_token
|
from app.downloads.links import (
|
||||||
|
_GRANT_TTL,
|
||||||
|
check_grant,
|
||||||
|
is_expired,
|
||||||
|
make_grant,
|
||||||
|
new_token,
|
||||||
|
public_meta,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_new_token_is_unguessable_and_unique():
|
def test_new_token_is_unguessable_and_unique():
|
||||||
@@ -48,6 +55,67 @@ class TestGrant:
|
|||||||
assert check_grant("tok", bad, 0) is False
|
assert check_grant("tok", bad, 0) is False
|
||||||
|
|
||||||
|
|
||||||
|
def _asset(**over):
|
||||||
|
base = dict(
|
||||||
|
title="Clip",
|
||||||
|
uploader="Someone",
|
||||||
|
uploader_url=None,
|
||||||
|
duration_s=42,
|
||||||
|
width=1280,
|
||||||
|
height=720,
|
||||||
|
vcodec="h264",
|
||||||
|
acodec="aac",
|
||||||
|
media_kind="video",
|
||||||
|
size_bytes=1000,
|
||||||
|
container="mp4",
|
||||||
|
source_webpage_url=None,
|
||||||
|
source_kind="url",
|
||||||
|
)
|
||||||
|
base.update(over)
|
||||||
|
return SimpleNamespace(**base)
|
||||||
|
|
||||||
|
|
||||||
|
class TestPublicMetaAudioFlag:
|
||||||
|
"""The watch page picks <audio> vs <video> from this flag; WebKit refuses an audio-only mp3 in a
|
||||||
|
<video>, so an audio-only asset (media_kind='audio') must report audio=True. The flag now reads
|
||||||
|
the stored media_kind (the single source), not a re-derived codec check."""
|
||||||
|
|
||||||
|
_link = SimpleNamespace(allow_download=True)
|
||||||
|
|
||||||
|
def test_audio_only_asset_is_flagged_audio(self):
|
||||||
|
asset = _asset(media_kind="audio", vcodec=None, acodec="mp3", width=None, height=None)
|
||||||
|
meta = public_meta(self._link, None, asset, "/f.mp3")
|
||||||
|
assert meta["audio"] is True
|
||||||
|
|
||||||
|
def test_a_video_asset_is_not_audio(self):
|
||||||
|
meta = public_meta(self._link, None, _asset(), "/f.mp4")
|
||||||
|
assert meta["audio"] is False
|
||||||
|
|
||||||
|
def test_a_generic_file_is_not_audio(self):
|
||||||
|
# A non-media upload (media_kind='file') has no player at all — never the audio bar.
|
||||||
|
meta = public_meta(self._link, None, _asset(media_kind="file"), "/f.bin")
|
||||||
|
assert meta["audio"] is False
|
||||||
|
assert meta["media_kind"] == "file"
|
||||||
|
|
||||||
|
|
||||||
|
class TestCreateLinkDefault:
|
||||||
|
"""D1 (E7 S3): a newly minted share link is downloadable by default. The one-click Share relies on
|
||||||
|
this — it POSTs no `allow_download`, so the API default decides. Forward-only lives here (the API
|
||||||
|
default), NOT in a migration: the DownloadLink column default stays False, so no existing back-
|
||||||
|
catalogue link silently flips to downloadable."""
|
||||||
|
|
||||||
|
def test_omitted_allow_download_defaults_downloadable(self):
|
||||||
|
from app.routes.downloads import CreateLinkIn
|
||||||
|
|
||||||
|
assert CreateLinkIn().allow_download is True
|
||||||
|
|
||||||
|
def test_explicit_false_is_respected(self):
|
||||||
|
# The owner can still uncheck it to mint a stream-only link.
|
||||||
|
from app.routes.downloads import CreateLinkIn
|
||||||
|
|
||||||
|
assert CreateLinkIn(allow_download=False).allow_download is False
|
||||||
|
|
||||||
|
|
||||||
class TestIsExpired:
|
class TestIsExpired:
|
||||||
def test_none_expiry_never_expires(self):
|
def test_none_expiry_never_expires(self):
|
||||||
assert is_expired(SimpleNamespace(expires_at=None)) is False
|
assert is_expired(SimpleNamespace(expires_at=None)) is False
|
||||||
|
|||||||
@@ -0,0 +1,360 @@
|
|||||||
|
"""S7 — two-way (3-way) YouTube playlist merge.
|
||||||
|
|
||||||
|
The load-bearing property: an item added (or deleted) on EITHER side survives the sync — the old
|
||||||
|
push was one-directional (local wins, deletes everything not local from YouTube), so YouTube-side
|
||||||
|
adds were lost. Pure-merge classification runs with no DB; the apply path uses the `db` lane with a
|
||||||
|
fake YouTube client so both the local rows and the simulated YouTube state can be asserted.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.models import Channel, Playlist, PlaylistItem, Video
|
||||||
|
from app.sync import playlists as pl_mod
|
||||||
|
from app.sync.playlists import (
|
||||||
|
merge_plan,
|
||||||
|
merge_playlist,
|
||||||
|
parse_baseline,
|
||||||
|
parse_unavailable,
|
||||||
|
plan_merge,
|
||||||
|
)
|
||||||
|
from app.youtube.client import YouTubeError
|
||||||
|
|
||||||
|
# --- pure merge classification (no DB) --------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestMergePlan:
|
||||||
|
def test_no_baseline_unions_losslessly(self):
|
||||||
|
# First-ever merge: can't tell an add from a delete, so keep everything, delete nothing.
|
||||||
|
mp = merge_plan(None, local=["a", "b"], youtube=["b", "c"])
|
||||||
|
assert mp.add_to_yt == ["a"] # local-only pushed out
|
||||||
|
assert mp.add_to_local == ["c"] # youtube-only pulled in
|
||||||
|
assert mp.remove_from_yt == [] and mp.remove_from_local == []
|
||||||
|
assert mp.final == ["a", "b", "c"] # local order, youtube-only appended
|
||||||
|
assert mp.baseline_known is False
|
||||||
|
|
||||||
|
def test_adds_on_both_sides_both_survive(self):
|
||||||
|
# baseline b,c ; local added 'a' ; youtube added 'd' — nothing deleted.
|
||||||
|
mp = merge_plan(["b", "c"], local=["a", "b", "c"], youtube=["b", "c", "d"])
|
||||||
|
assert mp.add_to_yt == ["a"]
|
||||||
|
assert mp.add_to_local == ["d"]
|
||||||
|
assert mp.remove_from_yt == [] and mp.remove_from_local == []
|
||||||
|
assert mp.final == ["a", "b", "c", "d"]
|
||||||
|
|
||||||
|
def test_genuine_local_delete_propagates_to_youtube(self):
|
||||||
|
# baseline a,b,c ; local removed 'b' ; youtube unchanged -> remove b from youtube.
|
||||||
|
mp = merge_plan(["a", "b", "c"], local=["a", "c"], youtube=["a", "b", "c"])
|
||||||
|
assert mp.remove_from_yt == ["b"]
|
||||||
|
assert mp.add_to_yt == [] and mp.add_to_local == []
|
||||||
|
assert mp.final == ["a", "c"]
|
||||||
|
|
||||||
|
def test_genuine_youtube_delete_propagates_to_local(self):
|
||||||
|
# baseline a,b,c ; youtube removed 'b' ; local unchanged -> remove b from local.
|
||||||
|
mp = merge_plan(["a", "b", "c"], local=["a", "b", "c"], youtube=["a", "c"])
|
||||||
|
assert mp.remove_from_local == ["b"]
|
||||||
|
assert mp.final == ["a", "c"]
|
||||||
|
|
||||||
|
def test_delete_on_both_sides_is_gone_not_a_conflict(self):
|
||||||
|
mp = merge_plan(["a", "b", "c"], local=["a", "c"], youtube=["a", "c"])
|
||||||
|
assert mp.remove_from_yt == [] and mp.remove_from_local == []
|
||||||
|
assert mp.final == ["a", "c"]
|
||||||
|
|
||||||
|
def test_a_local_delete_beats_a_stale_youtube_copy(self):
|
||||||
|
# 'b' was synced, deleted locally, still present on youtube -> it must be removed there,
|
||||||
|
# NOT resurrected into local as a youtube-add.
|
||||||
|
mp = merge_plan(["a", "b"], local=["a"], youtube=["a", "b"])
|
||||||
|
assert mp.add_to_local == []
|
||||||
|
assert mp.remove_from_yt == ["b"]
|
||||||
|
assert "b" not in mp.final
|
||||||
|
|
||||||
|
|
||||||
|
# --- apply path (db lane + fake YouTube) ------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class FakeYouTube:
|
||||||
|
"""Enough of YouTubeClient for merge_playlist: an in-memory playlist of (item_id, video_id)
|
||||||
|
and the write ops that mutate it. Every catalog video is pre-seeded so _ensure_videos never
|
||||||
|
needs get_videos."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
title: str,
|
||||||
|
items: list[str],
|
||||||
|
unavailable: set[str] | None = None,
|
||||||
|
fail_add: set[str] | None = None,
|
||||||
|
):
|
||||||
|
self.title = title
|
||||||
|
self._seq = 0
|
||||||
|
self.items = [(self._new_id(), v) for v in items]
|
||||||
|
# Ids the Data API won't return (private/deleted) — get_videos omits them, so _ensure_videos
|
||||||
|
# can't ingest them. They still live in the playlist (`items`) like a real un-ingestable video.
|
||||||
|
self.unavailable = set(unavailable or ())
|
||||||
|
# Ids whose add_playlist_item raises, to exercise the partial-failure baseline-hold path.
|
||||||
|
self.fail_add = set(fail_add or ())
|
||||||
|
|
||||||
|
def _new_id(self) -> str:
|
||||||
|
self._seq += 1
|
||||||
|
return f"it{self._seq}"
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *exc):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def create_playlist(self, title, description="", privacy="private"):
|
||||||
|
self.title = title
|
||||||
|
return "PLnew"
|
||||||
|
|
||||||
|
def get_playlist_snippet(self, pid):
|
||||||
|
return {"title": self.title}
|
||||||
|
|
||||||
|
def iter_playlist_items_with_ids(self, pid):
|
||||||
|
return [{"item_id": iid, "video_id": v} for iid, v in self.items]
|
||||||
|
|
||||||
|
def get_videos(self, ids):
|
||||||
|
# Mirror the Data API: a minimal ingestable record for each id, EXCEPT the un-ingestable
|
||||||
|
# ones (private/deleted), which are simply absent from the response.
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": v,
|
||||||
|
"snippet": {
|
||||||
|
"channelId": "UCtest",
|
||||||
|
"channelTitle": "Test",
|
||||||
|
"publishedAt": "2020-01-01T00:00:00Z",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for v in ids
|
||||||
|
if v not in self.unavailable
|
||||||
|
]
|
||||||
|
|
||||||
|
def update_playlist_title(self, pid, title):
|
||||||
|
self.title = title
|
||||||
|
|
||||||
|
def add_playlist_item(self, pid, vid, position=None):
|
||||||
|
if vid in self.fail_add:
|
||||||
|
raise YouTubeError("simulated add failure")
|
||||||
|
iid = self._new_id()
|
||||||
|
self.items.append((iid, vid))
|
||||||
|
return iid
|
||||||
|
|
||||||
|
def delete_playlist_item(self, item_id):
|
||||||
|
self.items = [(iid, v) for iid, v in self.items if iid != item_id]
|
||||||
|
|
||||||
|
def move_playlist_item(self, item_id, pid, vid, position):
|
||||||
|
row = next((r for r in self.items if r[0] == item_id), None)
|
||||||
|
if row is not None:
|
||||||
|
self.items.remove(row)
|
||||||
|
self.items.insert(position, row)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def video_ids(self) -> list[str]:
|
||||||
|
return [v for _, v in self.items]
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_videos(db, ids):
|
||||||
|
db.add(Channel(id="UCtest", title="Test"))
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
for v in ids:
|
||||||
|
db.add(Video(id=v, channel_id="UCtest", published_at=now))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _linked_playlist(db, user, *, local_ids, baseline):
|
||||||
|
pl = Playlist(
|
||||||
|
user_id=user.id,
|
||||||
|
name="Mix",
|
||||||
|
source="local",
|
||||||
|
yt_playlist_id="PLyt",
|
||||||
|
synced_video_ids=None if baseline is None else pl_mod._dump_ids(baseline),
|
||||||
|
)
|
||||||
|
db.add(pl)
|
||||||
|
db.flush()
|
||||||
|
for pos, v in enumerate(local_ids):
|
||||||
|
db.add(PlaylistItem(playlist_id=pl.id, video_id=v, position=pos))
|
||||||
|
db.commit()
|
||||||
|
return pl
|
||||||
|
|
||||||
|
|
||||||
|
def _local_ids(db, pl):
|
||||||
|
return pl_mod._local_video_ids(db, pl)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def _fake_yt(monkeypatch):
|
||||||
|
holder = {}
|
||||||
|
|
||||||
|
def install(fake):
|
||||||
|
holder["fake"] = fake
|
||||||
|
monkeypatch.setattr(pl_mod, "YouTubeClient", lambda db, user: fake)
|
||||||
|
return fake
|
||||||
|
|
||||||
|
return install
|
||||||
|
|
||||||
|
|
||||||
|
def test_adds_on_both_sides_survive_on_both_sides(db, make_user, _fake_yt):
|
||||||
|
user = make_user()
|
||||||
|
_seed_videos(db, ["a", "b", "c", "d"])
|
||||||
|
# baseline b,c ; local added 'a' ; youtube added 'd'
|
||||||
|
pl = _linked_playlist(db, user, local_ids=["a", "b", "c"], baseline=["b", "c"])
|
||||||
|
fake = _fake_yt(FakeYouTube(title="Mix", items=["b", "c", "d"]))
|
||||||
|
|
||||||
|
r = merge_playlist(db, user, pl)
|
||||||
|
|
||||||
|
assert r["failures"] == []
|
||||||
|
assert set(_local_ids(db, pl)) == {"a", "b", "c", "d"}, "youtube-add 'd' pulled into Siftlode"
|
||||||
|
assert set(fake.video_ids) == {"a", "b", "c", "d"}, "local-add 'a' pushed to YouTube"
|
||||||
|
assert r["inserted"] == 1 and r["pulled"] == 1
|
||||||
|
# Baseline advanced to the merged set so a re-sync is a no-op.
|
||||||
|
assert set(parse_baseline(pl)) == {"a", "b", "c", "d"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_genuine_delete_propagates_without_collateral_loss(db, make_user, _fake_yt):
|
||||||
|
user = make_user()
|
||||||
|
_seed_videos(db, ["a", "b", "c", "d"])
|
||||||
|
# baseline a,b,c ; local removed 'b' ; youtube added 'd' (a real add, must NOT be collateral)
|
||||||
|
pl = _linked_playlist(db, user, local_ids=["a", "c"], baseline=["a", "b", "c"])
|
||||||
|
fake = _fake_yt(FakeYouTube(title="Mix", items=["a", "b", "c", "d"]))
|
||||||
|
|
||||||
|
r = merge_playlist(db, user, pl)
|
||||||
|
|
||||||
|
assert r["failures"] == []
|
||||||
|
assert set(fake.video_ids) == {"a", "c", "d"}, "'b' removed on YouTube, 'd' kept"
|
||||||
|
assert set(_local_ids(db, pl)) == {"a", "c", "d"}, "'d' pulled in, 'b' stays deleted"
|
||||||
|
assert r["deleted"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_first_merge_with_no_baseline_never_deletes(db, make_user, _fake_yt):
|
||||||
|
user = make_user()
|
||||||
|
_seed_videos(db, ["a", "b", "c"])
|
||||||
|
pl = _linked_playlist(db, user, local_ids=["a", "b"], baseline=None)
|
||||||
|
fake = _fake_yt(FakeYouTube(title="Mix", items=["b", "c"]))
|
||||||
|
|
||||||
|
r = merge_playlist(db, user, pl)
|
||||||
|
|
||||||
|
assert r["deleted"] == 0 and r["removed_local"] == 0
|
||||||
|
assert set(_local_ids(db, pl)) == {"a", "b", "c"}
|
||||||
|
assert set(fake.video_ids) == {"a", "b", "c"}
|
||||||
|
assert parse_baseline(pl) is not None, "a baseline is recorded after the first union"
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_path_appends_and_records_baseline(db, make_user, _fake_yt):
|
||||||
|
user = make_user()
|
||||||
|
_seed_videos(db, ["a", "b"])
|
||||||
|
pl = Playlist(user_id=user.id, name="Fresh", source="local")
|
||||||
|
db.add(pl)
|
||||||
|
db.flush()
|
||||||
|
for pos, v in enumerate(["a", "b"]):
|
||||||
|
db.add(PlaylistItem(playlist_id=pl.id, video_id=v, position=pos))
|
||||||
|
db.commit()
|
||||||
|
_fake_yt(FakeYouTube(title="Fresh", items=[]))
|
||||||
|
|
||||||
|
r = merge_playlist(db, user, pl)
|
||||||
|
|
||||||
|
assert r["created"] == 1 and r["inserted"] == 2
|
||||||
|
assert pl.yt_playlist_id # created and linked
|
||||||
|
assert parse_baseline(pl) == ["a", "b"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_reorder_reaches_the_merged_order_on_youtube(db, make_user, _fake_yt):
|
||||||
|
# Same membership on both sides, but the user reordered locally to c,a,b. The apply-side
|
||||||
|
# insertion-sort must drive YouTube to that exact order — asserted as an ordered list, not a
|
||||||
|
# set, so an off-by-one or a dropped reorder pass can't pass unnoticed.
|
||||||
|
user = make_user()
|
||||||
|
_seed_videos(db, ["a", "b", "c"])
|
||||||
|
pl = _linked_playlist(db, user, local_ids=["c", "a", "b"], baseline=["a", "b", "c"])
|
||||||
|
fake = _fake_yt(FakeYouTube(title="Mix", items=["a", "b", "c"]))
|
||||||
|
|
||||||
|
r = merge_playlist(db, user, pl)
|
||||||
|
|
||||||
|
assert r["failures"] == []
|
||||||
|
assert fake.video_ids == ["c", "a", "b"], "YouTube order matches the merged (local) order"
|
||||||
|
assert r["reordered"] >= 1
|
||||||
|
assert parse_baseline(pl) == ["c", "a", "b"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_partial_youtube_failure_holds_the_baseline_for_retry(db, make_user, _fake_yt):
|
||||||
|
# A local-add whose YouTube insert fails must NOT advance the baseline — otherwise the retry
|
||||||
|
# sync would read the un-pushed add as a genuine YouTube-side delete and silently drop it.
|
||||||
|
user = make_user()
|
||||||
|
_seed_videos(db, ["a", "b", "c"])
|
||||||
|
pl = _linked_playlist(db, user, local_ids=["a", "b", "c"], baseline=["b", "c"])
|
||||||
|
fake = _fake_yt(FakeYouTube(title="Mix", items=["b", "c"], fail_add={"a"}))
|
||||||
|
|
||||||
|
r = merge_playlist(db, user, pl)
|
||||||
|
|
||||||
|
assert r["failures"] == ["a"], "the failed local-add is reported"
|
||||||
|
assert "a" not in fake.video_ids, "the add never landed on YouTube"
|
||||||
|
# Baseline is UNCHANGED so the next sync re-classifies 'a' as a local-add and re-pushes it,
|
||||||
|
# rather than treating it as a YouTube delete and removing it locally.
|
||||||
|
assert parse_baseline(pl) == ["b", "c"]
|
||||||
|
assert "a" in set(_local_ids(db, pl)), "'a' stays local for the retry"
|
||||||
|
|
||||||
|
|
||||||
|
def test_unavailable_youtube_video_converges(db, make_user, _fake_yt):
|
||||||
|
# A YouTube-only video that can't be ingested (private/deleted) must not make the playlist
|
||||||
|
# loop forever re-classifying it as a pending pull. It is recorded as un-ingestable and skipped
|
||||||
|
# by the next merge; the playlist reports "up to date".
|
||||||
|
user = make_user()
|
||||||
|
_seed_videos(db, ["a", "b"]) # note: 'x' is NOT in the catalog and can't be ingested
|
||||||
|
pl = _linked_playlist(db, user, local_ids=["a", "b"], baseline=["a", "b"])
|
||||||
|
fake = _fake_yt(FakeYouTube(title="Mix", items=["a", "b", "x"], unavailable={"x"}))
|
||||||
|
|
||||||
|
r1 = merge_playlist(db, user, pl)
|
||||||
|
|
||||||
|
assert r1["failures"] == []
|
||||||
|
assert r1["pulled"] == 0, "the un-ingestable video did not become a local row"
|
||||||
|
assert set(_local_ids(db, pl)) == {"a", "b"}
|
||||||
|
assert parse_unavailable(pl) == {"x"}, "'x' recorded as un-ingestable"
|
||||||
|
assert "x" in fake.video_ids, "'x' left untouched on YouTube"
|
||||||
|
|
||||||
|
# Second sync: the preview reports nothing pending (plan_merge filters 'x' via the stored
|
||||||
|
# unavailable set). The sync itself re-attempts ingestion of 'x' (the heal path), but since it
|
||||||
|
# still won't ingest, 'x' stays recorded as unavailable — no perpetual pending pull.
|
||||||
|
plan = plan_merge(db, user, pl)
|
||||||
|
assert plan["add_local"] == 0, "no perpetual pending pull — the playlist is up to date"
|
||||||
|
r2 = merge_playlist(db, user, pl)
|
||||||
|
assert r2["pulled"] == 0 and r2["failures"] == []
|
||||||
|
assert parse_unavailable(pl) == {"x"}
|
||||||
|
assert "x" in fake.video_ids
|
||||||
|
|
||||||
|
|
||||||
|
def test_unavailable_video_that_becomes_available_is_pulled_in(db, make_user, _fake_yt):
|
||||||
|
# A video marked un-ingestable (e.g. private) that later becomes available (public) must
|
||||||
|
# re-enter the merge on a normal sync — not stay skipped forever.
|
||||||
|
user = make_user()
|
||||||
|
_seed_videos(db, ["a", "b"])
|
||||||
|
pl = _linked_playlist(db, user, local_ids=["a", "b"], baseline=["a", "b"])
|
||||||
|
fake = _fake_yt(FakeYouTube(title="Mix", items=["a", "b", "x"], unavailable={"x"}))
|
||||||
|
|
||||||
|
merge_playlist(db, user, pl)
|
||||||
|
assert parse_unavailable(pl) == {"x"}, "recorded un-ingestable on the first sync"
|
||||||
|
|
||||||
|
# 'x' becomes available; the next sync re-attempts ingestion and pulls it in.
|
||||||
|
fake.unavailable.clear()
|
||||||
|
r2 = merge_playlist(db, user, pl)
|
||||||
|
|
||||||
|
assert r2["failures"] == []
|
||||||
|
assert "x" in set(_local_ids(db, pl)), "the now-available video is pulled into Siftlode"
|
||||||
|
assert parse_unavailable(pl) == set(), "'x' is no longer marked un-ingestable"
|
||||||
|
|
||||||
|
|
||||||
|
def test_unavailable_id_that_leaves_youtube_is_pruned(db, make_user, _fake_yt):
|
||||||
|
# A recorded un-ingestable id that is later removed from the YouTube playlist must be pruned
|
||||||
|
# from the persisted `unavailable` set (the `& yt_id_set` self-heal), not carried forever.
|
||||||
|
user = make_user()
|
||||||
|
_seed_videos(db, ["a", "b"])
|
||||||
|
pl = _linked_playlist(db, user, local_ids=["a", "b"], baseline=["a", "b"])
|
||||||
|
fake = _fake_yt(FakeYouTube(title="Mix", items=["a", "b", "x"], unavailable={"x"}))
|
||||||
|
|
||||||
|
merge_playlist(db, user, pl)
|
||||||
|
assert parse_unavailable(pl) == {"x"}, "recorded un-ingestable on the first sync"
|
||||||
|
|
||||||
|
# 'x' is removed from the YouTube playlist entirely; the next sync must drop it.
|
||||||
|
fake.items = [row for row in fake.items if row[1] != "x"]
|
||||||
|
r2 = merge_playlist(db, user, pl)
|
||||||
|
|
||||||
|
assert r2["failures"] == []
|
||||||
|
assert parse_unavailable(pl) == set(), "'x' pruned once it left YouTube"
|
||||||
@@ -9,11 +9,12 @@ Two independent pieces:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
from app.models import PlexItem, PlexLibrary, PlexState, User
|
from app.models import PlexItem, PlexLibrary, PlexState, User
|
||||||
from app.plex.watch_sync import _pull_apply
|
from app.plex.watch_sync import _pull_apply, _reconcile_inprogress_movies
|
||||||
from app.routes.plex.playback import (
|
from app.routes.plex.playback import (
|
||||||
_PUSH_THROTTLE_S,
|
_PUSH_THROTTLE_S,
|
||||||
_resume_push_superseded,
|
_resume_push_superseded,
|
||||||
@@ -33,6 +34,16 @@ def _seed_item_and_user(db):
|
|||||||
return item.id, user.id
|
return item.id, user.id
|
||||||
|
|
||||||
|
|
||||||
|
class _FakePlex:
|
||||||
|
"""Minimal PlexClient stand-in for the movie-probe tests: metadata(rating_key) → a leaf dict."""
|
||||||
|
|
||||||
|
def __init__(self, by_rk: dict[str, dict]):
|
||||||
|
self._by_rk = by_rk
|
||||||
|
|
||||||
|
def metadata(self, rating_key: str, markers: bool = False):
|
||||||
|
return self._by_rk.get(str(rating_key))
|
||||||
|
|
||||||
|
|
||||||
def test_pull_plex_wins_over_diverging_synced_local_resume(db):
|
def test_pull_plex_wins_over_diverging_synced_local_resume(db):
|
||||||
"""The core S2 change: a SYNCED local row (its state already reached Plex) that later diverges from
|
"""The core S2 change: a SYNCED local row (its state already reached Plex) that later diverges from
|
||||||
Plex = a real external Plex-side change, so the incremental pull resolves to PLEX's position — Plex
|
Plex = a real external Plex-side change, so the incremental pull resolves to PLEX's position — Plex
|
||||||
@@ -198,6 +209,130 @@ def test_pull_settles_flag_on_matching_dirty_row(db):
|
|||||||
assert st.synced_to_plex is True, "the dirty flag is settled so it isn't re-pushed endlessly"
|
assert st.synced_to_plex is True, "the dirty flag is settled so it isn't re-pushed endlessly"
|
||||||
|
|
||||||
|
|
||||||
|
def test_movie_probe_flips_stale_in_progress_to_watched(db):
|
||||||
|
"""S1 core: a local in-progress MOVIE that Plex has since finished (viewCount>0) and dropped from
|
||||||
|
Continue-Watching flips to watched on the incremental pass — without waiting for the daily reconcile.
|
||||||
|
This is the "movie stuck in progress after scrubbing the credits" bug."""
|
||||||
|
item_id, user_id = _seed_item_and_user(db)
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
db.add(
|
||||||
|
PlexState(
|
||||||
|
user_id=user_id,
|
||||||
|
item_id=item_id,
|
||||||
|
status="new",
|
||||||
|
position_seconds=300,
|
||||||
|
progress_updated_at=now,
|
||||||
|
synced_to_plex=True, # was created by a prior pull; the user then watched it on Plex
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
# Plex now reports the movie watched (viewCount>0, no offset) and it is NOT on the deck.
|
||||||
|
plex = _FakePlex({"m1": {"viewCount": 1, "lastViewedAt": int(now.timestamp())}})
|
||||||
|
out = _reconcile_inprogress_movies(
|
||||||
|
db, plex, SimpleNamespace(user_id=user_id), on_deck_ids=set()
|
||||||
|
)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
assert out == {"watched": 1, "in_progress": 0, "probed": 1}
|
||||||
|
st = (
|
||||||
|
db.execute(
|
||||||
|
select(PlexState).where(PlexState.user_id == user_id, PlexState.item_id == item_id)
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.one()
|
||||||
|
)
|
||||||
|
assert st.status == "watched"
|
||||||
|
assert st.position_seconds == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_movie_probe_skips_ondeck_and_episodes(db):
|
||||||
|
"""The probe must NOT touch (a) a movie Plex still lists on the deck — it's legitimately in
|
||||||
|
progress — nor (b) an in-progress EPISODE (episodes ride onDeck/history + rollup). Only stale
|
||||||
|
in-progress movies are probed, and metadata() is called for those alone."""
|
||||||
|
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
||||||
|
db.add(lib)
|
||||||
|
db.flush()
|
||||||
|
on_deck_movie = PlexItem(rating_key="mDeck", library_id=lib.id, kind="movie", title="On deck")
|
||||||
|
episode = PlexItem(rating_key="e1", library_id=lib.id, kind="episode", title="An episode")
|
||||||
|
db.add_all([on_deck_movie, episode])
|
||||||
|
user = User(email="probe2@example.com")
|
||||||
|
db.add(user)
|
||||||
|
db.flush()
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
for iid in (on_deck_movie.id, episode.id):
|
||||||
|
db.add(
|
||||||
|
PlexState(
|
||||||
|
user_id=user.id,
|
||||||
|
item_id=iid,
|
||||||
|
status="new",
|
||||||
|
position_seconds=300,
|
||||||
|
progress_updated_at=now,
|
||||||
|
synced_to_plex=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
# Plex would report both watched — but neither should be probed at all, so the fake returning
|
||||||
|
# "watched" must have NO effect.
|
||||||
|
plex = _FakePlex(
|
||||||
|
{
|
||||||
|
"mDeck": {"viewCount": 1, "lastViewedAt": int(now.timestamp())},
|
||||||
|
"e1": {"viewCount": 1, "lastViewedAt": int(now.timestamp())},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
out = _reconcile_inprogress_movies(
|
||||||
|
db, plex, SimpleNamespace(user_id=user.id), on_deck_ids={on_deck_movie.id}
|
||||||
|
)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
assert out == {"watched": 0, "in_progress": 0, "probed": 0}, "neither item is probed"
|
||||||
|
for iid in (on_deck_movie.id, episode.id):
|
||||||
|
st = (
|
||||||
|
db.execute(
|
||||||
|
select(PlexState).where(PlexState.user_id == user.id, PlexState.item_id == iid)
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.one()
|
||||||
|
)
|
||||||
|
assert st.status == "new" and st.position_seconds == 300, "left untouched"
|
||||||
|
|
||||||
|
|
||||||
|
def test_movie_probe_leaves_row_when_plex_has_no_signal(db):
|
||||||
|
"""If Plex reports no watch signal (viewCount 0, no offset) for a probed movie, the incremental pass
|
||||||
|
must NOT clear the local in-progress row — un-watch is the daily reconcile's job, and a Siftlode-only
|
||||||
|
in-progress state must survive."""
|
||||||
|
item_id, user_id = _seed_item_and_user(db)
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
db.add(
|
||||||
|
PlexState(
|
||||||
|
user_id=user_id,
|
||||||
|
item_id=item_id,
|
||||||
|
status="new",
|
||||||
|
position_seconds=300,
|
||||||
|
progress_updated_at=now,
|
||||||
|
synced_to_plex=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
plex = _FakePlex({"m1": {"viewCount": 0}})
|
||||||
|
out = _reconcile_inprogress_movies(
|
||||||
|
db, plex, SimpleNamespace(user_id=user_id), on_deck_ids=set()
|
||||||
|
)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
assert out == {"watched": 0, "in_progress": 0, "probed": 1}
|
||||||
|
st = (
|
||||||
|
db.execute(
|
||||||
|
select(PlexState).where(PlexState.user_id == user_id, PlexState.item_id == item_id)
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.one()
|
||||||
|
)
|
||||||
|
assert st.status == "new" and st.position_seconds == 300, "in-progress row preserved"
|
||||||
|
|
||||||
|
|
||||||
def test_should_push_resume_scopes_by_user_and_item():
|
def test_should_push_resume_scopes_by_user_and_item():
|
||||||
"""The throttle window is keyed by the FULL (user, item) tuple: pushing for one key must not
|
"""The throttle window is keyed by the FULL (user, item) tuple: pushing for one key must not
|
||||||
suppress a distinct user or a distinct item within the same window."""
|
suppress a distinct user or a distinct item within the same window."""
|
||||||
|
|||||||
@@ -0,0 +1,394 @@
|
|||||||
|
"""E7 S5a — local file upload ingest (the non-yt-dlp path into the media library).
|
||||||
|
|
||||||
|
Covers the ingest spine: media vs generic-file classification, the download-only serving of a
|
||||||
|
generic file (force-download so uploaded HTML/SVG can't execute inline), quota accounting with the
|
||||||
|
size known up front, the per-file cap enforced while streaming, filename sanitization, and the two
|
||||||
|
guards that keep an upload out of paths that assume a re-fetchable source (edit / redownload).
|
||||||
|
|
||||||
|
DB-backed (real Postgres) — skipped when no database is reachable. `settings.download_root` is
|
||||||
|
pointed at a per-test tmp dir so ingest's on-disk moves are isolated. The ingest/route tests
|
||||||
|
monkeypatch `_probe` so they don't shell out to ffprobe; the classifier's OWN parsing (image →
|
||||||
|
file, cover-art skip, dims, duration) is covered directly by `TestProbeClassifier` below, which
|
||||||
|
stubs one level down (`run_ffprobe_json`) and exercises the real `_probe`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.downloads import mediakind, quota, storage
|
||||||
|
from app.downloads import upload as uploadmod
|
||||||
|
from app.models import DownloadQuota, MediaAsset
|
||||||
|
|
||||||
|
|
||||||
|
# --- pure: the kind invariant ---------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_derive_media_kind():
|
||||||
|
assert uploadmod.mediakind.derive("h264", "aac") == mediakind.VIDEO
|
||||||
|
assert uploadmod.mediakind.derive("vp9", None) == mediakind.VIDEO
|
||||||
|
assert uploadmod.mediakind.derive(None, "aac") == mediakind.AUDIO
|
||||||
|
assert uploadmod.mediakind.derive(None, None) == mediakind.FILE
|
||||||
|
|
||||||
|
|
||||||
|
# --- ingest (direct) ------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def _root(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setattr(settings, "download_root", str(tmp_path))
|
||||||
|
return tmp_path
|
||||||
|
|
||||||
|
|
||||||
|
def _probe(monkeypatch, result):
|
||||||
|
monkeypatch.setattr(uploadmod, "_probe", lambda _p: result)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ingest_generic_file_is_download_only(db, make_user, _root, monkeypatch):
|
||||||
|
_probe(monkeypatch, (None, None, None, None, None))
|
||||||
|
u = make_user()
|
||||||
|
src = _root / "src.bin"
|
||||||
|
src.write_bytes(b"%PDF-1.4 hello")
|
||||||
|
job = uploadmod.ingest_upload(
|
||||||
|
db, u.id, filename="My Notes.pdf", src_path=src, size=src.stat().st_size
|
||||||
|
)
|
||||||
|
asset = db.get(MediaAsset, job.asset_id)
|
||||||
|
assert asset.media_kind == mediakind.FILE
|
||||||
|
assert asset.status == "ready" and job.status == "done" and job.progress == 100
|
||||||
|
assert asset.container == "pdf"
|
||||||
|
assert asset.poster_path is None # a generic file has no player and no poster
|
||||||
|
assert asset.title == "My Notes" # display title from the original name
|
||||||
|
assert job.job_kind == "upload" and job.source_kind == "upload"
|
||||||
|
# The file was MOVED into the per-user uploads tree, inside the root, and the temp is gone.
|
||||||
|
assert not src.exists()
|
||||||
|
assert asset.rel_path.startswith(".uploads/")
|
||||||
|
assert storage.safe_abs_path(str(_root), asset.rel_path) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_ingest_audio_is_media_no_poster(db, make_user, _root, monkeypatch):
|
||||||
|
_probe(monkeypatch, (None, "aac", None, None, 200))
|
||||||
|
u = make_user()
|
||||||
|
src = _root / "song.m4a"
|
||||||
|
src.write_bytes(b"fake-audio")
|
||||||
|
job = uploadmod.ingest_upload(db, u.id, filename="song.m4a", src_path=src, size=10)
|
||||||
|
asset = db.get(MediaAsset, job.asset_id)
|
||||||
|
assert asset.media_kind == mediakind.AUDIO
|
||||||
|
assert asset.duration_s == 200
|
||||||
|
assert asset.poster_path is None # audio has no frame to poster
|
||||||
|
|
||||||
|
|
||||||
|
def test_ingest_video_records_dims_and_poster(db, make_user, _root, monkeypatch):
|
||||||
|
_probe(monkeypatch, ("h264", "aac", 640, 480, 60))
|
||||||
|
# Stub the frame-cut so the test doesn't shell out to ffmpeg.
|
||||||
|
monkeypatch.setattr(uploadmod.editmod, "ensure_poster", lambda _a, _root: "p/x.jpg")
|
||||||
|
u = make_user()
|
||||||
|
src = _root / "clip.mp4"
|
||||||
|
src.write_bytes(b"fake-video")
|
||||||
|
job = uploadmod.ingest_upload(db, u.id, filename="clip.mp4", src_path=src, size=10)
|
||||||
|
asset = db.get(MediaAsset, job.asset_id)
|
||||||
|
assert asset.media_kind == mediakind.VIDEO
|
||||||
|
assert (asset.width, asset.height) == (640, 480)
|
||||||
|
assert asset.poster_path == "p/x.jpg"
|
||||||
|
|
||||||
|
|
||||||
|
def test_ingest_filename_is_sanitized_no_traversal(db, make_user, _root, monkeypatch):
|
||||||
|
_probe(monkeypatch, (None, None, None, None, None))
|
||||||
|
u = make_user()
|
||||||
|
src = _root / "src"
|
||||||
|
src.write_bytes(b"x")
|
||||||
|
job = uploadmod.ingest_upload(db, u.id, filename="../../etc/passwd", src_path=src, size=1)
|
||||||
|
asset = db.get(MediaAsset, job.asset_id)
|
||||||
|
assert ".." not in asset.rel_path
|
||||||
|
assert asset.rel_path.startswith(f".uploads/{u.id}/")
|
||||||
|
assert storage.safe_abs_path(str(_root), asset.rel_path) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_ingest_rejects_over_footprint_cap_before_writing(db, make_user, _root, monkeypatch):
|
||||||
|
_probe(monkeypatch, (None, None, None, None, None))
|
||||||
|
u = make_user()
|
||||||
|
db.add(
|
||||||
|
DownloadQuota(user_id=u.id, max_bytes=1000, max_concurrent=2, max_jobs=50, unlimited=False)
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
src = _root / "big.bin"
|
||||||
|
src.write_bytes(b"x" * 10)
|
||||||
|
with pytest.raises(quota.QuotaExceeded) as e:
|
||||||
|
uploadmod.ingest_upload(db, u.id, filename="big.bin", src_path=src, size=2000)
|
||||||
|
assert e.value.reason == "max_bytes"
|
||||||
|
assert src.exists() # rejected before any disk move — nothing landed
|
||||||
|
assert db.query(MediaAsset).count() == 0 # and no orphan asset row
|
||||||
|
|
||||||
|
|
||||||
|
# --- route ----------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_route_generic_forces_download(as_user, make_user, _root, monkeypatch):
|
||||||
|
_probe(monkeypatch, (None, None, None, None, None))
|
||||||
|
c = as_user(make_user())
|
||||||
|
r = c.post(
|
||||||
|
"/api/downloads/upload",
|
||||||
|
files={"file": ("report.html", b"<script>alert(1)</script>", "text/html")},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
job = r.json()
|
||||||
|
assert job["asset"]["media_kind"] == "file"
|
||||||
|
assert job["can_download"] is True
|
||||||
|
# The served file must be a neutral force-download attachment — never inline text/html.
|
||||||
|
fr = c.get(f"/api/downloads/{job['id']}/file")
|
||||||
|
assert fr.status_code == 200
|
||||||
|
assert fr.headers["content-type"].startswith("application/octet-stream")
|
||||||
|
assert "attachment" in fr.headers["content-disposition"].lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_route_media_html_extension_not_served_as_html(
|
||||||
|
as_user, make_user, _root, monkeypatch
|
||||||
|
):
|
||||||
|
"""The XSS guard: a MEDIA-classified upload (real codecs) named with an executable extension must
|
||||||
|
NOT be served with a guessed text/html type. The served Content-Type comes from the verified
|
||||||
|
media_kind + a container allow-list ('html' isn't one), so it force-downloads as octet-stream —
|
||||||
|
on both the authenticated file endpoint AND the login-free public watch endpoint. Without the fix
|
||||||
|
the public inline branch let a media/HTML polyglot named evil.html run as stored XSS."""
|
||||||
|
_probe(monkeypatch, ("h264", "aac", 320, 240, 5)) # ffprobe says it's video…
|
||||||
|
c = as_user(make_user())
|
||||||
|
r = c.post(
|
||||||
|
"/api/downloads/upload",
|
||||||
|
files={"file": ("evil.html", b"<script>alert(1)</script>", "text/html")},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
job = r.json()
|
||||||
|
assert job["asset"]["media_kind"] == "video" and job["asset"]["container"] == "html"
|
||||||
|
|
||||||
|
# Authenticated endpoint: never text/html.
|
||||||
|
fr = c.get(f"/api/downloads/{job['id']}/file")
|
||||||
|
assert fr.status_code == 200
|
||||||
|
assert fr.headers["content-type"].startswith("application/octet-stream")
|
||||||
|
assert "attachment" in fr.headers["content-disposition"].lower()
|
||||||
|
|
||||||
|
# Public login-free endpoint via a STREAM-ONLY link (the inline branch) — still not text/html.
|
||||||
|
link = c.post(f"/api/downloads/{job['id']}/links", json={"allow_download": False}).json()
|
||||||
|
pr = c.get(f"/api/public/watch/{link['token']}/file")
|
||||||
|
assert pr.status_code == 200
|
||||||
|
assert not pr.headers["content-type"].startswith("text/html")
|
||||||
|
assert pr.headers["content-type"].startswith("application/octet-stream")
|
||||||
|
assert "attachment" in pr.headers["content-disposition"].lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_public_watch_file_forces_download_for_generic_file(as_user, make_user, _root, monkeypatch):
|
||||||
|
"""The login-free public serve of a generic upload (media_kind='file') must be a neutral
|
||||||
|
force-download attachment — the branch that stops uploaded HTML/SVG executing on the public
|
||||||
|
origin. This is a SEPARATE code path from the authenticated /file route, so it needs its own
|
||||||
|
route-level assertion (previously only the authenticated branch was tested)."""
|
||||||
|
_probe(monkeypatch, (None, None, None, None, None))
|
||||||
|
c = as_user(make_user())
|
||||||
|
job = c.post(
|
||||||
|
"/api/downloads/upload",
|
||||||
|
files={"file": ("notes.html", b"<script>alert(1)</script>", "text/html")},
|
||||||
|
).json()
|
||||||
|
assert job["asset"]["media_kind"] == "file"
|
||||||
|
link = c.post(f"/api/downloads/{job['id']}/links", json={"allow_download": True}).json()
|
||||||
|
pr = c.get(f"/api/public/watch/{link['token']}/file")
|
||||||
|
assert pr.status_code == 200
|
||||||
|
assert pr.headers["content-type"].startswith("application/octet-stream")
|
||||||
|
assert "attachment" in pr.headers["content-disposition"].lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_public_watch_file_serves_media_inline_with_real_type(
|
||||||
|
as_user, make_user, _root, monkeypatch
|
||||||
|
):
|
||||||
|
"""A recognised media container IS still served inline with its real audio/video type (so it
|
||||||
|
plays in the watch page) — the fix must not break legitimate playback. A stream-only link →
|
||||||
|
inline; the type is video/mp4 (from the container allow-list), never text/* and never a guess."""
|
||||||
|
_probe(monkeypatch, ("h264", "aac", 640, 360, 12))
|
||||||
|
c = as_user(make_user())
|
||||||
|
job = c.post(
|
||||||
|
"/api/downloads/upload",
|
||||||
|
files={"file": ("clip.mp4", b"fake-mp4-bytes", "video/mp4")},
|
||||||
|
).json()
|
||||||
|
link = c.post(f"/api/downloads/{job['id']}/links", json={"allow_download": False}).json()
|
||||||
|
pr = c.get(f"/api/public/watch/{link['token']}/file")
|
||||||
|
assert pr.status_code == 200
|
||||||
|
assert pr.headers["content-type"].startswith("video/mp4")
|
||||||
|
assert "inline" in pr.headers["content-disposition"].lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_route_enforces_per_file_cap(as_user, make_user, db, _root, monkeypatch):
|
||||||
|
from app import sysconfig
|
||||||
|
|
||||||
|
sysconfig.set_value(db, "download_upload_max_bytes", 5)
|
||||||
|
db.commit()
|
||||||
|
c = as_user(make_user())
|
||||||
|
r = c.post(
|
||||||
|
"/api/downloads/upload",
|
||||||
|
files={"file": ("big.bin", b"way over the cap", "application/octet-stream")},
|
||||||
|
)
|
||||||
|
assert r.status_code == 422
|
||||||
|
assert r.json()["detail"]["code"] == "upload_too_large"
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_cap_cannot_be_disabled_with_zero(db):
|
||||||
|
"""download_upload_max_bytes floors at 1: a stored 0 would read falsy in the route's
|
||||||
|
`if cap and size > cap` guard and SILENTLY disable the per-file cap, so set_value must reject it.
|
||||||
|
Pins the min=1 invariant so a revert to min=0 can't pass the gate."""
|
||||||
|
from app import sysconfig
|
||||||
|
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
sysconfig.set_value(db, "download_upload_max_bytes", 0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_route_rejects_empty_file(as_user, make_user, _root, monkeypatch):
|
||||||
|
c = as_user(make_user())
|
||||||
|
r = c.post(
|
||||||
|
"/api/downloads/upload", files={"file": ("empty.bin", b"", "application/octet-stream")}
|
||||||
|
)
|
||||||
|
assert r.status_code == 400
|
||||||
|
assert r.json()["detail"]["code"] == "upload_empty"
|
||||||
|
|
||||||
|
|
||||||
|
def test_redownload_rejects_an_upload(as_user, make_user, _root, monkeypatch):
|
||||||
|
_probe(monkeypatch, (None, None, None, None, None))
|
||||||
|
c = as_user(make_user())
|
||||||
|
job = c.post(
|
||||||
|
"/api/downloads/upload", files={"file": ("a.bin", b"data", "application/octet-stream")}
|
||||||
|
).json()
|
||||||
|
r = c.post(f"/api/downloads/{job['id']}/redownload")
|
||||||
|
assert r.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
def test_edit_rejects_a_generic_upload_source(as_user, make_user, _root, monkeypatch):
|
||||||
|
_probe(monkeypatch, (None, None, None, None, None))
|
||||||
|
c = as_user(make_user())
|
||||||
|
job = c.post(
|
||||||
|
"/api/downloads/upload", files={"file": ("a.bin", b"data", "application/octet-stream")}
|
||||||
|
).json()
|
||||||
|
r = c.post(
|
||||||
|
"/api/downloads/edit",
|
||||||
|
json={"source_job_id": job["id"], "edit_spec": {"trim": {"start_s": 0, "end_s": 1}}},
|
||||||
|
)
|
||||||
|
assert r.status_code == 400
|
||||||
|
assert r.json()["detail"]["reason"] == "not_media"
|
||||||
|
|
||||||
|
|
||||||
|
def test_ingest_rejects_over_max_jobs(db, make_user, _root, monkeypatch):
|
||||||
|
"""The other check_upload rejection branch (holdings count, not bytes): once the user is at
|
||||||
|
max_jobs, a further upload is refused before any disk write."""
|
||||||
|
_probe(monkeypatch, (None, None, None, None, None))
|
||||||
|
u = make_user()
|
||||||
|
db.add(
|
||||||
|
DownloadQuota(user_id=u.id, max_bytes=10**12, max_concurrent=2, max_jobs=1, unlimited=False)
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
src1 = _root / "a.bin"
|
||||||
|
src1.write_bytes(b"a")
|
||||||
|
uploadmod.ingest_upload(db, u.id, filename="a.bin", src_path=src1, size=1) # now 1 holding job
|
||||||
|
src2 = _root / "b.bin"
|
||||||
|
src2.write_bytes(b"b")
|
||||||
|
with pytest.raises(quota.QuotaExceeded) as e:
|
||||||
|
uploadmod.ingest_upload(db, u.id, filename="b.bin", src_path=src2, size=1)
|
||||||
|
assert e.value.reason == "max_jobs"
|
||||||
|
assert src2.exists() # rejected before the move — nothing landed
|
||||||
|
|
||||||
|
|
||||||
|
def test_ingest_cleans_up_placed_files_on_commit_failure(db, make_user, _root, monkeypatch):
|
||||||
|
"""A failure AFTER storage.place_file has moved the file onto disk must roll back AND unlink the
|
||||||
|
placed file + any (even partial) poster — otherwise the orphan is permanent (the per-user delete
|
||||||
|
keys off the rolled-back asset.rel_path, and the GC orphan sweep skips the dot-prefixed .uploads/
|
||||||
|
tree). Exercises the rollback/cleanup branch that IS the leak remediation, incl. the poster case
|
||||||
|
where ensure_poster left a partial file but returned None."""
|
||||||
|
_probe(monkeypatch, ("h264", "aac", 320, 240, 5)) # video → the poster path runs
|
||||||
|
u = make_user()
|
||||||
|
|
||||||
|
# Simulate ffmpeg leaving a partial poster on disk, then failing (ensure_poster returns None).
|
||||||
|
def _partial_poster(asset, root):
|
||||||
|
storage.abs_path(root, str(Path(asset.rel_path).with_suffix(".jpg"))).write_bytes(
|
||||||
|
b"partial"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
monkeypatch.setattr(uploadmod.editmod, "ensure_poster", _partial_poster)
|
||||||
|
|
||||||
|
# Force the commit to fail after the file (and partial poster) are on disk.
|
||||||
|
def _boom():
|
||||||
|
raise RuntimeError("db down")
|
||||||
|
|
||||||
|
monkeypatch.setattr(db, "commit", _boom)
|
||||||
|
|
||||||
|
src = _root / "clip.mp4"
|
||||||
|
src.write_bytes(b"fake-video-bytes")
|
||||||
|
with pytest.raises(RuntimeError):
|
||||||
|
uploadmod.ingest_upload(db, u.id, filename="clip.mp4", src_path=src, size=16)
|
||||||
|
|
||||||
|
db.rollback() # drop the monkeypatched-commit session state before querying
|
||||||
|
uploads = _root / ".uploads"
|
||||||
|
leftover = [p for p in uploads.rglob("*") if p.is_file()] if uploads.exists() else []
|
||||||
|
assert leftover == [], f"orphaned files left on disk: {leftover}"
|
||||||
|
assert db.query(MediaAsset).count() == 0 # the flushed asset rolled back — no dangling row
|
||||||
|
|
||||||
|
|
||||||
|
# --- classifier: the REAL _probe parsing (stubs ffprobe one level down) ----------------------
|
||||||
|
# The ingest/route tests above stub _probe wholesale; these exercise _probe's OWN logic — image
|
||||||
|
# detection, the cover-art skip, dimension/duration parsing — by faking only run_ffprobe_json, so a
|
||||||
|
# regression in the classifier the force-download guarantee leans on is actually caught.
|
||||||
|
|
||||||
|
|
||||||
|
class TestProbeClassifier:
|
||||||
|
def _probe_with(self, monkeypatch, data):
|
||||||
|
monkeypatch.setattr(uploadmod.editmod, "run_ffprobe_json", lambda *a, **k: data)
|
||||||
|
return uploadmod._probe(Path("x"))
|
||||||
|
|
||||||
|
def test_probe_failure_is_a_generic_file(self, monkeypatch):
|
||||||
|
# run_ffprobe_json returns None (ffprobe failed / not media) → all None → derive() == FILE.
|
||||||
|
v, a, w, h, d = self._probe_with(monkeypatch, None)
|
||||||
|
assert (v, a, w, h, d) == (None, None, None, None, None)
|
||||||
|
assert mediakind.derive(v, a) == mediakind.FILE
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("fmt", ["image2", "png_pipe", "jpeg_pipe", "webp_pipe", "gif"])
|
||||||
|
def test_still_image_classifies_as_file_not_video(self, monkeypatch, fmt):
|
||||||
|
# A standalone image exposes a real video stream, but its container format is an image demuxer
|
||||||
|
# — it must NOT become media_kind='video' (broken player + inline serving). Stays a file.
|
||||||
|
data = {
|
||||||
|
"format": {"format_name": fmt},
|
||||||
|
"streams": [{"codec_type": "video", "codec_name": "png", "width": 8, "height": 8}],
|
||||||
|
}
|
||||||
|
v, a, w, h, d = self._probe_with(monkeypatch, data)
|
||||||
|
assert v is None and mediakind.derive(v, a) == mediakind.FILE
|
||||||
|
|
||||||
|
def test_cover_art_video_stream_is_skipped_audio_stays_audio(self, monkeypatch):
|
||||||
|
# Album art embedded in an audio file is a video stream flagged attached_pic — skip it so the
|
||||||
|
# asset classifies as audio, not video.
|
||||||
|
data = {
|
||||||
|
"format": {"format_name": "mp3", "duration": "212.5"},
|
||||||
|
"streams": [
|
||||||
|
{
|
||||||
|
"codec_type": "video",
|
||||||
|
"codec_name": "mjpeg",
|
||||||
|
"disposition": {"attached_pic": 1},
|
||||||
|
"width": 600,
|
||||||
|
"height": 600,
|
||||||
|
},
|
||||||
|
{"codec_type": "audio", "codec_name": "mp3"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
v, a, w, h, d = self._probe_with(monkeypatch, data)
|
||||||
|
assert (v, a, d) == (None, "mp3", 212)
|
||||||
|
assert mediakind.derive(v, a) == mediakind.AUDIO
|
||||||
|
|
||||||
|
def test_real_video_records_codecs_dims_duration(self, monkeypatch):
|
||||||
|
data = {
|
||||||
|
"format": {"format_name": "mov,mp4,m4a", "duration": "60.0"},
|
||||||
|
"streams": [
|
||||||
|
{"codec_type": "video", "codec_name": "h264", "width": 1920, "height": 1080},
|
||||||
|
{"codec_type": "audio", "codec_name": "aac"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
v, a, w, h, d = self._probe_with(monkeypatch, data)
|
||||||
|
assert (v, a, w, h, d) == ("h264", "aac", 1920, 1080, 60)
|
||||||
|
assert mediakind.derive(v, a) == mediakind.VIDEO
|
||||||
|
|
||||||
|
def test_unparseable_duration_is_none(self, monkeypatch):
|
||||||
|
data = {
|
||||||
|
"format": {"format_name": "wav", "duration": "N/A"},
|
||||||
|
"streams": [{"codec_type": "audio", "codec_name": "pcm_s16le"}],
|
||||||
|
}
|
||||||
|
v, a, w, h, d = self._probe_with(monkeypatch, data)
|
||||||
|
assert d is None and mediakind.derive(v, a) == mediakind.AUDIO
|
||||||
@@ -34,6 +34,7 @@ import type { FeedView } from "../lib/feedView";
|
|||||||
import { channelYouTubeUrl, formatViews } from "../lib/format";
|
import { channelYouTubeUrl, formatViews } from "../lib/format";
|
||||||
import { ChannelTagsControl } from "./ChannelTags";
|
import { ChannelTagsControl } from "./ChannelTags";
|
||||||
import { useChannelTagActions } from "../lib/useChannelTagActions";
|
import { useChannelTagActions } from "../lib/useChannelTagActions";
|
||||||
|
import { Button } from "./ui/Button";
|
||||||
|
|
||||||
// A dedicated channel page: an "About"-style header (banner, avatar, stats, subscribe) over the
|
// A dedicated channel page: an "About"-style header (banner, avatar, stats, subscribe) over the
|
||||||
// channel's videos (the catalog filtered to this channel). For an un-subscribed channel it
|
// channel's videos (the catalog filtered to this channel). For an un-subscribed channel it
|
||||||
@@ -441,21 +442,18 @@ export default function ChannelPage({
|
|||||||
it) so a large channel's paced tail reads as visible progress rather than a stuck
|
it) so a large channel's paced tail reads as visible progress rather than a stuck
|
||||||
spinner. */}
|
spinner. */}
|
||||||
{ch?.subscribed && !ch.backfill_done && !me.is_demo && (
|
{ch?.subscribed && !ch.backfill_done && !me.is_demo && (
|
||||||
<button
|
<Button
|
||||||
|
variant="outline"
|
||||||
onClick={() => !ch.deep_requested && deepBackfill.mutate()}
|
onClick={() => !ch.deep_requested && deepBackfill.mutate()}
|
||||||
disabled={ch.deep_requested || deepBackfill.isPending}
|
disabled={ch.deep_requested || deepBackfill.isPending}
|
||||||
|
loading={ch.deep_requested}
|
||||||
|
icon={<History className="w-4 h-4" />}
|
||||||
title={
|
title={
|
||||||
ch.deep_requested
|
ch.deep_requested
|
||||||
? t("channel.fullHistoryLoadingHint")
|
? t("channel.fullHistoryLoadingHint")
|
||||||
: t("channel.fullHistory")
|
: t("channel.fullHistory")
|
||||||
}
|
}
|
||||||
className="inline-flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm border border-border text-muted hover:text-accent hover:border-accent disabled:opacity-60 disabled:hover:text-muted disabled:hover:border-border transition"
|
|
||||||
>
|
>
|
||||||
{ch.deep_requested ? (
|
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<History className="w-4 h-4" />
|
|
||||||
)}
|
|
||||||
<span className="hidden sm:inline whitespace-nowrap">
|
<span className="hidden sm:inline whitespace-nowrap">
|
||||||
{!ch.deep_requested
|
{!ch.deep_requested
|
||||||
? t("channel.fullHistory")
|
? t("channel.fullHistory")
|
||||||
@@ -466,7 +464,7 @@ export default function ChannelPage({
|
|||||||
})
|
})
|
||||||
: t("channel.fullHistoryLoading")}
|
: t("channel.fullHistoryLoading")}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{!me.is_demo && (
|
{!me.is_demo && (
|
||||||
<button
|
<button
|
||||||
@@ -504,24 +502,24 @@ export default function ChannelPage({
|
|||||||
{!me.is_demo &&
|
{!me.is_demo &&
|
||||||
!ch?.blocked &&
|
!ch?.blocked &&
|
||||||
(ch?.subscribed ? (
|
(ch?.subscribed ? (
|
||||||
<button
|
<Button
|
||||||
|
variant="outline"
|
||||||
onClick={onUnsubscribe}
|
onClick={onUnsubscribe}
|
||||||
disabled={unsubscribe.isPending}
|
disabled={unsubscribe.isPending}
|
||||||
title={t("channel.unsubscribe")}
|
title={t("channel.unsubscribe")}
|
||||||
className="inline-flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm border border-border text-muted hover:text-fg disabled:opacity-50"
|
icon={<Check className="w-4 h-4" />}
|
||||||
>
|
>
|
||||||
<Check className="w-4 h-4" />
|
|
||||||
{t("channel.subscribedState")}
|
{t("channel.subscribedState")}
|
||||||
</button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<Button
|
||||||
|
variant="outline"
|
||||||
onClick={() => subscribe.mutate()}
|
onClick={() => subscribe.mutate()}
|
||||||
disabled={subscribe.isPending}
|
disabled={subscribe.isPending}
|
||||||
className="inline-flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm btn-primary disabled:opacity-50"
|
icon={<Plus className="w-4 h-4" />}
|
||||||
>
|
>
|
||||||
<Plus className="w-4 h-4" />
|
|
||||||
{t("channel.subscribe")}
|
{t("channel.subscribe")}
|
||||||
</button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
{/* Tagging is a subscription-scoped action (attach_tag → _user_subscription 404s
|
{/* Tagging is a subscription-scoped action (attach_tag → _user_subscription 404s
|
||||||
otherwise), and personal tags are for your subscriptions — so only offer it on a
|
otherwise), and personal tags are for your subscriptions — so only offer it on a
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createContext, useCallback, useContext, useState, type ReactNode } from "react";
|
import { createContext, useCallback, useContext, useState, type ReactNode } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import Modal from "./Modal";
|
import Modal from "./Modal";
|
||||||
|
import { Button } from "./ui/Button";
|
||||||
|
|
||||||
// App-styled, promise-based replacement for window.confirm. Wrap the tree in
|
// App-styled, promise-based replacement for window.confirm. Wrap the tree in
|
||||||
// <ConfirmProvider> once, then anywhere: const confirm = useConfirm();
|
// <ConfirmProvider> once, then anywhere: const confirm = useConfirm();
|
||||||
@@ -100,26 +101,25 @@ export function ConfirmProvider({ children }: { children: ReactNode }) {
|
|||||||
<div
|
<div
|
||||||
className={multi ? "flex flex-col gap-2 mt-5" : "flex justify-end gap-2 mt-5 flex-wrap"}
|
className={multi ? "flex flex-col gap-2 mt-5" : "flex justify-end gap-2 mt-5 flex-wrap"}
|
||||||
>
|
>
|
||||||
<button
|
<Button
|
||||||
|
variant="outline"
|
||||||
autoFocus={anyDanger}
|
autoFocus={anyDanger}
|
||||||
onClick={() => close(null)}
|
onClick={() => close(null)}
|
||||||
className={`px-4 py-2 rounded-lg text-sm border border-border text-muted hover:text-fg hover:bg-surface transition ${
|
fullWidth={multi}
|
||||||
multi ? "w-full order-last" : ""
|
className={multi ? "order-last" : ""}
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{state.opts.cancelLabel ?? t("common.cancel")}
|
{state.opts.cancelLabel ?? t("common.cancel")}
|
||||||
</button>
|
</Button>
|
||||||
{choices.map((c, i) => (
|
{choices.map((c, i) => (
|
||||||
<button
|
<Button
|
||||||
key={c.id}
|
key={c.id}
|
||||||
|
variant={c.danger ? "danger" : "primary"}
|
||||||
autoFocus={!anyDanger && i === choices.length - 1}
|
autoFocus={!anyDanger && i === choices.length - 1}
|
||||||
onClick={() => close(c.id)}
|
onClick={() => close(c.id)}
|
||||||
className={`px-4 py-2 rounded-lg text-sm font-semibold transition ${
|
fullWidth={multi}
|
||||||
multi ? "w-full" : ""
|
|
||||||
} ${c.danger ? "btn-danger" : "btn-primary"}`}
|
|
||||||
>
|
>
|
||||||
{c.label || t("common.confirm")}
|
{c.label || t("common.confirm")}
|
||||||
</button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ import {
|
|||||||
Ban,
|
Ban,
|
||||||
ClipboardPaste,
|
ClipboardPaste,
|
||||||
Download,
|
Download,
|
||||||
|
Link2,
|
||||||
Pause,
|
Pause,
|
||||||
Play,
|
Play,
|
||||||
|
PlayCircle,
|
||||||
Plus,
|
Plus,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
Scissors,
|
Scissors,
|
||||||
@@ -15,6 +17,7 @@ import {
|
|||||||
Share2,
|
Share2,
|
||||||
Pencil,
|
Pencil,
|
||||||
Trash2,
|
Trash2,
|
||||||
|
UploadCloud,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Tabs from "./Tabs";
|
import Tabs from "./Tabs";
|
||||||
import { PageToolbar } from "./PageShell";
|
import { PageToolbar } from "./PageShell";
|
||||||
@@ -22,15 +25,19 @@ import { LoadingState, StateMessage } from "./QueryState";
|
|||||||
// Lazy: these dialogs/editors open on demand, so they stay out of the Downloads page chunk until
|
// Lazy: these dialogs/editors open on demand, so they stay out of the Downloads page chunk until
|
||||||
// used (the video editor in particular is heavy — filmstrip + scrubber).
|
// used (the video editor in particular is heavy — filmstrip + scrubber).
|
||||||
const DownloadDialog = lazy(() => import("./DownloadDialog"));
|
const DownloadDialog = lazy(() => import("./DownloadDialog"));
|
||||||
|
const UploadDialog = lazy(() => import("./UploadDialog"));
|
||||||
const ProfileEditor = lazy(() => import("./ProfileEditor"));
|
const ProfileEditor = lazy(() => import("./ProfileEditor"));
|
||||||
const VideoEditor = lazy(() => import("./VideoEditor"));
|
const VideoEditor = lazy(() => import("./VideoEditor"));
|
||||||
const ShareDialog = lazy(() => import("./ShareDialog"));
|
const ShareDialog = lazy(() => import("./ShareDialog"));
|
||||||
|
const LibraryPlayer = lazy(() => import("./LibraryPlayer"));
|
||||||
import { useConfirm } from "./ConfirmProvider";
|
import { useConfirm } from "./ConfirmProvider";
|
||||||
import { useLiveQuery } from "../lib/useLiveQuery";
|
import { useLiveQuery } from "../lib/useLiveQuery";
|
||||||
import { useMe } from "../lib/useMe";
|
import { useMe } from "../lib/useMe";
|
||||||
import { api, type DownloadJob } from "../lib/api";
|
import { api, type DownloadJob } from "../lib/api";
|
||||||
import { invalidateDownloads } from "../lib/downloads";
|
import { invalidateDownloads, quickShareLink } from "../lib/downloads";
|
||||||
|
import { libraryRowActions, sharedRowActions } from "../lib/downloadActions";
|
||||||
import { inputCls } from "./ui/form";
|
import { inputCls } from "./ui/form";
|
||||||
|
import { Button } from "./ui/Button";
|
||||||
import { DownloadRow, IconBtn, UsageBar } from "./downloadRow";
|
import { DownloadRow, IconBtn, UsageBar } from "./downloadRow";
|
||||||
import { MetaEditModal, ErrorModal } from "./downloadModals";
|
import { MetaEditModal, ErrorModal } from "./downloadModals";
|
||||||
import { AdminSystem } from "./downloadAdmin";
|
import { AdminSystem } from "./downloadAdmin";
|
||||||
@@ -45,13 +52,21 @@ export default function DownloadCenter() {
|
|||||||
const [tab, setTab] = useState("queue");
|
const [tab, setTab] = useState("queue");
|
||||||
const [addUrl, setAddUrl] = useState("");
|
const [addUrl, setAddUrl] = useState("");
|
||||||
const [addSource, setAddSource] = useState<string | null>(null);
|
const [addSource, setAddSource] = useState<string | null>(null);
|
||||||
|
const [upload, setUpload] = useState(false);
|
||||||
const [manage, setManage] = useState(false);
|
const [manage, setManage] = useState(false);
|
||||||
const [metaJob, setMetaJob] = useState<DownloadJob | null>(null);
|
const [metaJob, setMetaJob] = useState<DownloadJob | null>(null);
|
||||||
const [shareJob, setShareJob] = useState<DownloadJob | null>(null);
|
const [shareJob, setShareJob] = useState<DownloadJob | null>(null);
|
||||||
|
const [playJob, setPlayJob] = useState<DownloadJob | null>(null);
|
||||||
const [editJob, setEditJob] = useState<DownloadJob | null>(null);
|
const [editJob, setEditJob] = useState<DownloadJob | null>(null);
|
||||||
const [errorJob, setErrorJob] = useState<DownloadJob | null>(null);
|
const [errorJob, setErrorJob] = useState<DownloadJob | null>(null);
|
||||||
|
|
||||||
const jobsQ = useLiveQuery(qk.downloads(), api.downloads, { intervalMs: 2000 });
|
// Poll fast (2s) while work is actually in flight; back off to 15s once the queue is settled
|
||||||
|
// (only done/error/paused rows left, which don't advance on their own) so an idle Download Center
|
||||||
|
// isn't hitting the API — and adding to the shared box's load — every 2s for nothing.
|
||||||
|
const jobsQ = useLiveQuery(qk.downloads(), api.downloads, {
|
||||||
|
intervalMs: (data) =>
|
||||||
|
data?.some((j) => j.status === "queued" || j.status === "running") ? 2000 : 15000,
|
||||||
|
});
|
||||||
const sharedQ = useQuery({
|
const sharedQ = useQuery({
|
||||||
queryKey: qk.downloadsShared(),
|
queryKey: qk.downloadsShared(),
|
||||||
queryFn: api.downloadsShared,
|
queryFn: api.downloadsShared,
|
||||||
@@ -94,6 +109,21 @@ export default function DownloadCenter() {
|
|||||||
if (await confirm({ title, message: body, confirmLabel: title, danger: true })) fn();
|
if (await confirm({ title, message: body, confirmLabel: title, danger: true })) fn();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// One-click share (E7 S3 · D2-A): ensure a downloadable link, copy it, toast with "open in new tab"
|
||||||
|
// (see quickShareLink). Re-clicking reuses the existing link; a per-job in-flight guard also stops a
|
||||||
|
// double-click from racing two POSTs into two links before the cache is primed. Advanced controls
|
||||||
|
// (password, expiry, revoke, share-with-a-user) live in the Share dialog, via the manage-share button.
|
||||||
|
const [sharingId, setSharingId] = useState<number | null>(null);
|
||||||
|
const quickShare = async (job: DownloadJob) => {
|
||||||
|
if (sharingId !== null) return;
|
||||||
|
setSharingId(job.id);
|
||||||
|
try {
|
||||||
|
await quickShareLink(qc, job, t);
|
||||||
|
} finally {
|
||||||
|
setSharingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Pull a link off the clipboard into the add-URL box. Clipboard reads need a secure context +
|
// Pull a link off the clipboard into the add-URL box. Clipboard reads need a secure context +
|
||||||
// a user gesture/permission (iOS Safari/Brave are strict) — so this is best-effort: the paste
|
// a user gesture/permission (iOS Safari/Brave are strict) — so this is best-effort: the paste
|
||||||
// button is an explicit gesture (reliable), and the input's onFocus tries silently (ignored if
|
// button is an explicit gesture (reliable), and the input's onFocus tries silently (ignored if
|
||||||
@@ -163,20 +193,34 @@ export default function DownloadCenter() {
|
|||||||
placeholder={t("downloads.page.addUrl")}
|
placeholder={t("downloads.page.addUrl")}
|
||||||
className={inputCls}
|
className={inputCls}
|
||||||
/>
|
/>
|
||||||
<button
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
iconOnly
|
||||||
|
icon={<ClipboardPaste className="w-4 h-4" />}
|
||||||
|
label={t("downloads.page.paste")}
|
||||||
onClick={() => pasteFromClipboard(true)}
|
onClick={() => pasteFromClipboard(true)}
|
||||||
title={t("downloads.page.paste")}
|
className="shrink-0"
|
||||||
aria-label={t("downloads.page.paste")}
|
/>
|
||||||
className="inline-flex items-center justify-center px-2.5 py-1.5 rounded-lg text-muted hover:text-fg hover:bg-surface transition shrink-0"
|
<Button
|
||||||
>
|
variant="primary"
|
||||||
<ClipboardPaste className="w-4 h-4" />
|
size="sm"
|
||||||
</button>
|
icon={<Plus className="w-4 h-4" />}
|
||||||
<button
|
|
||||||
onClick={() => addUrl.trim() && setAddSource(addUrl.trim())}
|
onClick={() => addUrl.trim() && setAddSource(addUrl.trim())}
|
||||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium btn-primary transition shrink-0"
|
className="shrink-0"
|
||||||
>
|
>
|
||||||
<Plus className="w-4 h-4" /> {t("downloads.page.add")}
|
{t("downloads.page.add")}
|
||||||
</button>
|
</Button>
|
||||||
|
{/* Local upload is a peer to "add a URL": a general file → the same library (E7 S5c). */}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
icon={<UploadCloud className="w-4 h-4" />}
|
||||||
|
onClick={() => setUpload(true)}
|
||||||
|
className="shrink-0"
|
||||||
|
>
|
||||||
|
{t("downloads.page.upload")}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{queue.map((job) => (
|
{queue.map((job) => (
|
||||||
@@ -236,50 +280,69 @@ export default function DownloadCenter() {
|
|||||||
<>
|
<>
|
||||||
<UsageBar />
|
<UsageBar />
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{library.map((job) => (
|
{library.map((job) => {
|
||||||
<DownloadRow key={job.id} job={job}>
|
// Row actions gated on the stored media_kind + job_kind (see downloadActions).
|
||||||
{saveBtn(job)}
|
const a = libraryRowActions(job);
|
||||||
{job.can_download && (job.asset?.duration_s ?? 0) > 0 && (
|
return (
|
||||||
<IconBtn onClick={() => setEditJob(job)} title={t("downloads.actions.edit")}>
|
<DownloadRow key={job.id} job={job}>
|
||||||
<Scissors className="w-4 h-4" />
|
{a.play && (
|
||||||
|
<IconBtn onClick={() => setPlayJob(job)} title={t("downloads.actions.play")}>
|
||||||
|
<PlayCircle className="w-4 h-4" />
|
||||||
|
</IconBtn>
|
||||||
|
)}
|
||||||
|
{saveBtn(job)}
|
||||||
|
{a.edit && (
|
||||||
|
<IconBtn onClick={() => setEditJob(job)} title={t("downloads.actions.edit")}>
|
||||||
|
<Scissors className="w-4 h-4" />
|
||||||
|
</IconBtn>
|
||||||
|
)}
|
||||||
|
{a.editDetails && (
|
||||||
|
<IconBtn
|
||||||
|
onClick={() => setMetaJob(job)}
|
||||||
|
title={t("downloads.actions.editDetails")}
|
||||||
|
>
|
||||||
|
<Pencil className="w-4 h-4" />
|
||||||
|
</IconBtn>
|
||||||
|
)}
|
||||||
|
<IconBtn onClick={() => quickShare(job)} title={t("downloads.actions.share")}>
|
||||||
|
<Share2 className="w-4 h-4" />
|
||||||
</IconBtn>
|
</IconBtn>
|
||||||
)}
|
<IconBtn
|
||||||
<IconBtn
|
onClick={() => setShareJob(job)}
|
||||||
onClick={() => setMetaJob(job)}
|
title={t("downloads.actions.shareManage")}
|
||||||
title={t("downloads.actions.editDetails")}
|
>
|
||||||
>
|
<Link2 className="w-4 h-4" />
|
||||||
<Pencil className="w-4 h-4" />
|
</IconBtn>
|
||||||
</IconBtn>
|
{a.redownload && (
|
||||||
<IconBtn onClick={() => setShareJob(job)} title={t("downloads.actions.share")}>
|
<IconBtn
|
||||||
<Share2 className="w-4 h-4" />
|
onClick={() =>
|
||||||
</IconBtn>
|
confirmThen(
|
||||||
<IconBtn
|
t("downloads.confirm.redownloadTitle"),
|
||||||
onClick={() =>
|
t("downloads.confirm.redownloadBody"),
|
||||||
confirmThen(
|
() => act.mutate({ id: job.id, op: "redownload" }),
|
||||||
t("downloads.confirm.redownloadTitle"),
|
)
|
||||||
t("downloads.confirm.redownloadBody"),
|
}
|
||||||
() => act.mutate({ id: job.id, op: "redownload" }),
|
title={t("downloads.actions.redownload")}
|
||||||
)
|
>
|
||||||
}
|
<RotateCcw className="w-4 h-4" />
|
||||||
title={t("downloads.actions.redownload")}
|
</IconBtn>
|
||||||
>
|
)}
|
||||||
<RotateCcw className="w-4 h-4" />
|
<IconBtn
|
||||||
</IconBtn>
|
onClick={() =>
|
||||||
<IconBtn
|
confirmThen(
|
||||||
onClick={() =>
|
t("downloads.confirm.deleteTitle"),
|
||||||
confirmThen(
|
t("downloads.confirm.deleteBody"),
|
||||||
t("downloads.confirm.deleteTitle"),
|
() => act.mutate({ id: job.id, op: "delete" }),
|
||||||
t("downloads.confirm.deleteBody"),
|
)
|
||||||
() => act.mutate({ id: job.id, op: "delete" }),
|
}
|
||||||
)
|
title={t("downloads.actions.delete")}
|
||||||
}
|
danger
|
||||||
title={t("downloads.actions.delete")}
|
>
|
||||||
danger
|
<Trash2 className="w-4 h-4" />
|
||||||
>
|
</IconBtn>
|
||||||
<Trash2 className="w-4 h-4" />
|
</DownloadRow>
|
||||||
</IconBtn>
|
);
|
||||||
</DownloadRow>
|
})}
|
||||||
))}
|
|
||||||
{library.length === 0 &&
|
{library.length === 0 &&
|
||||||
(jobsQ.isError && !jobsQ.data ? (
|
(jobsQ.isError && !jobsQ.data ? (
|
||||||
<StateMessage tone="error" onRetry={() => jobsQ.refetch()}>
|
<StateMessage tone="error" onRetry={() => jobsQ.refetch()}>
|
||||||
@@ -298,34 +361,50 @@ export default function DownloadCenter() {
|
|||||||
|
|
||||||
{tab === "shared" && (
|
{tab === "shared" && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{(sharedQ.data ?? []).map((job) => (
|
{(sharedQ.data ?? []).map((job) => {
|
||||||
<DownloadRow key={job.id} job={job}>
|
// Same media_kind + can_download gate as the library, via the shared helper (S5b).
|
||||||
{job.can_download && saveBtn(job)}
|
const a = sharedRowActions(job);
|
||||||
{job.can_download && (job.asset?.duration_s ?? 0) > 0 && (
|
return (
|
||||||
<IconBtn onClick={() => setEditJob(job)} title={t("downloads.actions.edit")}>
|
<DownloadRow key={job.id} job={job}>
|
||||||
<Scissors className="w-4 h-4" />
|
{a.play && (
|
||||||
|
<IconBtn onClick={() => setPlayJob(job)} title={t("downloads.actions.play")}>
|
||||||
|
<PlayCircle className="w-4 h-4" />
|
||||||
|
</IconBtn>
|
||||||
|
)}
|
||||||
|
{a.save && saveBtn(job)}
|
||||||
|
{a.edit && (
|
||||||
|
<IconBtn onClick={() => setEditJob(job)} title={t("downloads.actions.edit")}>
|
||||||
|
<Scissors className="w-4 h-4" />
|
||||||
|
</IconBtn>
|
||||||
|
)}
|
||||||
|
<IconBtn
|
||||||
|
onClick={() =>
|
||||||
|
confirmThen(
|
||||||
|
t("downloads.confirm.removeSharedTitle"),
|
||||||
|
t("downloads.confirm.removeSharedBody"),
|
||||||
|
() => removeShared.mutate(job.id),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
title={t("downloads.actions.removeShared")}
|
||||||
|
danger
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
</IconBtn>
|
</IconBtn>
|
||||||
)}
|
</DownloadRow>
|
||||||
<IconBtn
|
);
|
||||||
onClick={() =>
|
})}
|
||||||
confirmThen(
|
{(sharedQ.data ?? []).length === 0 &&
|
||||||
t("downloads.confirm.removeSharedTitle"),
|
(sharedQ.isError && !sharedQ.data ? (
|
||||||
t("downloads.confirm.removeSharedBody"),
|
<StateMessage tone="error" onRetry={() => sharedQ.refetch()}>
|
||||||
() => removeShared.mutate(job.id),
|
{t("common.loadError")}
|
||||||
)
|
</StateMessage>
|
||||||
}
|
) : sharedQ.isLoading ? (
|
||||||
title={t("downloads.actions.removeShared")}
|
<LoadingState />
|
||||||
danger
|
) : (
|
||||||
>
|
<div className="text-sm text-muted py-10 text-center">
|
||||||
<Trash2 className="w-4 h-4" />
|
{t("downloads.empty.shared")}
|
||||||
</IconBtn>
|
</div>
|
||||||
</DownloadRow>
|
))}
|
||||||
))}
|
|
||||||
{sharedQ.data && sharedQ.data.length === 0 && (
|
|
||||||
<div className="text-sm text-muted py-10 text-center">
|
|
||||||
{t("downloads.empty.shared")}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -341,9 +420,11 @@ export default function DownloadCenter() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{upload && <UploadDialog onClose={() => setUpload(false)} />}
|
||||||
{manage && <ProfileEditor onClose={() => setManage(false)} />}
|
{manage && <ProfileEditor onClose={() => setManage(false)} />}
|
||||||
{metaJob && <MetaEditModal job={metaJob} onClose={() => setMetaJob(null)} />}
|
{metaJob && <MetaEditModal job={metaJob} onClose={() => setMetaJob(null)} />}
|
||||||
{shareJob && <ShareDialog job={shareJob} onClose={() => setShareJob(null)} />}
|
{shareJob && <ShareDialog job={shareJob} onClose={() => setShareJob(null)} />}
|
||||||
|
{playJob && <LibraryPlayer job={playJob} onClose={() => setPlayJob(null)} />}
|
||||||
{editJob && <VideoEditor job={editJob} onClose={() => setEditJob(null)} />}
|
{editJob && <VideoEditor job={editJob} onClose={() => setEditJob(null)} />}
|
||||||
{errorJob && (
|
{errorJob && (
|
||||||
<ErrorModal
|
<ErrorModal
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { qk } from "../lib/queryKeys";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import Modal from "./Modal";
|
import Modal from "./Modal";
|
||||||
|
import { Button } from "./ui/Button";
|
||||||
import { api } from "../lib/api";
|
import { api } from "../lib/api";
|
||||||
import { invalidateDownloads } from "../lib/downloads";
|
import { invalidateDownloads } from "../lib/downloads";
|
||||||
|
|
||||||
@@ -89,19 +90,12 @@ export default function DownloadDialog({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
<button
|
<Button variant="ghost" size="sm" onClick={onClose}>
|
||||||
onClick={onClose}
|
|
||||||
className="px-3 py-1.5 rounded-lg text-sm text-muted hover:text-fg hover:bg-surface transition"
|
|
||||||
>
|
|
||||||
{t("downloads.actions.cancel")}
|
{t("downloads.actions.cancel")}
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button variant="primary" size="sm" onClick={submit} disabled={busy || !chosen}>
|
||||||
onClick={submit}
|
|
||||||
disabled={busy || !chosen}
|
|
||||||
className="px-3 py-1.5 rounded-lg text-sm font-medium btn-primary disabled:opacity-50 transition"
|
|
||||||
>
|
|
||||||
{t("downloads.dialog.submit")}
|
{t("downloads.dialog.submit")}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{manage && (
|
{manage && (
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useSyncExternalStore } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { dismissError, getError, subscribeError } from "../lib/errorDialog";
|
import { dismissError, getError, subscribeError } from "../lib/errorDialog";
|
||||||
import Modal from "./Modal";
|
import Modal from "./Modal";
|
||||||
|
import { Button } from "./ui/Button";
|
||||||
|
|
||||||
// Renders the current app error (if any) as a modal the user must acknowledge.
|
// Renders the current app error (if any) as a modal the user must acknowledge.
|
||||||
export default function ErrorDialog() {
|
export default function ErrorDialog() {
|
||||||
@@ -12,12 +13,7 @@ export default function ErrorDialog() {
|
|||||||
<Modal title={err.title} onClose={dismissError} maxWidth="max-w-md">
|
<Modal title={err.title} onClose={dismissError} maxWidth="max-w-md">
|
||||||
<p className="text-sm leading-relaxed text-fg/90">{err.message}</p>
|
<p className="text-sm leading-relaxed text-fg/90">{err.message}</p>
|
||||||
<div className="flex justify-end mt-5">
|
<div className="flex justify-end mt-5">
|
||||||
<button
|
<Button onClick={dismissError}>{t("errors.ok")}</Button>
|
||||||
onClick={dismissError}
|
|
||||||
className="px-4 py-2 rounded-lg btn-primary text-sm font-medium transition"
|
|
||||||
>
|
|
||||||
{t("errors.ok")}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
//
|
||||||
|
// E7·S4b — the in-app library player streams the completed item's LOCAL file into a native media
|
||||||
|
// element. video/audio is chosen from the stored media_kind (the single source), NOT a container
|
||||||
|
// list — so an audio upload with an uncommon/absent container still renders <audio src=/file>. i18n
|
||||||
|
// is key-passthrough here.
|
||||||
|
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||||
|
import { render, cleanup } from "@testing-library/react";
|
||||||
|
|
||||||
|
vi.mock("react-i18next", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||||
|
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
|
||||||
|
});
|
||||||
|
|
||||||
|
import LibraryPlayer from "./LibraryPlayer";
|
||||||
|
import type { DownloadJob } from "../lib/api";
|
||||||
|
|
||||||
|
const baseJob: DownloadJob = {
|
||||||
|
id: 7,
|
||||||
|
status: "done",
|
||||||
|
progress: 100,
|
||||||
|
speed_bps: null,
|
||||||
|
eta_s: null,
|
||||||
|
phase: null,
|
||||||
|
error: null,
|
||||||
|
error_code: null,
|
||||||
|
queue_pos: 0,
|
||||||
|
created_at: null,
|
||||||
|
source_kind: "youtube",
|
||||||
|
source_ref: "abc",
|
||||||
|
source_url: null,
|
||||||
|
poster_url: null,
|
||||||
|
profile_id: null,
|
||||||
|
spec: {
|
||||||
|
mode: "av",
|
||||||
|
max_height: null,
|
||||||
|
container: null,
|
||||||
|
audio_format: null,
|
||||||
|
vcodec: null,
|
||||||
|
embed_subs: false,
|
||||||
|
embed_chapters: false,
|
||||||
|
embed_thumbnail: false,
|
||||||
|
sponsorblock: false,
|
||||||
|
},
|
||||||
|
display_name: "Clip",
|
||||||
|
display_uploader: null,
|
||||||
|
display_uploader_url: null,
|
||||||
|
extra_links: [],
|
||||||
|
can_download: true,
|
||||||
|
expired: false,
|
||||||
|
asset: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const withAsset = (over: Partial<NonNullable<DownloadJob["asset"]>>): DownloadJob => ({
|
||||||
|
...baseJob,
|
||||||
|
asset: {
|
||||||
|
id: 10,
|
||||||
|
status: "ready",
|
||||||
|
title: "Clip",
|
||||||
|
uploader: null,
|
||||||
|
uploader_url: null,
|
||||||
|
upload_date: null,
|
||||||
|
duration_s: 12,
|
||||||
|
size_bytes: 1000,
|
||||||
|
width: null,
|
||||||
|
height: null,
|
||||||
|
container: null,
|
||||||
|
media_kind: "video",
|
||||||
|
thumbnail_url: null,
|
||||||
|
expires_at: null,
|
||||||
|
...over,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
describe("LibraryPlayer", () => {
|
||||||
|
it("plays a video asset in a <video> pointed at the range-aware /file endpoint", () => {
|
||||||
|
render(
|
||||||
|
<LibraryPlayer
|
||||||
|
job={withAsset({ width: 1280, height: 720, container: "mp4" })}
|
||||||
|
onClose={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const video = document.body.querySelector("video");
|
||||||
|
expect(video).not.toBeNull();
|
||||||
|
expect(video!.getAttribute("src")).toBe("/api/downloads/7/file");
|
||||||
|
// No <audio> element for a video asset.
|
||||||
|
expect(document.body.querySelector("audio")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("plays an audio-only asset (media_kind='audio') in an <audio> element", () => {
|
||||||
|
render(
|
||||||
|
<LibraryPlayer
|
||||||
|
job={withAsset({ media_kind: "audio", container: "m4a" })}
|
||||||
|
onClose={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const audio = document.body.querySelector("audio");
|
||||||
|
expect(audio).not.toBeNull();
|
||||||
|
expect(audio!.getAttribute("src")).toBe("/api/downloads/7/file");
|
||||||
|
expect(document.body.querySelector("video")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses media_kind — an audio upload with an uncommon container still renders <audio>", () => {
|
||||||
|
// The exact bug the fix targets: the old container whitelist (m4a/mp3/…) would drop an .m4b/.weba
|
||||||
|
// audio upload into a <video> box. media_kind='audio' is authoritative regardless of container.
|
||||||
|
render(
|
||||||
|
<LibraryPlayer
|
||||||
|
job={withAsset({ media_kind: "audio", container: "m4b" })}
|
||||||
|
onClose={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(document.body.querySelector("audio")).not.toBeNull();
|
||||||
|
expect(document.body.querySelector("video")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the container heuristic for a legacy null media_kind", () => {
|
||||||
|
// A pre-discriminator asset (media_kind null) still resolves audio vs video from dims+container.
|
||||||
|
render(
|
||||||
|
<LibraryPlayer job={withAsset({ media_kind: null, container: "mp3" })} onClose={() => {}} />,
|
||||||
|
);
|
||||||
|
expect(document.body.querySelector("audio")).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import Modal from "./Modal";
|
||||||
|
import { api, type DownloadJob } from "../lib/api";
|
||||||
|
import { jobTitle } from "./downloadRow";
|
||||||
|
|
||||||
|
// E7 S4b — in-app player for a completed library item. We do NOT reuse PlayerModal (a cross-origin
|
||||||
|
// YouTube IFrame shell) or PlexPlayer (a Plex HLS/session shell): both are bound to their own domain
|
||||||
|
// APIs and can't point at a local file. Instead we reuse the shared a11y shell (`Modal`: focus-trap,
|
||||||
|
// ESC/back-to-close, scroll-lock, layer-stack, portal) and stream the local file into a NATIVE
|
||||||
|
// <video>/<audio controls> — the same element VideoEditor already plays this URL in. Downloads are a
|
||||||
|
// browser-native container (mp4/H.264/AAC + m4a/mp3), so native playback (seek via the range-aware
|
||||||
|
// /file endpoint, volume, fullscreen, speed) works with zero transcode; an uploaded media item may be
|
||||||
|
// any codec (best-effort playback).
|
||||||
|
export default function LibraryPlayer({ job, onClose }: { job: DownloadJob; onClose: () => void }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const fileUrl = useMemo(() => api.downloadFileUrl(job.id), [job.id]);
|
||||||
|
// Video vs audio-only comes from the stored media_kind (the single source every sibling reads —
|
||||||
|
// WatchPage/downloadRow/DownloadCenter) — NOT a re-derived container list, which would misclassify
|
||||||
|
// an uploaded audio file whose container is outside a hardcoded set (uploads accept any container).
|
||||||
|
// Only fall back to the old dimension+container heuristic for a legacy asset with a null media_kind.
|
||||||
|
const asset = job.asset;
|
||||||
|
const isAudio =
|
||||||
|
asset?.media_kind === "audio" ||
|
||||||
|
(asset?.media_kind == null &&
|
||||||
|
!(asset?.width && asset?.height) &&
|
||||||
|
!!asset?.container &&
|
||||||
|
["m4a", "mp3", "opus", "ogg", "wav", "flac", "aac"].includes(asset.container.toLowerCase()));
|
||||||
|
// Our self-hosted poster (always same-origin, never expires) is the primary; the source thumbnail is
|
||||||
|
// the fallback. Same precedence as the library-row thumbnail.
|
||||||
|
const poster = job.poster_url ? api.posterImageUrl(job.id) : (asset?.thumbnail_url ?? undefined);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal title={jobTitle(job)} onClose={onClose} maxWidth="max-w-4xl">
|
||||||
|
{isAudio ? (
|
||||||
|
<div className="flex flex-col items-center gap-4 py-2">
|
||||||
|
{poster ? (
|
||||||
|
<img
|
||||||
|
src={poster}
|
||||||
|
alt=""
|
||||||
|
className="w-40 h-40 rounded-xl object-cover border border-border"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<audio src={fileUrl} controls autoPlay className="w-full">
|
||||||
|
{t("downloads.player.unsupported")}
|
||||||
|
</audio>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<video
|
||||||
|
src={fileUrl}
|
||||||
|
poster={poster}
|
||||||
|
controls
|
||||||
|
autoPlay
|
||||||
|
playsInline
|
||||||
|
className="w-full max-h-[70vh] rounded-lg bg-black object-contain"
|
||||||
|
>
|
||||||
|
{t("downloads.player.unsupported")}
|
||||||
|
</video>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
import { CSS } from "@dnd-kit/utilities";
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
import { coercePlexFilters, type FeedFilters, type PlexFilters, type SavedView } from "../lib/api";
|
import { coercePlexFilters, type FeedFilters, type PlexFilters, type SavedView } from "../lib/api";
|
||||||
import Modal from "./Modal";
|
import Modal from "./Modal";
|
||||||
|
import { Button } from "./ui/Button";
|
||||||
import { useSavedViews, asFilters } from "./SavedViewsProvider";
|
import { useSavedViews, asFilters } from "./SavedViewsProvider";
|
||||||
import { useActiveFilters } from "../lib/useActiveFilters";
|
import { useActiveFilters } from "../lib/useActiveFilters";
|
||||||
import { buildPlexActiveFilters } from "./usePlexActiveFilters";
|
import { buildPlexActiveFilters } from "./usePlexActiveFilters";
|
||||||
@@ -202,14 +203,16 @@ export default function PersonalViewsModal() {
|
|||||||
placeholder={t("views.namePlaceholder")}
|
placeholder={t("views.namePlaceholder")}
|
||||||
className="min-w-0 flex-1 rounded-lg border border-border bg-surface px-2 py-1.5 text-sm focus:border-accent focus:outline-hidden focus:ring-2 focus:ring-accent/30"
|
className="min-w-0 flex-1 rounded-lg border border-border bg-surface px-2 py-1.5 text-sm focus:border-accent focus:outline-hidden focus:ring-2 focus:ring-accent/30"
|
||||||
/>
|
/>
|
||||||
<button
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
iconOnly
|
||||||
|
icon={<BookmarkPlus className="h-4 w-4" />}
|
||||||
|
label={t("views.save")}
|
||||||
onClick={onCreate}
|
onClick={onCreate}
|
||||||
disabled={!createName.trim()}
|
disabled={!createName.trim()}
|
||||||
title={t("views.save")}
|
className="shrink-0"
|
||||||
className="inline-flex shrink-0 items-center gap-1 rounded-lg btn-primary px-2.5 py-1.5 text-sm font-semibold disabled:opacity-40"
|
/>
|
||||||
>
|
|
||||||
<BookmarkPlus className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -272,13 +275,14 @@ export default function PersonalViewsModal() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-auto flex flex-wrap items-center gap-2 border-t border-border/60 pt-3">
|
<div className="mt-auto flex flex-wrap items-center gap-2 border-t border-border/60 pt-3">
|
||||||
<button
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
icon={<Play className="h-3.5 w-3.5" />}
|
||||||
onClick={() => apply(selected)}
|
onClick={() => apply(selected)}
|
||||||
className="inline-flex items-center gap-1.5 rounded-lg btn-primary px-3 py-1.5 text-sm font-semibold"
|
|
||||||
>
|
>
|
||||||
<Play className="h-3.5 w-3.5" />
|
|
||||||
{t("views.apply")}
|
{t("views.apply")}
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<button
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
const saved = await saveCurrent(selected.id);
|
const saved = await saveCurrent(selected.id);
|
||||||
|
|||||||
@@ -362,7 +362,15 @@ export default function Playlists() {
|
|||||||
setPushing(true);
|
setPushing(true);
|
||||||
try {
|
try {
|
||||||
const plan = await api.playlistPushPlan(selectedId);
|
const plan = await api.playlistPushPlan(selectedId);
|
||||||
if (plan.action === "update" && !plan.to_insert && !plan.to_delete && !plan.to_reorder) {
|
const nothingToDo =
|
||||||
|
plan.action === "update" &&
|
||||||
|
!plan.add_yt &&
|
||||||
|
!plan.add_local &&
|
||||||
|
!plan.remove_yt &&
|
||||||
|
!plan.remove_local &&
|
||||||
|
!plan.to_reorder &&
|
||||||
|
!plan.rename;
|
||||||
|
if (nothingToDo) {
|
||||||
notify({ level: "info", message: t("playlists.pushUpToDate", { name: plName(detail) }) });
|
notify({ level: "info", message: t("playlists.pushUpToDate", { name: plName(detail) }) });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -376,21 +384,37 @@ export default function Playlists() {
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let message =
|
let message: string;
|
||||||
plan.action === "create"
|
if (plan.action === "create") {
|
||||||
? t("playlists.pushPlanCreate", {
|
message = t("playlists.pushPlanCreate", {
|
||||||
count: plan.to_insert,
|
count: plan.add_yt,
|
||||||
units: plan.units_estimate,
|
units: plan.units_estimate,
|
||||||
left: plan.remaining_today,
|
left: plan.remaining_today,
|
||||||
})
|
});
|
||||||
: t("playlists.pushPlanUpdate", {
|
} else {
|
||||||
insert: plan.to_insert,
|
// One sentence per side that actually changes; the untouched side gets a short "stays
|
||||||
del: plan.to_delete,
|
// unchanged" reassurance instead of a "+0 / −0" the reader has to decode.
|
||||||
reorder: plan.to_reorder,
|
const here: string[] = [];
|
||||||
units: plan.units_estimate,
|
if (plan.add_local) here.push(t("playlists.mergeAddLocal", { count: plan.add_local }));
|
||||||
left: plan.remaining_today,
|
if (plan.remove_local)
|
||||||
});
|
here.push(t("playlists.mergeRemoveLocal", { count: plan.remove_local }));
|
||||||
if (plan.yt_extra > 0) message += " " + t("playlists.pushDiverged", { count: plan.yt_extra });
|
const yt: string[] = [];
|
||||||
|
if (plan.add_yt) yt.push(t("playlists.mergeAddYt", { count: plan.add_yt }));
|
||||||
|
if (plan.remove_yt) yt.push(t("playlists.mergeRemoveYt", { count: plan.remove_yt }));
|
||||||
|
if (plan.to_reorder) yt.push(t("playlists.mergeReorder", { count: plan.to_reorder }));
|
||||||
|
if (plan.rename) yt.push(t("playlists.mergeRename"));
|
||||||
|
const sentences = [...here, ...yt];
|
||||||
|
if (here.length && !yt.length) sentences.push(t("playlists.mergeYtUnchanged"));
|
||||||
|
if (yt.length && !here.length) sentences.push(t("playlists.mergeLocalUnchanged"));
|
||||||
|
const quota =
|
||||||
|
plan.units_estimate > 0
|
||||||
|
? t("playlists.mergeQuota", {
|
||||||
|
units: plan.units_estimate,
|
||||||
|
left: plan.remaining_today,
|
||||||
|
})
|
||||||
|
: t("playlists.mergeQuotaFree");
|
||||||
|
message = sentences.join(" ") + quota;
|
||||||
|
}
|
||||||
const ok = await confirm({
|
const ok = await confirm({
|
||||||
title: t("playlists.pushTitle"),
|
title: t("playlists.pushTitle"),
|
||||||
message,
|
message,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import Modal from "./Modal";
|
|||||||
import { useConfirm } from "./ConfirmProvider";
|
import { useConfirm } from "./ConfirmProvider";
|
||||||
import { api, type DownloadProfile, type DownloadSpec } from "../lib/api";
|
import { api, type DownloadProfile, type DownloadSpec } from "../lib/api";
|
||||||
import { inputCls } from "./ui/form";
|
import { inputCls } from "./ui/form";
|
||||||
|
import { Button } from "./ui/Button";
|
||||||
|
|
||||||
const HEIGHTS = [null, 2160, 1440, 1080, 720, 480, 360];
|
const HEIGHTS = [null, 2160, 1440, 1080, 720, 480, 360];
|
||||||
|
|
||||||
@@ -145,12 +146,9 @@ export default function ProfileEditor({ onClose }: { onClose: () => void }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!formOpen ? (
|
{!formOpen ? (
|
||||||
<button
|
<Button variant="primary" size="sm" icon={<Plus className="w-4 h-4" />} onClick={startNew}>
|
||||||
onClick={startNew}
|
{t("downloads.profiles.new")}
|
||||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium btn-primary transition"
|
</Button>
|
||||||
>
|
|
||||||
<Plus className="w-4 h-4" /> {t("downloads.profiles.new")}
|
|
||||||
</button>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-xl border border-border p-4 space-y-3">
|
<div className="rounded-xl border border-border p-4 space-y-3">
|
||||||
<div>
|
<div>
|
||||||
@@ -197,6 +195,8 @@ export default function ProfileEditor({ onClose }: { onClose: () => void }) {
|
|||||||
<label className="block text-sm font-medium mb-1">
|
<label className="block text-sm font-medium mb-1">
|
||||||
{t("downloads.profiles.audioFormat")}
|
{t("downloads.profiles.audioFormat")}
|
||||||
</label>
|
</label>
|
||||||
|
{/* Only browser-playable audio: AAC (m4a) + MP3. Opus is dropped — Safari plays it
|
||||||
|
unreliably as a bare file, and the module's purpose is in-browser playback. */}
|
||||||
<select
|
<select
|
||||||
value={spec.audio_format ?? "m4a"}
|
value={spec.audio_format ?? "m4a"}
|
||||||
onChange={set("audio_format")}
|
onChange={set("audio_format")}
|
||||||
@@ -204,39 +204,12 @@ export default function ProfileEditor({ onClose }: { onClose: () => void }) {
|
|||||||
>
|
>
|
||||||
<option value="m4a">M4A</option>
|
<option value="m4a">M4A</option>
|
||||||
<option value="mp3">MP3</option>
|
<option value="mp3">MP3</option>
|
||||||
<option value="opus">Opus</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{spec.mode !== "a" && (
|
{/* Container/codec are no longer user-selectable: every download is clamped to the
|
||||||
<>
|
mp4/H.264/AAC compat profile (the only universally browser-playable combo) so a
|
||||||
<div>
|
shared link plays everywhere, iOS/Safari included. Enforced in backend `normalize`. */}
|
||||||
<label className="block text-sm font-medium mb-1">
|
|
||||||
{t("downloads.profiles.container")}
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
value={spec.container ?? "mp4"}
|
|
||||||
onChange={set("container")}
|
|
||||||
className={inputCls}
|
|
||||||
>
|
|
||||||
<option value="mp4">MP4</option>
|
|
||||||
<option value="mkv">MKV</option>
|
|
||||||
<option value="webm">WebM</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-1">
|
|
||||||
{t("downloads.profiles.vcodec")}
|
|
||||||
</label>
|
|
||||||
<select value={spec.vcodec ?? ""} onChange={set("vcodec")} className={inputCls}>
|
|
||||||
<option value="">{t("downloads.profiles.any")}</option>
|
|
||||||
<option value="h264">H.264</option>
|
|
||||||
<option value="vp9">VP9</option>
|
|
||||||
<option value="av1">AV1</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5 pt-1">
|
<div className="space-y-1.5 pt-1">
|
||||||
{spec.mode !== "a" && (
|
{spec.mode !== "a" && (
|
||||||
@@ -263,19 +236,17 @@ export default function ProfileEditor({ onClose }: { onClose: () => void }) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end gap-2 pt-1">
|
<div className="flex justify-end gap-2 pt-1">
|
||||||
<button
|
<Button variant="ghost" size="sm" onClick={() => setFormOpen(false)}>
|
||||||
onClick={() => setFormOpen(false)}
|
|
||||||
className="px-3 py-1.5 rounded-lg text-sm text-muted hover:text-fg hover:bg-surface transition"
|
|
||||||
>
|
|
||||||
{t("downloads.actions.cancel")}
|
{t("downloads.actions.cancel")}
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
onClick={() => save.mutate()}
|
onClick={() => save.mutate()}
|
||||||
disabled={!name.trim() || save.isPending}
|
disabled={!name.trim() || save.isPending}
|
||||||
className="px-3 py-1.5 rounded-lg text-sm font-medium btn-primary disabled:opacity-50 transition"
|
|
||||||
>
|
>
|
||||||
{editing ? t("downloads.profiles.save") : t("downloads.profiles.create")}
|
{editing ? t("downloads.profiles.save") : t("downloads.profiles.create")}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
//
|
||||||
|
// S6 — the per-user download-cookies section is WRITE-ONLY: the stored blob is never fetched or
|
||||||
|
// shown back, so opening "Set"/"Update" always reveals an EMPTY field, and saving posts exactly the
|
||||||
|
// pasted value to the matching host. This drives the real DownloadAuth against a mocked api so we can
|
||||||
|
// assert the field never carries a prior value and the right host is written.
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { render, screen, cleanup, fireEvent, waitFor } from "@testing-library/react";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
const h = vi.hoisted(() => ({
|
||||||
|
downloadCredentials: vi.fn(),
|
||||||
|
setDownloadCredential: vi.fn(),
|
||||||
|
deleteDownloadCredential: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("react-i18next", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
vi.mock("../lib/api", () => ({ api: h }));
|
||||||
|
// Partial mock: SettingsPanel's module graph (PrefsProvider) also pulls getNotifSettings from here.
|
||||||
|
vi.mock("../lib/notifications", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("../lib/notifications")>();
|
||||||
|
return { ...actual, notify: vi.fn() };
|
||||||
|
});
|
||||||
|
vi.mock("./ConfirmProvider", () => ({ useConfirm: () => vi.fn(async () => true) }));
|
||||||
|
|
||||||
|
import { DownloadAuth } from "./SettingsPanel";
|
||||||
|
|
||||||
|
const HOSTS = [
|
||||||
|
{ host_key: "facebook", label: "Facebook", configured: false, updated_at: null },
|
||||||
|
{
|
||||||
|
host_key: "instagram",
|
||||||
|
label: "Instagram",
|
||||||
|
configured: true,
|
||||||
|
updated_at: "2026-08-01T00:00:00Z",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function renderPanel() {
|
||||||
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<DownloadAuth />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("DownloadAuth (write-only cookies)", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
h.downloadCredentials.mockResolvedValue({ hosts: HOSTS });
|
||||||
|
h.setDownloadCredential.mockResolvedValue({ ok: true, hosts: HOSTS });
|
||||||
|
h.deleteDownloadCredential.mockResolvedValue({ ok: true, hosts: HOSTS });
|
||||||
|
});
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
it("lists every supported host with its configured state", async () => {
|
||||||
|
renderPanel();
|
||||||
|
await screen.findByTestId("cookie-host-facebook");
|
||||||
|
expect(screen.getByTestId("cookie-status-facebook").textContent).toContain(
|
||||||
|
"settings.downloadAuth.notSet",
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId("cookie-status-instagram").textContent).toContain(
|
||||||
|
"settings.downloadAuth.configured",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens an EMPTY field on Set and posts the pasted value to that host", async () => {
|
||||||
|
renderPanel();
|
||||||
|
await screen.findByTestId("cookie-host-facebook");
|
||||||
|
// The Set button is the outline action on the facebook row.
|
||||||
|
const setBtn = screen.getByText("settings.downloadAuth.set");
|
||||||
|
fireEvent.click(setBtn);
|
||||||
|
const field = (await screen.findByTestId("cookie-input-facebook")) as HTMLTextAreaElement;
|
||||||
|
expect(field.value).toBe(""); // write-only: never prefilled
|
||||||
|
|
||||||
|
fireEvent.change(field, {
|
||||||
|
target: { value: "# Netscape\n.facebook.com\tTRUE\t/\tT\t0\tc\t1\n" },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByText("settings.downloadAuth.save"));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(h.setDownloadCredential).toHaveBeenCalledWith(
|
||||||
|
"facebook",
|
||||||
|
"# Netscape\n.facebook.com\tTRUE\t/\tT\t0\tc\t1\n",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-opening Update on a configured host still shows an empty field", async () => {
|
||||||
|
renderPanel();
|
||||||
|
await screen.findByTestId("cookie-host-instagram");
|
||||||
|
fireEvent.click(screen.getByText("settings.downloadAuth.update"));
|
||||||
|
const field = (await screen.findByTestId("cookie-input-instagram")) as HTMLTextAreaElement;
|
||||||
|
expect(field.value).toBe(""); // the stored blob is never fetched into the field
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders an honest error state with retry when the fetch fails, not an empty section", async () => {
|
||||||
|
// Honest-states: a failed load must NOT render as a bare empty section (no host rows).
|
||||||
|
h.downloadCredentials.mockRejectedValueOnce(new Error("boom"));
|
||||||
|
h.downloadCredentials.mockResolvedValue({ hosts: HOSTS });
|
||||||
|
renderPanel();
|
||||||
|
// The error message shows and no host rows render.
|
||||||
|
await screen.findByText("settings.downloadAuth.loadError");
|
||||||
|
expect(screen.queryByTestId("cookie-host-facebook")).toBeNull();
|
||||||
|
// Retry refetches → the list appears.
|
||||||
|
fireEvent.click(screen.getByText("common.retry"));
|
||||||
|
await screen.findByTestId("cookie-host-facebook");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -11,8 +11,10 @@ import Avatar from "./Avatar";
|
|||||||
import { notify, type NotifSettings } from "../lib/notifications";
|
import { notify, type NotifSettings } from "../lib/notifications";
|
||||||
import Tooltip from "./Tooltip";
|
import Tooltip from "./Tooltip";
|
||||||
import { inputCls, Section, SettingRow, Switch } from "./ui/form";
|
import { inputCls, Section, SettingRow, Switch } from "./ui/form";
|
||||||
|
import { Button } from "./ui/Button";
|
||||||
import { type SaveState } from "./ui/DraftSaveBar";
|
import { type SaveState } from "./ui/DraftSaveBar";
|
||||||
import { useConfirm } from "./ConfirmProvider";
|
import { useConfirm } from "./ConfirmProvider";
|
||||||
|
import { StateMessage } from "./QueryState";
|
||||||
import { useWizard } from "./WizardProvider";
|
import { useWizard } from "./WizardProvider";
|
||||||
import { usePrefs } from "./PrefsProvider";
|
import { usePrefs } from "./PrefsProvider";
|
||||||
import Tabs from "./Tabs";
|
import Tabs from "./Tabs";
|
||||||
@@ -472,13 +474,9 @@ function SignInMethods({ me }: { me: Me }) {
|
|||||||
className={inputCls}
|
className={inputCls}
|
||||||
/>
|
/>
|
||||||
{err && <p className="text-xs text-danger">{err}</p>}
|
{err && <p className="text-xs text-danger">{err}</p>}
|
||||||
<button
|
<Button type="submit" variant="primary" size="sm" disabled={busy || !next}>
|
||||||
type="submit"
|
|
||||||
disabled={busy || !next}
|
|
||||||
className="px-3 py-1.5 rounded-lg text-sm btn-primary font-medium disabled:opacity-40 transition"
|
|
||||||
>
|
|
||||||
{busy ? t("settings.account.password.saving") : t("settings.account.password.save")}
|
{busy ? t("settings.account.password.saving") : t("settings.account.password.save")}
|
||||||
</button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -586,6 +584,161 @@ function PlexWatchSync() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Per-user download authentication (S6): store your own login cookies for an auth-walled host
|
||||||
|
// (e.g. a private Facebook group you belong to) so downloads from it use YOUR login. Write-only —
|
||||||
|
// the stored blob is never shown back; each row shows only the host + whether it's set + when.
|
||||||
|
// A cookie-authed download is kept private to your account (never cache-shared with another user).
|
||||||
|
// Exported for a focused DOM test (write-only behaviour); rendered inside Account below.
|
||||||
|
export function DownloadAuth() {
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const confirm = useConfirm();
|
||||||
|
const q = useQuery({
|
||||||
|
queryKey: qk.downloadCredentials(),
|
||||||
|
queryFn: () => api.downloadCredentials(),
|
||||||
|
});
|
||||||
|
const [openHost, setOpenHost] = useState<string | null>(null);
|
||||||
|
const [draft, setDraft] = useState("");
|
||||||
|
|
||||||
|
const save = useMutation({
|
||||||
|
mutationFn: ({ host, cookies }: { host: string; cookies: string }) =>
|
||||||
|
api.setDownloadCredential(host, cookies),
|
||||||
|
onSuccess: (res) => {
|
||||||
|
qc.setQueryData(qk.downloadCredentials(), res);
|
||||||
|
setOpenHost(null);
|
||||||
|
setDraft("");
|
||||||
|
notify({ level: "success", message: t("settings.downloadAuth.saved") });
|
||||||
|
},
|
||||||
|
// Validation errors (empty / not-a-cookies.txt) surface via the global error modal, which
|
||||||
|
// localizes the structured { code: "cookies" } detail (see lib/api core localizeDetail).
|
||||||
|
});
|
||||||
|
const remove = useMutation({
|
||||||
|
mutationFn: (host: string) => api.deleteDownloadCredential(host),
|
||||||
|
onSuccess: (res) => {
|
||||||
|
qc.setQueryData(qk.downloadCredentials(), res);
|
||||||
|
notify({ level: "success", message: t("settings.downloadAuth.removed") });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const hosts = q.data?.hosts ?? [];
|
||||||
|
const onRemove = async (host: string) => {
|
||||||
|
const ok = await confirm({
|
||||||
|
title: t("settings.downloadAuth.removeConfirmTitle"),
|
||||||
|
message: t("settings.downloadAuth.removeConfirmBody"),
|
||||||
|
confirmLabel: t("settings.downloadAuth.removeConfirmAction"),
|
||||||
|
danger: true,
|
||||||
|
});
|
||||||
|
if (ok) remove.mutate(host);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Section title={t("settings.downloadAuth.title")}>
|
||||||
|
<p className="text-xs text-muted leading-relaxed mb-2">{t("settings.downloadAuth.intro")}</p>
|
||||||
|
{/* The host list is server-provided (always non-empty on success), so an empty `hosts` means
|
||||||
|
the fetch failed or is still loading — render an honest state, never a bare empty section
|
||||||
|
that reads as "nothing to configure here". */}
|
||||||
|
{q.isError && hosts.length === 0 ? (
|
||||||
|
<StateMessage tone="error" onRetry={() => q.refetch()}>
|
||||||
|
{t("settings.downloadAuth.loadError")}
|
||||||
|
</StateMessage>
|
||||||
|
) : q.isLoading ? (
|
||||||
|
<StateMessage>{t("common.loading")}</StateMessage>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-border">
|
||||||
|
{hosts.map((h) => {
|
||||||
|
const when = h.updated_at
|
||||||
|
? new Date(h.updated_at).toLocaleDateString(i18n.language)
|
||||||
|
: "";
|
||||||
|
const open = openHost === h.host_key;
|
||||||
|
return (
|
||||||
|
<div key={h.host_key} className="py-2" data-testid={`cookie-host-${h.host_key}`}>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-sm font-medium">{h.label}</div>
|
||||||
|
<p
|
||||||
|
className="text-xs text-muted mt-0.5"
|
||||||
|
data-testid={`cookie-status-${h.host_key}`}
|
||||||
|
>
|
||||||
|
{h.configured
|
||||||
|
? t("settings.downloadAuth.configured", { when })
|
||||||
|
: t("settings.downloadAuth.notSet")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
{h.configured && (
|
||||||
|
<Button
|
||||||
|
variant="danger-outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onRemove(h.host_key)}
|
||||||
|
disabled={remove.isPending}
|
||||||
|
>
|
||||||
|
{t("settings.downloadAuth.remove")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setOpenHost(open ? null : h.host_key);
|
||||||
|
setDraft("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{h.configured
|
||||||
|
? t("settings.downloadAuth.update")
|
||||||
|
: t("settings.downloadAuth.set")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{open && (
|
||||||
|
<div className="mt-2 space-y-2">
|
||||||
|
{/* Write-only: the field is ALWAYS empty on open — the stored blob is never
|
||||||
|
fetched or prefilled. */}
|
||||||
|
<textarea
|
||||||
|
value={draft}
|
||||||
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
|
rows={5}
|
||||||
|
placeholder={t("settings.downloadAuth.placeholder")}
|
||||||
|
className={`${inputCls} font-mono text-xs resize-y`}
|
||||||
|
data-testid={`cookie-input-${h.host_key}`}
|
||||||
|
spellCheck={false}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
<p className="text-2xs text-muted leading-relaxed">
|
||||||
|
{t("settings.downloadAuth.help")}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
disabled={!draft.trim() || save.isPending}
|
||||||
|
onClick={() => save.mutate({ host: h.host_key, cookies: draft })}
|
||||||
|
>
|
||||||
|
{save.isPending
|
||||||
|
? t("settings.downloadAuth.saving")
|
||||||
|
: t("settings.downloadAuth.save")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setOpenHost(null);
|
||||||
|
setDraft("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("settings.downloadAuth.cancel")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) {
|
function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const confirm = useConfirm();
|
const confirm = useConfirm();
|
||||||
@@ -628,6 +781,8 @@ function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) {
|
|||||||
|
|
||||||
{me.role === "admin" && me.plex_enabled && <PlexWatchSync />}
|
{me.role === "admin" && me.plex_enabled && <PlexWatchSync />}
|
||||||
|
|
||||||
|
{!me.is_demo && <DownloadAuth />}
|
||||||
|
|
||||||
{me.is_demo ? (
|
{me.is_demo ? (
|
||||||
<Section title={t("settings.account.youtubeAccess")}>
|
<Section title={t("settings.account.youtubeAccess")}>
|
||||||
<p className="text-xs text-muted leading-relaxed">{t("settings.account.demoNotice")}</p>
|
<p className="text-xs text-muted leading-relaxed">{t("settings.account.demoNotice")}</p>
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ import { useConfirm } from "./ConfirmProvider";
|
|||||||
import { notify } from "../lib/notifications";
|
import { notify } from "../lib/notifications";
|
||||||
import { copyText } from "../lib/clipboard";
|
import { copyText } from "../lib/clipboard";
|
||||||
import { api, type DownloadJob, type ShareLink } from "../lib/api";
|
import { api, type DownloadJob, type ShareLink } from "../lib/api";
|
||||||
import { inputCls, btnCls } from "./ui/form";
|
import { inputCls } from "./ui/form";
|
||||||
|
import { Button } from "./ui/Button";
|
||||||
const EXPIRY_DAYS = [null, 1, 7, 30] as const;
|
const EXPIRY_DAYS = [null, 1, 7, 30] as const;
|
||||||
|
|
||||||
// --- share with a registered user (ACL) -----------------------------------------------------
|
// --- share with a registered user (ACL) -----------------------------------------------------
|
||||||
@@ -253,7 +254,9 @@ function LinkShare({ job }: { job: DownloadJob }) {
|
|||||||
queryFn: () => api.downloadLinks(job.id),
|
queryFn: () => api.downloadLinks(job.id),
|
||||||
});
|
});
|
||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
const [allowDownload, setAllowDownload] = useState(false);
|
// D1 (E7 S3): downloadable by default — matches the one-click Share (which relies on the backend
|
||||||
|
// default) and the common "let them have the file" intent. The owner can uncheck it here.
|
||||||
|
const [allowDownload, setAllowDownload] = useState(true);
|
||||||
const [expiryDays, setExpiryDays] = useState<number | null>(null);
|
const [expiryDays, setExpiryDays] = useState<number | null>(null);
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const invalidate = () => qc.invalidateQueries({ queryKey: qk.downloadLinks(job.id) });
|
const invalidate = () => qc.invalidateQueries({ queryKey: qk.downloadLinks(job.id) });
|
||||||
@@ -275,8 +278,8 @@ function LinkShare({ job }: { job: DownloadJob }) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Open with a link already generated: sharing is almost always the goal, so mint a default
|
// Open with a link already generated: sharing is almost always the goal, so mint a default
|
||||||
// (no password, no expiry, view-only) link the moment the dialog finds none — as if the user
|
// (no password, no expiry, downloadable per D1) link the moment the dialog finds none — as if the
|
||||||
// had pressed "New link" → "Create". All further controls (password, download toggle, expiry,
|
// user had pressed "New link" → "Create". All further controls (password, download toggle, expiry,
|
||||||
// extra links, revoke) stay available on the created link and via the "New link" button.
|
// extra links, revoke) stay available on the created link and via the "New link" button.
|
||||||
const autoTried = useRef(false);
|
const autoTried = useRef(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -343,31 +346,29 @@ function LinkShare({ job }: { job: DownloadJob }) {
|
|||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
/>
|
/>
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
<button
|
<Button variant="ghost" size="sm" onClick={() => setCreating(false)}>
|
||||||
onClick={() => setCreating(false)}
|
|
||||||
className={clsx(btnCls, "text-muted hover:text-fg hover:bg-surface")}
|
|
||||||
>
|
|
||||||
{t("common.cancel")}
|
{t("common.cancel")}
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
onClick={() => create.mutate()}
|
onClick={() => create.mutate()}
|
||||||
disabled={create.isPending}
|
disabled={create.isPending}
|
||||||
className={clsx(btnCls, "btn-primary")}
|
|
||||||
>
|
>
|
||||||
{t("downloads.share.createLink")}
|
{t("downloads.share.createLink")}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
onClick={() => setCreating(true)}
|
onClick={() => setCreating(true)}
|
||||||
className={clsx(
|
icon={<Link2 className="w-4 h-4" />}
|
||||||
btnCls,
|
className="mt-2"
|
||||||
"mt-2 border border-border text-muted hover:text-fg hover:border-muted inline-flex items-center gap-1.5",
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<Link2 className="w-4 h-4" /> {t("downloads.share.newLink")}
|
{t("downloads.share.newLink")}
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ export default function SortControl({
|
|||||||
const summary = activeOpt?.label ?? defaultLabel ?? options[0]?.label ?? "";
|
const summary = activeOpt?.label ?? defaultLabel ?? options[0]?.label ?? "";
|
||||||
|
|
||||||
const segCls = (on: boolean) =>
|
const segCls = (on: boolean) =>
|
||||||
`flex flex-1 items-center justify-center gap-1 rounded-md border px-2 py-1 text-2xs transition ${
|
`flex flex-1 items-center justify-center gap-1 whitespace-nowrap rounded-md border px-2 py-1 text-2xs transition ${
|
||||||
on
|
on
|
||||||
? "border-accent bg-accent/15 text-accent"
|
? "border-accent bg-accent/15 text-accent"
|
||||||
: "border-border text-muted hover:border-accent/50 hover:text-fg"
|
: "border-border text-muted hover:border-accent/50 hover:text-fg"
|
||||||
@@ -92,7 +92,7 @@ export default function SortControl({
|
|||||||
<PopoverSurface
|
<PopoverSurface
|
||||||
popover={pop}
|
popover={pop}
|
||||||
ariaLabel={label}
|
ariaLabel={label}
|
||||||
className="glass-menu min-w-52 rounded-xl p-1.5 shadow-lg animate-[popIn_0.16s_ease]"
|
className="glass-menu min-w-56 rounded-xl p-1.5 shadow-lg animate-[popIn_0.16s_ease]"
|
||||||
>
|
>
|
||||||
{/* One exclusive choice of sort key (radios), scoped by a group so AT announces which is
|
{/* One exclusive choice of sort key (radios), scoped by a group so AT announces which is
|
||||||
checked — the surface is a menu (see the mode above), never a listbox with orphaned options. */}
|
checked — the surface is a menu (see the mode above), never a listbox with orphaned options. */}
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
//
|
||||||
|
// E7·S5c — the local-upload dialog. It's a general file upload (any type, no format list): a picked
|
||||||
|
// file surfaces its name+size, and Upload streams it through api.uploadDownload (the XHR-progress
|
||||||
|
// path). This drives the real dialog against a mocked api so we can assert the file + optional name
|
||||||
|
// actually reach the ingest call, and that the success toast points at the library. i18n is
|
||||||
|
// key-passthrough.
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
const h = vi.hoisted(() => ({
|
||||||
|
uploadDownload: vi.fn(async (_f: File, _o: unknown) => ({ id: 42 })),
|
||||||
|
notify: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("react-i18next", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||||
|
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
|
||||||
|
});
|
||||||
|
vi.mock("../lib/api", () => ({ api: { uploadDownload: h.uploadDownload } }));
|
||||||
|
vi.mock("../lib/downloads", () => ({ invalidateDownloads: vi.fn() }));
|
||||||
|
vi.mock("../lib/notifications", () => ({ notify: h.notify }));
|
||||||
|
vi.mock("../lib/nav", () => ({ navigateTo: vi.fn() }));
|
||||||
|
|
||||||
|
import UploadDialog from "./UploadDialog";
|
||||||
|
|
||||||
|
const wrap = (onClose = vi.fn()) => {
|
||||||
|
const qc = new QueryClient();
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<UploadDialog onClose={onClose} />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// The dialog portals into document.body, so query the whole document, not the render container.
|
||||||
|
const fileInput = () => document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
h.uploadDownload.mockClear();
|
||||||
|
h.notify.mockClear();
|
||||||
|
});
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
describe("UploadDialog", () => {
|
||||||
|
it("shows the picked file's name and uploads it with the optional name", async () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
wrap(onClose);
|
||||||
|
const f = new File(["hello world"], "notes.pdf", { type: "application/pdf" });
|
||||||
|
fireEvent.change(fileInput(), { target: { files: [f] } });
|
||||||
|
|
||||||
|
// The drop zone now surfaces the file name.
|
||||||
|
expect(screen.getByText("notes.pdf")).toBeTruthy();
|
||||||
|
|
||||||
|
// Optional display name.
|
||||||
|
const nameBox = screen.getByPlaceholderText("downloads.upload.namePlaceholder");
|
||||||
|
fireEvent.change(nameBox, { target: { value: "My notes" } });
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("downloads.upload.submit"));
|
||||||
|
|
||||||
|
await waitFor(() => expect(h.uploadDownload).toHaveBeenCalledTimes(1));
|
||||||
|
const [passedFile, opts] = h.uploadDownload.mock.calls[0]!;
|
||||||
|
expect(passedFile).toBe(f);
|
||||||
|
expect((opts as { displayName?: string }).displayName).toBe("My notes");
|
||||||
|
await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(1));
|
||||||
|
await waitFor(() => expect(onClose).toHaveBeenCalled());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps Upload disabled until a file is chosen", () => {
|
||||||
|
wrap();
|
||||||
|
const btn = screen.getByText("downloads.upload.submit").closest("button")!;
|
||||||
|
expect(btn.disabled).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { UploadCloud, File as FileIcon } from "lucide-react";
|
||||||
|
import Modal from "./Modal";
|
||||||
|
import { Button } from "./ui/Button";
|
||||||
|
import { api } from "../lib/api";
|
||||||
|
import { invalidateDownloads } from "../lib/downloads";
|
||||||
|
import { notify } from "../lib/notifications";
|
||||||
|
import { navigateTo } from "../lib/nav";
|
||||||
|
import { formatBytes } from "../lib/format";
|
||||||
|
import { inputCls } from "./ui/form";
|
||||||
|
|
||||||
|
// Local-file ingest (E7 S5c · D5-A). A general upload — ANY file type, no format list: media
|
||||||
|
// (video/audio) joins the library with the full function set, a generic file is download-only. The
|
||||||
|
// file is streamed to the server via XHR so this dialog can show real upload progress. On success it
|
||||||
|
// lands in the Library as an already-`done` job, so we invalidate + point the user there.
|
||||||
|
export default function UploadDialog({ onClose }: { onClose: () => void }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const [file, setFile] = useState<File | null>(null);
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [dragOver, setDragOver] = useState(false);
|
||||||
|
const [pct, setPct] = useState<number | null>(null); // null = idle; 0–100 while uploading
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
|
// dragenter/dragleave fire as the cursor crosses the drop zone's OWN children (icon/text), so a
|
||||||
|
// plain boolean flickers. Count enter vs leave and only clear the highlight at depth 0.
|
||||||
|
const dragDepth = useRef(0);
|
||||||
|
const busy = pct !== null;
|
||||||
|
|
||||||
|
// Closing the dialog mid-upload (X / ESC / backdrop / browser Back all unmount us) must abort the
|
||||||
|
// in-flight XHR — otherwise it keeps streaming in the background and still toasts success. Only the
|
||||||
|
// footer Cancel wired the abort; this covers every other dismissal.
|
||||||
|
useEffect(() => () => abortRef.current?.abort(), []);
|
||||||
|
|
||||||
|
const pick = () => inputRef.current?.click();
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
if (!file || busy) return;
|
||||||
|
const ac = new AbortController();
|
||||||
|
abortRef.current = ac;
|
||||||
|
setPct(0);
|
||||||
|
try {
|
||||||
|
await api.uploadDownload(file, {
|
||||||
|
displayName: name.trim() || undefined,
|
||||||
|
onProgress: setPct,
|
||||||
|
signal: ac.signal,
|
||||||
|
});
|
||||||
|
invalidateDownloads(qc);
|
||||||
|
notify({
|
||||||
|
level: "success",
|
||||||
|
message: t("downloads.upload.added"),
|
||||||
|
action: { label: t("downloads.dialog.view"), onClick: () => navigateTo("downloads") },
|
||||||
|
});
|
||||||
|
onClose();
|
||||||
|
} catch {
|
||||||
|
// An aborted upload (Cancel) or a server rejection (cap/quota — already surfaced by the global
|
||||||
|
// error modal, like every other req call). Re-arm the form so the user can retry or pick again.
|
||||||
|
setPct(null);
|
||||||
|
abortRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal title={t("downloads.upload.title")} onClose={onClose}>
|
||||||
|
<p className="text-sm text-muted mb-4">{t("downloads.upload.hint")}</p>
|
||||||
|
|
||||||
|
{/* Drop zone doubles as the picker trigger — a click anywhere opens the native file dialog. */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={pick}
|
||||||
|
onDragEnter={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
dragDepth.current += 1;
|
||||||
|
setDragOver(true);
|
||||||
|
}}
|
||||||
|
onDragOver={(e) => e.preventDefault()}
|
||||||
|
onDragLeave={() => {
|
||||||
|
dragDepth.current = Math.max(0, dragDepth.current - 1);
|
||||||
|
if (dragDepth.current === 0) setDragOver(false);
|
||||||
|
}}
|
||||||
|
onDrop={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
dragDepth.current = 0;
|
||||||
|
setDragOver(false);
|
||||||
|
const f = e.dataTransfer.files?.[0];
|
||||||
|
if (f) {
|
||||||
|
setFile(f);
|
||||||
|
setName("");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={busy}
|
||||||
|
className={`w-full flex flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed px-4 py-8 text-center transition disabled:opacity-60 ${
|
||||||
|
dragOver ? "border-accent bg-accent/5" : "border-border hover:border-fg/40"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{file ? (
|
||||||
|
<>
|
||||||
|
<FileIcon className="w-6 h-6 text-muted" />
|
||||||
|
<span className="text-sm font-medium break-all">{file.name}</span>
|
||||||
|
<span className="text-xs text-muted">{formatBytes(file.size)}</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<UploadCloud className="w-7 h-7 text-muted" />
|
||||||
|
<span className="text-sm text-muted">
|
||||||
|
{dragOver ? t("downloads.upload.dropActive") : t("downloads.upload.drop")}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
const f = e.target.files?.[0];
|
||||||
|
if (f) {
|
||||||
|
setFile(f);
|
||||||
|
setName("");
|
||||||
|
}
|
||||||
|
e.target.value = ""; // let re-picking the same file fire onChange again
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<label className="block text-sm font-medium mt-4 mb-1">
|
||||||
|
{t("downloads.upload.nameLabel")}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder={t("downloads.upload.namePlaceholder")}
|
||||||
|
disabled={busy}
|
||||||
|
className={`${inputCls} mb-5`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{busy && (
|
||||||
|
<div className="mb-5" aria-live="polite">
|
||||||
|
<div
|
||||||
|
className="h-2 rounded-full bg-surface overflow-hidden"
|
||||||
|
role="progressbar"
|
||||||
|
aria-valuenow={pct ?? 0}
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={100}
|
||||||
|
// A STABLE descriptor as the name; the live percentage rides aria-valuenow (and the visible
|
||||||
|
// <p> below re-announces it via the aria-live wrapper) — don't bake the value into the name.
|
||||||
|
aria-label={t("downloads.upload.uploadProgress")}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="h-full bg-accent transition-[width] duration-150"
|
||||||
|
style={{ width: `${pct ?? 0}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted mt-1.5 text-center">
|
||||||
|
{t("downloads.upload.uploading", { pct: pct ?? 0 })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => (busy ? abortRef.current?.abort() : onClose())}
|
||||||
|
>
|
||||||
|
{t("downloads.actions.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button variant="primary" size="sm" onClick={submit} disabled={!file || busy}>
|
||||||
|
{t("downloads.upload.submit")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,22 +2,14 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|||||||
import { qk } from "../lib/queryKeys";
|
import { qk } from "../lib/queryKeys";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import { Crop, Eye, EyeOff, Pause, Play, Scissors, SplitSquareHorizontal, X } from "lucide-react";
|
||||||
Crop,
|
|
||||||
Eye,
|
|
||||||
EyeOff,
|
|
||||||
Pause,
|
|
||||||
Play,
|
|
||||||
Scissors,
|
|
||||||
SplitSquareHorizontal,
|
|
||||||
Loader2,
|
|
||||||
X,
|
|
||||||
} from "lucide-react";
|
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import Modal from "./Modal";
|
import Modal from "./Modal";
|
||||||
import { notify } from "../lib/notifications";
|
import { notify } from "../lib/notifications";
|
||||||
import { api, type DownloadJob, type EditSpec } from "../lib/api";
|
import { api, type DownloadJob, type EditSpec } from "../lib/api";
|
||||||
|
import { contentRect, cropToSourcePx, type Frac } from "../lib/cropGeometry";
|
||||||
import { inputCls, btnCls } from "./ui/form";
|
import { inputCls, btnCls } from "./ui/form";
|
||||||
|
import { Button } from "./ui/Button";
|
||||||
|
|
||||||
// mm:ss.d timecode <-> seconds.
|
// mm:ss.d timecode <-> seconds.
|
||||||
function tc(s: number): string {
|
function tc(s: number): string {
|
||||||
@@ -40,7 +32,6 @@ function parseTc(v: string): number | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Seg = { id: number; start: number; end: number; keep: boolean };
|
type Seg = { id: number; start: number; end: number; keep: boolean };
|
||||||
type Frac = { x: number; y: number; w: number; h: number };
|
|
||||||
type Drag = { type: "seek" } | { type: "boundary"; bi: number };
|
type Drag = { type: "seek" } | { type: "boundary"; bi: number };
|
||||||
|
|
||||||
const numCls =
|
const numCls =
|
||||||
@@ -60,6 +51,11 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
|||||||
const [current, setCurrent] = useState(0);
|
const [current, setCurrent] = useState(0);
|
||||||
const [playing, setPlaying] = useState(false);
|
const [playing, setPlaying] = useState(false);
|
||||||
const [trackW, setTrackW] = useState(0);
|
const [trackW, setTrackW] = useState(0);
|
||||||
|
// Intrinsic (source) video dims and the video element's rendered box, both needed to map the
|
||||||
|
// crop marquee onto the letterboxed picture. `vdim` starts from asset metadata so the overlay
|
||||||
|
// can size before the media decodes; `box` is measured off the element (max-h can clamp it).
|
||||||
|
const [vdim, setVdim] = useState({ w: job.asset?.width ?? 0, h: job.asset?.height ?? 0 });
|
||||||
|
const [box, setBox] = useState({ w: 0, h: 0 });
|
||||||
|
|
||||||
// The cut-list: contiguous segments covering [0, duration]. Starts as the whole video, kept.
|
// The cut-list: contiguous segments covering [0, duration]. Starts as the whole video, kept.
|
||||||
const [segments, setSegments] = useState<Seg[]>([{ id: 0, start: 0, end: srcDur, keep: true }]);
|
const [segments, setSegments] = useState<Seg[]>([{ id: 0, start: 0, end: srcDur, keep: true }]);
|
||||||
@@ -92,11 +88,28 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
|||||||
return () => ro.disconnect();
|
return () => ro.disconnect();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Measure the video element's rendered box (max-h can shrink it below its intrinsic size, which
|
||||||
|
// then pillarboxes the picture). The crop overlay is anchored to the picture, not this box.
|
||||||
|
useEffect(() => {
|
||||||
|
const v = videoRef.current;
|
||||||
|
if (!v || typeof ResizeObserver === "undefined") return;
|
||||||
|
const ro = new ResizeObserver(() => setBox({ w: v.clientWidth, h: v.clientHeight }));
|
||||||
|
ro.observe(v);
|
||||||
|
setBox({ w: v.clientWidth, h: v.clientHeight });
|
||||||
|
return () => ro.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// The picture's rendered rectangle inside the (possibly letterboxed) video box — the overlay's
|
||||||
|
// frame, so a crop fraction is a fraction of the real picture, not of the black bars.
|
||||||
|
const va = vdim.w && vdim.h ? vdim.w / vdim.h : aspect;
|
||||||
|
const picRect = contentRect(box.w, box.h, va);
|
||||||
|
|
||||||
const onLoaded = () => {
|
const onLoaded = () => {
|
||||||
const v = videoRef.current;
|
const v = videoRef.current;
|
||||||
if (!v) return;
|
if (!v) return;
|
||||||
const d = v.duration && isFinite(v.duration) ? v.duration : srcDur;
|
const d = v.duration && isFinite(v.duration) ? v.duration : srcDur;
|
||||||
setDuration(d);
|
setDuration(d);
|
||||||
|
if (v.videoWidth && v.videoHeight) setVdim({ w: v.videoWidth, h: v.videoHeight });
|
||||||
// Reconcile the still-pristine full-length segment to the REAL decoded duration. The initial
|
// Reconcile the still-pristine full-length segment to the REAL decoded duration. The initial
|
||||||
// segment ends at the (integer, often-rounded) metadata `srcDur`; once the media loads we know
|
// segment ends at the (integer, often-rounded) metadata `srcDur`; once the media loads we know
|
||||||
// the true length (e.g. 213.44 vs 213). Without this the untouched last segment stays short, so
|
// the true length (e.g. 213.44 vs 213). Without this the untouched last segment stays short, so
|
||||||
@@ -267,16 +280,12 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
|||||||
|
|
||||||
const cropPx = (): { x: number; y: number; w: number; h: number } | undefined => {
|
const cropPx = (): { x: number; y: number; w: number; h: number } | undefined => {
|
||||||
const v = videoRef.current;
|
const v = videoRef.current;
|
||||||
const vw = v?.videoWidth || job.asset?.width || 0;
|
const vw = v?.videoWidth || vdim.w;
|
||||||
const vh = v?.videoHeight || job.asset?.height || 0;
|
const vh = v?.videoHeight || vdim.h;
|
||||||
if (!cropOn || !vw || !vh) return undefined;
|
if (!cropOn) return undefined;
|
||||||
const even = (n: number) => Math.max(2, Math.round(n / 2) * 2);
|
// crop fractions are relative to the picture (the overlay is anchored to it), so this is a
|
||||||
return {
|
// straight fraction→source-px scale; see cropGeometry for the even-rounding + in-bounds clamp.
|
||||||
x: even(crop.x * vw),
|
return cropToSourcePx(crop, vw, vh) ?? undefined;
|
||||||
y: even(crop.y * vh),
|
|
||||||
w: even(crop.w * vw),
|
|
||||||
h: even(crop.h * vh),
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const create = useMutation({
|
const create = useMutation({
|
||||||
@@ -331,11 +340,20 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
|||||||
onTimeUpdate={onTimeUpdate}
|
onTimeUpdate={onTimeUpdate}
|
||||||
onPlay={() => setPlaying(true)}
|
onPlay={() => setPlaying(true)}
|
||||||
onPause={() => setPlaying(false)}
|
onPause={() => setPlaying(false)}
|
||||||
className="w-full max-h-[42vh] mx-auto block"
|
className="w-full max-h-[42vh] mx-auto block object-contain"
|
||||||
playsInline
|
playsInline
|
||||||
/>
|
/>
|
||||||
{cropOn && (
|
{cropOn && picRect && (
|
||||||
<div ref={overlayRef} className="absolute inset-0">
|
<div
|
||||||
|
ref={overlayRef}
|
||||||
|
className="absolute"
|
||||||
|
style={{
|
||||||
|
left: picRect.left,
|
||||||
|
top: picRect.top,
|
||||||
|
width: picRect.width,
|
||||||
|
height: picRect.height,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
className="absolute border-2 border-accent cursor-move"
|
className="absolute border-2 border-accent cursor-move"
|
||||||
style={{
|
style={{
|
||||||
@@ -621,28 +639,23 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
|||||||
{reencode ? t("editor.willReencode") : t("editor.willCopy")}
|
{reencode ? t("editor.willReencode") : t("editor.willCopy")}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<Button variant="ghost" size="sm" onClick={onClose}>
|
||||||
onClick={onClose}
|
|
||||||
className={clsx(btnCls, "text-muted hover:text-fg hover:bg-surface")}
|
|
||||||
>
|
|
||||||
{t("common.cancel")}
|
{t("common.cancel")}
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
onClick={() => create.mutate()}
|
onClick={() => create.mutate()}
|
||||||
disabled={!valid || create.isPending}
|
disabled={!valid}
|
||||||
className={clsx(btnCls, "btn-primary inline-flex items-center gap-1.5")}
|
loading={create.isPending}
|
||||||
|
icon={<Scissors className="w-4 h-4" />}
|
||||||
>
|
>
|
||||||
{create.isPending ? (
|
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Scissors className="w-4 h-4" />
|
|
||||||
)}
|
|
||||||
{willJoin
|
{willJoin
|
||||||
? t("editor.createJoin")
|
? t("editor.createJoin")
|
||||||
: kept.length > 1
|
: kept.length > 1
|
||||||
? t("editor.createN", { count: kept.length })
|
? t("editor.createN", { count: kept.length })
|
||||||
: t("editor.create")}
|
: t("editor.create")}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
//
|
||||||
|
// S3 item 5a: the public watch page must render a native <audio> element for an audio-only asset
|
||||||
|
// (mp3) and a <video> for everything else. WebKit (all iOS browsers) refuses to play an audio-only
|
||||||
|
// mp3 inside a <video>, which is exactly why the branch exists — this locks it so a refactor can't
|
||||||
|
// silently drop the audio player and reintroduce the iPad-broken behavior.
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { render, waitFor, cleanup } from "@testing-library/react";
|
||||||
|
import WatchPage from "./WatchPage";
|
||||||
|
|
||||||
|
function mockMeta(meta: Record<string, unknown>) {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(async () => ({ ok: true, json: async () => meta })),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("WatchPage media element (S3 · 5a)", () => {
|
||||||
|
it("renders a native <audio> for an audio-only asset", async () => {
|
||||||
|
window.history.replaceState({}, "", "/watch/tok-audio");
|
||||||
|
mockMeta({ needs_password: false, audio: true, file_url: "/f.mp3", title: "A song" });
|
||||||
|
const { container } = render(<WatchPage />);
|
||||||
|
await waitFor(() => expect(container.querySelector("audio")).not.toBeNull());
|
||||||
|
expect(container.querySelector("video")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a <video> for a normal video asset", async () => {
|
||||||
|
window.history.replaceState({}, "", "/watch/tok-video");
|
||||||
|
mockMeta({ needs_password: false, audio: false, file_url: "/f.mp4", title: "A clip" });
|
||||||
|
const { container } = render(<WatchPage />);
|
||||||
|
await waitFor(() => expect(container.querySelector("video")).not.toBeNull());
|
||||||
|
expect(container.querySelector("audio")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders an info card + Download for a generic file, no player (S5d)", async () => {
|
||||||
|
window.history.replaceState({}, "", "/watch/tok-file");
|
||||||
|
mockMeta({
|
||||||
|
needs_password: false,
|
||||||
|
media_kind: "file",
|
||||||
|
title: "report.pdf",
|
||||||
|
container: "pdf",
|
||||||
|
size_bytes: 2_400_000,
|
||||||
|
allow_download: true,
|
||||||
|
file_url: "/f.pdf",
|
||||||
|
});
|
||||||
|
const { container, findByText } = render(<WatchPage />);
|
||||||
|
await findByText("report.pdf");
|
||||||
|
expect(container.querySelector("video")).toBeNull();
|
||||||
|
expect(container.querySelector("audio")).toBeNull();
|
||||||
|
// Type · size chip and a Download link to the file.
|
||||||
|
await findByText(/PDF · 2\.3 MB/);
|
||||||
|
const dl = container.querySelector('a[href="/f.pdf"]');
|
||||||
|
expect(dl).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits the Download for a view-only file link (S5d)", async () => {
|
||||||
|
window.history.replaceState({}, "", "/watch/tok-file-noview");
|
||||||
|
mockMeta({
|
||||||
|
needs_password: false,
|
||||||
|
media_kind: "file",
|
||||||
|
title: "secret.zip",
|
||||||
|
container: "zip",
|
||||||
|
size_bytes: 1000,
|
||||||
|
allow_download: false,
|
||||||
|
file_url: "/f.zip",
|
||||||
|
});
|
||||||
|
const { container, findByText } = render(<WatchPage />);
|
||||||
|
await findByText("secret.zip");
|
||||||
|
expect(container.querySelector('a[href="/f.zip"]')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { Download, Link2, Lock, PlayCircle } from "lucide-react";
|
import { Download, File as FileIcon, Link2, Lock, PlayCircle } from "lucide-react";
|
||||||
|
import { Button } from "./ui/Button";
|
||||||
|
import { formatBytes } from "../lib/format";
|
||||||
|
|
||||||
// Standalone, login-free video page for a shared /watch/<token> link. Rendered OUTSIDE the app
|
// Standalone, login-free video page for a shared /watch/<token> link. Rendered OUTSIDE the app
|
||||||
// tree (no auth, no providers) — like the /privacy and /terms pages. Uses plain fetch and a tiny
|
// tree (no auth, no providers) — like the /privacy and /terms pages. Uses plain fetch and a tiny
|
||||||
@@ -11,10 +13,11 @@ import { Download, Link2, Lock, PlayCircle } from "lucide-react";
|
|||||||
// playing in the background on Brave iOS. We deliberately do NOT route the audio through the Web
|
// playing in the background on Brave iOS. We deliberately do NOT route the audio through the Web
|
||||||
// Audio API: `createMediaElementSource` pulls the element out of the normal media pipeline that the
|
// Audio API: `createMediaElementSource` pulls the element out of the normal media pipeline that the
|
||||||
// background feature (and iOS) hooks into, and iOS then suspends the AudioContext on background —
|
// background feature (and iOS) hooks into, and iOS then suspends the AudioContext on background —
|
||||||
// which STOPPED playback instead of keeping it alive (measured on iPad Brave). A plain <video> +
|
// which STOPPED playback instead of keeping it alive (measured on iPad Brave). A plain <video>/<audio>
|
||||||
// MediaSession leaves the browser's native background handling in charge.
|
// + MediaSession leaves the browser's native background handling in charge. Typed on HTMLMediaElement
|
||||||
|
// so it drives either the video or the audio-only player.
|
||||||
function useMediaSession(
|
function useMediaSession(
|
||||||
videoRef: React.RefObject<HTMLVideoElement | null>,
|
videoRef: React.RefObject<HTMLMediaElement | null>,
|
||||||
meta: Meta | null,
|
meta: Meta | null,
|
||||||
ready: boolean,
|
ready: boolean,
|
||||||
) {
|
) {
|
||||||
@@ -45,6 +48,10 @@ type Meta = {
|
|||||||
source_url?: string | null;
|
source_url?: string | null;
|
||||||
extra_links?: string[];
|
extra_links?: string[];
|
||||||
duration_s?: number | null;
|
duration_s?: number | null;
|
||||||
|
audio?: boolean;
|
||||||
|
media_kind?: "video" | "audio" | "file" | null;
|
||||||
|
size_bytes?: number | null;
|
||||||
|
container?: string | null;
|
||||||
allow_download?: boolean;
|
allow_download?: boolean;
|
||||||
file_url?: string;
|
file_url?: string;
|
||||||
poster_url?: string | null;
|
poster_url?: string | null;
|
||||||
@@ -70,6 +77,7 @@ const STR = {
|
|||||||
watch: "Watch",
|
watch: "Watch",
|
||||||
wrong: "Wrong password.",
|
wrong: "Wrong password.",
|
||||||
download: "Download",
|
download: "Download",
|
||||||
|
file: "File",
|
||||||
links: "Links",
|
links: "Links",
|
||||||
brand: "Shared via Siftlode",
|
brand: "Shared via Siftlode",
|
||||||
},
|
},
|
||||||
@@ -82,6 +90,7 @@ const STR = {
|
|||||||
watch: "Megnézem",
|
watch: "Megnézem",
|
||||||
wrong: "Hibás jelszó.",
|
wrong: "Hibás jelszó.",
|
||||||
download: "Letöltés",
|
download: "Letöltés",
|
||||||
|
file: "Fájl",
|
||||||
links: "Linkek",
|
links: "Linkek",
|
||||||
brand: "Megosztva a Siftlode-dal",
|
brand: "Megosztva a Siftlode-dal",
|
||||||
},
|
},
|
||||||
@@ -94,6 +103,7 @@ const STR = {
|
|||||||
watch: "Ansehen",
|
watch: "Ansehen",
|
||||||
wrong: "Falsches Passwort.",
|
wrong: "Falsches Passwort.",
|
||||||
download: "Herunterladen",
|
download: "Herunterladen",
|
||||||
|
file: "Datei",
|
||||||
links: "Links",
|
links: "Links",
|
||||||
brand: "Geteilt über Siftlode",
|
brand: "Geteilt über Siftlode",
|
||||||
},
|
},
|
||||||
@@ -112,8 +122,13 @@ export default function WatchPage() {
|
|||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [unlocking, setUnlocking] = useState(false);
|
const [unlocking, setUnlocking] = useState(false);
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
// One ref for whichever element renders (video or audio-only) — a callback ref so it can bind to
|
||||||
useMediaSession(videoRef, meta, status === "ready");
|
// both without a per-element typed ref.
|
||||||
|
const mediaRef = useRef<HTMLMediaElement | null>(null);
|
||||||
|
const setMedia = (el: HTMLMediaElement | null) => {
|
||||||
|
mediaRef.current = el;
|
||||||
|
};
|
||||||
|
useMediaSession(mediaRef, meta, status === "ready");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!token) {
|
if (!token) {
|
||||||
@@ -193,31 +208,73 @@ export default function WatchPage() {
|
|||||||
className="w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm outline-hidden focus:ring-2 focus:ring-accent/40"
|
className="w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm outline-hidden focus:ring-2 focus:ring-accent/40"
|
||||||
/>
|
/>
|
||||||
{error && <div className="text-sm text-danger mt-2">{error}</div>}
|
{error && <div className="text-sm text-danger mt-2">{error}</div>}
|
||||||
<button
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="md"
|
||||||
|
fullWidth
|
||||||
onClick={unlock}
|
onClick={unlock}
|
||||||
disabled={!password || unlocking}
|
disabled={!password || unlocking}
|
||||||
className="mt-4 w-full rounded-lg btn-primary disabled:opacity-50 py-2 text-sm font-medium transition"
|
className="mt-4"
|
||||||
>
|
>
|
||||||
{T.watch}
|
{T.watch}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{status === "ready" && meta && (
|
{status === "ready" && meta && meta.media_kind === "file" && (
|
||||||
<div>
|
/* A generic uploaded file has no player — just an info card (name/type/size) and, if the
|
||||||
{/* Shrink-wrap the player to the video so a vertical/short clip renders as a narrow
|
link allows it, a Download. The backend serves the file force-download regardless, so
|
||||||
player instead of a wide box with black bars; a landscape clip fills the width. */}
|
gating the button on allow_download only mirrors the owner's ACL, matching the media
|
||||||
<div className="rounded-xl overflow-hidden bg-black border border-border w-fit max-w-full mx-auto">
|
branch's download affordance below. */
|
||||||
<video
|
<div className="flex-1 grid place-items-center">
|
||||||
ref={videoRef}
|
<div className="w-full max-w-md rounded-2xl glass p-6 text-center">
|
||||||
controls
|
<div className="mx-auto w-16 h-16 rounded-xl bg-surface grid place-items-center mb-4">
|
||||||
playsInline
|
<FileIcon className="w-8 h-8 text-accent" />
|
||||||
src={meta.file_url}
|
</div>
|
||||||
poster={meta.poster_url || undefined}
|
<h1 className="text-lg font-semibold leading-snug break-words">
|
||||||
className="max-h-[80vh] max-w-full mx-auto block"
|
{meta.title || T.file}
|
||||||
/>
|
</h1>
|
||||||
|
<div className="text-sm text-muted mt-1">
|
||||||
|
{[meta.container?.toUpperCase(), formatBytes(meta.size_bytes)]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" · ")}
|
||||||
|
</div>
|
||||||
|
{meta.allow_download && (
|
||||||
|
<a
|
||||||
|
href={meta.file_url}
|
||||||
|
className="mt-5 inline-flex items-center gap-1.5 rounded-lg bg-surface hover:bg-card px-4 py-2 text-sm font-medium transition"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4" /> {T.download}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === "ready" && meta && meta.media_kind !== "file" && (
|
||||||
|
<div>
|
||||||
|
{meta.audio ? (
|
||||||
|
/* Audio-only asset (mp3): WebKit won't play it in a <video>, so render a native
|
||||||
|
<audio>. No poster exists for these, so present it as a deliberate padded bar on
|
||||||
|
the surface (not the black video box) so it looks intentional without artwork. */
|
||||||
|
<div className="rounded-xl border border-border bg-surface p-4">
|
||||||
|
<audio ref={setMedia} controls src={meta.file_url} className="w-full block" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* Shrink-wrap the player to the video so a vertical/short clip renders as a narrow
|
||||||
|
player instead of a wide box with black bars; a landscape clip fills the width. */
|
||||||
|
<div className="rounded-xl overflow-hidden bg-black border border-border w-fit max-w-full mx-auto">
|
||||||
|
<video
|
||||||
|
ref={setMedia}
|
||||||
|
controls
|
||||||
|
playsInline
|
||||||
|
src={meta.file_url}
|
||||||
|
poster={meta.poster_url || undefined}
|
||||||
|
className="max-h-[80vh] max-w-full mx-auto block"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="mt-3 flex items-start gap-3">
|
<div className="mt-3 flex items-start gap-3">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<h1 className="text-lg font-semibold leading-snug">{meta.title || "Video"}</h1>
|
<h1 className="text-lg font-semibold leading-snug">{meta.title || "Video"}</h1>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { useLiveQuery } from "../lib/useLiveQuery";
|
|||||||
import { api } from "../lib/api";
|
import { api } from "../lib/api";
|
||||||
import { formatBytes } from "../lib/format";
|
import { formatBytes } from "../lib/format";
|
||||||
import { inputCls } from "./ui/form";
|
import { inputCls } from "./ui/form";
|
||||||
|
import { Button } from "./ui/Button";
|
||||||
import { StatusPill, jobTitle } from "./downloadRow";
|
import { StatusPill, jobTitle } from "./downloadRow";
|
||||||
|
|
||||||
// The admin "System" tab — storage overview, per-user footprint, quota editor, and the global job
|
// The admin "System" tab — storage overview, per-user footprint, quota editor, and the global job
|
||||||
@@ -110,19 +111,17 @@ function QuotaModal({
|
|||||||
{t("downloads.admin.unlimited")}
|
{t("downloads.admin.unlimited")}
|
||||||
</label>
|
</label>
|
||||||
<div className="flex justify-between pt-1">
|
<div className="flex justify-between pt-1">
|
||||||
<button
|
<Button variant="ghost" size="sm" onClick={() => reset.mutate()}>
|
||||||
onClick={() => reset.mutate()}
|
|
||||||
className="px-3 py-1.5 rounded-lg text-sm text-muted hover:text-fg hover:bg-surface transition"
|
|
||||||
>
|
|
||||||
{t("downloads.admin.reset")}
|
{t("downloads.admin.reset")}
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
onClick={() => save.mutate()}
|
onClick={() => save.mutate()}
|
||||||
disabled={save.isPending}
|
disabled={save.isPending}
|
||||||
className="px-3 py-1.5 rounded-lg text-sm font-medium btn-primary disabled:opacity-50 transition"
|
|
||||||
>
|
>
|
||||||
{t("downloads.admin.save")}
|
{t("downloads.admin.save")}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Plus, Trash2 } from "lucide-react";
|
|||||||
import Modal from "./Modal";
|
import Modal from "./Modal";
|
||||||
import { api, type DownloadJob } from "../lib/api";
|
import { api, type DownloadJob } from "../lib/api";
|
||||||
import { inputCls } from "./ui/form";
|
import { inputCls } from "./ui/form";
|
||||||
|
import { Button } from "./ui/Button";
|
||||||
import { IconBtn } from "./downloadRow";
|
import { IconBtn } from "./downloadRow";
|
||||||
|
|
||||||
// Per-item user modals split out of DownloadCenter (self-contained, prop-driven): rename/metadata
|
// Per-item user modals split out of DownloadCenter (self-contained, prop-driven): rename/metadata
|
||||||
@@ -101,22 +102,26 @@ export function MetaEditModal({ job, onClose }: { job: DownloadJob; onClose: ()
|
|||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted">{t("downloads.edit.hint")}</p>
|
<p className="text-xs text-muted">{t("downloads.edit.hint")}</p>
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<button
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
onClick={() => save.mutate()}
|
onClick={() => save.mutate()}
|
||||||
disabled={!name.trim() || save.isPending}
|
disabled={!name.trim() || save.isPending}
|
||||||
className="px-3 py-1.5 rounded-lg text-sm font-medium btn-primary disabled:opacity-50 transition"
|
|
||||||
>
|
>
|
||||||
{t("downloads.edit.save")}
|
{t("downloads.edit.save")}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Transient failures worth an in-modal Retry button; the rest are permanent (private/geo/DRM/…),
|
// Transient failures worth an in-modal Retry button; the rest are permanent (private/geo/DRM/
|
||||||
// where a retry can't help — the queue row still keeps its own retry control regardless.
|
// sign-in/unsupported-host/…), where a retry can't help — the queue row still keeps its own retry
|
||||||
const RETRYABLE_ERRORS = ["postprocess", "network", "unknown"];
|
// control regardless. `unknown` is deliberately NOT here: an unclassified failure is most often an
|
||||||
|
// auth wall we couldn't parse (a login-walled Facebook post reads as a bare "no format"), so a
|
||||||
|
// prominent Retry would mislead; force-retrying from the row stays available for genuine transients.
|
||||||
|
const RETRYABLE_ERRORS = ["postprocess", "network"];
|
||||||
|
|
||||||
// A readable "why did this fail" window for a failed job: a category headline + plain explanation,
|
// A readable "why did this fail" window for a failed job: a category headline + plain explanation,
|
||||||
// the raw yt-dlp/ffmpeg text behind a disclosure, and a Retry when the failure may be transient.
|
// the raw yt-dlp/ffmpeg text behind a disclosure, and a Retry when the failure may be transient.
|
||||||
@@ -155,22 +160,20 @@ export function ErrorModal({
|
|||||||
)}
|
)}
|
||||||
<div className="flex justify-end gap-2 pt-1">
|
<div className="flex justify-end gap-2 pt-1">
|
||||||
{retryable && (
|
{retryable && (
|
||||||
<button
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onRetry();
|
onRetry();
|
||||||
onClose();
|
onClose();
|
||||||
}}
|
}}
|
||||||
className="px-3 py-1.5 rounded-lg text-sm font-medium btn-primary transition"
|
|
||||||
>
|
>
|
||||||
{t("downloads.actions.retry")}
|
{t("downloads.actions.retry")}
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<button
|
<Button variant="ghost" size="sm" onClick={onClose}>
|
||||||
onClick={onClose}
|
|
||||||
className="px-3 py-1.5 rounded-lg text-sm text-muted hover:text-fg hover:bg-surface transition"
|
|
||||||
>
|
|
||||||
{t("common.close")}
|
{t("common.close")}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
//
|
||||||
|
// Library-row metadata (E7·S1): the completed row surfaces the concrete container/format alongside the
|
||||||
|
// already-shown size. The format chip reads the raw `asset.container` (uppercased in the UI), so it must
|
||||||
|
// render when present and be absent when the asset has no container yet (queued/running before the worker
|
||||||
|
// fills it). i18n is key-passthrough here.
|
||||||
|
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||||
|
import { render, screen, cleanup } from "@testing-library/react";
|
||||||
|
|
||||||
|
vi.mock("react-i18next", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||||
|
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
|
||||||
|
});
|
||||||
|
|
||||||
|
import { DownloadRow } from "./downloadRow";
|
||||||
|
import type { DownloadJob } from "../lib/api";
|
||||||
|
|
||||||
|
const baseJob: DownloadJob = {
|
||||||
|
id: 1,
|
||||||
|
status: "done",
|
||||||
|
progress: 100,
|
||||||
|
speed_bps: null,
|
||||||
|
eta_s: null,
|
||||||
|
phase: null,
|
||||||
|
error: null,
|
||||||
|
error_code: null,
|
||||||
|
queue_pos: 0,
|
||||||
|
created_at: null,
|
||||||
|
source_kind: "youtube",
|
||||||
|
source_ref: "abc",
|
||||||
|
source_url: null,
|
||||||
|
poster_url: null,
|
||||||
|
profile_id: null,
|
||||||
|
spec: {
|
||||||
|
mode: "av",
|
||||||
|
max_height: null,
|
||||||
|
container: null,
|
||||||
|
audio_format: null,
|
||||||
|
vcodec: null,
|
||||||
|
embed_subs: false,
|
||||||
|
embed_chapters: false,
|
||||||
|
embed_thumbnail: false,
|
||||||
|
sponsorblock: false,
|
||||||
|
},
|
||||||
|
display_name: "Clip",
|
||||||
|
display_uploader: null,
|
||||||
|
display_uploader_url: null,
|
||||||
|
extra_links: [],
|
||||||
|
can_download: true,
|
||||||
|
expired: false,
|
||||||
|
asset: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const withAsset = (
|
||||||
|
container: string | null,
|
||||||
|
over: Partial<NonNullable<DownloadJob["asset"]>> = {},
|
||||||
|
): DownloadJob => ({
|
||||||
|
...baseJob,
|
||||||
|
asset: {
|
||||||
|
id: 10,
|
||||||
|
status: "ready",
|
||||||
|
title: "Clip",
|
||||||
|
uploader: null,
|
||||||
|
uploader_url: null,
|
||||||
|
upload_date: null,
|
||||||
|
duration_s: 90,
|
||||||
|
size_bytes: 148_800_000,
|
||||||
|
width: 1920,
|
||||||
|
height: 1080,
|
||||||
|
container,
|
||||||
|
media_kind: "video",
|
||||||
|
thumbnail_url: null,
|
||||||
|
expires_at: null,
|
||||||
|
...over,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
describe("DownloadRow format chip", () => {
|
||||||
|
it("shows the uppercased container next to the size when ready", () => {
|
||||||
|
render(<DownloadRow job={withAsset("mp4")} />);
|
||||||
|
// Raw container is stored lowercase; the row uppercases it via CSS, so the text node stays "mp4".
|
||||||
|
expect(screen.getByText(/·\s*mp4/i)).toBeTruthy();
|
||||||
|
expect(screen.getByText(/142 MB/)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits the format chip when the asset has no container yet", () => {
|
||||||
|
const { container } = render(<DownloadRow job={withAsset(null)} />);
|
||||||
|
expect(container.textContent).not.toMatch(/·\s*(mp4|m4a|webm)/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// E7·S5b — a generic uploaded file (media_kind='file') has no video frame and no timeline: the row
|
||||||
|
// shows a neutral file glyph instead of a thumbnail <img>, and no duration chip (it stays media info
|
||||||
|
// like the container/size). Media rows keep their thumbnail path (an <img>, or empty until a poster).
|
||||||
|
describe("DownloadRow file kind", () => {
|
||||||
|
it("renders a file glyph, not a thumbnail <img>, for a generic file", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<DownloadRow
|
||||||
|
job={withAsset("pdf", { media_kind: "file", duration_s: null, size_bytes: 2_400_000 })}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
// The file glyph is an inline svg; no <img> element is emitted for a file.
|
||||||
|
expect(container.querySelector("img")).toBeNull();
|
||||||
|
expect(container.querySelector("svg")).not.toBeNull();
|
||||||
|
// File info still reads (container + size), but there is no duration chip.
|
||||||
|
expect(screen.getByText(/·\s*pdf/i)).toBeTruthy();
|
||||||
|
expect(screen.getByText(/2\.3 MB/)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the thumbnail path (an <img> when a poster exists) for a media asset", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<DownloadRow job={{ ...withAsset("mp4"), poster_url: "/api/downloads/1/poster.jpg" }} />,
|
||||||
|
);
|
||||||
|
expect(container.querySelector("img")).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState, type ReactNode } from "react";
|
import { useEffect, useState, type ReactNode } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { CalendarClock, Copy, Link2, Scissors } from "lucide-react";
|
import { CalendarClock, Copy, File, Link2, Scissors } from "lucide-react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { qk } from "../lib/queryKeys";
|
import { qk } from "../lib/queryKeys";
|
||||||
import { useLiveQuery } from "../lib/useLiveQuery";
|
import { useLiveQuery } from "../lib/useLiveQuery";
|
||||||
@@ -43,12 +43,12 @@ export function StatusPill({ job }: { job: DownloadJob }) {
|
|||||||
// confused by the bar resetting between the separate video and audio streams.
|
// confused by the bar resetting between the separate video and audio streams.
|
||||||
if (job.status === "running" && job.phase && PHASE_KEYS.includes(job.phase)) {
|
if (job.status === "running" && job.phase && PHASE_KEYS.includes(job.phase)) {
|
||||||
return (
|
return (
|
||||||
<span className="text-xs font-medium text-accent">{t(`downloads.phase.${job.phase}`)}</span>
|
<span className="text-sm font-medium text-accent">{t(`downloads.phase.${job.phase}`)}</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const key = job.expired ? "expired" : job.status;
|
const key = job.expired ? "expired" : job.status;
|
||||||
return (
|
return (
|
||||||
<span className={clsx("text-xs font-medium", STATUS_CLS[job.status] ?? "text-muted")}>
|
<span className={clsx("text-sm font-medium", STATUS_CLS[job.status] ?? "text-muted")}>
|
||||||
{t(`downloads.status.${key}`)}
|
{t(`downloads.status.${key}`)}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
@@ -69,6 +69,15 @@ function Thumb({ job }: { job: DownloadJob }) {
|
|||||||
// On load failure (a genuinely missing poster file) fall back to the remote source thumbnail.
|
// On load failure (a genuinely missing poster file) fall back to the remote source thumbnail.
|
||||||
const [src, setSrc] = useState(primary);
|
const [src, setSrc] = useState(primary);
|
||||||
useEffect(() => setSrc(primary), [primary]);
|
useEffect(() => setSrc(primary), [primary]);
|
||||||
|
// A generic uploaded file (media_kind='file') has no video frame to poster — it's a document/
|
||||||
|
// archive, not playable media. Show a neutral file glyph instead of an (always-empty) thumbnail.
|
||||||
|
if (job.asset?.media_kind === "file") {
|
||||||
|
return (
|
||||||
|
<div className="w-28 aspect-video shrink-0 rounded-lg overflow-hidden bg-surface border border-border flex items-center justify-center text-muted">
|
||||||
|
<File className="w-8 h-8" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<div className="w-28 aspect-video shrink-0 rounded-lg overflow-hidden bg-surface border border-border">
|
<div className="w-28 aspect-video shrink-0 rounded-lg overflow-hidden bg-surface border border-border">
|
||||||
{src ? (
|
{src ? (
|
||||||
@@ -145,8 +154,8 @@ function SourceRef({ url }: { url: string }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1 mt-1 text-xs text-muted min-w-0">
|
<div className="flex items-center gap-1 mt-1 text-sm text-muted min-w-0">
|
||||||
<Link2 className="w-3 h-3 shrink-0" />
|
<Link2 className="w-3.5 h-3.5 shrink-0" />
|
||||||
<a
|
<a
|
||||||
href={url}
|
href={url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@@ -162,7 +171,7 @@ function SourceRef({ url }: { url: string }) {
|
|||||||
title={t("downloads.source.copy")}
|
title={t("downloads.source.copy")}
|
||||||
className="shrink-0 p-0.5 rounded-sm hover:bg-surface hover:text-fg transition"
|
className="shrink-0 p-0.5 rounded-sm hover:bg-surface hover:text-fg transition"
|
||||||
>
|
>
|
||||||
<Copy className="w-3 h-3" />
|
<Copy className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -173,7 +182,7 @@ function ChannelLine({ job }: { job: DownloadJob }) {
|
|||||||
const { name, url } = jobChannel(job);
|
const { name, url } = jobChannel(job);
|
||||||
if (url) {
|
if (url) {
|
||||||
return (
|
return (
|
||||||
<div className="text-xs mt-0.5 truncate">
|
<div className="text-sm mt-0.5 truncate">
|
||||||
<a
|
<a
|
||||||
href={url}
|
href={url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@@ -186,7 +195,7 @@ function ChannelLine({ job }: { job: DownloadJob }) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return <div className="text-xs text-muted truncate mt-0.5">{name}</div>;
|
return <div className="text-sm text-muted truncate mt-0.5">{name}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DownloadRow({
|
export function DownloadRow({
|
||||||
@@ -206,15 +215,20 @@ export function DownloadRow({
|
|||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="font-medium leading-snug line-clamp-1 flex items-center gap-1.5">
|
<div className="font-medium leading-snug line-clamp-1 flex items-center gap-1.5">
|
||||||
{job.job_kind === "edit" && (
|
{job.job_kind === "edit" && (
|
||||||
<span className="inline-flex items-center gap-1 shrink-0 px-1.5 py-0.5 rounded-sm text-3xs font-semibold uppercase tracking-wide bg-accent/15 text-accent">
|
<span className="inline-flex items-center gap-1 shrink-0 px-1.5 py-0.5 rounded-sm text-2xs font-semibold uppercase tracking-wide bg-accent/15 text-accent">
|
||||||
<Scissors className="w-3 h-3" /> {t("downloads.clipBadge")}
|
<Scissors className="w-3 h-3" /> {t("downloads.clipBadge")}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span className="truncate">{jobTitle(job)}</span>
|
<span className="truncate">{jobTitle(job)}</span>
|
||||||
</div>
|
</div>
|
||||||
<ChannelLine job={job} />
|
<ChannelLine job={job} />
|
||||||
<div className="flex items-center gap-2 mt-1 text-xs">
|
<div className="flex items-center gap-2 mt-1 text-sm">
|
||||||
<StatusPill job={job} />
|
<StatusPill job={job} />
|
||||||
|
{job.asset?.container ? (
|
||||||
|
// Concrete container (mp4 / m4a / webm …) — reads as the file format, and its audio-vs-video
|
||||||
|
// extension is the video/audio distinction the running-only phase pill can't show at rest.
|
||||||
|
<span className="text-muted uppercase">· {job.asset.container}</span>
|
||||||
|
) : null}
|
||||||
{job.asset?.size_bytes ? (
|
{job.asset?.size_bytes ? (
|
||||||
<span className="text-muted">· {formatBytes(job.asset.size_bytes)}</span>
|
<span className="text-muted">· {formatBytes(job.asset.size_bytes)}</span>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -226,7 +240,7 @@ export function DownloadRow({
|
|||||||
className="text-muted inline-flex items-center gap-1"
|
className="text-muted inline-flex items-center gap-1"
|
||||||
title={new Date(job.created_at).toLocaleString()}
|
title={new Date(job.created_at).toLocaleString()}
|
||||||
>
|
>
|
||||||
· <CalendarClock className="w-3 h-3" /> {relativeTime(job.created_at)}
|
· <CalendarClock className="w-3.5 h-3.5" /> {relativeTime(job.created_at)}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
{job.status === "error" ? (
|
{job.status === "error" ? (
|
||||||
@@ -259,7 +273,7 @@ export function DownloadRow({
|
|||||||
style={{ width: `${job.progress}%` }}
|
style={{ width: `${job.progress}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-2xs text-muted mt-0.5 flex gap-2">
|
<div className="text-xs text-muted mt-0.5 flex gap-2">
|
||||||
<span>{job.progress}%</span>
|
<span>{job.progress}%</span>
|
||||||
{job.speed_bps ? <span>{formatSpeed(job.speed_bps)}</span> : null}
|
{job.speed_bps ? <span>{formatSpeed(job.speed_bps)}</span> : null}
|
||||||
{job.eta_s ? <span>{formatEta(job.eta_s)}</span> : null}
|
{job.eta_s ? <span>{formatEta(job.eta_s)}</span> : null}
|
||||||
@@ -269,7 +283,12 @@ export function DownloadRow({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-start gap-0.5 shrink-0">{children}</div>
|
{/* Row action buttons: grown a step here (bigger tap target + icon) without touching the shared
|
||||||
|
IconBtn — scoped so the same primitive stays smaller in the modals. `shrink-0` + no wrap keeps
|
||||||
|
them on the title's line; the text column shrinks instead. */}
|
||||||
|
<div className="flex items-start gap-1 shrink-0 [&_button]:p-2 [&_svg]:size-[18px]">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -278,8 +297,12 @@ export function UsageBar() {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
// Poll live (like the library list) so the footprint updates the moment the worker finishes an
|
// Poll live (like the library list) so the footprint updates the moment the worker finishes an
|
||||||
// edit/download — enqueue only creates a queued job (0 bytes), the size lands asynchronously, so
|
// edit/download — enqueue only creates a queued job (0 bytes), the size lands asynchronously, so
|
||||||
// a one-shot fetch at enqueue time would show a stale 0 B until a manual reload.
|
// a one-shot fetch at enqueue time would show a stale 0 B until a manual reload. The footprint
|
||||||
const usage = useLiveQuery(qk.downloadUsage(), api.downloadUsage, { intervalMs: 2000 });
|
// only moves while a job is in flight, so poll fast (2s) then and idle-back-off to 15s otherwise
|
||||||
|
// (running_jobs counts queued+running — it flips back to 2s on the first poll after a new enqueue).
|
||||||
|
const usage = useLiveQuery(qk.downloadUsage(), api.downloadUsage, {
|
||||||
|
intervalMs: (u) => (u?.running_jobs ? 2000 : 15000),
|
||||||
|
});
|
||||||
const u = usage.data;
|
const u = usage.data;
|
||||||
if (!u) return null;
|
if (!u) return null;
|
||||||
const pct =
|
const pct =
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
//
|
||||||
|
// S5: the shared <Button> primitive. These lock the load-bearing contract the 120+ migrated call
|
||||||
|
// sites rely on: variant/size paint, icon-only accessibility (name must survive when the text is
|
||||||
|
// dropped), the loading state (disabled + spinner + aria-busy), and the type="button" default that
|
||||||
|
// stops an action button from submitting a surrounding form.
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { render, screen, cleanup, fireEvent } from "@testing-library/react";
|
||||||
|
import { Button } from "./Button";
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
describe("Button primitive (S5)", () => {
|
||||||
|
it("defaults to type=button so it never submits a form", () => {
|
||||||
|
render(<Button>Go</Button>);
|
||||||
|
expect(screen.getByRole("button", { name: "Go" }).getAttribute("type")).toBe("button");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies the variant paint recipe", () => {
|
||||||
|
render(
|
||||||
|
<Button variant="danger" size="sm">
|
||||||
|
Delete
|
||||||
|
</Button>,
|
||||||
|
);
|
||||||
|
const btn = screen.getByRole("button", { name: "Delete" });
|
||||||
|
expect(btn.className).toContain("btn-danger");
|
||||||
|
expect(btn.className).toContain("py-1.5");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exposes an accessible name for an icon-only button while dropping the text", () => {
|
||||||
|
render(
|
||||||
|
<Button iconOnly icon={<svg data-testid="ic" />} label="Block channel">
|
||||||
|
this text is dropped
|
||||||
|
</Button>,
|
||||||
|
);
|
||||||
|
const btn = screen.getByRole("button", { name: "Block channel" });
|
||||||
|
expect(btn.getAttribute("title")).toBe("Block channel");
|
||||||
|
expect(btn.textContent).not.toContain("this text is dropped");
|
||||||
|
expect(btn.querySelector("[data-testid=ic]")).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("loading disables the button, marks aria-busy, and blocks clicks", () => {
|
||||||
|
const onClick = vi.fn();
|
||||||
|
render(
|
||||||
|
<Button loading icon={<svg data-testid="ic" />} onClick={onClick}>
|
||||||
|
Save
|
||||||
|
</Button>,
|
||||||
|
);
|
||||||
|
const btn = screen.getByRole("button", { name: "Save" }) as HTMLButtonElement;
|
||||||
|
expect(btn.disabled).toBe(true);
|
||||||
|
expect(btn.getAttribute("aria-busy")).toBe("true");
|
||||||
|
// the leading icon is swapped for a spinner
|
||||||
|
expect(btn.querySelector("[data-testid=ic]")).toBeNull();
|
||||||
|
fireEvent.click(btn);
|
||||||
|
expect(onClick).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("loading disables even when an explicit disabled={false} is passed", () => {
|
||||||
|
// Regression: `disabled ?? loading` let an explicit boolean disabled shadow loading, so a
|
||||||
|
// call-site with `disabled={!valid} loading={pending}` (valid → disabled=false) stayed
|
||||||
|
// clickable mid-mutation and double-submitted. The contract is: loading always disables.
|
||||||
|
const onClick = vi.fn();
|
||||||
|
render(
|
||||||
|
<Button disabled={false} loading onClick={onClick}>
|
||||||
|
Create
|
||||||
|
</Button>,
|
||||||
|
);
|
||||||
|
const btn = screen.getByRole("button", { name: "Create" }) as HTMLButtonElement;
|
||||||
|
expect(btn.disabled).toBe(true);
|
||||||
|
fireEvent.click(btn);
|
||||||
|
expect(onClick).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fullWidth adds the w-full utility", () => {
|
||||||
|
render(<Button fullWidth>Wide</Button>);
|
||||||
|
expect(screen.getByRole("button", { name: "Wide" }).className).toContain("w-full");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { forwardRef } from "react";
|
||||||
|
import type { ButtonHTMLAttributes, ReactNode } from "react";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
|
||||||
|
// The ONE action-button primitive. Before this there were 385 raw <button>s styling themselves with
|
||||||
|
// ad-hoc utility strings over the CSS paint-recipes (.btn-primary/.btn-danger, index.css) + the btnCls
|
||||||
|
// constant. This centralises the geometry+paint so an "action button" (submit/cancel/subscribe/delete…)
|
||||||
|
// is one component with named variants. It deliberately does NOT cover the app's OTHER button families,
|
||||||
|
// which keep their own primitives: aria-pressed segmented pills (SegmentedToggle/SortControl), the
|
||||||
|
// Switch toggle, .chip removers, dropdown/popover trigger pills, and slider/overlay/card-click buttons.
|
||||||
|
//
|
||||||
|
// No focus-visible here on purpose: @layer base in index.css already rings every <button> for keyboard
|
||||||
|
// users. Adding one would double the ring.
|
||||||
|
|
||||||
|
type ButtonVariant =
|
||||||
|
| "primary" // solid accent — the main affirmative action
|
||||||
|
| "danger" // solid danger — destructive confirm
|
||||||
|
| "outline" // bordered, quiet — secondary action (Full history, Subscribe, Cancel-with-border)
|
||||||
|
| "ghost" // borderless, hover-fill — tertiary/dismiss (dialog Cancel, close)
|
||||||
|
| "glass" // glass card — the Plex/overlay action buttons
|
||||||
|
| "danger-outline"; // bordered danger — a destructive action that shouldn't shout
|
||||||
|
|
||||||
|
type ButtonSize =
|
||||||
|
| "xs" // dense inline action
|
||||||
|
| "sm" // the btnCls geometry (px-3 py-1.5)
|
||||||
|
| "md" // default action button (px-3 py-2)
|
||||||
|
| "lg" // prominent CTA
|
||||||
|
| "icon"; // square icon-only (w-9 h-9)
|
||||||
|
|
||||||
|
const VARIANTS: Record<ButtonVariant, string> = {
|
||||||
|
// .btn-primary / .btn-danger own the fill + one hover-dim (index.css @layer components).
|
||||||
|
primary: "btn-primary",
|
||||||
|
danger: "btn-danger",
|
||||||
|
outline: "border border-border text-muted hover:text-fg hover:border-fg/40",
|
||||||
|
ghost: "text-muted hover:text-fg hover:bg-surface",
|
||||||
|
glass: "glass-card glass-hover hover:text-accent",
|
||||||
|
"danger-outline": "border border-danger/60 text-danger hover:bg-danger/10 hover:border-danger",
|
||||||
|
};
|
||||||
|
|
||||||
|
const SIZES: Record<ButtonSize, string> = {
|
||||||
|
xs: "px-2 py-1 text-xs rounded-md gap-1",
|
||||||
|
sm: "px-3 py-1.5 text-sm rounded-lg gap-1.5",
|
||||||
|
md: "px-3 py-2 text-sm rounded-lg gap-1.5",
|
||||||
|
lg: "px-4 py-2.5 text-base rounded-lg gap-2",
|
||||||
|
icon: "w-9 h-9 rounded-lg justify-center",
|
||||||
|
};
|
||||||
|
|
||||||
|
const BASE =
|
||||||
|
"inline-flex items-center justify-center font-medium transition disabled:opacity-50 disabled:cursor-not-allowed";
|
||||||
|
|
||||||
|
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
variant?: ButtonVariant;
|
||||||
|
size?: ButtonSize;
|
||||||
|
/** Leading icon (an lucide element). Replaced by a spinner while `loading`. */
|
||||||
|
icon?: ReactNode;
|
||||||
|
/** Render only the icon: children are dropped and `label` supplies the accessible name. */
|
||||||
|
iconOnly?: boolean;
|
||||||
|
/** Accessible name + tooltip — required for `iconOnly`, optional otherwise. */
|
||||||
|
label?: string;
|
||||||
|
/** Disable + swap the icon for a spinner + mark aria-busy. */
|
||||||
|
loading?: boolean;
|
||||||
|
fullWidth?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The shared action button. `type` defaults to "button" so it never accidentally submits a form. */
|
||||||
|
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
|
||||||
|
{
|
||||||
|
variant = "primary",
|
||||||
|
size = "md",
|
||||||
|
icon,
|
||||||
|
iconOnly = false,
|
||||||
|
label,
|
||||||
|
loading = false,
|
||||||
|
fullWidth = false,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
disabled,
|
||||||
|
type,
|
||||||
|
...rest
|
||||||
|
},
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
const leading = loading ? <Loader2 className="w-4 h-4 animate-spin" /> : icon;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
ref={ref}
|
||||||
|
type={type ?? "button"}
|
||||||
|
// `loading` must always disable (see the prop's contract above), even when a call-site also
|
||||||
|
// passes an explicit boolean `disabled` — so `||`, not `??`. With `??`, `disabled={false}`
|
||||||
|
// would shadow `loading` and leave the button clickable mid-mutation (double-submit).
|
||||||
|
disabled={disabled || loading}
|
||||||
|
aria-busy={loading || undefined}
|
||||||
|
aria-label={label}
|
||||||
|
title={label}
|
||||||
|
className={clsx(BASE, VARIANTS[variant], SIZES[size], fullWidth && "w-full", className)}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
{leading}
|
||||||
|
{!iconOnly && children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { Check, RotateCcw, Save } from "lucide-react";
|
import { Check, RotateCcw, Save } from "lucide-react";
|
||||||
|
import { Button } from "./Button";
|
||||||
|
|
||||||
// The Save / Discard bar shown while an edited draft has unsaved changes (Settings prefs,
|
// The Save / Discard bar shown while an edited draft has unsaved changes (Settings prefs,
|
||||||
// admin Configuration). Two visual variants share one state machine + markup:
|
// admin Configuration). Two visual variants share one state machine + markup:
|
||||||
@@ -58,24 +59,27 @@ export function DraftSaveBar({
|
|||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<Button
|
||||||
data-testid="draft-discard"
|
data-testid="draft-discard"
|
||||||
|
variant="glass"
|
||||||
|
size="sm"
|
||||||
|
icon={<RotateCcw className="w-4 h-4" />}
|
||||||
onClick={onDiscard}
|
onClick={onDiscard}
|
||||||
disabled={saving || !dirty}
|
disabled={saving || !dirty}
|
||||||
className="glass-card glass-hover flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm disabled:opacity-40 transition"
|
|
||||||
>
|
>
|
||||||
<RotateCcw className="w-4 h-4" />
|
|
||||||
{labels.discard}
|
{labels.discard}
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
data-testid="draft-save"
|
data-testid="draft-save"
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
icon={<Save className="w-4 h-4" />}
|
||||||
onClick={onSave}
|
onClick={onSave}
|
||||||
disabled={saving || !dirty}
|
disabled={saving || !dirty}
|
||||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm btn-primary font-medium shadow-md shadow-accent/20 disabled:opacity-40 transition"
|
className="shadow-md shadow-accent/20"
|
||||||
>
|
>
|
||||||
<Save className="w-4 h-4" />
|
|
||||||
{saving ? labels.saving : labels.save}
|
{saving ? labels.saving : labels.save}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -17,5 +17,7 @@
|
|||||||
"sync_pause": "Sync paused",
|
"sync_pause": "Sync paused",
|
||||||
"sync_resume": "Sync resumed",
|
"sync_resume": "Sync resumed",
|
||||||
"job_run": "Scheduler job run",
|
"job_run": "Scheduler job run",
|
||||||
"audit_clear": "Audit log cleared"
|
"audit_clear": "Audit log cleared",
|
||||||
|
"download_credential_set": "Download cookies set",
|
||||||
|
"download_credential_delete": "Download cookies removed"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,8 +21,21 @@
|
|||||||
"addUrl": "Paste a YouTube link or video id…",
|
"addUrl": "Paste a YouTube link or video id…",
|
||||||
"add": "Add",
|
"add": "Add",
|
||||||
"paste": "Paste link from clipboard",
|
"paste": "Paste link from clipboard",
|
||||||
|
"upload": "Upload",
|
||||||
"manage": "Manage formats"
|
"manage": "Manage formats"
|
||||||
},
|
},
|
||||||
|
"upload": {
|
||||||
|
"title": "Upload a file",
|
||||||
|
"hint": "Any file type. Video and audio can be played and edited; other files are download-only.",
|
||||||
|
"drop": "Drag a file here, or click to choose",
|
||||||
|
"dropActive": "Drop to upload",
|
||||||
|
"nameLabel": "Name (optional)",
|
||||||
|
"namePlaceholder": "Leave blank to use the file name",
|
||||||
|
"submit": "Upload",
|
||||||
|
"uploading": "Uploading… {{pct}}%",
|
||||||
|
"uploadProgress": "Upload progress",
|
||||||
|
"added": "File uploaded"
|
||||||
|
},
|
||||||
"tabs": {
|
"tabs": {
|
||||||
"queue": "Queue",
|
"queue": "Queue",
|
||||||
"done": "Library",
|
"done": "Library",
|
||||||
@@ -56,13 +69,18 @@
|
|||||||
"delete": "Remove",
|
"delete": "Remove",
|
||||||
"retry": "Retry",
|
"retry": "Retry",
|
||||||
"download": "Save to my device",
|
"download": "Save to my device",
|
||||||
|
"play": "Play",
|
||||||
"rename": "Rename",
|
"rename": "Rename",
|
||||||
"editDetails": "Edit details",
|
"editDetails": "Edit details",
|
||||||
"share": "Share",
|
"share": "Share (copy link)",
|
||||||
|
"shareManage": "Manage sharing",
|
||||||
"edit": "Edit / trim",
|
"edit": "Edit / trim",
|
||||||
"redownload": "Re-download (rebuild file)",
|
"redownload": "Re-download (rebuild file)",
|
||||||
"removeShared": "Remove from my list"
|
"removeShared": "Remove from my list"
|
||||||
},
|
},
|
||||||
|
"player": {
|
||||||
|
"unsupported": "Your browser can't play this file."
|
||||||
|
},
|
||||||
"empty": {
|
"empty": {
|
||||||
"queue": "Nothing in the queue.",
|
"queue": "Nothing in the queue.",
|
||||||
"done": "No downloads yet.",
|
"done": "No downloads yet.",
|
||||||
@@ -104,6 +122,9 @@
|
|||||||
"noUsers": "No other users yet.",
|
"noUsers": "No other users yet.",
|
||||||
"linkSection": "Share a link",
|
"linkSection": "Share a link",
|
||||||
"linkHint": "Anyone with the link can watch — no account needed.",
|
"linkHint": "Anyone with the link can watch — no account needed.",
|
||||||
|
"linkCopied": "Share link copied to clipboard",
|
||||||
|
"openNewTab": "Open in new tab",
|
||||||
|
"linkFailed": "Couldn’t create the share link",
|
||||||
"noLinks": "No links yet.",
|
"noLinks": "No links yet.",
|
||||||
"newLink": "Create a link",
|
"newLink": "Create a link",
|
||||||
"createLink": "Create link",
|
"createLink": "Create link",
|
||||||
@@ -151,10 +172,7 @@
|
|||||||
"modeA": "Audio only",
|
"modeA": "Audio only",
|
||||||
"quality": "Max quality",
|
"quality": "Max quality",
|
||||||
"best": "Best",
|
"best": "Best",
|
||||||
"container": "Container",
|
|
||||||
"audioFormat": "Audio format",
|
"audioFormat": "Audio format",
|
||||||
"vcodec": "Video codec",
|
|
||||||
"any": "Any",
|
|
||||||
"embedSubs": "Embed subtitles",
|
"embedSubs": "Embed subtitles",
|
||||||
"embedChapters": "Embed chapters",
|
"embedChapters": "Embed chapters",
|
||||||
"embedThumbnail": "Embed thumbnail",
|
"embedThumbnail": "Embed thumbnail",
|
||||||
@@ -207,6 +225,10 @@
|
|||||||
"source_not_ready": "The source download isn't ready to edit.",
|
"source_not_ready": "The source download isn't ready to edit.",
|
||||||
"generic": "That edit couldn't be created."
|
"generic": "That edit couldn't be created."
|
||||||
},
|
},
|
||||||
|
"upload": {
|
||||||
|
"tooLarge": "That file is too large (max {{size}}).",
|
||||||
|
"empty": "That file is empty."
|
||||||
|
},
|
||||||
"fail": {
|
"fail": {
|
||||||
"title": "Download failed",
|
"title": "Download failed",
|
||||||
"detailsLink": "details",
|
"detailsLink": "details",
|
||||||
@@ -218,7 +240,11 @@
|
|||||||
},
|
},
|
||||||
"login_required": {
|
"login_required": {
|
||||||
"short": "Sign-in required",
|
"short": "Sign-in required",
|
||||||
"body": "This video needs a signed-in, age-verified, or members-only account to view, so it can't be downloaded here."
|
"body": "This video needs a signed-in account (age-restricted, members-only, or a private group) to view. Add your cookies for this site under Settings → Account → Download authentication to download it."
|
||||||
|
},
|
||||||
|
"unsupported_host": {
|
||||||
|
"short": "Site not supported",
|
||||||
|
"body": "This site isn't supported for downloading, so nothing could be fetched from this link."
|
||||||
},
|
},
|
||||||
"geo_blocked": {
|
"geo_blocked": {
|
||||||
"short": "Region-blocked",
|
"short": "Region-blocked",
|
||||||
@@ -249,6 +275,11 @@
|
|||||||
"body": "Something went wrong while downloading this item. The technical details below may help."
|
"body": "Something went wrong while downloading this item. The technical details below may help."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"cookies": {
|
||||||
|
"empty": "Paste your cookies first.",
|
||||||
|
"format": "That doesn't look like a cookies.txt file (Netscape format).",
|
||||||
|
"generic": "Those cookies couldn't be saved."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,8 +35,21 @@
|
|||||||
"pushTitle": "Sync to YouTube",
|
"pushTitle": "Sync to YouTube",
|
||||||
"pushConfirm": "Sync",
|
"pushConfirm": "Sync",
|
||||||
"pushPlanCreate": "Create a new YouTube playlist with {{count}} videos? (~{{units}} quota units; {{left}} left today.)",
|
"pushPlanCreate": "Create a new YouTube playlist with {{count}} videos? (~{{units}} quota units; {{left}} left today.)",
|
||||||
"pushPlanUpdate": "Update on YouTube — add {{insert}}, remove {{del}}, reorder {{reorder}}? (~{{units}} quota units; {{left}} left today.)",
|
"mergeAddLocal_one": "{{count}} video added to Siftlode from YouTube.",
|
||||||
"pushDiverged": "{{count}} video(s) currently on YouTube will be removed.",
|
"mergeAddLocal_other": "{{count}} videos added to Siftlode from YouTube.",
|
||||||
|
"mergeRemoveLocal_one": "{{count}} video removed from Siftlode.",
|
||||||
|
"mergeRemoveLocal_other": "{{count}} videos removed from Siftlode.",
|
||||||
|
"mergeAddYt_one": "{{count}} video added to YouTube.",
|
||||||
|
"mergeAddYt_other": "{{count}} videos added to YouTube.",
|
||||||
|
"mergeRemoveYt_one": "{{count}} video removed from YouTube.",
|
||||||
|
"mergeRemoveYt_other": "{{count}} videos removed from YouTube.",
|
||||||
|
"mergeReorder_one": "{{count}} video reordered on YouTube.",
|
||||||
|
"mergeReorder_other": "{{count}} videos reordered on YouTube.",
|
||||||
|
"mergeRename": "The playlist will be renamed on YouTube.",
|
||||||
|
"mergeYtUnchanged": "Your YouTube playlist stays unchanged.",
|
||||||
|
"mergeLocalUnchanged": "Your Siftlode playlist stays unchanged.",
|
||||||
|
"mergeQuota": " (~{{units}} quota units; {{left}} left today.)",
|
||||||
|
"mergeQuotaFree": " (~0 quota units.)",
|
||||||
"pushNoQuota": "Not enough YouTube quota left today ({{left}}) for this sync (~{{units}} needed). Try again tomorrow.",
|
"pushNoQuota": "Not enough YouTube quota left today ({{left}}) for this sync (~{{units}} needed). Try again tomorrow.",
|
||||||
"pushUpToDate": "“{{name}}” is already in sync with YouTube.",
|
"pushUpToDate": "“{{name}}” is already in sync with YouTube.",
|
||||||
"pushDone": "“{{name}}” synced to YouTube ✓",
|
"pushDone": "“{{name}}” synced to YouTube ✓",
|
||||||
|
|||||||
@@ -104,6 +104,27 @@
|
|||||||
"failed": "Couldn't sign out other sessions. Please try again."
|
"failed": "Couldn't sign out other sessions. Please try again."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"downloadAuth": {
|
||||||
|
"title": "Download authentication",
|
||||||
|
"intro": "Some sites (e.g. a private Facebook group you belong to) only let logged-in members view a video. Paste your browser cookies for a site here and your downloads from it will use your login. Cookies are encrypted, kept private to your account, and only ever sent to the matching site.",
|
||||||
|
"configured": "Set · updated {{when}}",
|
||||||
|
"notSet": "Not set",
|
||||||
|
"set": "Set cookies",
|
||||||
|
"update": "Update",
|
||||||
|
"remove": "Remove",
|
||||||
|
"save": "Save",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"saving": "Saving…",
|
||||||
|
"placeholder": "Paste the contents of your cookies.txt (Netscape format) here",
|
||||||
|
"help": "Export a cookies.txt for this site with a browser extension, then paste it here. It's write-only — we never show it back.",
|
||||||
|
"saved": "Cookies saved.",
|
||||||
|
"removed": "Cookies removed.",
|
||||||
|
"failed": "Couldn't save those cookies.",
|
||||||
|
"removeConfirmTitle": "Remove cookies?",
|
||||||
|
"removeConfirmBody": "Downloads from this site will stop using your login.",
|
||||||
|
"removeConfirmAction": "Remove",
|
||||||
|
"loadError": "Couldn't load your download authentication settings."
|
||||||
|
},
|
||||||
"demo": {
|
"demo": {
|
||||||
"title": "Demo access",
|
"title": "Demo access",
|
||||||
"intro": "Emails here can enter the shared demo account straight from the login page — no Google sign-in. They just type the address into the field and are let in after a moment.",
|
"intro": "Emails here can enter the shared demo account straight from the login page — no Google sign-in. They just type the address into the field and are let in after a moment.",
|
||||||
|
|||||||
@@ -17,5 +17,7 @@
|
|||||||
"sync_pause": "Szinkron szüneteltetve",
|
"sync_pause": "Szinkron szüneteltetve",
|
||||||
"sync_resume": "Szinkron folytatva",
|
"sync_resume": "Szinkron folytatva",
|
||||||
"job_run": "Ütemező job lefutott",
|
"job_run": "Ütemező job lefutott",
|
||||||
"audit_clear": "Napló ürítve"
|
"audit_clear": "Napló ürítve",
|
||||||
|
"download_credential_set": "Letöltési cookie-k beállítva",
|
||||||
|
"download_credential_delete": "Letöltési cookie-k eltávolítva"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,8 +21,21 @@
|
|||||||
"addUrl": "Illessz be egy YouTube linket vagy videó azonosítót…",
|
"addUrl": "Illessz be egy YouTube linket vagy videó azonosítót…",
|
||||||
"add": "Hozzáad",
|
"add": "Hozzáad",
|
||||||
"paste": "Link beillesztése a vágólapról",
|
"paste": "Link beillesztése a vágólapról",
|
||||||
|
"upload": "Feltöltés",
|
||||||
"manage": "Formátumok kezelése"
|
"manage": "Formátumok kezelése"
|
||||||
},
|
},
|
||||||
|
"upload": {
|
||||||
|
"title": "Fájl feltöltése",
|
||||||
|
"hint": "Bármilyen fájltípus. A videó és a hang lejátszható és szerkeszthető; a többi fájl csak letölthető.",
|
||||||
|
"drop": "Húzz ide egy fájlt, vagy kattints a kiválasztáshoz",
|
||||||
|
"dropActive": "Engedd el a feltöltéshez",
|
||||||
|
"nameLabel": "Név (opcionális)",
|
||||||
|
"namePlaceholder": "Hagyd üresen a fájlnév használatához",
|
||||||
|
"submit": "Feltöltés",
|
||||||
|
"uploading": "Feltöltés… {{pct}}%",
|
||||||
|
"uploadProgress": "Feltöltés folyamata",
|
||||||
|
"added": "Fájl feltöltve"
|
||||||
|
},
|
||||||
"tabs": {
|
"tabs": {
|
||||||
"queue": "Sor",
|
"queue": "Sor",
|
||||||
"done": "Könyvtár",
|
"done": "Könyvtár",
|
||||||
@@ -56,13 +69,18 @@
|
|||||||
"delete": "Eltávolítás",
|
"delete": "Eltávolítás",
|
||||||
"retry": "Újra",
|
"retry": "Újra",
|
||||||
"download": "Mentés a gépemre",
|
"download": "Mentés a gépemre",
|
||||||
|
"play": "Lejátszás",
|
||||||
"rename": "Átnevezés",
|
"rename": "Átnevezés",
|
||||||
"editDetails": "Adatok szerkesztése",
|
"editDetails": "Adatok szerkesztése",
|
||||||
"share": "Megosztás",
|
"share": "Megosztás (link másolása)",
|
||||||
|
"shareManage": "Megosztás kezelése",
|
||||||
"edit": "Szerkesztés / vágás",
|
"edit": "Szerkesztés / vágás",
|
||||||
"redownload": "Újraletöltés (fájl újraépítése)",
|
"redownload": "Újraletöltés (fájl újraépítése)",
|
||||||
"removeShared": "Eltávolítás a listámból"
|
"removeShared": "Eltávolítás a listámból"
|
||||||
},
|
},
|
||||||
|
"player": {
|
||||||
|
"unsupported": "A böngésződ nem tudja lejátszani ezt a fájlt."
|
||||||
|
},
|
||||||
"empty": {
|
"empty": {
|
||||||
"queue": "A sor üres.",
|
"queue": "A sor üres.",
|
||||||
"done": "Még nincs letöltés.",
|
"done": "Még nincs letöltés.",
|
||||||
@@ -104,6 +122,9 @@
|
|||||||
"noUsers": "Nincs más felhasználó.",
|
"noUsers": "Nincs más felhasználó.",
|
||||||
"linkSection": "Megosztás linkkel",
|
"linkSection": "Megosztás linkkel",
|
||||||
"linkHint": "A link birtokában bárki megnézheti — fiók nélkül is.",
|
"linkHint": "A link birtokában bárki megnézheti — fiók nélkül is.",
|
||||||
|
"linkCopied": "Megosztási link a vágólapra másolva",
|
||||||
|
"openNewTab": "Megnyitás új lapon",
|
||||||
|
"linkFailed": "Nem sikerült a megosztási link létrehozása",
|
||||||
"noLinks": "Még nincs link.",
|
"noLinks": "Még nincs link.",
|
||||||
"newLink": "Link létrehozása",
|
"newLink": "Link létrehozása",
|
||||||
"createLink": "Létrehozás",
|
"createLink": "Létrehozás",
|
||||||
@@ -151,10 +172,7 @@
|
|||||||
"modeA": "Csak hang",
|
"modeA": "Csak hang",
|
||||||
"quality": "Max. minőség",
|
"quality": "Max. minőség",
|
||||||
"best": "Legjobb",
|
"best": "Legjobb",
|
||||||
"container": "Konténer",
|
|
||||||
"audioFormat": "Hangformátum",
|
"audioFormat": "Hangformátum",
|
||||||
"vcodec": "Videó codec",
|
|
||||||
"any": "Bármelyik",
|
|
||||||
"embedSubs": "Feliratok beágyazása",
|
"embedSubs": "Feliratok beágyazása",
|
||||||
"embedChapters": "Fejezetek beágyazása",
|
"embedChapters": "Fejezetek beágyazása",
|
||||||
"embedThumbnail": "Bélyegkép beágyazása",
|
"embedThumbnail": "Bélyegkép beágyazása",
|
||||||
@@ -207,6 +225,10 @@
|
|||||||
"source_not_ready": "A forrásletöltés még nem szerkeszthető.",
|
"source_not_ready": "A forrásletöltés még nem szerkeszthető.",
|
||||||
"generic": "Ezt a vágást nem sikerült létrehozni."
|
"generic": "Ezt a vágást nem sikerült létrehozni."
|
||||||
},
|
},
|
||||||
|
"upload": {
|
||||||
|
"tooLarge": "Ez a fájl túl nagy (legfeljebb {{size}}).",
|
||||||
|
"empty": "Ez a fájl üres."
|
||||||
|
},
|
||||||
"fail": {
|
"fail": {
|
||||||
"title": "A letöltés nem sikerült",
|
"title": "A letöltés nem sikerült",
|
||||||
"detailsLink": "részletek",
|
"detailsLink": "részletek",
|
||||||
@@ -218,7 +240,11 @@
|
|||||||
},
|
},
|
||||||
"login_required": {
|
"login_required": {
|
||||||
"short": "Bejelentkezés szükséges",
|
"short": "Bejelentkezés szükséges",
|
||||||
"body": "Ehhez a videóhoz bejelentkezett, kor-ellenőrzött vagy tagsági fiók kell, ezért itt nem tölthető le."
|
"body": "Ehhez a videóhoz bejelentkezett fiók kell (korhatáros, csak tagoknak szóló vagy zárt csoport). Töltsd fel a cookie-kat ehhez az oldalhoz a Beállítások → Fiók → Letöltés-hitelesítés alatt a letöltéshez."
|
||||||
|
},
|
||||||
|
"unsupported_host": {
|
||||||
|
"short": "Nem támogatott oldal",
|
||||||
|
"body": "Ez az oldal nem támogatott a letöltéshez, ezért erről a linkről semmit sem sikerült lekérni."
|
||||||
},
|
},
|
||||||
"geo_blocked": {
|
"geo_blocked": {
|
||||||
"short": "Régió szerint tiltva",
|
"short": "Régió szerint tiltva",
|
||||||
@@ -249,6 +275,11 @@
|
|||||||
"body": "Valami hiba történt az elem letöltése közben. Az alábbi technikai részletek segíthetnek."
|
"body": "Valami hiba történt az elem letöltése közben. Az alábbi technikai részletek segíthetnek."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"cookies": {
|
||||||
|
"empty": "Előbb illeszd be a cookie-kat.",
|
||||||
|
"format": "Ez nem tűnik cookies.txt fájlnak (Netscape formátum).",
|
||||||
|
"generic": "A cookie-kat nem sikerült menteni."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,8 +35,21 @@
|
|||||||
"pushTitle": "Szinkron YouTube-ra",
|
"pushTitle": "Szinkron YouTube-ra",
|
||||||
"pushConfirm": "Szinkron",
|
"pushConfirm": "Szinkron",
|
||||||
"pushPlanCreate": "Létrehozol egy új YouTube-listát {{count}} videóval? (~{{units}} kvótaegység; ma még {{left}} maradt.)",
|
"pushPlanCreate": "Létrehozol egy új YouTube-listát {{count}} videóval? (~{{units}} kvótaegység; ma még {{left}} maradt.)",
|
||||||
"pushPlanUpdate": "Frissítés a YouTube-on — {{insert}} hozzáadása, {{del}} eltávolítása, {{reorder}} átrendezése? (~{{units}} kvótaegység; ma még {{left}} maradt.)",
|
"mergeAddLocal_one": "{{count}} videó bekerül a Siftlode-listádba a YouTube-ról.",
|
||||||
"pushDiverged": "{{count}} videó, amely jelenleg a YouTube-on van, el lesz távolítva.",
|
"mergeAddLocal_other": "{{count}} videó bekerül a Siftlode-listádba a YouTube-ról.",
|
||||||
|
"mergeRemoveLocal_one": "{{count}} videó törlődik a Siftlode-listádból.",
|
||||||
|
"mergeRemoveLocal_other": "{{count}} videó törlődik a Siftlode-listádból.",
|
||||||
|
"mergeAddYt_one": "{{count}} videó felkerül a YouTube-ra.",
|
||||||
|
"mergeAddYt_other": "{{count}} videó felkerül a YouTube-ra.",
|
||||||
|
"mergeRemoveYt_one": "{{count}} videó törlődik a YouTube-ról.",
|
||||||
|
"mergeRemoveYt_other": "{{count}} videó törlődik a YouTube-ról.",
|
||||||
|
"mergeReorder_one": "{{count}} videó átrendeződik a YouTube-on.",
|
||||||
|
"mergeReorder_other": "{{count}} videó átrendeződik a YouTube-on.",
|
||||||
|
"mergeRename": "A lista átnevezésre kerül a YouTube-on.",
|
||||||
|
"mergeYtUnchanged": "A YouTube-listád nem változik.",
|
||||||
|
"mergeLocalUnchanged": "A Siftlode-listád nem változik.",
|
||||||
|
"mergeQuota": " (~{{units}} kvótaegység; ma még {{left}} maradt.)",
|
||||||
|
"mergeQuotaFree": " (~0 kvótaegység.)",
|
||||||
"pushNoQuota": "Nincs elég YouTube-kvóta mára ({{left}}) ehhez a szinkronhoz (~{{units}} kell). Próbáld újra holnap.",
|
"pushNoQuota": "Nincs elég YouTube-kvóta mára ({{left}}) ehhez a szinkronhoz (~{{units}} kell). Próbáld újra holnap.",
|
||||||
"pushUpToDate": "A(z) „{{name}}” már szinkronban van a YouTube-bal.",
|
"pushUpToDate": "A(z) „{{name}}” már szinkronban van a YouTube-bal.",
|
||||||
"pushDone": "„{{name}}” szinkronizálva a YouTube-ra ✓",
|
"pushDone": "„{{name}}” szinkronizálva a YouTube-ra ✓",
|
||||||
|
|||||||
@@ -104,6 +104,27 @@
|
|||||||
"failed": "Nem sikerült kijelentkeztetni a többi munkamenetet. Próbáld újra."
|
"failed": "Nem sikerült kijelentkeztetni a többi munkamenetet. Próbáld újra."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"downloadAuth": {
|
||||||
|
"title": "Letöltés-hitelesítés",
|
||||||
|
"intro": "Egyes oldalak (pl. egy zárt Facebook-csoport, aminek tagja vagy) csak bejelentkezett tagoknak mutatják a videót. Illeszd be ide a böngésződ cookie-jait egy oldalhoz, és az onnan induló letöltések a te bejelentkezésedet használják. A cookie-k titkosítva, csak a te fiókodhoz kötve tárolódnak, és kizárólag az adott oldalnak küldjük el őket.",
|
||||||
|
"configured": "Beállítva · frissítve: {{when}}",
|
||||||
|
"notSet": "Nincs beállítva",
|
||||||
|
"set": "Cookie-k beállítása",
|
||||||
|
"update": "Frissítés",
|
||||||
|
"remove": "Eltávolítás",
|
||||||
|
"save": "Mentés",
|
||||||
|
"cancel": "Mégse",
|
||||||
|
"saving": "Mentés…",
|
||||||
|
"placeholder": "Illeszd be ide a cookies.txt tartalmát (Netscape formátum)",
|
||||||
|
"help": "Exportálj cookies.txt-t ehhez az oldalhoz egy böngészőbővítménnyel, majd illeszd be ide. Csak írható — sosem mutatjuk vissza.",
|
||||||
|
"saved": "Cookie-k mentve.",
|
||||||
|
"removed": "Cookie-k eltávolítva.",
|
||||||
|
"failed": "A cookie-kat nem sikerült menteni.",
|
||||||
|
"removeConfirmTitle": "Cookie-k eltávolítása?",
|
||||||
|
"removeConfirmBody": "Az erről az oldalról induló letöltések nem használják tovább a bejelentkezésedet.",
|
||||||
|
"removeConfirmAction": "Eltávolítás",
|
||||||
|
"loadError": "Nem sikerült betölteni a letöltési hitelesítési beállításokat."
|
||||||
|
},
|
||||||
"demo": {
|
"demo": {
|
||||||
"title": "Demo hozzáférés",
|
"title": "Demo hozzáférés",
|
||||||
"intro": "Az itt szereplő e-mailek a bejelentkező oldalról közvetlenül beléphetnek a közös demo fiókba — Google-bejelentkezés nélkül. Csak beírják a címet a mezőbe, és pár pillanat múlva bekerülnek.",
|
"intro": "Az itt szereplő e-mailek a bejelentkező oldalról közvetlenül beléphetnek a közös demo fiókba — Google-bejelentkezés nélkül. Csak beírják a címet a mezőbe, és pár pillanat múlva bekerülnek.",
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
//
|
||||||
|
// uploadReq is the XHR upload path (progress/abort/error/401/4xx-localize) that `fetch`/`req` can't
|
||||||
|
// cover. UploadDialog's DOM test mocks api.uploadDownload wholesale, so without this the abort, 401,
|
||||||
|
// and structured-detail-localize branches ship untested. A controllable fake XMLHttpRequest drives
|
||||||
|
// each lifecycle branch.
|
||||||
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
|
|
||||||
|
const { reportError, notify, remove } = vi.hoisted(() => ({
|
||||||
|
reportError: vi.fn(),
|
||||||
|
notify: vi.fn(() => 1),
|
||||||
|
remove: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../errorDialog", () => ({ reportError }));
|
||||||
|
vi.mock("../notifications", () => ({ notify, remove }));
|
||||||
|
vi.mock("../../i18n", () => ({ default: { t: (k: string) => k, language: "en" } }));
|
||||||
|
vi.mock("../activeAccount", () => ({
|
||||||
|
activeAccountId: () => null,
|
||||||
|
ACTIVE_ACCOUNT_KEY: "k",
|
||||||
|
notifyActiveAccountChange: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { uploadReq, setUnauthorizedHandler, HttpError } from "./core";
|
||||||
|
|
||||||
|
// A fake XHR whose lifecycle the test drives: capture the instance, set status/responseText, then
|
||||||
|
// invoke onload/onerror/abort by hand.
|
||||||
|
class FakeXHR {
|
||||||
|
static last: FakeXHR;
|
||||||
|
status = 0;
|
||||||
|
responseText = "";
|
||||||
|
withCredentials = false;
|
||||||
|
headers: Record<string, string> = {};
|
||||||
|
upload: { onprogress: ((e: ProgressEvent) => void) | null } = { onprogress: null };
|
||||||
|
onload: (() => void) | null = null;
|
||||||
|
onerror: (() => void) | null = null;
|
||||||
|
onabort: (() => void) | null = null;
|
||||||
|
sent = false;
|
||||||
|
constructor() {
|
||||||
|
FakeXHR.last = this;
|
||||||
|
}
|
||||||
|
open() {}
|
||||||
|
setRequestHeader(k: string, v: string) {
|
||||||
|
this.headers[k] = v;
|
||||||
|
}
|
||||||
|
send() {
|
||||||
|
this.sent = true;
|
||||||
|
}
|
||||||
|
abort() {
|
||||||
|
this.onabort?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
reportError.mockReset();
|
||||||
|
notify.mockReset();
|
||||||
|
setUnauthorizedHandler(null);
|
||||||
|
vi.stubGlobal("XMLHttpRequest", FakeXHR as unknown as typeof XMLHttpRequest);
|
||||||
|
});
|
||||||
|
|
||||||
|
const form = () => new FormData();
|
||||||
|
|
||||||
|
describe("uploadReq", () => {
|
||||||
|
it("resolves with the parsed JSON on a 2xx", async () => {
|
||||||
|
const p = uploadReq<{ id: number }>("/u", form());
|
||||||
|
const x = FakeXHR.last;
|
||||||
|
x.status = 200;
|
||||||
|
x.responseText = '{"id":7}';
|
||||||
|
x.onload!();
|
||||||
|
await expect(p).resolves.toEqual({ id: 7 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects immediately if the signal is already aborted (never sends)", async () => {
|
||||||
|
const ac = new AbortController();
|
||||||
|
ac.abort();
|
||||||
|
const p = uploadReq("/u", form(), { signal: ac.signal });
|
||||||
|
await expect(p).rejects.toBeInstanceOf(HttpError);
|
||||||
|
expect(FakeXHR.last?.sent).not.toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("aborts the XHR and rejects when the signal fires", async () => {
|
||||||
|
const ac = new AbortController();
|
||||||
|
const p = uploadReq("/u", form(), { signal: ac.signal });
|
||||||
|
ac.abort(); // → our abort listener calls xhr.abort() → onabort → reject
|
||||||
|
await expect(p).rejects.toMatchObject({ status: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls the unauthorized handler on 401", async () => {
|
||||||
|
const onUnauth = vi.fn();
|
||||||
|
setUnauthorizedHandler(onUnauth);
|
||||||
|
const p = uploadReq("/u", form());
|
||||||
|
const x = FakeXHR.last;
|
||||||
|
x.status = 401;
|
||||||
|
x.onload!();
|
||||||
|
await expect(p).rejects.toBeInstanceOf(HttpError);
|
||||||
|
expect(onUnauth).toHaveBeenCalledOnce();
|
||||||
|
expect(reportError).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("localizes a structured 422 upload_too_large and surfaces the error", async () => {
|
||||||
|
const p = uploadReq("/u", form());
|
||||||
|
const x = FakeXHR.last;
|
||||||
|
x.status = 422;
|
||||||
|
x.responseText = '{"detail":{"code":"upload_too_large","limit":5}}';
|
||||||
|
x.onload!();
|
||||||
|
await expect(p).rejects.toMatchObject({ status: 422 });
|
||||||
|
// localizeDetail routes upload_too_large → the tooLarge key (t is passthrough in the mock).
|
||||||
|
expect(reportError).toHaveBeenCalledWith("downloads.errors.upload.tooLarge");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a connectivity failure on a network error", async () => {
|
||||||
|
const p = uploadReq("/u", form());
|
||||||
|
FakeXHR.last.onerror!();
|
||||||
|
await expect(p).rejects.toMatchObject({ status: 0 });
|
||||||
|
// The soft self-clearing "connection lost" toast (via notify), not a blocking modal.
|
||||||
|
expect(notify).toHaveBeenCalled();
|
||||||
|
expect(reportError).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
// so the runtime graph is a clean barrel → domain → core with no cycles.
|
// so the runtime graph is a clean barrel → domain → core with no cycles.
|
||||||
import { notify, remove } from "../notifications";
|
import { notify, remove } from "../notifications";
|
||||||
import i18n from "../../i18n";
|
import i18n from "../../i18n";
|
||||||
|
import { formatBytes } from "../format";
|
||||||
import { reportError } from "../errorDialog";
|
import { reportError } from "../errorDialog";
|
||||||
import { activeAccountId, ACTIVE_ACCOUNT_KEY, notifyActiveAccountChange } from "../activeAccount";
|
import { activeAccountId, ACTIVE_ACCOUNT_KEY, notifyActiveAccountChange } from "../activeAccount";
|
||||||
|
|
||||||
@@ -43,6 +44,12 @@ function localizeDetail(d: unknown): string {
|
|||||||
}
|
}
|
||||||
if (o?.code === "edit")
|
if (o?.code === "edit")
|
||||||
return t(`downloads.errors.edit.${o.reason}`, t("downloads.errors.edit.generic"));
|
return t(`downloads.errors.edit.${o.reason}`, t("downloads.errors.edit.generic"));
|
||||||
|
if (o?.code === "cookies")
|
||||||
|
return t(`downloads.errors.cookies.${o.reason}`, t("downloads.errors.cookies.generic"));
|
||||||
|
// Local-upload ingest (E7 S5): the per-file cap carries the limit in bytes; empty is a bare code.
|
||||||
|
if (o?.code === "upload_too_large")
|
||||||
|
return t("downloads.errors.upload.tooLarge", { size: formatBytes(o.limit) });
|
||||||
|
if (o?.code === "upload_empty") return t("downloads.errors.upload.empty");
|
||||||
return t("errors.generic");
|
return t("errors.generic");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,3 +240,67 @@ export async function req<T = unknown>(
|
|||||||
return (r.status === 204 ? null : await r.json()) as T;
|
return (r.status === 204 ? null : await r.json()) as T;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Multipart upload with progress. `req` rides `fetch`, which can't report upload progress, so a
|
||||||
|
// file upload uses XHR instead: the caller gets a 0–100% callback as the body streams and an
|
||||||
|
// optional AbortSignal to cancel a large upload. Everything else mirrors `req` — the per-tab
|
||||||
|
// account header, credentials, the structured-detail localization, the global error modal for
|
||||||
|
// 4xx/5xx, and an `HttpError` throw — so callers handle failures exactly as they do a `req` call.
|
||||||
|
// The browser sets the multipart Content-Type (with its boundary), so we deliberately don't.
|
||||||
|
export function uploadReq<T = unknown>(
|
||||||
|
url: string,
|
||||||
|
form: FormData,
|
||||||
|
cfg: { onProgress?: (pct: number) => void; signal?: AbortSignal } = {},
|
||||||
|
): Promise<T> {
|
||||||
|
return new Promise<T>((resolve, reject) => {
|
||||||
|
const xhr = new XMLHttpRequest();
|
||||||
|
xhr.open("POST", url);
|
||||||
|
xhr.withCredentials = true;
|
||||||
|
const active = getActiveAccount();
|
||||||
|
if (active != null) xhr.setRequestHeader(ACTIVE_ACCOUNT_HEADER, String(active));
|
||||||
|
if (cfg.onProgress) {
|
||||||
|
xhr.upload.onprogress = (e) => {
|
||||||
|
if (e.lengthComputable) cfg.onProgress?.(Math.round((e.loaded / e.total) * 100));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (cfg.signal) {
|
||||||
|
if (cfg.signal.aborted) return reject(new HttpError(0, "aborted"));
|
||||||
|
cfg.signal.addEventListener("abort", () => xhr.abort());
|
||||||
|
}
|
||||||
|
xhr.onload = () => {
|
||||||
|
markConnectivityRestored();
|
||||||
|
if (xhr.status >= 200 && xhr.status < 300) {
|
||||||
|
try {
|
||||||
|
resolve((xhr.responseText ? JSON.parse(xhr.responseText) : null) as T);
|
||||||
|
} catch {
|
||||||
|
resolve(null as T);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let detail: string | undefined;
|
||||||
|
let detailData: unknown;
|
||||||
|
try {
|
||||||
|
const body = JSON.parse(xhr.responseText);
|
||||||
|
if (body && typeof body.detail === "string") detail = body.detail;
|
||||||
|
else if (body && body.detail && typeof body.detail === "object") {
|
||||||
|
detailData = body.detail;
|
||||||
|
detail = localizeDetail(body.detail);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* no JSON body */
|
||||||
|
}
|
||||||
|
if (xhr.status === 401) onUnauthorized?.();
|
||||||
|
else if (xhr.status >= 500)
|
||||||
|
reportError(detail || `${i18n.t("errors.server")} (${xhr.status})`);
|
||||||
|
else if (xhr.status === 400 || xhr.status === 409 || xhr.status === 422) reportError(detail);
|
||||||
|
reject(new HttpError(xhr.status, detail, detailData));
|
||||||
|
};
|
||||||
|
// A network-level failure (server unreachable) — same soft "connection lost" status as `req`.
|
||||||
|
xhr.onerror = () => {
|
||||||
|
markConnectivityLost();
|
||||||
|
reject(new HttpError(0, i18n.t("errors.offline.body")));
|
||||||
|
};
|
||||||
|
xhr.onabort = () => reject(new HttpError(0, "aborted"));
|
||||||
|
xhr.send(form);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Download-center domain: the yt-dlp job lifecycle, per-user profiles, share links/recipients, the
|
// Download-center domain: the yt-dlp job lifecycle, per-user profiles, share links/recipients, the
|
||||||
// phase-2 editor, storyboards, and the admin storage/quota views. Self-contained — its types only
|
// phase-2 editor, storyboards, and the admin storage/quota views. Self-contained — its types only
|
||||||
// reference each other, and its methods only touch the shared `req`/`getActiveAccount` from core.
|
// reference each other, and its methods only touch the shared `req`/`getActiveAccount` from core.
|
||||||
import { req, getActiveAccount } from "./core";
|
import { req, uploadReq, getActiveAccount } from "./core";
|
||||||
|
|
||||||
export interface DownloadSpec {
|
export interface DownloadSpec {
|
||||||
mode: "av" | "v" | "a"; // audio+video | video-only | audio-only
|
mode: "av" | "v" | "a"; // audio+video | video-only | audio-only
|
||||||
@@ -35,14 +35,19 @@ interface DownloadAsset {
|
|||||||
width: number | null;
|
width: number | null;
|
||||||
height: number | null;
|
height: number | null;
|
||||||
container: string | null;
|
container: string | null;
|
||||||
|
// "video" | "audio" | "file" — the stored discriminator that gates library row actions: media
|
||||||
|
// (video/audio) gets play/edit; a generic uploaded file is download+share only, no player. Null
|
||||||
|
// while still in flight (set at finalize). See backend downloads/mediakind.py.
|
||||||
|
media_kind: "video" | "audio" | "file" | null;
|
||||||
thumbnail_url: string | null;
|
thumbnail_url: string | null;
|
||||||
expires_at: string | null;
|
expires_at: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DownloadStatus = "queued" | "running" | "paused" | "done" | "error" | "canceled";
|
export type DownloadStatus = "queued" | "running" | "paused" | "done" | "error" | "canceled";
|
||||||
// "download" (default) or "edit" (a per-user trim/crop derivative cut from another job).
|
// "download" (default), "edit" (a per-user trim/crop derivative cut from another job), or "upload"
|
||||||
|
// (a locally-uploaded file — no re-fetchable source, so re-download is rejected backend-side).
|
||||||
// Local (not exported): consumers compare `job.job_kind` against literals, never name the type.
|
// Local (not exported): consumers compare `job.job_kind` against literals, never name the type.
|
||||||
type DownloadJobKind = "download" | "edit";
|
type DownloadJobKind = "download" | "edit" | "upload";
|
||||||
|
|
||||||
// Phase-2 editor recipe. A single `trim` (one output file) or a `segments` cut-list joined into
|
// Phase-2 editor recipe. A single `trim` (one output file) or a `segments` cut-list joined into
|
||||||
// one file. `accurate` re-encodes for a frame-accurate cut (a crop always re-encodes).
|
// one file. `accurate` re-encodes for a frame-accurate cut (a crop always re-encodes).
|
||||||
@@ -137,7 +142,31 @@ export interface AdminDownloadQuota {
|
|||||||
unlimited: boolean;
|
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 = {
|
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"),
|
downloadProfiles: (): Promise<DownloadProfile[]> => req("/api/downloads/profiles"),
|
||||||
createDownloadProfile: (p: { name: string; spec: DownloadSpec }): Promise<DownloadProfile> =>
|
createDownloadProfile: (p: { name: string; spec: DownloadSpec }): Promise<DownloadProfile> =>
|
||||||
req("/api/downloads/profiles", { method: "POST", body: JSON.stringify(p) }),
|
req("/api/downloads/profiles", { method: "POST", body: JSON.stringify(p) }),
|
||||||
@@ -154,6 +183,21 @@ export const downloadsApi = {
|
|||||||
spec?: DownloadSpec;
|
spec?: DownloadSpec;
|
||||||
display_name?: string;
|
display_name?: string;
|
||||||
}): Promise<DownloadJob> => req("/api/downloads", { method: "POST", body: JSON.stringify(body) }),
|
}): Promise<DownloadJob> => req("/api/downloads", { method: "POST", body: JSON.stringify(body) }),
|
||||||
|
// Ingest a local file into the library (E7 S5). Multipart via XHR so the dialog can show upload
|
||||||
|
// progress; any file type is accepted (media gets the full function set, a generic file is
|
||||||
|
// download-only). Returns the already-`done` job the ingest creates.
|
||||||
|
uploadDownload: (
|
||||||
|
file: File,
|
||||||
|
opts: { displayName?: string; onProgress?: (pct: number) => void; signal?: AbortSignal } = {},
|
||||||
|
): Promise<DownloadJob> => {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append("file", file);
|
||||||
|
if (opts.displayName) form.append("display_name", opts.displayName);
|
||||||
|
return uploadReq<DownloadJob>("/api/downloads/upload", form, {
|
||||||
|
onProgress: opts.onProgress,
|
||||||
|
signal: opts.signal,
|
||||||
|
});
|
||||||
|
},
|
||||||
enqueueEdit: (body: {
|
enqueueEdit: (body: {
|
||||||
source_job_id: number;
|
source_job_id: number;
|
||||||
edit_spec: EditSpec;
|
edit_spec: EditSpec;
|
||||||
|
|||||||
@@ -31,12 +31,16 @@ export interface PlaylistDetail {
|
|||||||
items: Video[];
|
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 {
|
export interface PushPlan {
|
||||||
action: "create" | "update";
|
action: "create" | "update";
|
||||||
to_insert: number;
|
add_yt: number; // local-adds to push out to YouTube
|
||||||
to_delete: number;
|
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;
|
to_reorder: number;
|
||||||
yt_extra: number; // items on YouTube that the push would remove (divergence)
|
rename: boolean;
|
||||||
units_estimate: number;
|
units_estimate: number;
|
||||||
remaining_today: number;
|
remaining_today: number;
|
||||||
affordable: boolean;
|
affordable: boolean;
|
||||||
@@ -44,9 +48,11 @@ export interface PushPlan {
|
|||||||
|
|
||||||
export interface PushResult {
|
export interface PushResult {
|
||||||
created: number;
|
created: number;
|
||||||
inserted: number;
|
inserted: number; // added on YouTube
|
||||||
deleted: number;
|
deleted: number; // removed on YouTube
|
||||||
reordered: number;
|
reordered: number;
|
||||||
|
pulled: number; // added into Siftlode from YouTube
|
||||||
|
removed_local: number; // removed from Siftlode (deleted on YouTube)
|
||||||
failures: string[];
|
failures: string[];
|
||||||
yt_playlist_id: string | null;
|
yt_playlist_id: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { contentRect, cropToSourcePx } from "./cropGeometry";
|
||||||
|
|
||||||
|
describe("contentRect (display-px letterbox mapping)", () => {
|
||||||
|
it("returns the full box when box aspect matches the video aspect", () => {
|
||||||
|
// 16:9 picture in a 16:9 box → no bars, content == box
|
||||||
|
expect(contentRect(1600, 900, 16 / 9)).toEqual({
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
width: 1600,
|
||||||
|
height: 900,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pillarboxes (bars left/right) when the box is wider than the picture", () => {
|
||||||
|
// 16:9 picture in a 2:1 box → full height, narrower centered width
|
||||||
|
const r = contentRect(2000, 1000, 16 / 9)!;
|
||||||
|
expect(r.height).toBe(1000);
|
||||||
|
expect(r.width).toBeCloseTo(1000 * (16 / 9), 3);
|
||||||
|
expect(r.top).toBe(0);
|
||||||
|
expect(r.left).toBeCloseTo((2000 - 1000 * (16 / 9)) / 2, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("letterboxes (bars top/bottom) when the box is taller than the picture", () => {
|
||||||
|
// 16:9 picture in a 1:1 box → full width, shorter centered height
|
||||||
|
const r = contentRect(1000, 1000, 16 / 9)!;
|
||||||
|
expect(r.width).toBe(1000);
|
||||||
|
expect(r.height).toBeCloseTo(1000 / (16 / 9), 3);
|
||||||
|
expect(r.left).toBe(0);
|
||||||
|
expect(r.top).toBeCloseTo((1000 - 1000 / (16 / 9)) / 2, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for unmeasurable inputs", () => {
|
||||||
|
expect(contentRect(0, 900, 16 / 9)).toBeNull();
|
||||||
|
expect(contentRect(1600, 0, 16 / 9)).toBeNull();
|
||||||
|
expect(contentRect(1600, 900, 0)).toBeNull();
|
||||||
|
expect(contentRect(1600, 900, NaN)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("cropToSourcePx (fraction-of-content → even source px)", () => {
|
||||||
|
it("scales content fractions straight to source pixels", () => {
|
||||||
|
// A marquee anchored to the content rect: a fraction is a fraction of the real picture.
|
||||||
|
expect(cropToSourcePx({ x: 0.25, y: 0.25, w: 0.5, h: 0.5 }, 1920, 1080)).toEqual({
|
||||||
|
x: 480,
|
||||||
|
y: 270,
|
||||||
|
w: 960,
|
||||||
|
h: 540,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rounds to even dimensions", () => {
|
||||||
|
// 0.1 * 1920 = 192 (even); 0.333 * 1080 = 359.64 → 360
|
||||||
|
const c = cropToSourcePx({ x: 0.1, y: 0.333, w: 0.2, h: 0.111 }, 1920, 1080)!;
|
||||||
|
expect(c.w % 2).toBe(0);
|
||||||
|
expect(c.h % 2).toBe(0);
|
||||||
|
expect(c.x % 2).toBe(0);
|
||||||
|
expect(c.y % 2).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows a top-left origin (x/y may be 0)", () => {
|
||||||
|
const c = cropToSourcePx({ x: 0, y: 0, w: 0.5, h: 0.5 }, 1920, 1080)!;
|
||||||
|
expect(c.x).toBe(0);
|
||||||
|
expect(c.y).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clamps the window inside the frame so even-rounding can't overflow", () => {
|
||||||
|
// A full-frame crop must stay exactly at the source size, not round past it.
|
||||||
|
const c = cropToSourcePx({ x: 0, y: 0, w: 1, h: 1 }, 1921, 1081)!;
|
||||||
|
expect(c.w).toBeLessThanOrEqual(1921);
|
||||||
|
expect(c.h).toBeLessThanOrEqual(1081);
|
||||||
|
expect(c.x + c.w).toBeLessThanOrEqual(1921);
|
||||||
|
expect(c.y + c.h).toBeLessThanOrEqual(1081);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps x+w within the frame for an off-square region near the right edge", () => {
|
||||||
|
const srcW = 1280;
|
||||||
|
const srcH = 720;
|
||||||
|
const c = cropToSourcePx({ x: 0.7, y: 0.1, w: 0.3, h: 0.8 }, srcW, srcH)!;
|
||||||
|
expect(c.x + c.w).toBeLessThanOrEqual(srcW);
|
||||||
|
expect(c.y + c.h).toBeLessThanOrEqual(srcH);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for an unusable source size", () => {
|
||||||
|
expect(cropToSourcePx({ x: 0, y: 0, w: 1, h: 1 }, 0, 1080)).toBeNull();
|
||||||
|
expect(cropToSourcePx({ x: 0, y: 0, w: 1, h: 1 }, 1920, 0)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
// Crop geometry for the video editor — the display-px ↔ source-px mapping, isolated as pure
|
||||||
|
// functions so the math is unit-testable without a DOM.
|
||||||
|
//
|
||||||
|
// The bug this fixes (E7 S1→S2): the crop marquee is drawn in fractions of the *video element box*,
|
||||||
|
// but a letterboxed video (object-fit: contain) does NOT fill that box — pillar/letterbox bars sit
|
||||||
|
// between the box edge and the actual picture. Multiplying box-fractions by the source resolution
|
||||||
|
// therefore emitted an ffmpeg crop shifted/scaled off from what the selector showed. The fix is to
|
||||||
|
// anchor the overlay to the *content* rect (below), so a fraction of the overlay is a fraction of
|
||||||
|
// the real picture, and cropToSourcePx() is then a straight fraction→pixel scale.
|
||||||
|
|
||||||
|
export type Frac = { x: number; y: number; w: number; h: number };
|
||||||
|
type Rect = { left: number; top: number; width: number; height: number };
|
||||||
|
type CropPx = { x: number; y: number; w: number; h: number };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The rendered video-content rectangle inside an element box of `boxW × boxH`, given the video's
|
||||||
|
* intrinsic aspect ratio, when the video is laid out `object-fit: contain` (letterboxed). Returns
|
||||||
|
* null while any input is unmeasurable (0/NaN), so callers can skip drawing until the video sizes.
|
||||||
|
*/
|
||||||
|
export function contentRect(boxW: number, boxH: number, videoAspect: number): Rect | null {
|
||||||
|
if (!(boxW > 0) || !(boxH > 0) || !(videoAspect > 0)) return null;
|
||||||
|
const boxAspect = boxW / boxH;
|
||||||
|
if (boxAspect > videoAspect) {
|
||||||
|
// box wider than the picture → pillarbox (bars left/right), full height
|
||||||
|
const width = boxH * videoAspect;
|
||||||
|
return { left: (boxW - width) / 2, top: 0, width, height: boxH };
|
||||||
|
}
|
||||||
|
// box taller than the picture → letterbox (bars top/bottom), full width
|
||||||
|
const height = boxW / videoAspect;
|
||||||
|
return { left: 0, top: (boxH - height) / 2, width: boxW, height };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A crop expressed as fractions of the video *content* → integer, even source-pixel crop args
|
||||||
|
* (ffmpeg `crop=w:h:x:y`). Even dimensions keep chroma-subsampled encoders happy; the window is
|
||||||
|
* clamped inside the frame so even-rounding can never push `x+w`/`y+h` past the source resolution
|
||||||
|
* (which ffmpeg rejects as "crop area out of bounds"). Returns null for an unusable source size.
|
||||||
|
*/
|
||||||
|
export function cropToSourcePx(crop: Frac, srcW: number, srcH: number): CropPx | null {
|
||||||
|
if (!(srcW > 0) || !(srcH > 0)) return null;
|
||||||
|
const evenDim = (n: number) => Math.max(2, Math.round(n / 2) * 2); // w/h ≥ 2
|
||||||
|
const evenOff = (n: number) => Math.max(0, Math.round(n / 2) * 2); // x/y ≥ 0 (top-left is valid)
|
||||||
|
const w = Math.min(evenDim(crop.w * srcW), srcW);
|
||||||
|
const h = Math.min(evenDim(crop.h * srcH), srcH);
|
||||||
|
const x = Math.min(evenOff(crop.x * srcW), srcW - w);
|
||||||
|
const y = Math.min(evenOff(crop.y * srcH), srcH - h);
|
||||||
|
return { x, y, w, h };
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
// E7·S5b — the Library/Shared row-action gate. A generic uploaded file (media_kind='file') is
|
||||||
|
// download+share only (no Play/Edit/Details); an edit clip and an upload can't be re-downloaded.
|
||||||
|
// These pin the gate so a regression that shows Play/Edit on a file, or re-download on an upload,
|
||||||
|
// is caught (the affordance itself, not just the backend guard behind it).
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { libraryRowActions, sharedRowActions } from "./downloadActions";
|
||||||
|
import type { DownloadJob } from "./api";
|
||||||
|
|
||||||
|
const job = (
|
||||||
|
over: Partial<DownloadJob>,
|
||||||
|
asset: Partial<NonNullable<DownloadJob["asset"]>> | null,
|
||||||
|
) =>
|
||||||
|
({
|
||||||
|
id: 1,
|
||||||
|
status: "done",
|
||||||
|
job_kind: "download",
|
||||||
|
can_download: true,
|
||||||
|
asset: asset && {
|
||||||
|
id: 1,
|
||||||
|
status: "ready",
|
||||||
|
title: "x",
|
||||||
|
duration_s: 90,
|
||||||
|
media_kind: "video",
|
||||||
|
container: "mp4",
|
||||||
|
...asset,
|
||||||
|
},
|
||||||
|
...over,
|
||||||
|
}) as unknown as DownloadJob;
|
||||||
|
|
||||||
|
describe("libraryRowActions", () => {
|
||||||
|
it("a media (video) item gets play, edit, details, redownload", () => {
|
||||||
|
expect(libraryRowActions(job({}, {}))).toEqual({
|
||||||
|
play: true,
|
||||||
|
edit: true,
|
||||||
|
editDetails: true,
|
||||||
|
redownload: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a generic file gets NO play/edit/details (download+share only)", () => {
|
||||||
|
const a = libraryRowActions(job({}, { media_kind: "file", duration_s: null }));
|
||||||
|
expect(a.play).toBe(false);
|
||||||
|
expect(a.edit).toBe(false);
|
||||||
|
expect(a.editDetails).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an upload cannot be re-downloaded (no re-fetchable source)", () => {
|
||||||
|
expect(libraryRowActions(job({ job_kind: "upload" }, { media_kind: "file" })).redownload).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an edit clip cannot be re-downloaded", () => {
|
||||||
|
expect(libraryRowActions(job({ job_kind: "edit" }, {})).redownload).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("edit is hidden when the media has no duration", () => {
|
||||||
|
expect(libraryRowActions(job({}, { duration_s: 0 })).edit).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sharedRowActions", () => {
|
||||||
|
it("a downloadable media item gets play, save, edit", () => {
|
||||||
|
expect(sharedRowActions(job({}, {}))).toEqual({ play: true, save: true, edit: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("nothing is offered when the owner disabled download", () => {
|
||||||
|
expect(sharedRowActions(job({ can_download: false }, {}))).toEqual({
|
||||||
|
play: false,
|
||||||
|
save: false,
|
||||||
|
edit: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a shared generic file gets only save (no player/trim)", () => {
|
||||||
|
const a = sharedRowActions(job({}, { media_kind: "file", duration_s: null }));
|
||||||
|
expect(a).toEqual({ play: false, save: true, edit: false });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import type { DownloadJob } from "./api";
|
||||||
|
|
||||||
|
// Which row actions apply to a completed library / shared item — gated on the stored media_kind and
|
||||||
|
// job_kind (E7·S5b). A generic uploaded file (media_kind='file') is download + share only: no player,
|
||||||
|
// no trim, no details edit. Re-download needs a re-fetchable source, which an edit clip and an upload
|
||||||
|
// both lack (the backend 409s both). A single source for the gate the Library + Shared rows render,
|
||||||
|
// so it's unit-testable instead of buried in JSX conditionals — and the two tabs can't drift apart.
|
||||||
|
|
||||||
|
const isMediaKind = (job: DownloadJob): boolean => job.asset?.media_kind !== "file";
|
||||||
|
const hasDuration = (job: DownloadJob): boolean => (job.asset?.duration_s ?? 0) > 0;
|
||||||
|
|
||||||
|
export interface LibraryRowActions {
|
||||||
|
play: boolean;
|
||||||
|
edit: boolean;
|
||||||
|
editDetails: boolean;
|
||||||
|
redownload: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function libraryRowActions(job: DownloadJob): LibraryRowActions {
|
||||||
|
const media = isMediaKind(job);
|
||||||
|
return {
|
||||||
|
play: media,
|
||||||
|
edit: media && job.can_download && hasDuration(job),
|
||||||
|
editDetails: media,
|
||||||
|
redownload: job.job_kind !== "edit" && job.job_kind !== "upload",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SharedRowActions {
|
||||||
|
play: boolean;
|
||||||
|
save: boolean;
|
||||||
|
edit: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sharedRowActions(job: DownloadJob): SharedRowActions {
|
||||||
|
const media = isMediaKind(job);
|
||||||
|
// The owner's `can_download` gates save/play/edit on a shared item (it reflects their link ACL).
|
||||||
|
return {
|
||||||
|
play: media && job.can_download,
|
||||||
|
save: job.can_download,
|
||||||
|
edit: media && job.can_download && hasDuration(job),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
//
|
||||||
|
// One-click share (E7 S3 · D2-A): quickShareLink turns the row's Share button into a single action —
|
||||||
|
// ensure a public link (mint on demand, downloadable per D1), copy it, and toast with an "open in new
|
||||||
|
// tab" action. These pin the branch matrix: reuse-vs-mint, copy-ok-vs-fail, and the create-failure path.
|
||||||
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
|
import { QueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
// vi.mock is hoisted above these consts, so the mock fns must live in a hoisted block too.
|
||||||
|
const { downloadLinks, createDownloadLink, copyText, notify } = vi.hoisted(() => ({
|
||||||
|
downloadLinks: vi.fn(),
|
||||||
|
createDownloadLink: vi.fn(),
|
||||||
|
copyText: vi.fn(),
|
||||||
|
notify: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./api", () => ({ api: { downloadLinks, createDownloadLink } }));
|
||||||
|
vi.mock("./clipboard", () => ({ copyText }));
|
||||||
|
vi.mock("./notifications", () => ({ notify }));
|
||||||
|
|
||||||
|
import { quickShareLink } from "./downloads";
|
||||||
|
|
||||||
|
const t = (k: string) => k;
|
||||||
|
const origin = window.location.origin;
|
||||||
|
|
||||||
|
type ToastArg = {
|
||||||
|
level: string;
|
||||||
|
message: string;
|
||||||
|
action?: { label: string; onClick: () => void };
|
||||||
|
};
|
||||||
|
const firstToast = () => notify.mock.calls[0]![0] as ToastArg;
|
||||||
|
|
||||||
|
function qc() {
|
||||||
|
return new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
downloadLinks.mockReset();
|
||||||
|
createDownloadLink.mockReset();
|
||||||
|
copyText.mockReset().mockResolvedValue(true);
|
||||||
|
notify.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("quickShareLink", () => {
|
||||||
|
it("mints a downloadable link on demand, copies it, and toasts with an open-in-new-tab action", async () => {
|
||||||
|
downloadLinks.mockResolvedValue([]); // none yet
|
||||||
|
createDownloadLink.mockResolvedValue({ id: 7, url: "/watch/tok7" });
|
||||||
|
const openSpy = vi.spyOn(window, "open").mockReturnValue(null);
|
||||||
|
|
||||||
|
const url = await quickShareLink(qc(), { id: 42 }, t);
|
||||||
|
|
||||||
|
expect(url).toBe(`${origin}/watch/tok7`);
|
||||||
|
// Minted with an EMPTY body so the backend D1 default (allow_download=true) applies.
|
||||||
|
expect(createDownloadLink).toHaveBeenCalledWith(42, {});
|
||||||
|
expect(copyText).toHaveBeenCalledWith(`${origin}/watch/tok7`);
|
||||||
|
const arg = firstToast();
|
||||||
|
expect(arg.level).toBe("success");
|
||||||
|
expect(arg.message).toBe("downloads.share.linkCopied");
|
||||||
|
expect(arg.action!.label).toBe("downloads.share.openNewTab");
|
||||||
|
arg.action!.onClick();
|
||||||
|
expect(openSpy).toHaveBeenCalledWith(`${origin}/watch/tok7`, "_blank", "noopener,noreferrer");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reuses an existing link instead of minting a duplicate", async () => {
|
||||||
|
downloadLinks.mockResolvedValue([{ id: 3, url: "/watch/old" }]);
|
||||||
|
|
||||||
|
const url = await quickShareLink(qc(), { id: 1 }, t);
|
||||||
|
|
||||||
|
expect(url).toBe(`${origin}/watch/old`);
|
||||||
|
expect(createDownloadLink).not.toHaveBeenCalled();
|
||||||
|
expect(firstToast().level).toBe("success");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still toasts (warning) with the open action when the clipboard copy fails", async () => {
|
||||||
|
downloadLinks.mockResolvedValue([{ id: 5, url: "/watch/x" }]);
|
||||||
|
copyText.mockResolvedValue(false);
|
||||||
|
|
||||||
|
const url = await quickShareLink(qc(), { id: 2 }, t);
|
||||||
|
|
||||||
|
expect(url).toBe(`${origin}/watch/x`); // the link is never a dead end
|
||||||
|
const arg = firstToast();
|
||||||
|
expect(arg.level).toBe("warning");
|
||||||
|
expect(arg.message).toBe("downloads.share.copyFailed");
|
||||||
|
expect(arg.action!.label).toBe("downloads.share.openNewTab");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports an error and returns null when link creation fails", async () => {
|
||||||
|
downloadLinks.mockResolvedValue([]);
|
||||||
|
createDownloadLink.mockRejectedValue(new Error("boom"));
|
||||||
|
|
||||||
|
const url = await quickShareLink(qc(), { id: 9 }, t);
|
||||||
|
|
||||||
|
expect(url).toBeNull();
|
||||||
|
expect(copyText).not.toHaveBeenCalled();
|
||||||
|
expect(notify).toHaveBeenCalledWith({ level: "error", message: "downloads.share.linkFailed" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
import type { QueryClient } from "@tanstack/react-query";
|
import type { QueryClient } from "@tanstack/react-query";
|
||||||
import { qk } from "./queryKeys";
|
import { qk } from "./queryKeys";
|
||||||
|
import { api } from "./api";
|
||||||
|
import { copyText } from "./clipboard";
|
||||||
|
import { notify } from "./notifications";
|
||||||
|
|
||||||
// The download queue, the per-card download index, and the storage-usage meter all move together
|
// The download queue, the per-card download index, and the storage-usage meter all move together
|
||||||
// whenever a job is enqueued, deleted, or its state changes — invalidate them as one unit.
|
// whenever a job is enqueued, deleted, or its state changes — invalidate them as one unit.
|
||||||
@@ -8,3 +11,42 @@ export function invalidateDownloads(qc: QueryClient) {
|
|||||||
qc.invalidateQueries({ queryKey: qk.downloadIndex() });
|
qc.invalidateQueries({ queryKey: qk.downloadIndex() });
|
||||||
qc.invalidateQueries({ queryKey: qk.downloadUsage() });
|
qc.invalidateQueries({ queryKey: qk.downloadUsage() });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One-click share (E7 S3 · D2-A): the row's Share button turns "share this" into a single action.
|
||||||
|
// Ensure the job has a public link (minting one on demand — omitting `allow_download` so the backend
|
||||||
|
// D1 default makes it downloadable), copy it to the clipboard, and toast with an "open in new tab"
|
||||||
|
// action. A copy failure still toasts (warning) WITH that action, so the link is never a dead end.
|
||||||
|
// Advanced controls (password, expiry, revoke, share-with-a-user) live in the Share dialog. Returns
|
||||||
|
// the shared URL, or null if link creation failed — so the caller/tests can react to the outcome.
|
||||||
|
export async function quickShareLink(
|
||||||
|
qc: QueryClient,
|
||||||
|
job: { id: number },
|
||||||
|
t: (key: string) => string,
|
||||||
|
): Promise<string | null> {
|
||||||
|
let link;
|
||||||
|
try {
|
||||||
|
const existing = await qc.fetchQuery({
|
||||||
|
queryKey: qk.downloadLinks(job.id),
|
||||||
|
queryFn: () => api.downloadLinks(job.id),
|
||||||
|
});
|
||||||
|
link = existing[0];
|
||||||
|
if (!link) {
|
||||||
|
link = await api.createDownloadLink(job.id, {}); // omit allow_download → backend D1 default (true)
|
||||||
|
qc.setQueryData(qk.downloadLinks(job.id), [link]);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
notify({ level: "error", message: t("downloads.share.linkFailed") });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const url = window.location.origin + link.url;
|
||||||
|
const action = {
|
||||||
|
label: t("downloads.share.openNewTab"),
|
||||||
|
onClick: () => window.open(url, "_blank", "noopener,noreferrer"),
|
||||||
|
};
|
||||||
|
notify(
|
||||||
|
(await copyText(url))
|
||||||
|
? { level: "success", message: t("downloads.share.linkCopied"), action }
|
||||||
|
: { level: "warning", message: t("downloads.share.copyFailed"), action },
|
||||||
|
);
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ export const qk = {
|
|||||||
downloadIndex: () => ["download-index"] as const,
|
downloadIndex: () => ["download-index"] as const,
|
||||||
downloadUsage: () => ["download-usage"] as const,
|
downloadUsage: () => ["download-usage"] as const,
|
||||||
downloadProfiles: () => ["download-profiles"] as const,
|
downloadProfiles: () => ["download-profiles"] as const,
|
||||||
|
downloadCredentials: () => ["download-credentials"] as const,
|
||||||
downloadLinks: (jobId: number) => ["download-links", jobId] as const,
|
downloadLinks: (jobId: number) => ["download-links", jobId] as const,
|
||||||
dlQuota: (userId: number) => ["dl-quota", userId] as const,
|
dlQuota: (userId: number) => ["dl-quota", userId] as const,
|
||||||
storyboard: (jobId: number) => ["storyboard", jobId] as const,
|
storyboard: (jobId: number) => ["storyboard", jobId] as const,
|
||||||
|
|||||||
@@ -14,6 +14,45 @@ export interface ReleaseEntry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const RELEASE_NOTES: ReleaseEntry[] = [
|
export const RELEASE_NOTES: ReleaseEntry[] = [
|
||||||
|
{
|
||||||
|
version: "0.76.0",
|
||||||
|
date: "2026-08-29",
|
||||||
|
summary:
|
||||||
|
"The Download Center becomes a real media library: play your downloaded and uploaded video and audio right in the app, upload any file into the same library, and share anything in one click. Plus a fix so a cropped clip is framed exactly as you selected.",
|
||||||
|
features: [
|
||||||
|
"Play a finished download or upload in the app — video and audio open in a proper in-app player with seek, volume and fullscreen, no separate download needed.",
|
||||||
|
"Upload your own files into the library. Video and audio join with the full function set (play, edit, share); any other file (PDF, zip, document…) joins as a download-only item with its own info card. Drag-and-drop or pick a file, with a live progress bar you can cancel.",
|
||||||
|
"One-click Share: the Share button now creates a link (if there isn't one), copies it, and offers “open in new tab” — all in a single click, downloadable by default. The full link controls (password, expiry, revoke, share-with-a-user) stay one click away.",
|
||||||
|
"Library rows now show the file format and size at a glance (e.g. “MP4 · 142 MB”), and a shared link to a plain file shows a clean info card with a Download button.",
|
||||||
|
],
|
||||||
|
fixes: [
|
||||||
|
"Cropping a clip in the editor now produces output framed exactly as the on-screen selection showed — the crop rectangle and the saved result no longer drift apart on a non-16:9 video.",
|
||||||
|
"Every download is now guaranteed to play in any browser (including iPhone/iPad): the output is a universally-playable MP4/H.264 (or M4A/MP3 for audio), so “Play” always works.",
|
||||||
|
],
|
||||||
|
chores: [
|
||||||
|
"The whole epic went through several multi-agent deep-review rounds — findings fixed and re-reviewed each round until a round came back clean. Included a security hardening pass on the new upload path (an uploaded file can never be served in a way that lets it run code in your browser) and a fix so a failed upload never leaves an orphaned file on disk.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: "0.75.0",
|
||||||
|
date: "2026-08-29",
|
||||||
|
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",
|
version: "0.74.0",
|
||||||
date: "2026-08-28",
|
date: "2026-08-28",
|
||||||
|
|||||||
Reference in New Issue
Block a user