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:
@@ -266,59 +266,75 @@ export default function DownloadCenter() {
|
|||||||
<>
|
<>
|
||||||
<UsageBar />
|
<UsageBar />
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{library.map((job) => (
|
{library.map((job) => {
|
||||||
<DownloadRow key={job.id} job={job}>
|
// E7·S5b — gate row actions on the stored media_kind. A generic uploaded file
|
||||||
<IconBtn onClick={() => setPlayJob(job)} title={t("downloads.actions.play")}>
|
// (media_kind='file') is download+share only: no player and no timeline to trim
|
||||||
<PlayCircle className="w-4 h-4" />
|
// (Play/Edit are media-only), and no metadata/channel edit. Re-download needs a
|
||||||
</IconBtn>
|
// re-fetchable source, which an edit clip and an upload both lack (backend rejects
|
||||||
{saveBtn(job)}
|
// both), so it's a true-download-only action.
|
||||||
{job.can_download && (job.asset?.duration_s ?? 0) > 0 && (
|
const isFile = job.asset?.media_kind === "file";
|
||||||
<IconBtn onClick={() => setEditJob(job)} title={t("downloads.actions.edit")}>
|
const isMedia = !isFile;
|
||||||
<Scissors className="w-4 h-4" />
|
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>
|
||||||
)}
|
<IconBtn
|
||||||
<IconBtn
|
onClick={() => setShareJob(job)}
|
||||||
onClick={() => setMetaJob(job)}
|
title={t("downloads.actions.shareManage")}
|
||||||
title={t("downloads.actions.editDetails")}
|
>
|
||||||
>
|
<Link2 className="w-4 h-4" />
|
||||||
<Pencil className="w-4 h-4" />
|
</IconBtn>
|
||||||
</IconBtn>
|
{canRedownload && (
|
||||||
<IconBtn onClick={() => quickShare(job)} title={t("downloads.actions.share")}>
|
<IconBtn
|
||||||
<Share2 className="w-4 h-4" />
|
onClick={() =>
|
||||||
</IconBtn>
|
confirmThen(
|
||||||
<IconBtn
|
t("downloads.confirm.redownloadTitle"),
|
||||||
onClick={() => setShareJob(job)}
|
t("downloads.confirm.redownloadBody"),
|
||||||
title={t("downloads.actions.shareManage")}
|
() => act.mutate({ id: job.id, op: "redownload" }),
|
||||||
>
|
)
|
||||||
<Link2 className="w-4 h-4" />
|
}
|
||||||
</IconBtn>
|
title={t("downloads.actions.redownload")}
|
||||||
<IconBtn
|
>
|
||||||
onClick={() =>
|
<RotateCcw className="w-4 h-4" />
|
||||||
confirmThen(
|
</IconBtn>
|
||||||
t("downloads.confirm.redownloadTitle"),
|
)}
|
||||||
t("downloads.confirm.redownloadBody"),
|
<IconBtn
|
||||||
() => act.mutate({ id: job.id, op: "redownload" }),
|
onClick={() =>
|
||||||
)
|
confirmThen(
|
||||||
}
|
t("downloads.confirm.deleteTitle"),
|
||||||
title={t("downloads.actions.redownload")}
|
t("downloads.confirm.deleteBody"),
|
||||||
>
|
() => act.mutate({ id: job.id, op: "delete" }),
|
||||||
<RotateCcw className="w-4 h-4" />
|
)
|
||||||
</IconBtn>
|
}
|
||||||
<IconBtn
|
title={t("downloads.actions.delete")}
|
||||||
onClick={() =>
|
danger
|
||||||
confirmThen(
|
>
|
||||||
t("downloads.confirm.deleteTitle"),
|
<Trash2 className="w-4 h-4" />
|
||||||
t("downloads.confirm.deleteBody"),
|
</IconBtn>
|
||||||
() => act.mutate({ id: job.id, op: "delete" }),
|
</DownloadRow>
|
||||||
)
|
);
|
||||||
}
|
})}
|
||||||
title={t("downloads.actions.delete")}
|
|
||||||
danger
|
|
||||||
>
|
|
||||||
<Trash2 className="w-4 h-4" />
|
|
||||||
</IconBtn>
|
|
||||||
</DownloadRow>
|
|
||||||
))}
|
|
||||||
{library.length === 0 &&
|
{library.length === 0 &&
|
||||||
(jobsQ.isError && !jobsQ.data ? (
|
(jobsQ.isError && !jobsQ.data ? (
|
||||||
<StateMessage tone="error" onRetry={() => jobsQ.refetch()}>
|
<StateMessage tone="error" onRetry={() => jobsQ.refetch()}>
|
||||||
@@ -337,34 +353,39 @@ export default function DownloadCenter() {
|
|||||||
|
|
||||||
{tab === "shared" && (
|
{tab === "shared" && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{(sharedQ.data ?? []).map((job) => (
|
{(sharedQ.data ?? []).map((job) => {
|
||||||
<DownloadRow key={job.id} job={job}>
|
// Same media_kind gate as the library (S5b): a shared generic file is download-only —
|
||||||
{job.can_download && (
|
// no player, no trim. Its owner set can_download, which still gates save/edit here.
|
||||||
<IconBtn onClick={() => setPlayJob(job)} title={t("downloads.actions.play")}>
|
const isMedia = job.asset?.media_kind !== "file";
|
||||||
<PlayCircle className="w-4 h-4" />
|
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>
|
</IconBtn>
|
||||||
)}
|
</DownloadRow>
|
||||||
{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>
|
|
||||||
))}
|
|
||||||
{sharedQ.data && sharedQ.data.length === 0 && (
|
{sharedQ.data && sharedQ.data.length === 0 && (
|
||||||
<div className="text-sm text-muted py-10 text-center">
|
<div className="text-sm text-muted py-10 text-center">
|
||||||
{t("downloads.empty.shared")}
|
{t("downloads.empty.shared")}
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ const withAsset = (over: Partial<NonNullable<DownloadJob["asset"]>>): DownloadJo
|
|||||||
width: null,
|
width: null,
|
||||||
height: null,
|
height: null,
|
||||||
container: null,
|
container: null,
|
||||||
|
media_kind: "video",
|
||||||
thumbnail_url: null,
|
thumbnail_url: null,
|
||||||
expires_at: null,
|
expires_at: null,
|
||||||
...over,
|
...over,
|
||||||
|
|||||||
@@ -51,7 +51,10 @@ const baseJob: DownloadJob = {
|
|||||||
asset: null,
|
asset: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const withAsset = (container: string | null): DownloadJob => ({
|
const withAsset = (
|
||||||
|
container: string | null,
|
||||||
|
over: Partial<NonNullable<DownloadJob["asset"]>> = {},
|
||||||
|
): DownloadJob => ({
|
||||||
...baseJob,
|
...baseJob,
|
||||||
asset: {
|
asset: {
|
||||||
id: 10,
|
id: 10,
|
||||||
@@ -65,8 +68,10 @@ const withAsset = (container: string | null): DownloadJob => ({
|
|||||||
width: 1920,
|
width: 1920,
|
||||||
height: 1080,
|
height: 1080,
|
||||||
container,
|
container,
|
||||||
|
media_kind: "video",
|
||||||
thumbnail_url: null,
|
thumbnail_url: null,
|
||||||
expires_at: null,
|
expires_at: null,
|
||||||
|
...over,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -85,3 +90,29 @@ describe("DownloadRow format chip", () => {
|
|||||||
expect(container.textContent).not.toMatch(/·\s*(mp4|m4a|webm)/i);
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState, type ReactNode } from "react";
|
import { useEffect, useState, type ReactNode } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
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 clsx from "clsx";
|
||||||
import { qk } from "../lib/queryKeys";
|
import { qk } from "../lib/queryKeys";
|
||||||
import { useLiveQuery } from "../lib/useLiveQuery";
|
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.
|
// On load failure (a genuinely missing poster file) fall back to the remote source thumbnail.
|
||||||
const [src, setSrc] = useState(primary);
|
const [src, setSrc] = useState(primary);
|
||||||
useEffect(() => setSrc(primary), [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 (
|
return (
|
||||||
<div className="w-28 aspect-video shrink-0 rounded-lg overflow-hidden bg-surface border border-border">
|
<div className="w-28 aspect-video shrink-0 rounded-lg overflow-hidden bg-surface border border-border">
|
||||||
{src ? (
|
{src ? (
|
||||||
|
|||||||
@@ -35,14 +35,19 @@ interface DownloadAsset {
|
|||||||
width: number | null;
|
width: number | null;
|
||||||
height: number | null;
|
height: number | null;
|
||||||
container: string | 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;
|
thumbnail_url: string | null;
|
||||||
expires_at: string | null;
|
expires_at: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DownloadStatus = "queued" | "running" | "paused" | "done" | "error" | "canceled";
|
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.
|
// 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
|
// 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).
|
// one file. `accurate` re-encodes for a frame-accurate cut (a crop always re-encodes).
|
||||||
|
|||||||
Reference in New Issue
Block a user