mirror of
https://github.com/screentinker/screentinker.git
synced 2026-06-29 09:23:16 -06:00
Merge fix/mute-and-zone-orphan-fallback: per-item mute round-trip + multi-zone orphan-zone fallback
Brings in the mute pair (GET /api/devices/:id muted column + Android YouTube mute), the zone-orphan fallback (web + Android player recovery, lib/zone-validate single source, assign-time clearing, dashboard warnings + i18n), and 5 server regression tests guarding the data contracts. 172/172 pass.
This commit is contained in:
commit
184f07c272
|
|
@ -657,7 +657,7 @@ class MainActivity : AppCompatActivity() {
|
|||
// YouTube content - play in WebView
|
||||
if (item.mimeType == "video/youtube" && !item.remoteUrl.isNullOrEmpty()) {
|
||||
Log.i("MainActivity", "Playing YouTube: ${item.remoteUrl}")
|
||||
mediaPlayer.playYoutube(item.remoteUrl!!, item.durationSec)
|
||||
mediaPlayer.playYoutube(item.remoteUrl!!, item.durationSec, item.muted)
|
||||
wsService?.sendPlaybackState(item.contentId, 0f)
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,9 +50,14 @@ class MediaPlayerManager(
|
|||
}
|
||||
}
|
||||
|
||||
fun playYoutube(embedUrl: String, durationSec: Int = 0) {
|
||||
Log.i("MediaPlayerManager", "Playing YouTube: $embedUrl")
|
||||
// #129: remembered so the live device:mute-changed toggle knows YouTube's current
|
||||
// state and the IFrame API bridge can flip it without reloading the embed.
|
||||
private var youtubeMuted = false
|
||||
|
||||
fun playYoutube(embedUrl: String, durationSec: Int = 0, muted: Boolean = false) {
|
||||
Log.i("MediaPlayerManager", "Playing YouTube: $embedUrl (muted=$muted)")
|
||||
currentType = MediaType.YOUTUBE
|
||||
youtubeMuted = muted || wallMute
|
||||
|
||||
playerView.visibility = android.view.View.GONE
|
||||
imageView.visibility = android.view.View.GONE
|
||||
|
|
@ -64,12 +69,25 @@ class MediaPlayerManager(
|
|||
com.remotedisplay.player.util.WebViewSupport.configure(this, "YouTube")
|
||||
setBackgroundColor(android.graphics.Color.BLACK)
|
||||
// Load via an embed wrapper with a valid youtube.com origin (Error 153 fix).
|
||||
val html = com.remotedisplay.player.util.WebViewSupport.youtubeEmbedHtml(embedUrl)
|
||||
// #129: initial mute comes from the per-item flag (no longer hardcoded).
|
||||
val html = com.remotedisplay.player.util.WebViewSupport.youtubeEmbedHtml(embedUrl, youtubeMuted)
|
||||
if (html != null) loadDataWithBaseURL(com.remotedisplay.player.util.WebViewSupport.EMBED_BASE, html, "text/html", "UTF-8", null)
|
||||
else loadUrl(embedUrl)
|
||||
}
|
||||
}
|
||||
|
||||
// #129: live mute for the YouTube embed via the IFrame API postMessage bridge
|
||||
// (enablejsapi=1 is set on the embed). Avoids a full reload of the player, which
|
||||
// would restart the video and flicker. Main thread only (WebView access).
|
||||
private fun setYoutubeMuted(muted: Boolean) {
|
||||
youtubeMuted = muted
|
||||
val func = if (muted) "mute" else "unMute"
|
||||
val js = "(function(){try{var f=document.querySelector('iframe');" +
|
||||
"if(f&&f.contentWindow){f.contentWindow.postMessage(" +
|
||||
"JSON.stringify({event:'command',func:'$func',args:[]}),'*');}}catch(e){}})()"
|
||||
youtubeWebView?.let { wv -> wv.post { try { wv.evaluateJavascript(js, null) } catch (_: Throwable) {} } }
|
||||
}
|
||||
|
||||
// Fullscreen widget render (single-zone / "fullscreen" layouts). Reuses the
|
||||
// full-screen WebView; ZoneManager handles widgets in multi-zone layouts.
|
||||
fun showWidget(url: String) {
|
||||
|
|
@ -185,12 +203,17 @@ class MediaPlayerManager(
|
|||
fun isPlayingVideo(): Boolean = currentType == MediaType.VIDEO && (exoPlayer?.isPlaying == true)
|
||||
|
||||
// #129: live per-item mute. Applies a dashboard mute toggle to the CURRENTLY playing
|
||||
// video in real time (decoupled from a playlist reload). Only video carries audio here
|
||||
// — YouTube embeds autoplay muted and images/widgets are silent — so this targets the
|
||||
// ExoPlayer volume. Persistence across the next play comes from the playlist payload's
|
||||
// per-item `muted` (honored in playVideo). Main thread only.
|
||||
// item in real time (decoupled from a playlist reload). Native video -> ExoPlayer
|
||||
// volume; YouTube -> the IFrame API mute()/unMute() bridge (setYoutubeMuted), which
|
||||
// previously this method ignored so YouTube could never be un/muted live. Images/
|
||||
// widgets are silent. Persistence across the next play comes from the playlist
|
||||
// payload's per-item `muted` (honored in playVideo/playYoutube). Main thread only.
|
||||
fun setVideoMuted(muted: Boolean) {
|
||||
if (currentType == MediaType.VIDEO) exoPlayer?.volume = if (muted) 0f else 1f
|
||||
when (currentType) {
|
||||
MediaType.VIDEO -> exoPlayer?.volume = if (muted) 0f else 1f
|
||||
MediaType.YOUTUBE -> setYoutubeMuted(muted) // #129: was a no-op for YouTube
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Video-wall (wall:sync) accessors. All must be called on the main thread. ----
|
||||
|
|
|
|||
|
|
@ -97,10 +97,24 @@ class ZoneManager(
|
|||
}
|
||||
|
||||
// Group assignments by zone_id, ordered by sort_order so rotation is stable.
|
||||
// Zone-orphan fallback: an item whose zone_id isn't a zone in the ACTIVE layout
|
||||
// (assigned under a different layout, or the layout was duplicated/switched — copies
|
||||
// get fresh zone ids) would otherwise be SILENTLY DROPPED: its bucket matches no
|
||||
// rendered zone. Re-bucket it into the LARGEST-area zone's rotation so it shares
|
||||
// screen time there (one item at a time -> never overlays existing content) and warn
|
||||
// via DebugLog (mirrored to the dashboard device-log when debug is on).
|
||||
val validZoneIds = zones.map { it.id }.toHashSet()
|
||||
val fallbackZone = zones.maxByOrNull { it.widthPercent * it.heightPercent }
|
||||
val assignmentsByZone = mutableMapOf<String?, MutableList<JSONObject>>()
|
||||
for (i in 0 until assignments.length()) {
|
||||
val a = assignments.getJSONObject(i)
|
||||
val zoneId = if (a.isNull("zone_id")) null else a.optString("zone_id", null)
|
||||
var zoneId = if (a.isNull("zone_id")) null else a.optString("zone_id", null)
|
||||
if (zoneId != null && !validZoneIds.contains(zoneId) && fallbackZone != null) {
|
||||
val item = a.optString("filename", "").ifEmpty { a.optString("content_id", a.optString("widget_id", "?")) }
|
||||
com.remotedisplay.player.util.DebugLog.w("Zone",
|
||||
"orphan zone_id=$zoneId item=$item -> fallback zone '${fallbackZone.name}'")
|
||||
zoneId = fallbackZone.id
|
||||
}
|
||||
assignmentsByZone.getOrPut(zoneId) { mutableListOf() }.add(a)
|
||||
}
|
||||
assignmentsByZone.values.forEach { list -> list.sortBy { it.optInt("sort_order", 0) } }
|
||||
|
|
@ -199,7 +213,9 @@ class ZoneManager(
|
|||
// YouTube - render via an embed wrapper with a valid origin (Error 153 fix)
|
||||
mimeType == "video/youtube" && !remoteUrl.isNullOrEmpty() -> {
|
||||
val webView = createWebView()
|
||||
val html = com.remotedisplay.player.util.WebViewSupport.youtubeEmbedHtml(remoteUrl)
|
||||
// #129: initial mute from the per-item flag (was hardcoded mute=1). Live
|
||||
// per-zone mute isn't wired (device:mute-changed targets the fullscreen item).
|
||||
val html = com.remotedisplay.player.util.WebViewSupport.youtubeEmbedHtml(remoteUrl, isMuted)
|
||||
if (html != null) webView.loadDataWithBaseURL(com.remotedisplay.player.util.WebViewSupport.EMBED_BASE, html, "text/html", "UTF-8", null)
|
||||
else webView.loadUrl(remoteUrl)
|
||||
webView.layoutParams = params
|
||||
|
|
|
|||
|
|
@ -74,10 +74,18 @@ object WebViewSupport {
|
|||
* HTML wrapper for a YouTube embed. Loaded via loadDataWithBaseURL(YT_BASE, ...)
|
||||
* so the iframe has a valid youtube.com origin/referer (a bare loadUrl of the
|
||||
* embed gives Error 153 "player misconfigured"). Returns null if no video id.
|
||||
*
|
||||
* #129: the initial mute now comes from the per-item [muted] flag (was hardcoded
|
||||
* mute=1, which made YouTube un-unmuteable). The WebView sets
|
||||
* mediaPlaybackRequiresUserGesture=false, so mute=0 still autoplays WITH audio.
|
||||
* enablejsapi=1 lets the live device:mute-changed toggle drive the player via the
|
||||
* IFrame API postMessage bridge (MediaPlayerManager.setYoutubeMuted) without a
|
||||
* flicker-y reload.
|
||||
*/
|
||||
fun youtubeEmbedHtml(url: String): String? {
|
||||
fun youtubeEmbedHtml(url: String, muted: Boolean = true): String? {
|
||||
val id = extractYoutubeId(url) ?: return null
|
||||
val src = "$YT_BASE/embed/$id?autoplay=1&mute=1&controls=0&rel=0&modestbranding=1&loop=1&playlist=$id&playsinline=1"
|
||||
val mute = if (muted) 1 else 0
|
||||
val src = "$YT_BASE/embed/$id?autoplay=1&mute=$mute&controls=0&rel=0&modestbranding=1&loop=1&playlist=$id&playsinline=1&enablejsapi=1"
|
||||
return "<!DOCTYPE html><html><head><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">" +
|
||||
"<style>html,body{margin:0;padding:0;height:100%;background:#000;overflow:hidden}iframe{display:block;width:100%;height:100%;border:0}</style>" +
|
||||
"</head><body><iframe src=\"$src\" allow=\"autoplay; encrypted-media\" allowfullscreen></iframe></body></html>"
|
||||
|
|
|
|||
|
|
@ -2,6 +2,11 @@
|
|||
// standard for B2B software in DACH). Native review recommended before
|
||||
// publicizing as fully supported.
|
||||
export default {
|
||||
// #zone-orphan dashboard warnings
|
||||
'device.pl_item.orphan_zone': 'Zone aus einem anderen Layout — neu zuweisen',
|
||||
'device.pl_item.orphan_zone_tip': 'Die Zone dieses Elements gehört nicht zum aktuellen Layout des Geräts. Es wird weiterhin abgespielt (in die größte Zone verschoben), sollte aber einer Zone dieses Layouts neu zugewiesen werden.',
|
||||
'dashboard.device_orphan_tip_one': '{n} Element ist einer Zone zugewiesen, die nicht im Layout dieses Geräts enthalten ist — zum Neuzuweisen das Gerät öffnen',
|
||||
'dashboard.device_orphan_tip_other': '{n} Elemente sind einer Zone zugewiesen, die nicht im Layout dieses Geräts enthalten ist — zum Neuzuweisen das Gerät öffnen',
|
||||
// Nav
|
||||
'nav.displays': 'Bildschirme',
|
||||
'nav.content': 'Inhalt',
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
// English translations. This file is the source of truth for keys —
|
||||
// every other locale should mirror its keys (or fall back to en).
|
||||
export default {
|
||||
// #zone-orphan dashboard warnings
|
||||
'device.pl_item.orphan_zone': 'Zone from a different layout — reassign',
|
||||
'device.pl_item.orphan_zone_tip': "This item's zone isn't part of the device's current layout. It still plays (recovered into the largest zone), but reassign it to a zone in this layout.",
|
||||
'dashboard.device_orphan_tip_one': "{n} item assigned to a zone that isn't in this device's layout — open the device to reassign",
|
||||
'dashboard.device_orphan_tip_other': "{n} items assigned to a zone that isn't in this device's layout — open the device to reassign",
|
||||
// Nav (sidebar)
|
||||
'nav.displays': 'Displays',
|
||||
'nav.content': 'Content',
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
// Spanish translations. Reviewed for UI register (informal tú).
|
||||
// Native review still recommended before publicizing as fully supported.
|
||||
export default {
|
||||
// #zone-orphan dashboard warnings
|
||||
'device.pl_item.orphan_zone': 'Zona de otro diseño — reasignar',
|
||||
'device.pl_item.orphan_zone_tip': 'La zona de este elemento no pertenece al diseño actual del dispositivo. Se sigue reproduciendo (recuperado en la zona más grande), pero reasígnalo a una zona de este diseño.',
|
||||
'dashboard.device_orphan_tip_one': '{n} elemento asignado a una zona que no está en el diseño de este dispositivo — abre el dispositivo para reasignar',
|
||||
'dashboard.device_orphan_tip_other': '{n} elementos asignados a una zona que no está en el diseño de este dispositivo — abre el dispositivo para reasignar',
|
||||
// Nav
|
||||
'nav.displays': 'Pantallas',
|
||||
'nav.content': 'Contenido',
|
||||
|
|
|
|||
|
|
@ -2,6 +2,11 @@
|
|||
// standard for software UIs in France; tu would feel underdressed for a B2B tool).
|
||||
// Native review recommended before publicizing as fully supported.
|
||||
export default {
|
||||
// #zone-orphan dashboard warnings
|
||||
'device.pl_item.orphan_zone': 'Zone d\'une autre mise en page — réattribuer',
|
||||
'device.pl_item.orphan_zone_tip': 'La zone de cet élément ne fait pas partie de la mise en page actuelle de l\'appareil. Il continue de s\'afficher (récupéré dans la plus grande zone), mais réattribuez-le à une zone de cette mise en page.',
|
||||
'dashboard.device_orphan_tip_one': '{n} élément attribué à une zone absente de la mise en page de cet appareil — ouvrez l\'appareil pour le réattribuer',
|
||||
'dashboard.device_orphan_tip_other': '{n} éléments attribués à une zone absente de la mise en page de cet appareil — ouvrez l\'appareil pour les réattribuer',
|
||||
// Nav
|
||||
'nav.displays': 'Écrans',
|
||||
'nav.content': 'Contenu',
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
// Italian translations. This file is the source of truth for keys —
|
||||
// every other locale should mirror its keys (or fall back to en).
|
||||
export default {
|
||||
// #zone-orphan dashboard warnings
|
||||
'device.pl_item.orphan_zone': 'Zona di un altro layout — riassegna',
|
||||
'device.pl_item.orphan_zone_tip': 'La zona di questo elemento non fa parte del layout attuale del dispositivo. Continua a essere riprodotto (recuperato nella zona più grande), ma riassegnalo a una zona di questo layout.',
|
||||
'dashboard.device_orphan_tip_one': '{n} elemento assegnato a una zona non presente nel layout di questo dispositivo — apri il dispositivo per riassegnarlo',
|
||||
'dashboard.device_orphan_tip_other': '{n} elementi assegnati a una zona non presente nel layout di questo dispositivo — apri il dispositivo per riassegnarli',
|
||||
// Nav (sidebar)
|
||||
'nav.displays': 'Schermi',
|
||||
'nav.content': 'Contenuti',
|
||||
|
|
|
|||
|
|
@ -2,6 +2,11 @@
|
|||
// Reviewed for UI register (informal você). Native review recommended before
|
||||
// publicizing as fully supported.
|
||||
export default {
|
||||
// #zone-orphan dashboard warnings
|
||||
'device.pl_item.orphan_zone': 'Zona de outro layout — reatribuir',
|
||||
'device.pl_item.orphan_zone_tip': 'A zona deste item não faz parte do layout atual do dispositivo. Ele continua sendo reproduzido (recuperado na maior zona), mas reatribua-o a uma zona deste layout.',
|
||||
'dashboard.device_orphan_tip_one': '{n} item atribuído a uma zona que não está no layout deste dispositivo — abra o dispositivo para reatribuir',
|
||||
'dashboard.device_orphan_tip_other': '{n} itens atribuídos a uma zona que não está no layout deste dispositivo — abra o dispositivo para reatribuir',
|
||||
// Nav
|
||||
'nav.displays': 'Telas',
|
||||
'nav.content': 'Conteúdo',
|
||||
|
|
|
|||
|
|
@ -114,7 +114,10 @@ function renderDeviceCard(device) {
|
|||
</div>
|
||||
</div>
|
||||
<div class="device-card-body">
|
||||
<div class="device-card-name">${esc(device.name)}</div>
|
||||
<div class="device-card-name">${esc(device.name)}${device.orphan_count > 0 ? `
|
||||
<span class="device-orphan-badge" title="${tn('dashboard.device_orphan_tip', device.orphan_count)}" style="margin-left:6px;display:inline-flex;align-items:center;gap:3px;font-size:11px;color:var(--danger);vertical-align:middle">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>${device.orphan_count}
|
||||
</span>` : ''}</div>
|
||||
${device.owner_name || device.owner_email ? `<div style="font-size:11px;color:var(--text-muted);margin-bottom:4px">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align:-1px">
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>
|
||||
|
|
|
|||
|
|
@ -580,6 +580,10 @@ function renderPlaylist(assignments) {
|
|||
${!a.content_duration && !a.mime_type?.startsWith('video/') && a.duration_sec ? ` · ${a.duration_sec}s` : ''}
|
||||
${a.schedule_start ? ` · ${a.schedule_start}-${a.schedule_end}` : ''}
|
||||
</div>
|
||||
${a.orphan ? `<div class="pl-orphan-warning" data-orphan-assignment="${a.id}" title="${t('device.pl_item.orphan_zone_tip')}" style="margin-top:4px;font-size:11px;color:var(--danger);cursor:pointer;display:inline-flex;align-items:center;gap:4px">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
|
||||
${t('device.pl_item.orphan_zone')}
|
||||
</div>` : ''}
|
||||
</div>
|
||||
<div class="playlist-item-actions" style="display:flex;align-items:center;gap:4px">
|
||||
<select class="input zone-select" data-assignment-id="${a.id}" data-current-zone-id="${a.zone_id || ''}" style="width:100px;font-size:11px;padding:2px 4px;background:var(--bg-input);display:none">
|
||||
|
|
@ -1202,26 +1206,37 @@ function attachRemoveHandlers(device) {
|
|||
// Fetch errors are logged - the dropdowns simply stay hidden (display:none
|
||||
// is the default from the render), same end-state as before but no longer
|
||||
// silent.
|
||||
// Inline per-item zone reassign dropdowns. A device WITH a layout always gets them;
|
||||
// visibility is NOT gated on whether the zone list arrived (gating on that silently hid
|
||||
// the selector when the server payload lacked active_layout_zones — e.g. a stale server
|
||||
// during a deploy skew). Only a genuinely fullscreen device (no layout_id) has no zones.
|
||||
if (device.layout_id) {
|
||||
const token = localStorage.getItem('token');
|
||||
fetch(`/api/layouts/${device.layout_id}`, { headers: { Authorization: `Bearer ${token}` }})
|
||||
.then(r => {
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
return r.json();
|
||||
})
|
||||
.then(layout => {
|
||||
const zones = layout.zones || [];
|
||||
// Show the selectors immediately; they become usable once zones populate below.
|
||||
document.querySelectorAll('.zone-select').forEach(s => { s.style.display = ''; });
|
||||
// Clicking an item's orphan warning badge scrolls to + focuses its zone-select.
|
||||
document.querySelectorAll('.pl-orphan-warning').forEach(w => {
|
||||
w.addEventListener('click', () => {
|
||||
const sel = document.querySelector('.zone-select[data-assignment-id="' + w.dataset.orphanAssignment + '"]');
|
||||
if (sel) { sel.scrollIntoView({ block: 'center', behavior: 'smooth' }); sel.focus(); }
|
||||
});
|
||||
});
|
||||
|
||||
const populateZoneSelects = (zones) => {
|
||||
const activeIds = new Set((zones || []).map(z => z.id));
|
||||
document.querySelectorAll('.zone-select').forEach(select => {
|
||||
select.style.display = '';
|
||||
while (select.options.length > 1) select.remove(1); // keep the "no zone" placeholder, drop stale options
|
||||
const assignmentId = select.dataset.assignmentId;
|
||||
const currentZoneId = select.dataset.currentZoneId || '';
|
||||
zones.forEach(z => {
|
||||
(zones || []).forEach(z => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = z.id;
|
||||
opt.textContent = z.name;
|
||||
select.appendChild(opt);
|
||||
});
|
||||
if (currentZoneId) select.value = currentZoneId;
|
||||
const orphan = !!currentZoneId && !activeIds.has(currentZoneId);
|
||||
if (currentZoneId && !orphan) select.value = currentZoneId; // can't select a zone the layout lacks
|
||||
if (orphan) { select.style.borderColor = 'var(--danger)'; select.style.color = 'var(--danger)'; }
|
||||
select.onchange = async () => {
|
||||
try {
|
||||
await api.updateAssignment(assignmentId, { zone_id: select.value || null });
|
||||
|
|
@ -1230,12 +1245,21 @@ function attachRemoveHandlers(device) {
|
|||
} catch (err) { showToast(err.message, 'error'); }
|
||||
};
|
||||
});
|
||||
})
|
||||
.catch(e => {
|
||||
// No toast - fires once per device-detail load, would be annoying for
|
||||
// a layout misconfig that's already surfaced via the modal info row.
|
||||
console.warn('Failed to load layout for edit-zone dropdowns:', e.message);
|
||||
});
|
||||
};
|
||||
|
||||
if (device.active_layout_zones && device.active_layout_zones.length) {
|
||||
// Fast path: zones already in the device payload — no round-trip.
|
||||
populateZoneSelects(device.active_layout_zones);
|
||||
} else {
|
||||
// Fallback: payload field absent/empty (server/frontend version skew, or a payload
|
||||
// change) — fetch the layout so the dropdowns still populate. A missing field must
|
||||
// never make the selector silently vanish.
|
||||
const token = localStorage.getItem('token');
|
||||
fetch(`/api/layouts/${device.layout_id}`, { headers: { Authorization: `Bearer ${token}` } })
|
||||
.then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); })
|
||||
.then(layout => populateZoneSelects(layout.zones || []))
|
||||
.catch(e => console.warn('Zone dropdowns: layout fetch fallback failed:', e.message));
|
||||
}
|
||||
}
|
||||
|
||||
// Mute toggle buttons
|
||||
|
|
|
|||
66
scripts/find-orphan-zone-items.js
Normal file
66
scripts/find-orphan-zone-items.js
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
#!/usr/bin/env node
|
||||
/*
|
||||
* Report-only audit: find playlist_items whose zone_id is NOT a zone in the
|
||||
* device's ACTIVE layout — i.e. orphaned cross-layout assignments. Un-patched
|
||||
* players silently drop these; patched players (this branch) route them to the
|
||||
* largest zone and emit a "zone" device-log warning. This script only REPORTS;
|
||||
* it never mutates. Run it against a COPY of the prod DB.
|
||||
*
|
||||
* node scripts/find-orphan-zone-items.js [path/to/remote_display.db]
|
||||
*
|
||||
* Exit code is always 0 (it's a report); the count is printed.
|
||||
*/
|
||||
const path = require('path');
|
||||
let Database;
|
||||
try {
|
||||
Database = require('better-sqlite3');
|
||||
} catch (e) {
|
||||
// Resolve from the server's node_modules when run from the repo root.
|
||||
Database = require(path.join(__dirname, '..', 'server', 'node_modules', 'better-sqlite3'));
|
||||
}
|
||||
|
||||
const dbPath = process.argv[2] || path.join(__dirname, '..', 'server', 'db', 'remote_display.db');
|
||||
const db = new Database(dbPath, { readonly: true });
|
||||
|
||||
// One row per (device, zoned item). A playlist shared by N devices is checked
|
||||
// against EACH device's layout, since the same item can be valid for one device
|
||||
// and orphaned for another.
|
||||
const rows = db.prepare(`
|
||||
SELECT d.id AS device_id, d.name AS device_name,
|
||||
d.layout_id AS device_layout, dl.name AS device_layout_name,
|
||||
pi.id AS item_id, pi.zone_id,
|
||||
c.filename, c.mime_type,
|
||||
lz.layout_id AS zone_layout, zl.name AS zone_layout_name, lz.name AS zone_name
|
||||
FROM devices d
|
||||
JOIN playlist_items pi ON pi.playlist_id = d.playlist_id
|
||||
LEFT JOIN content c ON c.id = pi.content_id
|
||||
LEFT JOIN layout_zones lz ON lz.id = pi.zone_id
|
||||
LEFT JOIN layouts dl ON dl.id = d.layout_id
|
||||
LEFT JOIN layouts zl ON zl.id = lz.layout_id
|
||||
WHERE pi.zone_id IS NOT NULL
|
||||
`).all();
|
||||
|
||||
// Orphan = the item's zone doesn't exist any more, OR it belongs to a different
|
||||
// layout than the device is actually rendering.
|
||||
const orphans = rows.filter(r => !r.zone_layout || r.zone_layout !== r.device_layout);
|
||||
|
||||
if (!orphans.length) {
|
||||
console.log(`No orphaned zone assignments found in ${dbPath}.`);
|
||||
db.close();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log(`Found ${orphans.length} orphaned playlist_item(s) in ${dbPath}`);
|
||||
console.log(`(zone_id references a zone that is NOT in the device's active layout):\n`);
|
||||
for (const o of orphans) {
|
||||
const sid = s => (s || '').slice(0, 8);
|
||||
const where = o.zone_layout
|
||||
? `zone "${o.zone_name}" lives in layout "${o.zone_layout_name}" (${sid(o.zone_layout)})`
|
||||
: `zone_id no longer exists`;
|
||||
console.log(` device "${o.device_name}" (${sid(o.device_id)}) active layout "${o.device_layout_name || '—'}" (${sid(o.device_layout)})`);
|
||||
console.log(` item #${o.item_id} ${o.filename || '?'} [${o.mime_type || '?'}] zone_id=${sid(o.zone_id)} -> ${where}`);
|
||||
}
|
||||
console.log(`\nReport only — nothing changed. Un-patched players drop these; patched players`);
|
||||
console.log(`route them to the largest zone and log a "zone" warning. Use the hardening`);
|
||||
console.log(`(remap-on-duplicate / validate-on-assign) to stop new ones being created.`);
|
||||
db.close();
|
||||
51
server/lib/zone-validate.js
Normal file
51
server/lib/zone-validate.js
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
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 };
|
||||
|
|
@ -363,6 +363,11 @@
|
|||
// Per-zone rotation timers (multi-zone). Each zone advances independently on
|
||||
// its own interval, decoupled from the fullscreen advanceTimer/nextItem.
|
||||
let zoneTimers = {};
|
||||
// #zone-orphan: parts of the operator-only PREVIEW banner (never shown on a live
|
||||
// player). 'layout' = dominant-layout note when items span >1 layout; 'orphans' =
|
||||
// the list of items whose zone isn't in the active layout. renderPreviewBanner()
|
||||
// composes them into a single #previewBanner element.
|
||||
let previewBannerParts = {};
|
||||
// Video wall state. wallConfig is the tile assignment from the server
|
||||
// (null when this device isn't in a wall). The leader runs the playlist
|
||||
// normally and broadcasts wall:sync every second; followers don't run
|
||||
|
|
@ -690,11 +695,9 @@
|
|||
// playlist-only: items span >1 layout (rare) — server picked the dominant one.
|
||||
// Device payloads never carry this flag (layout is device-bound, unambiguous).
|
||||
if (payload.layout && payload.layout._preview_ambiguous) {
|
||||
const b = document.createElement('div');
|
||||
b.textContent = 'Previewing layout: ' + (payload.layout.name || '—');
|
||||
b.style.cssText = 'position:fixed;top:0;left:0;right:0;z-index:3000;background:rgba(245,158,11,.92);color:#000;font:12px sans-serif;padding:4px 10px;text-align:center';
|
||||
document.body.appendChild(b);
|
||||
previewBannerParts.layout = 'Previewing dominant layout "' + (payload.layout.name || '—') + '" — items span more than one layout';
|
||||
}
|
||||
renderPreviewBanner();
|
||||
handlePlaylistUpdate(payload);
|
||||
} catch (e) {
|
||||
console.error('preview fetch failed', e);
|
||||
|
|
@ -702,6 +705,23 @@
|
|||
}
|
||||
}
|
||||
|
||||
// #zone-orphan: one operator-only banner, built from previewBannerParts. Created on
|
||||
// first use, removed when there's nothing to report. PREVIEW_MODE only — the live
|
||||
// player/wall must stay clean for the audience.
|
||||
function renderPreviewBanner() {
|
||||
if (!PREVIEW_MODE) return;
|
||||
const lines = [previewBannerParts.layout, previewBannerParts.orphans].filter(Boolean);
|
||||
let el = document.getElementById('previewBanner');
|
||||
if (!lines.length) { if (el) el.remove(); return; }
|
||||
if (!el) {
|
||||
el = document.createElement('div');
|
||||
el.id = 'previewBanner';
|
||||
el.style.cssText = 'position:fixed;top:0;left:0;right:0;z-index:3000;background:rgba(245,158,11,.94);color:#000;font:12px sans-serif;padding:5px 10px;text-align:center;line-height:1.4';
|
||||
document.body.appendChild(el);
|
||||
}
|
||||
el.innerHTML = lines.join('<br>');
|
||||
}
|
||||
|
||||
function showPreviewError(status) {
|
||||
const msg = (status === 401 || status === 403) ? 'Not authorized to preview this playlist'
|
||||
: status ? ('Preview failed (' + status + ')') : 'Preview failed to load';
|
||||
|
|
@ -963,6 +983,13 @@
|
|||
function pipReport(level, msg) {
|
||||
try { if (socket?.connected && config.deviceId) socket.emit('device:log', { device_id: config.deviceId, tag: 'pip', level, message: msg }); } catch (e) {}
|
||||
}
|
||||
// Zone diagnostics (orphaned zone_id fallback). Mirrors pipReport: stream to the
|
||||
// dashboard device-log (tag 'zone') AND the in-page debug overlay buffer.
|
||||
function zoneReport(level, msg) {
|
||||
try { if (socket?.connected && config.deviceId) socket.emit('device:log', { device_id: config.deviceId, tag: 'zone', level, message: msg }); } catch (e) {}
|
||||
try { window.__debugLog_push && window.__debugLog_push({ type: 'zone', level: level, msg: msg }); } catch (e) {}
|
||||
try { console.warn('[zone] ' + msg); } catch (e) {}
|
||||
}
|
||||
function pipTeardown() {
|
||||
try { if (pipTimer) clearTimeout(pipTimer); } catch (e) {}
|
||||
pipTimer = null; pipCurrent = null;
|
||||
|
|
@ -1741,13 +1768,42 @@
|
|||
// Group assignments by zone, ordered by sort_order so each zone rotates its
|
||||
// OWN list independently (images/widgets on a duration timer, videos on end)
|
||||
// rather than every zone re-rendering on a single global tick.
|
||||
// Zone-orphan fallback: an item whose zone_id is NOT a zone in the active layout
|
||||
// (assigned under a different layout, or the layout was duplicated/switched — the new
|
||||
// zones get fresh ids) would otherwise be SILENTLY DROPPED, because its bucket never
|
||||
// matches a rendered zone. Re-bucket it into the LARGEST-area zone's rotation so it
|
||||
// shares screen time there (one item at a time -> never overlays/stacks on existing
|
||||
// content) and emit telemetry so the stale assignment is diagnosable. (See the
|
||||
// fallback-rule rationale in the change notes.)
|
||||
const validZoneIds = new Set(layout.zones.map(z => z.id));
|
||||
const fallbackZone = layout.zones.reduce(
|
||||
(a, b) => (((b.width_percent || 0) * (b.height_percent || 0)) > ((a.width_percent || 0) * (a.height_percent || 0)) ? b : a),
|
||||
layout.zones[0]);
|
||||
const byZone = {};
|
||||
const orphanNames = [];
|
||||
for (const a of playlist) {
|
||||
const zid = a.zone_id || '__none__';
|
||||
let zid = a.zone_id || '__none__';
|
||||
if (a.zone_id && !validZoneIds.has(a.zone_id) && fallbackZone) {
|
||||
zoneReport('warn', 'orphan zone_id=' + a.zone_id + ' item=' + (a.filename || a.content_id || a.widget_id || '?') +
|
||||
' device=' + (config.deviceId || 'preview') + ' -> fallback zone "' + (fallbackZone.name || fallbackZone.id) + '"');
|
||||
orphanNames.push(a.filename || a.content_id || a.widget_id || '?');
|
||||
zid = fallbackZone.id;
|
||||
}
|
||||
(byZone[zid] = byZone[zid] || []).push(a);
|
||||
}
|
||||
for (const k in byZone) byZone[k].sort((x, y) => (x.sort_order || 0) - (y.sort_order || 0));
|
||||
|
||||
// #zone-orphan: operator-only preview note naming the stale items (NOT on a live
|
||||
// player — zoneReport already streams those to the dashboard device-log instead).
|
||||
if (PREVIEW_MODE) {
|
||||
previewBannerParts.orphans = orphanNames.length
|
||||
? orphanNames.length + ' item(s) assigned to a different layout: ' +
|
||||
orphanNames.slice(0, 6).join(', ') + (orphanNames.length > 6 ? '…' : '') +
|
||||
' — showing in "' + ((fallbackZone && (fallbackZone.name || fallbackZone.id)) || '—') + '"'
|
||||
: null;
|
||||
renderPreviewBanner();
|
||||
}
|
||||
|
||||
let unassignedUsed = false;
|
||||
layout.zones.forEach(zone => {
|
||||
let items = byZone[zone.id];
|
||||
|
|
|
|||
|
|
@ -7,12 +7,29 @@ const { PLATFORM_ROLES, ELEVATED_ROLES } = require('../middleware/auth');
|
|||
// already carry workspace_id from Phase 1; this route can use them even
|
||||
// though playlists.js itself isn't yet workspace-filtered.
|
||||
const { accessContext } = require('../lib/tenancy');
|
||||
const { zoneInLayout } = require('../lib/zone-validate');
|
||||
|
||||
// Mark playlist as draft (called after any item mutation)
|
||||
function markDraft(playlistId) {
|
||||
db.prepare("UPDATE playlists SET status = 'draft', updated_at = strftime('%s','now') WHERE id = ?").run(playlistId);
|
||||
}
|
||||
|
||||
// Hardening (#zone-orphan): a zone_id only renders if it belongs to the layout the
|
||||
// device is actually showing. Assigning a zone from a DIFFERENT layout (e.g. after a
|
||||
// layout switch/duplicate) creates an item that the players can't place. We CLEAR a
|
||||
// stale zone_id to null here (-> "unassigned", which the players route sensibly) rather
|
||||
// than reject, so this can't break a caller; the cleared write is logged. NOTE for
|
||||
// review: switch to a 400 reject if you'd rather surface the bad zone to the operator.
|
||||
// Returns the zone_id to persist (the given one, or null if it isn't in the device's
|
||||
// active layout). deviceLayoutId may be null (device on fullscreen) -> any zone_id is
|
||||
// stale, so cleared.
|
||||
function validZoneForLayout(zoneId, deviceLayoutId, ctx) {
|
||||
if (!zoneId) return null;
|
||||
if (zoneInLayout(zoneId, deviceLayoutId)) return zoneId;
|
||||
console.warn(`[assign] cleared stale zone_id ${zoneId} (not in active layout ${deviceLayoutId || 'none'})${ctx ? ' ' + ctx : ''}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Phase 2.2j: workspace-aware device access check. Returns access context
|
||||
// (with workspaceRole/actingAs) or null. Caller decides if read or write.
|
||||
function checkDeviceAccess(req, res, paramName = 'deviceId', requireWrite = true) {
|
||||
|
|
@ -99,6 +116,10 @@ router.post('/device/:deviceId', (req, res) => {
|
|||
|
||||
const playlistId = ensureDevicePlaylist(req.params.deviceId, req.user.id);
|
||||
|
||||
// Hardening: clear a zone_id that isn't in THIS device's active layout (prevents new orphans).
|
||||
const devLayout = db.prepare('SELECT layout_id FROM devices WHERE id = ?').get(req.params.deviceId);
|
||||
const effZone = validZoneForLayout(zone_id, devLayout?.layout_id, `on add to device ${req.params.deviceId}`);
|
||||
|
||||
let order = sort_order;
|
||||
if (order === undefined || order === null) {
|
||||
const max = db.prepare('SELECT MAX(sort_order) as max_order FROM playlist_items WHERE playlist_id = ?')
|
||||
|
|
@ -110,7 +131,7 @@ router.post('/device/:deviceId', (req, res) => {
|
|||
const result = db.prepare(`
|
||||
INSERT INTO playlist_items (playlist_id, content_id, widget_id, zone_id, sort_order, duration_sec)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(playlistId, content_id || null, widget_id || null, zone_id || null, order, duration_sec);
|
||||
`).run(playlistId, content_id || null, widget_id || null, effZone, order, duration_sec);
|
||||
|
||||
markDraft(playlistId);
|
||||
|
||||
|
|
@ -168,7 +189,17 @@ router.put('/:id', (req, res) => {
|
|||
if (duration_sec !== undefined) { updates.push('duration_sec = ?'); values.push(duration_sec); }
|
||||
// zone_id can be null (clear the zone) - treat undefined as "no change",
|
||||
// any other value (including null) as "write this".
|
||||
if (zone_id !== undefined) { updates.push('zone_id = ?'); values.push(zone_id || null); }
|
||||
if (zone_id !== undefined) {
|
||||
// Hardening: if this playlist is bound to exactly ONE device with a layout, clear a
|
||||
// zone_id that isn't in that layout (prevents new orphans). Multi-device / fullscreen
|
||||
// playlists can't be bound to one layout here, so we leave those to the player fallback.
|
||||
let effZone = zone_id || null;
|
||||
if (effZone) {
|
||||
const devs = db.prepare('SELECT layout_id FROM devices WHERE playlist_id = ? AND layout_id IS NOT NULL').all(item.playlist_id);
|
||||
if (devs.length === 1) effZone = validZoneForLayout(effZone, devs[0].layout_id, `on update of item ${req.params.id}`);
|
||||
}
|
||||
updates.push('zone_id = ?'); values.push(effZone);
|
||||
}
|
||||
// #129: per-item mute (coerced to 0/1). Was silently dropped here before, so the
|
||||
// dashboard toggle did nothing.
|
||||
const mutedChanged = muted !== undefined && (item.muted ? 1 : 0) !== (muted ? 1 : 0);
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ const { PLATFORM_ROLES, ELEVATED_ROLES, isPlatformStaff } = require('../middlewa
|
|||
// or null based on the caller's reach into a specific workspace.
|
||||
const { accessContext } = require('../lib/tenancy');
|
||||
const { stripDeviceSecrets } = require('../lib/device-sanitize');
|
||||
const { layoutZones, orphanCountsByDevice } = require('../lib/zone-validate');
|
||||
|
||||
// List devices in the caller's current workspace.
|
||||
// Phase 2.2a: filter by workspace_id instead of user_id. The caller's current
|
||||
|
|
@ -40,7 +41,10 @@ router.get('/', (req, res) => {
|
|||
ORDER BY d.sort_order ASC, d.created_at ASC
|
||||
LIMIT ? OFFSET ?
|
||||
`).all(req.workspaceId, limit, offset);
|
||||
res.json(devices.map(stripDeviceSecrets));
|
||||
// #zone-orphan: lightweight per-device count of playlist items whose zone_id isn't in
|
||||
// the device's active layout, so the dashboard can flag screens that need attention.
|
||||
const orphanCounts = orphanCountsByDevice(devices.map(d => d.id));
|
||||
res.json(devices.map(d => ({ ...stripDeviceSecrets(d), orphan_count: orphanCounts[d.id] || 0 })));
|
||||
});
|
||||
|
||||
// #106: reorder display tiles (cosmetic, within-section). Writes devices.sort_order
|
||||
|
|
@ -109,7 +113,7 @@ router.get('/:id', (req, res) => {
|
|||
let playlist_has_published = false;
|
||||
if (device.playlist_id) {
|
||||
assignments = db.prepare(`
|
||||
SELECT pi.id, pi.content_id, pi.widget_id, pi.zone_id, pi.sort_order, pi.duration_sec,
|
||||
SELECT pi.id, pi.content_id, pi.widget_id, pi.zone_id, pi.sort_order, pi.duration_sec, pi.muted,
|
||||
pi.created_at, pi.updated_at,
|
||||
COALESCE(c.filename, w.name) as filename, c.mime_type, c.filepath, c.thumbnail_path,
|
||||
c.duration_sec as content_duration, c.remote_url,
|
||||
|
|
@ -127,6 +131,14 @@ router.get('/:id', (req, res) => {
|
|||
}
|
||||
}
|
||||
|
||||
// #zone-orphan: flag any item whose zone_id isn't a zone in the device's ACTIVE layout
|
||||
// (same rule as lib/zone-validate). The dashboard shows a per-item "reassign" warning;
|
||||
// active_layout_zones ships the zone list here too so the inline reassign dropdown needs
|
||||
// no separate /api/layouts round-trip. Informational only — playback uses the fallback.
|
||||
const active_layout_zones = layoutZones(device.layout_id);
|
||||
const activeZoneIdSet = new Set(active_layout_zones.map(z => z.id));
|
||||
for (const a of assignments) a.orphan = !!a.zone_id && !activeZoneIdSet.has(a.zone_id);
|
||||
|
||||
// Uptime timeline: get status change events for last 24 hours
|
||||
const dayAgo = Math.floor(Date.now() / 1000) - 86400;
|
||||
let statusLog = [];
|
||||
|
|
@ -141,7 +153,7 @@ router.get('/:id', (req, res) => {
|
|||
'SELECT reported_at FROM device_telemetry WHERE device_id = ? AND reported_at > ? ORDER BY reported_at ASC'
|
||||
).all(req.params.id, dayAgo).map(r => r.reported_at);
|
||||
|
||||
res.json({ ...stripDeviceSecrets(device), telemetry, screenshot, assignments, playlist_status, playlist_has_published, uptimeData, statusLog });
|
||||
res.json({ ...stripDeviceSecrets(device), telemetry, screenshot, assignments, active_layout_zones, playlist_status, playlist_has_published, uptimeData, statusLog });
|
||||
});
|
||||
|
||||
// Helper: check device write access via the workspace the device belongs to.
|
||||
|
|
|
|||
|
|
@ -227,20 +227,29 @@ router.post('/:id/duplicate', (req, res) => {
|
|||
db.prepare('INSERT INTO layouts (id, user_id, workspace_id, name, width, height) VALUES (?, ?, ?, ?, ?, ?)')
|
||||
.run(newId, req.user.id, req.workspaceId, name, source.width, source.height);
|
||||
|
||||
// Copy zones
|
||||
// Copy zones, keeping an old->new zone-id map. The copy gets fresh zone ids, so any
|
||||
// playlist_items still pointing at the SOURCE zones would be orphaned if a device is
|
||||
// moved onto this copy. We return the map (zone_id_map) so a follow-up remap can run.
|
||||
// NOTE for review: we intentionally do NOT auto-rewrite playlist_items.zone_id here —
|
||||
// the source layout's own assignments must keep pointing at the source. A safe remap is
|
||||
// a scoped op ("migrate playlist P from layout A to its copy B"), best done explicitly;
|
||||
// see find-orphan-zone-items.js + the player fallback, which already de-risk the runtime.
|
||||
const zones = db.prepare('SELECT * FROM layout_zones WHERE layout_id = ?').all(req.params.id);
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO layout_zones (id, layout_id, name, x_percent, y_percent, width_percent, height_percent, z_index, zone_type, fit_mode, background_color, sort_order)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
const zone_id_map = {};
|
||||
zones.forEach(z => {
|
||||
stmt.run(uuidv4(), newId, z.name, z.x_percent, z.y_percent, z.width_percent, z.height_percent,
|
||||
const nz = uuidv4();
|
||||
zone_id_map[z.id] = nz;
|
||||
stmt.run(nz, newId, z.name, z.x_percent, z.y_percent, z.width_percent, z.height_percent,
|
||||
z.z_index, z.zone_type, z.fit_mode, z.background_color, z.sort_order);
|
||||
});
|
||||
|
||||
const layout = db.prepare('SELECT * FROM layouts WHERE id = ?').get(newId);
|
||||
layout.zones = db.prepare('SELECT * FROM layout_zones WHERE layout_id = ? ORDER BY sort_order').all(newId);
|
||||
res.status(201).json(layout);
|
||||
res.status(201).json({ ...layout, zone_id_map });
|
||||
});
|
||||
|
||||
// Assign layout to device.
|
||||
|
|
|
|||
166
server/test/device-zone-contract.test.js
Normal file
166
server/test/device-zone-contract.test.js
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
'use strict';
|
||||
|
||||
// Regression tests for the SERVER-SIDE data contracts added by the mute + zone-orphan
|
||||
// branch. These guard the exact bugs we fixed so they can't silently come back:
|
||||
// 1. GET /api/devices/:id must carry each item's `muted` — and BOTH true and false
|
||||
// (the bug was a SELECT that dropped the column; the false case is the one that broke).
|
||||
// 2. GET /api/devices/:id must return `active_layout_zones` for a multi-zone device
|
||||
// (the contract the dashboard zone-selector now depends on).
|
||||
// 3. The single-source orphan rule (lib/zone-validate): a zone in the active layout is
|
||||
// NOT orphaned; a zone from a DIFFERENT layout IS — surfaced as the per-item `orphan`
|
||||
// flag and the device-list `orphan_count`.
|
||||
// 4. Reassigning an orphan to a valid zone drops `orphan_count` to 0.
|
||||
// 5. Assign-time hardening: a zone_id not in the device's active layout is cleared to
|
||||
// null on POST; a valid one is kept.
|
||||
//
|
||||
// Mirrors mute.test.js: boots the real server.js against an isolated DB and seeds rows on
|
||||
// one connection (FK off) to avoid WAL visibility races. No player/DOM/Playwright tests.
|
||||
|
||||
const { test, before, after } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { spawn } = require('node:child_process');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
const fs = require('node:fs');
|
||||
const crypto = require('node:crypto');
|
||||
const Database = require('better-sqlite3');
|
||||
|
||||
const PORT = 3996;
|
||||
const BASE = `http://127.0.0.1:${PORT}`;
|
||||
const DATA_DIR = path.join(os.tmpdir(), 'st-zone-test-' + crypto.randomBytes(4).toString('hex'));
|
||||
const LOG = path.join(os.tmpdir(), 'st-zone-' + crypto.randomBytes(4).toString('hex') + '.log');
|
||||
const PW = 'Passw0rd123';
|
||||
let proc, db;
|
||||
const S = {};
|
||||
|
||||
async function jfetch(p, opts = {}) {
|
||||
const res = await fetch(BASE + p, opts);
|
||||
let body = null; try { body = await res.json(); } catch { /* non-JSON */ }
|
||||
return { status: res.status, body };
|
||||
}
|
||||
const auth = (tok) => ({ headers: { Authorization: 'Bearer ' + tok, 'Content-Type': 'application/json' } });
|
||||
const post = (tok, obj) => ({ method: 'POST', ...auth(tok), body: JSON.stringify(obj || {}) });
|
||||
const put = (tok, obj) => ({ method: 'PUT', ...auth(tok), body: JSON.stringify(obj || {}) });
|
||||
|
||||
// Find one item in the device payload by playlist_item id (ids are integers; coerce both).
|
||||
async function getAssignment(itemId) {
|
||||
const r = await jfetch(`/api/devices/${S.deviceId}`, auth(S.jwt));
|
||||
return (r.body.assignments || []).find((a) => Number(a.id) === Number(itemId));
|
||||
}
|
||||
// Read the device's orphan_count off the workspace device list.
|
||||
async function getOrphanCount() {
|
||||
const r = await jfetch('/api/devices', auth(S.jwt));
|
||||
const d = (r.body || []).find((x) => x.id === S.deviceId);
|
||||
return d ? d.orphan_count : undefined;
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
const logFd = fs.openSync(LOG, 'w');
|
||||
proc = spawn('node', ['server.js'], {
|
||||
cwd: path.join(__dirname, '..'),
|
||||
env: { ...process.env, DATA_DIR, SELF_HOSTED: 'true', PORT: String(PORT), NODE_ENV: 'test' },
|
||||
stdio: ['ignore', logFd, logFd],
|
||||
});
|
||||
let up = false;
|
||||
for (let i = 0; i < 80; i++) {
|
||||
try { const r = await fetch(BASE + '/api/status'); if (r.ok) { up = true; break; } } catch { /* not yet */ }
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
}
|
||||
if (!up) throw new Error('server did not boot:\n' + fs.readFileSync(LOG, 'utf8').slice(-2000));
|
||||
|
||||
// First user -> platform_admin; register returns the JWT, the user, and the workspace.
|
||||
const reg = await jfetch('/api/auth/register', post(null, { email: 'z' + crypto.randomBytes(4).toString('hex') + '@x.local', password: PW }));
|
||||
S.jwt = reg.body.token;
|
||||
S.userId = reg.body.user.id;
|
||||
S.wsA = reg.body.current_workspace_id;
|
||||
|
||||
// Active multi-zone layout L1 (Main, Side) + a DIFFERENT layout L2 (Other) — via the API
|
||||
// so zone ids are real and workspace-scoped. L2's zone is the "different layout" orphan.
|
||||
const l1 = await jfetch('/api/layouts', post(S.jwt, { name: 'L1', zones: [{ name: 'Main', width_percent: 60, height_percent: 100 }, { name: 'Side', width_percent: 40, height_percent: 100 }] }));
|
||||
S.L1 = l1.body.id; S.Z1 = l1.body.zones[0].id; S.Z2 = l1.body.zones[1].id;
|
||||
const l2 = await jfetch('/api/layouts', post(S.jwt, { name: 'L2', zones: [{ name: 'Other', width_percent: 100, height_percent: 100 }] }));
|
||||
S.ZX = l2.body.zones[0].id;
|
||||
|
||||
const pl = await jfetch('/api/playlists', post(S.jwt, { name: 'zone-pl' }));
|
||||
S.playlistId = pl.body.id;
|
||||
|
||||
// Seed content, a device, and playlist_items on one connection (FK off). The orphan item
|
||||
// is seeded DIRECTLY so it bypasses assign-time validation — that's how real orphans
|
||||
// arise (assigned under a different layout / layout switched after the fact).
|
||||
db = new Database(path.join(DATA_DIR, 'db', 'remote_display.db'), { timeout: 5000 });
|
||||
db.pragma('foreign_keys = OFF');
|
||||
const mkContent = (name) => {
|
||||
const id = crypto.randomUUID();
|
||||
db.prepare("INSERT INTO content (id, filename, filepath, mime_type, file_size, remote_url) VALUES (?,?,?,?,0,?)")
|
||||
.run(id, name, '', 'image/png', 'https://example.com/' + name + '.png');
|
||||
return id;
|
||||
};
|
||||
S.cMute = mkContent('mute'); S.cValid = mkContent('valid'); S.cOrphan = mkContent('orphan');
|
||||
S.cPostStale = mkContent('post-stale'); S.cPostOk = mkContent('post-ok');
|
||||
|
||||
S.deviceId = crypto.randomUUID();
|
||||
db.prepare("INSERT INTO devices (id, name, status, workspace_id, user_id, layout_id, playlist_id) VALUES (?,?,?,?,?,?,?)")
|
||||
.run(S.deviceId, 'ZoneDev', 'online', S.wsA, S.userId, S.L1, S.playlistId);
|
||||
|
||||
const addItem = (contentId, zoneId, sort) =>
|
||||
db.prepare("INSERT INTO playlist_items (playlist_id, content_id, zone_id, sort_order, duration_sec, muted) VALUES (?,?,?,?,10,0)")
|
||||
.run(S.playlistId, contentId, zoneId, sort).lastInsertRowid;
|
||||
S.itemMute = addItem(S.cMute, null, 0); // no zone — for the mute round-trip
|
||||
S.itemValid = addItem(S.cValid, S.Z1, 1); // zone in the active layout -> NOT orphan
|
||||
S.itemOrphan = addItem(S.cOrphan, S.ZX, 2); // zone from L2 -> orphan
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
try { db?.close(); } catch { /* */ }
|
||||
if (proc) proc.kill('SIGKILL');
|
||||
for (const f of [DATA_DIR, LOG]) { try { fs.rmSync(f, { recursive: true, force: true }); } catch { /* */ } }
|
||||
});
|
||||
|
||||
// 1. muted must round-trip through the device payload SELECT — both states.
|
||||
test('GET /api/devices/:id carries per-item muted (true AND false)', async () => {
|
||||
await jfetch(`/api/assignments/${S.itemMute}`, put(S.jwt, { muted: true }));
|
||||
let a = await getAssignment(S.itemMute);
|
||||
assert.ok(a, 'item appears in the device payload');
|
||||
assert.equal(a.muted, 1, 'muted=true survives the GET /api/devices/:id SELECT');
|
||||
|
||||
await jfetch(`/api/assignments/${S.itemMute}`, put(S.jwt, { muted: false }));
|
||||
a = await getAssignment(S.itemMute);
|
||||
assert.equal(a.muted, 0, 'muted=false survives too (the case that originally broke)');
|
||||
});
|
||||
|
||||
// 2. active_layout_zones contract for a multi-zone device.
|
||||
test('GET /api/devices/:id returns active_layout_zones for a multi-zone device', async () => {
|
||||
const r = await jfetch(`/api/devices/${S.deviceId}`, auth(S.jwt));
|
||||
const zones = r.body.active_layout_zones;
|
||||
assert.ok(Array.isArray(zones), 'active_layout_zones is present');
|
||||
assert.equal(zones.length, 2, 'both zones of the active layout are returned');
|
||||
assert.deepEqual(zones.map((z) => z.id).sort(), [S.Z1, S.Z2].sort(), 'exactly the active-layout zone ids');
|
||||
});
|
||||
|
||||
// 3. orphan definition: in-layout zone -> not orphan; different-layout zone -> orphan.
|
||||
test('orphan flag + orphan_count reflect the single-source rule', async () => {
|
||||
const valid = await getAssignment(S.itemValid);
|
||||
const orphan = await getAssignment(S.itemOrphan);
|
||||
assert.equal(valid.orphan, false, 'a zone in the active layout is NOT orphaned');
|
||||
assert.equal(orphan.orphan, true, 'a zone from a different layout IS orphaned');
|
||||
assert.equal(await getOrphanCount(), 1, 'device list orphan_count counts exactly the one orphan');
|
||||
});
|
||||
|
||||
// 4. reassigning the orphan to a valid zone clears the count.
|
||||
test('reassigning an orphan to a valid zone clears orphan_count', async () => {
|
||||
assert.equal(await getOrphanCount(), 1, 'precondition: one orphan');
|
||||
const r = await jfetch(`/api/assignments/${S.itemOrphan}`, put(S.jwt, { zone_id: S.Z1 }));
|
||||
assert.equal(r.body.zone_id, S.Z1, 'reassignment to a valid zone persists');
|
||||
assert.equal(await getOrphanCount(), 0, 'orphan_count drops to 0 after reassign');
|
||||
});
|
||||
|
||||
// 5. assign-time hardening: stale zone_id cleared, valid kept.
|
||||
test('POST assignment clears a stale zone_id and keeps a valid one', async () => {
|
||||
const stale = await jfetch(`/api/assignments/device/${S.deviceId}`, post(S.jwt, { content_id: S.cPostStale, zone_id: S.ZX, duration_sec: 10 }));
|
||||
assert.equal(stale.status, 201);
|
||||
assert.equal(stale.body.zone_id, null, 'a zone_id from a different layout is cleared to null on add');
|
||||
|
||||
const ok = await jfetch(`/api/assignments/device/${S.deviceId}`, post(S.jwt, { content_id: S.cPostOk, zone_id: S.Z2, duration_sec: 10 }));
|
||||
assert.equal(ok.status, 201);
|
||||
assert.equal(ok.body.zone_id, S.Z2, 'a zone_id in the active layout is kept');
|
||||
});
|
||||
Loading…
Reference in a new issue