diff --git a/package.json b/package.json index b6fccfc..8bfd6aa 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "temp_app", "private": true, - "version": "0.0.0", + "version": "0.1.0", "type": "module", "scripts": { "dev": "vite", @@ -42,4 +42,4 @@ "typescript-eslint": "^8.46.4", "vite": "^7.2.4" } -} +} \ No newline at end of file diff --git a/server/add_is_active_column.js b/server/add_is_active_column.js new file mode 100644 index 0000000..ba895b3 --- /dev/null +++ b/server/add_is_active_column.js @@ -0,0 +1,38 @@ +const mysql = require('mysql2/promise'); +require('dotenv').config(); // Load .env from current directory + +async function addColumn() { + process.env.DB_HOST = process.env.DB_HOST || 'localhost'; + process.env.DB_USER = process.env.DB_USER || 'root'; + process.env.DB_PASSWORD = process.env.DB_PASSWORD || 'password'; // Fallback or assume env is loaded + process.env.DB_NAME = process.env.DB_NAME || 'smartasset_db'; + + console.log(`Connecting to database: ${process.env.DB_NAME} at ${process.env.DB_HOST}`); + + const connection = await mysql.createConnection({ + host: process.env.DB_HOST, + user: process.env.DB_USER, + password: process.env.DB_PASSWORD, + database: process.env.DB_NAME, + port: process.env.DB_PORT || 3306 + }); + + try { + console.log("Checking if 'is_active' column exists..."); + const [rows] = await connection.execute("SHOW COLUMNS FROM camera_settings LIKE 'is_active'"); + + if (rows.length === 0) { + console.log("Adding 'is_active' column..."); + await connection.execute("ALTER TABLE camera_settings ADD COLUMN is_active BOOLEAN DEFAULT TRUE"); + console.log("Column 'is_active' added successfully!"); + } else { + console.log("Column 'is_active' already exists."); + } + } catch (error) { + console.error("Error updating database:", error); + } finally { + await connection.end(); + } +} + +addColumn(); diff --git a/server/index.js b/server/index.js index 4469bc0..27912be 100644 --- a/server/index.js +++ b/server/index.js @@ -357,12 +357,17 @@ app.post('/api/upload', upload.single('image'), (req, res) => { app.use('/api', require('./modules/asset/routes')); // Serve Frontend Static Files (Production/Deployment) -app.use(express.static(path.join(__dirname, '../dist'))); +// Strict Sibling Structure: Expects 'dist' to be a sibling of 'server' +// This matches the local development environment structure. +const distPath = path.join(__dirname, '../dist'); + +console.log(`Serving static files from: ${distPath}`); +app.use(express.static(distPath)); // The "catchall" handler: for any request that doesn't // match one above, send back React's index.html file. app.get(/(.*)/, (req, res) => { - res.sendFile(path.join(__dirname, '../dist/index.html')); + res.sendFile(path.join(distPath, 'index.html')); }); const server = app.listen(PORT, () => { diff --git a/server/migrate_db.js b/server/migrate_db.js new file mode 100644 index 0000000..70557e2 --- /dev/null +++ b/server/migrate_db.js @@ -0,0 +1,53 @@ +const mysql = require('mysql2/promise'); +require('dotenv').config(); + +const SOURCE_DB = 'smart_asset_db'; +const TARGET_DBS = ['sokuree_platform_dev', 'sokuree_platform_prod']; + +async function migrate() { + const connection = await mysql.createConnection({ + host: process.env.DB_HOST, + user: process.env.DB_USER, + password: process.env.DB_PASSWORD, + port: process.env.DB_PORT || 3306 + }); + + try { + console.log(`Connected to MySQL at ${process.env.DB_HOST}`); + + for (const targetDb of TARGET_DBS) { + console.log(`\nProcessing Target DB: ${targetDb}`); + + // 1. Create Database + console.log(`Creating database ${targetDb} if not exists...`); + await connection.query(`CREATE DATABASE IF NOT EXISTS ${targetDb}`); + + // 2. Get Tables from Source + const [tables] = await connection.query(`SHOW TABLES FROM ${SOURCE_DB}`); + const tableNames = tables.map(row => Object.values(row)[0]); + + for (const table of tableNames) { + console.log(` Migrating table: ${table}...`); + + // 3. Create Table (Structure) + // Drop if exists to ensure clean state or handle updates? + // Let's drop for now to ensure exact clone. + await connection.query(`DROP TABLE IF EXISTS ${targetDb}.${table}`); + await connection.query(`CREATE TABLE ${targetDb}.${table} LIKE ${SOURCE_DB}.${table}`); + + // 4. Copy Data + await connection.query(`INSERT INTO ${targetDb}.${table} SELECT * FROM ${SOURCE_DB}.${table}`); + console.log(` -> Data copied.`); + } + } + + console.log('\nMigration completed successfully!'); + + } catch (error) { + console.error('Migration failed:', error); + } finally { + await connection.end(); + } +} + +migrate(); diff --git a/server/modules/cctv/routes.js b/server/modules/cctv/routes.js index a2167da..dd98f9f 100644 --- a/server/modules/cctv/routes.js +++ b/server/modules/cctv/routes.js @@ -91,6 +91,27 @@ router.put('/:id', hasRole('admin'), async (req, res) => { } }); +// Toggle Stream Status (Admin only) +router.patch('/:id/status', hasRole('admin'), async (req, res) => { + const { is_active } = req.body; + try { + const [result] = await db.query('UPDATE camera_settings SET is_active = ? WHERE id = ?', [is_active, req.params.id]); + if (result.affectedRows === 0) return res.status(404).json({ error: 'Camera not found' }); + + const streamRelay = req.app.get('streamRelay'); + if (streamRelay) { + // If disabled, stop stream. If enabled, reset (which stops and allows reconnect) + console.log(`Stream status changed for camera ${req.params.id} to ${is_active}`); + streamRelay.resetStream(req.params.id); + } + + res.json({ message: `Stream ${is_active ? 'enabled' : 'disabled'}` }); + } catch (err) { + console.error(err); + res.status(500).json({ error: 'Database error' }); + } +}); + // Delete camera (Admin only) router.delete('/:id', hasRole('admin'), async (req, res) => { try { diff --git a/server/modules/cctv/streamRelay.js b/server/modules/cctv/streamRelay.js index 90c493d..656fad0 100644 --- a/server/modules/cctv/streamRelay.js +++ b/server/modules/cctv/streamRelay.js @@ -1,55 +1,11 @@ const WebSocket = require('ws'); const ffmpeg = require('fluent-ffmpeg'); -const ffmpegPath = require('ffmpeg-static'); +const staticFfmpegBinary = require('ffmpeg-static'); // Renamed for clarity const db = require('../../db'); - const fs = require('fs'); -// Determine FFmpeg path: Prefer system install on Synology, fallback to static -// Determine FFmpeg path: Prioritize Community packages (RTSP support) > System (limited) > Static -let systemFfmpegPath = ffmpegPath; - -// Potential paths for fully-featured FFmpeg (SynoCommunity, etc.) -// User 'find /usr' showed it's NOT in /usr/local/bin, so we must look in package dirs. -const priorityPaths = [ - // SynoCommunity or Package Center standard paths - '/var/packages/ffmpeg/target/bin/ffmpeg', - '/var/packages/ffmpeg7/target/bin/ffmpeg', - '/var/packages/ffmpeg6/target/bin/ffmpeg', - - // Direct volume paths (common on Synology) - '/volume1/@appstore/ffmpeg/bin/ffmpeg', - '/volume1/@appstore/ffmpeg7/bin/ffmpeg', - '/volume1/@appstore/ffmpeg6/bin/ffmpeg', - - // Standard Linux paths - '/usr/local/bin/ffmpeg', - '/usr/bin/ffmpeg', - '/bin/ffmpeg' -]; - -let foundPath = false; -for (const path of priorityPaths) { - if (fs.existsSync(path)) { - systemFfmpegPath = path; - console.log(`[StreamRelay] Found FFmpeg at: ${path}`); - // If we found a path that is NOT the system default (which we know is broken), likely we are good. - // But even if it is system default, we use it if nothing else found. - if (path !== '/usr/bin/ffmpeg' && path !== '/bin/ffmpeg') { - console.log('[StreamRelay] Using generic/community FFmpeg (likely supports RTSP).'); - } else { - console.warn('[StreamRelay] WARNING: Using system FFmpeg. RTSP might fail ("Protocol not found").'); - } - foundPath = true; - break; - } -} - -if (!foundPath) { - console.warn('[StreamRelay] No FFmpeg binary found in priority paths. Falling back to ffmpeg-static or default PATH.'); -} - -ffmpeg.setFfmpegPath(systemFfmpegPath); +// We don't set global ffmpeg path here immediately because we might want to switch per-stream or check system paths dynamically +// (though fluent-ffmpeg usage usually suggests setting it once if possible, but we check inside startFFmpeg for robustness) class StreamRelay { constructor(server) { @@ -74,8 +30,6 @@ class StreamRelay { } parseCameraId(url) { - // url format: /api/stream?cameraId=1 or /api/stream/1 (if path matching works that way, but ws path is fixed) - // Let's use query param: ws://host/api/stream?cameraId=1 try { const params = new URLSearchParams(url.split('?')[1]); return params.get('cameraId'); @@ -104,19 +58,15 @@ class StreamRelay { } } - // Forcefully disconnect all clients to trigger reconnection resetStream(cameraId) { const stream = this.streams.get(cameraId); if (stream) { console.log(`Resetting stream for camera ${cameraId} (closing ${stream.clients.size} clients)...`); - // Close all clients. This will trigger 'close' event on ws, - // calling removeClient -> stopStream. for (const client of stream.clients) { if (client.readyState === WebSocket.OPEN) { client.close(1000, "Stream Reset"); } } - // Ensure stream is stopped even if no clients existed this.stopStream(cameraId); } } @@ -133,7 +83,17 @@ class StreamRelay { } const camera = rows[0]; - // Construct RTSP URL + // Check if streaming is enabled + // Ignore if IGNORE_CAMERA_STATUS is explicitly 'true' (for local dev) + if ((camera.is_active === 0 || camera.is_active === false)) { + if (process.env.IGNORE_CAMERA_STATUS === 'true') { + console.log(`[DEV OVERRIDE] Streaming disabled in DB for camera ${cameraId}, but forced ON by IGNORE_CAMERA_STATUS.`); + } else { + console.log(`Streaming is disabled for camera ${cameraId} (${camera.name}). skipping FFmpeg start.`); + return; + } + } + let rtspUrl = 'rtsp://'; if (camera.username && camera.password) { const user = camera.rtsp_encoding ? encodeURIComponent(camera.username) : camera.username; @@ -144,71 +104,85 @@ class StreamRelay { console.log(`Starting FFmpeg for camera ${cameraId} (${camera.name})...`); - const transportMode = camera.transport_mode === 'udp' ? 'udp' : 'tcp'; + // 1. Determine FFmpeg Binary Path (v1.0.8 Fix) + let selectedFfmpegPath; + if (process.env.FORCE_STATIC_FFMPEG === 'true') { + selectedFfmpegPath = staticFfmpegBinary; + } else { + const systemFfmpegPath = '/var/packages/ffmpeg7/target/bin/ffmpeg'; + // Check system paths, priority to Synology packages, fallback to static + if (fs.existsSync(systemFfmpegPath)) { + selectedFfmpegPath = systemFfmpegPath; + } else if (fs.existsSync('/var/packages/ffmpeg6/target/bin/ffmpeg')) { + selectedFfmpegPath = '/var/packages/ffmpeg6/target/bin/ffmpeg'; + } else { + selectedFfmpegPath = staticFfmpegBinary; + } + } - // Determine Quality Scaling + // Set for this command (Note: fluent-ffmpeg setFfmpegPath is global, but usually fine if concurrent calls settle on same binary or if we just set it before command) + ffmpeg.setFfmpegPath(selectedFfmpegPath); + + // 2. Transport Mode + const transportMode = process.env.CCTV_TRANSPORT_OVERRIDE || camera.transport_mode || 'tcp'; + + // 3. Unique User-Agent to prevent session conflicts + const userAgent = `SmartAsset-Relay-v1.0.8-${transportMode}`; + + // Quality Scaling let scaleFilter = []; let videoBitrate = '1000k'; - const qual = camera.quality || 'low'; - if (qual === 'low') { - scaleFilter = ['-vf scale=640:-1']; - videoBitrate = '1000k'; + scaleFilter = ['-vf scale=1280:-1']; // Using 720p even for low for better visibility, bitrate controls size + videoBitrate = '2000k'; } else if (qual === 'medium') { scaleFilter = ['-vf scale=1280:-1']; - videoBitrate = '2500k'; + videoBitrate = '4000k'; } else { - // Original - Restore high bitrate as requested videoBitrate = '6000k'; } - // Compatibility: Synology FFmpeg might not support -rtsp_transport flag or strict ordering - // Try removing explicit transport mode if it fails, or rely on Auto. - const inputOptions = []; + const inputOptions = [ + '-user_agent', userAgent + ]; if (transportMode !== 'auto') { - // We restored this because we are now ensuring a proper FFmpeg version (v6/7 or static) is used. - // REVERT: User reported local Windows issues with this flag enforced. - // Commenting out to restore local functionality. Synology should still work via Auto or default. - // inputOptions.push(`-rtsp_transport ${transportMode}`); + inputOptions.push('-rtsp_transport', transportMode); } - console.log(`[StreamRelay] Using FFmpeg binary: ${systemFfmpegPath}`); + console.log(`[StreamRelay v1.0.8] Using FFmpeg binary: ${selectedFfmpegPath}`); + console.log(`[StreamRelay v1.0.10] Transport Mode: ${transportMode} (Override: ${process.env.CCTV_TRANSPORT_OVERRIDE || 'none'})`); + console.log(`[StreamRelay v1.0.8] User-Agent: ${userAgent}`); + console.log(`[StreamRelay v1.0.8] Input Options:`, inputOptions); - const command = ffmpeg(rtspUrl) - .inputOptions(inputOptions) - // .inputOptions([ - // `-rtsp_transport ${transportMode}` - // ]) - .addOptions([ - '-c:v mpeg1video', // Video codec for JSMpeg - '-f mpegts', // Output format - '-codec:a mp2', // Audio codec - `-b:v ${videoBitrate}`, // Dynamic Video bitrate - `-maxrate ${videoBitrate}`, // Cap max bitrate - `-bufsize ${videoBitrate}`, - '-r 30', // FPS 30 (Restored) - '-g 30', // GOP size 30 (1 keyframe per sec) - '-bf 0', // No B-frames - ...scaleFilter - ]) + const command = ffmpeg(rtspUrl); + if (inputOptions.length > 0) { + command.inputOptions(inputOptions); + } + + command.addOptions([ + '-c:v mpeg1video', + '-f mpegts', + '-codec:a mp2', + `-b:v ${videoBitrate}`, + `-maxrate ${videoBitrate}`, + `-bufsize ${videoBitrate}`, + '-r 30', + '-g 30', + '-bf 0', + ...scaleFilter + ]) .on('start', (cmdLine) => { console.log('FFmpeg started:', cmdLine); }) .on('error', (err) => { - // Only log if not killed manually (checking active stream usually tricky, but error msg usually enough) if (!err.message.includes('SIGKILL')) { console.error('FFmpeg error:', err.message); - // If error occurs, maybe stop stream? - // For reload, we might retry? simple stop for now. this.stopStream(cameraId); } }) .on('end', () => { console.log('FFmpeg exited'); - // We don't indiscriminately stopStream here because 'reload' causes an exit too. - // But 'stopStream' checks if process matches? - // Let's rely on the process being replaced or nullified. }); stream.process = command; diff --git a/server/package.json b/server/package.json index e8c7510..ab40139 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "server", - "version": "1.0.0", + "version": "0.1.0", "description": "", "main": "index.js", "scripts": { @@ -27,4 +27,4 @@ "devDependencies": { "nodemon": "^3.1.11" } -} +} \ No newline at end of file diff --git a/src/modules/cctv/pages/MonitoringPage.tsx b/src/modules/cctv/pages/MonitoringPage.tsx index 727fbeb..b079250 100644 --- a/src/modules/cctv/pages/MonitoringPage.tsx +++ b/src/modules/cctv/pages/MonitoringPage.tsx @@ -19,6 +19,7 @@ interface Camera { rtsp_encoding?: boolean; quality?: 'low' | 'medium' | 'original'; display_order?: number; + is_active?: boolean | number; } // Wrap Camera Card with Sortable @@ -138,6 +139,31 @@ export function MonitoringPage() { } }; + const handleToggleStatus = async (camera: Camera) => { + // Optimistic UI Update: Calculate new status + const currentStatus = camera.is_active !== 0 && camera.is_active !== false; + const newStatus = !currentStatus; + + // Immediately update UI state + setCameras(prev => prev.map(c => + c.id === camera.id ? { ...c, is_active: newStatus ? 1 : 0 } : c + )); + + try { + // Send request to server + await apiClient.patch(`/cameras/${camera.id}/status`, { is_active: newStatus }); + // Only silent fetch to sync eventually, no alert needed + fetchCameras(); + } catch (err) { + console.error('Failed to toggle status', err); + // Revert UI on failure + setCameras(prev => prev.map(c => + c.id === camera.id ? { ...c, is_active: currentStatus ? 1 : 0 } : c + )); + alert('상태 변경 실패 (되돌리기)'); + } + }; + const handleDragEnd = async (event: DragEndEvent) => { const { active, over } = event; @@ -254,6 +280,17 @@ export function MonitoringPage() { {/* Overlay Controls */} {user?.role === 'admin' && (