Skip to content

Commit d7ef271

Browse files
electron: video: library: Allow "downloading" ZIP with chunks and telemetry file
1 parent 0577490 commit d7ef271

6 files changed

Lines changed: 258 additions & 55 deletions

File tree

src/components/VideoLibraryModal.vue

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -560,6 +560,18 @@
560560
</div>
561561
</div>
562562
<div class="flex gap-2 mt-4">
563+
<v-btn
564+
icon
565+
variant="outlined"
566+
size="small"
567+
:disabled="isProcessingChunks"
568+
@click="downloadChunkGroup(group)"
569+
>
570+
<v-tooltip open-delay="500" activator="parent" location="bottom">
571+
Download chunk group as ZIP
572+
</v-tooltip>
573+
<v-icon>mdi-download</v-icon>
574+
</v-btn>
563575
<v-btn
564576
icon
565577
variant="outlined"

src/composables/videoChunkManager.ts

Lines changed: 71 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -299,72 +299,88 @@ export const useVideoChunkManager = (): {
299299
* @returns {Promise<void>} Promise that resolves when download is complete
300300
*/
301301
const downloadChunkGroup = async (group: ChunkGroup): Promise<void> => {
302-
if (isElectron()) return
303-
304302
try {
305303
isProcessingChunks.value = true
306304

307-
const MAX_BATCH_SIZE = 1024 * 1024 * 1024 // 1GB
308-
const batches: Array<{
309-
/**
310-
* The chunks to add to the ZIP
311-
*/
312-
chunks: typeof group.chunks
313-
/**
314-
* The size of the current batch
315-
*/
316-
size: number
317-
}> = []
318-
let currentBatch: typeof group.chunks = []
319-
let currentBatchSize = 0
320-
321-
for (const chunk of group.chunks) {
322-
if (currentBatchSize + chunk.size > MAX_BATCH_SIZE && currentBatch.length > 0) {
323-
batches.push({ chunks: [...currentBatch], size: currentBatchSize })
324-
currentBatch = []
325-
currentBatchSize = 0
305+
if (isElectron()) {
306+
// Electron version: Create ZIP using filesystem (no memory limits)
307+
const zipFilePath = await window.electronAPI?.createVideoChunksZip(group.hash)
308+
309+
if (zipFilePath) {
310+
const count = group.chunkCount
311+
const msg = `ZIP file created successfully with ${count} chunks at: ${zipFilePath}`
312+
openSnackbar({ message: msg, variant: 'success' })
313+
} else {
314+
throw new Error('Failed to create ZIP file')
315+
}
316+
} else {
317+
// Web version: Create ZIP in memory (with 1GB limit)
318+
const MAX_BATCH_SIZE = 1024 * 1024 * 1024 // 1GB
319+
const batches: Array<{
320+
/**
321+
* The chunks to add to the ZIP
322+
*/
323+
chunks: typeof group.chunks
324+
/**
325+
* The size of the current batch
326+
*/
327+
size: number
328+
}> = []
329+
let currentBatch: typeof group.chunks = []
330+
let currentBatchSize = 0
331+
332+
for (const chunk of group.chunks) {
333+
if (currentBatchSize + chunk.size > MAX_BATCH_SIZE && currentBatch.length > 0) {
334+
batches.push({ chunks: [...currentBatch], size: currentBatchSize })
335+
currentBatch = []
336+
currentBatchSize = 0
337+
}
338+
currentBatch.push(chunk)
339+
currentBatchSize += chunk.size
326340
}
327-
currentBatch.push(chunk)
328-
currentBatchSize += chunk.size
329-
}
330341

331-
if (currentBatch.length > 0) {
332-
batches.push({ chunks: currentBatch, size: currentBatchSize })
333-
}
342+
if (currentBatch.length > 0) {
343+
batches.push({ chunks: currentBatch, size: currentBatchSize })
344+
}
334345

335-
// Download each batch
336-
for (let i = 0; i < batches.length; i++) {
337-
const batch = batches[i]
338-
const zipFilename = batches.length === 1 ? `chunks_${group.hash}.zip` : `chunks_${group.hash}_part${i + 1}.zip`
339-
340-
// Add chunks to ZIP
341-
const files = await Promise.all(
342-
batch.chunks.map(async (chunk) => {
343-
const chunkDate = await getChunkTimestamp(chunk.key)
344-
const blob = (await videoStore.tempVideoStorage.getItem(chunk.key)) as Blob
345-
return { file: { blob, filename: chunk.key }, lastModDate: chunkDate }
346-
})
347-
)
348-
349-
// Add .ass telemetry file (only in first batch)
350-
if (i === 0) {
351-
const assFile = await findAssTelemetryFile(group.hash)
352-
if (assFile) {
353-
files.push({ file: { blob: assFile.blob, filename: assFile.filename }, lastModDate: files[0].lastModDate })
354-
} else {
355-
openSnackbar({ message: 'Failed to find .ass telemetry file for the recording.', variant: 'error' })
346+
// Download each batch
347+
for (let i = 0; i < batches.length; i++) {
348+
const batch = batches[i]
349+
const zipFilename =
350+
batches.length === 1 ? `chunks_${group.hash}.zip` : `chunks_${group.hash}_part${i + 1}.zip`
351+
352+
// Add chunks to ZIP
353+
const files = await Promise.all(
354+
batch.chunks.map(async (chunk) => {
355+
const chunkDate = await getChunkTimestamp(chunk.key)
356+
const blob = (await videoStore.tempVideoStorage.getItem(chunk.key)) as Blob
357+
return { file: { blob, filename: chunk.key }, lastModDate: chunkDate }
358+
})
359+
)
360+
361+
// Add .ass telemetry file (only in first batch)
362+
if (i === 0) {
363+
const assFile = await findAssTelemetryFile(group.hash)
364+
if (assFile) {
365+
files.push({
366+
file: { blob: assFile.blob, filename: assFile.filename },
367+
lastModDate: files[0].lastModDate,
368+
})
369+
} else {
370+
openSnackbar({ message: 'Failed to find .ass telemetry file for the recording.', variant: 'error' })
371+
}
356372
}
357-
}
358373

359-
await videoStore.createZipAndDownload(files, zipFilename)
374+
await videoStore.createZipAndDownload(files, zipFilename)
360375

361-
if (batches.length > 1) {
362-
await new Promise((resolve) => setTimeout(resolve, 1000))
376+
if (batches.length > 1) {
377+
await new Promise((resolve) => setTimeout(resolve, 1000))
378+
}
363379
}
364-
}
365380

366-
const count = group.chunkCount
367-
openSnackbar({ message: `Downloaded ${count} chunks in ${batches.length} archive(s)`, variant: 'success' })
381+
const count = group.chunkCount
382+
openSnackbar({ message: `Downloaded ${count} chunks in ${batches.length} archive(s)`, variant: 'success' })
383+
}
368384
} catch (error) {
369385
openSnackbar({ message: 'Failed to download chunks', variant: 'error' })
370386
} finally {

src/electron/main.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { setupResourceMonitoringService } from './services/resource-monitoring'
1111
import { setupFilesystemStorage } from './services/storage'
1212
import { setupSystemInfoService } from './services/system-info'
1313
import { setupUserAgentService } from './services/user-agent'
14+
import { setupVideoChunksZipService } from './services/video-chunks-zip'
1415
import { setupVideoRecordingService } from './services/video-recording'
1516
import { setupWorkspaceService } from './services/workspace'
1617

@@ -88,6 +89,7 @@ setupUserAgentService()
8889
setupWorkspaceService()
8990
setupJoystickMonitoring()
9091
setupVideoRecordingService()
92+
setupVideoChunksZipService()
9193

9294
app.whenReady().then(async () => {
9395
console.log('Electron app is ready.')

src/electron/preload.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
5858
readChunkFile: (chunkPath: string) => ipcRenderer.invoke('read-chunk-file', chunkPath),
5959
copyTelemetryFile: (assFilePath: string, outputVideoPath: string) =>
6060
ipcRenderer.invoke('copy-telemetry-file', assFilePath, outputVideoPath),
61+
createVideoChunksZip: (hash: string) => ipcRenderer.invoke('create-video-chunks-zip', hash),
6162
cleanupTempDir: (tempDir: string) => ipcRenderer.invoke('cleanup-temp-dir', tempDir),
6263
captureWorkspace: (rect?: Electron.Rectangle) => ipcRenderer.invoke('capture-workspace', rect),
6364
serialListPorts: () => ipcRenderer.invoke('serial-list-ports'),
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
import { format } from 'date-fns'
2+
import { ipcMain } from 'electron'
3+
import { promises as fs } from 'fs'
4+
import { createWriteStream } from 'fs'
5+
import { join } from 'path'
6+
import * as yazl from 'yazl'
7+
8+
import { cockpitFolderPath, filesystemStorage } from './storage'
9+
10+
/**
11+
* Create a ZIP file containing video chunks and telemetry file
12+
* @param {string} hash - The hash identifier for the chunk group
13+
* @returns {Promise<string>} Path to the created ZIP file
14+
*/
15+
const createVideoChunksZip = async (hash: string): Promise<string> => {
16+
console.debug(`Creating ZIP file for chunk group ${hash}`)
17+
18+
// Find all chunk files for this hash
19+
const tempChunksPath = join(cockpitFolderPath, 'videos', 'temporary-video-chunks')
20+
21+
let chunkFiles: string[] = []
22+
try {
23+
const allFiles = await fs.readdir(tempChunksPath)
24+
chunkFiles = allFiles
25+
.filter((file) => file.includes(hash))
26+
.sort((a, b) => {
27+
// Extract chunk numbers from filenames like "hash_0", "hash_1", etc.
28+
const aMatch = a.match(/_(\d+)/)
29+
const bMatch = b.match(/_(\d+)/)
30+
31+
if (aMatch && bMatch) {
32+
return parseInt(aMatch[1], 10) - parseInt(bMatch[1], 10)
33+
}
34+
35+
return a.localeCompare(b)
36+
})
37+
} catch (error) {
38+
console.error(`Failed to read temporary chunks directory:`, error)
39+
throw new Error('Failed to access temporary chunks directory')
40+
}
41+
42+
if (chunkFiles.length === 0) {
43+
throw new Error(`No chunks found for hash ${hash}`)
44+
}
45+
46+
console.debug(`Found ${chunkFiles.length} chunks for hash ${hash}`)
47+
48+
// Find .ass telemetry file
49+
let assFileName: string | null = null
50+
try {
51+
const videoKeys = await filesystemStorage.keys(['videos'])
52+
assFileName = videoKeys.find((key) => key.includes(hash) && key.endsWith('.ass')) || null
53+
} catch (error) {
54+
console.warn(`Failed to find .ass file for hash ${hash}:`, error)
55+
}
56+
57+
// Generate default filename based on creation date
58+
let defaultFileName = `chunks_${hash}`
59+
try {
60+
const firstChunkPath = join(tempChunksPath, chunkFiles[0])
61+
const stats = await fs.stat(firstChunkPath)
62+
const creationDate = stats.birthtime || stats.mtime
63+
const timeString = format(creationDate, 'LLL dd, yyyy - HH꞉mm꞉ss O')
64+
defaultFileName = assFileName ? assFileName.replace('.ass', '') : `Cockpit (${timeString}) #${hash}`
65+
} catch (error) {
66+
console.warn(`Failed to get creation date, using default filename:`, error)
67+
}
68+
69+
// Generate ZIP filename
70+
const tempChunksFolderPath = join(cockpitFolderPath, 'videos', 'temporary-video-chunks')
71+
const zipFilename = `${defaultFileName}.zip`
72+
const zipFilePath = join(tempChunksFolderPath, zipFilename)
73+
74+
return new Promise((resolve, reject) => {
75+
const zipfile = new yazl.ZipFile()
76+
77+
// Set up the output stream
78+
const outputStream = createWriteStream(zipFilePath)
79+
80+
zipfile.outputStream.pipe(outputStream)
81+
82+
let addedFiles = 0
83+
const totalFiles = chunkFiles.length + (assFileName ? 1 : 0)
84+
85+
// Function to check if all files have been added
86+
const checkComplete = (): void => {
87+
addedFiles++
88+
if (addedFiles >= totalFiles) {
89+
zipfile.end()
90+
}
91+
}
92+
93+
// Add video chunks to ZIP
94+
chunkFiles.forEach((chunkFile) => {
95+
const chunkPath = join(tempChunksPath, chunkFile)
96+
97+
// Check if chunk file exists and get its stats
98+
fs.access(chunkPath)
99+
.then(() => fs.stat(chunkPath))
100+
.then((stats) => {
101+
zipfile.addFile(chunkPath, chunkFile, {
102+
mtime: stats.mtime,
103+
})
104+
console.debug(`Added chunk ${chunkFile} to ZIP`)
105+
checkComplete()
106+
})
107+
.catch((error) => {
108+
console.error(`Failed to add chunk ${chunkFile} to ZIP:`, error)
109+
checkComplete()
110+
})
111+
})
112+
113+
// Add .ass telemetry file if found
114+
if (assFileName) {
115+
const assFilePath = join(cockpitFolderPath, 'videos', assFileName)
116+
117+
fs.access(assFilePath)
118+
.then(() => fs.stat(assFilePath))
119+
.then((stats) => {
120+
zipfile.addFile(assFilePath, assFileName, {
121+
mtime: stats.mtime,
122+
})
123+
console.debug(`Added .ass file ${assFileName} to ZIP`)
124+
checkComplete()
125+
})
126+
.catch((error) => {
127+
console.warn(`Failed to add .ass file ${assFileName} to ZIP:`, error)
128+
checkComplete()
129+
})
130+
}
131+
132+
// Handle ZIP completion
133+
outputStream.on('close', () => {
134+
console.debug(`ZIP file created successfully: ${zipFilePath}`)
135+
resolve(zipFilePath)
136+
})
137+
138+
outputStream.on('error', (error) => {
139+
console.error('Error creating ZIP file:', error)
140+
reject(error)
141+
})
142+
143+
zipfile.on('error', (error: Error) => {
144+
console.error('ZIP file error:', error)
145+
reject(error)
146+
})
147+
})
148+
}
149+
150+
/**
151+
* Setup ZIP service IPC handlers for Electron main process
152+
*/
153+
export const setupVideoChunksZipService = (): void => {
154+
/**
155+
* Create a ZIP file containing video chunks and telemetry
156+
*/
157+
ipcMain.handle('create-video-chunks-zip', async (_, hash: string) => {
158+
try {
159+
const zipFilePath = await createVideoChunksZip(hash)
160+
return zipFilePath
161+
} catch (error) {
162+
console.error('Error creating video chunks ZIP:', error)
163+
throw error
164+
}
165+
})
166+
}

src/libs/cosmos.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -421,6 +421,12 @@ declare global {
421421
* @param outputVideoPath - Path to the output video file
422422
*/
423423
copyTelemetryFile: (assFilePath: string, outputVideoPath: string) => Promise<void>
424+
/**
425+
* Create a ZIP file containing video chunks and telemetry file
426+
* @param hash - The hash identifier for the chunk group
427+
* @returns Promise resolving to the path to the created ZIP file
428+
*/
429+
createVideoChunksZip: (hash: string) => Promise<string>
424430
/**
425431
* Clean up temporary directory
426432
* @param tempDir - Path to the temporary directory to remove

0 commit comments

Comments
 (0)