Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Save videos directly to the filesystem (electron-only) #1358

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { app, BrowserWindow, protocol, screen } from 'electron'
import { join } from 'path'

import { setupNetworkService } from './services/network'
import { setupFilesystemStorage } from './services/storage'

export const ROOT_PATH = {
dist: join(__dirname, '..'),
Expand Down Expand Up @@ -61,9 +62,16 @@ protocol.registerSchemesAsPrivileged([
},
])

setupFilesystemStorage()
setupNetworkService()

app.whenReady().then(createWindow)
app.whenReady().then(async () => {
console.log('Electron app is ready.')
console.log(`Cockpit version: ${app.getVersion()}`)

console.log('Creating window...')
createWindow()
})

app.on('before-quit', () => {
// @ts-ignore: import.meta.env does not exist in the types
Expand Down
17 changes: 17 additions & 0 deletions electron/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,21 @@ import { contextBridge, ipcRenderer } from 'electron'

contextBridge.exposeInMainWorld('electronAPI', {
getInfoOnSubnets: () => ipcRenderer.invoke('get-info-on-subnets'),
setItem: async (key: string, value: Blob, subFolders?: string[]) => {
const arrayBuffer = await value.arrayBuffer()
await ipcRenderer.invoke('setItem', { key, value: new Uint8Array(arrayBuffer), subFolders })
},
getItem: async (key: string, subFolders?: string[]) => {
const arrayBuffer = await ipcRenderer.invoke('getItem', { key, subFolders })
return arrayBuffer ? new Blob([arrayBuffer]) : null
},
removeItem: async (key: string, subFolders?: string[]) => {
await ipcRenderer.invoke('removeItem', { key, subFolders })
},
clear: async (subFolders?: string[]) => {
await ipcRenderer.invoke('clear', { subFolders })
},
keys: async (subFolders?: string[]) => {
return await ipcRenderer.invoke('keys', { subFolders })
},
})
61 changes: 61 additions & 0 deletions electron/services/storage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { ipcMain } from 'electron'
import { app } from 'electron'
import * as fs from 'fs/promises'
import { dirname, join } from 'path'

// Create a new storage interface for filesystem
const cockpitFolderPath = join(app.getPath('home'), 'Cockpit')
fs.mkdir(cockpitFolderPath, { recursive: true })

export const filesystemStorage = {
async setItem(key: string, value: ArrayBuffer, subFolders?: string[]): Promise<void> {
const buffer = Buffer.from(value)
const filePath = join(cockpitFolderPath, ...(subFolders ?? []), key)
await fs.mkdir(dirname(filePath), { recursive: true })
await fs.writeFile(filePath, buffer)
},
async getItem(key: string, subFolders?: string[]): Promise<ArrayBuffer | null> {
const filePath = join(cockpitFolderPath, ...(subFolders ?? []), key)
try {
return await fs.readFile(filePath)
} catch (error) {
if (error.code === 'ENOENT') return null
throw error
}
},
async removeItem(key: string, subFolders?: string[]): Promise<void> {
const filePath = join(cockpitFolderPath, ...(subFolders ?? []), key)
await fs.unlink(filePath)
},
async clear(subFolders?: string[]): Promise<void> {
const dirPath = join(cockpitFolderPath, ...(subFolders ?? []))
await fs.rm(dirPath, { recursive: true })
},
async keys(subFolders?: string[]): Promise<string[]> {
const dirPath = join(cockpitFolderPath, ...(subFolders ?? []))
try {
return await fs.readdir(dirPath)
} catch (error) {
if (error.code === 'ENOENT') return []
throw error
}
},
}

export const setupFilesystemStorage = (): void => {
ipcMain.handle('setItem', async (_, data) => {
await filesystemStorage.setItem(data.key, data.value, data.subFolders)
})
ipcMain.handle('getItem', async (_, data) => {
return await filesystemStorage.getItem(data.key, data.subFolders)
})
ipcMain.handle('removeItem', async (_, data) => {
await filesystemStorage.removeItem(data.key, data.subFolders)
})
ipcMain.handle('clear', async (_, data) => {
await filesystemStorage.clear(data.subFolders)
})
ipcMain.handle('keys', async (_, data) => {
return await filesystemStorage.keys(data.subFolders)
})
}
9 changes: 5 additions & 4 deletions src/components/VideoLibraryModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -889,11 +889,12 @@ const fetchVideosAndLogData = async (): Promise<void> => {
const logFileOperations: Promise<VideoLibraryLogFile>[] = []

// Fetch processed videos and logs
await videoStore.videoStoringDB.iterate((value, key) => {
const keys = await videoStore.videoStorage.keys()
for (const key of keys) {
if (videoStore.isVideoFilename(key)) {
videoFilesOperations.push(
(async () => {
const videoBlob = await videoStore.videoStoringDB.getItem<Blob>(key)
const videoBlob = await videoStore.videoStorage.getItem(key)
let url = ''
let isProcessed = true
if (videoBlob instanceof Blob) {
Expand All @@ -910,7 +911,7 @@ const fetchVideosAndLogData = async (): Promise<void> => {
if (key.endsWith('.ass')) {
logFileOperations.push(
(async () => {
const videoBlob = await videoStore.videoStoringDB.getItem<Blob>(key)
const videoBlob = await videoStore.videoStorage.getItem(key)
let url = ''
if (videoBlob instanceof Blob) {
url = URL.createObjectURL(videoBlob)
Expand All @@ -923,7 +924,7 @@ const fetchVideosAndLogData = async (): Promise<void> => {
})()
)
}
})
}

// Fetch unprocessed videos
const unprocessedVideos = await videoStore.unprocessedVideos
Expand Down
3 changes: 2 additions & 1 deletion src/components/mini-widgets/MiniVideoRecorder.vue
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,8 @@ watch(nameSelectedStream, (newName) => {

// Fetch number of temporary videos on storage
const fetchNumberOfTempVideos = async (): Promise<void> => {
const nProcessedVideos = (await videoStore.videoStoringDB.keys()).filter((k) => videoStore.isVideoFilename(k)).length
const keys = await videoStore.videoStorage.keys()
const nProcessedVideos = keys.filter((k) => videoStore.isVideoFilename(k)).length
const nFailedUnprocessedVideos = Object.keys(videoStore.keysFailedUnprocessedVideos).length
numberOfVideosOnDB.value = nProcessedVideos + nFailedUnprocessedVideos
}
Expand Down
3 changes: 2 additions & 1 deletion src/libs/cosmos.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { isBrowser } from 'browser-or-node'

import { ElectronStorageDB } from '@/types/general'
import { NetworkInfo } from '@/types/network'

import {
Expand Down Expand Up @@ -107,7 +108,7 @@ declare global {
/**
* Electron API exposed through preload script
*/
electronAPI?: {
electronAPI?: ElectronStorageDB & {
/**
* Get network information from the main process
* @returns Promise containing subnet information
Expand Down
127 changes: 127 additions & 0 deletions src/libs/videoStorage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import localforage from 'localforage'

import type { ElectronStorageDB, StorageDB } from '@/types/general'

import { isElectron } from './utils'

const throwIfNotElectron = (): void => {
if (!isElectron()) {
console.warn('Filesystem storage is only available in Electron.')
return
}
if (!window.electronAPI) {
console.error('electronAPI is not available on window object')
console.debug('Available window properties:', Object.keys(window))
throw new Error('Electron filesystem API is not properly initialized. This is likely a setup issue.')
}
}

/**
* Electron storage implementation.
* Uses the exposed IPC renderer API to store and retrieve data in the filesystem.
*/
class ElectronStorage implements ElectronStorageDB {
subFolders: string[]
electronAPI: ElectronStorageDB

/**
* Creates a new instance of the ElectronStorage class.
* @param {string[]} subFolders - The subfolders to store the data in.
*/
constructor(subFolders: string[]) {
throwIfNotElectron()

this.subFolders = subFolders
this.electronAPI = window.electronAPI as StorageDB
}

setItem = async (key: string, value: Blob): Promise<void> => {
throwIfNotElectron()
await this.electronAPI.setItem(key, value, this.subFolders)
}

getItem = async (key: string): Promise<Blob | null | undefined> => {
throwIfNotElectron()
return await this.electronAPI.getItem(key, this.subFolders)
}

removeItem = async (key: string): Promise<void> => {
throwIfNotElectron()
await this.electronAPI.removeItem(key, this.subFolders)
}

clear = async (): Promise<void> => {
throwIfNotElectron()
await this.electronAPI.clear(this.subFolders)
}

keys = async (): Promise<string[]> => {
throwIfNotElectron()
return await this.electronAPI.keys(this.subFolders)
}
}

/**
* LocalForage storage implementation.
* Uses the localforage library to store and retrieve data in the IndexedDB.
*/
class LocalForageStorage implements StorageDB {
localForage: LocalForage

/**
* Creates a new instance of the LocalForageStorage class.
* @param {string} name - The name of the localforage instance.
* @param {string} storeName - The name of the store to store the data in.
* @param {number} version - The version of the localforage instance.
* @param {string} description - The description of the localforage instance.
*/
constructor(name: string, storeName: string, version: number, description: string) {
this.localForage = localforage.createInstance({
driver: localforage.INDEXEDDB,
name: name,
storeName: storeName,
version: version,
description: description,
})
}

setItem = async (key: string, value: Blob): Promise<void> => {
await this.localForage.setItem(key, value)
}

getItem = async (key: string): Promise<Blob | null | undefined> => {
return await this.localForage.getItem(key)
}

removeItem = async (key: string): Promise<void> => {
await this.localForage.removeItem(key)
}

clear = async (): Promise<void> => {
await this.localForage.clear()
}

keys = async (): Promise<string[]> => {
return await this.localForage.keys()
}
}

const tempVideoChunksIndexdedDB: StorageDB = new LocalForageStorage(
'Cockpit - Temporary Video',
'cockpit-temp-video-db',
1.0,
'Database for storing the chunks of an ongoing recording, to be merged afterwards.'
)

const videoStoringIndexedDB: StorageDB = new LocalForageStorage(
'Cockpit - Video Recovery',
'cockpit-video-recovery-db',
1.0,
'Cockpit video recordings and their corresponding telemetry subtitles.'
)

const electronVideoStorage = new ElectronStorage(['videos'])
const temporaryElectronVideoStorage = new ElectronStorage(['videos', 'temporary-video-chunks'])

export const videoStorage = isElectron() ? electronVideoStorage : videoStoringIndexedDB
export const tempVideoStorage = isElectron() ? temporaryElectronVideoStorage : tempVideoChunksIndexdedDB
Loading
Loading