Equicord/src/equicordplugins/clipUpload.desktop/upload.ts
Quadrinex 4d593cf710
Fixing <#1513197912660906035> on my own plugin. (#1163)
* Fixing <#1513197912660906035> on my own plugin.

* #1163: Update src/equicordplugins/clipUpload.desktop/ffmpeg.ts

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* #1163: Update src/equicordplugins/clipUpload.desktop/upload.ts

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* #1163: Update src/equicordplugins/clipUpload.desktop/upload.ts

* chore(clipUpload): document unavoidable path exposure

Added documentation to  in the main process and  in the renderer. This clarifies that exposing the absolute filesystem path is necessary because Discord's  is a renderer-only module and requires an absolute path to function.

Co-authored-by: Gemini <198982749+gemini-code-assist@users.noreply.github.com>

* Conflict Fix

* [autofix.ci] apply automated fixes

* Fix Missing upper-bound size check on renderer-supplied bytes

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: Gemini <198982749+gemini-code-assist@users.noreply.github.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-06-10 07:23:32 -04:00

361 lines
12 KiB
TypeScript

/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { Logger } from "@utils/Logger";
import { isObject } from "@utils/misc";
import type { PluginNative } from "@utils/types";
import type { User } from "@vencord/discord-types";
import { Constants, MediaEngineStore, RestAPI, showToast, SnowflakeUtils, Toasts } from "@webpack/common";
import { convertClipToMp4 } from "./ffmpeg";
const logger = new Logger("ClipUpload");
const Native = VencordNative.pluginHelpers.ClipUpload as PluginNative<typeof import("./native")>;
const defaultFileName = "clip.mp4";
const compatibleExtensions = [".mp4", ".m4v"];
const uploadAbortControllers = new Set<AbortController>();
export interface ClipMetadata {
name?: string;
applicationId?: unknown;
createdAt?: number | string | Date;
users?: User[];
remoteClipId?: string | number;
eventsTimeline?: unknown;
}
export interface ClipUploadOptions {
fileName: string;
participants: string[];
title: string;
spoiler: boolean;
remix: boolean;
thumbnail: boolean;
createdAt: string;
message: string;
channelId: string;
applicationId?: string;
remoteClipId?: string;
eventsTimeline?: unknown;
}
interface AttachmentUploadResponse {
attachments?: Array<{
upload_url: string;
upload_filename: string;
}>;
}
interface RestResponse {
ok?: boolean;
body?: unknown;
text?: string;
}
interface ClipMetadataUpdater {
updateClipMetadata(clipId: string, metadata: string): Promise<void>;
}
export function getString(value: unknown) {
return typeof value === "string" ? value : undefined;
}
export function abortActiveClipUploads() {
for (const controller of uploadAbortControllers) {
controller.abort();
}
uploadAbortControllers.clear();
}
export function getClipTitleFromName(name: string) {
const extensionStart = name.lastIndexOf(".");
return extensionStart > 0 ? name.slice(0, extensionStart) : name;
}
export function getDefaultClipTitle(clip?: ClipMetadata | null) {
return typeof clip?.name === "string" ? clip.name.trim() : "";
}
export function getClipCreatedAt(clip?: ClipMetadata | null) {
const { createdAt } = clip ?? {};
if (createdAt instanceof Date) return createdAt.toISOString();
if (typeof createdAt === "number") return new Date(createdAt).toISOString();
if (typeof createdAt === "string") {
const date = new Date(createdAt);
if (!Number.isNaN(date.getTime())) return date.toISOString();
}
return new Date().toISOString();
}
export function getParticipantIds(clip?: ClipMetadata | null): string[] {
if (!Array.isArray(clip?.users)) return [];
return clip.users.flatMap(user => {
if (typeof user === "string") return user;
if (isObject(user) && typeof user.id === "string") return user.id;
return [];
});
}
export function isValidDate(value: string) {
return Boolean(value.trim()) && !Number.isNaN(new Date(value).getTime());
}
export function getDefaultFileName() {
return defaultFileName;
}
function getFileExtension(fileName: string) {
return fileName.match(/\.[a-z0-9]+$/i)?.[0].toLowerCase() ?? ".mp4";
}
function getTempVideoFileName(fileName: string) {
if (compatibleExtensions.includes(getFileExtension(fileName))) return fileName;
return `${getClipTitleFromName(fileName) || defaultFileName}.mp4`;
}
async function readStampedVideoFile(token: string, name: string, type: string) {
const tmpToken = await Native.createTempVideoFile(token);
if (!tmpToken) throw new Error("Couldn't prepare the selected file.");
try {
await updateTempVideoMetadata(tmpToken);
const data = await Native.readVideoFile(tmpToken);
if (!data) throw new Error("Couldn't read the selected file.");
return new File([new Uint8Array(data)], name, { type });
} finally {
await Native.deleteTempVideoFile(tmpToken);
}
}
async function updateTempVideoMetadata(tmpToken: string) {
// We need the absolute path here because Discord's MediaEngineStore requires it
// and it is a renderer-only module, so this cannot be done in the main process.
const tmpPath = await Native.getTempVideoFilePath(tmpToken);
if (!tmpPath) throw new Error("Couldn't prepare the selected file.");
const mediaEngine = MediaEngineStore.getMediaEngine();
if (!isClipMetadataUpdater(mediaEngine)) throw new Error("Couldn't update clip metadata.");
await mediaEngine.updateClipMetadata(tmpPath, "{}");
}
async function stampVideoFile(file: File, fileName: string) {
const tmpToken = await Native.createTempVideoFileFromBytes(getTempVideoFileName(fileName), new Uint8Array(await file.arrayBuffer()));
if (!tmpToken) throw new Error("Couldn't prepare the selected file.");
try {
await updateTempVideoMetadata(tmpToken);
const data = await Native.readVideoFile(tmpToken);
if (!data) throw new Error("Couldn't read the selected file.");
return new File([new Uint8Array(data)], fileName, { type: "video/mp4" });
} finally {
await Native.deleteTempVideoFile(tmpToken);
}
}
function isClipMetadataUpdater(value: unknown): value is ClipMetadataUpdater {
return isObject(value) && typeof (value as { updateClipMetadata?: unknown; }).updateClipMetadata === "function";
}
export interface ParsedClipMetadata {
id?: string;
applicationId?: string;
applicationName?: string;
users?: string[];
version?: number;
}
export async function pickClipFile(parseFileMetadata: boolean) {
const picked = await Native.chooseVideoFile();
if (!picked) return null;
let metadata: ParsedClipMetadata | null = null;
if (parseFileMetadata) {
const result = await Native.parseClipFileMetadata(picked.token);
metadata = result?.[0] ?? null;
}
const file = await readStampedVideoFile(picked.token, picked.name, picked.type);
return { file, metadata };
}
function isCompatibleClipFile(file: File) {
return file.type === "video/mp4" || compatibleExtensions.includes(getFileExtension(file.name));
}
function prepareClipFile(file: File, fileName: string) {
if (isCompatibleClipFile(file)) {
return new File([file], fileName, { type: file.type || "video/mp4" });
}
throw new Error("This file is not compatible. Use an MP4 clip with H.264 video and AAC audio.");
}
function parseAttachmentUploadResponse(response: RestResponse): AttachmentUploadResponse {
if (isObject(response.body)) return response.body as AttachmentUploadResponse;
return parseJSON<AttachmentUploadResponse>(response.text) ?? {};
}
async function reserveClipUpload(options: ClipUploadOptions, file: File) {
const response = await RestAPI.post({
url: Constants.Endpoints.MESSAGE_CREATE_ATTACHMENT_UPLOAD(options.channelId),
body: {
content: options.message,
files: [{
filename: options.fileName,
file_size: file.size,
id: "0",
is_clip: true,
is_spoiler: options.spoiler,
is_remix: options.remix,
is_thumbnail: options.thumbnail,
title: options.title,
application_id: options.applicationId,
clip_created_at: options.createdAt,
clip_participant_ids: options.participants,
clip_remote_id: options.remoteClipId,
clip_events_timeline: options.eventsTimeline,
original_content_type: file.type || "video/mp4"
}]
}
}) as RestResponse;
return parseAttachmentUploadResponse(response).attachments?.[0];
}
async function uploadReservedClip(attachment: NonNullable<Awaited<ReturnType<typeof reserveClipUpload>>>, uploadFile: File) {
const uploadAbortController = new AbortController();
uploadAbortControllers.add(uploadAbortController);
try {
const uploadResponse = await fetch(attachment.upload_url, {
method: "PUT",
body: uploadFile,
signal: uploadAbortController.signal,
referrer: "https://discord.com/",
referrerPolicy: "strict-origin-when-cross-origin",
mode: "cors",
credentials: "omit"
});
if (!uploadResponse.ok) throw new Error("Upload failed.");
} finally {
uploadAbortControllers.delete(uploadAbortController);
}
}
async function sendClipUpload(uploadFile: File, options: ClipUploadOptions) {
const attachment = await reserveClipUpload(options, uploadFile);
if (!attachment) throw new Error("Discord did not return an upload slot.");
await uploadReservedClip(attachment, uploadFile);
const messageResponse = await RestAPI.post({
url: Constants.Endpoints.MESSAGES(options.channelId),
body: {
content: options.message,
nonce: SnowflakeUtils.fromTimestamp(Date.now()),
channel_id: options.channelId,
sticker_ids: [],
type: 0,
attachments: [{
id: "0",
filesize: uploadFile.size,
filename: options.fileName,
uploaded_filename: attachment.upload_filename,
is_clip: true,
is_spoiler: options.spoiler,
is_remix: options.remix,
is_thumbnail: options.thumbnail,
title: options.title,
application_id: options.applicationId,
clip_created_at: options.createdAt,
clip_participant_ids: options.participants,
clip_remote_id: options.remoteClipId,
clip_events_timeline: options.eventsTimeline
}]
}
}) as RestResponse;
if (messageResponse.ok === false) throw messageResponse;
}
function getSearchableErrorText(error: unknown): string {
if (error instanceof Error) return error.message;
if (typeof error === "string") return error;
if (!isObject(error)) return "";
const { body, message, text } = error as { body?: unknown; message?: unknown; text?: unknown; };
return [
typeof message === "string" ? message : "",
typeof text === "string" ? text : "",
typeof body === "string" ? body : "",
isObject(body) ? JSON.stringify(body) : ""
].join("\n");
}
function getRestErrorCode(error: unknown) {
if (!isObject(error)) return undefined;
const { body } = error as { body?: unknown; };
if (!isObject(body)) return undefined;
const { code } = body as Record<string, unknown>;
return typeof code === "number" ? code : undefined;
}
function shouldRetryWithFFmpeg(error: unknown) {
const errorText = getSearchableErrorText(error).toLowerCase();
return getRestErrorCode(error) === 50174 || errorText.includes("not a valid clip");
}
export async function uploadClipFile(file: File, options: ClipUploadOptions) {
try {
showToast("Checking clip file.", Toasts.Type.MESSAGE);
const uploadFile = await prepareClipFile(file, options.fileName);
try {
await sendClipUpload(uploadFile, options);
} catch (error) {
if (!shouldRetryWithFFmpeg(error)) throw error;
showToast("Converting clip file.", Toasts.Type.MESSAGE);
await sendClipUpload(await stampVideoFile(await convertClipToMp4(file, options.fileName), options.fileName), options);
}
showToast("Clip uploaded.", Toasts.Type.SUCCESS);
return true;
} catch (error) {
logger.error(error);
showToast(getErrorMessage(error), Toasts.Type.FAILURE);
return false;
}
}
export function getErrorMessage(error: unknown): string {
if (error instanceof DOMException && error.name === "AbortError") return "Upload canceled.";
if (error instanceof Error) return error.message;
return "Failed to upload clip.";
}
function parseJSON<T>(text?: string): T | null {
if (!text) return null;
try {
return JSON.parse(text) as T;
} catch {
return null;
}
}