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).