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