스트리밍 방식으로 동영상을 불러올 때 다운로드 메뉴가 표시되지 않는 버그 수정

This commit is contained in:
static
2026-01-11 09:25:40 +09:00
parent 0c295a2ffa
commit 1efcdd68f1
3 changed files with 83 additions and 36 deletions

View File

@@ -154,6 +154,7 @@
? info?.parentId
: undefined}
{fileBlob}
downloadUrl={videoStreamUrl}
filename={info?.name}
/>
</div>

View File

@@ -10,17 +10,29 @@
interface Props {
directoryId?: "root" | number;
downloadUrl?: string;
fileBlob?: Blob;
filename?: string;
isOpen: boolean;
}
let { directoryId, fileBlob, filename, isOpen = $bindable() }: Props = $props();
let { directoryId, downloadUrl, fileBlob, filename, isOpen = $bindable() }: Props = $props();
const handleDownload = () => {
if (fileBlob && filename) {
FileSaver.saveAs(fileBlob, filename);
} else if (downloadUrl && filename) {
// Use streaming download via Content-Disposition header
const url = new URL(downloadUrl, window.location.origin);
url.searchParams.set("download", filename);
window.open(url.toString(), "_blank");
}
};
</script>
<svelte:window onclick={() => (isOpen = false)} />
{#if isOpen && (directoryId || fileBlob)}
{#if isOpen && (directoryId || downloadUrl || fileBlob)}
<div
class="absolute right-2 top-full z-20 space-y-1 rounded-lg bg-white px-1 py-2 shadow-2xl"
transition:fly={{ y: -8, duration: 200 }}
@@ -49,10 +61,8 @@
),
)}
{/if}
{#if fileBlob}
{@render menuButton(IconCloudDownload, "다운로드", () => {
FileSaver.saveAs(fileBlob, filename);
})}
{#if fileBlob || downloadUrl}
{@render menuButton(IconCloudDownload, "다운로드", handleDownload)}
{/if}
</div>
</div>

View File

@@ -10,15 +10,23 @@ const createResponse = (
isRangeRequest: boolean,
range: { start: number; end: number; total: number },
contentType?: string,
downloadFilename?: string,
) => {
return new Response(stream, {
status: isRangeRequest ? 206 : 200,
headers: {
const headers: Record<string, string> = {
"Accept-Ranges": "bytes",
"Content-Length": String(range.end - range.start + 1),
"Content-Type": contentType ?? "application/octet-stream",
...(isRangeRequest ? getContentRangeHeader(range) : {}),
},
};
if (downloadFilename) {
headers["Content-Disposition"] =
`attachment; filename*=UTF-8''${encodeURIComponent(downloadFilename)}`;
}
return new Response(stream, {
status: isRangeRequest ? 206 : 200,
headers,
});
};
@@ -26,6 +34,7 @@ const streamFromOpfs = async (
file: File,
metadata?: FileMetadata,
range?: { start?: number; end?: number },
downloadFilename?: string,
) => {
const start = range?.start ?? 0;
const end = range?.end ?? file.size - 1;
@@ -38,6 +47,7 @@ const streamFromOpfs = async (
!!range,
{ start, end, total: file.size },
metadata?.contentType,
downloadFilename,
);
};
@@ -45,6 +55,7 @@ const streamFromServer = async (
id: number,
metadata: FileMetadata,
range?: { start?: number; end?: number },
downloadFilename?: string,
) => {
const totalSize = getDecryptedSize(metadata.encContentSize, metadata.isLegacy);
const start = range?.start ?? 0;
@@ -59,40 +70,64 @@ const streamFromServer = async (
const apiResponse = await fetch(`/api/file/${id}/download`, {
headers: { Range: `bytes=${encryptedRange.start}-${encryptedRange.end}` },
});
if (apiResponse.status !== 206) {
if (apiResponse.status !== 206 || !apiResponse.body) {
return new Response("Failed to fetch encrypted file", { status: 502 });
}
if (metadata.isLegacy) {
const fileEncrypted = await apiResponse.arrayBuffer();
const decrypted = await decryptChunk(fileEncrypted, metadata.dataKey);
return createResponse(
new ReadableStream<Uint8Array>({
async start(controller) {
if (metadata.isLegacy) {
const decrypted = await decryptChunk(fileEncrypted, metadata.dataKey);
start(controller) {
controller.enqueue(new Uint8Array(decrypted.slice(start, end + 1)));
controller.close();
return;
}
const chunks = encryptedRange.lastChunkIndex - encryptedRange.firstChunkIndex + 1;
for (let i = 0; i < chunks; i++) {
const chunk = await decryptChunk(
fileEncrypted.slice(i * ENCRYPTED_CHUNK_SIZE, (i + 1) * ENCRYPTED_CHUNK_SIZE),
metadata.dataKey,
);
const sliceStart = i === 0 ? start % CHUNK_SIZE : 0;
const sliceEnd = i === chunks - 1 ? (end % CHUNK_SIZE) + 1 : chunk.byteLength;
controller.enqueue(new Uint8Array(chunk.slice(sliceStart, sliceEnd)));
}
controller.close();
},
}),
!!range,
{ start, end, total: totalSize },
metadata.contentType,
);
}
const totalChunks = encryptedRange.lastChunkIndex - encryptedRange.firstChunkIndex + 1;
let currentChunkIndex = 0;
let buffer = new Uint8Array(0);
const decryptingStream = new TransformStream<Uint8Array, Uint8Array>({
async transform(chunk, controller) {
const newBuffer = new Uint8Array(buffer.length + chunk.length);
newBuffer.set(buffer);
newBuffer.set(chunk, buffer.length);
buffer = newBuffer;
while (buffer.length >= ENCRYPTED_CHUNK_SIZE && currentChunkIndex < totalChunks - 1) {
const encryptedChunk = buffer.slice(0, ENCRYPTED_CHUNK_SIZE);
buffer = buffer.slice(ENCRYPTED_CHUNK_SIZE);
const decrypted = await decryptChunk(encryptedChunk.buffer, metadata.dataKey);
const sliceStart = currentChunkIndex === 0 ? start % CHUNK_SIZE : 0;
controller.enqueue(new Uint8Array(decrypted.slice(sliceStart)));
currentChunkIndex++;
}
},
async flush(controller) {
if (buffer.length > 0) {
const decrypted = await decryptChunk(buffer.buffer, metadata.dataKey);
const sliceStart = currentChunkIndex === 0 ? start % CHUNK_SIZE : 0;
const sliceEnd = (end % CHUNK_SIZE) + 1;
controller.enqueue(new Uint8Array(decrypted.slice(sliceStart, sliceEnd)));
}
},
});
return createResponse(
apiResponse.body.pipeThrough(decryptingStream),
!!range,
{ start, end, total: totalSize },
metadata.contentType,
downloadFilename,
);
};
const decryptFileHandler = async (request: Request) => {
@@ -102,13 +137,14 @@ const decryptFileHandler = async (request: Request) => {
throw new Response("Invalid file id", { status: 400 });
}
const downloadFilename = url.searchParams.get("download") ?? undefined;
const metadata = fileMetadataStore.get(fileId);
const range = parseRangeHeader(request.headers.get("Range"));
const cache = await getFile(`/cache/${fileId}`);
if (cache) {
return streamFromOpfs(cache, metadata, range);
return streamFromOpfs(cache, metadata, range, downloadFilename);
} else if (metadata) {
return streamFromServer(fileId, metadata, range);
return streamFromServer(fileId, metadata, range, downloadFilename);
} else {
return new Response("Decryption not prepared", { status: 400 });
}