From 8fefbc1bcb29daa68d3a17a68722e1a9470fb73f Mon Sep 17 00:00:00 2001 From: static Date: Sun, 6 Jul 2025 23:17:48 +0900 Subject: [PATCH] =?UTF-8?q?=EC=8D=B8=EB=84=A4=EC=9D=BC=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=99=84=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/modules/file/upload.ts | 1 - src/lib/services/file.ts | 15 +- src/routes/(fullscreen)/file/[id]/service.ts | 15 +- .../settings/thumbnails/+page.svelte | 62 ++++----- .../settings/thumbnails/File.svelte | 24 +++- .../settings/thumbnails/service.svelte.ts | 129 ++++++++++++++++++ .../settings/thumbnails/service.ts | 73 ---------- 7 files changed, 185 insertions(+), 134 deletions(-) create mode 100644 src/routes/(fullscreen)/settings/thumbnails/service.svelte.ts delete mode 100644 src/routes/(fullscreen)/settings/thumbnails/service.ts diff --git a/src/lib/modules/file/upload.ts b/src/lib/modules/file/upload.ts index 3f70b13..e2652a1 100644 --- a/src/lib/modules/file/upload.ts +++ b/src/lib/modules/file/upload.ts @@ -94,7 +94,6 @@ const generateThumbnail = async (file: File, fileType: string) => { } return null; } catch { - // TODO: Error handling return null; } finally { if (url) { diff --git a/src/lib/services/file.ts b/src/lib/services/file.ts index 70d8887..57a0749 100644 --- a/src/lib/services/file.ts +++ b/src/lib/services/file.ts @@ -1,9 +1,22 @@ import { callGetApi } from "$lib/hooks"; import { decryptData } from "$lib/modules/crypto"; -import { storeFileThumbnail } from "$lib/modules/file"; +import { getFileCache, storeFileCache, downloadFile, storeFileThumbnail } from "$lib/modules/file"; import { getThumbnailUrl } from "$lib/modules/thumbnail"; import type { FileThumbnailInfoResponse } from "$lib/server/schemas"; +export const requestFileDownload = async ( + fileId: number, + fileEncryptedIv: string, + dataKey: CryptoKey, +) => { + const cache = await getFileCache(fileId); + if (cache) return cache; + + const fileBuffer = await downloadFile(fileId, fileEncryptedIv, dataKey); + storeFileCache(fileId, fileBuffer); // Intended + return fileBuffer; +}; + export const requestFileThumbnailDownload = async (fileId: number, dataKey: CryptoKey) => { let res = await callGetApi(`/api/file/${fileId}/thumbnail`); if (!res.ok) return null; diff --git a/src/routes/(fullscreen)/file/[id]/service.ts b/src/routes/(fullscreen)/file/[id]/service.ts index 43f0134..ccedcd1 100644 --- a/src/routes/(fullscreen)/file/[id]/service.ts +++ b/src/routes/(fullscreen)/file/[id]/service.ts @@ -1,21 +1,8 @@ import { callPostApi } from "$lib/hooks"; -import { getFileCache, storeFileCache, downloadFile } from "$lib/modules/file"; import type { CategoryFileAddRequest } from "$lib/server/schemas"; export { requestCategoryCreation, requestFileRemovalFromCategory } from "$lib/services/category"; - -export const requestFileDownload = async ( - fileId: number, - fileEncryptedIv: string, - dataKey: CryptoKey, -) => { - const cache = await getFileCache(fileId); - if (cache) return cache; - - const fileBuffer = await downloadFile(fileId, fileEncryptedIv, dataKey); - storeFileCache(fileId, fileBuffer); // Intended - return fileBuffer; -}; +export { requestFileDownload } from "$lib/services/file"; export const requestFileAdditionToCategory = async (fileId: number, categoryId: number) => { const res = await callPostApi(`/api/category/${categoryId}/file/add`, { diff --git a/src/routes/(fullscreen)/settings/thumbnails/+page.svelte b/src/routes/(fullscreen)/settings/thumbnails/+page.svelte index f57d542..5be902e 100644 --- a/src/routes/(fullscreen)/settings/thumbnails/+page.svelte +++ b/src/routes/(fullscreen)/settings/thumbnails/+page.svelte @@ -1,52 +1,35 @@ @@ -56,19 +39,20 @@ - {#if fileInfos && fileInfos.length > 0} + {#if persistentStates.files.length > 0}

- {fileInfos.length}개 파일의 썸네일이 존재하지 않아요. + {persistentStates.files.length}개 파일의 썸네일이 존재하지 않아요.

- {#each fileInfos as fileInfo} + {#each persistentStates.files as { info, status }} goto(`/file/${id}`)} - onGenerateThumbnailClick={generateThumbnail} + onGenerateThumbnailClick={requestFileThumbnailGeneration} /> {/each}
diff --git a/src/routes/(fullscreen)/settings/thumbnails/File.svelte b/src/routes/(fullscreen)/settings/thumbnails/File.svelte index d06d435..a9530e1 100644 --- a/src/routes/(fullscreen)/settings/thumbnails/File.svelte +++ b/src/routes/(fullscreen)/settings/thumbnails/File.svelte @@ -1,9 +1,20 @@ + + {#if $info} @@ -24,10 +36,10 @@ onActionButtonClick={() => onGenerateThumbnailClick($info)} actionButtonClass="text-gray-800" > - + {@const subtext = + $generationStatus && $generationStatus !== "uploaded" + ? subtexts[$generationStatus] + : formatDateTime($info.createdAt ?? $info.lastModifiedAt)} + {/if} diff --git a/src/routes/(fullscreen)/settings/thumbnails/service.svelte.ts b/src/routes/(fullscreen)/settings/thumbnails/service.svelte.ts new file mode 100644 index 0000000..66d3e18 --- /dev/null +++ b/src/routes/(fullscreen)/settings/thumbnails/service.svelte.ts @@ -0,0 +1,129 @@ +import { limitFunction } from "p-limit"; +import { get, writable, type Writable } from "svelte/store"; +import { encryptData } from "$lib/modules/crypto"; +import { storeFileThumbnail } from "$lib/modules/file"; +import type { FileInfo } from "$lib/modules/filesystem"; +import { generateImageThumbnail, generateVideoThumbnail } from "$lib/modules/thumbnail"; +import type { FileThumbnailUploadRequest } from "$lib/server/schemas"; +import { requestFileDownload } from "$lib/services/file"; + +export type GenerationStatus = + | "generation-pending" + | "generating" + | "upload-pending" + | "uploading" + | "uploaded" + | "error"; + +interface File { + id: number; + info: Writable; + status?: Writable; +} + +const workingFiles = new Map>(); + +export const persistentStates = $state({ + files: [] as File[], +}); + +export const getGenerationStatus = (fileId: number): Writable | undefined => { + return workingFiles.get(fileId); +}; + +const generateThumbnail = limitFunction( + async ( + status: Writable, + fileBuffer: ArrayBuffer, + fileType: string, + dataKey: CryptoKey, + ) => { + let url, thumbnail; + status.set("generating"); + + try { + if (fileType === "image/heic") { + const { default: heic2any } = await import("heic2any"); + url = URL.createObjectURL( + (await heic2any({ + blob: new Blob([fileBuffer], { type: fileType }), + toType: "image/png", + })) as Blob, + ); + thumbnail = await generateImageThumbnail(url); + } else if (fileType.startsWith("image/")) { + url = URL.createObjectURL(new Blob([fileBuffer], { type: fileType })); + thumbnail = await generateImageThumbnail(url); + } else if (fileType.startsWith("video/")) { + url = URL.createObjectURL(new Blob([fileBuffer], { type: fileType })); + thumbnail = await generateVideoThumbnail(url); + } else { + status.set("error"); + return null; + } + + const thumbnailBuffer = await thumbnail.arrayBuffer(); + const thumbnailEncrypted = await encryptData(thumbnailBuffer, dataKey); + status.set("upload-pending"); + return { plaintext: thumbnailBuffer, ...thumbnailEncrypted }; + } catch { + status.set("error"); + return null; + } finally { + if (url) { + URL.revokeObjectURL(url); + } + } + }, + { concurrency: 4 }, +); + +const requestThumbnailUpload = limitFunction( + async ( + status: Writable, + fileId: number, + dataKeyVersion: Date, + thumbnail: { plaintext: ArrayBuffer; ciphertext: ArrayBuffer; iv: string }, + ) => { + status.set("uploading"); + + const form = new FormData(); + form.set( + "metadata", + JSON.stringify({ + dekVersion: dataKeyVersion.toISOString(), + contentIv: thumbnail.iv, + } satisfies FileThumbnailUploadRequest), + ); + form.set("content", new Blob([thumbnail.ciphertext])); + + const res = await fetch(`/api/file/${fileId}/thumbnail/upload`, { method: "POST", body: form }); + if (!res.ok) return false; + + status.set("uploaded"); + workingFiles.delete(fileId); + persistentStates.files = persistentStates.files.filter(({ id }) => id != fileId); + + storeFileThumbnail(fileId, thumbnail.plaintext); // Intended + return true; + }, + { concurrency: 4 }, +); + +export const requestFileThumbnailGeneration = async (fileInfo: FileInfo) => { + let status = workingFiles.get(fileInfo.id); + if (status && get(status) !== "error") return; + + status = writable("generation-pending"); + workingFiles.set(fileInfo.id, status); + persistentStates.files = persistentStates.files.map((file) => + file.id === fileInfo.id ? { ...file, status } : file, + ); + + // TODO: Error Handling + const file = await requestFileDownload(fileInfo.id, fileInfo.contentIv!, fileInfo.dataKey!); + const thumbnail = await generateThumbnail(status, file, fileInfo.contentType, fileInfo.dataKey!); + if (!thumbnail) return; + + await requestThumbnailUpload(status, fileInfo.id, fileInfo.dataKeyVersion!, thumbnail); +}; diff --git a/src/routes/(fullscreen)/settings/thumbnails/service.ts b/src/routes/(fullscreen)/settings/thumbnails/service.ts deleted file mode 100644 index e3c828c..0000000 --- a/src/routes/(fullscreen)/settings/thumbnails/service.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { limitFunction } from "p-limit"; -import { encryptData } from "$lib/modules/crypto"; -import { getFileCache, storeFileCache, downloadFile, storeFileThumbnail } from "$lib/modules/file"; -import { generateImageThumbnail, generateVideoThumbnail } from "$lib/modules/thumbnail"; -import type { FileThumbnailUploadRequest } from "$lib/server/schemas"; - -export const requestFileDownload = async ( - fileId: number, - fileEncryptedIv: string, - dataKey: CryptoKey, -) => { - const cache = await getFileCache(fileId); - if (cache) return cache; - - const fileBuffer = await downloadFile(fileId, fileEncryptedIv, dataKey); - storeFileCache(fileId, fileBuffer); // Intended - return fileBuffer; -}; - -export const generateThumbnail = limitFunction( - async (fileBuffer: ArrayBuffer, fileType: string) => { - let url; - try { - if (fileType === "image/heic") { - const { default: heic2any } = await import("heic2any"); - url = URL.createObjectURL( - (await heic2any({ - blob: new Blob([fileBuffer], { type: fileType }), - toType: "image/png", - })) as Blob, - ); - return await generateImageThumbnail(url); - } else if (fileType.startsWith("image/")) { - url = URL.createObjectURL(new Blob([fileBuffer], { type: fileType })); - return await generateImageThumbnail(url); - } else if (fileType.startsWith("video/")) { - url = URL.createObjectURL(new Blob([fileBuffer], { type: fileType })); - return await generateVideoThumbnail(url); - } - return null; - } catch { - // TODO: Error handling - return null; - } finally { - if (url) { - URL.revokeObjectURL(url); - } - } - }, - { concurrency: 4 }, -); - -export const requestThumbnailUpload = limitFunction( - async (fileId: number, thumbnail: ArrayBuffer, dataKey: CryptoKey, dataKeyVersion: Date) => { - const thumbnailEncrypted = await encryptData(thumbnail, dataKey); - const form = new FormData(); - form.set( - "metadata", - JSON.stringify({ - dekVersion: dataKeyVersion.toISOString(), - contentIv: thumbnailEncrypted.iv, - } satisfies FileThumbnailUploadRequest), - ); - form.set("content", new Blob([thumbnailEncrypted.ciphertext])); - - const res = await fetch(`/api/file/${fileId}/thumbnail/upload`, { method: "POST", body: form }); - if (!res.ok) return false; - - storeFileThumbnail(fileId, thumbnail); // Intended - return true; - }, - { concurrency: 4 }, -);