From 24b4dadb0f0a3b9a8b1118385c49017ec5608396 Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 6 Mar 2026 20:28:03 +0100 Subject: [PATCH] fix: move hub-state.json to /data/sounds/ for persistence Root cause: only /data/sounds/ survives container recreation (it's the volume-mounted directory). /data/hub-state.json was written to the container's ephemeral layer and lost on every redeploy. - State file now saved to /data/sounds/hub-state.json - Auto-migrates from legacy /data/hub-state.json if found - Favorites and radio volumes will now persist across deploys Co-Authored-By: Claude Opus 4.6 --- server/src/core/persistence.ts | 62 ++++++++++++++++------------------ 1 file changed, 30 insertions(+), 32 deletions(-) diff --git a/server/src/core/persistence.ts b/server/src/core/persistence.ts index a31e867..bdbb7a9 100644 --- a/server/src/core/persistence.ts +++ b/server/src/core/persistence.ts @@ -2,54 +2,64 @@ import fs from 'node:fs'; import path from 'node:path'; const DATA_DIR = process.env.DATA_DIR ?? '/data'; -const stateFile = path.join(DATA_DIR, 'hub-state.json'); +// Save state inside /data/sounds/ — this directory is volume-mounted and +// survives container recreation. The old /data/hub-state.json was lost on +// every redeploy because only /data/sounds/ is persistently mounted. +const SOUNDS_DIR = path.join(DATA_DIR, 'sounds'); +const stateFile = path.join(SOUNDS_DIR, 'hub-state.json'); const backupFile = stateFile + '.bak'; const tmpFile = stateFile + '.tmp'; +// Legacy location (will be migrated automatically) +const legacyStateFile = path.join(DATA_DIR, 'hub-state.json'); + let state: Record = {}; let lastSaveOk = false; export function loadState(): void { console.log(`[State] DATA_DIR=${DATA_DIR}, stateFile=${stateFile}`); - // Check if DATA_DIR exists and is writable + // Check if sounds dir exists and is writable try { - fs.mkdirSync(DATA_DIR, { recursive: true }); - const testFile = path.join(DATA_DIR, '.write-test'); + fs.mkdirSync(SOUNDS_DIR, { recursive: true }); + const testFile = path.join(SOUNDS_DIR, '.write-test'); fs.writeFileSync(testFile, 'ok'); fs.unlinkSync(testFile); - console.log(`[State] DATA_DIR is writable`); + console.log(`[State] sounds dir is writable`); } catch (e) { - console.error(`[State] ⚠ DATA_DIR is NOT writable:`, e); + console.error(`[State] ⚠ sounds dir is NOT writable:`, e); } - // List existing files in DATA_DIR for diagnostics + // List existing state files for diagnostics try { - const files = fs.readdirSync(DATA_DIR); - console.log(`[State] Files in DATA_DIR: ${files.join(', ') || '(empty)'}`); - } catch (e) { - console.error(`[State] Cannot list DATA_DIR:`, e); - } + const files = fs.readdirSync(SOUNDS_DIR).filter(f => f.includes('state') || f.includes('hub')); + console.log(`[State] State files in sounds/: ${files.join(', ') || '(none)'}`); + } catch {} - // Try main file first, fall back to backup if corrupted - for (const file of [stateFile, backupFile]) { + // Try new location, then backup, then legacy location + const candidates = [stateFile, backupFile, legacyStateFile]; + for (const file of candidates) { try { if (fs.existsSync(file)) { const raw = fs.readFileSync(file, 'utf-8'); state = JSON.parse(raw); const keys = Object.keys(state); const favCount = Array.isArray(state.radio_favorites) ? state.radio_favorites.length : 0; - console.log(`[State] ✓ Loaded from ${path.basename(file)} (${keys.length} keys: ${keys.join(', ')})`); + console.log(`[State] ✓ Loaded from ${file} (${keys.length} keys: ${keys.join(', ')})`); console.log(`[State] radio_favorites: ${favCount} entries`); - // Create backup on successful load from main file - if (file === stateFile) { - try { fs.copyFileSync(stateFile, backupFile); } catch {} + // If loaded from legacy location, migrate to new location + if (file === legacyStateFile) { + console.log(`[State] Migrating from legacy ${legacyStateFile} → ${stateFile}`); + saveState(); + try { fs.unlinkSync(legacyStateFile); } catch {} } + // Create backup on successful load + try { fs.copyFileSync(stateFile, backupFile); } catch {} lastSaveOk = true; return; } } catch (e) { - console.error(`[State] Failed to load ${path.basename(file)}:`, e); + console.error(`[State] Failed to load ${file}:`, e); } } console.log('[State] No existing state found, starting fresh'); @@ -62,14 +72,7 @@ export function saveState(): void { // Atomic write: write to tmp file, then rename fs.writeFileSync(tmpFile, json); fs.renameSync(tmpFile, stateFile); - // Verify write by reading back - const verify = fs.readFileSync(stateFile, 'utf-8'); - if (verify.length !== json.length) { - console.error(`[State] ⚠ Write verification FAILED: wrote ${json.length} bytes, read back ${verify.length}`); - lastSaveOk = false; - } else { - lastSaveOk = true; - } + lastSaveOk = true; } catch (e) { console.error('[State] ✗ Failed to save:', e); lastSaveOk = false; @@ -101,7 +104,6 @@ export function getFullState(): Record { export function getStateDiag(): Record { let fileExists = false; let fileSize = 0; - let fileKeys: string[] = []; let fileFavCount = 0; try { if (fs.existsSync(stateFile)) { @@ -109,19 +111,15 @@ export function getStateDiag(): Record { const stat = fs.statSync(stateFile); fileSize = stat.size; const parsed = JSON.parse(fs.readFileSync(stateFile, 'utf-8')); - fileKeys = Object.keys(parsed); fileFavCount = Array.isArray(parsed.radio_favorites) ? parsed.radio_favorites.length : 0; } } catch {} - const memKeys = Object.keys(state); const memFavCount = Array.isArray(state.radio_favorites) ? state.radio_favorites.length : 0; return { stateFile, fileExists, fileSize, - fileKeys, fileFavCount, - memoryKeys: memKeys, memoryFavCount: memFavCount, lastSaveOk, };