feat(downloads): every ready file is phone-safe — uploads and clips too
A share link is opened on whatever the friend holds. Measured on an iPhone and an iPad: AV1, Matroska, HEVC tagged hev1 and MP3 inside an .mp4 fail there; Chrome and Firefox add HEVC and 10-bit / 4:4:4 H.264. The old check judged the codec NAME only and ran on downloads only, so a 10-bit H.264 passed, an upload went out exactly as it came (an iPhone's HEVC .mov, a webm, an mkv) and a fast trim kept its source's codecs. One rule now, `downloads/playable.py`: video is H.264 8-bit 4:2:0 + AAC in .mp4, audio-only is .mp3 or AAC .m4a; `plan` says what must change, `build_cmd` re-encodes only the failing stream (or remuxes). The worker applies it to a download, an editor clip and — new — an upload: media that fails the rule is queued (asset pending, job queued) and the worker converts it, keeping only the converted file (owner's choice). Until then the original sits at rel_path, so Retry starts from it and a delete removes it; an upload that lost its last holder mid-conversion is reaped by the GC. Every libx264 run (edit, concat, conversion) now pins `-pix_fmt yuv420p` — without it a 10-bit source came out High 10. The download cache signature moves to v5 so a cached 10-bit asset re-derives. The upload toast says when the file is still converting (HU/EN). Tests: the plan for every measured class; real ffmpeg turning a 10-bit Matroska + Opus and an mp3-in-mp4 into files the rule accepts (red without the pixel-format pin); the queued upload's delete, cancel-then-GC, failed conversion and real worker conversion (the cleanup tests red without their rules); the toast (red without the branch). Live on localdev: 9 fixture uploads converted, every served file probed H.264 High yuv420p + AAC, or mp3 / m4a.
This commit is contained in:
@@ -10,7 +10,7 @@ import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/re
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
const h = vi.hoisted(() => ({
|
||||
uploadDownload: vi.fn(async (_f: File, _o: unknown) => ({ id: 42 })),
|
||||
uploadDownload: vi.fn(async (_f: File, _o: unknown) => ({ id: 42, status: "done" })),
|
||||
// The dialog reads the per-file upload cap so it can refuse an over-cap file before spending the
|
||||
// user's bandwidth on it. Generous by default; the cap test below lowers it.
|
||||
downloadUsage: vi.fn(async () => ({ upload_max_bytes: 1_000_000 })),
|
||||
@@ -78,9 +78,25 @@ describe("UploadDialog", () => {
|
||||
expect(passedFile).toBe(f);
|
||||
expect((opts as { displayName?: string }).displayName).toBe("My notes");
|
||||
await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(1));
|
||||
expect(h.notify.mock.calls[0]![0]).toMatchObject({ message: "downloads.upload.added" });
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it("says so when the server queued the upload for its phone-safe conversion", async () => {
|
||||
// A video no phone could play as it is (an iPhone's HEVC .mov) comes back `queued`, not `done`:
|
||||
// "File uploaded" alone would send the user to a Library row that is still converting.
|
||||
h.uploadDownload.mockResolvedValueOnce({ id: 43, status: "queued" });
|
||||
wrap();
|
||||
fireEvent.change(fileInput(), {
|
||||
target: { files: [new File(["x"], "clip.mov", { type: "video/quicktime" })] },
|
||||
});
|
||||
fireEvent.click(screen.getByText("downloads.upload.submit"));
|
||||
await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(1));
|
||||
expect(h.notify.mock.calls[0]![0]).toMatchObject({
|
||||
message: "downloads.upload.addedConverting",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps Upload disabled until a file is chosen", () => {
|
||||
wrap();
|
||||
const btn = screen.getByText("downloads.upload.submit").closest("button")!;
|
||||
|
||||
@@ -19,7 +19,14 @@ import { Input } from "./ui/Input";
|
||||
// Local-file ingest. A general upload — ANY file type, no format list: media
|
||||
// (video/audio) joins the library with the full function set, a generic file is download-only. The
|
||||
// file is streamed to the server via XHR so this dialog can show real upload progress. On success it
|
||||
// lands in the Library as an already-`done` job, so we invalidate + point the user there.
|
||||
// lands in the Library as an already-`done` job — or, for media no phone could play as it is (an
|
||||
// iPhone's HEVC, a webm, an mkv), as a `queued` job the worker converts first — so we invalidate,
|
||||
// say which, and point the user there.
|
||||
// Out here, not inline in the upload handler: a `?:` inside its `try` makes the React Compiler
|
||||
// skip the whole dialog.
|
||||
const uploadedKey = (status: string) =>
|
||||
status === "done" ? "downloads.upload.added" : "downloads.upload.addedConverting";
|
||||
|
||||
export default function UploadDialog({ onClose }: { onClose: () => void }) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
@@ -73,7 +80,7 @@ export default function UploadDialog({ onClose }: { onClose: () => void }) {
|
||||
setPct(0);
|
||||
const displayName = name.trim() || undefined;
|
||||
try {
|
||||
await api.uploadDownload(file, {
|
||||
const job = await api.uploadDownload(file, {
|
||||
displayName,
|
||||
onProgress: setPct,
|
||||
signal: ac.signal,
|
||||
@@ -81,7 +88,7 @@ export default function UploadDialog({ onClose }: { onClose: () => void }) {
|
||||
invalidateDownloads(qc);
|
||||
notify({
|
||||
level: "success",
|
||||
message: t("downloads.upload.added"),
|
||||
message: t(uploadedKey(job.status)),
|
||||
action: { label: t("downloads.dialog.view"), onClick: () => navigateTo("downloads") },
|
||||
});
|
||||
onClose();
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
"uploading": "Uploading… {{pct}}%",
|
||||
"uploadProgress": "Upload progress",
|
||||
"added": "File uploaded",
|
||||
"addedConverting": "File uploaded — converting it so it plays on any phone",
|
||||
"failed": "Couldn't upload the file"
|
||||
},
|
||||
"tabs": {
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
"uploading": "Feltöltés… {{pct}}%",
|
||||
"uploadProgress": "Feltöltés folyamata",
|
||||
"added": "Fájl feltöltve",
|
||||
"addedConverting": "Fájl feltöltve — átalakítjuk, hogy minden telefonon lejátszható legyen",
|
||||
"failed": "Nem sikerült feltölteni a fájlt"
|
||||
},
|
||||
"tabs": {
|
||||
|
||||
@@ -196,7 +196,8 @@ export const downloadsApi = {
|
||||
}): Promise<DownloadJob> => req("/api/downloads", { method: "POST", body: JSON.stringify(body) }),
|
||||
// Ingest a local file into the library. Sent via XHR rather than `req` so the dialog can
|
||||
// show upload progress; any file type is accepted (media gets the full function set, a generic
|
||||
// file is download-only). Returns the already-`done` job the ingest creates.
|
||||
// file is download-only). Returns the job the ingest creates: `done`, or `queued` while the
|
||||
// worker converts media no phone could play as it is (`downloads/upload.py`).
|
||||
// The body IS the file, and the two names ride as headers. Not a style choice: a multipart part is
|
||||
// a DECLARED body, which FastAPI reads and spools BEFORE the route's `current_user` dependency
|
||||
// runs — so with the old shape an unauthenticated caller could make the server write the whole
|
||||
|
||||
Reference in New Issue
Block a user