release: 0.76.0 (feature/e7-downloads-media-library)
gate / frontend (push) Failing after 1m36s
gate / backend (push) Successful in 2m26s
gate / frontend (push) Failing after 1m36s
gate / backend (push) Successful in 2m26s
This commit is contained in:
@@ -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")
|
||||
@@ -208,6 +208,10 @@ class Settings(BaseSettings):
|
||||
download_default_max_bytes: int = 5_368_709_120 # 5 GiB
|
||||
download_default_max_concurrent: int = 2
|
||||
download_default_max_jobs: int = 50
|
||||
# Per-file cap for local uploads (E7). Unlike a download (whose size is unknown until the worker
|
||||
# finishes), an upload's size is known at ingest, so this bounds a single file directly; the
|
||||
# user's overall byte footprint is still governed by download_default_max_bytes.
|
||||
download_upload_max_bytes: int = 524_288_000 # 500 MiB
|
||||
# Retention: an asset expires this many days after its last access; the GC job then
|
||||
# deletes it (once no job references it) after an additional grace window.
|
||||
download_retention_days: int = 30
|
||||
|
||||
@@ -438,18 +438,42 @@ _BROWSER_SAFE_VCODEC = ("h264",)
|
||||
_BROWSER_SAFE_ACODEC = ("aac", "mp3")
|
||||
|
||||
|
||||
def parse_probe_streams(payload: str) -> tuple[str | None, str | None]:
|
||||
"""(video_codec, audio_codec) out of ffprobe's JSON stream list, lowercased.
|
||||
|
||||
JSON, not the flat `default=nw=1` output: there the fields arrive in ffprobe's own struct
|
||||
order, which puts `codec_name` BEFORE `codec_type`, so a line-by-line "remember the last
|
||||
codec_type" parser attributes every name to the PREVIOUS stream. Measured on real downloads:
|
||||
an ordinary H.264+AAC mp4 probed as `v=aac a=None` — the browser-playability check then
|
||||
believed the video stream was AAC and re-encoded a perfectly fine file on every download."""
|
||||
def run_ffprobe_json(
|
||||
path: Path,
|
||||
*,
|
||||
show_streams: bool = False,
|
||||
show_format: bool = False,
|
||||
entries: str | None = None,
|
||||
) -> dict | None:
|
||||
"""One ffprobe call → its parsed JSON dict, or None on any failure (timeout, non-zero exit,
|
||||
missing binary, unparseable output). The SINGLE ffprobe-JSON-invocation point in the downloads
|
||||
package: the browser-playability codec probe here and the upload-ingest probe (`upload._probe`)
|
||||
both go through it, so the subprocess flags/timeout/error handling live in one place instead of
|
||||
two drifting copies."""
|
||||
args = ["ffprobe", "-v", "error"]
|
||||
if show_streams:
|
||||
args.append("-show_streams")
|
||||
if show_format:
|
||||
args.append("-show_format")
|
||||
if entries:
|
||||
args += ["-show_entries", entries]
|
||||
args += ["-of", "json", str(path)]
|
||||
try:
|
||||
streams = json.loads(payload or "{}").get("streams") or []
|
||||
except ValueError:
|
||||
return None, None
|
||||
out = subprocess.run(args, capture_output=True, text=True, timeout=30, check=True).stdout
|
||||
return json.loads(out)
|
||||
except (subprocess.TimeoutExpired, subprocess.CalledProcessError, OSError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _codecs_from_streams(streams: list) -> tuple[str | None, str | None]:
|
||||
"""(video_codec, audio_codec) lowercased from an ffprobe JSON stream list — the first of each kind.
|
||||
|
||||
Parsed from the JSON `streams` list, NOT ffprobe's flat `default=nw=1` output: there the fields
|
||||
arrive in ffprobe's own struct order, which puts `codec_name` BEFORE `codec_type`, so a line-by-line
|
||||
"remember the last codec_type" parser attributes every name to the PREVIOUS stream. Measured on real
|
||||
downloads: an ordinary H.264+AAC mp4 probed as `v=aac a=None` — the browser-playability check then
|
||||
believed the video stream was AAC and re-encoded a perfectly fine file on every download. Reading each
|
||||
stream dict by its own `codec_type` is immune to that ordering."""
|
||||
vcodec = acodec = None
|
||||
for s in streams:
|
||||
name = (s.get("codec_name") or "").lower() or None
|
||||
@@ -464,26 +488,10 @@ def parse_probe_streams(payload: str) -> tuple[str | None, str | None]:
|
||||
def probe_codecs(path: Path) -> tuple[str | None, str | None]:
|
||||
"""Return (video_codec, audio_codec) lowercased via one ffprobe call; None for a missing
|
||||
stream or if ffprobe fails (a failed probe is treated as 'leave it alone' upstream)."""
|
||||
try:
|
||||
out = subprocess.run(
|
||||
[
|
||||
"ffprobe",
|
||||
"-v",
|
||||
"error",
|
||||
"-show_entries",
|
||||
"stream=codec_type,codec_name",
|
||||
"-of",
|
||||
"json",
|
||||
str(path),
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
check=True,
|
||||
).stdout
|
||||
except (subprocess.TimeoutExpired, subprocess.CalledProcessError, OSError):
|
||||
data = run_ffprobe_json(path, entries="stream=codec_type,codec_name")
|
||||
if data is None:
|
||||
return None, None
|
||||
return parse_probe_streams(out)
|
||||
return _codecs_from_streams(data.get("streams") or [])
|
||||
|
||||
|
||||
def probe_duration(path: Path) -> int:
|
||||
|
||||
@@ -5,13 +5,16 @@ of truth for what file gets produced. `format_sig` hashes exactly the fields tha
|
||||
OUTPUT BYTES, so two users picking equivalent settings share one cached MediaAsset.
|
||||
`build_ydl_opts` turns a spec into a yt-dlp options dict (format selector + postprocessors).
|
||||
|
||||
spec shape (all keys optional; defaults applied by `normalize`):
|
||||
spec shape (all keys optional; defaults + browser-playability clamp applied by `normalize`):
|
||||
mode "av" (audio+video) | "v" (video-only) | "a" (audio-only)
|
||||
max_height int | null (null = best)
|
||||
container "mp4" | "mkv" | "webm" | null (merge/remux target for av/v)
|
||||
audio_format "m4a" | "mp3" | "opus" | null (extract target for mode "a")
|
||||
vcodec "h264" | "vp9" | "av1" | null (preference, best-effort)
|
||||
container always coerced to "mp4" (the only universally browser-playable container)
|
||||
audio_format "m4a" | "mp3" (mode "a" extract target; anything else — e.g. opus — → "m4a")
|
||||
vcodec always coerced to null → H.264 selected (see `normalize`); no opt-out
|
||||
embed_subs bool embed_chapters bool embed_thumbnail bool sponsorblock bool
|
||||
|
||||
`normalize` clamps container/vcodec/audio_format so EVERY download plays in-browser (E7); the
|
||||
`container`/`vcodec` keys survive only so an old stored profile deserializes without error.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
@@ -50,10 +53,12 @@ _DEFAULTS = {
|
||||
# prefer H.264+AAC (browser/iOS-compatible) rather than yt-dlp's default VP9/Opus best.
|
||||
# v3: mp4 compat now *selects* H.264 (avc1) with a progressive fallback + re-encodes the rare
|
||||
# AV1/VP9-only result to H.264, so cached AV1 assets must re-derive (fixes iOS broken-player).
|
||||
_SIG_VERSION = 3
|
||||
# v4: EVERY download is clamped to the mp4/H.264/AAC compat profile (no more mkv/webm/VP9/AV1/Opus
|
||||
# opt-out), so any asset cached under an old non-compat spec — or a stale non-H.264 mp4 that slipped
|
||||
# the v3 net — must re-derive through the current browser-safe guarantee.
|
||||
_SIG_VERSION = 4
|
||||
|
||||
_SPONSORBLOCK_CATEGORIES = ["sponsor", "selfpromo", "interaction"]
|
||||
_VCODEC_SORT = {"h264": "vcodec:h264", "vp9": "vcodec:vp9", "av1": "vcodec:av01"}
|
||||
|
||||
|
||||
def normalize(spec: dict) -> dict:
|
||||
@@ -76,6 +81,18 @@ def normalize(spec: dict) -> dict:
|
||||
# Audio-only files can't embed subtitles/thumbnails as video; keep the flags meaningful.
|
||||
if s["mode"] == "a":
|
||||
s["embed_subs"] = False
|
||||
# Browser-playability guarantee (E7): the Download module exists to download → share → PLAY
|
||||
# in-browser, so every output must play in EVERY browser, iOS/Safari/WebKit included. The only
|
||||
# universally-safe combo is mp4 + H.264 + AAC/MP3, so we CLAMP the spec to it HERE — the single
|
||||
# point every path (picker, stored profile, raw API) flows through — instead of only hiding
|
||||
# options in the UI. Non-mp4 containers (mkv doesn't play at all; webm/VP9/AV1 fail iOS) and any
|
||||
# explicit vcodec are coerced to the compat profile; Opus audio (Safari-flaky as a bare file)
|
||||
# falls back to AAC/m4a. A source that exposes no H.264 is still caught by the worker's
|
||||
# browser-safe re-encode (`_ensure_browser_playable`), so playback is guaranteed end-to-end.
|
||||
s["container"] = "mp4"
|
||||
s["vcodec"] = None
|
||||
if s["audio_format"] not in ("m4a", "mp3"):
|
||||
s["audio_format"] = "m4a"
|
||||
return s
|
||||
|
||||
|
||||
@@ -153,51 +170,33 @@ def build_ydl_opts(
|
||||
)
|
||||
else:
|
||||
hcap = f"[height<=?{h}]" if h else ""
|
||||
# Compatibility-first for mp4 (the universal container): produce H.264 video + AAC audio
|
||||
# so shared/downloaded files play in EVERY browser, including iOS/Safari. WebKit (which
|
||||
# every iOS browser, Brave included, is forced to use) decodes only H.264/H.265 + AAC
|
||||
# inside mp4 — a VP9/AV1-in-mp4 file shows a broken player there, even though Chromium
|
||||
# plays it fine. A custom profile that explicitly sets `vcodec` opts out.
|
||||
compat = s["container"] == "mp4" and s["vcodec"] is None
|
||||
# Every video download is the mp4/H.264 compat profile (normalize() clamps container→mp4,
|
||||
# vcodec→None), so shared/downloaded files play in EVERY browser, iOS/Safari included. WebKit
|
||||
# (which every iOS browser, Brave included, is forced to use) decodes only H.264/H.265 + AAC
|
||||
# inside mp4 — a VP9/AV1-in-mp4 file shows a broken player there — so H.264 is selected at the
|
||||
# SELECTION level, not merely preferred: a sort key can't conjure an H.264 stream a source
|
||||
# doesn't expose as video-only. A source that ships only non-H.264 is caught after download by
|
||||
# the worker's browser-safe re-encode (`_ensure_browser_playable`).
|
||||
if s["mode"] == "v":
|
||||
if compat:
|
||||
# Video-only: keep it audio-free. Prefer an H.264 *video-only* stream, then any
|
||||
# video-only, and only fall to a progressive (audio-bearing) `best` as a last
|
||||
# resort — same audio-free-when-possible guarantee as the old `bestvideo/best`.
|
||||
opts["format"] = (
|
||||
f"bestvideo[vcodec^=avc1]{hcap}/bestvideo{hcap}"
|
||||
f"/best[vcodec^=avc1]{hcap}/best{hcap}"
|
||||
)
|
||||
else:
|
||||
opts["format"] = f"bestvideo{hcap}/best{hcap}"
|
||||
# Video-only: keep it audio-free. Prefer an H.264 video-only stream, then any video-only,
|
||||
# and only fall to a progressive (audio-bearing) `best` as a last resort.
|
||||
opts["format"] = (
|
||||
f"bestvideo[vcodec^=avc1]{hcap}/bestvideo{hcap}/best[vcodec^=avc1]{hcap}/best{hcap}"
|
||||
)
|
||||
else: # av
|
||||
if compat:
|
||||
# Prefer H.264 at the SELECTION level, not just via format_sort: a sort key is only
|
||||
# a *preference* and can't conjure an H.264 stream a source doesn't expose as
|
||||
# video-only. A non-YouTube source (e.g. Facebook) may ship AV1 as the best
|
||||
# video-only stream and H.264 only as a *progressive* (a+v) format — which
|
||||
# `bestvideo` skips — so `bestvideo+bestaudio` picked AV1 and iOS broke. This chain
|
||||
# tries avc1 video-only, then the avc1 progressive, then anything.
|
||||
opts["format"] = (
|
||||
f"bestvideo[vcodec^=avc1]{hcap}+bestaudio[acodec^=mp4a]"
|
||||
f"/best[vcodec^=avc1]{hcap}"
|
||||
f"/bestvideo{hcap}+bestaudio/best{hcap}"
|
||||
)
|
||||
else:
|
||||
opts["format"] = f"bestvideo{hcap}+bestaudio/best{hcap}"
|
||||
if s["container"]:
|
||||
opts["merge_output_format"] = s["container"]
|
||||
sort = []
|
||||
# Keep the codec preference as a tiebreaker (ranks H.264/AAC above resolution among the
|
||||
# selected candidates); the selector above is the hard guarantee, this is belt-and-braces.
|
||||
if compat:
|
||||
sort += ["vcodec:h264", "acodec:aac"]
|
||||
# avc1 video-only + m4a audio, then the avc1 progressive, then anything (re-encoded).
|
||||
opts["format"] = (
|
||||
f"bestvideo[vcodec^=avc1]{hcap}+bestaudio[acodec^=mp4a]"
|
||||
f"/best[vcodec^=avc1]{hcap}"
|
||||
f"/bestvideo{hcap}+bestaudio/best{hcap}"
|
||||
)
|
||||
opts["merge_output_format"] = "mp4"
|
||||
# Codec preference tiebreaker (ranks H.264/AAC among the selected candidates); the selector
|
||||
# above is the hard guarantee, this is belt-and-braces.
|
||||
sort = ["vcodec:h264", "acodec:aac"]
|
||||
if h:
|
||||
sort.append(f"res:{h}")
|
||||
if s["vcodec"] in _VCODEC_SORT:
|
||||
sort.append(_VCODEC_SORT[s["vcodec"]])
|
||||
if sort:
|
||||
opts["format_sort"] = sort
|
||||
opts["format_sort"] = sort
|
||||
|
||||
# SponsorBlock first (marks segments), then strip them from the media + chapters.
|
||||
if s["sponsorblock"]:
|
||||
|
||||
@@ -80,13 +80,13 @@ def public_meta(
|
||||
|
||||
Resolves the owner's per-download display overrides (custom title/channel) over the asset's
|
||||
auto-extracted values, and surfaces the source + any extra reference links as clickable URLs."""
|
||||
from app.downloads import service
|
||||
from app.downloads import mediakind, service
|
||||
|
||||
source_url = service.reference_url(job, asset)
|
||||
# Audio-only assets (mp3 rips, extracted audio) carry a codec but no video stream. WebKit (all
|
||||
# iOS browsers) refuses to play them inside a <video> element, so the watch page must render a
|
||||
# native <audio> instead — flag it here from the recorded codecs (a real video always has vcodec).
|
||||
is_audio = asset.vcodec is None and asset.acodec is not None
|
||||
# Audio-only assets (mp3 rips, extracted audio) can't play inside a <video> element on WebKit
|
||||
# (all iOS browsers), so the watch page renders a native <audio> instead. A generic uploaded
|
||||
# file (media_kind='file') has no player at all — the watch page shows file info + a download.
|
||||
# Both branches read the stored media_kind (the single source), not a re-derived codec check.
|
||||
return {
|
||||
"title": (job and job.display_name) or asset.title,
|
||||
"uploader": (job and job.display_uploader) or asset.uploader,
|
||||
@@ -96,7 +96,10 @@ def public_meta(
|
||||
"duration_s": asset.duration_s,
|
||||
"width": asset.width,
|
||||
"height": asset.height,
|
||||
"audio": is_audio,
|
||||
"audio": asset.media_kind == mediakind.AUDIO,
|
||||
"media_kind": asset.media_kind,
|
||||
"size_bytes": asset.size_bytes,
|
||||
"container": asset.container,
|
||||
"allow_download": link.allow_download,
|
||||
"file_url": file_url,
|
||||
"poster_url": poster_url,
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
"""The media-kind discriminator for a library asset — the single home of one invariant.
|
||||
|
||||
`MediaAsset.media_kind` is `"video" | "audio" | "file"`. It decides which library actions apply:
|
||||
media (video/audio) gets play/edit/share; a generic uploaded file gets download + share only, and
|
||||
is never rendered inline (see the force-download serving in the file/watch routes). Every finalize
|
||||
path computes it HERE — the download worker + the upload ingest via `derive`, the editor by
|
||||
carrying the source's kind over — so the rule lives in code, not in a comment that can drift (an
|
||||
earlier ad-hoc `vcodec is None and acodec is not None` audio check lived only in links.py).
|
||||
"""
|
||||
|
||||
VIDEO = "video"
|
||||
AUDIO = "audio"
|
||||
FILE = "file"
|
||||
|
||||
|
||||
def derive(vcodec: str | None, acodec: str | None) -> str:
|
||||
"""Media kind from the probed codecs: a video stream -> video, audio-only -> audio, neither
|
||||
(a non-media upload: pdf/zip/docx/…) -> file."""
|
||||
if vcodec:
|
||||
return VIDEO
|
||||
if acodec:
|
||||
return AUDIO
|
||||
return FILE
|
||||
@@ -15,7 +15,7 @@ from app.config import settings
|
||||
from app.db import SessionLocal
|
||||
from app.downloads import edit as editmod
|
||||
from app.downloads import errors as dlerrors
|
||||
from app.downloads import adapters, credentials, formats, service, storage
|
||||
from app.downloads import adapters, credentials, formats, mediakind, service, storage
|
||||
from app.downloads.jobdb import (
|
||||
_edit_staging_dir,
|
||||
_job_status,
|
||||
@@ -484,6 +484,7 @@ def _download(
|
||||
asset.acodec = (
|
||||
info.get("acodec") if info.get("acodec") not in (None, "none") else None
|
||||
)
|
||||
asset.media_kind = mediakind.derive(asset.vcodec, asset.acodec)
|
||||
asset.title = meta.title
|
||||
asset.uploader = meta.uploader
|
||||
asset.uploader_url = adapter.uploader_url(info)
|
||||
|
||||
@@ -122,6 +122,22 @@ def check_enqueue(db: Session, user_id: int) -> None:
|
||||
raise QuotaExceeded("max_bytes", lim.max_bytes, used)
|
||||
|
||||
|
||||
def check_upload(db: Session, user_id: int, add_bytes: int) -> None:
|
||||
"""Like check_enqueue, but for an upload whose size is KNOWN at ingest: also reject when adding
|
||||
this file would push the footprint over max_bytes. A download can't pre-check its size (unknown
|
||||
until the worker finishes) so check_enqueue only compares the current footprint; an upload can,
|
||||
so it's charged against the cap before it lands. Raises QuotaExceeded(max_jobs|max_bytes)."""
|
||||
lim = resolve(db, user_id)
|
||||
if lim.unlimited:
|
||||
return
|
||||
n = active_jobs(db, user_id)
|
||||
if n >= lim.max_jobs:
|
||||
raise QuotaExceeded("max_jobs", lim.max_jobs, n)
|
||||
projected = footprint(db, user_id) + add_bytes
|
||||
if projected > lim.max_bytes:
|
||||
raise QuotaExceeded("max_bytes", lim.max_bytes, projected)
|
||||
|
||||
|
||||
def usage(db: Session, user_id: int) -> dict:
|
||||
"""Compact usage snapshot for the UI (footprint + counts vs limits)."""
|
||||
lim = resolve(db, user_id)
|
||||
|
||||
@@ -17,7 +17,7 @@ from app.config import settings
|
||||
from app.downloads import adapters
|
||||
from app.downloads import credentials
|
||||
from app.downloads import edit as editmod
|
||||
from app.downloads import formats, quota, storage
|
||||
from app.downloads import formats, mediakind, quota, storage
|
||||
from app.downloads.status import HOLDING, JobStatus
|
||||
from app.models import Channel, DownloadJob, MediaAsset, Video
|
||||
|
||||
@@ -216,6 +216,10 @@ def enqueue_edit(
|
||||
src_asset = db.get(MediaAsset, source_job.asset_id) if source_job.asset_id else None
|
||||
if src_asset is None or src_asset.status != "ready" or not src_asset.rel_path:
|
||||
raise EditError("source_not_ready")
|
||||
# A generic uploaded file (media_kind='file') has no timeline to trim/crop — the editor path is
|
||||
# media-only. Reject here so an upload's file never reaches the ffmpeg edit worker.
|
||||
if src_asset.media_kind == mediakind.FILE:
|
||||
raise EditError("not_media")
|
||||
|
||||
spec = editmod.normalize_edit_spec(edit_spec)
|
||||
if not spec:
|
||||
@@ -232,6 +236,9 @@ def enqueue_edit(
|
||||
asset.thumbnail_url = src_asset.thumbnail_url
|
||||
asset.upload_date = src_asset.upload_date
|
||||
asset.duration_s = editmod.clip_duration(spec, src_asset.duration_s)
|
||||
# A clip inherits its source's kind (a video edit stays video, an audio trim stays audio);
|
||||
# the ffmpeg edit worker doesn't re-probe codecs, so set it here where the source is known.
|
||||
asset.media_kind = src_asset.media_kind or mediakind.VIDEO
|
||||
|
||||
job = DownloadJob(
|
||||
user_id=user_id,
|
||||
|
||||
@@ -19,6 +19,8 @@ from datetime import date
|
||||
from pathlib import Path
|
||||
from xml.sax.saxutils import escape
|
||||
|
||||
from app.downloads import mediakind
|
||||
|
||||
# Reserve room for the "_id.ext" suffix + parent dirs within the 255-char component limit.
|
||||
_MAX_TITLE = 120
|
||||
# User-facing (Content-Disposition) names can be longer than an on-disk component.
|
||||
@@ -147,6 +149,81 @@ def edit_rel_path(user_id: int, asset_id: int, ext: str) -> str:
|
||||
return f".edits/{user_id}/clip_{asset_id}.{ext}"
|
||||
|
||||
|
||||
def _clean_upload_ext(filename: str) -> str:
|
||||
"""The upload's extension reduced to a shell-safe, lowercased, bounded token (``""`` when the
|
||||
upload has no extension). The SINGLE definition feeding both the on-disk suffix and the stored
|
||||
`container` — they must agree (the served Content-Disposition suffix has to match the container),
|
||||
so the sanitize/truncation rule lives in one place, not two drifting copies."""
|
||||
return _UNSAFE.sub("", Path(filename).suffix.lstrip(".")).lower()[:16]
|
||||
|
||||
|
||||
def upload_rel_path(user_id: int, asset_id: int, filename: str) -> str:
|
||||
"""On-disk path (relative to DOWNLOAD_ROOT) for a locally-uploaded file.
|
||||
|
||||
Kept in a per-user `.uploads/` tree — device-downloadable via the file endpoint, but NOT part of
|
||||
the Plex library (an upload is arbitrary user content, not a canonical episode; no .nfo/poster
|
||||
episode sidecars). The name is system-decided and bound to the asset id (`_a{id}` keeps two
|
||||
same-named uploads apart); the original filename is display metadata only. The extension is
|
||||
preserved (shell-safe, bounded) so the served file + its Content-Disposition carry a correct
|
||||
suffix; an extensionless upload stays extensionless."""
|
||||
stem = sanitize(Path(filename).stem, 80)
|
||||
ext = _clean_upload_ext(filename)
|
||||
suffix = f".{ext}" if ext else ""
|
||||
return f".uploads/{user_id}/{stem}_a{asset_id}{suffix}"
|
||||
|
||||
|
||||
def upload_ext(filename: str) -> str | None:
|
||||
"""The stored `container` for an uploaded file: its shell-safe lowercased extension, or None when
|
||||
the upload has no extension (a bare name). Shares `_clean_upload_ext` with `upload_rel_path` so the
|
||||
stored container and the on-disk suffix can never diverge."""
|
||||
return _clean_upload_ext(filename) or None
|
||||
|
||||
|
||||
# The Content-Type used to serve an inline-playable media asset, keyed by its stored `container`.
|
||||
# DELIBERATELY a strict allow-list of known audio/video containers: the served type is chosen from
|
||||
# the asset's ffprobe-verified media_kind + this table, NEVER guessed from the raw (user-controlled)
|
||||
# upload extension. Without this, an uploaded media/HTML polyglot named "evil.html" — a real MP3/MP4
|
||||
# that ffprobe classifies as media, so it dodges the generic-file force-download — would be served
|
||||
# with a Content-Type Starlette guesses from ".html" (text/html) and execute as stored XSS on the
|
||||
# serving origin. A media asset whose container isn't in this table falls back to a neutral
|
||||
# force-download attachment (see `served_media_type`), so nothing script-executable is ever inline.
|
||||
_MEDIA_MIME: dict[str, str] = {
|
||||
# video containers
|
||||
"mp4": "video/mp4",
|
||||
"m4v": "video/mp4",
|
||||
"mov": "video/quicktime",
|
||||
"webm": "video/webm",
|
||||
"mkv": "video/x-matroska",
|
||||
"ogv": "video/ogg",
|
||||
"avi": "video/x-msvideo",
|
||||
# audio containers
|
||||
"m4a": "audio/mp4",
|
||||
"mp3": "audio/mpeg",
|
||||
"aac": "audio/aac",
|
||||
"ogg": "audio/ogg",
|
||||
"oga": "audio/ogg",
|
||||
"opus": "audio/ogg",
|
||||
"weba": "audio/webm",
|
||||
"wav": "audio/wav",
|
||||
"flac": "audio/flac",
|
||||
}
|
||||
|
||||
|
||||
def served_media_type(media_kind: str | None, container: str | None) -> str | None:
|
||||
"""The Content-Type a library file MUST be served with — a real audio/video MIME for a recognised
|
||||
media container, else ``None`` meaning "serve as a neutral force-download attachment".
|
||||
|
||||
Returns None for a generic ``file`` kind AND for any media whose container we don't positively
|
||||
recognise as safe. Callers serve None as ``application/octet-stream`` + ``attachment`` so the
|
||||
served Content-Type is NEVER derived from the (attacker-controlled) file extension — the guard
|
||||
that stops uploaded HTML/SVG or a media/HTML polyglot from executing inline on the serving
|
||||
origin. A ``None`` media_kind (a legacy asset before the discriminator was backfilled) is treated
|
||||
as media so an old download keeps its inline player."""
|
||||
if media_kind == mediakind.FILE:
|
||||
return None
|
||||
return _MEDIA_MIME.get((container or "").lower())
|
||||
|
||||
|
||||
def download_filename(display_name: str, container: str | None, path: Path) -> str:
|
||||
"""The Content-Disposition filename for a served download: the cleaned display name carrying a single
|
||||
correct extension — the asset container, else the file's own suffix — without doubling it when the
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
"""Local file ingest for the download center (E7 S5) — the non-yt-dlp path into the media library.
|
||||
|
||||
A user-uploaded file joins the SAME library as a downloaded one, so it inherits the whole function
|
||||
set already built (metadata chips, share links, and — for media — in-app play/edit). Unlike a
|
||||
download it bypasses yt-dlp AND the worker entirely: the file is already on the host, so ingest
|
||||
probes it, places it on disk, and creates an already-`ready` MediaAsset + `done` DownloadJob in one
|
||||
step — the same "the file exists, mark it done now" shortcut `service.enqueue` takes on a cache hit.
|
||||
|
||||
Kinds (the invariant lives in `mediakind`): a probe with a real video stream -> video, audio-only
|
||||
-> audio, neither -> a generic 'file' (pdf/zip/docx/…) that is download-only and never rendered
|
||||
inline (see the force-download serving in the file/watch routes). This module is the ingest seam a
|
||||
future sub-function (e.g. convert) plugs a new adapter into, mirroring how it sidesteps the worker.
|
||||
|
||||
An upload does NOT get an `expires_at`: it's user-provided and may be irreplaceable (there's no
|
||||
source to re-download), so it doesn't age out on the retention clock the way a re-fetchable download
|
||||
does — it lives until the user deletes it.
|
||||
"""
|
||||
|
||||
import secrets
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import settings
|
||||
from app.downloads import edit as editmod
|
||||
from app.downloads import mediakind, quota, service, storage
|
||||
from app.models import DownloadJob, MediaAsset
|
||||
|
||||
# ffprobe container formats that mean "this is a still image / image sequence", NOT playable media.
|
||||
# A standalone jpg/png/gif/webp/bmp reports a real video stream (codec mjpeg/png/…) with no
|
||||
# `attached_pic` flag, so without this guard it would classify as media_kind='video' and get a Play
|
||||
# button + a broken in-app <video> player (and inline serving on the public share). Keyed on the
|
||||
# container format so an animated gif/webp also lands as a generic 'file' (no browser plays those in
|
||||
# a <video> either). SVG and anything else ffprobe can't decode already fails the probe → 'file'.
|
||||
_IMAGE_FORMATS = {
|
||||
"image2",
|
||||
"image2pipe",
|
||||
"png_pipe",
|
||||
"mjpeg",
|
||||
"mjpeg_pipe",
|
||||
"jpeg_pipe",
|
||||
"gif",
|
||||
"webp_pipe",
|
||||
"bmp_pipe",
|
||||
"tiff_pipe",
|
||||
"ico",
|
||||
"svg_pipe",
|
||||
}
|
||||
|
||||
|
||||
def _probe(path: Path) -> tuple[str | None, str | None, int | None, int | None, int | None]:
|
||||
"""(vcodec, acodec, width, height, duration_s) from one ffprobe call; all None if ffprobe fails
|
||||
or the file isn't playable media (an unprobeable file — or a still image — is treated as a generic
|
||||
non-media upload, so it becomes a download-only 'file' rather than broken 'video').
|
||||
|
||||
A video stream flagged `attached_pic` (embedded cover art in an mp3/m4a) is skipped — it's a
|
||||
thumbnail, not a real video track, so an audio file with album art still classifies as audio."""
|
||||
data = editmod.run_ffprobe_json(path, show_streams=True, show_format=True)
|
||||
if data is None:
|
||||
return None, None, None, None, None
|
||||
|
||||
fmt = data.get("format") or {}
|
||||
fmt_names = {n.strip() for n in (fmt.get("format_name") or "").split(",")}
|
||||
if fmt_names & _IMAGE_FORMATS:
|
||||
# A still image / image sequence is a generic file, not video — no vcodec means media_kind='file'.
|
||||
return None, None, None, None, None
|
||||
|
||||
vcodec = acodec = None
|
||||
width = height = None
|
||||
for stream in data.get("streams", []):
|
||||
kind = stream.get("codec_type")
|
||||
if kind == "video" and vcodec is None:
|
||||
if (stream.get("disposition") or {}).get("attached_pic"):
|
||||
continue # cover art, not a video track
|
||||
vcodec = stream.get("codec_name")
|
||||
width = stream.get("width")
|
||||
height = stream.get("height")
|
||||
elif kind == "audio" and acodec is None:
|
||||
acodec = stream.get("codec_name")
|
||||
|
||||
duration = None
|
||||
try:
|
||||
raw = fmt.get("duration")
|
||||
if raw is not None:
|
||||
duration = int(float(raw))
|
||||
except (TypeError, ValueError):
|
||||
duration = None
|
||||
return vcodec, acodec, width, height, duration
|
||||
|
||||
|
||||
def _title_from_filename(filename: str) -> str:
|
||||
"""A human display title from the original upload name: the stem with separators tidied to
|
||||
spaces (kept as-is otherwise — this is display metadata, not the shell-safe on-disk name)."""
|
||||
stem = Path(filename).stem.replace("_", " ").replace(".", " ").strip()
|
||||
return (stem or "Upload")[:255]
|
||||
|
||||
|
||||
def ingest_upload(
|
||||
db: Session,
|
||||
user_id: int,
|
||||
*,
|
||||
filename: str,
|
||||
src_path: Path,
|
||||
size: int,
|
||||
display_name: str | None = None,
|
||||
) -> DownloadJob:
|
||||
"""Ingest an already-uploaded file at `src_path` into `user_id`'s library and return the `done`
|
||||
job. `src_path` is MOVED into place on success (the caller stages it to a temp file first).
|
||||
|
||||
Raises quota.QuotaExceeded (holdings/footprint cap) — checked BEFORE any disk write, so a
|
||||
rejected upload leaves nothing behind and the caller just cleans up its temp file."""
|
||||
quota.check_upload(db, user_id, size) # the file's size is known → charge it up front
|
||||
|
||||
vcodec, acodec, width, height, duration = _probe(src_path)
|
||||
kind = mediakind.derive(vcodec, acodec)
|
||||
|
||||
# An upload never dedups (each file is its own asset): a random ref makes the cache key unique,
|
||||
# so `format_sig` is a constant marker rather than a format hash.
|
||||
ref = f"{user_id}:{secrets.token_hex(8)}"[:512]
|
||||
asset = MediaAsset(
|
||||
source_kind="upload", source_ref=ref, format_sig="upload", status="pending", ref_count=0
|
||||
)
|
||||
db.add(asset)
|
||||
db.flush() # need asset.id for the on-disk path
|
||||
|
||||
rel = storage.upload_rel_path(user_id, asset.id, filename)
|
||||
storage.place_file(src_path, settings.download_root, rel)
|
||||
# From here the file is on disk under `.uploads/`. If the transaction fails before it commits, the
|
||||
# asset/job rows roll back but the placed file would be ORPHANED — nothing references it, the
|
||||
# per-user delete path keys off asset.rel_path (which is gone), and the GC orphan sweep skips the
|
||||
# dot-prefixed `.uploads/` tree. So roll back the on-disk side too on any failure.
|
||||
try:
|
||||
final = storage.abs_path(settings.download_root, rel)
|
||||
actual_size = final.stat().st_size if final.exists() else size
|
||||
|
||||
asset.status = "ready"
|
||||
asset.rel_path = rel
|
||||
asset.size_bytes = actual_size
|
||||
asset.container = storage.upload_ext(filename)
|
||||
asset.vcodec = vcodec
|
||||
asset.acodec = acodec
|
||||
asset.width = width
|
||||
asset.height = height
|
||||
asset.duration_s = duration
|
||||
asset.media_kind = kind
|
||||
asset.title = (display_name or "").strip() or _title_from_filename(filename)
|
||||
asset.last_access_at = datetime.now(timezone.utc)
|
||||
# No expires_at — see the module docstring (an upload is irreplaceable, so it doesn't age out).
|
||||
if kind == mediakind.VIDEO:
|
||||
# A frame poster gives the library row a thumbnail + the share link an og:image. Audio has
|
||||
# no frame and a generic file no player, so neither gets one. Best-effort (ffmpeg decodes
|
||||
# server side even for a codec the browser can't play).
|
||||
asset.poster_path = editmod.ensure_poster(asset, settings.download_root)
|
||||
|
||||
job = DownloadJob(
|
||||
user_id=user_id,
|
||||
asset_id=asset.id,
|
||||
job_kind="upload",
|
||||
source_kind="upload",
|
||||
source_ref=ref,
|
||||
profile_snapshot={},
|
||||
display_name=(display_name or "").strip() or None,
|
||||
status="done",
|
||||
progress=100,
|
||||
queue_pos=service.next_queue_pos(db),
|
||||
)
|
||||
db.add(job)
|
||||
service.incr_ref_count(db, asset.id)
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
# The rows are gone; remove the now-unreferenced file so no bytes leak. Clean the poster by its
|
||||
# DETERMINISTIC path (rel with a .jpg suffix, the same path ensure_poster writes to) rather than
|
||||
# ensure_poster's return value: it writes the frame directly to that path and returns None on an
|
||||
# ffmpeg failure, so a partial poster can be on disk while the return is None — gating on the
|
||||
# return would skip that orphan. unlink is missing_ok, so the poster path is a harmless no-op
|
||||
# when none was generated (audio/file, or a video whose poster never started).
|
||||
for leaked in (rel, str(Path(rel).with_suffix(".jpg"))):
|
||||
storage.abs_path(settings.download_root, leaked).unlink(missing_ok=True)
|
||||
raise
|
||||
db.refresh(job)
|
||||
return job
|
||||
@@ -708,6 +708,12 @@ class MediaAsset(Base, TimestampMixin):
|
||||
container: Mapped[str | None] = mapped_column(String(16))
|
||||
vcodec: Mapped[str | None] = mapped_column(String(32))
|
||||
acodec: Mapped[str | None] = mapped_column(String(32))
|
||||
# "video" | "audio" | "file" — the media-kind discriminator that drives which library actions
|
||||
# apply (play/edit for media; download-only for a generic uploaded file). Null while an asset is
|
||||
# still pending (kind is known once the file is probed at finalize). It is the single stored home
|
||||
# of the invariant `downloads.mediakind.derive` used to compute — never re-derived in prose (an
|
||||
# earlier ad-hoc `vcodec is None and acodec` check lived only in links.py; consolidated here).
|
||||
media_kind: Mapped[str | None] = mapped_column(String(16))
|
||||
nfo_written: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||
# Set once the GC has warned holders of the upcoming expiry, so it doesn't warn every run.
|
||||
gc_notified: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||
|
||||
@@ -6,11 +6,14 @@ worker is a separate process); this module never downloads — it enqueues, mana
|
||||
serves finished files (range-aware, with the user's custom display name), and shares them.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import func, select
|
||||
@@ -24,6 +27,7 @@ from app.downloads import credentials as dlcreds
|
||||
from app.downloads import edit as editmod
|
||||
from app.downloads import links as linksmod
|
||||
from app.downloads import quota, service, storage
|
||||
from app.downloads import upload as uploadmod
|
||||
from app.models import (
|
||||
DownloadJob,
|
||||
DownloadLink,
|
||||
@@ -123,6 +127,9 @@ def _serialize(job: DownloadJob, asset: MediaAsset | None) -> dict:
|
||||
"width": asset.width,
|
||||
"height": asset.height,
|
||||
"container": asset.container,
|
||||
# "video" | "audio" | "file" — the library gates play/edit on this (media only); a
|
||||
# generic uploaded file is download-only. Null while still in flight (set at finalize).
|
||||
"media_kind": asset.media_kind,
|
||||
"uploader_url": asset.uploader_url,
|
||||
"thumbnail_url": asset.thumbnail_url,
|
||||
"expires_at": iso(asset.expires_at),
|
||||
@@ -354,6 +361,61 @@ def enqueue_download(
|
||||
return _serialize_job(db, job)
|
||||
|
||||
|
||||
# Read the upload in 1 MiB chunks so a large file never sits fully in memory, and the size cap is
|
||||
# enforced on the ACTUAL bytes read (a client-set Content-Length can't be trusted to gate it).
|
||||
_UPLOAD_CHUNK = 1024 * 1024
|
||||
|
||||
|
||||
@router.post("/upload")
|
||||
def upload_download(
|
||||
file: UploadFile = File(...),
|
||||
display_name: str | None = Form(None),
|
||||
user: User = Depends(require_human),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Ingest a local file into the caller's library (E7 S5). Any file type is accepted; media
|
||||
(video/audio) gets the full function set, a generic file is download-only. The file is streamed
|
||||
to a temp path under the download volume (enforcing the per-file cap as it goes), then handed to
|
||||
the non-yt-dlp ingest which probes + places it and returns an already-`done` job.
|
||||
|
||||
A sync (threadpool) handler on purpose: the ingest does blocking work — an ffprobe of up to 30s
|
||||
plus sync DB writes — so it must NOT run on the event loop. `file.file` is the sync file object
|
||||
Starlette spools the multipart body into."""
|
||||
cap = sysconfig.get_int(db, "download_upload_max_bytes")
|
||||
staging = Path(settings.download_root) / ".staging" / "uploads"
|
||||
staging.mkdir(parents=True, exist_ok=True)
|
||||
fd, tmp = tempfile.mkstemp(dir=str(staging))
|
||||
tmp_path = Path(tmp)
|
||||
try:
|
||||
size = 0
|
||||
with os.fdopen(fd, "wb") as out:
|
||||
while chunk := file.file.read(_UPLOAD_CHUNK):
|
||||
size += len(chunk)
|
||||
if cap and size > cap:
|
||||
raise HTTPException(
|
||||
status_code=422, detail={"code": "upload_too_large", "limit": cap}
|
||||
)
|
||||
out.write(chunk)
|
||||
if size == 0:
|
||||
raise HTTPException(status_code=400, detail={"code": "upload_empty"})
|
||||
try:
|
||||
job = uploadmod.ingest_upload(
|
||||
db,
|
||||
user.id,
|
||||
filename=file.filename or "upload",
|
||||
src_path=tmp_path,
|
||||
size=size,
|
||||
display_name=(display_name or "").strip() or None,
|
||||
)
|
||||
except quota.QuotaExceeded as e:
|
||||
raise HTTPException(status_code=422, detail=_quota_detail(e))
|
||||
return _serialize_job(db, job)
|
||||
finally:
|
||||
# Ingest MOVES the temp file into place on success (unlink is then a no-op); on any early exit
|
||||
# — cap hit, empty, quota, probe/move error — this removes the abandoned staging file.
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
class EnqueueEditIn(BaseModel):
|
||||
source_job_id: int = 0
|
||||
edit_spec: dict
|
||||
@@ -544,8 +606,10 @@ def redownload_download(
|
||||
never deletes a file another job/user still shares: the old asset is only removed if THIS job
|
||||
was its last holder."""
|
||||
job = _own_job(db, user, job_id)
|
||||
if job.job_kind == "edit":
|
||||
raise HTTPException(status_code=409, detail="Editor clips can't be re-downloaded.")
|
||||
if job.job_kind in ("edit", "upload"):
|
||||
# Neither has a re-fetchable source: an editor clip is derived locally, an upload IS the
|
||||
# source. Re-running yt-dlp against source_kind='upload'/'edit' would just fail.
|
||||
raise HTTPException(status_code=409, detail="This item can't be re-downloaded.")
|
||||
if job.status in ("queued", "running", "paused"):
|
||||
raise HTTPException(status_code=409, detail="This download is still in progress.")
|
||||
service.redownload(db, job)
|
||||
@@ -610,8 +674,21 @@ def download_file(
|
||||
|
||||
asset.last_access_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
# Starlette's FileResponse honours the Range header (206 partial content) for seek/resume.
|
||||
return FileResponse(path, filename=filename)
|
||||
# The served Content-Type comes from the ffprobe-verified media_kind + a container allow-list
|
||||
# (storage.served_media_type), NEVER guessed from the file's own extension. A generic 'file' (and
|
||||
# any media whose container we don't recognise — e.g. a media/HTML polyglot named "evil.html")
|
||||
# returns None → a neutral force-download attachment, so uploaded HTML/SVG can't execute inline on
|
||||
# this origin. Recognised media is served with its real type so the in-app player streams it. Both
|
||||
# are range-aware (FileResponse → 206).
|
||||
media_type = storage.served_media_type(asset.media_kind, asset.container)
|
||||
if media_type is None:
|
||||
return FileResponse(
|
||||
path,
|
||||
filename=filename,
|
||||
media_type="application/octet-stream",
|
||||
content_disposition_type="attachment",
|
||||
)
|
||||
return FileResponse(path, filename=filename, media_type=media_type)
|
||||
|
||||
|
||||
# --- editor filmstrip (scrub track) --------------------------------------------------------
|
||||
@@ -823,7 +900,12 @@ def list_links(
|
||||
|
||||
class CreateLinkIn(BaseModel):
|
||||
password: str = ""
|
||||
allow_download: bool = False
|
||||
# D1 (E7 S3): a newly shared download is downloadable by default — the common intent is "let the
|
||||
# recipient have the file", not stream-only. Forward-only: this only sets the default for links
|
||||
# minted from now on; existing links keep whatever `allow_download` they were created with (the
|
||||
# column default stays False, so no back-catalogue link silently flips). The owner can still
|
||||
# uncheck it in the Share dialog, and toggle any link stream-only afterwards.
|
||||
allow_download: bool = True
|
||||
expires_days: int | None = None
|
||||
|
||||
|
||||
|
||||
@@ -122,9 +122,25 @@ def watch_file(token: str, g: str | None = None, db: Session = Depends(get_db)):
|
||||
raise HTTPException(status_code=404, detail="This video is no longer available.")
|
||||
|
||||
filename = storage.download_filename(asset.title or "video", asset.container, path)
|
||||
# The served Content-Type comes from the asset's ffprobe-verified media_kind + a container
|
||||
# allow-list (storage.served_media_type) — NEVER guessed from the file's own extension. A generic
|
||||
# 'file' (and any media whose container we don't positively recognise, e.g. a media/HTML polyglot
|
||||
# named "evil.html") returns None → a neutral force-download attachment, so arbitrary uploaded
|
||||
# HTML/SVG can't execute inline on this login-free public origin. A recognised media container is
|
||||
# served with its real audio/video type, inline unless the link forces a download so it can play
|
||||
# in the watch page. Both are range-aware (FileResponse → 206) for seeking/resume.
|
||||
media_type = storage.served_media_type(asset.media_kind, asset.container)
|
||||
if media_type is None:
|
||||
return FileResponse(
|
||||
path,
|
||||
filename=filename,
|
||||
media_type="application/octet-stream",
|
||||
content_disposition_type="attachment",
|
||||
)
|
||||
disposition = "attachment" if link.allow_download else "inline"
|
||||
# Starlette's FileResponse honours the Range header (206) for seeking/scrubbing.
|
||||
return FileResponse(path, filename=filename, content_disposition_type=disposition)
|
||||
return FileResponse(
|
||||
path, filename=filename, media_type=media_type, content_disposition_type=disposition
|
||||
)
|
||||
|
||||
|
||||
@router.get("/watch/{token}/poster.jpg")
|
||||
|
||||
@@ -127,6 +127,10 @@ SPECS: tuple[ConfigSpec, ...] = (
|
||||
min=1,
|
||||
max=100_000,
|
||||
),
|
||||
# min=1, NOT 0: the per-file cap is enforced as `if cap and size > cap`, so a stored 0 would read
|
||||
# as falsy and SILENTLY disable the limit (an unbounded streamed write to the volume). Floor it at
|
||||
# 1 so the cap is always active; the overall footprint is separately bounded by the byte quota.
|
||||
ConfigSpec("download_upload_max_bytes", "int", "downloads", "download_upload_max_bytes", min=1),
|
||||
ConfigSpec(
|
||||
"download_retention_days", "int", "downloads", "download_retention_days", min=0, max=3_650
|
||||
),
|
||||
|
||||
@@ -43,6 +43,7 @@ pydantic-settings==2.15.0
|
||||
pydantic==2.13.4
|
||||
pydantic_core==2.46.4
|
||||
python-dotenv==1.2.3
|
||||
python-multipart==0.0.32
|
||||
requests==2.34.2
|
||||
starlette==1.6.0
|
||||
typing-inspection==0.4.4
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
fastapi>=0.115,<1.0
|
||||
# Multipart form parsing — required by Starlette/FastAPI to accept file uploads (UploadFile/Form).
|
||||
# The download center's local-file Upload (E7) is the only multipart endpoint in the app.
|
||||
python-multipart>=0.0.9,<1.0
|
||||
uvicorn[standard]>=0.30,<1.0
|
||||
sqlalchemy>=2.0,<2.1
|
||||
psycopg[binary]>=3.2,<4.0
|
||||
|
||||
@@ -6,7 +6,7 @@ with "PosixPath('.') has an empty name" instead of downloading."""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.downloads.edit import _concat_quote, parse_probe_streams
|
||||
from app.downloads.edit import _codecs_from_streams, _concat_quote, run_ffprobe_json
|
||||
from app.downloads.pipeline import _media_info, _produced_file
|
||||
from pathlib import Path, PurePosixPath
|
||||
|
||||
@@ -105,39 +105,76 @@ class TestMediaInfo:
|
||||
assert _media_info(info)["id"] == "vid"
|
||||
|
||||
|
||||
class TestParseProbeStreams:
|
||||
class TestCodecsFromStreams:
|
||||
# ffprobe emits codec_name BEFORE codec_type, so the old line parser shifted every name onto
|
||||
# the previous stream and an H.264+AAC mp4 came back as (aac, None) → a needless re-encode.
|
||||
# _codecs_from_streams reads each stream dict by its own codec_type, immune to that ordering.
|
||||
def test_reads_each_stream_by_its_own_type(self):
|
||||
payload = '{"streams":[{"codec_name":"h264","codec_type":"video"},{"codec_name":"aac","codec_type":"audio"}]}'
|
||||
assert parse_probe_streams(payload) == ("h264", "aac")
|
||||
streams = [
|
||||
{"codec_name": "h264", "codec_type": "video"},
|
||||
{"codec_name": "aac", "codec_type": "audio"},
|
||||
]
|
||||
assert _codecs_from_streams(streams) == ("h264", "aac")
|
||||
|
||||
def test_audio_first_ordering_is_handled(self):
|
||||
payload = '{"streams":[{"codec_name":"AAC","codec_type":"audio"},{"codec_name":"H264","codec_type":"video"}]}'
|
||||
assert parse_probe_streams(payload) == ("h264", "aac")
|
||||
streams = [
|
||||
{"codec_name": "AAC", "codec_type": "audio"},
|
||||
{"codec_name": "H264", "codec_type": "video"},
|
||||
]
|
||||
assert _codecs_from_streams(streams) == ("h264", "aac")
|
||||
|
||||
def test_extra_streams_do_not_win_over_the_first_of_their_kind(self):
|
||||
payload = (
|
||||
'{"streams":[{"codec_name":"h264","codec_type":"video"},'
|
||||
'{"codec_name":"vp9","codec_type":"video"},'
|
||||
'{"codec_name":"mov_text","codec_type":"subtitle"},'
|
||||
'{"codec_name":"opus","codec_type":"audio"}]}'
|
||||
)
|
||||
assert parse_probe_streams(payload) == ("h264", "opus")
|
||||
streams = [
|
||||
{"codec_name": "h264", "codec_type": "video"},
|
||||
{"codec_name": "vp9", "codec_type": "video"},
|
||||
{"codec_name": "mov_text", "codec_type": "subtitle"},
|
||||
{"codec_name": "opus", "codec_type": "audio"},
|
||||
]
|
||||
assert _codecs_from_streams(streams) == ("h264", "opus")
|
||||
|
||||
def test_audio_only_and_video_only(self):
|
||||
assert parse_probe_streams('{"streams":[{"codec_name":"opus","codec_type":"audio"}]}') == (
|
||||
assert _codecs_from_streams([{"codec_name": "opus", "codec_type": "audio"}]) == (
|
||||
None,
|
||||
"opus",
|
||||
)
|
||||
assert parse_probe_streams('{"streams":[{"codec_name":"av1","codec_type":"video"}]}') == (
|
||||
"av1",
|
||||
None,
|
||||
)
|
||||
assert _codecs_from_streams([{"codec_name": "av1", "codec_type": "video"}]) == ("av1", None)
|
||||
|
||||
def test_garbage_is_not_a_crash(self):
|
||||
assert parse_probe_streams("") == (None, None)
|
||||
assert parse_probe_streams("not json") == (None, None)
|
||||
def test_empty_stream_list(self):
|
||||
assert _codecs_from_streams([]) == (None, None)
|
||||
|
||||
|
||||
class TestRunFfprobeJson:
|
||||
"""The single ffprobe-JSON invocation point (shared by probe_codecs + upload._probe). A failed
|
||||
probe or unparseable output must degrade to None, not raise — the malformed-input safety the old
|
||||
parse_probe_streams string parser used to own now lives here."""
|
||||
|
||||
def test_unparseable_output_returns_none(self, monkeypatch):
|
||||
import subprocess
|
||||
|
||||
class _R:
|
||||
stdout = "not json"
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", lambda *a, **k: _R())
|
||||
assert run_ffprobe_json(Path("x")) is None
|
||||
|
||||
def test_ffprobe_failure_returns_none(self, monkeypatch):
|
||||
import subprocess
|
||||
|
||||
def _boom(*a, **k):
|
||||
raise subprocess.CalledProcessError(1, "ffprobe")
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", _boom)
|
||||
assert run_ffprobe_json(Path("x")) is None
|
||||
|
||||
def test_parses_streams_from_valid_json(self, monkeypatch):
|
||||
import subprocess
|
||||
|
||||
class _R:
|
||||
stdout = '{"streams":[{"codec_name":"h264","codec_type":"video"}]}'
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", lambda *a, **k: _R())
|
||||
data = run_ffprobe_json(Path("x"), entries="stream=codec_type,codec_name")
|
||||
assert _codecs_from_streams(data["streams"]) == ("h264", None)
|
||||
|
||||
|
||||
class TestConcatQuote:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""build_ydl_opts: the mp4 compat profile must SELECT H.264 (not merely prefer it), so shared
|
||||
downloads play on iOS/Safari, while custom vcodec / non-mp4 profiles opt out. See the iPad AV1
|
||||
broken-player fix."""
|
||||
"""build_ydl_opts: EVERY download is clamped to the mp4/H.264 compat profile (E7 browser-
|
||||
playability guarantee) and must SELECT H.264 (not merely prefer it), so shared downloads play in
|
||||
every browser incl. iOS/Safari. Non-mp4 containers and non-H.264 vcodecs are coerced, not honoured.
|
||||
See the iPad AV1 broken-player fix + E7 format narrowing."""
|
||||
|
||||
from app.downloads import edit as editmod
|
||||
from app.downloads import formats
|
||||
@@ -56,16 +57,37 @@ def test_max_height_caps_every_branch():
|
||||
assert fmt.count("[height<=?720]") >= 3 # applied to each avc1/fallback branch
|
||||
|
||||
|
||||
def test_explicit_vcodec_opts_out_of_avc1_selector():
|
||||
def test_explicit_vcodec_is_clamped_to_the_compat_selector():
|
||||
# E7 browser-playability guarantee: a non-H.264 vcodec no longer opts out — normalize() coerces
|
||||
# vcodec→None, so the avc1 selector (and mp4 merge) is used regardless of what was requested.
|
||||
opts = _opts({"mode": "av", "container": "mp4", "vcodec": "vp9"})
|
||||
assert "vcodec^=avc1" not in opts["format"]
|
||||
assert opts["format"] == "bestvideo+bestaudio/best"
|
||||
assert "vcodec:vp9" in opts["format_sort"]
|
||||
assert opts["format"].startswith("bestvideo[vcodec^=avc1]")
|
||||
assert opts["merge_output_format"] == "mp4"
|
||||
assert "vcodec:vp9" not in opts["format_sort"]
|
||||
|
||||
|
||||
def test_non_mp4_container_opts_out():
|
||||
fmt = _opts({"mode": "av", "container": "webm"})["format"]
|
||||
assert "vcodec^=avc1" not in fmt
|
||||
def test_non_mp4_container_is_clamped_to_mp4():
|
||||
# A webm/mkv request is coerced to the mp4/H.264 compat profile (nothing plays mkv in-browser;
|
||||
# webm/VP9 fails iOS), so the avc1 selector is used and the merge target is mp4.
|
||||
opts = _opts({"mode": "av", "container": "webm"})
|
||||
assert opts["format"].startswith("bestvideo[vcodec^=avc1]")
|
||||
assert opts["merge_output_format"] == "mp4"
|
||||
|
||||
|
||||
def test_normalize_clamps_to_browser_playable():
|
||||
n = formats.normalize({"container": "mkv", "vcodec": "av1", "audio_format": "opus"})
|
||||
assert n["container"] == "mp4"
|
||||
assert n["vcodec"] is None
|
||||
assert n["audio_format"] == "m4a"
|
||||
# A playable audio choice is preserved.
|
||||
assert formats.normalize({"audio_format": "mp3"})["audio_format"] == "mp3"
|
||||
|
||||
|
||||
def test_non_playable_specs_dedup_to_the_compat_identity():
|
||||
# Two requests that differ only in (now-clamped) non-playable fields share ONE cache asset.
|
||||
a = formats.format_sig({"mode": "av", "max_height": 720, "container": "webm", "vcodec": "av1"})
|
||||
b = formats.format_sig({"mode": "av", "max_height": 720, "container": "mp4"})
|
||||
assert a == b
|
||||
|
||||
|
||||
def test_audio_only_unaffected():
|
||||
|
||||
@@ -65,6 +65,9 @@ def _asset(**over):
|
||||
height=720,
|
||||
vcodec="h264",
|
||||
acodec="aac",
|
||||
media_kind="video",
|
||||
size_bytes=1000,
|
||||
container="mp4",
|
||||
source_webpage_url=None,
|
||||
source_kind="url",
|
||||
)
|
||||
@@ -74,12 +77,13 @@ def _asset(**over):
|
||||
|
||||
class TestPublicMetaAudioFlag:
|
||||
"""The watch page picks <audio> vs <video> from this flag; WebKit refuses an audio-only mp3 in a
|
||||
<video>, so an audio-only asset (codec present, no video stream) must report audio=True."""
|
||||
<video>, so an audio-only asset (media_kind='audio') must report audio=True. The flag now reads
|
||||
the stored media_kind (the single source), not a re-derived codec check."""
|
||||
|
||||
_link = SimpleNamespace(allow_download=True)
|
||||
|
||||
def test_audio_only_asset_is_flagged_audio(self):
|
||||
asset = _asset(vcodec=None, acodec="mp3", width=None, height=None)
|
||||
asset = _asset(media_kind="audio", vcodec=None, acodec="mp3", width=None, height=None)
|
||||
meta = public_meta(self._link, None, asset, "/f.mp3")
|
||||
assert meta["audio"] is True
|
||||
|
||||
@@ -87,10 +91,29 @@ class TestPublicMetaAudioFlag:
|
||||
meta = public_meta(self._link, None, _asset(), "/f.mp4")
|
||||
assert meta["audio"] is False
|
||||
|
||||
def test_a_silent_video_is_not_audio(self):
|
||||
# Video present but no audio track — still a video player, not the audio bar.
|
||||
meta = public_meta(self._link, None, _asset(acodec=None), "/f.mp4")
|
||||
def test_a_generic_file_is_not_audio(self):
|
||||
# A non-media upload (media_kind='file') has no player at all — never the audio bar.
|
||||
meta = public_meta(self._link, None, _asset(media_kind="file"), "/f.bin")
|
||||
assert meta["audio"] is False
|
||||
assert meta["media_kind"] == "file"
|
||||
|
||||
|
||||
class TestCreateLinkDefault:
|
||||
"""D1 (E7 S3): a newly minted share link is downloadable by default. The one-click Share relies on
|
||||
this — it POSTs no `allow_download`, so the API default decides. Forward-only lives here (the API
|
||||
default), NOT in a migration: the DownloadLink column default stays False, so no existing back-
|
||||
catalogue link silently flips to downloadable."""
|
||||
|
||||
def test_omitted_allow_download_defaults_downloadable(self):
|
||||
from app.routes.downloads import CreateLinkIn
|
||||
|
||||
assert CreateLinkIn().allow_download is True
|
||||
|
||||
def test_explicit_false_is_respected(self):
|
||||
# The owner can still uncheck it to mint a stream-only link.
|
||||
from app.routes.downloads import CreateLinkIn
|
||||
|
||||
assert CreateLinkIn(allow_download=False).allow_download is False
|
||||
|
||||
|
||||
class TestIsExpired:
|
||||
|
||||
@@ -0,0 +1,394 @@
|
||||
"""E7 S5a — local file upload ingest (the non-yt-dlp path into the media library).
|
||||
|
||||
Covers the ingest spine: media vs generic-file classification, the download-only serving of a
|
||||
generic file (force-download so uploaded HTML/SVG can't execute inline), quota accounting with the
|
||||
size known up front, the per-file cap enforced while streaming, filename sanitization, and the two
|
||||
guards that keep an upload out of paths that assume a re-fetchable source (edit / redownload).
|
||||
|
||||
DB-backed (real Postgres) — skipped when no database is reachable. `settings.download_root` is
|
||||
pointed at a per-test tmp dir so ingest's on-disk moves are isolated. The ingest/route tests
|
||||
monkeypatch `_probe` so they don't shell out to ffprobe; the classifier's OWN parsing (image →
|
||||
file, cover-art skip, dims, duration) is covered directly by `TestProbeClassifier` below, which
|
||||
stubs one level down (`run_ffprobe_json`) and exercises the real `_probe`.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.config import settings
|
||||
from app.downloads import mediakind, quota, storage
|
||||
from app.downloads import upload as uploadmod
|
||||
from app.models import DownloadQuota, MediaAsset
|
||||
|
||||
|
||||
# --- pure: the kind invariant ---------------------------------------------------------------
|
||||
|
||||
|
||||
def test_derive_media_kind():
|
||||
assert uploadmod.mediakind.derive("h264", "aac") == mediakind.VIDEO
|
||||
assert uploadmod.mediakind.derive("vp9", None) == mediakind.VIDEO
|
||||
assert uploadmod.mediakind.derive(None, "aac") == mediakind.AUDIO
|
||||
assert uploadmod.mediakind.derive(None, None) == mediakind.FILE
|
||||
|
||||
|
||||
# --- ingest (direct) ------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _root(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(settings, "download_root", str(tmp_path))
|
||||
return tmp_path
|
||||
|
||||
|
||||
def _probe(monkeypatch, result):
|
||||
monkeypatch.setattr(uploadmod, "_probe", lambda _p: result)
|
||||
|
||||
|
||||
def test_ingest_generic_file_is_download_only(db, make_user, _root, monkeypatch):
|
||||
_probe(monkeypatch, (None, None, None, None, None))
|
||||
u = make_user()
|
||||
src = _root / "src.bin"
|
||||
src.write_bytes(b"%PDF-1.4 hello")
|
||||
job = uploadmod.ingest_upload(
|
||||
db, u.id, filename="My Notes.pdf", src_path=src, size=src.stat().st_size
|
||||
)
|
||||
asset = db.get(MediaAsset, job.asset_id)
|
||||
assert asset.media_kind == mediakind.FILE
|
||||
assert asset.status == "ready" and job.status == "done" and job.progress == 100
|
||||
assert asset.container == "pdf"
|
||||
assert asset.poster_path is None # a generic file has no player and no poster
|
||||
assert asset.title == "My Notes" # display title from the original name
|
||||
assert job.job_kind == "upload" and job.source_kind == "upload"
|
||||
# The file was MOVED into the per-user uploads tree, inside the root, and the temp is gone.
|
||||
assert not src.exists()
|
||||
assert asset.rel_path.startswith(".uploads/")
|
||||
assert storage.safe_abs_path(str(_root), asset.rel_path) is not None
|
||||
|
||||
|
||||
def test_ingest_audio_is_media_no_poster(db, make_user, _root, monkeypatch):
|
||||
_probe(monkeypatch, (None, "aac", None, None, 200))
|
||||
u = make_user()
|
||||
src = _root / "song.m4a"
|
||||
src.write_bytes(b"fake-audio")
|
||||
job = uploadmod.ingest_upload(db, u.id, filename="song.m4a", src_path=src, size=10)
|
||||
asset = db.get(MediaAsset, job.asset_id)
|
||||
assert asset.media_kind == mediakind.AUDIO
|
||||
assert asset.duration_s == 200
|
||||
assert asset.poster_path is None # audio has no frame to poster
|
||||
|
||||
|
||||
def test_ingest_video_records_dims_and_poster(db, make_user, _root, monkeypatch):
|
||||
_probe(monkeypatch, ("h264", "aac", 640, 480, 60))
|
||||
# Stub the frame-cut so the test doesn't shell out to ffmpeg.
|
||||
monkeypatch.setattr(uploadmod.editmod, "ensure_poster", lambda _a, _root: "p/x.jpg")
|
||||
u = make_user()
|
||||
src = _root / "clip.mp4"
|
||||
src.write_bytes(b"fake-video")
|
||||
job = uploadmod.ingest_upload(db, u.id, filename="clip.mp4", src_path=src, size=10)
|
||||
asset = db.get(MediaAsset, job.asset_id)
|
||||
assert asset.media_kind == mediakind.VIDEO
|
||||
assert (asset.width, asset.height) == (640, 480)
|
||||
assert asset.poster_path == "p/x.jpg"
|
||||
|
||||
|
||||
def test_ingest_filename_is_sanitized_no_traversal(db, make_user, _root, monkeypatch):
|
||||
_probe(monkeypatch, (None, None, None, None, None))
|
||||
u = make_user()
|
||||
src = _root / "src"
|
||||
src.write_bytes(b"x")
|
||||
job = uploadmod.ingest_upload(db, u.id, filename="../../etc/passwd", src_path=src, size=1)
|
||||
asset = db.get(MediaAsset, job.asset_id)
|
||||
assert ".." not in asset.rel_path
|
||||
assert asset.rel_path.startswith(f".uploads/{u.id}/")
|
||||
assert storage.safe_abs_path(str(_root), asset.rel_path) is not None
|
||||
|
||||
|
||||
def test_ingest_rejects_over_footprint_cap_before_writing(db, make_user, _root, monkeypatch):
|
||||
_probe(monkeypatch, (None, None, None, None, None))
|
||||
u = make_user()
|
||||
db.add(
|
||||
DownloadQuota(user_id=u.id, max_bytes=1000, max_concurrent=2, max_jobs=50, unlimited=False)
|
||||
)
|
||||
db.commit()
|
||||
src = _root / "big.bin"
|
||||
src.write_bytes(b"x" * 10)
|
||||
with pytest.raises(quota.QuotaExceeded) as e:
|
||||
uploadmod.ingest_upload(db, u.id, filename="big.bin", src_path=src, size=2000)
|
||||
assert e.value.reason == "max_bytes"
|
||||
assert src.exists() # rejected before any disk move — nothing landed
|
||||
assert db.query(MediaAsset).count() == 0 # and no orphan asset row
|
||||
|
||||
|
||||
# --- route ----------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_upload_route_generic_forces_download(as_user, make_user, _root, monkeypatch):
|
||||
_probe(monkeypatch, (None, None, None, None, None))
|
||||
c = as_user(make_user())
|
||||
r = c.post(
|
||||
"/api/downloads/upload",
|
||||
files={"file": ("report.html", b"<script>alert(1)</script>", "text/html")},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
job = r.json()
|
||||
assert job["asset"]["media_kind"] == "file"
|
||||
assert job["can_download"] is True
|
||||
# The served file must be a neutral force-download attachment — never inline text/html.
|
||||
fr = c.get(f"/api/downloads/{job['id']}/file")
|
||||
assert fr.status_code == 200
|
||||
assert fr.headers["content-type"].startswith("application/octet-stream")
|
||||
assert "attachment" in fr.headers["content-disposition"].lower()
|
||||
|
||||
|
||||
def test_upload_route_media_html_extension_not_served_as_html(
|
||||
as_user, make_user, _root, monkeypatch
|
||||
):
|
||||
"""The XSS guard: a MEDIA-classified upload (real codecs) named with an executable extension must
|
||||
NOT be served with a guessed text/html type. The served Content-Type comes from the verified
|
||||
media_kind + a container allow-list ('html' isn't one), so it force-downloads as octet-stream —
|
||||
on both the authenticated file endpoint AND the login-free public watch endpoint. Without the fix
|
||||
the public inline branch let a media/HTML polyglot named evil.html run as stored XSS."""
|
||||
_probe(monkeypatch, ("h264", "aac", 320, 240, 5)) # ffprobe says it's video…
|
||||
c = as_user(make_user())
|
||||
r = c.post(
|
||||
"/api/downloads/upload",
|
||||
files={"file": ("evil.html", b"<script>alert(1)</script>", "text/html")},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
job = r.json()
|
||||
assert job["asset"]["media_kind"] == "video" and job["asset"]["container"] == "html"
|
||||
|
||||
# Authenticated endpoint: never text/html.
|
||||
fr = c.get(f"/api/downloads/{job['id']}/file")
|
||||
assert fr.status_code == 200
|
||||
assert fr.headers["content-type"].startswith("application/octet-stream")
|
||||
assert "attachment" in fr.headers["content-disposition"].lower()
|
||||
|
||||
# Public login-free endpoint via a STREAM-ONLY link (the inline branch) — still not text/html.
|
||||
link = c.post(f"/api/downloads/{job['id']}/links", json={"allow_download": False}).json()
|
||||
pr = c.get(f"/api/public/watch/{link['token']}/file")
|
||||
assert pr.status_code == 200
|
||||
assert not pr.headers["content-type"].startswith("text/html")
|
||||
assert pr.headers["content-type"].startswith("application/octet-stream")
|
||||
assert "attachment" in pr.headers["content-disposition"].lower()
|
||||
|
||||
|
||||
def test_public_watch_file_forces_download_for_generic_file(as_user, make_user, _root, monkeypatch):
|
||||
"""The login-free public serve of a generic upload (media_kind='file') must be a neutral
|
||||
force-download attachment — the branch that stops uploaded HTML/SVG executing on the public
|
||||
origin. This is a SEPARATE code path from the authenticated /file route, so it needs its own
|
||||
route-level assertion (previously only the authenticated branch was tested)."""
|
||||
_probe(monkeypatch, (None, None, None, None, None))
|
||||
c = as_user(make_user())
|
||||
job = c.post(
|
||||
"/api/downloads/upload",
|
||||
files={"file": ("notes.html", b"<script>alert(1)</script>", "text/html")},
|
||||
).json()
|
||||
assert job["asset"]["media_kind"] == "file"
|
||||
link = c.post(f"/api/downloads/{job['id']}/links", json={"allow_download": True}).json()
|
||||
pr = c.get(f"/api/public/watch/{link['token']}/file")
|
||||
assert pr.status_code == 200
|
||||
assert pr.headers["content-type"].startswith("application/octet-stream")
|
||||
assert "attachment" in pr.headers["content-disposition"].lower()
|
||||
|
||||
|
||||
def test_public_watch_file_serves_media_inline_with_real_type(
|
||||
as_user, make_user, _root, monkeypatch
|
||||
):
|
||||
"""A recognised media container IS still served inline with its real audio/video type (so it
|
||||
plays in the watch page) — the fix must not break legitimate playback. A stream-only link →
|
||||
inline; the type is video/mp4 (from the container allow-list), never text/* and never a guess."""
|
||||
_probe(monkeypatch, ("h264", "aac", 640, 360, 12))
|
||||
c = as_user(make_user())
|
||||
job = c.post(
|
||||
"/api/downloads/upload",
|
||||
files={"file": ("clip.mp4", b"fake-mp4-bytes", "video/mp4")},
|
||||
).json()
|
||||
link = c.post(f"/api/downloads/{job['id']}/links", json={"allow_download": False}).json()
|
||||
pr = c.get(f"/api/public/watch/{link['token']}/file")
|
||||
assert pr.status_code == 200
|
||||
assert pr.headers["content-type"].startswith("video/mp4")
|
||||
assert "inline" in pr.headers["content-disposition"].lower()
|
||||
|
||||
|
||||
def test_upload_route_enforces_per_file_cap(as_user, make_user, db, _root, monkeypatch):
|
||||
from app import sysconfig
|
||||
|
||||
sysconfig.set_value(db, "download_upload_max_bytes", 5)
|
||||
db.commit()
|
||||
c = as_user(make_user())
|
||||
r = c.post(
|
||||
"/api/downloads/upload",
|
||||
files={"file": ("big.bin", b"way over the cap", "application/octet-stream")},
|
||||
)
|
||||
assert r.status_code == 422
|
||||
assert r.json()["detail"]["code"] == "upload_too_large"
|
||||
|
||||
|
||||
def test_upload_cap_cannot_be_disabled_with_zero(db):
|
||||
"""download_upload_max_bytes floors at 1: a stored 0 would read falsy in the route's
|
||||
`if cap and size > cap` guard and SILENTLY disable the per-file cap, so set_value must reject it.
|
||||
Pins the min=1 invariant so a revert to min=0 can't pass the gate."""
|
||||
from app import sysconfig
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
sysconfig.set_value(db, "download_upload_max_bytes", 0)
|
||||
|
||||
|
||||
def test_upload_route_rejects_empty_file(as_user, make_user, _root, monkeypatch):
|
||||
c = as_user(make_user())
|
||||
r = c.post(
|
||||
"/api/downloads/upload", files={"file": ("empty.bin", b"", "application/octet-stream")}
|
||||
)
|
||||
assert r.status_code == 400
|
||||
assert r.json()["detail"]["code"] == "upload_empty"
|
||||
|
||||
|
||||
def test_redownload_rejects_an_upload(as_user, make_user, _root, monkeypatch):
|
||||
_probe(monkeypatch, (None, None, None, None, None))
|
||||
c = as_user(make_user())
|
||||
job = c.post(
|
||||
"/api/downloads/upload", files={"file": ("a.bin", b"data", "application/octet-stream")}
|
||||
).json()
|
||||
r = c.post(f"/api/downloads/{job['id']}/redownload")
|
||||
assert r.status_code == 409
|
||||
|
||||
|
||||
def test_edit_rejects_a_generic_upload_source(as_user, make_user, _root, monkeypatch):
|
||||
_probe(monkeypatch, (None, None, None, None, None))
|
||||
c = as_user(make_user())
|
||||
job = c.post(
|
||||
"/api/downloads/upload", files={"file": ("a.bin", b"data", "application/octet-stream")}
|
||||
).json()
|
||||
r = c.post(
|
||||
"/api/downloads/edit",
|
||||
json={"source_job_id": job["id"], "edit_spec": {"trim": {"start_s": 0, "end_s": 1}}},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
assert r.json()["detail"]["reason"] == "not_media"
|
||||
|
||||
|
||||
def test_ingest_rejects_over_max_jobs(db, make_user, _root, monkeypatch):
|
||||
"""The other check_upload rejection branch (holdings count, not bytes): once the user is at
|
||||
max_jobs, a further upload is refused before any disk write."""
|
||||
_probe(monkeypatch, (None, None, None, None, None))
|
||||
u = make_user()
|
||||
db.add(
|
||||
DownloadQuota(user_id=u.id, max_bytes=10**12, max_concurrent=2, max_jobs=1, unlimited=False)
|
||||
)
|
||||
db.commit()
|
||||
src1 = _root / "a.bin"
|
||||
src1.write_bytes(b"a")
|
||||
uploadmod.ingest_upload(db, u.id, filename="a.bin", src_path=src1, size=1) # now 1 holding job
|
||||
src2 = _root / "b.bin"
|
||||
src2.write_bytes(b"b")
|
||||
with pytest.raises(quota.QuotaExceeded) as e:
|
||||
uploadmod.ingest_upload(db, u.id, filename="b.bin", src_path=src2, size=1)
|
||||
assert e.value.reason == "max_jobs"
|
||||
assert src2.exists() # rejected before the move — nothing landed
|
||||
|
||||
|
||||
def test_ingest_cleans_up_placed_files_on_commit_failure(db, make_user, _root, monkeypatch):
|
||||
"""A failure AFTER storage.place_file has moved the file onto disk must roll back AND unlink the
|
||||
placed file + any (even partial) poster — otherwise the orphan is permanent (the per-user delete
|
||||
keys off the rolled-back asset.rel_path, and the GC orphan sweep skips the dot-prefixed .uploads/
|
||||
tree). Exercises the rollback/cleanup branch that IS the leak remediation, incl. the poster case
|
||||
where ensure_poster left a partial file but returned None."""
|
||||
_probe(monkeypatch, ("h264", "aac", 320, 240, 5)) # video → the poster path runs
|
||||
u = make_user()
|
||||
|
||||
# Simulate ffmpeg leaving a partial poster on disk, then failing (ensure_poster returns None).
|
||||
def _partial_poster(asset, root):
|
||||
storage.abs_path(root, str(Path(asset.rel_path).with_suffix(".jpg"))).write_bytes(
|
||||
b"partial"
|
||||
)
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(uploadmod.editmod, "ensure_poster", _partial_poster)
|
||||
|
||||
# Force the commit to fail after the file (and partial poster) are on disk.
|
||||
def _boom():
|
||||
raise RuntimeError("db down")
|
||||
|
||||
monkeypatch.setattr(db, "commit", _boom)
|
||||
|
||||
src = _root / "clip.mp4"
|
||||
src.write_bytes(b"fake-video-bytes")
|
||||
with pytest.raises(RuntimeError):
|
||||
uploadmod.ingest_upload(db, u.id, filename="clip.mp4", src_path=src, size=16)
|
||||
|
||||
db.rollback() # drop the monkeypatched-commit session state before querying
|
||||
uploads = _root / ".uploads"
|
||||
leftover = [p for p in uploads.rglob("*") if p.is_file()] if uploads.exists() else []
|
||||
assert leftover == [], f"orphaned files left on disk: {leftover}"
|
||||
assert db.query(MediaAsset).count() == 0 # the flushed asset rolled back — no dangling row
|
||||
|
||||
|
||||
# --- classifier: the REAL _probe parsing (stubs ffprobe one level down) ----------------------
|
||||
# The ingest/route tests above stub _probe wholesale; these exercise _probe's OWN logic — image
|
||||
# detection, the cover-art skip, dimension/duration parsing — by faking only run_ffprobe_json, so a
|
||||
# regression in the classifier the force-download guarantee leans on is actually caught.
|
||||
|
||||
|
||||
class TestProbeClassifier:
|
||||
def _probe_with(self, monkeypatch, data):
|
||||
monkeypatch.setattr(uploadmod.editmod, "run_ffprobe_json", lambda *a, **k: data)
|
||||
return uploadmod._probe(Path("x"))
|
||||
|
||||
def test_probe_failure_is_a_generic_file(self, monkeypatch):
|
||||
# run_ffprobe_json returns None (ffprobe failed / not media) → all None → derive() == FILE.
|
||||
v, a, w, h, d = self._probe_with(monkeypatch, None)
|
||||
assert (v, a, w, h, d) == (None, None, None, None, None)
|
||||
assert mediakind.derive(v, a) == mediakind.FILE
|
||||
|
||||
@pytest.mark.parametrize("fmt", ["image2", "png_pipe", "jpeg_pipe", "webp_pipe", "gif"])
|
||||
def test_still_image_classifies_as_file_not_video(self, monkeypatch, fmt):
|
||||
# A standalone image exposes a real video stream, but its container format is an image demuxer
|
||||
# — it must NOT become media_kind='video' (broken player + inline serving). Stays a file.
|
||||
data = {
|
||||
"format": {"format_name": fmt},
|
||||
"streams": [{"codec_type": "video", "codec_name": "png", "width": 8, "height": 8}],
|
||||
}
|
||||
v, a, w, h, d = self._probe_with(monkeypatch, data)
|
||||
assert v is None and mediakind.derive(v, a) == mediakind.FILE
|
||||
|
||||
def test_cover_art_video_stream_is_skipped_audio_stays_audio(self, monkeypatch):
|
||||
# Album art embedded in an audio file is a video stream flagged attached_pic — skip it so the
|
||||
# asset classifies as audio, not video.
|
||||
data = {
|
||||
"format": {"format_name": "mp3", "duration": "212.5"},
|
||||
"streams": [
|
||||
{
|
||||
"codec_type": "video",
|
||||
"codec_name": "mjpeg",
|
||||
"disposition": {"attached_pic": 1},
|
||||
"width": 600,
|
||||
"height": 600,
|
||||
},
|
||||
{"codec_type": "audio", "codec_name": "mp3"},
|
||||
],
|
||||
}
|
||||
v, a, w, h, d = self._probe_with(monkeypatch, data)
|
||||
assert (v, a, d) == (None, "mp3", 212)
|
||||
assert mediakind.derive(v, a) == mediakind.AUDIO
|
||||
|
||||
def test_real_video_records_codecs_dims_duration(self, monkeypatch):
|
||||
data = {
|
||||
"format": {"format_name": "mov,mp4,m4a", "duration": "60.0"},
|
||||
"streams": [
|
||||
{"codec_type": "video", "codec_name": "h264", "width": 1920, "height": 1080},
|
||||
{"codec_type": "audio", "codec_name": "aac"},
|
||||
],
|
||||
}
|
||||
v, a, w, h, d = self._probe_with(monkeypatch, data)
|
||||
assert (v, a, w, h, d) == ("h264", "aac", 1920, 1080, 60)
|
||||
assert mediakind.derive(v, a) == mediakind.VIDEO
|
||||
|
||||
def test_unparseable_duration_is_none(self, monkeypatch):
|
||||
data = {
|
||||
"format": {"format_name": "wav", "duration": "N/A"},
|
||||
"streams": [{"codec_type": "audio", "codec_name": "pcm_s16le"}],
|
||||
}
|
||||
v, a, w, h, d = self._probe_with(monkeypatch, data)
|
||||
assert d is None and mediakind.derive(v, a) == mediakind.AUDIO
|
||||
@@ -6,8 +6,10 @@ import {
|
||||
Ban,
|
||||
ClipboardPaste,
|
||||
Download,
|
||||
Link2,
|
||||
Pause,
|
||||
Play,
|
||||
PlayCircle,
|
||||
Plus,
|
||||
RotateCcw,
|
||||
Scissors,
|
||||
@@ -15,6 +17,7 @@ import {
|
||||
Share2,
|
||||
Pencil,
|
||||
Trash2,
|
||||
UploadCloud,
|
||||
} from "lucide-react";
|
||||
import Tabs from "./Tabs";
|
||||
import { PageToolbar } from "./PageShell";
|
||||
@@ -22,14 +25,17 @@ import { LoadingState, StateMessage } from "./QueryState";
|
||||
// Lazy: these dialogs/editors open on demand, so they stay out of the Downloads page chunk until
|
||||
// used (the video editor in particular is heavy — filmstrip + scrubber).
|
||||
const DownloadDialog = lazy(() => import("./DownloadDialog"));
|
||||
const UploadDialog = lazy(() => import("./UploadDialog"));
|
||||
const ProfileEditor = lazy(() => import("./ProfileEditor"));
|
||||
const VideoEditor = lazy(() => import("./VideoEditor"));
|
||||
const ShareDialog = lazy(() => import("./ShareDialog"));
|
||||
const LibraryPlayer = lazy(() => import("./LibraryPlayer"));
|
||||
import { useConfirm } from "./ConfirmProvider";
|
||||
import { useLiveQuery } from "../lib/useLiveQuery";
|
||||
import { useMe } from "../lib/useMe";
|
||||
import { api, type DownloadJob } from "../lib/api";
|
||||
import { invalidateDownloads } from "../lib/downloads";
|
||||
import { invalidateDownloads, quickShareLink } from "../lib/downloads";
|
||||
import { libraryRowActions, sharedRowActions } from "../lib/downloadActions";
|
||||
import { inputCls } from "./ui/form";
|
||||
import { Button } from "./ui/Button";
|
||||
import { DownloadRow, IconBtn, UsageBar } from "./downloadRow";
|
||||
@@ -46,9 +52,11 @@ export default function DownloadCenter() {
|
||||
const [tab, setTab] = useState("queue");
|
||||
const [addUrl, setAddUrl] = useState("");
|
||||
const [addSource, setAddSource] = useState<string | null>(null);
|
||||
const [upload, setUpload] = useState(false);
|
||||
const [manage, setManage] = useState(false);
|
||||
const [metaJob, setMetaJob] = useState<DownloadJob | null>(null);
|
||||
const [shareJob, setShareJob] = useState<DownloadJob | null>(null);
|
||||
const [playJob, setPlayJob] = useState<DownloadJob | null>(null);
|
||||
const [editJob, setEditJob] = useState<DownloadJob | null>(null);
|
||||
const [errorJob, setErrorJob] = useState<DownloadJob | null>(null);
|
||||
|
||||
@@ -101,6 +109,21 @@ export default function DownloadCenter() {
|
||||
if (await confirm({ title, message: body, confirmLabel: title, danger: true })) fn();
|
||||
};
|
||||
|
||||
// One-click share (E7 S3 · D2-A): ensure a downloadable link, copy it, toast with "open in new tab"
|
||||
// (see quickShareLink). Re-clicking reuses the existing link; a per-job in-flight guard also stops a
|
||||
// double-click from racing two POSTs into two links before the cache is primed. Advanced controls
|
||||
// (password, expiry, revoke, share-with-a-user) live in the Share dialog, via the manage-share button.
|
||||
const [sharingId, setSharingId] = useState<number | null>(null);
|
||||
const quickShare = async (job: DownloadJob) => {
|
||||
if (sharingId !== null) return;
|
||||
setSharingId(job.id);
|
||||
try {
|
||||
await quickShareLink(qc, job, t);
|
||||
} finally {
|
||||
setSharingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Pull a link off the clipboard into the add-URL box. Clipboard reads need a secure context +
|
||||
// a user gesture/permission (iOS Safari/Brave are strict) — so this is best-effort: the paste
|
||||
// button is an explicit gesture (reliable), and the input's onFocus tries silently (ignored if
|
||||
@@ -188,6 +211,16 @@ export default function DownloadCenter() {
|
||||
>
|
||||
{t("downloads.page.add")}
|
||||
</Button>
|
||||
{/* Local upload is a peer to "add a URL": a general file → the same library (E7 S5c). */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
icon={<UploadCloud className="w-4 h-4" />}
|
||||
onClick={() => setUpload(true)}
|
||||
className="shrink-0"
|
||||
>
|
||||
{t("downloads.page.upload")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{queue.map((job) => (
|
||||
@@ -247,50 +280,69 @@ export default function DownloadCenter() {
|
||||
<>
|
||||
<UsageBar />
|
||||
<div className="space-y-2">
|
||||
{library.map((job) => (
|
||||
<DownloadRow key={job.id} job={job}>
|
||||
{saveBtn(job)}
|
||||
{job.can_download && (job.asset?.duration_s ?? 0) > 0 && (
|
||||
<IconBtn onClick={() => setEditJob(job)} title={t("downloads.actions.edit")}>
|
||||
<Scissors className="w-4 h-4" />
|
||||
{library.map((job) => {
|
||||
// Row actions gated on the stored media_kind + job_kind (see downloadActions).
|
||||
const a = libraryRowActions(job);
|
||||
return (
|
||||
<DownloadRow key={job.id} job={job}>
|
||||
{a.play && (
|
||||
<IconBtn onClick={() => setPlayJob(job)} title={t("downloads.actions.play")}>
|
||||
<PlayCircle className="w-4 h-4" />
|
||||
</IconBtn>
|
||||
)}
|
||||
{saveBtn(job)}
|
||||
{a.edit && (
|
||||
<IconBtn onClick={() => setEditJob(job)} title={t("downloads.actions.edit")}>
|
||||
<Scissors className="w-4 h-4" />
|
||||
</IconBtn>
|
||||
)}
|
||||
{a.editDetails && (
|
||||
<IconBtn
|
||||
onClick={() => setMetaJob(job)}
|
||||
title={t("downloads.actions.editDetails")}
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</IconBtn>
|
||||
)}
|
||||
<IconBtn onClick={() => quickShare(job)} title={t("downloads.actions.share")}>
|
||||
<Share2 className="w-4 h-4" />
|
||||
</IconBtn>
|
||||
)}
|
||||
<IconBtn
|
||||
onClick={() => setMetaJob(job)}
|
||||
title={t("downloads.actions.editDetails")}
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</IconBtn>
|
||||
<IconBtn onClick={() => setShareJob(job)} title={t("downloads.actions.share")}>
|
||||
<Share2 className="w-4 h-4" />
|
||||
</IconBtn>
|
||||
<IconBtn
|
||||
onClick={() =>
|
||||
confirmThen(
|
||||
t("downloads.confirm.redownloadTitle"),
|
||||
t("downloads.confirm.redownloadBody"),
|
||||
() => act.mutate({ id: job.id, op: "redownload" }),
|
||||
)
|
||||
}
|
||||
title={t("downloads.actions.redownload")}
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
</IconBtn>
|
||||
<IconBtn
|
||||
onClick={() =>
|
||||
confirmThen(
|
||||
t("downloads.confirm.deleteTitle"),
|
||||
t("downloads.confirm.deleteBody"),
|
||||
() => act.mutate({ id: job.id, op: "delete" }),
|
||||
)
|
||||
}
|
||||
title={t("downloads.actions.delete")}
|
||||
danger
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</IconBtn>
|
||||
</DownloadRow>
|
||||
))}
|
||||
<IconBtn
|
||||
onClick={() => setShareJob(job)}
|
||||
title={t("downloads.actions.shareManage")}
|
||||
>
|
||||
<Link2 className="w-4 h-4" />
|
||||
</IconBtn>
|
||||
{a.redownload && (
|
||||
<IconBtn
|
||||
onClick={() =>
|
||||
confirmThen(
|
||||
t("downloads.confirm.redownloadTitle"),
|
||||
t("downloads.confirm.redownloadBody"),
|
||||
() => act.mutate({ id: job.id, op: "redownload" }),
|
||||
)
|
||||
}
|
||||
title={t("downloads.actions.redownload")}
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
</IconBtn>
|
||||
)}
|
||||
<IconBtn
|
||||
onClick={() =>
|
||||
confirmThen(
|
||||
t("downloads.confirm.deleteTitle"),
|
||||
t("downloads.confirm.deleteBody"),
|
||||
() => act.mutate({ id: job.id, op: "delete" }),
|
||||
)
|
||||
}
|
||||
title={t("downloads.actions.delete")}
|
||||
danger
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</IconBtn>
|
||||
</DownloadRow>
|
||||
);
|
||||
})}
|
||||
{library.length === 0 &&
|
||||
(jobsQ.isError && !jobsQ.data ? (
|
||||
<StateMessage tone="error" onRetry={() => jobsQ.refetch()}>
|
||||
@@ -309,34 +361,50 @@ export default function DownloadCenter() {
|
||||
|
||||
{tab === "shared" && (
|
||||
<div className="space-y-2">
|
||||
{(sharedQ.data ?? []).map((job) => (
|
||||
<DownloadRow key={job.id} job={job}>
|
||||
{job.can_download && saveBtn(job)}
|
||||
{job.can_download && (job.asset?.duration_s ?? 0) > 0 && (
|
||||
<IconBtn onClick={() => setEditJob(job)} title={t("downloads.actions.edit")}>
|
||||
<Scissors className="w-4 h-4" />
|
||||
{(sharedQ.data ?? []).map((job) => {
|
||||
// Same media_kind + can_download gate as the library, via the shared helper (S5b).
|
||||
const a = sharedRowActions(job);
|
||||
return (
|
||||
<DownloadRow key={job.id} job={job}>
|
||||
{a.play && (
|
||||
<IconBtn onClick={() => setPlayJob(job)} title={t("downloads.actions.play")}>
|
||||
<PlayCircle className="w-4 h-4" />
|
||||
</IconBtn>
|
||||
)}
|
||||
{a.save && saveBtn(job)}
|
||||
{a.edit && (
|
||||
<IconBtn onClick={() => setEditJob(job)} title={t("downloads.actions.edit")}>
|
||||
<Scissors className="w-4 h-4" />
|
||||
</IconBtn>
|
||||
)}
|
||||
<IconBtn
|
||||
onClick={() =>
|
||||
confirmThen(
|
||||
t("downloads.confirm.removeSharedTitle"),
|
||||
t("downloads.confirm.removeSharedBody"),
|
||||
() => removeShared.mutate(job.id),
|
||||
)
|
||||
}
|
||||
title={t("downloads.actions.removeShared")}
|
||||
danger
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</IconBtn>
|
||||
)}
|
||||
<IconBtn
|
||||
onClick={() =>
|
||||
confirmThen(
|
||||
t("downloads.confirm.removeSharedTitle"),
|
||||
t("downloads.confirm.removeSharedBody"),
|
||||
() => removeShared.mutate(job.id),
|
||||
)
|
||||
}
|
||||
title={t("downloads.actions.removeShared")}
|
||||
danger
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</IconBtn>
|
||||
</DownloadRow>
|
||||
))}
|
||||
{sharedQ.data && sharedQ.data.length === 0 && (
|
||||
<div className="text-sm text-muted py-10 text-center">
|
||||
{t("downloads.empty.shared")}
|
||||
</div>
|
||||
)}
|
||||
</DownloadRow>
|
||||
);
|
||||
})}
|
||||
{(sharedQ.data ?? []).length === 0 &&
|
||||
(sharedQ.isError && !sharedQ.data ? (
|
||||
<StateMessage tone="error" onRetry={() => sharedQ.refetch()}>
|
||||
{t("common.loadError")}
|
||||
</StateMessage>
|
||||
) : sharedQ.isLoading ? (
|
||||
<LoadingState />
|
||||
) : (
|
||||
<div className="text-sm text-muted py-10 text-center">
|
||||
{t("downloads.empty.shared")}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -352,9 +420,11 @@ export default function DownloadCenter() {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{upload && <UploadDialog onClose={() => setUpload(false)} />}
|
||||
{manage && <ProfileEditor onClose={() => setManage(false)} />}
|
||||
{metaJob && <MetaEditModal job={metaJob} onClose={() => setMetaJob(null)} />}
|
||||
{shareJob && <ShareDialog job={shareJob} onClose={() => setShareJob(null)} />}
|
||||
{playJob && <LibraryPlayer job={playJob} onClose={() => setPlayJob(null)} />}
|
||||
{editJob && <VideoEditor job={editJob} onClose={() => setEditJob(null)} />}
|
||||
{errorJob && (
|
||||
<ErrorModal
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// E7·S4b — the in-app library player streams the completed item's LOCAL file into a native media
|
||||
// element. video/audio is chosen from the stored media_kind (the single source), NOT a container
|
||||
// list — so an audio upload with an uncommon/absent container still renders <audio src=/file>. i18n
|
||||
// is key-passthrough here.
|
||||
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||
import { render, cleanup } from "@testing-library/react";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
|
||||
});
|
||||
|
||||
import LibraryPlayer from "./LibraryPlayer";
|
||||
import type { DownloadJob } from "../lib/api";
|
||||
|
||||
const baseJob: DownloadJob = {
|
||||
id: 7,
|
||||
status: "done",
|
||||
progress: 100,
|
||||
speed_bps: null,
|
||||
eta_s: null,
|
||||
phase: null,
|
||||
error: null,
|
||||
error_code: null,
|
||||
queue_pos: 0,
|
||||
created_at: null,
|
||||
source_kind: "youtube",
|
||||
source_ref: "abc",
|
||||
source_url: null,
|
||||
poster_url: null,
|
||||
profile_id: null,
|
||||
spec: {
|
||||
mode: "av",
|
||||
max_height: null,
|
||||
container: null,
|
||||
audio_format: null,
|
||||
vcodec: null,
|
||||
embed_subs: false,
|
||||
embed_chapters: false,
|
||||
embed_thumbnail: false,
|
||||
sponsorblock: false,
|
||||
},
|
||||
display_name: "Clip",
|
||||
display_uploader: null,
|
||||
display_uploader_url: null,
|
||||
extra_links: [],
|
||||
can_download: true,
|
||||
expired: false,
|
||||
asset: null,
|
||||
};
|
||||
|
||||
const withAsset = (over: Partial<NonNullable<DownloadJob["asset"]>>): DownloadJob => ({
|
||||
...baseJob,
|
||||
asset: {
|
||||
id: 10,
|
||||
status: "ready",
|
||||
title: "Clip",
|
||||
uploader: null,
|
||||
uploader_url: null,
|
||||
upload_date: null,
|
||||
duration_s: 12,
|
||||
size_bytes: 1000,
|
||||
width: null,
|
||||
height: null,
|
||||
container: null,
|
||||
media_kind: "video",
|
||||
thumbnail_url: null,
|
||||
expires_at: null,
|
||||
...over,
|
||||
},
|
||||
});
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("LibraryPlayer", () => {
|
||||
it("plays a video asset in a <video> pointed at the range-aware /file endpoint", () => {
|
||||
render(
|
||||
<LibraryPlayer
|
||||
job={withAsset({ width: 1280, height: 720, container: "mp4" })}
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
const video = document.body.querySelector("video");
|
||||
expect(video).not.toBeNull();
|
||||
expect(video!.getAttribute("src")).toBe("/api/downloads/7/file");
|
||||
// No <audio> element for a video asset.
|
||||
expect(document.body.querySelector("audio")).toBeNull();
|
||||
});
|
||||
|
||||
it("plays an audio-only asset (media_kind='audio') in an <audio> element", () => {
|
||||
render(
|
||||
<LibraryPlayer
|
||||
job={withAsset({ media_kind: "audio", container: "m4a" })}
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
const audio = document.body.querySelector("audio");
|
||||
expect(audio).not.toBeNull();
|
||||
expect(audio!.getAttribute("src")).toBe("/api/downloads/7/file");
|
||||
expect(document.body.querySelector("video")).toBeNull();
|
||||
});
|
||||
|
||||
it("uses media_kind — an audio upload with an uncommon container still renders <audio>", () => {
|
||||
// The exact bug the fix targets: the old container whitelist (m4a/mp3/…) would drop an .m4b/.weba
|
||||
// audio upload into a <video> box. media_kind='audio' is authoritative regardless of container.
|
||||
render(
|
||||
<LibraryPlayer
|
||||
job={withAsset({ media_kind: "audio", container: "m4b" })}
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(document.body.querySelector("audio")).not.toBeNull();
|
||||
expect(document.body.querySelector("video")).toBeNull();
|
||||
});
|
||||
|
||||
it("falls back to the container heuristic for a legacy null media_kind", () => {
|
||||
// A pre-discriminator asset (media_kind null) still resolves audio vs video from dims+container.
|
||||
render(
|
||||
<LibraryPlayer job={withAsset({ media_kind: null, container: "mp3" })} onClose={() => {}} />,
|
||||
);
|
||||
expect(document.body.querySelector("audio")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Modal from "./Modal";
|
||||
import { api, type DownloadJob } from "../lib/api";
|
||||
import { jobTitle } from "./downloadRow";
|
||||
|
||||
// E7 S4b — in-app player for a completed library item. We do NOT reuse PlayerModal (a cross-origin
|
||||
// YouTube IFrame shell) or PlexPlayer (a Plex HLS/session shell): both are bound to their own domain
|
||||
// APIs and can't point at a local file. Instead we reuse the shared a11y shell (`Modal`: focus-trap,
|
||||
// ESC/back-to-close, scroll-lock, layer-stack, portal) and stream the local file into a NATIVE
|
||||
// <video>/<audio controls> — the same element VideoEditor already plays this URL in. Downloads are a
|
||||
// browser-native container (mp4/H.264/AAC + m4a/mp3), so native playback (seek via the range-aware
|
||||
// /file endpoint, volume, fullscreen, speed) works with zero transcode; an uploaded media item may be
|
||||
// any codec (best-effort playback).
|
||||
export default function LibraryPlayer({ job, onClose }: { job: DownloadJob; onClose: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const fileUrl = useMemo(() => api.downloadFileUrl(job.id), [job.id]);
|
||||
// Video vs audio-only comes from the stored media_kind (the single source every sibling reads —
|
||||
// WatchPage/downloadRow/DownloadCenter) — NOT a re-derived container list, which would misclassify
|
||||
// an uploaded audio file whose container is outside a hardcoded set (uploads accept any container).
|
||||
// Only fall back to the old dimension+container heuristic for a legacy asset with a null media_kind.
|
||||
const asset = job.asset;
|
||||
const isAudio =
|
||||
asset?.media_kind === "audio" ||
|
||||
(asset?.media_kind == null &&
|
||||
!(asset?.width && asset?.height) &&
|
||||
!!asset?.container &&
|
||||
["m4a", "mp3", "opus", "ogg", "wav", "flac", "aac"].includes(asset.container.toLowerCase()));
|
||||
// Our self-hosted poster (always same-origin, never expires) is the primary; the source thumbnail is
|
||||
// the fallback. Same precedence as the library-row thumbnail.
|
||||
const poster = job.poster_url ? api.posterImageUrl(job.id) : (asset?.thumbnail_url ?? undefined);
|
||||
|
||||
return (
|
||||
<Modal title={jobTitle(job)} onClose={onClose} maxWidth="max-w-4xl">
|
||||
{isAudio ? (
|
||||
<div className="flex flex-col items-center gap-4 py-2">
|
||||
{poster ? (
|
||||
<img
|
||||
src={poster}
|
||||
alt=""
|
||||
className="w-40 h-40 rounded-xl object-cover border border-border"
|
||||
/>
|
||||
) : null}
|
||||
<audio src={fileUrl} controls autoPlay className="w-full">
|
||||
{t("downloads.player.unsupported")}
|
||||
</audio>
|
||||
</div>
|
||||
) : (
|
||||
<video
|
||||
src={fileUrl}
|
||||
poster={poster}
|
||||
controls
|
||||
autoPlay
|
||||
playsInline
|
||||
className="w-full max-h-[70vh] rounded-lg bg-black object-contain"
|
||||
>
|
||||
{t("downloads.player.unsupported")}
|
||||
</video>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -195,6 +195,8 @@ export default function ProfileEditor({ onClose }: { onClose: () => void }) {
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
{t("downloads.profiles.audioFormat")}
|
||||
</label>
|
||||
{/* Only browser-playable audio: AAC (m4a) + MP3. Opus is dropped — Safari plays it
|
||||
unreliably as a bare file, and the module's purpose is in-browser playback. */}
|
||||
<select
|
||||
value={spec.audio_format ?? "m4a"}
|
||||
onChange={set("audio_format")}
|
||||
@@ -202,39 +204,12 @@ export default function ProfileEditor({ onClose }: { onClose: () => void }) {
|
||||
>
|
||||
<option value="m4a">M4A</option>
|
||||
<option value="mp3">MP3</option>
|
||||
<option value="opus">Opus</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
{spec.mode !== "a" && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
{t("downloads.profiles.container")}
|
||||
</label>
|
||||
<select
|
||||
value={spec.container ?? "mp4"}
|
||||
onChange={set("container")}
|
||||
className={inputCls}
|
||||
>
|
||||
<option value="mp4">MP4</option>
|
||||
<option value="mkv">MKV</option>
|
||||
<option value="webm">WebM</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
{t("downloads.profiles.vcodec")}
|
||||
</label>
|
||||
<select value={spec.vcodec ?? ""} onChange={set("vcodec")} className={inputCls}>
|
||||
<option value="">{t("downloads.profiles.any")}</option>
|
||||
<option value="h264">H.264</option>
|
||||
<option value="vp9">VP9</option>
|
||||
<option value="av1">AV1</option>
|
||||
</select>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{/* Container/codec are no longer user-selectable: every download is clamped to the
|
||||
mp4/H.264/AAC compat profile (the only universally browser-playable combo) so a
|
||||
shared link plays everywhere, iOS/Safari included. Enforced in backend `normalize`. */}
|
||||
</div>
|
||||
<div className="space-y-1.5 pt-1">
|
||||
{spec.mode !== "a" && (
|
||||
|
||||
@@ -254,7 +254,9 @@ function LinkShare({ job }: { job: DownloadJob }) {
|
||||
queryFn: () => api.downloadLinks(job.id),
|
||||
});
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [allowDownload, setAllowDownload] = useState(false);
|
||||
// D1 (E7 S3): downloadable by default — matches the one-click Share (which relies on the backend
|
||||
// default) and the common "let them have the file" intent. The owner can uncheck it here.
|
||||
const [allowDownload, setAllowDownload] = useState(true);
|
||||
const [expiryDays, setExpiryDays] = useState<number | null>(null);
|
||||
const [password, setPassword] = useState("");
|
||||
const invalidate = () => qc.invalidateQueries({ queryKey: qk.downloadLinks(job.id) });
|
||||
@@ -276,8 +278,8 @@ function LinkShare({ job }: { job: DownloadJob }) {
|
||||
});
|
||||
|
||||
// Open with a link already generated: sharing is almost always the goal, so mint a default
|
||||
// (no password, no expiry, view-only) link the moment the dialog finds none — as if the user
|
||||
// had pressed "New link" → "Create". All further controls (password, download toggle, expiry,
|
||||
// (no password, no expiry, downloadable per D1) link the moment the dialog finds none — as if the
|
||||
// user had pressed "New link" → "Create". All further controls (password, download toggle, expiry,
|
||||
// extra links, revoke) stay available on the created link and via the "New link" button.
|
||||
const autoTried = useRef(false);
|
||||
useEffect(() => {
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// E7·S5c — the local-upload dialog. It's a general file upload (any type, no format list): a picked
|
||||
// file surfaces its name+size, and Upload streams it through api.uploadDownload (the XHR-progress
|
||||
// path). This drives the real dialog against a mocked api so we can assert the file + optional name
|
||||
// actually reach the ingest call, and that the success toast points at the library. i18n is
|
||||
// key-passthrough.
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
const h = vi.hoisted(() => ({
|
||||
uploadDownload: vi.fn(async (_f: File, _o: unknown) => ({ id: 42 })),
|
||||
notify: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
|
||||
});
|
||||
vi.mock("../lib/api", () => ({ api: { uploadDownload: h.uploadDownload } }));
|
||||
vi.mock("../lib/downloads", () => ({ invalidateDownloads: vi.fn() }));
|
||||
vi.mock("../lib/notifications", () => ({ notify: h.notify }));
|
||||
vi.mock("../lib/nav", () => ({ navigateTo: vi.fn() }));
|
||||
|
||||
import UploadDialog from "./UploadDialog";
|
||||
|
||||
const wrap = (onClose = vi.fn()) => {
|
||||
const qc = new QueryClient();
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<UploadDialog onClose={onClose} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
};
|
||||
|
||||
// The dialog portals into document.body, so query the whole document, not the render container.
|
||||
const fileInput = () => document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
|
||||
beforeEach(() => {
|
||||
h.uploadDownload.mockClear();
|
||||
h.notify.mockClear();
|
||||
});
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("UploadDialog", () => {
|
||||
it("shows the picked file's name and uploads it with the optional name", async () => {
|
||||
const onClose = vi.fn();
|
||||
wrap(onClose);
|
||||
const f = new File(["hello world"], "notes.pdf", { type: "application/pdf" });
|
||||
fireEvent.change(fileInput(), { target: { files: [f] } });
|
||||
|
||||
// The drop zone now surfaces the file name.
|
||||
expect(screen.getByText("notes.pdf")).toBeTruthy();
|
||||
|
||||
// Optional display name.
|
||||
const nameBox = screen.getByPlaceholderText("downloads.upload.namePlaceholder");
|
||||
fireEvent.change(nameBox, { target: { value: "My notes" } });
|
||||
|
||||
fireEvent.click(screen.getByText("downloads.upload.submit"));
|
||||
|
||||
await waitFor(() => expect(h.uploadDownload).toHaveBeenCalledTimes(1));
|
||||
const [passedFile, opts] = h.uploadDownload.mock.calls[0]!;
|
||||
expect(passedFile).toBe(f);
|
||||
expect((opts as { displayName?: string }).displayName).toBe("My notes");
|
||||
await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(1));
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it("keeps Upload disabled until a file is chosen", () => {
|
||||
wrap();
|
||||
const btn = screen.getByText("downloads.upload.submit").closest("button")!;
|
||||
expect(btn.disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { UploadCloud, File as FileIcon } from "lucide-react";
|
||||
import Modal from "./Modal";
|
||||
import { Button } from "./ui/Button";
|
||||
import { api } from "../lib/api";
|
||||
import { invalidateDownloads } from "../lib/downloads";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { navigateTo } from "../lib/nav";
|
||||
import { formatBytes } from "../lib/format";
|
||||
import { inputCls } from "./ui/form";
|
||||
|
||||
// Local-file ingest (E7 S5c · D5-A). A general upload — ANY file type, no format list: media
|
||||
// (video/audio) joins the library with the full function set, a generic file is download-only. The
|
||||
// file is streamed to the server via XHR so this dialog can show real upload progress. On success it
|
||||
// lands in the Library as an already-`done` job, so we invalidate + point the user there.
|
||||
export default function UploadDialog({ onClose }: { onClose: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [name, setName] = useState("");
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const [pct, setPct] = useState<number | null>(null); // null = idle; 0–100 while uploading
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
// dragenter/dragleave fire as the cursor crosses the drop zone's OWN children (icon/text), so a
|
||||
// plain boolean flickers. Count enter vs leave and only clear the highlight at depth 0.
|
||||
const dragDepth = useRef(0);
|
||||
const busy = pct !== null;
|
||||
|
||||
// Closing the dialog mid-upload (X / ESC / backdrop / browser Back all unmount us) must abort the
|
||||
// in-flight XHR — otherwise it keeps streaming in the background and still toasts success. Only the
|
||||
// footer Cancel wired the abort; this covers every other dismissal.
|
||||
useEffect(() => () => abortRef.current?.abort(), []);
|
||||
|
||||
const pick = () => inputRef.current?.click();
|
||||
|
||||
const submit = async () => {
|
||||
if (!file || busy) return;
|
||||
const ac = new AbortController();
|
||||
abortRef.current = ac;
|
||||
setPct(0);
|
||||
try {
|
||||
await api.uploadDownload(file, {
|
||||
displayName: name.trim() || undefined,
|
||||
onProgress: setPct,
|
||||
signal: ac.signal,
|
||||
});
|
||||
invalidateDownloads(qc);
|
||||
notify({
|
||||
level: "success",
|
||||
message: t("downloads.upload.added"),
|
||||
action: { label: t("downloads.dialog.view"), onClick: () => navigateTo("downloads") },
|
||||
});
|
||||
onClose();
|
||||
} catch {
|
||||
// An aborted upload (Cancel) or a server rejection (cap/quota — already surfaced by the global
|
||||
// error modal, like every other req call). Re-arm the form so the user can retry or pick again.
|
||||
setPct(null);
|
||||
abortRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal title={t("downloads.upload.title")} onClose={onClose}>
|
||||
<p className="text-sm text-muted mb-4">{t("downloads.upload.hint")}</p>
|
||||
|
||||
{/* Drop zone doubles as the picker trigger — a click anywhere opens the native file dialog. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={pick}
|
||||
onDragEnter={(e) => {
|
||||
e.preventDefault();
|
||||
dragDepth.current += 1;
|
||||
setDragOver(true);
|
||||
}}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDragLeave={() => {
|
||||
dragDepth.current = Math.max(0, dragDepth.current - 1);
|
||||
if (dragDepth.current === 0) setDragOver(false);
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
dragDepth.current = 0;
|
||||
setDragOver(false);
|
||||
const f = e.dataTransfer.files?.[0];
|
||||
if (f) {
|
||||
setFile(f);
|
||||
setName("");
|
||||
}
|
||||
}}
|
||||
disabled={busy}
|
||||
className={`w-full flex flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed px-4 py-8 text-center transition disabled:opacity-60 ${
|
||||
dragOver ? "border-accent bg-accent/5" : "border-border hover:border-fg/40"
|
||||
}`}
|
||||
>
|
||||
{file ? (
|
||||
<>
|
||||
<FileIcon className="w-6 h-6 text-muted" />
|
||||
<span className="text-sm font-medium break-all">{file.name}</span>
|
||||
<span className="text-xs text-muted">{formatBytes(file.size)}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<UploadCloud className="w-7 h-7 text-muted" />
|
||||
<span className="text-sm text-muted">
|
||||
{dragOver ? t("downloads.upload.dropActive") : t("downloads.upload.drop")}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f) {
|
||||
setFile(f);
|
||||
setName("");
|
||||
}
|
||||
e.target.value = ""; // let re-picking the same file fire onChange again
|
||||
}}
|
||||
/>
|
||||
|
||||
<label className="block text-sm font-medium mt-4 mb-1">
|
||||
{t("downloads.upload.nameLabel")}
|
||||
</label>
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={t("downloads.upload.namePlaceholder")}
|
||||
disabled={busy}
|
||||
className={`${inputCls} mb-5`}
|
||||
/>
|
||||
|
||||
{busy && (
|
||||
<div className="mb-5" aria-live="polite">
|
||||
<div
|
||||
className="h-2 rounded-full bg-surface overflow-hidden"
|
||||
role="progressbar"
|
||||
aria-valuenow={pct ?? 0}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
// A STABLE descriptor as the name; the live percentage rides aria-valuenow (and the visible
|
||||
// <p> below re-announces it via the aria-live wrapper) — don't bake the value into the name.
|
||||
aria-label={t("downloads.upload.uploadProgress")}
|
||||
>
|
||||
<div
|
||||
className="h-full bg-accent transition-[width] duration-150"
|
||||
style={{ width: `${pct ?? 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted mt-1.5 text-center">
|
||||
{t("downloads.upload.uploading", { pct: pct ?? 0 })}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => (busy ? abortRef.current?.abort() : onClose())}
|
||||
>
|
||||
{t("downloads.actions.cancel")}
|
||||
</Button>
|
||||
<Button variant="primary" size="sm" onClick={submit} disabled={!file || busy}>
|
||||
{t("downloads.upload.submit")}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import clsx from "clsx";
|
||||
import Modal from "./Modal";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { api, type DownloadJob, type EditSpec } from "../lib/api";
|
||||
import { contentRect, cropToSourcePx, type Frac } from "../lib/cropGeometry";
|
||||
import { inputCls, btnCls } from "./ui/form";
|
||||
import { Button } from "./ui/Button";
|
||||
|
||||
@@ -31,7 +32,6 @@ function parseTc(v: string): number | null {
|
||||
}
|
||||
|
||||
type Seg = { id: number; start: number; end: number; keep: boolean };
|
||||
type Frac = { x: number; y: number; w: number; h: number };
|
||||
type Drag = { type: "seek" } | { type: "boundary"; bi: number };
|
||||
|
||||
const numCls =
|
||||
@@ -51,6 +51,11 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
||||
const [current, setCurrent] = useState(0);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [trackW, setTrackW] = useState(0);
|
||||
// Intrinsic (source) video dims and the video element's rendered box, both needed to map the
|
||||
// crop marquee onto the letterboxed picture. `vdim` starts from asset metadata so the overlay
|
||||
// can size before the media decodes; `box` is measured off the element (max-h can clamp it).
|
||||
const [vdim, setVdim] = useState({ w: job.asset?.width ?? 0, h: job.asset?.height ?? 0 });
|
||||
const [box, setBox] = useState({ w: 0, h: 0 });
|
||||
|
||||
// The cut-list: contiguous segments covering [0, duration]. Starts as the whole video, kept.
|
||||
const [segments, setSegments] = useState<Seg[]>([{ id: 0, start: 0, end: srcDur, keep: true }]);
|
||||
@@ -83,11 +88,28 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
// Measure the video element's rendered box (max-h can shrink it below its intrinsic size, which
|
||||
// then pillarboxes the picture). The crop overlay is anchored to the picture, not this box.
|
||||
useEffect(() => {
|
||||
const v = videoRef.current;
|
||||
if (!v || typeof ResizeObserver === "undefined") return;
|
||||
const ro = new ResizeObserver(() => setBox({ w: v.clientWidth, h: v.clientHeight }));
|
||||
ro.observe(v);
|
||||
setBox({ w: v.clientWidth, h: v.clientHeight });
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
// The picture's rendered rectangle inside the (possibly letterboxed) video box — the overlay's
|
||||
// frame, so a crop fraction is a fraction of the real picture, not of the black bars.
|
||||
const va = vdim.w && vdim.h ? vdim.w / vdim.h : aspect;
|
||||
const picRect = contentRect(box.w, box.h, va);
|
||||
|
||||
const onLoaded = () => {
|
||||
const v = videoRef.current;
|
||||
if (!v) return;
|
||||
const d = v.duration && isFinite(v.duration) ? v.duration : srcDur;
|
||||
setDuration(d);
|
||||
if (v.videoWidth && v.videoHeight) setVdim({ w: v.videoWidth, h: v.videoHeight });
|
||||
// Reconcile the still-pristine full-length segment to the REAL decoded duration. The initial
|
||||
// segment ends at the (integer, often-rounded) metadata `srcDur`; once the media loads we know
|
||||
// the true length (e.g. 213.44 vs 213). Without this the untouched last segment stays short, so
|
||||
@@ -258,16 +280,12 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
||||
|
||||
const cropPx = (): { x: number; y: number; w: number; h: number } | undefined => {
|
||||
const v = videoRef.current;
|
||||
const vw = v?.videoWidth || job.asset?.width || 0;
|
||||
const vh = v?.videoHeight || job.asset?.height || 0;
|
||||
if (!cropOn || !vw || !vh) return undefined;
|
||||
const even = (n: number) => Math.max(2, Math.round(n / 2) * 2);
|
||||
return {
|
||||
x: even(crop.x * vw),
|
||||
y: even(crop.y * vh),
|
||||
w: even(crop.w * vw),
|
||||
h: even(crop.h * vh),
|
||||
};
|
||||
const vw = v?.videoWidth || vdim.w;
|
||||
const vh = v?.videoHeight || vdim.h;
|
||||
if (!cropOn) return undefined;
|
||||
// crop fractions are relative to the picture (the overlay is anchored to it), so this is a
|
||||
// straight fraction→source-px scale; see cropGeometry for the even-rounding + in-bounds clamp.
|
||||
return cropToSourcePx(crop, vw, vh) ?? undefined;
|
||||
};
|
||||
|
||||
const create = useMutation({
|
||||
@@ -322,11 +340,20 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
||||
onTimeUpdate={onTimeUpdate}
|
||||
onPlay={() => setPlaying(true)}
|
||||
onPause={() => setPlaying(false)}
|
||||
className="w-full max-h-[42vh] mx-auto block"
|
||||
className="w-full max-h-[42vh] mx-auto block object-contain"
|
||||
playsInline
|
||||
/>
|
||||
{cropOn && (
|
||||
<div ref={overlayRef} className="absolute inset-0">
|
||||
{cropOn && picRect && (
|
||||
<div
|
||||
ref={overlayRef}
|
||||
className="absolute"
|
||||
style={{
|
||||
left: picRect.left,
|
||||
top: picRect.top,
|
||||
width: picRect.width,
|
||||
height: picRect.height,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute border-2 border-accent cursor-move"
|
||||
style={{
|
||||
|
||||
@@ -36,4 +36,41 @@ describe("WatchPage media element (S3 · 5a)", () => {
|
||||
await waitFor(() => expect(container.querySelector("video")).not.toBeNull());
|
||||
expect(container.querySelector("audio")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders an info card + Download for a generic file, no player (S5d)", async () => {
|
||||
window.history.replaceState({}, "", "/watch/tok-file");
|
||||
mockMeta({
|
||||
needs_password: false,
|
||||
media_kind: "file",
|
||||
title: "report.pdf",
|
||||
container: "pdf",
|
||||
size_bytes: 2_400_000,
|
||||
allow_download: true,
|
||||
file_url: "/f.pdf",
|
||||
});
|
||||
const { container, findByText } = render(<WatchPage />);
|
||||
await findByText("report.pdf");
|
||||
expect(container.querySelector("video")).toBeNull();
|
||||
expect(container.querySelector("audio")).toBeNull();
|
||||
// Type · size chip and a Download link to the file.
|
||||
await findByText(/PDF · 2\.3 MB/);
|
||||
const dl = container.querySelector('a[href="/f.pdf"]');
|
||||
expect(dl).not.toBeNull();
|
||||
});
|
||||
|
||||
it("omits the Download for a view-only file link (S5d)", async () => {
|
||||
window.history.replaceState({}, "", "/watch/tok-file-noview");
|
||||
mockMeta({
|
||||
needs_password: false,
|
||||
media_kind: "file",
|
||||
title: "secret.zip",
|
||||
container: "zip",
|
||||
size_bytes: 1000,
|
||||
allow_download: false,
|
||||
file_url: "/f.zip",
|
||||
});
|
||||
const { container, findByText } = render(<WatchPage />);
|
||||
await findByText("secret.zip");
|
||||
expect(container.querySelector('a[href="/f.zip"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Download, Link2, Lock, PlayCircle } from "lucide-react";
|
||||
import { Download, File as FileIcon, Link2, Lock, PlayCircle } from "lucide-react";
|
||||
import { Button } from "./ui/Button";
|
||||
import { formatBytes } from "../lib/format";
|
||||
|
||||
// Standalone, login-free video page for a shared /watch/<token> link. Rendered OUTSIDE the app
|
||||
// tree (no auth, no providers) — like the /privacy and /terms pages. Uses plain fetch and a tiny
|
||||
@@ -48,6 +49,9 @@ type Meta = {
|
||||
extra_links?: string[];
|
||||
duration_s?: number | null;
|
||||
audio?: boolean;
|
||||
media_kind?: "video" | "audio" | "file" | null;
|
||||
size_bytes?: number | null;
|
||||
container?: string | null;
|
||||
allow_download?: boolean;
|
||||
file_url?: string;
|
||||
poster_url?: string | null;
|
||||
@@ -73,6 +77,7 @@ const STR = {
|
||||
watch: "Watch",
|
||||
wrong: "Wrong password.",
|
||||
download: "Download",
|
||||
file: "File",
|
||||
links: "Links",
|
||||
brand: "Shared via Siftlode",
|
||||
},
|
||||
@@ -85,6 +90,7 @@ const STR = {
|
||||
watch: "Megnézem",
|
||||
wrong: "Hibás jelszó.",
|
||||
download: "Letöltés",
|
||||
file: "Fájl",
|
||||
links: "Linkek",
|
||||
brand: "Megosztva a Siftlode-dal",
|
||||
},
|
||||
@@ -97,6 +103,7 @@ const STR = {
|
||||
watch: "Ansehen",
|
||||
wrong: "Falsches Passwort.",
|
||||
download: "Herunterladen",
|
||||
file: "Datei",
|
||||
links: "Links",
|
||||
brand: "Geteilt über Siftlode",
|
||||
},
|
||||
@@ -215,7 +222,37 @@ export default function WatchPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === "ready" && meta && (
|
||||
{status === "ready" && meta && meta.media_kind === "file" && (
|
||||
/* A generic uploaded file has no player — just an info card (name/type/size) and, if the
|
||||
link allows it, a Download. The backend serves the file force-download regardless, so
|
||||
gating the button on allow_download only mirrors the owner's ACL, matching the media
|
||||
branch's download affordance below. */
|
||||
<div className="flex-1 grid place-items-center">
|
||||
<div className="w-full max-w-md rounded-2xl glass p-6 text-center">
|
||||
<div className="mx-auto w-16 h-16 rounded-xl bg-surface grid place-items-center mb-4">
|
||||
<FileIcon className="w-8 h-8 text-accent" />
|
||||
</div>
|
||||
<h1 className="text-lg font-semibold leading-snug break-words">
|
||||
{meta.title || T.file}
|
||||
</h1>
|
||||
<div className="text-sm text-muted mt-1">
|
||||
{[meta.container?.toUpperCase(), formatBytes(meta.size_bytes)]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
</div>
|
||||
{meta.allow_download && (
|
||||
<a
|
||||
href={meta.file_url}
|
||||
className="mt-5 inline-flex items-center gap-1.5 rounded-lg bg-surface hover:bg-card px-4 py-2 text-sm font-medium transition"
|
||||
>
|
||||
<Download className="w-4 h-4" /> {T.download}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === "ready" && meta && meta.media_kind !== "file" && (
|
||||
<div>
|
||||
{meta.audio ? (
|
||||
/* Audio-only asset (mp3): WebKit won't play it in a <video>, so render a native
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Library-row metadata (E7·S1): the completed row surfaces the concrete container/format alongside the
|
||||
// already-shown size. The format chip reads the raw `asset.container` (uppercased in the UI), so it must
|
||||
// render when present and be absent when the asset has no container yet (queued/running before the worker
|
||||
// fills it). i18n is key-passthrough here.
|
||||
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||
import { render, screen, cleanup } from "@testing-library/react";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
|
||||
});
|
||||
|
||||
import { DownloadRow } from "./downloadRow";
|
||||
import type { DownloadJob } from "../lib/api";
|
||||
|
||||
const baseJob: DownloadJob = {
|
||||
id: 1,
|
||||
status: "done",
|
||||
progress: 100,
|
||||
speed_bps: null,
|
||||
eta_s: null,
|
||||
phase: null,
|
||||
error: null,
|
||||
error_code: null,
|
||||
queue_pos: 0,
|
||||
created_at: null,
|
||||
source_kind: "youtube",
|
||||
source_ref: "abc",
|
||||
source_url: null,
|
||||
poster_url: null,
|
||||
profile_id: null,
|
||||
spec: {
|
||||
mode: "av",
|
||||
max_height: null,
|
||||
container: null,
|
||||
audio_format: null,
|
||||
vcodec: null,
|
||||
embed_subs: false,
|
||||
embed_chapters: false,
|
||||
embed_thumbnail: false,
|
||||
sponsorblock: false,
|
||||
},
|
||||
display_name: "Clip",
|
||||
display_uploader: null,
|
||||
display_uploader_url: null,
|
||||
extra_links: [],
|
||||
can_download: true,
|
||||
expired: false,
|
||||
asset: null,
|
||||
};
|
||||
|
||||
const withAsset = (
|
||||
container: string | null,
|
||||
over: Partial<NonNullable<DownloadJob["asset"]>> = {},
|
||||
): DownloadJob => ({
|
||||
...baseJob,
|
||||
asset: {
|
||||
id: 10,
|
||||
status: "ready",
|
||||
title: "Clip",
|
||||
uploader: null,
|
||||
uploader_url: null,
|
||||
upload_date: null,
|
||||
duration_s: 90,
|
||||
size_bytes: 148_800_000,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
container,
|
||||
media_kind: "video",
|
||||
thumbnail_url: null,
|
||||
expires_at: null,
|
||||
...over,
|
||||
},
|
||||
});
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("DownloadRow format chip", () => {
|
||||
it("shows the uppercased container next to the size when ready", () => {
|
||||
render(<DownloadRow job={withAsset("mp4")} />);
|
||||
// Raw container is stored lowercase; the row uppercases it via CSS, so the text node stays "mp4".
|
||||
expect(screen.getByText(/·\s*mp4/i)).toBeTruthy();
|
||||
expect(screen.getByText(/142 MB/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("omits the format chip when the asset has no container yet", () => {
|
||||
const { container } = render(<DownloadRow job={withAsset(null)} />);
|
||||
expect(container.textContent).not.toMatch(/·\s*(mp4|m4a|webm)/i);
|
||||
});
|
||||
});
|
||||
|
||||
// E7·S5b — a generic uploaded file (media_kind='file') has no video frame and no timeline: the row
|
||||
// shows a neutral file glyph instead of a thumbnail <img>, and no duration chip (it stays media info
|
||||
// like the container/size). Media rows keep their thumbnail path (an <img>, or empty until a poster).
|
||||
describe("DownloadRow file kind", () => {
|
||||
it("renders a file glyph, not a thumbnail <img>, for a generic file", () => {
|
||||
const { container } = render(
|
||||
<DownloadRow
|
||||
job={withAsset("pdf", { media_kind: "file", duration_s: null, size_bytes: 2_400_000 })}
|
||||
/>,
|
||||
);
|
||||
// The file glyph is an inline svg; no <img> element is emitted for a file.
|
||||
expect(container.querySelector("img")).toBeNull();
|
||||
expect(container.querySelector("svg")).not.toBeNull();
|
||||
// File info still reads (container + size), but there is no duration chip.
|
||||
expect(screen.getByText(/·\s*pdf/i)).toBeTruthy();
|
||||
expect(screen.getByText(/2\.3 MB/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("keeps the thumbnail path (an <img> when a poster exists) for a media asset", () => {
|
||||
const { container } = render(
|
||||
<DownloadRow job={{ ...withAsset("mp4"), poster_url: "/api/downloads/1/poster.jpg" }} />,
|
||||
);
|
||||
expect(container.querySelector("img")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CalendarClock, Copy, Link2, Scissors } from "lucide-react";
|
||||
import { CalendarClock, Copy, File, Link2, Scissors } from "lucide-react";
|
||||
import clsx from "clsx";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { useLiveQuery } from "../lib/useLiveQuery";
|
||||
@@ -43,12 +43,12 @@ export function StatusPill({ job }: { job: DownloadJob }) {
|
||||
// confused by the bar resetting between the separate video and audio streams.
|
||||
if (job.status === "running" && job.phase && PHASE_KEYS.includes(job.phase)) {
|
||||
return (
|
||||
<span className="text-xs font-medium text-accent">{t(`downloads.phase.${job.phase}`)}</span>
|
||||
<span className="text-sm font-medium text-accent">{t(`downloads.phase.${job.phase}`)}</span>
|
||||
);
|
||||
}
|
||||
const key = job.expired ? "expired" : job.status;
|
||||
return (
|
||||
<span className={clsx("text-xs font-medium", STATUS_CLS[job.status] ?? "text-muted")}>
|
||||
<span className={clsx("text-sm font-medium", STATUS_CLS[job.status] ?? "text-muted")}>
|
||||
{t(`downloads.status.${key}`)}
|
||||
</span>
|
||||
);
|
||||
@@ -69,6 +69,15 @@ function Thumb({ job }: { job: DownloadJob }) {
|
||||
// On load failure (a genuinely missing poster file) fall back to the remote source thumbnail.
|
||||
const [src, setSrc] = useState(primary);
|
||||
useEffect(() => setSrc(primary), [primary]);
|
||||
// A generic uploaded file (media_kind='file') has no video frame to poster — it's a document/
|
||||
// archive, not playable media. Show a neutral file glyph instead of an (always-empty) thumbnail.
|
||||
if (job.asset?.media_kind === "file") {
|
||||
return (
|
||||
<div className="w-28 aspect-video shrink-0 rounded-lg overflow-hidden bg-surface border border-border flex items-center justify-center text-muted">
|
||||
<File className="w-8 h-8" aria-hidden="true" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="w-28 aspect-video shrink-0 rounded-lg overflow-hidden bg-surface border border-border">
|
||||
{src ? (
|
||||
@@ -145,8 +154,8 @@ function SourceRef({ url }: { url: string }) {
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className="flex items-center gap-1 mt-1 text-xs text-muted min-w-0">
|
||||
<Link2 className="w-3 h-3 shrink-0" />
|
||||
<div className="flex items-center gap-1 mt-1 text-sm text-muted min-w-0">
|
||||
<Link2 className="w-3.5 h-3.5 shrink-0" />
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
@@ -162,7 +171,7 @@ function SourceRef({ url }: { url: string }) {
|
||||
title={t("downloads.source.copy")}
|
||||
className="shrink-0 p-0.5 rounded-sm hover:bg-surface hover:text-fg transition"
|
||||
>
|
||||
<Copy className="w-3 h-3" />
|
||||
<Copy className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
@@ -173,7 +182,7 @@ function ChannelLine({ job }: { job: DownloadJob }) {
|
||||
const { name, url } = jobChannel(job);
|
||||
if (url) {
|
||||
return (
|
||||
<div className="text-xs mt-0.5 truncate">
|
||||
<div className="text-sm mt-0.5 truncate">
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
@@ -186,7 +195,7 @@ function ChannelLine({ job }: { job: DownloadJob }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <div className="text-xs text-muted truncate mt-0.5">{name}</div>;
|
||||
return <div className="text-sm text-muted truncate mt-0.5">{name}</div>;
|
||||
}
|
||||
|
||||
export function DownloadRow({
|
||||
@@ -206,15 +215,20 @@ export function DownloadRow({
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-medium leading-snug line-clamp-1 flex items-center gap-1.5">
|
||||
{job.job_kind === "edit" && (
|
||||
<span className="inline-flex items-center gap-1 shrink-0 px-1.5 py-0.5 rounded-sm text-3xs font-semibold uppercase tracking-wide bg-accent/15 text-accent">
|
||||
<span className="inline-flex items-center gap-1 shrink-0 px-1.5 py-0.5 rounded-sm text-2xs font-semibold uppercase tracking-wide bg-accent/15 text-accent">
|
||||
<Scissors className="w-3 h-3" /> {t("downloads.clipBadge")}
|
||||
</span>
|
||||
)}
|
||||
<span className="truncate">{jobTitle(job)}</span>
|
||||
</div>
|
||||
<ChannelLine job={job} />
|
||||
<div className="flex items-center gap-2 mt-1 text-xs">
|
||||
<div className="flex items-center gap-2 mt-1 text-sm">
|
||||
<StatusPill job={job} />
|
||||
{job.asset?.container ? (
|
||||
// Concrete container (mp4 / m4a / webm …) — reads as the file format, and its audio-vs-video
|
||||
// extension is the video/audio distinction the running-only phase pill can't show at rest.
|
||||
<span className="text-muted uppercase">· {job.asset.container}</span>
|
||||
) : null}
|
||||
{job.asset?.size_bytes ? (
|
||||
<span className="text-muted">· {formatBytes(job.asset.size_bytes)}</span>
|
||||
) : null}
|
||||
@@ -226,7 +240,7 @@ export function DownloadRow({
|
||||
className="text-muted inline-flex items-center gap-1"
|
||||
title={new Date(job.created_at).toLocaleString()}
|
||||
>
|
||||
· <CalendarClock className="w-3 h-3" /> {relativeTime(job.created_at)}
|
||||
· <CalendarClock className="w-3.5 h-3.5" /> {relativeTime(job.created_at)}
|
||||
</span>
|
||||
) : null}
|
||||
{job.status === "error" ? (
|
||||
@@ -259,7 +273,7 @@ export function DownloadRow({
|
||||
style={{ width: `${job.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-2xs text-muted mt-0.5 flex gap-2">
|
||||
<div className="text-xs text-muted mt-0.5 flex gap-2">
|
||||
<span>{job.progress}%</span>
|
||||
{job.speed_bps ? <span>{formatSpeed(job.speed_bps)}</span> : null}
|
||||
{job.eta_s ? <span>{formatEta(job.eta_s)}</span> : null}
|
||||
@@ -269,7 +283,12 @@ export function DownloadRow({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-start gap-0.5 shrink-0">{children}</div>
|
||||
{/* Row action buttons: grown a step here (bigger tap target + icon) without touching the shared
|
||||
IconBtn — scoped so the same primitive stays smaller in the modals. `shrink-0` + no wrap keeps
|
||||
them on the title's line; the text column shrinks instead. */}
|
||||
<div className="flex items-start gap-1 shrink-0 [&_button]:p-2 [&_svg]:size-[18px]">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,8 +21,21 @@
|
||||
"addUrl": "Paste a YouTube link or video id…",
|
||||
"add": "Add",
|
||||
"paste": "Paste link from clipboard",
|
||||
"upload": "Upload",
|
||||
"manage": "Manage formats"
|
||||
},
|
||||
"upload": {
|
||||
"title": "Upload a file",
|
||||
"hint": "Any file type. Video and audio can be played and edited; other files are download-only.",
|
||||
"drop": "Drag a file here, or click to choose",
|
||||
"dropActive": "Drop to upload",
|
||||
"nameLabel": "Name (optional)",
|
||||
"namePlaceholder": "Leave blank to use the file name",
|
||||
"submit": "Upload",
|
||||
"uploading": "Uploading… {{pct}}%",
|
||||
"uploadProgress": "Upload progress",
|
||||
"added": "File uploaded"
|
||||
},
|
||||
"tabs": {
|
||||
"queue": "Queue",
|
||||
"done": "Library",
|
||||
@@ -56,13 +69,18 @@
|
||||
"delete": "Remove",
|
||||
"retry": "Retry",
|
||||
"download": "Save to my device",
|
||||
"play": "Play",
|
||||
"rename": "Rename",
|
||||
"editDetails": "Edit details",
|
||||
"share": "Share",
|
||||
"share": "Share (copy link)",
|
||||
"shareManage": "Manage sharing",
|
||||
"edit": "Edit / trim",
|
||||
"redownload": "Re-download (rebuild file)",
|
||||
"removeShared": "Remove from my list"
|
||||
},
|
||||
"player": {
|
||||
"unsupported": "Your browser can't play this file."
|
||||
},
|
||||
"empty": {
|
||||
"queue": "Nothing in the queue.",
|
||||
"done": "No downloads yet.",
|
||||
@@ -104,6 +122,9 @@
|
||||
"noUsers": "No other users yet.",
|
||||
"linkSection": "Share a link",
|
||||
"linkHint": "Anyone with the link can watch — no account needed.",
|
||||
"linkCopied": "Share link copied to clipboard",
|
||||
"openNewTab": "Open in new tab",
|
||||
"linkFailed": "Couldn’t create the share link",
|
||||
"noLinks": "No links yet.",
|
||||
"newLink": "Create a link",
|
||||
"createLink": "Create link",
|
||||
@@ -151,10 +172,7 @@
|
||||
"modeA": "Audio only",
|
||||
"quality": "Max quality",
|
||||
"best": "Best",
|
||||
"container": "Container",
|
||||
"audioFormat": "Audio format",
|
||||
"vcodec": "Video codec",
|
||||
"any": "Any",
|
||||
"embedSubs": "Embed subtitles",
|
||||
"embedChapters": "Embed chapters",
|
||||
"embedThumbnail": "Embed thumbnail",
|
||||
@@ -207,6 +225,10 @@
|
||||
"source_not_ready": "The source download isn't ready to edit.",
|
||||
"generic": "That edit couldn't be created."
|
||||
},
|
||||
"upload": {
|
||||
"tooLarge": "That file is too large (max {{size}}).",
|
||||
"empty": "That file is empty."
|
||||
},
|
||||
"fail": {
|
||||
"title": "Download failed",
|
||||
"detailsLink": "details",
|
||||
|
||||
@@ -21,8 +21,21 @@
|
||||
"addUrl": "Illessz be egy YouTube linket vagy videó azonosítót…",
|
||||
"add": "Hozzáad",
|
||||
"paste": "Link beillesztése a vágólapról",
|
||||
"upload": "Feltöltés",
|
||||
"manage": "Formátumok kezelése"
|
||||
},
|
||||
"upload": {
|
||||
"title": "Fájl feltöltése",
|
||||
"hint": "Bármilyen fájltípus. A videó és a hang lejátszható és szerkeszthető; a többi fájl csak letölthető.",
|
||||
"drop": "Húzz ide egy fájlt, vagy kattints a kiválasztáshoz",
|
||||
"dropActive": "Engedd el a feltöltéshez",
|
||||
"nameLabel": "Név (opcionális)",
|
||||
"namePlaceholder": "Hagyd üresen a fájlnév használatához",
|
||||
"submit": "Feltöltés",
|
||||
"uploading": "Feltöltés… {{pct}}%",
|
||||
"uploadProgress": "Feltöltés folyamata",
|
||||
"added": "Fájl feltöltve"
|
||||
},
|
||||
"tabs": {
|
||||
"queue": "Sor",
|
||||
"done": "Könyvtár",
|
||||
@@ -56,13 +69,18 @@
|
||||
"delete": "Eltávolítás",
|
||||
"retry": "Újra",
|
||||
"download": "Mentés a gépemre",
|
||||
"play": "Lejátszás",
|
||||
"rename": "Átnevezés",
|
||||
"editDetails": "Adatok szerkesztése",
|
||||
"share": "Megosztás",
|
||||
"share": "Megosztás (link másolása)",
|
||||
"shareManage": "Megosztás kezelése",
|
||||
"edit": "Szerkesztés / vágás",
|
||||
"redownload": "Újraletöltés (fájl újraépítése)",
|
||||
"removeShared": "Eltávolítás a listámból"
|
||||
},
|
||||
"player": {
|
||||
"unsupported": "A böngésződ nem tudja lejátszani ezt a fájlt."
|
||||
},
|
||||
"empty": {
|
||||
"queue": "A sor üres.",
|
||||
"done": "Még nincs letöltés.",
|
||||
@@ -104,6 +122,9 @@
|
||||
"noUsers": "Nincs más felhasználó.",
|
||||
"linkSection": "Megosztás linkkel",
|
||||
"linkHint": "A link birtokában bárki megnézheti — fiók nélkül is.",
|
||||
"linkCopied": "Megosztási link a vágólapra másolva",
|
||||
"openNewTab": "Megnyitás új lapon",
|
||||
"linkFailed": "Nem sikerült a megosztási link létrehozása",
|
||||
"noLinks": "Még nincs link.",
|
||||
"newLink": "Link létrehozása",
|
||||
"createLink": "Létrehozás",
|
||||
@@ -151,10 +172,7 @@
|
||||
"modeA": "Csak hang",
|
||||
"quality": "Max. minőség",
|
||||
"best": "Legjobb",
|
||||
"container": "Konténer",
|
||||
"audioFormat": "Hangformátum",
|
||||
"vcodec": "Videó codec",
|
||||
"any": "Bármelyik",
|
||||
"embedSubs": "Feliratok beágyazása",
|
||||
"embedChapters": "Fejezetek beágyazása",
|
||||
"embedThumbnail": "Bélyegkép beágyazása",
|
||||
@@ -207,6 +225,10 @@
|
||||
"source_not_ready": "A forrásletöltés még nem szerkeszthető.",
|
||||
"generic": "Ezt a vágást nem sikerült létrehozni."
|
||||
},
|
||||
"upload": {
|
||||
"tooLarge": "Ez a fájl túl nagy (legfeljebb {{size}}).",
|
||||
"empty": "Ez a fájl üres."
|
||||
},
|
||||
"fail": {
|
||||
"title": "A letöltés nem sikerült",
|
||||
"detailsLink": "részletek",
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// uploadReq is the XHR upload path (progress/abort/error/401/4xx-localize) that `fetch`/`req` can't
|
||||
// cover. UploadDialog's DOM test mocks api.uploadDownload wholesale, so without this the abort, 401,
|
||||
// and structured-detail-localize branches ship untested. A controllable fake XMLHttpRequest drives
|
||||
// each lifecycle branch.
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
|
||||
const { reportError, notify, remove } = vi.hoisted(() => ({
|
||||
reportError: vi.fn(),
|
||||
notify: vi.fn(() => 1),
|
||||
remove: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../errorDialog", () => ({ reportError }));
|
||||
vi.mock("../notifications", () => ({ notify, remove }));
|
||||
vi.mock("../../i18n", () => ({ default: { t: (k: string) => k, language: "en" } }));
|
||||
vi.mock("../activeAccount", () => ({
|
||||
activeAccountId: () => null,
|
||||
ACTIVE_ACCOUNT_KEY: "k",
|
||||
notifyActiveAccountChange: vi.fn(),
|
||||
}));
|
||||
|
||||
import { uploadReq, setUnauthorizedHandler, HttpError } from "./core";
|
||||
|
||||
// A fake XHR whose lifecycle the test drives: capture the instance, set status/responseText, then
|
||||
// invoke onload/onerror/abort by hand.
|
||||
class FakeXHR {
|
||||
static last: FakeXHR;
|
||||
status = 0;
|
||||
responseText = "";
|
||||
withCredentials = false;
|
||||
headers: Record<string, string> = {};
|
||||
upload: { onprogress: ((e: ProgressEvent) => void) | null } = { onprogress: null };
|
||||
onload: (() => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
onabort: (() => void) | null = null;
|
||||
sent = false;
|
||||
constructor() {
|
||||
FakeXHR.last = this;
|
||||
}
|
||||
open() {}
|
||||
setRequestHeader(k: string, v: string) {
|
||||
this.headers[k] = v;
|
||||
}
|
||||
send() {
|
||||
this.sent = true;
|
||||
}
|
||||
abort() {
|
||||
this.onabort?.();
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
reportError.mockReset();
|
||||
notify.mockReset();
|
||||
setUnauthorizedHandler(null);
|
||||
vi.stubGlobal("XMLHttpRequest", FakeXHR as unknown as typeof XMLHttpRequest);
|
||||
});
|
||||
|
||||
const form = () => new FormData();
|
||||
|
||||
describe("uploadReq", () => {
|
||||
it("resolves with the parsed JSON on a 2xx", async () => {
|
||||
const p = uploadReq<{ id: number }>("/u", form());
|
||||
const x = FakeXHR.last;
|
||||
x.status = 200;
|
||||
x.responseText = '{"id":7}';
|
||||
x.onload!();
|
||||
await expect(p).resolves.toEqual({ id: 7 });
|
||||
});
|
||||
|
||||
it("rejects immediately if the signal is already aborted (never sends)", async () => {
|
||||
const ac = new AbortController();
|
||||
ac.abort();
|
||||
const p = uploadReq("/u", form(), { signal: ac.signal });
|
||||
await expect(p).rejects.toBeInstanceOf(HttpError);
|
||||
expect(FakeXHR.last?.sent).not.toBe(true);
|
||||
});
|
||||
|
||||
it("aborts the XHR and rejects when the signal fires", async () => {
|
||||
const ac = new AbortController();
|
||||
const p = uploadReq("/u", form(), { signal: ac.signal });
|
||||
ac.abort(); // → our abort listener calls xhr.abort() → onabort → reject
|
||||
await expect(p).rejects.toMatchObject({ status: 0 });
|
||||
});
|
||||
|
||||
it("calls the unauthorized handler on 401", async () => {
|
||||
const onUnauth = vi.fn();
|
||||
setUnauthorizedHandler(onUnauth);
|
||||
const p = uploadReq("/u", form());
|
||||
const x = FakeXHR.last;
|
||||
x.status = 401;
|
||||
x.onload!();
|
||||
await expect(p).rejects.toBeInstanceOf(HttpError);
|
||||
expect(onUnauth).toHaveBeenCalledOnce();
|
||||
expect(reportError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("localizes a structured 422 upload_too_large and surfaces the error", async () => {
|
||||
const p = uploadReq("/u", form());
|
||||
const x = FakeXHR.last;
|
||||
x.status = 422;
|
||||
x.responseText = '{"detail":{"code":"upload_too_large","limit":5}}';
|
||||
x.onload!();
|
||||
await expect(p).rejects.toMatchObject({ status: 422 });
|
||||
// localizeDetail routes upload_too_large → the tooLarge key (t is passthrough in the mock).
|
||||
expect(reportError).toHaveBeenCalledWith("downloads.errors.upload.tooLarge");
|
||||
});
|
||||
|
||||
it("reports a connectivity failure on a network error", async () => {
|
||||
const p = uploadReq("/u", form());
|
||||
FakeXHR.last.onerror!();
|
||||
await expect(p).rejects.toMatchObject({ status: 0 });
|
||||
// The soft self-clearing "connection lost" toast (via notify), not a blocking modal.
|
||||
expect(notify).toHaveBeenCalled();
|
||||
expect(reportError).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@
|
||||
// so the runtime graph is a clean barrel → domain → core with no cycles.
|
||||
import { notify, remove } from "../notifications";
|
||||
import i18n from "../../i18n";
|
||||
import { formatBytes } from "../format";
|
||||
import { reportError } from "../errorDialog";
|
||||
import { activeAccountId, ACTIVE_ACCOUNT_KEY, notifyActiveAccountChange } from "../activeAccount";
|
||||
|
||||
@@ -45,6 +46,10 @@ function localizeDetail(d: unknown): string {
|
||||
return t(`downloads.errors.edit.${o.reason}`, t("downloads.errors.edit.generic"));
|
||||
if (o?.code === "cookies")
|
||||
return t(`downloads.errors.cookies.${o.reason}`, t("downloads.errors.cookies.generic"));
|
||||
// Local-upload ingest (E7 S5): the per-file cap carries the limit in bytes; empty is a bare code.
|
||||
if (o?.code === "upload_too_large")
|
||||
return t("downloads.errors.upload.tooLarge", { size: formatBytes(o.limit) });
|
||||
if (o?.code === "upload_empty") return t("downloads.errors.upload.empty");
|
||||
return t("errors.generic");
|
||||
}
|
||||
|
||||
@@ -235,3 +240,67 @@ export async function req<T = unknown>(
|
||||
return (r.status === 204 ? null : await r.json()) as T;
|
||||
}
|
||||
}
|
||||
|
||||
// Multipart upload with progress. `req` rides `fetch`, which can't report upload progress, so a
|
||||
// file upload uses XHR instead: the caller gets a 0–100% callback as the body streams and an
|
||||
// optional AbortSignal to cancel a large upload. Everything else mirrors `req` — the per-tab
|
||||
// account header, credentials, the structured-detail localization, the global error modal for
|
||||
// 4xx/5xx, and an `HttpError` throw — so callers handle failures exactly as they do a `req` call.
|
||||
// The browser sets the multipart Content-Type (with its boundary), so we deliberately don't.
|
||||
export function uploadReq<T = unknown>(
|
||||
url: string,
|
||||
form: FormData,
|
||||
cfg: { onProgress?: (pct: number) => void; signal?: AbortSignal } = {},
|
||||
): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open("POST", url);
|
||||
xhr.withCredentials = true;
|
||||
const active = getActiveAccount();
|
||||
if (active != null) xhr.setRequestHeader(ACTIVE_ACCOUNT_HEADER, String(active));
|
||||
if (cfg.onProgress) {
|
||||
xhr.upload.onprogress = (e) => {
|
||||
if (e.lengthComputable) cfg.onProgress?.(Math.round((e.loaded / e.total) * 100));
|
||||
};
|
||||
}
|
||||
if (cfg.signal) {
|
||||
if (cfg.signal.aborted) return reject(new HttpError(0, "aborted"));
|
||||
cfg.signal.addEventListener("abort", () => xhr.abort());
|
||||
}
|
||||
xhr.onload = () => {
|
||||
markConnectivityRestored();
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
try {
|
||||
resolve((xhr.responseText ? JSON.parse(xhr.responseText) : null) as T);
|
||||
} catch {
|
||||
resolve(null as T);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let detail: string | undefined;
|
||||
let detailData: unknown;
|
||||
try {
|
||||
const body = JSON.parse(xhr.responseText);
|
||||
if (body && typeof body.detail === "string") detail = body.detail;
|
||||
else if (body && body.detail && typeof body.detail === "object") {
|
||||
detailData = body.detail;
|
||||
detail = localizeDetail(body.detail);
|
||||
}
|
||||
} catch {
|
||||
/* no JSON body */
|
||||
}
|
||||
if (xhr.status === 401) onUnauthorized?.();
|
||||
else if (xhr.status >= 500)
|
||||
reportError(detail || `${i18n.t("errors.server")} (${xhr.status})`);
|
||||
else if (xhr.status === 400 || xhr.status === 409 || xhr.status === 422) reportError(detail);
|
||||
reject(new HttpError(xhr.status, detail, detailData));
|
||||
};
|
||||
// A network-level failure (server unreachable) — same soft "connection lost" status as `req`.
|
||||
xhr.onerror = () => {
|
||||
markConnectivityLost();
|
||||
reject(new HttpError(0, i18n.t("errors.offline.body")));
|
||||
};
|
||||
xhr.onabort = () => reject(new HttpError(0, "aborted"));
|
||||
xhr.send(form);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Download-center domain: the yt-dlp job lifecycle, per-user profiles, share links/recipients, the
|
||||
// phase-2 editor, storyboards, and the admin storage/quota views. Self-contained — its types only
|
||||
// reference each other, and its methods only touch the shared `req`/`getActiveAccount` from core.
|
||||
import { req, getActiveAccount } from "./core";
|
||||
import { req, uploadReq, getActiveAccount } from "./core";
|
||||
|
||||
export interface DownloadSpec {
|
||||
mode: "av" | "v" | "a"; // audio+video | video-only | audio-only
|
||||
@@ -35,14 +35,19 @@ interface DownloadAsset {
|
||||
width: number | null;
|
||||
height: number | null;
|
||||
container: string | null;
|
||||
// "video" | "audio" | "file" — the stored discriminator that gates library row actions: media
|
||||
// (video/audio) gets play/edit; a generic uploaded file is download+share only, no player. Null
|
||||
// while still in flight (set at finalize). See backend downloads/mediakind.py.
|
||||
media_kind: "video" | "audio" | "file" | null;
|
||||
thumbnail_url: string | null;
|
||||
expires_at: string | null;
|
||||
}
|
||||
|
||||
export type DownloadStatus = "queued" | "running" | "paused" | "done" | "error" | "canceled";
|
||||
// "download" (default) or "edit" (a per-user trim/crop derivative cut from another job).
|
||||
// "download" (default), "edit" (a per-user trim/crop derivative cut from another job), or "upload"
|
||||
// (a locally-uploaded file — no re-fetchable source, so re-download is rejected backend-side).
|
||||
// Local (not exported): consumers compare `job.job_kind` against literals, never name the type.
|
||||
type DownloadJobKind = "download" | "edit";
|
||||
type DownloadJobKind = "download" | "edit" | "upload";
|
||||
|
||||
// Phase-2 editor recipe. A single `trim` (one output file) or a `segments` cut-list joined into
|
||||
// one file. `accurate` re-encodes for a frame-accurate cut (a crop always re-encodes).
|
||||
@@ -178,6 +183,21 @@ export const downloadsApi = {
|
||||
spec?: DownloadSpec;
|
||||
display_name?: string;
|
||||
}): Promise<DownloadJob> => req("/api/downloads", { method: "POST", body: JSON.stringify(body) }),
|
||||
// Ingest a local file into the library (E7 S5). Multipart via XHR so the dialog can show upload
|
||||
// progress; any file type is accepted (media gets the full function set, a generic file is
|
||||
// download-only). Returns the already-`done` job the ingest creates.
|
||||
uploadDownload: (
|
||||
file: File,
|
||||
opts: { displayName?: string; onProgress?: (pct: number) => void; signal?: AbortSignal } = {},
|
||||
): Promise<DownloadJob> => {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
if (opts.displayName) form.append("display_name", opts.displayName);
|
||||
return uploadReq<DownloadJob>("/api/downloads/upload", form, {
|
||||
onProgress: opts.onProgress,
|
||||
signal: opts.signal,
|
||||
});
|
||||
},
|
||||
enqueueEdit: (body: {
|
||||
source_job_id: number;
|
||||
edit_spec: EditSpec;
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { contentRect, cropToSourcePx } from "./cropGeometry";
|
||||
|
||||
describe("contentRect (display-px letterbox mapping)", () => {
|
||||
it("returns the full box when box aspect matches the video aspect", () => {
|
||||
// 16:9 picture in a 16:9 box → no bars, content == box
|
||||
expect(contentRect(1600, 900, 16 / 9)).toEqual({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 1600,
|
||||
height: 900,
|
||||
});
|
||||
});
|
||||
|
||||
it("pillarboxes (bars left/right) when the box is wider than the picture", () => {
|
||||
// 16:9 picture in a 2:1 box → full height, narrower centered width
|
||||
const r = contentRect(2000, 1000, 16 / 9)!;
|
||||
expect(r.height).toBe(1000);
|
||||
expect(r.width).toBeCloseTo(1000 * (16 / 9), 3);
|
||||
expect(r.top).toBe(0);
|
||||
expect(r.left).toBeCloseTo((2000 - 1000 * (16 / 9)) / 2, 3);
|
||||
});
|
||||
|
||||
it("letterboxes (bars top/bottom) when the box is taller than the picture", () => {
|
||||
// 16:9 picture in a 1:1 box → full width, shorter centered height
|
||||
const r = contentRect(1000, 1000, 16 / 9)!;
|
||||
expect(r.width).toBe(1000);
|
||||
expect(r.height).toBeCloseTo(1000 / (16 / 9), 3);
|
||||
expect(r.left).toBe(0);
|
||||
expect(r.top).toBeCloseTo((1000 - 1000 / (16 / 9)) / 2, 3);
|
||||
});
|
||||
|
||||
it("returns null for unmeasurable inputs", () => {
|
||||
expect(contentRect(0, 900, 16 / 9)).toBeNull();
|
||||
expect(contentRect(1600, 0, 16 / 9)).toBeNull();
|
||||
expect(contentRect(1600, 900, 0)).toBeNull();
|
||||
expect(contentRect(1600, 900, NaN)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("cropToSourcePx (fraction-of-content → even source px)", () => {
|
||||
it("scales content fractions straight to source pixels", () => {
|
||||
// A marquee anchored to the content rect: a fraction is a fraction of the real picture.
|
||||
expect(cropToSourcePx({ x: 0.25, y: 0.25, w: 0.5, h: 0.5 }, 1920, 1080)).toEqual({
|
||||
x: 480,
|
||||
y: 270,
|
||||
w: 960,
|
||||
h: 540,
|
||||
});
|
||||
});
|
||||
|
||||
it("rounds to even dimensions", () => {
|
||||
// 0.1 * 1920 = 192 (even); 0.333 * 1080 = 359.64 → 360
|
||||
const c = cropToSourcePx({ x: 0.1, y: 0.333, w: 0.2, h: 0.111 }, 1920, 1080)!;
|
||||
expect(c.w % 2).toBe(0);
|
||||
expect(c.h % 2).toBe(0);
|
||||
expect(c.x % 2).toBe(0);
|
||||
expect(c.y % 2).toBe(0);
|
||||
});
|
||||
|
||||
it("allows a top-left origin (x/y may be 0)", () => {
|
||||
const c = cropToSourcePx({ x: 0, y: 0, w: 0.5, h: 0.5 }, 1920, 1080)!;
|
||||
expect(c.x).toBe(0);
|
||||
expect(c.y).toBe(0);
|
||||
});
|
||||
|
||||
it("clamps the window inside the frame so even-rounding can't overflow", () => {
|
||||
// A full-frame crop must stay exactly at the source size, not round past it.
|
||||
const c = cropToSourcePx({ x: 0, y: 0, w: 1, h: 1 }, 1921, 1081)!;
|
||||
expect(c.w).toBeLessThanOrEqual(1921);
|
||||
expect(c.h).toBeLessThanOrEqual(1081);
|
||||
expect(c.x + c.w).toBeLessThanOrEqual(1921);
|
||||
expect(c.y + c.h).toBeLessThanOrEqual(1081);
|
||||
});
|
||||
|
||||
it("keeps x+w within the frame for an off-square region near the right edge", () => {
|
||||
const srcW = 1280;
|
||||
const srcH = 720;
|
||||
const c = cropToSourcePx({ x: 0.7, y: 0.1, w: 0.3, h: 0.8 }, srcW, srcH)!;
|
||||
expect(c.x + c.w).toBeLessThanOrEqual(srcW);
|
||||
expect(c.y + c.h).toBeLessThanOrEqual(srcH);
|
||||
});
|
||||
|
||||
it("returns null for an unusable source size", () => {
|
||||
expect(cropToSourcePx({ x: 0, y: 0, w: 1, h: 1 }, 0, 1080)).toBeNull();
|
||||
expect(cropToSourcePx({ x: 0, y: 0, w: 1, h: 1 }, 1920, 0)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
// Crop geometry for the video editor — the display-px ↔ source-px mapping, isolated as pure
|
||||
// functions so the math is unit-testable without a DOM.
|
||||
//
|
||||
// The bug this fixes (E7 S1→S2): the crop marquee is drawn in fractions of the *video element box*,
|
||||
// but a letterboxed video (object-fit: contain) does NOT fill that box — pillar/letterbox bars sit
|
||||
// between the box edge and the actual picture. Multiplying box-fractions by the source resolution
|
||||
// therefore emitted an ffmpeg crop shifted/scaled off from what the selector showed. The fix is to
|
||||
// anchor the overlay to the *content* rect (below), so a fraction of the overlay is a fraction of
|
||||
// the real picture, and cropToSourcePx() is then a straight fraction→pixel scale.
|
||||
|
||||
export type Frac = { x: number; y: number; w: number; h: number };
|
||||
type Rect = { left: number; top: number; width: number; height: number };
|
||||
type CropPx = { x: number; y: number; w: number; h: number };
|
||||
|
||||
/**
|
||||
* The rendered video-content rectangle inside an element box of `boxW × boxH`, given the video's
|
||||
* intrinsic aspect ratio, when the video is laid out `object-fit: contain` (letterboxed). Returns
|
||||
* null while any input is unmeasurable (0/NaN), so callers can skip drawing until the video sizes.
|
||||
*/
|
||||
export function contentRect(boxW: number, boxH: number, videoAspect: number): Rect | null {
|
||||
if (!(boxW > 0) || !(boxH > 0) || !(videoAspect > 0)) return null;
|
||||
const boxAspect = boxW / boxH;
|
||||
if (boxAspect > videoAspect) {
|
||||
// box wider than the picture → pillarbox (bars left/right), full height
|
||||
const width = boxH * videoAspect;
|
||||
return { left: (boxW - width) / 2, top: 0, width, height: boxH };
|
||||
}
|
||||
// box taller than the picture → letterbox (bars top/bottom), full width
|
||||
const height = boxW / videoAspect;
|
||||
return { left: 0, top: (boxH - height) / 2, width: boxW, height };
|
||||
}
|
||||
|
||||
/**
|
||||
* A crop expressed as fractions of the video *content* → integer, even source-pixel crop args
|
||||
* (ffmpeg `crop=w:h:x:y`). Even dimensions keep chroma-subsampled encoders happy; the window is
|
||||
* clamped inside the frame so even-rounding can never push `x+w`/`y+h` past the source resolution
|
||||
* (which ffmpeg rejects as "crop area out of bounds"). Returns null for an unusable source size.
|
||||
*/
|
||||
export function cropToSourcePx(crop: Frac, srcW: number, srcH: number): CropPx | null {
|
||||
if (!(srcW > 0) || !(srcH > 0)) return null;
|
||||
const evenDim = (n: number) => Math.max(2, Math.round(n / 2) * 2); // w/h ≥ 2
|
||||
const evenOff = (n: number) => Math.max(0, Math.round(n / 2) * 2); // x/y ≥ 0 (top-left is valid)
|
||||
const w = Math.min(evenDim(crop.w * srcW), srcW);
|
||||
const h = Math.min(evenDim(crop.h * srcH), srcH);
|
||||
const x = Math.min(evenOff(crop.x * srcW), srcW - w);
|
||||
const y = Math.min(evenOff(crop.y * srcH), srcH - h);
|
||||
return { x, y, w, h };
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
// E7·S5b — the Library/Shared row-action gate. A generic uploaded file (media_kind='file') is
|
||||
// download+share only (no Play/Edit/Details); an edit clip and an upload can't be re-downloaded.
|
||||
// These pin the gate so a regression that shows Play/Edit on a file, or re-download on an upload,
|
||||
// is caught (the affordance itself, not just the backend guard behind it).
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { libraryRowActions, sharedRowActions } from "./downloadActions";
|
||||
import type { DownloadJob } from "./api";
|
||||
|
||||
const job = (
|
||||
over: Partial<DownloadJob>,
|
||||
asset: Partial<NonNullable<DownloadJob["asset"]>> | null,
|
||||
) =>
|
||||
({
|
||||
id: 1,
|
||||
status: "done",
|
||||
job_kind: "download",
|
||||
can_download: true,
|
||||
asset: asset && {
|
||||
id: 1,
|
||||
status: "ready",
|
||||
title: "x",
|
||||
duration_s: 90,
|
||||
media_kind: "video",
|
||||
container: "mp4",
|
||||
...asset,
|
||||
},
|
||||
...over,
|
||||
}) as unknown as DownloadJob;
|
||||
|
||||
describe("libraryRowActions", () => {
|
||||
it("a media (video) item gets play, edit, details, redownload", () => {
|
||||
expect(libraryRowActions(job({}, {}))).toEqual({
|
||||
play: true,
|
||||
edit: true,
|
||||
editDetails: true,
|
||||
redownload: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("a generic file gets NO play/edit/details (download+share only)", () => {
|
||||
const a = libraryRowActions(job({}, { media_kind: "file", duration_s: null }));
|
||||
expect(a.play).toBe(false);
|
||||
expect(a.edit).toBe(false);
|
||||
expect(a.editDetails).toBe(false);
|
||||
});
|
||||
|
||||
it("an upload cannot be re-downloaded (no re-fetchable source)", () => {
|
||||
expect(libraryRowActions(job({ job_kind: "upload" }, { media_kind: "file" })).redownload).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("an edit clip cannot be re-downloaded", () => {
|
||||
expect(libraryRowActions(job({ job_kind: "edit" }, {})).redownload).toBe(false);
|
||||
});
|
||||
|
||||
it("edit is hidden when the media has no duration", () => {
|
||||
expect(libraryRowActions(job({}, { duration_s: 0 })).edit).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sharedRowActions", () => {
|
||||
it("a downloadable media item gets play, save, edit", () => {
|
||||
expect(sharedRowActions(job({}, {}))).toEqual({ play: true, save: true, edit: true });
|
||||
});
|
||||
|
||||
it("nothing is offered when the owner disabled download", () => {
|
||||
expect(sharedRowActions(job({ can_download: false }, {}))).toEqual({
|
||||
play: false,
|
||||
save: false,
|
||||
edit: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("a shared generic file gets only save (no player/trim)", () => {
|
||||
const a = sharedRowActions(job({}, { media_kind: "file", duration_s: null }));
|
||||
expect(a).toEqual({ play: false, save: true, edit: false });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { DownloadJob } from "./api";
|
||||
|
||||
// Which row actions apply to a completed library / shared item — gated on the stored media_kind and
|
||||
// job_kind (E7·S5b). A generic uploaded file (media_kind='file') is download + share only: no player,
|
||||
// no trim, no details edit. Re-download needs a re-fetchable source, which an edit clip and an upload
|
||||
// both lack (the backend 409s both). A single source for the gate the Library + Shared rows render,
|
||||
// so it's unit-testable instead of buried in JSX conditionals — and the two tabs can't drift apart.
|
||||
|
||||
const isMediaKind = (job: DownloadJob): boolean => job.asset?.media_kind !== "file";
|
||||
const hasDuration = (job: DownloadJob): boolean => (job.asset?.duration_s ?? 0) > 0;
|
||||
|
||||
export interface LibraryRowActions {
|
||||
play: boolean;
|
||||
edit: boolean;
|
||||
editDetails: boolean;
|
||||
redownload: boolean;
|
||||
}
|
||||
|
||||
export function libraryRowActions(job: DownloadJob): LibraryRowActions {
|
||||
const media = isMediaKind(job);
|
||||
return {
|
||||
play: media,
|
||||
edit: media && job.can_download && hasDuration(job),
|
||||
editDetails: media,
|
||||
redownload: job.job_kind !== "edit" && job.job_kind !== "upload",
|
||||
};
|
||||
}
|
||||
|
||||
export interface SharedRowActions {
|
||||
play: boolean;
|
||||
save: boolean;
|
||||
edit: boolean;
|
||||
}
|
||||
|
||||
export function sharedRowActions(job: DownloadJob): SharedRowActions {
|
||||
const media = isMediaKind(job);
|
||||
// The owner's `can_download` gates save/play/edit on a shared item (it reflects their link ACL).
|
||||
return {
|
||||
play: media && job.can_download,
|
||||
save: job.can_download,
|
||||
edit: media && job.can_download && hasDuration(job),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// One-click share (E7 S3 · D2-A): quickShareLink turns the row's Share button into a single action —
|
||||
// ensure a public link (mint on demand, downloadable per D1), copy it, and toast with an "open in new
|
||||
// tab" action. These pin the branch matrix: reuse-vs-mint, copy-ok-vs-fail, and the create-failure path.
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
|
||||
// vi.mock is hoisted above these consts, so the mock fns must live in a hoisted block too.
|
||||
const { downloadLinks, createDownloadLink, copyText, notify } = vi.hoisted(() => ({
|
||||
downloadLinks: vi.fn(),
|
||||
createDownloadLink: vi.fn(),
|
||||
copyText: vi.fn(),
|
||||
notify: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./api", () => ({ api: { downloadLinks, createDownloadLink } }));
|
||||
vi.mock("./clipboard", () => ({ copyText }));
|
||||
vi.mock("./notifications", () => ({ notify }));
|
||||
|
||||
import { quickShareLink } from "./downloads";
|
||||
|
||||
const t = (k: string) => k;
|
||||
const origin = window.location.origin;
|
||||
|
||||
type ToastArg = {
|
||||
level: string;
|
||||
message: string;
|
||||
action?: { label: string; onClick: () => void };
|
||||
};
|
||||
const firstToast = () => notify.mock.calls[0]![0] as ToastArg;
|
||||
|
||||
function qc() {
|
||||
return new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
downloadLinks.mockReset();
|
||||
createDownloadLink.mockReset();
|
||||
copyText.mockReset().mockResolvedValue(true);
|
||||
notify.mockReset();
|
||||
});
|
||||
|
||||
describe("quickShareLink", () => {
|
||||
it("mints a downloadable link on demand, copies it, and toasts with an open-in-new-tab action", async () => {
|
||||
downloadLinks.mockResolvedValue([]); // none yet
|
||||
createDownloadLink.mockResolvedValue({ id: 7, url: "/watch/tok7" });
|
||||
const openSpy = vi.spyOn(window, "open").mockReturnValue(null);
|
||||
|
||||
const url = await quickShareLink(qc(), { id: 42 }, t);
|
||||
|
||||
expect(url).toBe(`${origin}/watch/tok7`);
|
||||
// Minted with an EMPTY body so the backend D1 default (allow_download=true) applies.
|
||||
expect(createDownloadLink).toHaveBeenCalledWith(42, {});
|
||||
expect(copyText).toHaveBeenCalledWith(`${origin}/watch/tok7`);
|
||||
const arg = firstToast();
|
||||
expect(arg.level).toBe("success");
|
||||
expect(arg.message).toBe("downloads.share.linkCopied");
|
||||
expect(arg.action!.label).toBe("downloads.share.openNewTab");
|
||||
arg.action!.onClick();
|
||||
expect(openSpy).toHaveBeenCalledWith(`${origin}/watch/tok7`, "_blank", "noopener,noreferrer");
|
||||
});
|
||||
|
||||
it("reuses an existing link instead of minting a duplicate", async () => {
|
||||
downloadLinks.mockResolvedValue([{ id: 3, url: "/watch/old" }]);
|
||||
|
||||
const url = await quickShareLink(qc(), { id: 1 }, t);
|
||||
|
||||
expect(url).toBe(`${origin}/watch/old`);
|
||||
expect(createDownloadLink).not.toHaveBeenCalled();
|
||||
expect(firstToast().level).toBe("success");
|
||||
});
|
||||
|
||||
it("still toasts (warning) with the open action when the clipboard copy fails", async () => {
|
||||
downloadLinks.mockResolvedValue([{ id: 5, url: "/watch/x" }]);
|
||||
copyText.mockResolvedValue(false);
|
||||
|
||||
const url = await quickShareLink(qc(), { id: 2 }, t);
|
||||
|
||||
expect(url).toBe(`${origin}/watch/x`); // the link is never a dead end
|
||||
const arg = firstToast();
|
||||
expect(arg.level).toBe("warning");
|
||||
expect(arg.message).toBe("downloads.share.copyFailed");
|
||||
expect(arg.action!.label).toBe("downloads.share.openNewTab");
|
||||
});
|
||||
|
||||
it("reports an error and returns null when link creation fails", async () => {
|
||||
downloadLinks.mockResolvedValue([]);
|
||||
createDownloadLink.mockRejectedValue(new Error("boom"));
|
||||
|
||||
const url = await quickShareLink(qc(), { id: 9 }, t);
|
||||
|
||||
expect(url).toBeNull();
|
||||
expect(copyText).not.toHaveBeenCalled();
|
||||
expect(notify).toHaveBeenCalledWith({ level: "error", message: "downloads.share.linkFailed" });
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import { qk } from "./queryKeys";
|
||||
import { api } from "./api";
|
||||
import { copyText } from "./clipboard";
|
||||
import { notify } from "./notifications";
|
||||
|
||||
// The download queue, the per-card download index, and the storage-usage meter all move together
|
||||
// whenever a job is enqueued, deleted, or its state changes — invalidate them as one unit.
|
||||
@@ -8,3 +11,42 @@ export function invalidateDownloads(qc: QueryClient) {
|
||||
qc.invalidateQueries({ queryKey: qk.downloadIndex() });
|
||||
qc.invalidateQueries({ queryKey: qk.downloadUsage() });
|
||||
}
|
||||
|
||||
// One-click share (E7 S3 · D2-A): the row's Share button turns "share this" into a single action.
|
||||
// Ensure the job has a public link (minting one on demand — omitting `allow_download` so the backend
|
||||
// D1 default makes it downloadable), copy it to the clipboard, and toast with an "open in new tab"
|
||||
// action. A copy failure still toasts (warning) WITH that action, so the link is never a dead end.
|
||||
// Advanced controls (password, expiry, revoke, share-with-a-user) live in the Share dialog. Returns
|
||||
// the shared URL, or null if link creation failed — so the caller/tests can react to the outcome.
|
||||
export async function quickShareLink(
|
||||
qc: QueryClient,
|
||||
job: { id: number },
|
||||
t: (key: string) => string,
|
||||
): Promise<string | null> {
|
||||
let link;
|
||||
try {
|
||||
const existing = await qc.fetchQuery({
|
||||
queryKey: qk.downloadLinks(job.id),
|
||||
queryFn: () => api.downloadLinks(job.id),
|
||||
});
|
||||
link = existing[0];
|
||||
if (!link) {
|
||||
link = await api.createDownloadLink(job.id, {}); // omit allow_download → backend D1 default (true)
|
||||
qc.setQueryData(qk.downloadLinks(job.id), [link]);
|
||||
}
|
||||
} catch {
|
||||
notify({ level: "error", message: t("downloads.share.linkFailed") });
|
||||
return null;
|
||||
}
|
||||
const url = window.location.origin + link.url;
|
||||
const action = {
|
||||
label: t("downloads.share.openNewTab"),
|
||||
onClick: () => window.open(url, "_blank", "noopener,noreferrer"),
|
||||
};
|
||||
notify(
|
||||
(await copyText(url))
|
||||
? { level: "success", message: t("downloads.share.linkCopied"), action }
|
||||
: { level: "warning", message: t("downloads.share.copyFailed"), action },
|
||||
);
|
||||
return url;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,25 @@ export interface 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",
|
||||
|
||||
Reference in New Issue
Block a user