Skip to content

Commit b3ac8bd

Browse files
committed
feat: add WebRTC P2P file transfer with HTTP fallback
1 parent f68388e commit b3ac8bd

File tree

4 files changed

+562
-124
lines changed

4 files changed

+562
-124
lines changed

web/src/pages/download/App.vue

Lines changed: 130 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { onMounted } from 'vue';
55
import { Download, FileText, HardDrive } from 'lucide-vue-next';
66
import { downloadFile } from '@/utils/requests';
77
import { processDownloadWithConcurrencyLimit } from '@/utils/asyncPool';
8+
import { receiveViaWebRtc } from '@/utils/webrtc';
89
910
const { Title, Text } = Typography;
1011
@@ -26,7 +27,7 @@ const formatBytes = (bytes: number, decimals = 2) => {
2627
const handleGetFile = async () => {
2728
isDownloading.value = true;
2829
downloadProgress.value = 0;
29-
30+
3031
let start = 0;
3132
let fileData: Map<number, Uint8Array> = new Map();
3233
let downloadedBytes = 0;
@@ -35,117 +36,158 @@ const handleGetFile = async () => {
3536
const pathParts = window.location.pathname.split('/');
3637
const fileId = pathParts[1]; // Assumes URL format is /{id}/file
3738
38-
// Create an array to hold all chunk download promises
39-
const downloadPromises: Array<() => Promise<any>> = [];
39+
const downloadViaHttp = async () => {
40+
// Create an array to hold all chunk download promises
41+
const downloadPromises: Array<() => Promise<any>> = [];
4042
41-
// Add chunks to download (in 1MB chunks)
42-
while (start < fileSize.value) {
43-
const chunkEnd = Math.min(start + 1024 * 1024, fileSize.value) - 1;
44-
const currentStart = start; // 保存当前start值的快照
43+
// Add chunks to download (in 1MB chunks)
44+
while (start < fileSize.value) {
45+
const chunkEnd = Math.min(start + 1024 * 1024, fileSize.value) - 1;
46+
const currentStart = start; // 保存当前start值的快照
4547
46-
// Create a function that returns a promise for this chunk download
47-
// Pass fileId and the byte range start, but not fileName since it's not needed for the request
48-
downloadPromises.push(() => downloadFile(fileId, currentStart, fileName));
48+
// Create a function that returns a promise for this chunk download
49+
// Pass fileId and the byte range start, but not fileName since it's not needed for the request
50+
downloadPromises.push(() => downloadFile(fileId, currentStart, fileName));
4951
50-
if (chunkEnd === fileSize.value - 1) break;
51-
start += 1024 * 1024;
52-
}
52+
if (chunkEnd === fileSize.value - 1) break;
53+
start += 1024 * 1024;
54+
}
5355
54-
// Process downloads with a concurrency limit of 4
55-
try {
56-
await processDownloadWithConcurrencyLimit(downloadPromises, 4, async (rangeMatch: RegExpMatchArray, response: Response) => {
56+
// Process downloads with a concurrency limit of 4
57+
try {
58+
await processDownloadWithConcurrencyLimit(downloadPromises, 4, async (rangeMatch: RegExpMatchArray, response: Response) => {
5759
const rangeStart = parseInt(rangeMatch[1]);
5860
59-
// Get the file data
60-
const data = await response.arrayBuffer();
61-
const chunk = new Uint8Array(data);
62-
fileData.set(rangeStart, chunk);
61+
// Get the file data
62+
const data = await response.arrayBuffer();
63+
const chunk = new Uint8Array(data);
64+
fileData.set(rangeStart, chunk);
6365
64-
// Update progress - 累加当前chunk的大小而不是重新计算所有chunks
65-
downloadedBytes += chunk.length;
66+
// Update progress - 累加当前chunk的大小而不是重新计算所有chunks
67+
downloadedBytes += chunk.length;
6668
67-
// Only update progress if we have a valid totalSize
68-
if (fileSize.value > 0) {
69-
downloadProgress.value = Math.round((downloadedBytes / fileSize.value) * 100);
70-
}
71-
});
72-
} catch (error) {
73-
message.error('下载过程中发生错误: ' + (error instanceof Error ? error.message : '未知错误'));
74-
isDownloading.value = false;
75-
return;
76-
}
69+
// Only update progress if we have a valid totalSize
70+
if (fileSize.value > 0) {
71+
downloadProgress.value = Math.round((downloadedBytes / fileSize.value) * 100);
72+
}
73+
});
74+
} catch (error) {
75+
message.error('下载过程中发生错误: ' + (error instanceof Error ? error.message : '未知错误'));
76+
isDownloading.value = false;
77+
return;
78+
}
7779
78-
await new Promise<void>(resolve => { setTimeout(resolve, 500)});
80+
await new Promise<void>(resolve => { setTimeout(resolve, 500)});
7981
80-
// Combine all chunks and save the file
81-
if (fileData.size > 0) {
82-
try {
83-
// Combine all Uint8Array chunks into a single Uint8Array
84-
const combinedData = new Uint8Array(fileSize.value);
85-
let offset = 0;
86-
const sortedChunks = Array.from(fileData.entries()).sort((a, b) => a[0] - b[0]);
82+
// Combine all chunks and save the file
83+
if (fileData.size > 0) {
84+
try {
85+
// Combine all Uint8Array chunks into a single Uint8Array
86+
const combinedData = new Uint8Array(fileSize.value);
87+
let offset = 0;
88+
const sortedChunks = Array.from(fileData.entries()).sort((a, b) => a[0] - b[0]);
8789
88-
for (const [start, chunk] of sortedChunks) {
89-
if(start !== offset){
90+
for (const [start, chunk] of sortedChunks) {
91+
if (start !== offset) {
9092
message.error(`文件 ${fileName.value} 中有缺失的块,请重新上传`);
9193
return;
94+
}
95+
combinedData.set(chunk, offset);
96+
offset += chunk.length;
9297
}
93-
combinedData.set(chunk, offset);
94-
offset += chunk.length;
95-
}
96-
97-
// Create a Blob from the combined data
98-
const blob = new Blob([combinedData], { type: 'application/octet-stream' });
99-
100-
// Create a download link and trigger the download
101-
const url = URL.createObjectURL(blob);
102-
const a = document.createElement('a');
103-
a.href = url;
104-
a.download = fileName.value || 'downloaded_file';
105-
document.body.appendChild(a);
106-
a.click();
107-
108-
// Clean up
109-
document.body.removeChild(a);
110-
URL.revokeObjectURL(url);
11198
112-
message.success('文件下载完成!');
113-
114-
// Send download completion signal to server
115-
try {
116-
const pathParts = window.location.pathname.split('/');
117-
const fileId = pathParts[1]; // Assumes URL format is /{id}/file
118-
119-
const response = await fetch(`/api/${fileId}/done`, {
120-
method: 'PUT',
121-
headers: {
122-
'Content-Type': 'application/json',
123-
},
124-
body: JSON.stringify({})
125-
});
126-
127-
if (response.ok) {
128-
// Download completion signal sent successfully
129-
} else {
99+
// Create a Blob from the combined data
100+
const blob = new Blob([combinedData], { type: 'application/octet-stream' });
101+
102+
// Create a download link and trigger the download
103+
const url = URL.createObjectURL(blob);
104+
const a = document.createElement('a');
105+
a.href = url;
106+
a.download = fileName.value || 'downloaded_file';
107+
document.body.appendChild(a);
108+
a.click();
109+
110+
// Clean up
111+
document.body.removeChild(a);
112+
URL.revokeObjectURL(url);
113+
114+
message.success('文件下载完成!');
115+
116+
// Send download completion signal to server
117+
try {
118+
const pathParts = window.location.pathname.split('/');
119+
const fileId = pathParts[1]; // Assumes URL format is /{id}/file
120+
121+
const response = await fetch(`/api/fileflow/${fileId}/done`, {
122+
method: 'PUT',
123+
headers: {
124+
'Content-Type': 'application/json',
125+
},
126+
body: JSON.stringify({})
127+
});
128+
129+
if (response.ok) {
130+
// Download completion signal sent successfully
131+
} else {
132+
message.warning('无法通知服务器下载完成,但文件已成功下载');
133+
}
134+
} catch (error) {
130135
message.warning('无法通知服务器下载完成,但文件已成功下载');
131136
}
132137
} catch (error) {
138+
message.error('保存文件时发生错误: ' + (error instanceof Error ? error.message : '未知错误'));
139+
}
140+
} else {
141+
message.error('没有下载到任何文件数据');
142+
}
143+
144+
isDownloading.value = false;
145+
isFinished.value = true;
146+
};
147+
148+
const rid = localStorage.getItem("rid") || "";
149+
const p2pResult = await receiveViaWebRtc(fileId, rid, {
150+
onProgress: (percent) => {
151+
downloadProgress.value = percent;
152+
},
153+
onMetadata: (name, size) => {
154+
if (!fileName.value) fileName.value = name;
155+
if (!fileSize.value) fileSize.value = size;
156+
},
157+
onStatus: (status) => {
158+
if (status.startsWith('fallback')) {
159+
message.warning('P2P 失败,切换到服务器下载');
160+
}
161+
},
162+
});
163+
164+
if (p2pResult.status === 'success') {
165+
try {
166+
const response = await fetch(`/api/fileflow/${fileId}/done`, {
167+
method: 'PUT',
168+
headers: {
169+
'Content-Type': 'application/json',
170+
},
171+
body: JSON.stringify({})
172+
});
173+
if (!response.ok) {
133174
message.warning('无法通知服务器下载完成,但文件已成功下载');
134175
}
135-
} catch (error) {
136-
message.error('保存文件时发生错误: ' + (error instanceof Error ? error.message : '未知错误'));
176+
} catch {
177+
message.warning('无法通知服务器下载完成,但文件已成功下载');
137178
}
138-
} else {
139-
message.error('没有下载到任何文件数据');
179+
isDownloading.value = false;
180+
isFinished.value = true;
181+
message.success('文件下载完成!');
182+
return;
140183
}
141-
142-
isDownloading.value = false;
143-
isFinished.value = true;
184+
await new Promise(resolve => setTimeout(resolve, 1000));
185+
await downloadViaHttp();
144186
}
145187
146188
onMounted(async () => {
147189
if (localStorage.getItem("rid") == null || localStorage.getItem("rid") == "" || localStorage.getItem("rid") == undefined) {
148-
localStorage.setItem("rid", Math.random().toString(36).substring(16));
190+
localStorage.setItem("rid", Math.random().toString(36).slice(2, 10));
149191
}
150192
151193
// Get the file info from status API
@@ -154,7 +196,7 @@ onMounted(async () => {
154196
const pathParts = window.location.pathname.split('/');
155197
const fileId = pathParts[1]; // Assumes URL format is /{id}/file
156198
157-
const response = await fetch(`/api/${fileId}/status`);
199+
const response = await fetch(`/api/fileflow/${fileId}/status`);
158200
const statusData = await response.json();
159201
160202
if (!statusData.success) {
@@ -310,4 +352,4 @@ onMounted(async () => {
310352
:deep(.ant-progress-bg) {
311353
border-radius: 10px;
312354
}
313-
</style>
355+
</style>

0 commit comments

Comments
 (0)