console.log(`started @ ${new Date().toISOString()}`); import { encode } from "blurhash"; import mime from 'mime/lite'; import sharp from "sharp"; import exitHook from 'exit-hook'; import { execFileSync, spawnSync } from "child_process"; import { watch, existsSync, readdirSync, readFileSync, writeFileSync, unlinkSync } from "fs"; import { join } from "path"; import { access_token, homeserver, folder2room } from "./config.js"; exitHook(signal => { console.log(`stopped @ ${new Date().toISOString()} signal ${signal}`); unlinkSync("pid.txt"); }); writeFileSync("pid.txt", String(process.pid)); async function fetch(url, options) { while (!res?.ok) { try { console.debug("fetch", url); var res = await global.fetch(url, options); if (!res.ok) throw new Error(`HTTP ${res.status}, ${await res.text()}`); } catch (error) { console.error("fetch:", error.message); } await new Promise(r => setTimeout(r, 30000)); } return res; } //todo use win32 api function fileIdToPath(fileId) { var out = execFileSync("fsutil", ["file", "queryFileNameById", "C:", fileId]).toString().trim(); var path = out.slice(out.indexOf("\\\\")); //console.debug(`${fileId} = ${path}`); return path; } function filePathToId(filePath) { var out = execFileSync("fsutil", ["file", "queryFileId", filePath]).toString().trim(); var id = BigInt(out.slice(out.indexOf("0x"))); //console.debug(`${filePath} = ${id}`); return id; } folder2room.forEach((roomId, folderId) => { console.log(`loading ids for ${roomId}`); var folderPath = fileIdToPath(folderId); var existingFiles = readdirSync(folderPath).map(fileName => filePathToId(join(folderPath, fileName))); console.log(`${existingFiles.length} files`); watch(folderPath, async (eventType, fileName) => { try { console.log(eventType, folderPath, fileName); if (eventType != "rename") return; if (fileName.endsWith(".crdownload")) return; var filePath = join(folderPath, fileName); if (!existsSync(filePath)) return; var fileId = filePathToId(filePath); if (existingFiles.includes(fileId)) return; existingFiles.push(fileId); var mimetype = mime.getType(filePath) || "application/octet-stream"; await postImageOnMatrix(roomId, filePath, fileName, mimetype); } catch (error) { console.error(error.stack); } }); }); console.log("ready"); async function postImageOnMatrix(roomId, filePath, fileName, mimetype) { console.log(`Posting ${filePath} to room ${roomId}`); var file = readFileSync(filePath); if (mimetype.startsWith("image/")) { var {data, info} = await sharp(file).ensureAlpha().raw().toBuffer({resolveWithObject: true}); var blurhash = encode( new Uint8ClampedArray(data), info.width, info.height, // https://github.com/element-hq/element-web/blob/7dbffb348dfe8fbd83c9ea4bff9f37292b0979bb/src/workers/blurhash.worker.ts#L29-L31 info.width >= info.height ? 4 : 3, info.height >= info.width ? 4 : 3, ); } else if (mimetype.startsWith("video/")) { let {stdout, stderr} = spawnSync("ffmpeg", ["-i", filePath, "-frames:v", "1", "-f", "webp", "-c:v", "libwebp", "-"]); stderr = stderr.toString(); console.debug(stderr); var duration = stderr.match(/Duration: (\d{2}):(\d{2}):(\d{2})\.(\d{2})/); if (duration) { duration = parseInt(duration[1]) * 3600 + parseInt(duration[2]) * 60 + parseInt(duration[3]) + parseInt(duration[4]) / 100; duration = duration * 1000; console.debug(`Duration: ${duration} ms`); } var {data, info} = await sharp(stdout).ensureAlpha().raw().toBuffer({resolveWithObject: true}); var thumbnail_info = { w: info.width, h: info.height, mimetype: "image/webp", size: stdout.length, } var blurhash = encode( new Uint8ClampedArray(data), info.width, info.height, // https://github.com/element-hq/element-web/blob/7dbffb348dfe8fbd83c9ea4bff9f37292b0979bb/src/workers/blurhash.worker.ts#L29-L31 info.width >= info.height ? 4 : 3, info.height >= info.width ? 4 : 3, ); var thumbnail_url = await uploadFile(fileName + ".webp", stdout, "image/webp"); } else if (mimetype.startsWith("audio/")) { // todo get duration } var content_uri = await uploadFile(fileName, file, mimetype); var txnId = Math.random(); var res = await fetch(`${homeserver}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${txnId}`, { method: "PUT", headers: { "Authorization": `Bearer ${access_token}`, "Content-Type": "application/json", }, body: JSON.stringify({ "msgtype": mimetype.startsWith("image/") ? "m.image" : mimetype.startsWith("video/") ? "m.video" : mimetype.startsWith("audio/") ? "m.audio" : "m.file", "body": fileName, "info": { "xyg.amorgan.blurhash": blurhash, "mimetype": mimetype, "w": info?.width, "h": info?.height, "size": file.length, thumbnail_info, thumbnail_url, duration }, "url": content_uri, }) }); var {event_id} = await res.json(); console.log(event_id); //todo store mapping } async function uploadFile(fileName, file, mimetype) { var res = await fetch(`${homeserver}/_matrix/media/v3/upload?filename=${encodeURIComponent(fileName)}`, { method: "POST", headers: { "Authorization": `Bearer ${access_token}`, "Content-Type": mimetype, }, body: file }); var {content_uri} = await res.json(); console.debug(content_uri); return content_uri; }