|
| 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 | +} |
0 commit comments