Adding a 32s video gave it the flat 10s default, so it was cut off mid-play
unless the operator looked up the runtime and typed it — per item, every time.
The content row already carries the probed duration; it now becomes the default.
The rule lives in one place (lib/item-duration.js) because the operator sees one
product, not six insert paths: playlist add, assign-to-display, group assign,
agency portal, content-only schedule, and the public API all share it. Only the
playlist route defaulted before, and it stored the raw probe (31.7) which the
Android player's optInt read silently truncated back to 31.
Explicit values always win. Content with no trustworthy duration (image, widget,
YouTube, remote URL, failed probe) keeps the 10s default, and a duration that is
0/negative/NaN or absurd (> 12h, i.e. a broken probe) falls back rather than
reaching a device — a 0 makes the players schedule a 0ms advance, which self-loops
and black-screens the TV.
Dashboard: the add-item picker shows a clip's length, the assign-to-display modal
pre-fills the duration field from the selected clip (never overwriting a value the
operator typed), and onboarding stops hardcoding 10 on the first assignment.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
Three related pieces. Zones were already stored as percentages and layouts
already carried their own width/height, so this is mostly design work rather
than plumbing.
SIX PORTRAIT TEMPLATES at 1080x1920. Deliberately not the landscape set turned
sideways: "Three Column" at 33% each becomes three tall slivers, and a 15% ticker
that reads well across 1080px is a 288px band on a 1920px-tall panel, so the
portrait ticker is 12% and the PiP window is wider than tall (a 30x30 box is
square on 16:9 and 324x576 in portrait). Seeded in schema.sql for fresh installs
AND as a migration, because schema.sql never runs on an existing database — and
upgraded instances are exactly the ones with portrait panels already deployed.
THE EDITOR CANVAS followed a hardcoded padding-top:56.25% — the 16:9 ratio trick.
Authoring a portrait layout meant dragging zones on a landscape canvas: the
percentages landed correctly on the panel and looked wrong everywhere you
designed them. It now derives from the layout's own height/width, clamped so a
pathological row cannot produce an unusable editor.
THE PLAYLIST PAGE now draws where content actually lands. A playlist has no
intrinsic layout, so the server reuses #104's derivation from the items' own zone
bindings and returns it. Previously an item could be tagged "Bottom Ticker" with
nothing to say the ticker is a thin strip along the bottom — people assigned by
zone name and found out by looking at a screen. Empty zones are dimmed, because
an empty zone shows its background colour on a real panel and that is worth
seeing before publishing rather than after.
Verified against a copy of prod: 6 templates and 12 zones created, the 7
landscape templates untouched, no errors at boot, and a second boot changes
nothing. Each stacked template's zone heights sum to exactly 100%.
1074 pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
devices.playlist_id is ON DELETE SET NULL, so the database detached correctly — but the handler
emitted nothing, so a screen kept displaying the deleted playlist until it happened to reconnect or
was restarted. You delete a playlist to take content off the wall; the wall carried on showing it.
Every sibling mutation in this file already pushes (publish, assign), and DELETE
/devices/:id/playlist was given a push for precisely this reason: "so the screen stops, rather than
leaving the old content up until something else happens to update it".
The affected devices are read before the delete, since the association is gone the moment it runs.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
Two separate faults in the same widget, both reported on #234.
1. Text taller than the screen vanished in silence. renderText set overflow:hidden on the document
with nothing able to scroll it, so anything past the bottom edge was simply gone: "Text goes to
bottom and disappears. It dont fit."
The content now gets a wrapper and an overflow mode:
fit (default) shrink until it fits — a NO-OP when the content already fits, so it rescues
widgets that are currently losing text without changing ones that are fine
scroll pan through it on a loop with a pause at each end, for content genuinely longer than a
screen where shrinking would make it unreadable
clip the old behaviour, kept because a designer-positioned layout may deliberately run past
the edge and must not be rescaled underneath its author
Measuring runs after layout, after web fonts settle, and on resize — a rotation or a resized zone
changes the answer, and fonts arriving late is the classic cause of a fit computed against the
wrong height.
2. Editing a widget did not reach the screen until the app was restarted. The render endpoint serves
live config, but the player deliberately keeps a widget's WebView while its URL is unchanged
(re-navigating every duration is a visible flash and destroys widget state — a half-typed
directory search, scroll position). Editing changes the content, not the id, so the URL never
changed and the reuse check always hit.
The widget's updated_at now travels to the player as widget_rev and goes into the render URL, so
the URL differs exactly when the content differs — and only then, so the anti-flash reuse still
holds for untouched widgets. The rev is refreshed at send time rather than read from the
published snapshot, because a widget edit does not republish the playlist. Editing a widget also
now pushes to the displays showing it, instead of notifying nothing at all.
859 server tests green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
Subtitles/captions are set once in the content library and applied
automatically by the player — no in-player controls (the player stays bare).
- DB: 4 new content columns (captions_enabled, captions_lang for YouTube;
subtitle_url, subtitle_lang for uploaded videos), all default off/NULL.
- buildSnapshotItems: denormalize the 4 fields into published_snapshot so the
player receives them (enumerated query).
- content.js PUT: accept the 4 fields (subtitle_url only clearable here).
- POST /:id/subtitle: dedicated .vtt uploader (separate multer, since the main
filter is video/image-only); stores the file in the content dir, records
subtitle_url + subtitle_lang. Old subtitle file replaced; DELETE cleans up
the sidecar.
- Player: YouTube -> loadModule('captions') + setOption(...languageCode) in
onReady (best-effort, wrapped). Uploaded video -> a <track kind="subtitles">
appended to the <video>, forced mode='showing' on load (same-origin, so
CORS-clean like the video).
- Edit modal: YouTube gets an enable-captions checkbox + language; uploaded
video gets a .vtt file picker + language + a remove-subtitle option. en/es.
Honest limitation (in the PR): YouTube caption control via the IFrame API is
undocumented/version-dependent and only works if the video actually has
captions — hence best-effort and wrapped so it can never break playback.
Test: content-subtitles.test.js — the 4 fields survive publish -> snapshot,
the .vtt upload endpoint stores + records the file, and a non-.vtt is rejected.
Suite 550/550.
Closes#216
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
On Android TV with weak/unstable WiFi, YouTube embeds auto-select 1080p+
and buffer/stall. Add a per-item "Unstable connection" flag that biases the
YouTube embed toward a 720p ceiling.
- DB: content.unstable_connection INTEGER NOT NULL DEFAULT 0 (non-destructive,
existing rows unchanged).
- buildSnapshotItems: denormalize the flag into published_snapshot so it
reaches the player (that query enumerates columns, so it had to be added
explicitly — covered by a new test). preview-payload reuses the same query.
- content.js PUT: accept + coerce unstable_connection to 0/1.
- Player: playerVars.vq='hd720' + best-effort setPlaybackQuality('hd720') in
onReady when the flag is set. Both are hints YouTube may still override, but
together they bias the initial selection down to 720p.
- Edit modal: YouTube-only checkbox + hint; en/es i18n.
Scoped to the core ask; the issue's optional "Shorts get inverse hd1080"
refinement is intentionally left out (dubious for signage, and forcing higher
quality on a weak link is the opposite of the goal).
Closes#217
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
GET /:id built the items array but never called schedulesForItem, so the playlist
editor rendered "always plays" for items that have a live schedule. Because the
editor re-PUTs whatever it loaded and PUT .../schedules is a wholesale
DELETE+INSERT, an unchanged save on a mis-loaded item silently wiped the real
schedule. Mirror GET /:id/items:351 so the read path returns the blocks the
editor and player already agree on.
Adds render / round-trip / wipe-trap regression tests (subprocess HTTP harness).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(server): persist + ship + real-time per-item mute (#129)
The dashboard mute toggle was a no-op end to end. The active model is playlist_items
(the device payload is its published_snapshot); the legacy `assignments` table the bug
report cited is unused for devices. Three breaks:
- PUT /api/assignments/:id silently dropped `muted` (only read sort_order/duration_sec/
zone_id). It now accepts muted (coerced 0/1) and ITEM_SELECT returns it, so the toggle
persists and its on/off state sticks.
- playlist_items had no `muted` column — added (schema + idempotent migration).
- buildSnapshotItems didn't select muted, so it never reached the published_snapshot /
device payload — now included.
Real-time: on a mute change, emit device:mute-changed { content_id, widget_id, muted } to
every device on that playlist so the player toggles the matching item's volume live,
decoupled from publish (the value is also in the next snapshot, so it persists). Adds a
[mute] log line (the report noted zero mute log entries).
Test: test/mute.test.js — PUT persists + returns muted, it reaches the published
snapshot, and a non-mute update doesn't reset it. Server suite 164/164.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(player): apply per-item mute live on Android + web (#129)
Honor the new per-item mute from the server, both in real time and on reload.
Android:
- WebSocketService: onMuteChanged callback + main-thread device:mute-changed handler.
- MediaPlayerManager.setVideoMuted(): flips the live ExoPlayer volume on the current
video (YouTube autoplays muted; images/widgets are silent).
- MainActivity: on device:mute-changed, apply immediately if the toggled item is the
one playing now.
- PlaylistController.sig(): include muted so a published mute change re-renders/persists
instead of being de-duped.
Web player (server/player/index.html):
- device:mute-changed handler toggles the current <video>; the video mount now also
honors item.muted so a published mute sticks across reloads.
Tizen intentionally not included: its player mutes ALL video for autoplay, so per-item
unmute isn't achievable there.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Duplicate and Replace per-item actions, both leaning on the normalized
playlist_items schema (only content_id/widget_id/zone_id/sort_order/
duration_sec; type-specific fields are JOINed at snapshot time).
- Replace: extend PUT /:id/items/:itemId to accept a content_id/widget_id
swap. Clean FK swap across ANY content type (image<->video<->youtube<->
widget) — sets one, nulls the other, preserving zone_id/duration/
sort_order/schedule rows. Only acts when content_id|widget_id is present,
so partial PUTs are unaffected. Workspace-validated; markDraft.
- Duplicate: new POST /:id/items/:itemId/duplicate — copies the row +
its schedule blocks (new ids) in one transaction, appended (sort_order
MAX+1). markDraft.
- Frontend: Replace + Duplicate icon buttons per item; Replace reuses the
add-item picker in a replaceItemId mode (PUT instead of POST). i18n x6.
Validated end-to-end against the live API: duplicate (incl. schedule copy
with distinct ids), replace same-type and cross-type both directions,
preservation of duration/schedule/zone, and validation (both->400,
missing->404). 149 server tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replaces the broken/fragmented preview with a single surface that renders a
DRAFT playlist exactly as a device does, by reusing the player's renderer in a
same-origin iframe. Fixes "not all items load" (one renderer, full type union)
and inherits the player's YouTube correctness (YT.Player handshake).
Server:
- deviceSocket: extract assemblePayload() (zone-reset + canonical shape) from
buildPlaylistPayload so the device path and preview can't drift. Pure refactor
(all 149 tests green).
- playlists: GET /:id/preview-payload (requirePlaylistRead, workspace-scoped).
Draft-aware via buildSnapshotItems (live items, not published_snapshot);
derivePreviewLayout() resolves layout from the playlist's own zone-bound items
(0 zoned -> fullscreen; 1 -> use it; >1 -> dominant + ambiguous flag, never
crashes). orientation validated/passthrough; wall_config/timezone null.
Player (renderer UNTOUCHED):
- ?preview=1&playlist=ID boot branch: fetch preview-payload (same-origin Bearer
token) and call handlePlaylistUpdate(). Gated before the pairing/socket path
so the unpaired auto-connect never fires. All socket emits already guarded.
- Webpage widgets: always-visible honest note (no auto-detection — an XFO
refusal is provably indistinguishable client-side from a working embed).
Dashboard:
- playlists: Preview button + player-iframe modal with landscape/portrait toggle.
- widgets: same honest note on the existing widget preview modal (the surface the
bug was reported on).
- i18n x6 (en/es/fr/de/it/pt) + player i18n x5.
Validated end-to-end (headless Chrome + CDP): preview boots, webpage note
renders, 3-zone layout derives+renders, shape parity with device snapshot proven
on real data, auth gate returns 401. The world-readable /uploads finding is
tracked separately as #107 (not a #104 concern — same path the device uses).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Agencies can only be designated FULL-SCREEN playlists (no item with zone_id) - a full-screen
agency upload can't safely target a zone, so the ambiguous case is excluded rather than
solved. Checked at THREE points:
- Designation (tokens.js create + PUT /:id/targets) -> 400: reject a zoned target.
- Upload (agency.js item-add) -> 409: block if the playlist BECAME zoned after designation.
MANDATORY because auto-publish has no draft net - a full-screen playlist designated to an
auto-publish token, then zone-assigned, would otherwise auto-publish a full-screen upload
into a zoned playlist. The upload check is the only thing that catches it.
- Picker (settings.js): zoned playlists greyed/disabled with the reason (GET /playlists now
returns a zoned flag); backend reject is the guard if the UI is bypassed. i18n x5.
isZonedPlaylist = EXISTS(playlist_items WHERE zone_id IS NOT NULL). Pure restriction - no
zone structure, no api_token_target_zones.
Bite-test (the exact sequence) GREEN and re-proven to bite: full-screen -> designate to an
auto-publish token -> zone-assign the playlist -> agency upload is BLOCKED (409), not
auto-published; neutralizing the upload check makes it go red. 149 suite green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Investigation found zone placement is a DEVICE property (device.layout_id), not a playlist
property: a normal playlist has no derivable layout (zone_id is NULL unless set in the
device-assignment flow), so a playlist-scoped zone grant can't reach the normal flow. The
right model: placement belongs to the device (same playlist can be full-screen on one screen,
a zone on another); the agency just gets whole-playlist grants + size-guidance.
Removed the zone-grant machinery (security-adjacent dead surface is a liability, not dormant
convenience): api_token_target_zones (schema + a DROP migration for the dev DB where the
short-lived CREATE ran), resolveGrantedZone, grantableZoneIds, buildZoneGrantRows, the
create/PUT zone validation, GET /api/playlists/:id/zones, getPlaylistZones, the settings
zone-picker + its i18n, and the zone-grant bite-test.
KEPT (model-agnostic, good): the reactive per-playlist size-guidance card - GET
/api/agency/playlists/:playlistId/layout (router.param-confined) now reports the zones the
playlist actually feeds (where/what-size content lands), or full-screen when it has no layout.
Whole-playlist grants = today's working model. 147 suite green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Issuance (on the proven seam):
- tokens.js create + PUT /:id/targets accept per-playlist zone grants (target_zones), inserted
into api_token_target_zones inside the same transaction as the playlist grants (FK requires
the parent, so order matters and is correct).
- Issuance validation (the mirror of runtime confinement): grantableZoneIds() - can grant ONLY
a zone the playlist's layout actually feeds; can't grant one it doesn't have or one from
another playlist's layout. Bite-tested. PUT re-designate stays atomic: delete parent rows ->
zone grants cascade out (no manual child delete).
- settings.js: checking a designated playlist reveals its grantable zones (GET
/api/playlists/:id/zones, JWT); leave unchecked = whole-playlist. i18n across all 5 locales.
Card:
- GET /api/agency/playlists/:playlistId/layout (rides router.param - confined; a non-
designated playlist -> 403, asserted). "Your zone" = the GRANTED zones. Retired the
token-wide /layouts (the per-playlist card replaces the disconnected lump).
- Portal card reacts to the playlist selector: pick a playlist -> its layout renders, the
granted zone highlighted with px size, siblings as context.
Full suite + agency bite-suite green (154).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
POST /:id/publish snapshots items into published_snapshot (what devices consume) + pushes
to devices. Extracted that into publishPlaylist(id, req) so the agency auto-publish path can
call the IDENTICAL logic - a "published" playlist that wasn't snapshotted would be live on no
screen. The manual endpoint now calls it; behavior-preserving (suite green).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- playlists: accept zone_id on item create + update, validated against a template or a
layout in the playlist's workspace (no cross-tenant zone reference).
- devices: accept layout_id on PUT /api/devices/:id (symmetry with the layouts route),
validated the same way; null clears it. Both are already returned in the GET SELECTs.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Each playlist item can carry schedule blocks (active days, start/end
time-of-day, optional start/end dates). An item plays when the screen's
local "now" matches at least one block; an item with no blocks always
plays. #74 covers time-of-day/day-of-week windows including overnight
wrap; #75 covers inclusive date ranges (auto-expiry). Evaluation is
on-device, so dayparting and expiry work offline.
- Shared evaluator contract: shared/schedule-vectors.json (39 vectors —
DST US+AU, overnight-wrap anchoring, timezone correctness, date
boundaries). Canonical JS evaluator in server/lib/schedule-eval.js;
Kotlin and Tizen ports kept in lockstep by drift guards (Tizen byte-diff
test, Kotlin JUnit reads the shared JSON, new android-test CI job).
- All three players (web, Android, Tizen) filter by schedule against their
own clock, idle with a "Nothing scheduled" message + 30s re-check when
everything is filtered, and fail open on any evaluator error.
- Editor: per-item schedule modal + row badge in the playlist editor;
client validation mirrors the server; editing marks the playlist draft.
- Part B (behaviour change): device/group schedule overrides now evaluate
in each device's effective timezone instead of server-local time.
- Device detail shows the reported timezone + a clock-skew warning.
- i18n for en/es/fr/de/pt across all new strings (namespaced itemsched.*
to avoid colliding with the device-schedule calendar's schedule.*).
- CHANGELOG documents the feature, the Part B change, the fail-open
guarantee, and the scheduled-single-video re-render tradeoff.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase 2 (assignments -> playlist_items) dropped zone_id during the
conversion: migrateAssignmentsToPlaylists INSERTed only (playlist_id,
content_id, widget_id, sort_order, duration_sec), and the new
playlist_items DDL omitted the zone_id column entirely. Every write
path on top of playlist_items inherited that omission - the
multi-zone layout assignment feature stopped working.
Frontend always sent zone_id correctly (device-detail.js:1015,1072
POST and PUT both include it; api.addAssignment and api.updateAssignment
forward the body verbatim). Server silently dropped it. The
assignments.js PUT route was the most direct evidence: it destructured
zone_id from req.body but never added it to the updates array.
Schema:
- schema.sql: add zone_id TEXT REFERENCES layout_zones(id) ON DELETE
SET NULL to fresh-install DDL.
- database.js migrations[]: add idempotent ALTER TABLE for existing
installs (the surrounding try/catch loop handles duplicate-column).
Backfill (new gated migration phase2_zone_id_backfill):
- Pre-migration snapshot copied to db/remote_display.pre-zone-id-
backfill-<ts>.db (one-off for this migration; the general
every-migration-snapshot framework is a separate concern, not built
here).
- Best-effort UPDATE playlist_items.zone_id from surviving
assignments rows via device.playlist_id + content_id/widget_id
match, LIMIT 1 for the multi-match edge case.
- Regenerates published_snapshot for every published playlist so the
JSON the player consumes carries zone_id going forward. Even with
zero rows backfilled (the common case post-Phase-2 cleanup) this
closes the snapshot-staleness gap.
- Stamps schema_migrations regardless so it won't re-run on next boot.
- On the live local DB: 0 playlist_items backfilled, 18
published_snapshots regenerated. On the April 13 prod fixture
(sandboxed copy): 0 backfilled, 7 regenerated. Expected and matches
our pre-flight finding that assignments was effectively scrubbed of
zone_id everywhere.
Route wiring (7 sites + 1 shared constant):
- assignments.js ITEM_SELECT: project pi.zone_id (read path so the
frontend display at device-detail.js:500 surfaces the value).
- assignments.js POST INSERT: include zone_id column + value.
- assignments.js PUT: actually use the already-destructured zone_id
in the updates allow-list. Treats undefined as "no change" so a PUT
that omits zone_id leaves the existing value intact; any explicit
value (including null) is written.
- assignments.js copy-to INSERT: preserve a.zone_id during
device-to-device playlist copy.
- playlists.js buildSnapshotItems: project pi.zone_id so the snapshot
JSON carries it. This is what the player's renderZones loop reads
(player/index.html:1338 matches a.zone_id === zone.id).
- playlists.js discard-revert INSERT: restore zone_id from snapshot
item on revert.
Out of scope (verified safe by SQL semantics + UI inspection):
- playlists.js POST item-add and PUT item-update in the playlist-detail
surface: the UI there doesn't expose zone editing, and their SQL
leaves zone_id NULL on insert / untouched on update. No regression.
- Other playlists.js SELECT projections (lines 141, 190, 240, 265, 334,
379, 419) all use SELECT pi.* and auto-pick zone_id once the column
exists.
- Kiosk-page assign at device-detail.js:1027 doesn't send zone_id;
separate pre-existing gap, not part of this regression.
Tests (all local, no push, no prod deploy):
- Migration boot on live local DB: clean, idempotent (second boot
skips the gated function).
- Migration boot on April 13 prod fixture (sandboxed copy at
/tmp/zone-fix-fixtures/test-run.db): cleanly runs the full migration
stack (multi-tenancy + 5 other phases the fixture predated) then
the new zone_id backfill. Live local DB untouched.
- 8 SQL-level route behavior tests pass: INSERT stores zone_id, PUT
changes/clears zone_id, ITEM_SELECT and buildSnapshotItems
projections include zone_id, copy-to preserves, discard-revert
restores from snapshot JSON, undefined zone_id in PUT leaves
existing value intact.
Not verified: end-to-end multi-zone playback on a real device. The
SQL + snapshot JSON layer is correct (player consumes
playlist.find(a => a.zone_id === zone.id) and now gets the right
zone_id back from the snapshot); confirming render-to-correct-zone
on actual hardware is the next step before prod deploy.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Short-lived per-device queue covers the TV-flap window (issue #3):
when a device is mid-reconnect, prior code emitted to an empty room
and the event vanished. Now playlist-updates and commands targeting
an offline device are queued and flushed in order on the next
device:register for that device_id.
server/lib/command-queue.js (new):
- pendingPlaylistUpdate: per-device marker (rebuild via builder on
flush -> always fresh DB state, no stale snapshots)
- pendingCommands: per-device Map<type, payload> with last-of-type
dedup (most recent screen_off wins)
- TTL via COMMAND_QUEUE_TTL_MS env (default 30000)
- Active sweep every 30s prunes expired entries
Memory bounds: ~6 entries per device worst case (1 playlist marker
+ 5 command types), unref'd sweep timer.
Wired emit sites (8 total; the four direct socket.emit calls in
deviceSocket register handlers are intentionally NOT queued because
the socket is alive by definition at those points):
- server/routes/video-walls.js (pushWallPayloadToDevice)
- server/routes/device-groups.js (pushPlaylistToDevice)
- server/routes/content.js (content-delete fan-out)
- server/routes/playlists.js (pushToDevices + assign)
- server/services/scheduler.js (scheduled rotations)
- server/ws/deviceSocket.js x2 (wall leader reclaim/reassign)
server/ws/deviceSocket.js register paths now call flushQueue after
heartbeat.registerConnection + socket.join. Existing
socket.emit('device:playlist-update', ...) lines kept - they send
the initial state on register; the flush replays any queued events.
Player's handlePlaylistUpdate fingerprint check dedupes the
overlap.
Refs #3
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Security fixes:
- Critical: Add ownership checks to assignments PUT/:id and DELETE/:id (IDOR)
- Critical: Add ownership checks to assignments copy-to endpoint for both devices
- High: Validate device ownership when adding to device groups
- High: UUID-validate content ID before LIKE query + scope to owner's playlists
- Low: Handle FK violations gracefully in playlist discard (deleted content/widgets)
- Low: Escape mime_type with esc() in playlist item display (XSS)
Bug fix:
- Device-detail mutation handlers now reload full page to show draft banner
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Schema: add status and published_snapshot columns to playlists table.
Migration snapshots all existing playlists as published (idempotent via schema_migrations).
Devices always receive the published_snapshot, not live playlist_items.
Edits from device-detail/groups auto-publish immediately (display updates instantly).
Edits from playlist detail page go to draft (requires explicit publish).
POST /playlists/:id/publish snapshots and pushes to all devices.
POST /playlists/:id/discard reverts playlist_items from published snapshot.
Content deletion scrubs references from all published snapshots.
Frontend: draft badge in playlist list, prominent yellow banner with publish/discard
buttons on playlist detail and device detail pages.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
List and detail endpoints now include display_count (devices using this playlist).
New POST /:id/assign endpoint sets a playlist on a device and pushes the update.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Swap execFileSync to execFile with promise wrapper in
probeAndUpdateDuration(). Wrap the add-item handler in try/catch
for Express 4.x async safety (Express 4 doesn't catch rejected
promises from async handlers).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
If a video's duration_sec is NULL in the content table (e.g. ffprobe
wasn't available at upload time), re-probe it when the content is
added to a playlist. Backfills the content table so subsequent adds
skip the probe. Non-video content and probe failures fall back to
the 10s default.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
When adding a content item to a playlist without an explicit
duration_sec, use the content's own duration (from ffprobe at upload
time) instead of defaulting to 10s. Falls back to 10s for images
or content without a detected duration.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>