mirror of
https://github.com/screentinker/screentinker.git
synced 2026-06-29 09:23:16 -06:00
Two independent multi-zone bugs, plus operator-facing warnings, i18n, and regression tests guarding the data contracts. Bug 1 — per-item mute was a no-op end to end: - GET /api/devices/:id dropped the `muted` column from its assignments SELECT, so the dashboard toggle never reflected state (the muted=false case in particular). Column restored to the device payload. - Android player now honours the per-item mute flag for YouTube (initial state + live via the IFrame JS API). Bug 2 — items whose zone_id belongs to a different layout were silently dropped: - Player fallback (web + Android): an orphaned zone_id is recovered into the largest zone instead of vanishing, with telemetry. - server/lib/zone-validate.js is the single source of truth for the orphan rule (zone not in the device's active layout); used by the device payload (per-item `orphan` flag + `active_layout_zones`) and the device list (`orphan_count`). - Assign-time hardening: a stale zone_id (not in the device's active layout) is cleared to null on POST/PUT rather than persisted as a new orphan. - scripts/find-orphan-zone-items.js: read-only sweep for existing orphans. Dashboard warnings (operator-facing, never on the live player): - Per-item badge + reassign affordance, device-list glance, preview banner. - Graceful degradation: the zone selector falls back to /api/layouts/:id so it can't vanish on a stale payload. i18n: orphan-zone strings added to en/es/fr/de/pt/it (hi falls back by design; count strings interpolate through tn()). Tests: server/test/device-zone-contract.test.js adds 5 regression tests for the data contracts above (muted true/false round-trip, active_layout_zones, orphan flag + count, orphan-clears-on-reassign, assign-time clearing). 172/172 pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
52 lines
2.2 KiB
JavaScript
52 lines
2.2 KiB
JavaScript
const { db } = require('../db/database');
|
|
|
|
// Single source of truth for the "orphaned zone" definition used across the server:
|
|
// assignment validation (routes/assignments.js validZoneForLayout), the device payload
|
|
// orphan flags/counts (routes/devices.js), and — by the SAME rule, mirrored in their own
|
|
// languages — the player fallback (server/player/index.html, ZoneManager.kt) and the
|
|
// find-orphan-zone-items.js sweep.
|
|
//
|
|
// Rule: an item's zone_id is VALID only if it is a zone in the device's ACTIVE layout.
|
|
// A null/empty zone_id is "unassigned" (not an orphan). A zone_id on a device with no
|
|
// active layout can never be valid -> orphan.
|
|
|
|
/** True if zoneId belongs to layoutId (or zoneId is empty = unassigned). */
|
|
function zoneInLayout(zoneId, layoutId) {
|
|
if (!zoneId) return true;
|
|
if (!layoutId) return false;
|
|
return !!db.prepare('SELECT 1 FROM layout_zones WHERE id = ? AND layout_id = ?').get(zoneId, layoutId);
|
|
}
|
|
|
|
/** True when zoneId is set but NOT a zone in the device's active layout. */
|
|
function isOrphanZone(zoneId, layoutId) {
|
|
return !!zoneId && !zoneInLayout(zoneId, layoutId);
|
|
}
|
|
|
|
/** Zones (id+name) of a layout, for populating reassign dropdowns. [] if none. */
|
|
function layoutZones(layoutId) {
|
|
if (!layoutId) return [];
|
|
return db.prepare('SELECT id, name FROM layout_zones WHERE layout_id = ? ORDER BY sort_order').all(layoutId);
|
|
}
|
|
|
|
/**
|
|
* Bulk: map of device_id -> count of its playlist_items whose zone_id is NOT in the
|
|
* device's active layout. Same rule as isOrphanZone, computed in one query for the
|
|
* dashboard device list. Devices with zero orphans are omitted from the map.
|
|
*/
|
|
function orphanCountsByDevice(deviceIds) {
|
|
const rows = db.prepare(`
|
|
SELECT d.id AS device_id, COUNT(*) AS n
|
|
FROM devices d
|
|
JOIN playlist_items pi ON pi.playlist_id = d.playlist_id
|
|
LEFT JOIN layout_zones lz ON lz.id = pi.zone_id AND lz.layout_id = d.layout_id
|
|
WHERE pi.zone_id IS NOT NULL AND lz.id IS NULL
|
|
GROUP BY d.id
|
|
`).all();
|
|
const map = {};
|
|
const want = deviceIds && deviceIds.length ? new Set(deviceIds) : null;
|
|
for (const r of rows) { if (!want || want.has(r.device_id)) map[r.device_id] = r.n; }
|
|
return map;
|
|
}
|
|
|
|
module.exports = { zoneInLayout, isOrphanZone, layoutZones, orphanCountsByDevice };
|