diff --git a/electron-app/electron/apiServer.js b/electron-app/electron/apiServer.js index 027e25b..a8571f9 100644 --- a/electron-app/electron/apiServer.js +++ b/electron-app/electron/apiServer.js @@ -209,7 +209,7 @@ function startApiServer(appStore) { const data = await readBody(req) if (!data.path) return json(res, { error: 'Not found' }, 404) if (!isAllowedPath(data.path)) return json(res, { error: 'Forbidden' }, 403) - const result = service.deleteFile(data.path) + const result = await service.deleteFile(data.path) return json(res, result, result.status || 200) } @@ -218,7 +218,7 @@ function startApiServer(appStore) { const data = await readBody(req) if (!data.path) return json(res, { error: 'Not found' }, 404) if (!isAllowedPath(data.path)) return json(res, { error: 'Forbidden' }, 403) - const result = service.deleteFile(data.path) + const result = await service.deleteFile(data.path) return json(res, result, result.status || 200) } @@ -371,7 +371,7 @@ function startApiServer(appStore) { skippedLocked.push(p) continue } - const result = service.deleteFile(p) + const result = await service.deleteFile(p) if (result.success) deleted.push(p) else failed.push({ path: p, error: result.error }) } diff --git a/electron-app/electron/fileManager.js b/electron-app/electron/fileManager.js index 0a083cd..5bd9576 100644 --- a/electron-app/electron/fileManager.js +++ b/electron-app/electron/fileManager.js @@ -248,7 +248,7 @@ async function organizeRecordings(store, gameName, onProgress = () => {}) { console.error('Waveform pre-caching failed before delete:', error) } try { - fs.unlinkSync(movedTo) + await unlinkWithRetry(movedTo) } catch {} } } @@ -536,7 +536,7 @@ async function finalizeDirectRecording(store, gameName, recordingDir, onProgress console.error('Waveform pre-caching failed before delete:', error) } try { - fs.unlinkSync(movedTo) + await unlinkWithRetry(movedTo) } catch {} } } diff --git a/electron-app/electron/ipcHandlers/watcherHandlers.js b/electron-app/electron/ipcHandlers/watcherHandlers.js index d7c49ff..4e26c64 100644 --- a/electron-app/electron/ipcHandlers/watcherHandlers.js +++ b/electron-app/electron/ipcHandlers/watcherHandlers.js @@ -21,11 +21,9 @@ function registerWatcherHandlers(ipcMain, store, appState) { } const { runAutoDelete } = require('../recordingService') - try { - runAutoDelete() - } catch (err) { + runAutoDelete().catch((err) => { console.warn('[watcherHandlers] runAutoDelete failed (non-fatal):', err.message) - } + }) try { appState.watcherStartedAt = Date.now() diff --git a/electron-app/electron/main.js b/electron-app/electron/main.js index d062863..2cd2a97 100644 --- a/electron-app/electron/main.js +++ b/electron-app/electron/main.js @@ -267,9 +267,7 @@ app.whenReady().then(async () => { if (store.get('settings.startWatcherOnStartup')) { ensureGameState() const { runAutoDelete } = require('./recordingService') - try { - runAutoDelete() - } catch {} + runAutoDelete().catch(() => {}) appState.watcherStartedAt = Date.now() appState.watcher = setupGameWatcher( store, diff --git a/electron-app/electron/recordingService.js b/electron-app/electron/recordingService.js index 2c657fa..84ff252 100644 --- a/electron-app/electron/recordingService.js +++ b/electron-app/electron/recordingService.js @@ -6,6 +6,7 @@ const fs = require('fs') const path = require('path') const { execFile } = require('child_process') const { isVideoFile, formatFileSize, CODEC_MAP, FFMPEG_PATH } = require('./constants') +const { waitForUnlock, unlinkWithRetry } = require('./fileOperations') let store // set via init() @@ -716,9 +717,17 @@ async function finalizeTrim(sourcePath) { } } -function deleteFile(filePath) { +async function deleteFile(filePath) { try { - fs.unlinkSync(filePath) + // Wait for any open stream (e.g. video player range requests) to release the handle + // before attempting deletion; retries up to ~2 seconds on EPERM/EBUSY/EACCES. + try { + await waitForUnlock(filePath, 4, 500) + } catch { + // If still locked after retries, attempt deletion anyway — unlinkWithRetry + // will handle transient holds and surface a clear error if it truly fails. + } + await unlinkWithRetry(filePath, 4, 500) try { fs.unlinkSync(filePath + '.tracks.json') } catch {} @@ -861,7 +870,7 @@ function reencodeVideo( * 2. Pushing total storage over max_storage_gb (oldest deleted first). * Returns a summary { deleted, skipped, errors }. */ -function runAutoDelete() { +async function runAutoDelete() { const settings = store.get('storageSettings') || {} if (!settings.auto_delete_enabled) return { deleted: 0, skipped: 0, errors: 0 } @@ -902,7 +911,7 @@ function runAutoDelete() { let deleted = 0, errors = 0 for (const filePath of toDelete) { - const result = deleteFile(filePath) + const result = await deleteFile(filePath) if (result.success) deleted++ else errors++ } diff --git a/electron-app/src/viewer/components/VideoPlayer.jsx b/electron-app/src/viewer/components/VideoPlayer.jsx index 60832c7..d0117f7 100644 --- a/electron-app/src/viewer/components/VideoPlayer.jsx +++ b/electron-app/src/viewer/components/VideoPlayer.jsx @@ -815,11 +815,28 @@ function VideoPlayer({ return () => unsub?.() }, []) + const handleDelete = useCallback(() => { + // Close the video stream before the parent's delete flow begins — on Windows, + // open file handles cause EPERM on unlink. + if (videoRef.current) { + videoRef.current.pause() + videoRef.current.removeAttribute('src') + videoRef.current.load() + } + onDelete?.() + }, [onDelete]) + const handleOrganize = useCallback(async () => { if (!media || !organizeGame || isOrganizing) return setIsOrganizing(true) setIsManualOrganizing(true) setOrganizeProgress(null) + // Release the file handle before rename — Windows blocks rename on open files. + if (videoRef.current) { + videoRef.current.pause() + videoRef.current.removeAttribute('src') + videoRef.current.load() + } try { const result = await api.organizeRecording(media.path, organizeGame, organizeRemux) if (result && result.success) { @@ -1105,7 +1122,7 @@ function VideoPlayer({ isCreatingClip={isCreatingClip} handleTrimClip={handleTrimClip} trimPending={trimPending} - onDelete={onDelete} + onDelete={handleDelete} onShare={isClip ? handleShare : undefined} onShareRemove={isClip ? handleShareRemove : undefined} isSharing={isSharing} diff --git a/electron-app/tests/unit/recordingService.test.js b/electron-app/tests/unit/recordingService.test.js index b523236..c2c8955 100644 --- a/electron-app/tests/unit/recordingService.test.js +++ b/electron-app/tests/unit/recordingService.test.js @@ -206,32 +206,32 @@ describe('countClipsForDate', () => { // ─── deleteFile ─────────────────────────────────────────────────── describe('deleteFile', () => { - it('deletes a file that exists', () => { + it('deletes a file that exists', async () => { const fp = path.join(destDir, 'rec.mp4') makeFile(fp) - expect(service.deleteFile(fp)).toEqual({ success: true }) + expect(await service.deleteFile(fp)).toEqual({ success: true }) expect(fs.existsSync(fp)).toBe(false) }) - it('also deletes .tracks.json sidecar', () => { + it('also deletes .tracks.json sidecar', async () => { const fp = path.join(destDir, 'rec.mp4') makeFile(fp) fs.writeFileSync(fp + '.tracks.json', '[]') - service.deleteFile(fp) + await service.deleteFile(fp) expect(fs.existsSync(fp + '.tracks.json')).toBe(false) }) - it('returns error for non-existent file', () => { - const result = service.deleteFile(path.join(destDir, 'missing.mp4')) + it('returns error for non-existent file', async () => { + const result = await service.deleteFile(path.join(destDir, 'missing.mp4')) expect(result.error).toBeDefined() expect(result.status).toBe(404) }) - it('invalidates cache after deletion', () => { + it('invalidates cache after deletion', async () => { const fp = path.join(destDir, 'Halo', 'Halo Session 2025-01-15 #1.mp4') makeFile(fp) service.scanRecordings() - service.deleteFile(fp) + await service.deleteFile(fp) expect(service.scanRecordings().every((r) => r.path !== fp)).toBe(true) }) }) @@ -270,7 +270,7 @@ describe('scoped cache invalidation', () => { expect(service.scanRecordings()).toBe(recs1) // same reference — recordings cache intact }) - it('deleteFile of a recording only invalidates the recordings cache', () => { + it('deleteFile of a recording only invalidates the recordings cache', async () => { const clipFp = path.join(clipsDir, 'Halo Clip 2025-01-15 #1.mp4') const recFp = path.join(destDir, 'Halo', 'Halo Session 2025-01-15 #1.mp4') makeFile(clipFp) @@ -278,14 +278,14 @@ describe('scoped cache invalidation', () => { service.invalidateCache() const clips1 = service.scanClips() service.scanRecordings() - service.deleteFile(recFp) + await service.deleteFile(recFp) // Clips cache should be untouched (same reference) expect(service.scanClips()).toBe(clips1) // Recordings cache was cleared so the deleted file is gone expect(service.scanRecordings().every((r) => r.path !== recFp)).toBe(true) }) - it('deleteFile of a clip only invalidates the clips cache', () => { + it('deleteFile of a clip only invalidates the clips cache', async () => { const clipFp = path.join(clipsDir, 'Halo Clip 2025-01-15 #1.mp4') const recFp = path.join(destDir, 'Halo', 'Halo Session 2025-01-15 #1.mp4') makeFile(clipFp) @@ -293,7 +293,7 @@ describe('scoped cache invalidation', () => { service.invalidateCache() const recs1 = service.scanRecordings() service.scanClips() - service.deleteFile(clipFp) + await service.deleteFile(clipFp) // Recordings cache should be untouched (same reference) expect(service.scanRecordings()).toBe(recs1) // Clips cache was cleared so the deleted clip is gone @@ -478,12 +478,12 @@ describe('trimClip', () => { // ─── runAutoDelete ──────────────────────────────────────────────── describe('runAutoDelete', () => { - it('returns zeros when auto-delete is disabled', () => { + it('returns zeros when auto-delete is disabled', async () => { store._data.storageSettings = { auto_delete_enabled: false } - expect(service.runAutoDelete()).toEqual({ deleted: 0, skipped: 0, errors: 0 }) + expect(await service.runAutoDelete()).toEqual({ deleted: 0, skipped: 0, errors: 0 }) }) - it('deletes files older than max_age_days', () => { + it('deletes files older than max_age_days', async () => { store._data.storageSettings = { auto_delete_enabled: true, max_age_days: 1, @@ -494,11 +494,11 @@ describe('runAutoDelete', () => { makeFile(fp) fs.utimesSync(fp, new Date(Date.now() - 2 * 86400000), new Date(Date.now() - 2 * 86400000)) service.invalidateCache() - expect(service.runAutoDelete().deleted).toBe(1) + expect((await service.runAutoDelete()).deleted).toBe(1) expect(fs.existsSync(fp)).toBe(false) }) - it('skips locked recordings', () => { + it('skips locked recordings', async () => { const fp = path.join(destDir, 'Halo', 'Halo Session 2025-01-01 #1.mp4') makeFile(fp) fs.utimesSync(fp, new Date(Date.now() - 2 * 86400000), new Date(Date.now() - 2 * 86400000)) @@ -510,11 +510,11 @@ describe('runAutoDelete', () => { exclude_clips: true, } service.invalidateCache() - expect(service.runAutoDelete().skipped).toBeGreaterThan(0) + expect((await service.runAutoDelete()).skipped).toBeGreaterThan(0) expect(fs.existsSync(fp)).toBe(true) }) - it('does not delete clips when exclude_clips is true', () => { + it('does not delete clips when exclude_clips is true', async () => { const fp = path.join(clipsDir, 'Halo Clip 2025-01-01 #1.mp4') makeFile(fp) fs.utimesSync(fp, new Date(Date.now() - 2 * 86400000), new Date(Date.now() - 2 * 86400000)) @@ -525,11 +525,11 @@ describe('runAutoDelete', () => { exclude_clips: true, } service.invalidateCache() - service.runAutoDelete() + await service.runAutoDelete() expect(fs.existsSync(fp)).toBe(true) }) - it('deletes clips when exclude_clips is false', () => { + it('deletes clips when exclude_clips is false', async () => { const fp = path.join(clipsDir, 'Halo Clip 2025-01-01 #1.mp4') makeFile(fp) fs.utimesSync(fp, new Date(Date.now() - 2 * 86400000), new Date(Date.now() - 2 * 86400000)) @@ -540,7 +540,7 @@ describe('runAutoDelete', () => { exclude_clips: false, } service.invalidateCache() - expect(service.runAutoDelete().deleted).toBe(1) + expect((await service.runAutoDelete()).deleted).toBe(1) expect(fs.existsSync(fp)).toBe(false) }) })