From b4ad59274b7c15a8604314301c95ca6d6947f623 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 29 Aug 2026 18:38:21 +0200 Subject: [PATCH] feat(downloads): gate library row actions by media_kind (E7 S5b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surface the S5a media_kind discriminator in the library UI. A generic uploaded file (media_kind='file') is download+share only — no player and no timeline to trim, so Play/Edit/Edit-details are media-only; it shows a neutral file glyph instead of an (always-empty) thumbnail and no duration chip (container/size still read). The "Shared with me" tab's file row is gated the same way. Re-download is now hidden for edit clips and uploads too (neither has a re-fetchable source — the backend already rejects both with 409), removing a latent click-to-409 on edit rows. FE-only: the serializer already emits media_kind (S5a). Adds media_kind to the DownloadAsset type and "upload" to the job_kind union; DOM tests cover the file-row presentation (glyph, no img, no duration). --- frontend/src/components/DownloadCenter.tsx | 179 ++++++++++-------- .../src/components/LibraryPlayer.dom.test.tsx | 1 + .../src/components/downloadRow.dom.test.tsx | 33 +++- frontend/src/components/downloadRow.tsx | 11 +- frontend/src/lib/api/downloads.ts | 9 +- 5 files changed, 150 insertions(+), 83 deletions(-) diff --git a/frontend/src/components/DownloadCenter.tsx b/frontend/src/components/DownloadCenter.tsx index d9135406..2ac0ad6d 100644 --- a/frontend/src/components/DownloadCenter.tsx +++ b/frontend/src/components/DownloadCenter.tsx @@ -266,59 +266,75 @@ export default function DownloadCenter() { <>
- {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).