diff --git a/frontend/js/views/dashboard.js b/frontend/js/views/dashboard.js index bbfb349..94b01dc 100644 --- a/frontend/js/views/dashboard.js +++ b/frontend/js/views/dashboard.js @@ -246,7 +246,6 @@ export function render(container) { }; screenshotHandler = (data) => { - // Update all instances of this device's preview (may appear in multiple groups) document.querySelectorAll(`#preview-${data.device_id}`).forEach(preview => { const imgSrc = data.image_data || (data.url + '&token=' + localStorage.getItem('token')); const img = preview.querySelector('img'); @@ -286,7 +285,13 @@ async function loadDashboard() { if (!main) return; try { - const [devices, groups, playlists] = await Promise.all([api.getDevices(), api.getGroups(), api.getPlaylists()]); + const [rawDevices, groups, playlists] = await Promise.all([api.getDevices(), api.getGroups(), api.getPlaylists()]); + + // Deduplicate devices by id — a stale reconnect race can briefly cause the same + // device to appear twice in the list. Last-write-wins keeps the freshest state. + const seen = new Map(); + for (const d of rawDevices) seen.set(d.id, d); + const devices = Array.from(seen.values()); // Stats const online = devices.filter(d => d.status === 'online').length; @@ -339,10 +344,17 @@ async function loadDashboard() { return { ...g, devices: fullDevices, memberIds }; })); - // Find ungrouped devices - const allGroupedIds = new Set(); - groupsWithDevices.forEach(g => g.memberIds.forEach(id => allGroupedIds.add(id))); - const ungrouped = devices.filter(d => !allGroupedIds.has(d.id)); + // Render each device exactly once: the first group it belongs to wins. + // memberIds is preserved for the Manage modal so multi-group membership info stays accurate. + const renderedIds = new Set(); + for (const g of groupsWithDevices) { + g.devices = g.devices.filter(d => { + if (renderedIds.has(d.id)) return false; + renderedIds.add(d.id); + return true; + }); + } + const ungrouped = devices.filter(d => !renderedIds.has(d.id)); let html = ''; diff --git a/server/ws/deviceSocket.js b/server/ws/deviceSocket.js index 0b1316a..7e3bb7f 100644 --- a/server/ws/deviceSocket.js +++ b/server/ws/deviceSocket.js @@ -118,6 +118,19 @@ module.exports = function setupDeviceSocket(io) { const deviceNs = io.of('/device'); const dashboardNs = io.of('/dashboard'); + // Disconnect any existing socket that is currently registered for this device_id. + // Called when a fresh registration comes in for the same device so the old (likely + // half-dead) socket can't fire its disconnect handler and clobber the new entry. + function evictPriorSocket(deviceId, exceptSocketId) { + const prior = heartbeat.getConnection(deviceId); + if (!prior || prior.socketId === exceptSocketId) return; + const oldSocket = deviceNs.sockets.get(prior.socketId); + if (oldSocket) { + console.log(`Evicting prior socket ${prior.socketId} for device ${deviceId}`); + try { oldSocket.disconnect(true); } catch (_) {} + } + } + deviceNs.on('connection', (socket) => { console.log(`Device socket connected: ${socket.id}`); let currentDeviceId = null; @@ -145,6 +158,7 @@ module.exports = function setupDeviceSocket(io) { db.prepare('UPDATE devices SET device_token = ? WHERE id = ?').run(newToken, existing.device_id); console.log(`Fingerprint match: linking reinstalled app to existing device ${existing.device_id} (new token issued)`); authenticated = true; + evictPriorSocket(existing.device_id, socket.id); db.prepare("UPDATE devices SET status = 'online', last_heartbeat = strftime('%s','now'), ip_address = ?, updated_at = strftime('%s','now') WHERE id = ?") .run(getClientIp(socket), existing.device_id); socket.emit('device:registered', { device_id: existing.device_id, device_token: newToken, status: 'online' }); @@ -189,6 +203,7 @@ module.exports = function setupDeviceSocket(io) { currentDeviceId = device_id; authenticated = true; + evictPriorSocket(device_id, socket.id); db.prepare("UPDATE devices SET status = 'online', last_heartbeat = strftime('%s','now'), ip_address = ?, updated_at = strftime('%s','now') WHERE id = ?") .run(getClientIp(socket), device_id); @@ -384,6 +399,15 @@ module.exports = function setupDeviceSocket(io) { socket.on('disconnect', () => { if (currentDeviceId) { + // If a newer socket has already taken over this device_id, this is a stale + // disconnect from a replaced socket — skip the offline transition so we don't + // flip an actively-connected device offline or clobber the new heartbeat entry. + const activeConn = heartbeat.getConnection(currentDeviceId); + if (activeConn && activeConn.socketId !== socket.id) { + console.log(`Stale disconnect for ${currentDeviceId} (socket ${socket.id}); active is ${activeConn.socketId}, skipping offline`); + return; + } + console.log(`Device disconnected: ${currentDeviceId}`); db.prepare("UPDATE devices SET status = 'offline', updated_at = strftime('%s','now') WHERE id = ?") .run(currentDeviceId);