feat(downloads): gate library row actions by media_kind (E7 S5b)

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).
This commit is contained in:
2026-08-29 18:38:21 +02:00
parent fe0b34cd00
commit b4ad59274b
5 changed files with 150 additions and 83 deletions
+100 -79
View File
@@ -266,59 +266,75 @@ export default function DownloadCenter() {
<>
<UsageBar />
<div className="space-y-2">
{library.map((job) => (
<DownloadRow key={job.id} job={job}>
<IconBtn onClick={() => setPlayJob(job)} title={t("downloads.actions.play")}>
<PlayCircle className="w-4 h-4" />
</IconBtn>
{saveBtn(job)}
{job.can_download && (job.asset?.duration_s ?? 0) > 0 && (
<IconBtn onClick={() => setEditJob(job)} title={t("downloads.actions.edit")}>
<Scissors className="w-4 h-4" />
{library.map((job) => {
// 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 (
<DownloadRow key={job.id} job={job}>
{isMedia && (
<IconBtn onClick={() => setPlayJob(job)} title={t("downloads.actions.play")}>
<PlayCircle className="w-4 h-4" />
</IconBtn>
)}
{saveBtn(job)}
{isMedia && job.can_download && (job.asset?.duration_s ?? 0) > 0 && (
<IconBtn onClick={() => setEditJob(job)} title={t("downloads.actions.edit")}>
<Scissors className="w-4 h-4" />
</IconBtn>
)}
{isMedia && (
<IconBtn
onClick={() => setMetaJob(job)}
title={t("downloads.actions.editDetails")}
>
<Pencil className="w-4 h-4" />
</IconBtn>
)}
<IconBtn onClick={() => quickShare(job)} title={t("downloads.actions.share")}>
<Share2 className="w-4 h-4" />
</IconBtn>
)}
<IconBtn
onClick={() => setMetaJob(job)}
title={t("downloads.actions.editDetails")}
>
<Pencil className="w-4 h-4" />
</IconBtn>
<IconBtn onClick={() => quickShare(job)} title={t("downloads.actions.share")}>
<Share2 className="w-4 h-4" />
</IconBtn>
<IconBtn
onClick={() => setShareJob(job)}
title={t("downloads.actions.shareManage")}
>
<Link2 className="w-4 h-4" />
</IconBtn>
<IconBtn
onClick={() =>
confirmThen(
t("downloads.confirm.redownloadTitle"),
t("downloads.confirm.redownloadBody"),
() => act.mutate({ id: job.id, op: "redownload" }),
)
}
title={t("downloads.actions.redownload")}
>
<RotateCcw className="w-4 h-4" />
</IconBtn>
<IconBtn
onClick={() =>
confirmThen(
t("downloads.confirm.deleteTitle"),
t("downloads.confirm.deleteBody"),
() => act.mutate({ id: job.id, op: "delete" }),
)
}
title={t("downloads.actions.delete")}
danger
>
<Trash2 className="w-4 h-4" />
</IconBtn>
</DownloadRow>
))}
<IconBtn
onClick={() => setShareJob(job)}
title={t("downloads.actions.shareManage")}
>
<Link2 className="w-4 h-4" />
</IconBtn>
{canRedownload && (
<IconBtn
onClick={() =>
confirmThen(
t("downloads.confirm.redownloadTitle"),
t("downloads.confirm.redownloadBody"),
() => act.mutate({ id: job.id, op: "redownload" }),
)
}
title={t("downloads.actions.redownload")}
>
<RotateCcw className="w-4 h-4" />
</IconBtn>
)}
<IconBtn
onClick={() =>
confirmThen(
t("downloads.confirm.deleteTitle"),
t("downloads.confirm.deleteBody"),
() => act.mutate({ id: job.id, op: "delete" }),
)
}
title={t("downloads.actions.delete")}
danger
>
<Trash2 className="w-4 h-4" />
</IconBtn>
</DownloadRow>
);
})}
{library.length === 0 &&
(jobsQ.isError && !jobsQ.data ? (
<StateMessage tone="error" onRetry={() => jobsQ.refetch()}>
@@ -337,34 +353,39 @@ export default function DownloadCenter() {
{tab === "shared" && (
<div className="space-y-2">
{(sharedQ.data ?? []).map((job) => (
<DownloadRow key={job.id} job={job}>
{job.can_download && (
<IconBtn onClick={() => setPlayJob(job)} title={t("downloads.actions.play")}>
<PlayCircle className="w-4 h-4" />
{(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 (
<DownloadRow key={job.id} job={job}>
{isMedia && job.can_download && (
<IconBtn onClick={() => setPlayJob(job)} title={t("downloads.actions.play")}>
<PlayCircle className="w-4 h-4" />
</IconBtn>
)}
{job.can_download && saveBtn(job)}
{isMedia && job.can_download && (job.asset?.duration_s ?? 0) > 0 && (
<IconBtn onClick={() => setEditJob(job)} title={t("downloads.actions.edit")}>
<Scissors className="w-4 h-4" />
</IconBtn>
)}
<IconBtn
onClick={() =>
confirmThen(
t("downloads.confirm.removeSharedTitle"),
t("downloads.confirm.removeSharedBody"),
() => removeShared.mutate(job.id),
)
}
title={t("downloads.actions.removeShared")}
danger
>
<Trash2 className="w-4 h-4" />
</IconBtn>
)}
{job.can_download && saveBtn(job)}
{job.can_download && (job.asset?.duration_s ?? 0) > 0 && (
<IconBtn onClick={() => setEditJob(job)} title={t("downloads.actions.edit")}>
<Scissors className="w-4 h-4" />
</IconBtn>
)}
<IconBtn
onClick={() =>
confirmThen(
t("downloads.confirm.removeSharedTitle"),
t("downloads.confirm.removeSharedBody"),
() => removeShared.mutate(job.id),
)
}
title={t("downloads.actions.removeShared")}
danger
>
<Trash2 className="w-4 h-4" />
</IconBtn>
</DownloadRow>
))}
</DownloadRow>
);
})}
{sharedQ.data && sharedQ.data.length === 0 && (
<div className="text-sm text-muted py-10 text-center">
{t("downloads.empty.shared")}
@@ -65,6 +65,7 @@ const withAsset = (over: Partial<NonNullable<DownloadJob["asset"]>>): DownloadJo
width: null,
height: null,
container: null,
media_kind: "video",
thumbnail_url: null,
expires_at: null,
...over,
@@ -51,7 +51,10 @@ const baseJob: DownloadJob = {
asset: null,
};
const withAsset = (container: string | null): DownloadJob => ({
const withAsset = (
container: string | null,
over: Partial<NonNullable<DownloadJob["asset"]>> = {},
): 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 <img>, and no duration chip (it stays media info
// like the container/size). Media rows keep their thumbnail path (an <img>, or empty until a poster).
describe("DownloadRow file kind", () => {
it("renders a file glyph, not a thumbnail <img>, for a generic file", () => {
const { container } = render(
<DownloadRow
job={withAsset("pdf", { media_kind: "file", duration_s: null, size_bytes: 2_400_000 })}
/>,
);
// The file glyph is an inline svg; no <img> element is emitted for a file.
expect(container.querySelector("img")).toBeNull();
expect(container.querySelector("svg")).not.toBeNull();
// File info still reads (container + size), but there is no duration chip.
expect(screen.getByText(/·\s*pdf/i)).toBeTruthy();
expect(screen.getByText(/2\.3 MB/)).toBeTruthy();
});
it("keeps the thumbnail path (an <img> when a poster exists) for a media asset", () => {
const { container } = render(
<DownloadRow job={{ ...withAsset("mp4"), poster_url: "/api/downloads/1/poster.jpg" }} />,
);
expect(container.querySelector("img")).not.toBeNull();
});
});
+10 -1
View File
@@ -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 (
<div className="w-28 aspect-video shrink-0 rounded-lg overflow-hidden bg-surface border border-border flex items-center justify-center text-muted">
<File className="w-8 h-8" aria-hidden="true" />
</div>
);
}
return (
<div className="w-28 aspect-video shrink-0 rounded-lg overflow-hidden bg-surface border border-border">
{src ? (
+7 -2
View File
@@ -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).