feat(downloads): ad-hoc clips cut straight from a YouTube video or a Plex item
POST /api/downloads/clip takes a source (a YouTube id or a Plex rating key), a range of at most ten minutes and, for Plex, the audio track being heard. The clip becomes a per-user asset in the library like an editor clip, shareable as soon as it is ready. A YouTube clip fetches only its range (yt-dlp download_ranges: 3-7 s whatever the video's length); a Plex clip reads the library file in place. Both then go through one re-encode: frame-exact at the marks, H.264 at most 1080p with HDR tonemapped to SDR, stereo AAC, no chapter track, .mp4. Job kinds are a JobKind StrEnum now, and Re-download refuses every kind but a download.
This commit is contained in:
@@ -268,7 +268,7 @@ export function DownloadRow({
|
||||
<div
|
||||
className={`font-medium leading-snug break-words line-clamp-1 @max-[40rem]/dlrow:line-clamp-2`}
|
||||
>
|
||||
{job.job_kind === "edit" && (
|
||||
{(job.job_kind === "edit" || job.job_kind === "clip") && (
|
||||
<span className="mr-1.5 inline-flex items-center gap-1 px-1.5 py-0.5 align-middle rounded-sm text-2xs font-semibold uppercase tracking-wide bg-accent/15 text-accent">
|
||||
<Scissors className="w-3 h-3" /> {t("downloads.clipBadge")}
|
||||
</span>
|
||||
|
||||
@@ -59,6 +59,7 @@ const CANCEL_COPY: Record<DownloadJobKind, typeof CANCEL_DOWNLOAD> = {
|
||||
download: CANCEL_DOWNLOAD,
|
||||
upload: CANCEL_UPLOAD,
|
||||
edit: CANCEL_EDIT,
|
||||
clip: CANCEL_EDIT, // both are a clip being cut: the same words
|
||||
};
|
||||
// Deleting a download may leave its shared cache file for other holders. An upload and a clip are
|
||||
// their owner's alone (a clip's asset is keyed by its user), so deleting one removes the file for good.
|
||||
@@ -69,6 +70,7 @@ const DELETE_COPY: Record<DownloadJobKind, { title: string; body: string }> = {
|
||||
body: "downloads.confirm.deleteUploadBody",
|
||||
},
|
||||
edit: { title: "downloads.confirm.deleteEditTitle", body: "downloads.confirm.deleteEditBody" },
|
||||
clip: { title: "downloads.confirm.deleteEditTitle", body: "downloads.confirm.deleteEditBody" },
|
||||
};
|
||||
|
||||
export function useDownloadActions({
|
||||
|
||||
@@ -44,10 +44,10 @@ interface DownloadAsset {
|
||||
}
|
||||
|
||||
export type DownloadStatus = "queued" | "running" | "paused" | "done" | "error" | "canceled";
|
||||
// "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.
|
||||
export type DownloadJobKind = "download" | "edit" | "upload";
|
||||
// "download" (default), "edit" (a per-user trim/crop derivative cut from another job), "upload"
|
||||
// (a locally-uploaded file) or "clip" (a marked range of a YouTube video or a Plex item, cut straight
|
||||
// from the source). Only a download has a re-fetchable source; re-download refuses the rest.
|
||||
export type DownloadJobKind = "download" | "edit" | "upload" | "clip";
|
||||
|
||||
// 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).
|
||||
|
||||
@@ -52,6 +52,7 @@ describe("libraryRowActions", () => {
|
||||
|
||||
it("an edit clip cannot be re-downloaded", () => {
|
||||
expect(libraryRowActions(job({ job_kind: "edit" }, {})).redownload).toBe(false);
|
||||
expect(libraryRowActions(job({ job_kind: "clip" }, {})).redownload).toBe(false);
|
||||
});
|
||||
|
||||
it("edit is hidden when the media has no duration", () => {
|
||||
|
||||
@@ -22,7 +22,7 @@ export function libraryRowActions(job: DownloadJob): LibraryRowActions {
|
||||
play: media,
|
||||
edit: media && job.can_download && hasDuration(job),
|
||||
editDetails: media,
|
||||
redownload: job.job_kind !== "edit" && job.job_kind !== "upload",
|
||||
redownload: !job.job_kind || job.job_kind === "download",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@ describe("failTitleKey", () => {
|
||||
await i18n.changeLanguage("en");
|
||||
expect(failTitleKey({ job_kind: "upload" })).toBe("downloads.errors.fail.titleUpload");
|
||||
expect(failTitleKey({ job_kind: "edit" })).toBe("downloads.errors.fail.titleEdit");
|
||||
expect(failTitleKey({ job_kind: "clip" })).toBe("downloads.errors.fail.titleEdit");
|
||||
expect(failTitleKey({ job_kind: "download" })).toBe("downloads.errors.fail.title");
|
||||
expect(failTitleKey({})).toBe("downloads.errors.fail.title");
|
||||
for (const lng of ["en", "hu"]) {
|
||||
@@ -106,6 +107,15 @@ describe("failTitleKey", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("a clip from YouTube or Plex is worded as the editor's clip", () => {
|
||||
for (const part of ["short", "body"] as const)
|
||||
expect(
|
||||
failCopyKeys({ error_code: "unknown", error_detail: null, job_kind: "clip" }, part),
|
||||
).toEqual(
|
||||
failCopyKeys({ error_code: "unknown", error_detail: null, job_kind: "edit" }, part),
|
||||
);
|
||||
});
|
||||
|
||||
it("an upload's own failures read as an upload's", async () => {
|
||||
await i18n.changeLanguage("en");
|
||||
for (const code of ["convert_failed", "upload_missing"]) {
|
||||
|
||||
@@ -13,7 +13,8 @@ export function failCopyKeys(
|
||||
job: Pick<DownloadJob, "error_code" | "error_detail" | "job_kind">,
|
||||
part: "short" | "body",
|
||||
): string[] {
|
||||
const kind = job.job_kind ?? "download";
|
||||
// A clip from YouTube or Plex is worded as the editor's clip: both are "making the clip".
|
||||
const kind = job.job_kind === "clip" ? "edit" : (job.job_kind ?? "download");
|
||||
const category = (code: string) => [
|
||||
...(kind === "download" ? [] : [`downloads.errors.fail.${kind}.${code}.${part}`]),
|
||||
`downloads.errors.fail.cat.${code}.${part}`,
|
||||
@@ -30,6 +31,7 @@ const TITLE_KEYS: Record<DownloadJobKind, string> = {
|
||||
download: "downloads.errors.fail.title",
|
||||
upload: "downloads.errors.fail.titleUpload", // its failure is a conversion, not a download
|
||||
edit: "downloads.errors.fail.titleEdit", // a clip being cut
|
||||
clip: "downloads.errors.fail.titleEdit", // a clip being cut, from YouTube or Plex
|
||||
};
|
||||
|
||||
/** The title over a failed job's explanation, worded for what the job was making. */
|
||||
|
||||
Reference in New Issue
Block a user