Phase 4 group scheduling: schema migration adds group_id to schedules with
CHECK constraint, scheduler evaluates group+device schedules with priority,
group deletion converts schedules to per-device copies. Dashboard gets
playlist assignment dropdown and current playlist label on group headers.
Player persists audio unlock state in localStorage so version reloads
don't lose audio on unattended displays.
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>
New schema_migrations table (id TEXT PK, ran_at INTEGER) tracks which
one-time migrations have executed. The Phase 2 playlist migration now
checks for 'phase2_playlist_migration' in this table instead of
inferring state from devices.playlist_id. Records the migration ID
after successful completion. Eliminates ffprobe overhead on subsequent
startups.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Reports devices migrated, total playlist items created, videos probed
via ffprobe, and existing schedules. Helps verify the migration ran
correctly on first startup.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Migration (database.js): switched from sync execFileSync to async execFile
with promise wrapper, matching the pattern in playlists.js. Probes each
video content item, backfills content.duration_sec, and uses the real
duration in playlist_items. Falls back to the assignment's original
duration_sec if the probe fails or content isn't a video.
V1 import (status.js): moved assignment-to-playlist conversion out of the
synchronous db.transaction() so async ffprobe can run. Content files are
already on disk from the transaction, so probing works. Same fallback logic.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Videos were getting the default 10s from the assignments table. Now ffprobe
runs for each video content item during migration, backfills the content
table, and uses the real duration in playlist_items.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Runs once at startup. For each device with assignments, creates an
auto-generated playlist (is_auto_generated=1) containing those items
and sets device.playlist_id. Skips if any device already has a playlist.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Every device will point to exactly one playlist. Schedules can temporarily
override a device's playlist. Auto-generated playlists (from migration) are
flagged so the UI can filter them.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Make assignments.content_id nullable so widgets can be assigned to playlists
- Fix designer publish to use vw units matching preview (was hardcoded px)
- Add px-to-vw conversion in text widget renderer for backward compat
- Fix webpage widget zoom scaling
- Add widget rendering support in fullscreen player mode
- Set no-cache headers on JS/CSS/HTML for instant updates (ETag/304)
- Set 30-day cache on media files and uploaded content for Cloudflare
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
ScreenTinker - open source digital signage management software.
MIT License, all features included, no license gates.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>