Skip to content
Merged
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
6 changes: 3 additions & 3 deletions electron-app/electron/apiServer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand All @@ -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)
}

Expand Down Expand Up @@ -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 })
}
Expand Down
4 changes: 2 additions & 2 deletions electron-app/electron/fileManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
}
}
Expand Down Expand Up @@ -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 {}
}
}
Expand Down
6 changes: 2 additions & 4 deletions electron-app/electron/ipcHandlers/watcherHandlers.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
4 changes: 1 addition & 3 deletions electron-app/electron/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
17 changes: 13 additions & 4 deletions electron-app/electron/recordingService.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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 {}
Expand Down Expand Up @@ -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 }

Expand Down Expand Up @@ -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++
}
Expand Down
19 changes: 18 additions & 1 deletion electron-app/src/viewer/components/VideoPlayer.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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}
Expand Down
44 changes: 22 additions & 22 deletions electron-app/tests/unit/recordingService.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
})
Expand Down Expand Up @@ -270,30 +270,30 @@ 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)
makeFile(recFp)
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)
makeFile(recFp)
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
Expand Down Expand Up @@ -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,
Expand All @@ -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))
Expand All @@ -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))
Expand All @@ -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))
Expand All @@ -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)
})
})
Expand Down
Loading