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

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 ? info?.parentId
: undefined} : undefined}
{fileBlob} {fileBlob}
downloadUrl={videoStreamUrl}
filename={info?.name} filename={info?.name}
/> />
</div> </div>

View File

@@ -10,17 +10,29 @@
interface Props { interface Props {
directoryId?: "root" | number; directoryId?: "root" | number;
downloadUrl?: string;
fileBlob?: Blob; fileBlob?: Blob;
filename?: string; filename?: string;
isOpen: boolean; 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> </script>
<svelte:window onclick={() => (isOpen = false)} /> <svelte:window onclick={() => (isOpen = false)} />
{#if isOpen && (directoryId || fileBlob)} {#if isOpen && (directoryId || downloadUrl || fileBlob)}
<div <div
class="absolute right-2 top-full z-20 space-y-1 rounded-lg bg-white px-1 py-2 shadow-2xl" 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 }} transition:fly={{ y: -8, duration: 200 }}
@@ -49,10 +61,8 @@
), ),
)} )}
{/if} {/if}
{#if fileBlob} {#if fileBlob || downloadUrl}
{@render menuButton(IconCloudDownload, "다운로드", () => { {@render menuButton(IconCloudDownload, "다운로드", handleDownload)}
FileSaver.saveAs(fileBlob, filename);
})}
{/if} {/if}
</div> </div>
</div> </div>

View File

@@ -10,15 +10,23 @@ const createResponse = (
isRangeRequest: boolean, isRangeRequest: boolean,
range: { start: number; end: number; total: number }, range: { start: number; end: number; total: number },
contentType?: string, contentType?: string,
downloadFilename?: string,
) => { ) => {
return new Response(stream, { const headers: Record<string, string> = {
status: isRangeRequest ? 206 : 200,
headers: {
"Accept-Ranges": "bytes", "Accept-Ranges": "bytes",
"Content-Length": String(range.end - range.start + 1), "Content-Length": String(range.end - range.start + 1),
"Content-Type": contentType ?? "application/octet-stream", "Content-Type": contentType ?? "application/octet-stream",
...(isRangeRequest ? getContentRangeHeader(range) : {}), ...(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, file: File,
metadata?: FileMetadata, metadata?: FileMetadata,
range?: { start?: number; end?: number }, range?: { start?: number; end?: number },
downloadFilename?: string,
) => { ) => {
const start = range?.start ?? 0; const start = range?.start ?? 0;
const end = range?.end ?? file.size - 1; const end = range?.end ?? file.size - 1;
@@ -38,6 +47,7 @@ const streamFromOpfs = async (
!!range, !!range,
{ start, end, total: file.size }, { start, end, total: file.size },
metadata?.contentType, metadata?.contentType,
downloadFilename,
); );
}; };
@@ -45,6 +55,7 @@ const streamFromServer = async (
id: number, id: number,
metadata: FileMetadata, metadata: FileMetadata,
range?: { start?: number; end?: number }, range?: { start?: number; end?: number },
downloadFilename?: string,
) => { ) => {
const totalSize = getDecryptedSize(metadata.encContentSize, metadata.isLegacy); const totalSize = getDecryptedSize(metadata.encContentSize, metadata.isLegacy);
const start = range?.start ?? 0; const start = range?.start ?? 0;
@@ -59,40 +70,64 @@ const streamFromServer = async (
const apiResponse = await fetch(`/api/file/${id}/download`, { const apiResponse = await fetch(`/api/file/${id}/download`, {
headers: { Range: `bytes=${encryptedRange.start}-${encryptedRange.end}` }, 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 }); return new Response("Failed to fetch encrypted file", { status: 502 });
} }
if (metadata.isLegacy) {
const fileEncrypted = await apiResponse.arrayBuffer(); const fileEncrypted = await apiResponse.arrayBuffer();
const decrypted = await decryptChunk(fileEncrypted, metadata.dataKey);
return createResponse( return createResponse(
new ReadableStream<Uint8Array>({ new ReadableStream<Uint8Array>({
async start(controller) { start(controller) {
if (metadata.isLegacy) {
const decrypted = await decryptChunk(fileEncrypted, metadata.dataKey);
controller.enqueue(new Uint8Array(decrypted.slice(start, end + 1))); controller.enqueue(new Uint8Array(decrypted.slice(start, end + 1)));
controller.close(); 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, !!range,
{ start, end, total: totalSize }, { start, end, total: totalSize },
metadata.contentType, 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) => { const decryptFileHandler = async (request: Request) => {
@@ -102,13 +137,14 @@ const decryptFileHandler = async (request: Request) => {
throw new Response("Invalid file id", { status: 400 }); throw new Response("Invalid file id", { status: 400 });
} }
const downloadFilename = url.searchParams.get("download") ?? undefined;
const metadata = fileMetadataStore.get(fileId); const metadata = fileMetadataStore.get(fileId);
const range = parseRangeHeader(request.headers.get("Range")); const range = parseRangeHeader(request.headers.get("Range"));
const cache = await getFile(`/cache/${fileId}`); const cache = await getFile(`/cache/${fileId}`);
if (cache) { if (cache) {
return streamFromOpfs(cache, metadata, range); return streamFromOpfs(cache, metadata, range, downloadFilename);
} else if (metadata) { } else if (metadata) {
return streamFromServer(fileId, metadata, range); return streamFromServer(fileId, metadata, range, downloadFilename);
} else { } else {
return new Response("Decryption not prepared", { status: 400 }); return new Response("Decryption not prepared", { status: 400 });
} }