From bdbff89507c9b09dd8aefd64619fa6cc076c3509 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 29 Aug 2026 05:21:54 +0200 Subject: [PATCH 01/13] feat(downloads): show container/format chip in library rows (E7 S1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The completed download rows already surfaced size + duration; add the concrete container (MP4/M4A/WebM…) as a compact chip so the library reads as a media list with a visible format — the audio-vs-video distinction the running-only phase pill can't show at rest. Pure display of the already-serialized asset.container; locked by a small DOM test (present when ready, absent before the worker fills it). --- .../src/components/downloadRow.dom.test.tsx | 87 +++++++++++++++++++ frontend/src/components/downloadRow.tsx | 5 ++ 2 files changed, 92 insertions(+) create mode 100644 frontend/src/components/downloadRow.dom.test.tsx diff --git a/frontend/src/components/downloadRow.dom.test.tsx b/frontend/src/components/downloadRow.dom.test.tsx new file mode 100644 index 00000000..be5d62b3 --- /dev/null +++ b/frontend/src/components/downloadRow.dom.test.tsx @@ -0,0 +1,87 @@ +// @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(); + 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): 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, + thumbnail_url: null, + expires_at: null, + }, +}); + +afterEach(cleanup); + +describe("DownloadRow format chip", () => { + it("shows the uppercased container next to the size when ready", () => { + render(); + // 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(); + expect(container.textContent).not.toMatch(/·\s*(mp4|m4a|webm)/i); + }); +}); diff --git a/frontend/src/components/downloadRow.tsx b/frontend/src/components/downloadRow.tsx index 53f00711..45a918f7 100644 --- a/frontend/src/components/downloadRow.tsx +++ b/frontend/src/components/downloadRow.tsx @@ -215,6 +215,11 @@ export function DownloadRow({
+ {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. + · {job.asset.container} + ) : null} {job.asset?.size_bytes ? ( · {formatBytes(job.asset.size_bytes)} ) : null} From bb6cb6e33521d3396151acb00539dacbe7988f81 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 29 Aug 2026 05:29:19 +0200 Subject: [PATCH 02/13] style(downloads): bump library-row meta text + action buttons a step (E7 S1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enlarge every non-title text on the card (channel, status/format/size/date meta, source-ref line, progress readout) one step (xs→sm) plus their inline icons, and grow the row action buttons (padding + icon) via the actions wrapper so the shared IconBtn stays smaller in the modals. Buttons keep the title's line — the wrapper is shrink-0/no-wrap, so the text column shrinks instead. --- frontend/src/components/downloadRow.tsx | 29 +++++++++++++++---------- 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/frontend/src/components/downloadRow.tsx b/frontend/src/components/downloadRow.tsx index 45a918f7..14b3dcca 100644 --- a/frontend/src/components/downloadRow.tsx +++ b/frontend/src/components/downloadRow.tsx @@ -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 ( - {t(`downloads.phase.${job.phase}`)} + {t(`downloads.phase.${job.phase}`)} ); } const key = job.expired ? "expired" : job.status; return ( - + {t(`downloads.status.${key}`)} ); @@ -145,8 +145,8 @@ function SourceRef({ url }: { url: string }) { } }; return ( -
- + ); @@ -173,7 +173,7 @@ function ChannelLine({ job }: { job: DownloadJob }) { const { name, url } = jobChannel(job); if (url) { return ( -
+
); } - return
{name}
; + return
{name}
; } export function DownloadRow({ @@ -206,14 +206,14 @@ export function DownloadRow({
{job.job_kind === "edit" && ( - + {t("downloads.clipBadge")} )} {jobTitle(job)}
-
+
{job.asset?.container ? ( // Concrete container (mp4 / m4a / webm …) — reads as the file format, and its audio-vs-video @@ -231,7 +231,7 @@ export function DownloadRow({ className="text-muted inline-flex items-center gap-1" title={new Date(job.created_at).toLocaleString()} > - · {relativeTime(job.created_at)} + · {relativeTime(job.created_at)} ) : null} {job.status === "error" ? ( @@ -264,7 +264,7 @@ export function DownloadRow({ style={{ width: `${job.progress}%` }} />
-
+
{job.progress}% {job.speed_bps ? {formatSpeed(job.speed_bps)} : null} {job.eta_s ? {formatEta(job.eta_s)} : null} @@ -274,7 +274,12 @@ export function DownloadRow({
)}
-
{children}
+ {/* 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. */} +
+ {children} +
); } From fb2c843fa965353c4e07a66e033116d19d3279e3 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 29 Aug 2026 05:43:41 +0200 Subject: [PATCH 03/13] fix(downloads): anchor crop overlay to the letterboxed picture (E7 S2) The video preview is object-fit: contain (the browser default for
+ {cropOn && picRect && ( +
{ + 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(); + }); +}); diff --git a/frontend/src/lib/cropGeometry.ts b/frontend/src/lib/cropGeometry.ts new file mode 100644 index 00000000..eebd268f --- /dev/null +++ b/frontend/src/lib/cropGeometry.ts @@ -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 }; +} From 94fcc40bde35b99e75c9648f86045fdd3d7ab27f Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 29 Aug 2026 06:08:21 +0200 Subject: [PATCH 04/13] feat(downloads): guarantee every download is browser-playable (mp4/H.264/AAC) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Download module's purpose is download -> share -> play in-browser, so the output must play in EVERY browser (iOS/Safari/WebKit included). The only universally-safe combo is mp4 + H.264 + AAC/MP3. - formats.normalize() now CLAMPS every spec to that profile (container->mp4, vcodec->None, audio_format opus->m4a) at the single point all paths flow through (picker, stored profile, raw API) — not just by hiding UI options. A source exposing no H.264 is still caught by the worker's browser-safe re-encode, so playability is guaranteed end-to-end. - build_ydl_opts drops the now-unreachable non-compat branches (+ _VCODEC_SORT). - _SIG_VERSION 3->4 so assets cached under an old non-compat spec — or a stale non-H.264 mp4 that slipped the v3 net (the HEVC Facebook dls) — re-derive. - ProfileEditor drops the container/codec pickers and Opus; audio is M4A/MP3. - i18n: remove the now-unused container/vcodec/any keys (HU+EN parity). --- backend/app/downloads/formats.py | 93 ++++++++++----------- backend/tests/test_formats.py | 42 +++++++--- frontend/src/components/ProfileEditor.tsx | 35 ++------ frontend/src/i18n/locales/en/downloads.json | 3 - frontend/src/i18n/locales/hu/downloads.json | 3 - 5 files changed, 83 insertions(+), 93 deletions(-) diff --git a/backend/app/downloads/formats.py b/backend/app/downloads/formats.py index 5ad7dce6..cf836462 100644 --- a/backend/app/downloads/formats.py +++ b/backend/app/downloads/formats.py @@ -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"]: diff --git a/backend/tests/test_formats.py b/backend/tests/test_formats.py index c3edcd1b..b5803d05 100644 --- a/backend/tests/test_formats.py +++ b/backend/tests/test_formats.py @@ -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(): diff --git a/frontend/src/components/ProfileEditor.tsx b/frontend/src/components/ProfileEditor.tsx index b043d5a2..7466b520 100644 --- a/frontend/src/components/ProfileEditor.tsx +++ b/frontend/src/components/ProfileEditor.tsx @@ -195,6 +195,8 @@ export default function ProfileEditor({ onClose }: { onClose: () => void }) { + {/* 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. */}
)} - {spec.mode !== "a" && ( - <> -
- - -
-
- - -
- - )} + {/* Container/codec are no longer user-selectable: every download is clamped to the + mp4/H.264/AAC compat profile (the only universally browser-playable combo) so a + shared link plays everywhere, iOS/Safari included. Enforced in backend `normalize`. */}
{spec.mode !== "a" && ( diff --git a/frontend/src/i18n/locales/en/downloads.json b/frontend/src/i18n/locales/en/downloads.json index 68762c63..8338f218 100644 --- a/frontend/src/i18n/locales/en/downloads.json +++ b/frontend/src/i18n/locales/en/downloads.json @@ -151,10 +151,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", diff --git a/frontend/src/i18n/locales/hu/downloads.json b/frontend/src/i18n/locales/hu/downloads.json index 37ac260e..b08efe38 100644 --- a/frontend/src/i18n/locales/hu/downloads.json +++ b/frontend/src/i18n/locales/hu/downloads.json @@ -151,10 +151,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", From fdca12968b21852b0dd11ac5866201a290d3991e Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 29 Aug 2026 06:36:07 +0200 Subject: [PATCH 05/13] feat(downloads): one-click Share + downloadable-by-default links (E7 S3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit D2-A: the library row's Share button becomes a single action — quickShareLink() ensures a public link (minting on demand), copies it to the clipboard, and toasts 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/ user-ACL) stay in the existing ShareDialog, reached via a new "Manage sharing" row button. D1 (forward-only): CreateLinkIn.allow_download defaults to true, so a newly minted link is downloadable by default. The default lives in the API, not a migration — the download_links column default stays false, so no existing link flips. The ShareDialog create-default is flipped to match. Tests: TestCreateLinkDefault (default true; explicit false honored); 4 vitest cases for quickShareLink (mint-vs-reuse, copy ok/fail, create failure). HU/EN parity. --- backend/app/routes/downloads.py | 7 +- backend/tests/test_links.py | 18 ++++ frontend/src/components/DownloadCenter.tsx | 26 +++++- frontend/src/components/ShareDialog.tsx | 8 +- frontend/src/i18n/locales/en/downloads.json | 6 +- frontend/src/i18n/locales/hu/downloads.json | 6 +- frontend/src/lib/downloads.test.ts | 97 +++++++++++++++++++++ frontend/src/lib/downloads.ts | 42 +++++++++ 8 files changed, 202 insertions(+), 8 deletions(-) create mode 100644 frontend/src/lib/downloads.test.ts diff --git a/backend/app/routes/downloads.py b/backend/app/routes/downloads.py index 950639c0..c4f5e231 100644 --- a/backend/app/routes/downloads.py +++ b/backend/app/routes/downloads.py @@ -823,7 +823,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 diff --git a/backend/tests/test_links.py b/backend/tests/test_links.py index 8a7aa55b..6d5907d1 100644 --- a/backend/tests/test_links.py +++ b/backend/tests/test_links.py @@ -93,6 +93,24 @@ class TestPublicMetaAudioFlag: assert meta["audio"] is False +class TestCreateLinkDefault: + """D1 (E7 S3): a newly minted share link is downloadable by default. The one-click Share relies on + this — it POSTs no `allow_download`, so the API default decides. Forward-only lives here (the API + default), NOT in a migration: the DownloadLink column default stays False, so no existing back- + catalogue link silently flips to downloadable.""" + + def test_omitted_allow_download_defaults_downloadable(self): + from app.routes.downloads import CreateLinkIn + + assert CreateLinkIn().allow_download is True + + def test_explicit_false_is_respected(self): + # The owner can still uncheck it to mint a stream-only link. + from app.routes.downloads import CreateLinkIn + + assert CreateLinkIn(allow_download=False).allow_download is False + + class TestIsExpired: def test_none_expiry_never_expires(self): assert is_expired(SimpleNamespace(expires_at=None)) is False diff --git a/frontend/src/components/DownloadCenter.tsx b/frontend/src/components/DownloadCenter.tsx index 46e5c9bb..c02dbdd1 100644 --- a/frontend/src/components/DownloadCenter.tsx +++ b/frontend/src/components/DownloadCenter.tsx @@ -6,6 +6,7 @@ import { Ban, ClipboardPaste, Download, + Link2, Pause, Play, Plus, @@ -29,7 +30,7 @@ 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 { inputCls } from "./ui/form"; import { Button } from "./ui/Button"; import { DownloadRow, IconBtn, UsageBar } from "./downloadRow"; @@ -101,6 +102,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(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 @@ -261,9 +277,15 @@ export default function DownloadCenter() { > - setShareJob(job)} title={t("downloads.actions.share")}> + quickShare(job)} title={t("downloads.actions.share")}> + setShareJob(job)} + title={t("downloads.actions.shareManage")} + > + + confirmThen( diff --git a/frontend/src/components/ShareDialog.tsx b/frontend/src/components/ShareDialog.tsx index 41e58e19..f8c72a27 100644 --- a/frontend/src/components/ShareDialog.tsx +++ b/frontend/src/components/ShareDialog.tsx @@ -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(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(() => { diff --git a/frontend/src/i18n/locales/en/downloads.json b/frontend/src/i18n/locales/en/downloads.json index 8338f218..198b7c6b 100644 --- a/frontend/src/i18n/locales/en/downloads.json +++ b/frontend/src/i18n/locales/en/downloads.json @@ -58,7 +58,8 @@ "download": "Save to my device", "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" @@ -104,6 +105,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", diff --git a/frontend/src/i18n/locales/hu/downloads.json b/frontend/src/i18n/locales/hu/downloads.json index b08efe38..583f3f01 100644 --- a/frontend/src/i18n/locales/hu/downloads.json +++ b/frontend/src/i18n/locales/hu/downloads.json @@ -58,7 +58,8 @@ "download": "Mentés a gépemre", "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" @@ -104,6 +105,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", diff --git a/frontend/src/lib/downloads.test.ts b/frontend/src/lib/downloads.test.ts new file mode 100644 index 00000000..b540cc4f --- /dev/null +++ b/frontend/src/lib/downloads.test.ts @@ -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" }); + }); +}); diff --git a/frontend/src/lib/downloads.ts b/frontend/src/lib/downloads.ts index 36dc6cb6..f5a39fb5 100644 --- a/frontend/src/lib/downloads.ts +++ b/frontend/src/lib/downloads.ts @@ -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 { + 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; +} From 9eaa01617c3eb91ddaa0c2de341344116fd6f7a9 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 29 Aug 2026 07:18:58 +0200 Subject: [PATCH 06/13] feat(downloads): in-app player for library items (E7 S4b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Play a completed library item in-app: a Play button on Library rows (and Shared rows with download access) opens a modal that streams the local file into a native
{library.map((job) => ( + setPlayJob(job)} title={t("downloads.actions.play")}> + + {saveBtn(job)} {job.can_download && (job.asset?.duration_s ?? 0) > 0 && ( setEditJob(job)} title={t("downloads.actions.edit")}> @@ -333,6 +339,11 @@ export default function DownloadCenter() {
{(sharedQ.data ?? []).map((job) => ( + {job.can_download && ( + setPlayJob(job)} title={t("downloads.actions.play")}> + + + )} {job.can_download && saveBtn(job)} {job.can_download && (job.asset?.duration_s ?? 0) > 0 && ( setEditJob(job)} title={t("downloads.actions.edit")}> @@ -377,6 +388,7 @@ export default function DownloadCenter() { {manage && setManage(false)} />} {metaJob && setMetaJob(null)} />} {shareJob && setShareJob(null)} />} + {playJob && setPlayJob(null)} />} {editJob && setEditJob(null)} />} {errorJob && ( ; an audio-only asset (m4a/mp3, no dims) renders
- {library.map((job) => ( - - setPlayJob(job)} title={t("downloads.actions.play")}> - - - {saveBtn(job)} - {job.can_download && (job.asset?.duration_s ?? 0) > 0 && ( - setEditJob(job)} title={t("downloads.actions.edit")}> - + {library.map((job) => { + // E7·S5b — gate row actions on the stored media_kind. A generic uploaded file + // (media_kind='file') is download+share only: no player and no timeline to trim + // (Play/Edit are media-only), and no metadata/channel edit. Re-download needs a + // re-fetchable source, which an edit clip and an upload both lack (backend rejects + // both), so it's a true-download-only action. + const isFile = job.asset?.media_kind === "file"; + const isMedia = !isFile; + const canRedownload = job.job_kind !== "edit" && job.job_kind !== "upload"; + return ( + + {isMedia && ( + setPlayJob(job)} title={t("downloads.actions.play")}> + + + )} + {saveBtn(job)} + {isMedia && job.can_download && (job.asset?.duration_s ?? 0) > 0 && ( + setEditJob(job)} title={t("downloads.actions.edit")}> + + + )} + {isMedia && ( + setMetaJob(job)} + title={t("downloads.actions.editDetails")} + > + + + )} + quickShare(job)} title={t("downloads.actions.share")}> + - )} - setMetaJob(job)} - title={t("downloads.actions.editDetails")} - > - - - quickShare(job)} title={t("downloads.actions.share")}> - - - setShareJob(job)} - title={t("downloads.actions.shareManage")} - > - - - - confirmThen( - t("downloads.confirm.redownloadTitle"), - t("downloads.confirm.redownloadBody"), - () => act.mutate({ id: job.id, op: "redownload" }), - ) - } - title={t("downloads.actions.redownload")} - > - - - - confirmThen( - t("downloads.confirm.deleteTitle"), - t("downloads.confirm.deleteBody"), - () => act.mutate({ id: job.id, op: "delete" }), - ) - } - title={t("downloads.actions.delete")} - danger - > - - - - ))} + setShareJob(job)} + title={t("downloads.actions.shareManage")} + > + + + {canRedownload && ( + + confirmThen( + t("downloads.confirm.redownloadTitle"), + t("downloads.confirm.redownloadBody"), + () => act.mutate({ id: job.id, op: "redownload" }), + ) + } + title={t("downloads.actions.redownload")} + > + + + )} + + confirmThen( + t("downloads.confirm.deleteTitle"), + t("downloads.confirm.deleteBody"), + () => act.mutate({ id: job.id, op: "delete" }), + ) + } + title={t("downloads.actions.delete")} + danger + > + + + + ); + })} {library.length === 0 && (jobsQ.isError && !jobsQ.data ? ( jobsQ.refetch()}> @@ -337,34 +353,39 @@ export default function DownloadCenter() { {tab === "shared" && (
- {(sharedQ.data ?? []).map((job) => ( - - {job.can_download && ( - setPlayJob(job)} title={t("downloads.actions.play")}> - + {(sharedQ.data ?? []).map((job) => { + // Same media_kind gate as the library (S5b): a shared generic file is download-only — + // no player, no trim. Its owner set can_download, which still gates save/edit here. + const isMedia = job.asset?.media_kind !== "file"; + return ( + + {isMedia && job.can_download && ( + setPlayJob(job)} title={t("downloads.actions.play")}> + + + )} + {job.can_download && saveBtn(job)} + {isMedia && job.can_download && (job.asset?.duration_s ?? 0) > 0 && ( + setEditJob(job)} title={t("downloads.actions.edit")}> + + + )} + + confirmThen( + t("downloads.confirm.removeSharedTitle"), + t("downloads.confirm.removeSharedBody"), + () => removeShared.mutate(job.id), + ) + } + title={t("downloads.actions.removeShared")} + danger + > + - )} - {job.can_download && saveBtn(job)} - {job.can_download && (job.asset?.duration_s ?? 0) > 0 && ( - setEditJob(job)} title={t("downloads.actions.edit")}> - - - )} - - confirmThen( - t("downloads.confirm.removeSharedTitle"), - t("downloads.confirm.removeSharedBody"), - () => removeShared.mutate(job.id), - ) - } - title={t("downloads.actions.removeShared")} - danger - > - - - - ))} + + ); + })} {sharedQ.data && sharedQ.data.length === 0 && (
{t("downloads.empty.shared")} diff --git a/frontend/src/components/LibraryPlayer.dom.test.tsx b/frontend/src/components/LibraryPlayer.dom.test.tsx index f7251ff2..0ca2fc96 100644 --- a/frontend/src/components/LibraryPlayer.dom.test.tsx +++ b/frontend/src/components/LibraryPlayer.dom.test.tsx @@ -65,6 +65,7 @@ const withAsset = (over: Partial>): DownloadJo width: null, height: null, container: null, + media_kind: "video", thumbnail_url: null, expires_at: null, ...over, diff --git a/frontend/src/components/downloadRow.dom.test.tsx b/frontend/src/components/downloadRow.dom.test.tsx index be5d62b3..611f81fa 100644 --- a/frontend/src/components/downloadRow.dom.test.tsx +++ b/frontend/src/components/downloadRow.dom.test.tsx @@ -51,7 +51,10 @@ const baseJob: DownloadJob = { asset: null, }; -const withAsset = (container: string | null): DownloadJob => ({ +const withAsset = ( + container: string | null, + over: Partial> = {}, +): DownloadJob => ({ ...baseJob, asset: { id: 10, @@ -65,8 +68,10 @@ const withAsset = (container: string | null): DownloadJob => ({ width: 1920, height: 1080, container, + media_kind: "video", thumbnail_url: null, expires_at: null, + ...over, }, }); @@ -85,3 +90,29 @@ describe("DownloadRow format chip", () => { 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 , and no duration chip (it stays media info +// like the container/size). Media rows keep their thumbnail path (an , or empty until a poster). +describe("DownloadRow file kind", () => { + it("renders a file glyph, not a thumbnail , for a generic file", () => { + const { container } = render( + , + ); + // The file glyph is an inline svg; no 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 when a poster exists) for a media asset", () => { + const { container } = render( + , + ); + expect(container.querySelector("img")).not.toBeNull(); + }); +}); diff --git a/frontend/src/components/downloadRow.tsx b/frontend/src/components/downloadRow.tsx index 14b3dcca..376b03db 100644 --- a/frontend/src/components/downloadRow.tsx +++ b/frontend/src/components/downloadRow.tsx @@ -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"; @@ -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 ( +
+
+ ); + } return (
{src ? ( diff --git a/frontend/src/lib/api/downloads.ts b/frontend/src/lib/api/downloads.ts index e53bf088..cec665cd 100644 --- a/frontend/src/lib/api/downloads.ts +++ b/frontend/src/lib/api/downloads.ts @@ -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). From 584e9ced0c96bbcf06919910af9f07ce5f5f06d1 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 29 Aug 2026 18:53:53 +0200 Subject: [PATCH 09/13] =?UTF-8?q?feat(downloads):=20local-upload=20UI=20?= =?UTF-8?q?=E2=80=94=20drag-drop=20dialog=20+=20progress=20(E7=20S5c)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UploadDialog (drag-drop + native picker + optional display name) opened from a new Upload button in the queue add-row. Uploads stream through a new XHR helper (uploadReq in api/core) so the dialog shows real 0-100% progress with a cancel — fetch/req can't report upload progress. The upload lands as an already-done job; the S5b file-kind row gates it to download+share. i18n HU/EN; upload cap/empty errors localize via the shared structured-detail path. --- frontend/src/components/DownloadCenter.tsx | 14 ++ .../src/components/UploadDialog.dom.test.tsx | 75 +++++++++ frontend/src/components/UploadDialog.tsx | 152 ++++++++++++++++++ frontend/src/i18n/locales/en/downloads.json | 16 ++ frontend/src/i18n/locales/hu/downloads.json | 16 ++ frontend/src/lib/api/core.ts | 69 ++++++++ frontend/src/lib/api/downloads.ts | 17 +- 7 files changed, 358 insertions(+), 1 deletion(-) create mode 100644 frontend/src/components/UploadDialog.dom.test.tsx create mode 100644 frontend/src/components/UploadDialog.tsx diff --git a/frontend/src/components/DownloadCenter.tsx b/frontend/src/components/DownloadCenter.tsx index 2ac0ad6d..0f2cb7b5 100644 --- a/frontend/src/components/DownloadCenter.tsx +++ b/frontend/src/components/DownloadCenter.tsx @@ -17,6 +17,7 @@ import { Share2, Pencil, Trash2, + UploadCloud, } from "lucide-react"; import Tabs from "./Tabs"; import { PageToolbar } from "./PageShell"; @@ -24,6 +25,7 @@ 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")); @@ -49,6 +51,7 @@ export default function DownloadCenter() { const [tab, setTab] = useState("queue"); const [addUrl, setAddUrl] = useState(""); const [addSource, setAddSource] = useState(null); + const [upload, setUpload] = useState(false); const [manage, setManage] = useState(false); const [metaJob, setMetaJob] = useState(null); const [shareJob, setShareJob] = useState(null); @@ -207,6 +210,16 @@ export default function DownloadCenter() { > {t("downloads.page.add")} + {/* Local upload is a peer to "add a URL": a general file → the same library (E7 S5c). */} +
{queue.map((job) => ( @@ -406,6 +419,7 @@ export default function DownloadCenter() { }} /> )} + {upload && setUpload(false)} />} {manage && setManage(false)} />} {metaJob && setMetaJob(null)} />} {shareJob && setShareJob(null)} />} diff --git a/frontend/src/components/UploadDialog.dom.test.tsx b/frontend/src/components/UploadDialog.dom.test.tsx new file mode 100644 index 00000000..4259b031 --- /dev/null +++ b/frontend/src/components/UploadDialog.dom.test.tsx @@ -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(); + 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( + + + , + ); +}; + +// 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); + }); +}); diff --git a/frontend/src/components/UploadDialog.tsx b/frontend/src/components/UploadDialog.tsx new file mode 100644 index 00000000..4edbdd85 --- /dev/null +++ b/frontend/src/components/UploadDialog.tsx @@ -0,0 +1,152 @@ +import { 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(null); + const [name, setName] = useState(""); + const [dragOver, setDragOver] = useState(false); + const [pct, setPct] = useState(null); // null = idle; 0–100 while uploading + const inputRef = useRef(null); + const abortRef = useRef(null); + const busy = pct !== null; + + 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 ( + +

{t("downloads.upload.hint")}

+ + {/* Drop zone doubles as the picker trigger — a click anywhere opens the native file dialog. */} + + { + const f = e.target.files?.[0]; + if (f) { + setFile(f); + setName(""); + } + e.target.value = ""; // let re-picking the same file fire onChange again + }} + /> + + + setName(e.target.value)} + placeholder={t("downloads.upload.namePlaceholder")} + disabled={busy} + className={`${inputCls} mb-5`} + /> + + {busy && ( +
+
+
+
+

+ {t("downloads.upload.uploading", { pct: pct ?? 0 })} +

+
+ )} + +
+ + +
+ + ); +} diff --git a/frontend/src/i18n/locales/en/downloads.json b/frontend/src/i18n/locales/en/downloads.json index b96178a8..06c7cd21 100644 --- a/frontend/src/i18n/locales/en/downloads.json +++ b/frontend/src/i18n/locales/en/downloads.json @@ -21,8 +21,20 @@ "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}}%", + "added": "File uploaded" + }, "tabs": { "queue": "Queue", "done": "Library", @@ -212,6 +224,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", diff --git a/frontend/src/i18n/locales/hu/downloads.json b/frontend/src/i18n/locales/hu/downloads.json index 98149116..c4e03813 100644 --- a/frontend/src/i18n/locales/hu/downloads.json +++ b/frontend/src/i18n/locales/hu/downloads.json @@ -21,8 +21,20 @@ "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}}%", + "added": "Fájl feltöltve" + }, "tabs": { "queue": "Sor", "done": "Könyvtár", @@ -212,6 +224,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", diff --git a/frontend/src/lib/api/core.ts b/frontend/src/lib/api/core.ts index 82e125f5..c3bf4e0a 100644 --- a/frontend/src/lib/api/core.ts +++ b/frontend/src/lib/api/core.ts @@ -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( 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( + url: string, + form: FormData, + cfg: { onProgress?: (pct: number) => void; signal?: AbortSignal } = {}, +): Promise { + return new Promise((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); + }); +} diff --git a/frontend/src/lib/api/downloads.ts b/frontend/src/lib/api/downloads.ts index cec665cd..ed8ca53c 100644 --- a/frontend/src/lib/api/downloads.ts +++ b/frontend/src/lib/api/downloads.ts @@ -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 @@ -183,6 +183,21 @@ export const downloadsApi = { spec?: DownloadSpec; display_name?: string; }): Promise => 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 => { + const form = new FormData(); + form.append("file", file); + if (opts.displayName) form.append("display_name", opts.displayName); + return uploadReq("/api/downloads/upload", form, { + onProgress: opts.onProgress, + signal: opts.signal, + }); + }, enqueueEdit: (body: { source_job_id: number; edit_spec: EditSpec; From 104bd1e3a6fdd7cd56b9719d702a4817a2753815 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 29 Aug 2026 19:51:58 +0200 Subject: [PATCH 10/13] feat(downloads): public generic-file watch surface (E7 S5d) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /watch/{token} SPA gets a third branch: media_kind==='file' renders an info card (name / TYPE · size) + a Download button, no player. The
)} - {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. */ +
+
+
+ +
+

+ {meta.title || T.file} +

+
+ {[meta.container?.toUpperCase(), formatBytes(meta.size_bytes)] + .filter(Boolean) + .join(" · ")} +
+ {meta.allow_download && ( + + {T.download} + + )} +
+
+ )} + + {status === "ready" && meta && meta.media_kind !== "file" && (
{meta.audio ? ( /* Audio-only asset (mp3): WebKit won't play it in a