Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a45552fed3 | ||
|
|
a5e8ecb21f | ||
|
|
9d5029eff6 | ||
|
|
37345f3949 | ||
|
|
ed8d51580e | ||
|
|
e95928f31c | ||
|
|
2b9cabdab2 | ||
|
|
a9d1d45d78 | ||
|
|
d06da6fea2 | ||
|
|
90db0f933d | ||
|
|
d6417b667c | ||
|
|
c643d2fb07 | ||
|
|
de1469c29d | ||
|
|
42279d95aa | ||
|
|
9c5cabbd13 | ||
|
|
815c808d73 | ||
|
|
4afaeaafb1 | ||
|
|
ef9cdd2986 | ||
|
|
2176fb9042 | ||
|
|
6cb45d7839 | ||
|
|
eb1f33d82f | ||
|
|
07ec17ad91 | ||
|
|
b5fbf79af7 | ||
|
|
4ca4da473f | ||
|
|
f4de31269d | ||
|
|
235d2da6aa | ||
|
|
d24c9c4035 | ||
|
|
04ddec50ad | ||
|
|
47613e61a2 | ||
|
|
bd130a1070 | ||
|
|
d48f0e779f | ||
|
|
90ec3bb2df | ||
|
|
0ca20035cd | ||
|
|
c18f597068 | ||
|
|
0e89892c94 | ||
|
|
9ee938fd2f | ||
|
|
ad514102e7 | ||
|
|
cceb5d8fde | ||
|
|
b0e72e40d4 | ||
|
|
db9d718cd8 | ||
|
|
8d3b7d557b | ||
|
|
9f08ccfea1 | ||
|
|
370ec81c33 | ||
|
|
5e7df5e55c | ||
|
|
cfca5bfe3b | ||
|
|
5eff70755b | ||
|
|
0666d5d07c | ||
|
|
8b479283e1 | ||
|
|
13e13d0d68 | ||
|
|
e051958bea | ||
|
|
7973ee0ea4 | ||
|
|
6416dbfd99 | ||
|
|
6fb413cb6d | ||
|
|
8393923c5a | ||
|
|
2ea8d389fa | ||
|
|
480ccdbe9c | ||
|
|
8020a12408 | ||
|
|
b7f1d800ef | ||
|
|
b0649d838e |
+4
-6
@@ -1,6 +1,4 @@
|
||||
node_modules
|
||||
npm-debug.log
|
||||
uploads
|
||||
.git
|
||||
.gitignore
|
||||
*.tmp
|
||||
*
|
||||
!package.json
|
||||
!src/
|
||||
!src/**
|
||||
|
||||
+6
-8
@@ -1,19 +1,17 @@
|
||||
NODE_ENV=production
|
||||
WEB_PORT=3000
|
||||
PLAYER_PORT=3001
|
||||
PULSE_SIGNAGE_IMAGE=git.lzstealth.com/lzstealth/pulse-signage:latest
|
||||
PULSE_SIGNAGE_SHARED_SECRET=
|
||||
|
||||
DB_HOST=mysql
|
||||
DB_PORT=3306
|
||||
DB_NAME=signage
|
||||
DB_USER=signage_user
|
||||
DB_NAME=pulse-signage
|
||||
DB_USER=pulse-signage
|
||||
DB_PASSWORD=signage_password
|
||||
MYSQL_ROOT_PASSWORD=root_password
|
||||
|
||||
PLAYER_INTERNAL_BASE_URL=http://player:3001
|
||||
PLAYER_PUBLIC_BASE_URL=http://localhost:3001
|
||||
PLAYER_PUBLIC_BASE_URL=http://localhost:8081
|
||||
PLAYER_INTERNAL_BASE_URL=http://player:8081
|
||||
|
||||
SESSION_MAX_AGE_DAYS=14
|
||||
DASHBOARD_REFRESH_INTERVAL_MS=2000
|
||||
DEFAULT_ADMIN_USERNAME=admin
|
||||
DEFAULT_ADMIN_NAME=Admin
|
||||
DEFAULT_ADMIN_PASSWORD=admin
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
name: Bug report
|
||||
about: Report a problem with Pulse Signage
|
||||
labels:
|
||||
- bug
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Please provide enough detail to reproduce the problem.
|
||||
|
||||
- type: textarea
|
||||
id: steps
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
description: List the actions that trigger the bug.
|
||||
placeholder: |
|
||||
1. Open the dashboard
|
||||
2. Change the playlist on a screen
|
||||
3. Save and wait for playback to refresh
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: Expected behavior
|
||||
description: What you expected to happen.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: actual
|
||||
attributes:
|
||||
label: Actual behavior
|
||||
description: What actually happened.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: environment
|
||||
attributes:
|
||||
label: Environment
|
||||
description: Include version, browser, player setup, and any relevant logs.
|
||||
placeholder: |
|
||||
Version:
|
||||
Browser:
|
||||
Player version:
|
||||
Logs:
|
||||
@@ -0,0 +1,5 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Documentation
|
||||
url: https://git.lzstealth.com/LZStealth/pulse-signage
|
||||
about: Check the project repository and docs before opening an issue.
|
||||
@@ -0,0 +1,37 @@
|
||||
name: Feature request
|
||||
about: Suggest an improvement or new capability
|
||||
labels:
|
||||
- enhancement
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Share the problem you're trying to solve and the outcome you'd like.
|
||||
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: Problem statement
|
||||
description: What limitation or workflow gap are you trying to address?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: proposal
|
||||
attributes:
|
||||
label: Proposed solution
|
||||
description: Describe the change you would like to see.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Alternatives considered
|
||||
description: Any workarounds or other approaches you've considered.
|
||||
|
||||
- type: textarea
|
||||
id: context
|
||||
attributes:
|
||||
label: Additional context
|
||||
description: Mockups, screenshots, examples, or related links.
|
||||
@@ -0,0 +1,14 @@
|
||||
# Repository Instructions
|
||||
|
||||
## Versioning and releases
|
||||
|
||||
- Treat `package.json` as the source of truth for the application version.
|
||||
- When the app version changes, update `CHANGELOG.md` in the same change.
|
||||
- Keep database migration versions aligned with the release they actually belong to.
|
||||
- If only part of a migration batch belongs to a newer release, split that batch into a separate migration entry instead of relabeling the earlier release.
|
||||
- Do not bump migration versions just because the app version changed.
|
||||
|
||||
## Before finishing a versioned change
|
||||
|
||||
- Confirm the package version, changelog entry, and migration tags are consistent.
|
||||
- If a release note mentions a feature or fix, make sure the matching code path or migration block is tagged to the same release.
|
||||
+5
-1
@@ -1,6 +1,10 @@
|
||||
node_modules/
|
||||
uploads/
|
||||
media/
|
||||
!src/web/lib/media/
|
||||
!src/web/lib/media/**
|
||||
docker-compose.dev.yml
|
||||
/dev-demo-seed.js
|
||||
.vscode/
|
||||
.env
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
|
||||
+560
@@ -0,0 +1,560 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## 2.5.4 - 2026-08-03
|
||||
|
||||
### Changed
|
||||
|
||||
- The API source bearer token field now uses a password input with a right-side toggle to show or hide the token.
|
||||
|
||||
### Fixed
|
||||
|
||||
- QR code regions now render at the correct size in the slide editor preview, and the QR chip header now matches the other region cards.
|
||||
- The dashboard onboarding link now uses the player public base URL stored in the database instead of a `/screen/...` URL.
|
||||
|
||||
## 2.5.3 - 2026-08-03
|
||||
|
||||
### Changed
|
||||
|
||||
- The user edit page delete action now shows a confirmation prompt before removing an account.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Schedule modal cancel now restores the original rule set instead of keeping edits made after the modal opened.
|
||||
|
||||
## 2.5.2 - 2026-08-03
|
||||
|
||||
### Changed
|
||||
|
||||
- Player page auth now writes a cookie for websocket reuse, and player and announcement sockets accept that cookie so they no longer depend on query-string tokens.
|
||||
- RSS and API region placeholder panels now use shared field-aware chip rendering, with updated spacing and defaults across the slide editor.
|
||||
- RTMP regions now default audio to enabled in the editor, and the slide rich-text editor no longer allows anchor tags.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Player websocket auth now falls back to the `pulse_page_auth` cookie when the auth query parameter is unavailable.
|
||||
|
||||
## 2.5.1 - 2026-08-03
|
||||
|
||||
### Added
|
||||
|
||||
- Template regions now include a dedicated advanced animation modal for editing intro, outro, and Attention Seekers settings directly.
|
||||
|
||||
### Changed
|
||||
|
||||
- Template region animation settings now store as a single JSON object with `intro`, `outro`, and `loop` keys.
|
||||
- The template editor advanced animation field now edits the JSON object directly instead of separate preset selects.
|
||||
- Animate.css is now loaded from vendored assets so template previews and player rendering use the local copy.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Existing databases now migrate legacy animation columns into `animation_json` and then drop the old columns.
|
||||
|
||||
## 2.5.0 - 2026-08-03
|
||||
|
||||
### Added
|
||||
|
||||
- Template regions now support intro, exit, and Attention Seekers animation presets in the editor, with in-canvas preview controls.
|
||||
|
||||
### Changed
|
||||
|
||||
- The template editor now locks editing while preview playback is running so animation changes are easier to inspect.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Existing databases now receive the template region animation columns through the versioned migration path.
|
||||
|
||||
## 2.4.2 - 2026-08-02
|
||||
|
||||
### Added
|
||||
|
||||
- Screen-level controls are now shown on the dashboard for quicker access to screen actions.
|
||||
- Connected clients can now be moved to another screen from the dashboard, and the player redirects to the new screen after the binding updates.
|
||||
- Playlist slide config now includes a mute toggle for video and RTMP items, and the player honors the stored mute state during playback.
|
||||
|
||||
### Changed
|
||||
|
||||
- Playlist canvas lock now stores `canvas_id` on `c_playlists` and links directly to `c_canvas_sizes`.
|
||||
- Playlist add and edit now keep the selected canvas size tied to the playlist form flow, so canvas changes stay consistent while editing.
|
||||
- RBAC permission sections now follow the same order as the admin sidebar, with section spacing kept in 10-point increments.
|
||||
- Admin add/edit pages now share form view-model helpers and common form shells across data sources, signage, RBAC, and user settings, reducing duplicated template markup.
|
||||
- Playlist editing now goes through a shared form page helper with dedicated schedule-rule cards and drag sorting, keeping slide rows and rule state in sync.
|
||||
- Data sources now use per-section route modules for API sources, RSS feeds, and timetables, while the shared controller only keeps the root `/data-sources` redirect.
|
||||
- The consolidated admin shell also refreshed shared layout, toast, login, and error rendering to match the newer helper registration flow.
|
||||
- Connected client row actions now use consistent icon-and-label buttons for pause and blackout across the server-rendered page and live dashboard updates.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Canvas filtering for playlist slides now follows the selected canvas size id instead of a derived signature.
|
||||
- Saving API sources and RSS feeds no longer throws an internal server error after the route split, because the shared data-source refresh task service is wired through the web bootstrap again.
|
||||
|
||||
## 2.4.1 - 2026-08-02
|
||||
|
||||
### Changed
|
||||
|
||||
- Playlist canvas size is now stored on `c_playlists.canvas_signature` and the migration backfills existing single-canvas playlists.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Playlist canvas sizing no longer relies on startup bootstrap logic.
|
||||
|
||||
## 2.4.0 - 2026-08-02
|
||||
|
||||
### Added
|
||||
|
||||
- Playlist slide scheduling now supports multiple OR-ed rule blocks instead of a single date-or-time schedule.
|
||||
|
||||
### Changed
|
||||
|
||||
- Playlist slide schedule data now stores rule sets in `schedule_rules_json`, and the migration backfills legacy schedule columns before dropping them.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Legacy `schedule_mode`, date, time, and day columns are removed from `c_playlist_slides` after the new rule schema is installed.
|
||||
|
||||
## 2.3.0 - 2026-08-02
|
||||
|
||||
### Added
|
||||
|
||||
- Shared add/edit form templates now cover API sources, RSS feeds, announcements, canvas sizes, screens, and templates, reducing duplicate page markup.
|
||||
- Schedule data source CRUD was added for grouped events in the admin UI.
|
||||
- The schedule region was added for rendering grouped events in slides and player playback.
|
||||
- Schedule data now flows through `scheduleGroups` in the slide editor and player payloads, letting the schedule region show upcoming or current entries from a selected group.
|
||||
|
||||
### Changed
|
||||
|
||||
- Admin save actions were standardized around the shared `saveActionButtons` helper, including save, save-and-close, save-and-new, cancel, and delete controls.
|
||||
- Admin routes and page renderers now route through the shared form/view helpers instead of separate add/edit templates for the refactored sections.
|
||||
- Toast, error, login, and shared layout rendering were refreshed to line up with the consolidated admin shell and helper registration flow.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Canvas size defaults now seed only once during bootstrap when `c_canvas_sizes` is empty, instead of running from the schema layout path on every startup.
|
||||
- The schema bootstrap file is now limited to table layout definitions, with one-time seed data handled by the bootstrap layer.
|
||||
|
||||
## 2.2.2 - 2026-08-01
|
||||
|
||||
### Fixed
|
||||
|
||||
- The theme bootstrap now resolves saved `auto` to an actual dark or light value on first paint, so explicit dark mode no longer flashes white while the page loads.
|
||||
- The shared app header is now sticky so the top navigation stays visible while scrolling.
|
||||
|
||||
## 2.2.1 - 2026-08-01
|
||||
|
||||
### Fixed
|
||||
|
||||
- Managed-font source files are now tracked correctly so packaged releases include the `src/web/lib/media` module instead of shipping a broken build.
|
||||
|
||||
## 2.2.0 - 2026-08-01
|
||||
|
||||
### Added
|
||||
|
||||
- Announcement management is now wired for the admin UI and player overlay, with color, template, and expiry controls.
|
||||
- Time / Date regions now render live in the editor and player.
|
||||
|
||||
### Changed
|
||||
|
||||
- Announcement lists now default-sort by Description and show the affected screens for each announcement.
|
||||
- Role add and edit pages now include a Cancel action alongside the save buttons.
|
||||
- Announcement publishing now only targets explicitly assigned screens; unassigned announcements no longer publish globally.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Announcement screen labels now correctly distinguish No Screens, partial assignments, and All Screens.
|
||||
- Role duplicate-name validation on edit now checks the correct RBAC table.
|
||||
|
||||
## 2.1.1 - 2026-08-01
|
||||
|
||||
### Added
|
||||
|
||||
- API source latest-response viewing now supports collapsing and expanding all JSON sections from the edit page.
|
||||
- Placeholder transforms now support basic string helpers like `.upper()`, `.title()`, and `.lower()`.
|
||||
|
||||
### Changed
|
||||
|
||||
- The API source add page now matches the edit page’s section titles and layout more closely.
|
||||
- API source placeholder suggestions now stay leaf-only, so container paths and arrays are no longer suggested as placeholders.
|
||||
- Slide editor and player placeholder resolution now share the same parsing and transform logic.
|
||||
- API source and RSS editor helper text now shows the new placeholder transform syntax.
|
||||
- API sources, RSS feeds, slides, users, and roles now render their delete actions in a disabled outlined-danger state when deletion is blocked, matching the rest of the admin tables.
|
||||
- The users edit page no longer wraps all sections in one outer card, so the profile, password, and roles cards render as separate sections.
|
||||
|
||||
### Fixed
|
||||
|
||||
- API source item-path overrides continue to treat the final path as an array source, while nested object fields remain available for placeholders.
|
||||
- The API source latest-response panel now opens as a tree view by default instead of a flat JSON blob.
|
||||
|
||||
## 2.1.0 - 2026-07-30
|
||||
|
||||
### Added
|
||||
|
||||
- Screen and dashboard player links now render the full player URL instead of only the player base host.
|
||||
|
||||
### Changed
|
||||
|
||||
- The player registry now uses a singleton `d_players` row and `d_screens.player_id` points at that shared player record.
|
||||
- Player startup is now responsible for creating the shared player record, while onboarding and state polling no longer create extra player rows.
|
||||
- The screen/player binding path was simplified so opening a screen binds it to the shared player record without trying to re-register the player.
|
||||
- Added notes near the singleton-player code paths to make the future multi-player migration work easier to revisit.
|
||||
- Managed fonts now have dedicated editor/player sync handling, including a scheduled font sweep, sorted font-family lists, the TinyMCE fullscreen button, and shared font stylesheet loading in the WYSIWYG editor.
|
||||
- Text and RSS region types now own their own default style and fallback behavior instead of relying on shared slide helper fallbacks.
|
||||
- Image and video region handling was also pushed further into modular per-type modules, keeping their editor, preview, and player logic closer to the region itself instead of the shared slide helper layer.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Removed several onboarding and runtime code paths that were still inserting or refreshing `d_players` rows during normal player playback.
|
||||
- Fixed the screen-binding flow so it no longer writes a device UUID into `d_screens.player_id`, which avoided foreign key errors under the shared-player model.
|
||||
|
||||
## 2.0.0 - 2026-07-28
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- Breaking database schema changes were introduced in this release; existing databases may require migration or recreation before upgrading.
|
||||
|
||||
### Added
|
||||
|
||||
- Broader player and admin release coverage across onboarding, playlist playback, background task handling, and media-region support.
|
||||
|
||||
### Changed
|
||||
|
||||
- The database bootstrap and migration flow were reorganized, with shared DB helpers split out to support the newer schema layout.
|
||||
- Admin and signage views were updated around the newer list, playlist, and RBAC behavior, including playlist scheduling and screen/client actions.
|
||||
- Player runtime, onboarding, and region rendering were tightened so HTML, image, RSS, RTMP, text, video, and webpage regions follow the newer playback flow.
|
||||
- Background task, upload sync, and data-source refresh handling were refined across the web app and startup paths.
|
||||
- Docker, environment, README, and websocket/api documentation were refreshed to match the current release structure.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Client name checks, playlist editing, and slide/media synchronization paths were adjusted to better preserve existing references during admin and player updates.
|
||||
|
||||
## 1.5.16 - 2026-07-26
|
||||
|
||||
### Added
|
||||
|
||||
- Background task handling was split into dedicated handler and scheduling modules, with startup data-source refreshes and unused-upload cleanup wired through the shared setup flow.
|
||||
|
||||
### Changed
|
||||
|
||||
- The admin shell, shared theme, and toast presentation were refreshed to match the newer layout and notification styling.
|
||||
- Playlist scheduling now handles video-duration toggles and already-assigned slides more clearly in the picker and editor UI.
|
||||
- RBAC and settings views were updated to align with the revised admin layout and shared table behavior.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Template and slide thumbnail refreshes now run through the background task queue so template-wide thumbnail regeneration stays consistent.
|
||||
|
||||
## 1.5.15 - 2026-07-26
|
||||
|
||||
### Changed
|
||||
|
||||
- Player slide transitions now start and pause video playback around the fade window so video regions stay aligned with the slide boundary.
|
||||
- Dashboard action cards now use a three-column grid on wider layouts.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Slide video duration probes now preserve sub-second precision instead of rounding to whole seconds.
|
||||
|
||||
## 1.5.14 - 2026-07-26
|
||||
|
||||
### Fixed
|
||||
|
||||
- Dashboard client action icons now render valid Bootstrap Icon classes without duplicating the `bi-` prefix.
|
||||
|
||||
## 1.5.13 - 2026-07-26
|
||||
|
||||
### Added
|
||||
|
||||
- Shared table helpers for pagination, search, and sorting across the admin and signage views.
|
||||
|
||||
### Changed
|
||||
|
||||
- Refreshed the shared admin shell and moved common UI behavior into shared layout and helper modules.
|
||||
- Reworked dashboard, background tasks, RBAC, and signage list pages to use the newer shared table and state flow.
|
||||
- Updated the admin and player branding assets and tightened the page scripts around the new layout structure.
|
||||
|
||||
## 1.5.12 - 2026-07-26
|
||||
|
||||
### Changed
|
||||
|
||||
- Shared web UI helpers are now loaded once in the admin shell and reused across the dashboard, admin, and template editor.
|
||||
- Template lists now show slide counts, and the template editor tracks which regions are already in use.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Template editing now blocks removal of regions that are still referenced by slides.
|
||||
|
||||
## 1.5.11 - 2026-07-26
|
||||
|
||||
### Added
|
||||
|
||||
- Video regions are now supported end to end, including player rendering, admin editing, playlist scheduling, and player sync.
|
||||
|
||||
### Changed
|
||||
|
||||
- The slide, template, and playlist flows were updated so video content can be created, configured, and scheduled alongside existing region types.
|
||||
- Player rendering and related media handling now recognize the new video region implementation.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Region and upload handling were adjusted so video media syncs cleanly through the player and web admin paths.
|
||||
|
||||
## 1.5.10 - 2026-07-26
|
||||
|
||||
### Added
|
||||
|
||||
- Scheduled background tasks now have their own settings page, separate permission gate, and manual run action alongside the main queue view.
|
||||
- The slide editor now includes a refreshed preview/sidebar layout and a cropper-based image upload flow.
|
||||
|
||||
### Changed
|
||||
|
||||
- Background task and admin list views now use richer pagination and filtering so queue state, task sources, and recurring jobs stay easier to navigate.
|
||||
- The admin shell and shared styling were refreshed to support the updated settings pages, slide editor layout, and task table presentation.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Upload sync and slide thumbnail refresh handling were tightened so slide changes queue follow-up work more reliably.
|
||||
|
||||
## 1.5.9 - 2026-07-26
|
||||
|
||||
### Fixed
|
||||
|
||||
- User creation now keeps the role checkboxes inside the add-user form so selected roles are submitted correctly.
|
||||
|
||||
## 1.5.8 - 2026-07-25
|
||||
|
||||
### Fixed
|
||||
|
||||
- Upload removal requests now authenticate correctly when a deployed player parses a bodyless `DELETE` request as an empty object, which could cause 401s for newly uploaded images.
|
||||
|
||||
## 1.5.6 - 2026-07-25
|
||||
|
||||
### Changed
|
||||
|
||||
- Playlist slide picker now uses a button to reveal already added slides, while still defaulting to showing only available slides.
|
||||
- Already-added slides in the picker are now labeled and visually muted to make them easier to spot.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Thumbnails in slide and playlist tables now follow the slide canvas ratio and cap at 5.5rem tall.
|
||||
- Thumbnail-only table columns are no longer sortable.
|
||||
|
||||
## 1.5.5 - 2026-07-25
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed a migration gap that could leave `slides.thumbnail_path` missing on upgraded databases.
|
||||
|
||||
## 1.5.4 - 2026-07-25
|
||||
|
||||
### Added
|
||||
|
||||
- Cropper.js has been added to aid with image uploading.
|
||||
|
||||
### Changed
|
||||
|
||||
- Upgraded Multer to 2.x to remove the deprecated 1.x dependency warning.
|
||||
- Tightened a few admin UI details, including slide editor and RBAC list spacing/typography adjustments.
|
||||
|
||||
## 1.5.3 - 2026-07-25
|
||||
|
||||
### Added
|
||||
|
||||
- A modal-based slide picker for playlist editing with search, multi-select, and slide thumbnails.
|
||||
- Reusable modal and table-pagination helpers for the admin UI.
|
||||
- Slide thumbnail previews in playlist schedules, with image fallbacks when a thumbnail is missing.
|
||||
|
||||
### Changed
|
||||
|
||||
- RTMP playback now probes session readiness more aggressively and retries startup before surfacing failures.
|
||||
- Playlist editing now includes richer scheduling controls, live slide counts, and support for skipping unavailable RTMP slides.
|
||||
- Admin lists and background task pages now preserve filter and pagination state during AJAX navigation.
|
||||
- Shared admin shell styling and helpers were refreshed to support the newer list, modal, and pagination UI.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Player upload sync and route handling now degrade more gracefully when backend services are unavailable.
|
||||
- RTMP and playlist playback now recover more cleanly when a stream fails to initialize or respond in time.
|
||||
|
||||
## 1.5.2 - 2026-07-25
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed player media upload sync so signed upload requests are verified after the request body has been parsed.
|
||||
|
||||
## 1.5.1 - 2026-07-25
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the account page so it can save changes again.
|
||||
|
||||
## 1.5.0 - 2026-07-25
|
||||
|
||||
### Added
|
||||
|
||||
- RTMP slide regions with ffmpeg-to-HLS playback support in the player.
|
||||
- Service-worker-backed player caching and offline recovery paths for playlists and playback state.
|
||||
- Broader slide, template, playlist, RSS, and API source support, including richer region handling and background refresh processing.
|
||||
|
||||
### Changed
|
||||
|
||||
- Reworked the web admin app around a newer modular route and view layout, with shared helpers moving into `src/web/lib`, `src/web/pages`, and `src/web/views`.
|
||||
- Refreshed the admin shell styling and theme bootstrapping, including local AdminLTE assets, vendored Bootstrap Icons, and earlier theme initialization on first paint.
|
||||
- Rebuilt RBAC around CRUD permissions and updated the admin screens for roles, users, and action gating.
|
||||
- Refactored the player onboarding flow, client-name handling, and runtime storage so screen identities persist more reliably across refreshes.
|
||||
- Tightened duplicate-name checks and other admin-side validation paths.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Improved player fallback behavior when the database is unavailable.
|
||||
- Cleaned up player runtime and routing paths so screen lookups and live updates degrade more gracefully.
|
||||
|
||||
### Docs and Ops
|
||||
|
||||
- Updated Docker, environment, and documentation files to match the current release structure and player/web APIs.
|
||||
|
||||
## 1.4.6 - 2026-07-22
|
||||
|
||||
### Changed
|
||||
|
||||
- Refined RBAC enforcement across the admin UI and route layer, especially for list actions and edit screens.
|
||||
- Updated the dashboard and admin styling to match the revised permission model and page layout.
|
||||
- Adjusted database helpers and admin data views to support the newer RBAC and list rendering behavior.
|
||||
|
||||
## 1.4.5 - 2026-07-21
|
||||
|
||||
### Changed
|
||||
|
||||
- Renamed the admin screen commands route to client commands and aligned the dashboard copy with client-focused terminology.
|
||||
- Updated the RBAC and database helpers to support the revised client command flow.
|
||||
- Refined the client list view and dashboard interactions around the new client command behavior.
|
||||
|
||||
## 1.4.4 - 2026-07-21
|
||||
|
||||
### Fixed
|
||||
|
||||
- Rendered 404 pages for unmatched routes.
|
||||
|
||||
## 1.4.3 - 2026-07-21
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed RBAC client action visibility.
|
||||
|
||||
## 1.4.2 - 2026-07-21
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed duplicate permissions cleanup.
|
||||
|
||||
## 1.4.1 - 2026-07-21
|
||||
|
||||
### Added
|
||||
|
||||
- RBAC roles system support.
|
||||
|
||||
### Changed
|
||||
|
||||
- Version bump to 1.4.1.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Schema migration audit columns.
|
||||
|
||||
## 1.3.4 - 2026-07-21
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed duplicate onboarding client names.
|
||||
|
||||
## 1.3.3 - 2026-07-21
|
||||
|
||||
### Changed
|
||||
|
||||
- Added the first round of modular admin-page work, including a shared admin route layer and refreshed dashboard/list rendering.
|
||||
- Updated the admin shell styling and layout handling for the newer page structure.
|
||||
- Adjusted package metadata and lockfile state to match the release.
|
||||
|
||||
## 1.3.2 - 2026-07-21
|
||||
|
||||
### Changed
|
||||
|
||||
- Version bump to 1.3.2.
|
||||
|
||||
## 1.3.0 - 2026-07-20
|
||||
|
||||
### Added
|
||||
|
||||
- Split shared helpers, bootstrap logic, route groups, and upload-sync behavior out of `web.js`.
|
||||
- Split websocket and runtime handling into `runtime.js`.
|
||||
- Split playlist assembly and revision hashing into `playlist.js`.
|
||||
- Split onboarding and player HTTP routes into dedicated modules.
|
||||
- Split render utilities and template loading into `render-helpers.js`.
|
||||
|
||||
### Changed
|
||||
|
||||
- Kept `web.js` focused on wiring and server startup.
|
||||
- Kept `player.js` mostly as startup and orchestration.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed screen playlist reassignment so changing a screen's playlist now triggers a refresh.
|
||||
- Fixed single-slide playlist refresh behavior so updates do not get stuck behind the current slide.
|
||||
|
||||
## 1.1.5 - 2026-07-15
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed text pasting in the admin UI.
|
||||
|
||||
## 1.1.4 - 2026-07-15
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the admin table and playlist reorder UI.
|
||||
|
||||
## 1.1.3 - 2026-07-15
|
||||
|
||||
### Added
|
||||
|
||||
- Playlist drag sorting.
|
||||
- Local SortableJS.
|
||||
- Playlist UI updates.
|
||||
|
||||
## 1.1.2 - 2026-07-14
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated the admin layout and frame shell styling.
|
||||
- Refined `src/web/view.js` so view rendering handles the revised shell structure.
|
||||
- Adjusted package metadata for the release.
|
||||
|
||||
## 1.1.1 - 2026-07-14
|
||||
|
||||
### Changed
|
||||
|
||||
- Refreshed favicon and layout assets.
|
||||
|
||||
## 1.1.0 - 2026-07-14
|
||||
|
||||
### Changed
|
||||
|
||||
- Refactored the web server and release workflow.
|
||||
|
||||
## 1.0.2 - 2026-07-14
|
||||
|
||||
### Fixed
|
||||
|
||||
- Handled player redirects on screen slug change.
|
||||
|
||||
## 1.0.1 - 2026-07-14
|
||||
|
||||
### Changed
|
||||
|
||||
- Made screen slugs editable.
|
||||
|
||||
## 1.0.0 - 2026-07-14
|
||||
|
||||
### Fixed
|
||||
|
||||
- Initial release.
|
||||
+6
-1
@@ -2,12 +2,17 @@ FROM node:24-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apk add --no-cache ffmpeg chromium nss freetype harfbuzz ttf-freefont
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm install --omit=dev --no-audit --no-fund
|
||||
|
||||
COPY src ./src
|
||||
|
||||
RUN mkdir -p /app/uploads
|
||||
RUN mkdir -p /app/src/web/public/vendor/animate.css && cp /app/node_modules/animate.css/animate.min.css /app/src/web/public/vendor/animate.css/animate.min.css
|
||||
RUN mkdir -p /app/src/player/public/vendor/animate.css && cp /app/node_modules/animate.css/animate.min.css /app/src/player/public/vendor/animate.css/animate.min.css
|
||||
|
||||
RUN mkdir -p /app/media
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
|
||||
@@ -1,84 +1,81 @@
|
||||
# Pulse Signage
|
||||
|
||||
Pulse Signage is a digital signage system that lets you manage screens, playlists, slides, templates, and uploaded media from one admin interface.
|
||||
Pulse Signage is a self-hosted digital signage platform built for teams that want clear, flexible control over what appears on every screen.
|
||||
|
||||
It runs as two connected services:
|
||||
It gives you one place to manage playlists, slides, templates, screen assignments, announcements, data-driven content, and live player control without handing that workflow to a third-party service.
|
||||
|
||||
- the web admin app for managing content and screens
|
||||
- the player app that shows the current signage on each screen and listens for live updates
|
||||
## Why It Stands Out
|
||||
|
||||
## What You Can Do
|
||||
- Keep signage under your own control.
|
||||
- Build polished screen experiences with reusable templates and playlists.
|
||||
- Push announcements, schedules, RSS feeds, API content, and other dynamic content to screens.
|
||||
- See what is live, what is queued, and what needs attention from a single admin dashboard.
|
||||
- Use the same system for everyday signage, timed messages, and more structured information displays.
|
||||
|
||||
- Sign in to the admin dashboard
|
||||
- Create and organize playlists and slides
|
||||
- Design reusable templates and canvas sizes
|
||||
- Register screens and assign playlists to them
|
||||
- Upload images and other media for use in slides and templates
|
||||
- View live screen connections and send player commands
|
||||
## What It Handles
|
||||
|
||||
## Documentation
|
||||
- Screens and playlist assignments.
|
||||
- Slides, templates, and canvas sizes.
|
||||
- Announcements and screen-specific targeting.
|
||||
- RSS feeds, API sources, and schedule-based content.
|
||||
- Roles and permissions for admin access.
|
||||
- Background tasks and scheduled refresh jobs.
|
||||
- Player onboarding and live playback control.
|
||||
|
||||
Player-facing API details live in [docs/api.md](docs/api.md). It covers the player HTTP endpoints for screen playback, playlist data, connections, and commands.
|
||||
## Simple Deployment
|
||||
|
||||
Player websocket behavior lives in [docs/websocket.md](docs/websocket.md). It covers the player control socket, snapshot stream, and the command/message shapes the player accepts.
|
||||
Pulse Signage ships with a Docker Compose stack for the published image:
|
||||
|
||||
## What You Need
|
||||
- Published-image stack: `docker compose -f docker-compose.yml up -d`
|
||||
|
||||
- Docker and Docker Compose
|
||||
- A MySQL database
|
||||
Use this stack when you want to run the tagged image instead of building from source.
|
||||
|
||||
## First-Time Setup
|
||||
## Environment File
|
||||
|
||||
When the app starts for the first time, it creates the database tables it needs and adds a default admin account if no users exist yet.
|
||||
The compose files read from a root `.env` file. Start by copying `.env.example` to `.env`, then adjust the values for your setup.
|
||||
|
||||
The variables are split by who uses them:
|
||||
|
||||
Shared across the stack:
|
||||
|
||||
- `PULSE_SIGNAGE_IMAGE`
|
||||
- `PULSE_SIGNAGE_SHARED_SECRET`
|
||||
|
||||
Web container:
|
||||
|
||||
- `SESSION_MAX_AGE_DAYS`
|
||||
- `DEFAULT_ADMIN_USERNAME`
|
||||
- `DEFAULT_ADMIN_NAME`
|
||||
- `DEFAULT_ADMIN_PASSWORD`
|
||||
- `PASSWORD_HASH_ITERATIONS`
|
||||
|
||||
Player container:
|
||||
|
||||
- `PLAYER_PUBLIC_BASE_URL`
|
||||
- `PLAYER_INTERNAL_BASE_URL`
|
||||
|
||||
MySQL container:
|
||||
|
||||
- `DB_HOST`
|
||||
- `DB_PORT`
|
||||
- `DB_NAME`
|
||||
- `DB_USER`
|
||||
- `DB_PASSWORD`
|
||||
- `MYSQL_ROOT_PASSWORD`
|
||||
|
||||
`DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, and `DB_PASSWORD` are shared in practice because both app containers talk to the same database service.
|
||||
|
||||
`PLAYER_INTERNAL_BASE_URL` is used by the web side for server-to-player calls, while `PLAYER_PUBLIC_BASE_URL` is used for browser-facing player links.
|
||||
|
||||
## First-Time Use
|
||||
|
||||
The first startup seeds a default admin account if the database is empty.
|
||||
|
||||
- Username: `admin`
|
||||
- Password: `admin`
|
||||
|
||||
You can change the initial admin credentials with these optional environment variables:
|
||||
## In Practice
|
||||
|
||||
- `DEFAULT_ADMIN_USERNAME`
|
||||
- `DEFAULT_ADMIN_NAME`
|
||||
- `DEFAULT_ADMIN_PASSWORD`
|
||||
Pulse Signage is designed to feel focused and reliable rather than heavy or noisy. It works well when you want a clean internal signage system for offices, venues, dashboards, or any environment where screens need to stay up to date without a lot of manual effort.
|
||||
|
||||
## Configuration
|
||||
|
||||
The app reads its settings from environment variables.
|
||||
|
||||
### Database Connection
|
||||
|
||||
- `DB_HOST` - MySQL host, default `127.0.0.1`
|
||||
- `DB_PORT` - MySQL port, default `3306`
|
||||
- `DB_NAME` - database name, default `signage`
|
||||
- `DB_USER` - database user, default `signage_user`
|
||||
- `DB_PASSWORD` - database password, default `signage_password`
|
||||
|
||||
### Web Admin App
|
||||
|
||||
- `WEB_PORT` - admin app port, default `3000`
|
||||
- `PLAYER_INTERNAL_BASE_URL` - player address used by the server, default `http://player:3001`
|
||||
- `PLAYER_PUBLIC_BASE_URL` - player address shown in browser links, default `http://localhost:3001`
|
||||
|
||||
### Player App
|
||||
|
||||
- `PLAYER_PORT` - player port, default `3001`
|
||||
|
||||
## Docker Compose
|
||||
|
||||
The repository includes a `docker-compose.yml` file that starts three services:
|
||||
|
||||
- `web` - the admin app on port `3000`
|
||||
- `player` - the signage player on port `3001`
|
||||
- `mysql` - the database on port `3306`
|
||||
|
||||
By default, `web` and `player` use the published image from `git.lzstealth.com/lzstealth/pulse-signage:latest`. You can point both services at a specific release by setting `PULSE_SIGNAGE_IMAGE` to a tagged image such as `git.lzstealth.com/lzstealth/pulse-signage:v1.0.0`.
|
||||
|
||||
The Compose file also defines a shared `uploads` volume for media and a `mysql_data` volume for database persistence.
|
||||
|
||||
In Docker, upload storage is controlled by the `uploads` volume mount at `/app/uploads`.
|
||||
Outside Docker, the web app and player do not have to use the same upload location or even the same server, as long as each service can access its own configured media path.
|
||||
|
||||
## Important Notes
|
||||
|
||||
- The web app must be able to reach the player through `PLAYER_INTERNAL_BASE_URL`.
|
||||
- When running in Docker, that value should point to the Docker service name, not `localhost`.
|
||||
- Uploaded media is stored separately from the application source, so make sure it is backed up if you are not using Docker volumes.
|
||||
The player and admin pieces stay linked, so changes made in the dashboard can flow out to screens quickly and consistently.
|
||||
+11
-18
@@ -1,20 +1,16 @@
|
||||
services:
|
||||
web:
|
||||
image: ${PULSE_SIGNAGE_IMAGE:-git.lzstealth.com/lzstealth/pulse-signage:latest}
|
||||
container_name: signage-web
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3000:3000"
|
||||
- "8080:8080"
|
||||
environment:
|
||||
NODE_ENV: ${NODE_ENV:-production}
|
||||
WEB_PORT: ${WEB_PORT:-3000}
|
||||
DB_HOST: ${DB_HOST:-mysql}
|
||||
DB_PORT: ${DB_PORT:-3306}
|
||||
DB_NAME: ${DB_NAME:-signage}
|
||||
DB_USER: ${DB_USER:-signage_user}
|
||||
DB_PASSWORD: ${DB_PASSWORD:-signage_password}
|
||||
PLAYER_INTERNAL_BASE_URL: ${PLAYER_INTERNAL_BASE_URL:-http://player:3001}
|
||||
PLAYER_PUBLIC_BASE_URL: ${PLAYER_PUBLIC_BASE_URL:-http://localhost:3001}
|
||||
PULSE_SIGNAGE_SHARED_SECRET: ${PULSE_SIGNAGE_SHARED_SECRET:-}
|
||||
SESSION_MAX_AGE_DAYS: ${SESSION_MAX_AGE_DAYS:-14}
|
||||
DASHBOARD_REFRESH_INTERVAL_MS: ${DASHBOARD_REFRESH_INTERVAL_MS:-2000}
|
||||
DEFAULT_ADMIN_USERNAME: ${DEFAULT_ADMIN_USERNAME:-admin}
|
||||
@@ -22,8 +18,8 @@ services:
|
||||
DEFAULT_ADMIN_PASSWORD: ${DEFAULT_ADMIN_PASSWORD:-admin}
|
||||
PASSWORD_HASH_ITERATIONS: ${PASSWORD_HASH_ITERATIONS:-310000}
|
||||
volumes:
|
||||
- uploads:/app/uploads
|
||||
command: ["npm", "run", "start:web"]
|
||||
- media:/app/media
|
||||
command: ["node", "src/web.js"]
|
||||
depends_on:
|
||||
mysql:
|
||||
condition: service_healthy
|
||||
@@ -32,21 +28,21 @@ services:
|
||||
|
||||
player:
|
||||
image: ${PULSE_SIGNAGE_IMAGE:-git.lzstealth.com/lzstealth/pulse-signage:latest}
|
||||
container_name: signage-player
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3001:3001"
|
||||
- "8081:8081"
|
||||
environment:
|
||||
NODE_ENV: ${NODE_ENV:-production}
|
||||
PLAYER_PORT: ${PLAYER_PORT:-3001}
|
||||
PLAYER_PUBLIC_BASE_URL: ${PLAYER_PUBLIC_BASE_URL:-http://localhost:8081}
|
||||
PLAYER_INTERNAL_BASE_URL: ${PLAYER_INTERNAL_BASE_URL:-http://player:8081}
|
||||
PULSE_SIGNAGE_SHARED_SECRET: ${PULSE_SIGNAGE_SHARED_SECRET:-}
|
||||
DB_HOST: ${DB_HOST:-mysql}
|
||||
DB_PORT: ${DB_PORT:-3306}
|
||||
DB_NAME: ${DB_NAME:-signage}
|
||||
DB_USER: ${DB_USER:-signage_user}
|
||||
DB_PASSWORD: ${DB_PASSWORD:-signage_password}
|
||||
volumes:
|
||||
- uploads:/app/uploads
|
||||
command: ["npm", "run", "start:player"]
|
||||
- media:/app/media
|
||||
command: ["node", "src/player.js"]
|
||||
depends_on:
|
||||
mysql:
|
||||
condition: service_healthy
|
||||
@@ -55,10 +51,7 @@ services:
|
||||
|
||||
mysql:
|
||||
image: mysql:8.4
|
||||
container_name: signage-mysql
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3306:3306"
|
||||
environment:
|
||||
MYSQL_DATABASE: ${DB_NAME:-signage}
|
||||
MYSQL_USER: ${DB_USER:-signage_user}
|
||||
@@ -79,7 +72,7 @@ services:
|
||||
|
||||
volumes:
|
||||
mysql_data:
|
||||
uploads:
|
||||
media:
|
||||
|
||||
networks:
|
||||
pulse_signage:
|
||||
|
||||
+188
-4
@@ -2,27 +2,136 @@
|
||||
|
||||
## Overview
|
||||
|
||||
Player service base URL: `http://localhost:3001`
|
||||
Player service base URL: `http://localhost:8081`
|
||||
|
||||
This document uses OpenAPI-style sections, but stays in plain markdown.
|
||||
This document covers the player HTTP surface only. The admin dashboard exposes its own routes for screen commands and onboarding management.
|
||||
|
||||
Access note: most player endpoints are unauthenticated because they are meant to run inside a trusted deployment network. Anything that mutates state or writes files should be treated as internal-only unless you add your own auth layer in front of it.
|
||||
|
||||
When `PULSE_SIGNAGE_SHARED_SECRET` is set, the player pages sign same-origin API fetches with `x-pulse-page-auth`, and the web app signs server-to-player requests with `x-pulse-request-timestamp` plus `x-pulse-request-signature`. Page tokens auto-renew before expiry while the page stays active, and signed server requests are only accepted when their timestamp is fresh. If the secret is unset, those checks stay disabled for compatibility.
|
||||
|
||||
## Endpoints
|
||||
|
||||
### `GET /`
|
||||
Returns a plain-text landing response for the player service.
|
||||
Returns the player onboarding landing page.
|
||||
Access: public within the trusted player deployment.
|
||||
|
||||
### `GET /onboard`
|
||||
Returns the onboarding form page.
|
||||
Access: public within the trusted player deployment.
|
||||
|
||||
### `GET /screen/{slug}`
|
||||
Returns the rendered player page for a screen.
|
||||
Access: public within the trusted player deployment.
|
||||
|
||||
### `GET /api/onboarding/status`
|
||||
Returns the persisted onboarding status for a device.
|
||||
Access: public within the trusted player deployment.
|
||||
|
||||
Query fields:
|
||||
|
||||
- `deviceId` required
|
||||
|
||||
### `GET /api/onboarding/screens`
|
||||
Returns the list of screens available for onboarding.
|
||||
Access: public within the trusted player deployment.
|
||||
|
||||
### `GET /api/onboarding/qr`
|
||||
Returns an SVG QR code that points to the onboarding form.
|
||||
Access: public within the trusted player deployment.
|
||||
|
||||
Query fields:
|
||||
|
||||
- `deviceId` required
|
||||
|
||||
### `POST /api/auth/page`
|
||||
Renews the current page-auth token before it expires.
|
||||
Access: internal to the player page and onboarding page. The request must include a valid `x-pulse-page-auth` header.
|
||||
|
||||
Response fields:
|
||||
|
||||
- `token`
|
||||
- `issuedAt`
|
||||
- `expiresAt`
|
||||
|
||||
### `POST /api/onboarding`
|
||||
Binds a device to a screen and client name.
|
||||
Access: internal-only. Protect this endpoint if the player service is reachable outside your trusted network.
|
||||
|
||||
Accepted request fields:
|
||||
|
||||
- `deviceId` required
|
||||
- `clientName` required
|
||||
- `screenSlug` required
|
||||
|
||||
Response fields:
|
||||
|
||||
- `deviceId`
|
||||
- `clientName`
|
||||
- `screenId`
|
||||
- `screenSlug`
|
||||
- `screenName`
|
||||
- `playerUrl`
|
||||
- `queued`
|
||||
|
||||
### `GET /api/media/config`
|
||||
Returns the upload directory configured for the player service.
|
||||
Access: internal-only.
|
||||
|
||||
### `PUT /api/media/{filename}`
|
||||
Writes an uploaded file into the player upload directory.
|
||||
Access: internal-only and write-protected behind your deployment boundary.
|
||||
|
||||
### `DELETE /api/media/{filename}`
|
||||
Deletes a file from the player upload directory.
|
||||
Access: internal-only and write-protected behind your deployment boundary.
|
||||
|
||||
### `GET /api/rtmp/session`
|
||||
Creates or reuses an RTMP-to-HLS session for a source URL.
|
||||
Access: internal-only.
|
||||
|
||||
Query fields:
|
||||
|
||||
- `source` required
|
||||
- `disableAudio` optional
|
||||
|
||||
### `GET /api/rtmp/streams/{key}/index.m3u8`
|
||||
Returns the RTMP session HLS manifest.
|
||||
Access: internal-only.
|
||||
|
||||
### `GET /api/rtmp/streams/{key}/{fileName}`
|
||||
Returns an RTMP HLS segment or related stream file.
|
||||
Access: internal-only.
|
||||
|
||||
### `GET /api/screens/{slug}/playlist`
|
||||
Returns the current playlist payload for a screen.
|
||||
Access: public within the trusted player deployment.
|
||||
|
||||
Response fields:
|
||||
|
||||
- `screen`
|
||||
- `playlist`
|
||||
- `slides`
|
||||
- `rssFeeds`
|
||||
- `apiSources`
|
||||
- `revision`
|
||||
|
||||
### `GET /api/screens/{slug}/connections`
|
||||
### `GET /api/screens/{slug}/clients`
|
||||
Returns the live player connection snapshot for a screen.
|
||||
Access: public within the trusted player deployment, but it exposes live connection state.
|
||||
|
||||
Response fields:
|
||||
|
||||
- `screen`
|
||||
- `screenSlug`
|
||||
- `count`
|
||||
- `connections`
|
||||
- `degraded`
|
||||
|
||||
### `POST /api/screens/{slug}/commands`
|
||||
Sends a command to the player connections for a screen.
|
||||
Access: internal-only. The admin dashboard should remain the protected control surface for commands.
|
||||
|
||||
Accepted request fields:
|
||||
|
||||
@@ -31,21 +140,87 @@ Accepted request fields:
|
||||
- `clientId` optional
|
||||
- `blackout` optional when `command=blackout`
|
||||
|
||||
Command-specific fields:
|
||||
|
||||
- `url` for `redirect`
|
||||
- `clientName` for `setclientname`
|
||||
- `deviceId` for `setclientname`
|
||||
|
||||
Supported commands:
|
||||
|
||||
- `refresh`
|
||||
- `reload`
|
||||
- `redirect`
|
||||
- `pause`
|
||||
- `blackout`
|
||||
- `previous`
|
||||
- `next`
|
||||
- `left`
|
||||
- `right`
|
||||
- `setclientname`
|
||||
|
||||
If `connectionId` or `clientId` is provided, the command targets a single player connection. Otherwise it is broadcast to all connections for that screen.
|
||||
|
||||
Response fields:
|
||||
|
||||
- `screen`
|
||||
- `screenSlug`
|
||||
- `command`
|
||||
- `connectionId`
|
||||
- `sent`
|
||||
- `degraded`
|
||||
|
||||
## Response Shapes
|
||||
|
||||
### Onboarding Status Response
|
||||
|
||||
`GET /api/onboarding/status` returns an object with:
|
||||
|
||||
- `deviceId`
|
||||
- `onboarded`
|
||||
- `clientName`
|
||||
- `screenId`
|
||||
- `screenSlug`
|
||||
- `screenName`
|
||||
- `playerUrl`
|
||||
|
||||
### Onboarding Screens Response
|
||||
|
||||
`GET /api/onboarding/screens` returns an object with:
|
||||
|
||||
- `screens`
|
||||
|
||||
### QR Response
|
||||
|
||||
`GET /api/onboarding/qr` returns SVG markup.
|
||||
|
||||
### Upload Config Response
|
||||
|
||||
`GET /api/media/config` returns an object with:
|
||||
|
||||
- `uploadDir`
|
||||
|
||||
### RTMP Session Response
|
||||
|
||||
`GET /api/rtmp/session` returns an object with:
|
||||
|
||||
- `key`
|
||||
- `playlistUrl`
|
||||
- `disableAudio`
|
||||
- `ready`
|
||||
|
||||
### Onboarding Write Response
|
||||
|
||||
`POST /api/onboarding` returns an object with:
|
||||
|
||||
- `deviceId`
|
||||
- `clientName`
|
||||
- `screenId`
|
||||
- `screenSlug`
|
||||
- `screenName`
|
||||
- `playerUrl`
|
||||
- `queued`
|
||||
|
||||
### Playlist Response
|
||||
|
||||
`GET /api/screens/{slug}/playlist` returns an object with:
|
||||
@@ -53,6 +228,9 @@ If `connectionId` or `clientId` is provided, the command targets a single player
|
||||
- `screen`
|
||||
- `playlist`
|
||||
- `slides`
|
||||
- `rssFeeds`
|
||||
- `apiSources`
|
||||
- `revision`
|
||||
|
||||
### Connections Response
|
||||
|
||||
@@ -62,6 +240,7 @@ If `connectionId` or `clientId` is provided, the command targets a single player
|
||||
- `screenSlug`
|
||||
- `count`
|
||||
- `connections`
|
||||
- `degraded`
|
||||
|
||||
### Command Response
|
||||
|
||||
@@ -72,6 +251,7 @@ If `connectionId` or `clientId` is provided, the command targets a single player
|
||||
- `command`
|
||||
- `connectionId`
|
||||
- `sent`
|
||||
- `degraded`
|
||||
|
||||
## Data Models
|
||||
|
||||
@@ -113,11 +293,15 @@ If `connectionId` or `clientId` is provided, the command targets a single player
|
||||
|
||||
- `id`
|
||||
- `clientId`
|
||||
- `clientName`
|
||||
- `deviceId`
|
||||
- `label`
|
||||
- `userAgent`
|
||||
- `viewport`
|
||||
- `page`
|
||||
- `currentSlide`
|
||||
- `currentSlideId`
|
||||
- `currentSlideTitle`
|
||||
- `paused`
|
||||
- `blackout`
|
||||
- `clientIp`
|
||||
@@ -128,4 +312,4 @@ If `connectionId` or `clientId` is provided, the command targets a single player
|
||||
## Notes
|
||||
|
||||
- The player API is the only surface documented here.
|
||||
- Web UI/admin routes are intentionally omitted.
|
||||
- Web UI/admin routes are intentionally omitted, except where the player service itself exposes onboarding and upload endpoints.
|
||||
|
||||
+286
@@ -0,0 +1,286 @@
|
||||
# Database Schema
|
||||
|
||||
This app creates and maintains its schema at startup through `src/db/index.js`.
|
||||
The sections below summarize the current tables and their purpose.
|
||||
|
||||
Primary keys are `id` unless noted otherwise. Timestamps are stored as `created_at` and `modified_at` when a table supports auditing.
|
||||
|
||||
## Admin
|
||||
|
||||
- `a_users` - admin user accounts and password hashes.
|
||||
- `a_roles` - named role definitions.
|
||||
- `a_permissions` - permission catalog seeded from the application constants.
|
||||
- `a_role_permissions` - many-to-many mapping between roles and permissions.
|
||||
- `a_user_roles` - many-to-many mapping between users and roles.
|
||||
- `a_sessions` - persisted admin session tokens.
|
||||
|
||||
### `a_users`
|
||||
|
||||
- `id`, `name`, `username`, `password_hash`, `password_salt`, `password_iterations`, `created_at`, `created_by`, `modified_at`, `modified_by`
|
||||
- `username` is unique.
|
||||
|
||||
### `a_roles`
|
||||
|
||||
- `id`, `name`, `description`, `created_at`, `created_by`, `modified_at`, `modified_by`
|
||||
- `name` is unique.
|
||||
|
||||
### `a_permissions`
|
||||
|
||||
- `id`, `permission_key`, `name`, `section_name`, `description`, `created_at`, `modified_at`
|
||||
- `permission_key` is unique.
|
||||
|
||||
### `a_role_permissions`
|
||||
|
||||
- `role_id`, `permission_id`, `created_at`, `modified_at`
|
||||
- Foreign keys:
|
||||
- `role_id` -> `a_roles.id`
|
||||
- `permission_id` -> `a_permissions.id`
|
||||
- Composite primary key: `(role_id, permission_id)`
|
||||
|
||||
### `a_user_roles`
|
||||
|
||||
- `user_id`, `role_id`, `created_at`, `modified_at`
|
||||
- Foreign keys:
|
||||
- `user_id` -> `a_users.id`
|
||||
- `role_id` -> `a_roles.id`
|
||||
- Composite primary key: `(user_id, role_id)`
|
||||
|
||||
### `a_sessions`
|
||||
|
||||
- `session_hash`, `user_id`, `expires_at`, `created_at`, `created_by`, `last_used_at`, `modified_by`
|
||||
- `session_hash` is the primary key.
|
||||
- Foreign key:
|
||||
- `user_id` -> `a_users.id`
|
||||
|
||||
## Content
|
||||
|
||||
- `c_canvas_sizes` - reusable canvas presets for templates.
|
||||
- `c_playlists` - playlist definitions, playback options, and canvas assignment.
|
||||
- `c_templates` - slide templates with canvas and background settings.
|
||||
- `c_template_regions` - template region layout and metadata.
|
||||
- `c_slides` - slide records with template binding, JSON content, and thumbnail path.
|
||||
- `c_playlist_slides` - ordered playlist items and timing.
|
||||
- `c_playlist_slide_schedule_rules` - rule rows attached to playlist slides.
|
||||
|
||||
### `c_canvas_sizes`
|
||||
|
||||
- `id`, `name`, `width`, `height`, `created_at`, `created_by`, `modified_at`, `modified_by`
|
||||
- `(width, height)` is unique.
|
||||
|
||||
### `c_playlists`
|
||||
|
||||
- `id`, `name`, `fade_between_slides`, `skip_unavailable_rtmp`, `canvas_id`, `created_at`, `created_by`, `modified_at`, `modified_by`
|
||||
- Foreign key:
|
||||
- `canvas_id` -> `c_canvas_sizes.id` with `ON DELETE SET NULL`
|
||||
|
||||
### `c_templates`
|
||||
|
||||
- `id`, `name`, `canvas_size_id`, `background_image_path`, `background_color`, `created_at`, `created_by`, `modified_at`, `modified_by`
|
||||
- Foreign key:
|
||||
- `canvas_size_id` -> `c_canvas_sizes.id` with `ON DELETE SET NULL`
|
||||
|
||||
### `c_template_regions`
|
||||
|
||||
- `id`, `template_id`, `region_key`, `region_type`, `label`, `font_family`, `lock_ratio`, `animation_json`, `x`, `y`, `width`, `height`, `z_index`, `created_at`, `created_by`, `modified_at`, `modified_by`
|
||||
- Foreign key:
|
||||
- `template_id` -> `c_templates.id` with `ON DELETE CASCADE`
|
||||
|
||||
### `c_slides`
|
||||
|
||||
- `id`, `title`, `template_id`, `content_json`, `thumbnail_path`, `created_at`, `created_by`, `modified_at`, `modified_by`
|
||||
- Foreign key:
|
||||
- `template_id` -> `c_templates.id` with `ON DELETE SET NULL`
|
||||
|
||||
### `c_playlist_slides`
|
||||
|
||||
- `id`, `playlist_id`, `slide_id`, `position`, `duration_seconds`, `use_video_duration`, `created_at`, `created_by`, `modified_at`, `modified_by`
|
||||
- Foreign keys:
|
||||
- `playlist_id` -> `c_playlists.id` with `ON DELETE CASCADE`
|
||||
- `slide_id` -> `c_slides.id` with `ON DELETE CASCADE`
|
||||
|
||||
### `c_playlist_slide_schedule_rules`
|
||||
|
||||
- `id`, `playlist_slide_id`, `position`, `start_datetime`, `end_datetime`, `start_time`, `end_time`, `schedule_days_json`, `created_at`, `created_by`, `modified_at`, `modified_by`
|
||||
- Foreign key:
|
||||
- `playlist_slide_id` -> `c_playlist_slides.id` with `ON DELETE CASCADE`
|
||||
- Index:
|
||||
- `(playlist_slide_id, position)`
|
||||
|
||||
- Each playlist slide can have zero or more schedule rules.
|
||||
- Rules are evaluated with OR across rows and with AND across the fields inside a single rule.
|
||||
|
||||
## Devices
|
||||
|
||||
- `d_players` - player registry and connection metadata.
|
||||
- `d_screens` - screen records and playlist/player assignment.
|
||||
- `d_onboarding_devices` - device-to-screen bindings and onboarded client names.
|
||||
|
||||
## Announcements
|
||||
|
||||
- `d_announcements` - announcement content and display metadata.
|
||||
- `d_announcement_screens` - announcement-to-screen assignments.
|
||||
|
||||
### `d_players`
|
||||
|
||||
- `device_id`, `public_base_url`, `internal_base_url`, `last_seen_at`, `created_at`, `modified_at`
|
||||
- `device_id` is the primary key.
|
||||
|
||||
### `d_screens`
|
||||
|
||||
- `id`, `name`, `slug`, `playlist_id`, `player_id`, `created_at`, `created_by`, `modified_at`, `modified_by`
|
||||
- `slug` is unique.
|
||||
- Foreign keys:
|
||||
- `playlist_id` -> `c_playlists.id` with `ON DELETE SET NULL`
|
||||
- `player_id` -> `d_players.id` with `ON DELETE RESTRICT`
|
||||
|
||||
- `player_id` is required and defaults to `'1'` for the current singleton-player model.
|
||||
|
||||
### `d_onboarding_devices`
|
||||
|
||||
- `device_id`, `client_name`, `screen_id`, `created_at`, `created_by`, `modified_at`, `modified_by`
|
||||
- `device_id` is the primary key.
|
||||
- Foreign key:
|
||||
- `screen_id` -> `d_screens.id` with `ON DELETE SET NULL`
|
||||
|
||||
### `d_announcements`
|
||||
|
||||
- `id`, `message`, `short_label`, `announcement_type`, `color_key`, `icon_key`, `duration_seconds`, `expires_at`, `created_at`, `created_by`, `modified_at`, `modified_by`
|
||||
- `announcement_type` defaults to `lower-third`.
|
||||
|
||||
### `d_announcement_screens`
|
||||
|
||||
- `announcement_id`, `screen_id`, `created_at`, `created_by`, `modified_at`, `modified_by`
|
||||
- Foreign keys:
|
||||
- `announcement_id` -> `d_announcements.id` with `ON DELETE CASCADE`
|
||||
- `screen_id` -> `d_screens.id` with `ON DELETE CASCADE`
|
||||
- Composite primary key: `(announcement_id, screen_id)`
|
||||
|
||||
## Onboarding
|
||||
|
||||
- The onboarding flow uses `d_onboarding_devices` to bind a device to a screen and persist the client name.
|
||||
|
||||
## Integrations
|
||||
|
||||
- `i_rss_feeds` - RSS feed definitions and refresh cadence.
|
||||
- `i_rss_feed_items` - cached RSS feed items.
|
||||
- `i_api_sources` - API source definitions and last response snapshot.
|
||||
- `i_schedule_groups` - grouped schedule definitions used by the schedule region.
|
||||
- `i_schedule_entries` - dated entries that belong to a schedule group.
|
||||
|
||||
### `i_rss_feeds`
|
||||
|
||||
- `id`, `name`, `feed_url`, `update_interval_value`, `update_interval_unit`, `item_limit`, `created_at`, `created_by`, `modified_at`, `modified_by`
|
||||
|
||||
### `i_rss_feed_items`
|
||||
|
||||
- `id`, `rss_feed_id`, `position`, `item_json`, `created_at`, `created_by`, `modified_at`, `modified_by`
|
||||
- Foreign key:
|
||||
- `rss_feed_id` -> `i_rss_feeds.id` with `ON DELETE CASCADE`
|
||||
- Unique key:
|
||||
- `(rss_feed_id, position)`
|
||||
|
||||
### `i_api_sources`
|
||||
|
||||
- `id`, `name`, `api_url`, `auth_method`, `auth_username`, `auth_password`, `auth_bearer_token`, `auth_header_name`, `auth_header_value`, `items_path`, `update_interval_value`, `update_interval_unit`, `last_pulled_at`, `last_pull_error`, `last_response_status`, `last_response_content_type`, `last_response_json`, `created_at`, `created_by`, `modified_at`, `modified_by`
|
||||
|
||||
### `i_schedule_groups`
|
||||
|
||||
- `id`, `name`, `short_description`, `created_at`, `created_by`, `modified_at`, `modified_by`
|
||||
|
||||
### `i_schedule_entries`
|
||||
|
||||
- `id`, `schedule_group_id`, `title`, `short_description`, `start_datetime`, `end_datetime`, `created_at`, `created_by`, `modified_at`, `modified_by`
|
||||
- Foreign key:
|
||||
- `schedule_group_id` -> `i_schedule_groups.id` with `ON DELETE CASCADE`
|
||||
- Index:
|
||||
- `(schedule_group_id, start_datetime)`
|
||||
|
||||
## Operations
|
||||
|
||||
- `o_background_tasks` - queue and history for background jobs.
|
||||
|
||||
### `o_background_tasks`
|
||||
|
||||
- `id`, `task_key`, `task_type`, `title`, `category`, `status`, `payload_json`, `metadata_json`, `attempts`, `created_at`, `created_by`, `started_at`, `finished_at`, `error_message`
|
||||
- Indexed by `status`, `task_key`, and `task_type`.
|
||||
|
||||
## Notes
|
||||
|
||||
- The schema is initialized with `CREATE TABLE IF NOT EXISTS`, so new installs can start from an empty database.
|
||||
- `src/db/bootstrap.js` seeds the canvas size defaults, default permissions, and the default administrator role.
|
||||
- The migration module stays in place for future releases, but this version treats the current schema as the install baseline.
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
A_USERS {
|
||||
}
|
||||
A_ROLES {
|
||||
}
|
||||
A_PERMISSIONS {
|
||||
}
|
||||
A_ROLE_PERMISSIONS {
|
||||
}
|
||||
A_USER_ROLES {
|
||||
}
|
||||
A_SESSIONS {
|
||||
}
|
||||
C_CANVAS_SIZES {
|
||||
}
|
||||
C_PLAYLISTS {
|
||||
}
|
||||
C_TEMPLATES {
|
||||
}
|
||||
C_TEMPLATE_REGIONS {
|
||||
}
|
||||
C_SLIDES {
|
||||
}
|
||||
C_PLAYLIST_SLIDES {
|
||||
}
|
||||
C_PLAYLIST_SLIDE_SCHEDULE_RULES {
|
||||
}
|
||||
D_PLAYERS {
|
||||
}
|
||||
D_SCREENS {
|
||||
}
|
||||
D_ONBOARDING_DEVICES {
|
||||
}
|
||||
D_ANNOUNCEMENTS {
|
||||
}
|
||||
D_ANNOUNCEMENT_SCREENS {
|
||||
}
|
||||
I_RSS_FEEDS {
|
||||
}
|
||||
I_RSS_FEED_ITEMS {
|
||||
}
|
||||
I_API_SOURCES {
|
||||
}
|
||||
I_SCHEDULE_GROUPS {
|
||||
}
|
||||
I_SCHEDULE_ENTRIES {
|
||||
}
|
||||
O_BACKGROUND_TASKS {
|
||||
}
|
||||
|
||||
A_USERS ||--o{ A_USER_ROLES : has
|
||||
A_ROLES ||--o{ A_USER_ROLES : assigned_to
|
||||
A_ROLES ||--o{ A_ROLE_PERMISSIONS : has
|
||||
A_PERMISSIONS ||--o{ A_ROLE_PERMISSIONS : granted_to
|
||||
A_USERS ||--o{ A_SESSIONS : owns
|
||||
|
||||
C_CANVAS_SIZES ||--o{ C_TEMPLATES : used_by
|
||||
C_CANVAS_SIZES ||--o{ C_PLAYLISTS : used_by
|
||||
C_TEMPLATES ||--o{ C_TEMPLATE_REGIONS : contains
|
||||
C_TEMPLATES ||--o{ C_SLIDES : used_by
|
||||
C_PLAYLISTS ||--o{ C_PLAYLIST_SLIDES : contains
|
||||
C_SLIDES ||--o{ C_PLAYLIST_SLIDES : included_in
|
||||
C_PLAYLIST_SLIDES ||--o{ C_PLAYLIST_SLIDE_SCHEDULE_RULES : has_rules
|
||||
|
||||
D_PLAYERS ||--o{ D_SCREENS : assigned_to
|
||||
C_PLAYLISTS ||--o{ D_SCREENS : uses
|
||||
D_SCREENS ||--o{ D_ONBOARDING_DEVICES : binds
|
||||
D_ANNOUNCEMENTS ||--o{ D_ANNOUNCEMENT_SCREENS : targets
|
||||
D_SCREENS ||--o{ D_ANNOUNCEMENT_SCREENS : receives
|
||||
|
||||
I_RSS_FEEDS ||--o{ I_RSS_FEED_ITEMS : caches
|
||||
I_SCHEDULE_GROUPS ||--o{ I_SCHEDULE_ENTRIES : contains
|
||||
```
|
||||
+63
-36
@@ -2,10 +2,12 @@
|
||||
|
||||
## Overview
|
||||
|
||||
Player service websocket base URL: `ws://localhost:3001`
|
||||
Player service websocket base URL: `ws://localhost:8081`
|
||||
|
||||
This document uses OpenAPI-style sections, but stays in plain markdown.
|
||||
|
||||
The `PULSE_SIGNAGE_SHARED_SECRET` setting does not change the websocket message format here. It is used to sign the player control socket URL and the server-side snapshot subscription; the message payloads themselves remain the same.
|
||||
|
||||
## Channels
|
||||
|
||||
### `GET /ws/screens/{slug}`
|
||||
@@ -13,36 +15,27 @@ Player control channel.
|
||||
|
||||
This is the bidirectional socket used by the player page. The player sends status messages to the server, and the server sends commands back to the player.
|
||||
|
||||
Access: the player page must include a valid `auth` query parameter signed with the shared secret. The browser page refreshes this token automatically while it is active.
|
||||
|
||||
### `GET /ws/screens/{slug}/events`
|
||||
Player snapshot channel.
|
||||
|
||||
This is the server-to-dashboard snapshot stream for live player connection state.
|
||||
|
||||
Access: internal-only. The web backend subscribes with signed request headers; browsers should not connect directly.
|
||||
|
||||
### `GET /ws/screens/{slug}/announcements`
|
||||
Player announcement channel.
|
||||
|
||||
This is the server-to-player push channel used to wake the player when an announcement changes. The browser player reconnects automatically and refreshes its announcement state when it receives an `announcement-refresh` message.
|
||||
|
||||
Access: the player page must include a valid `auth` query parameter signed with the shared secret.
|
||||
|
||||
## Player Control Channel
|
||||
|
||||
### Messages from player to server
|
||||
|
||||
The player sends two message types:
|
||||
|
||||
- `hello`
|
||||
- `state`
|
||||
|
||||
#### `hello`
|
||||
|
||||
Example payload:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "hello",
|
||||
"clientId": "client-id",
|
||||
"userAgent": "browser ua",
|
||||
"page": "http://.../screen/demo",
|
||||
"viewport": { "width": 1920, "height": 1080 },
|
||||
"paused": false,
|
||||
"blackout": false,
|
||||
"currentSlide": null
|
||||
}
|
||||
```
|
||||
The player sends a state snapshot message.
|
||||
|
||||
#### `state`
|
||||
|
||||
@@ -52,20 +45,21 @@ Example payload:
|
||||
{
|
||||
"type": "state",
|
||||
"clientId": "client-id",
|
||||
"clientName": "friendly label",
|
||||
"deviceId": "device-id",
|
||||
"userAgent": "browser ua",
|
||||
"page": "http://.../screen/demo",
|
||||
"viewport": { "width": 1920, "height": 1080 },
|
||||
"paused": false,
|
||||
"blackout": false,
|
||||
"currentSlide": {
|
||||
"id": 12,
|
||||
"title": "Main Slide",
|
||||
"kind": "image",
|
||||
"playlistSignature": "..."
|
||||
}
|
||||
"currentSlide": null
|
||||
}
|
||||
```
|
||||
|
||||
When `currentSlide` is present, the player includes the active slide id, title, kind, and playlist signature.
|
||||
|
||||
The server recognizes `clientId`, `clientName`, `deviceId`, `userAgent`, `page`, `viewport`, `paused`, `blackout`, and `currentSlide` from state messages.
|
||||
|
||||
### Messages from server to player
|
||||
|
||||
The server sends command messages with:
|
||||
@@ -75,17 +69,19 @@ The server sends command messages with:
|
||||
- optional `sentAt`
|
||||
- optional `targetConnectionId`
|
||||
- optional `blackout` for blackout commands
|
||||
- optional `url` for redirect commands
|
||||
- optional `clientName` and `deviceId` for client-name updates
|
||||
|
||||
Supported commands:
|
||||
|
||||
- `blackout`
|
||||
- `next`
|
||||
- `pause`
|
||||
- `previous`
|
||||
- `refresh`
|
||||
- `reload`
|
||||
- `pause`
|
||||
- `blackout`
|
||||
- `previous`
|
||||
- `next`
|
||||
- `left`
|
||||
- `right`
|
||||
- `redirect`
|
||||
- `setclientname`
|
||||
|
||||
#### `refresh`
|
||||
Asks the player to refetch the current playlist.
|
||||
@@ -111,12 +107,39 @@ Example payload:
|
||||
|
||||
Use `false` to restore and `true` to blackout.
|
||||
|
||||
#### `previous` / `left`
|
||||
#### `redirect`
|
||||
Requests the player page to navigate to a new location. The destination is supplied as `url` by the caller that forwarded the command.
|
||||
|
||||
#### `previous`
|
||||
Moves to the previous slide.
|
||||
|
||||
#### `next` / `right`
|
||||
#### `next`
|
||||
Moves to the next slide.
|
||||
|
||||
#### `setclientname`
|
||||
Updates the client name associated with the player session and onboarding record.
|
||||
The command payload should include `clientName` and may include `deviceId` when the caller is updating a specific onboarding binding.
|
||||
|
||||
## Announcement Channel
|
||||
|
||||
### Messages from server to player
|
||||
|
||||
The server sends a lightweight refresh notification.
|
||||
|
||||
#### `announcement-refresh`
|
||||
|
||||
Example payload:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "announcement-refresh",
|
||||
"slug": "demo",
|
||||
"sentAt": "2026-08-01T12:00:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
When the player receives this message, it refetches the current announcement state over HTTP.
|
||||
|
||||
## Snapshot Channel
|
||||
|
||||
### Messages from server to client
|
||||
@@ -142,11 +165,15 @@ The snapshot channel is server-to-client only.
|
||||
|
||||
- `id`
|
||||
- `clientId`
|
||||
- `clientName`
|
||||
- `deviceId`
|
||||
- `label`
|
||||
- `userAgent`
|
||||
- `viewport`
|
||||
- `page`
|
||||
- `currentSlide`
|
||||
- `currentSlideId`
|
||||
- `currentSlideTitle`
|
||||
- `paused`
|
||||
- `blackout`
|
||||
- `clientIp`
|
||||
|
||||
Generated
+3476
File diff suppressed because it is too large
Load Diff
+18
-8
@@ -1,8 +1,12 @@
|
||||
{
|
||||
"name": "pulse-signage",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "Pulse Signage application with MySQL and media uploads",
|
||||
"version": "2.5.4",
|
||||
"private": false,
|
||||
"description": "Pulse Signage application with MySQL and media storage",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.lzstealth.com/LZStealth/pulse-signage.git"
|
||||
},
|
||||
"main": "src/common.js",
|
||||
"scripts": {
|
||||
"start": "node -r dotenv/config src/web.js",
|
||||
@@ -10,17 +14,23 @@
|
||||
"start:player": "node -r dotenv/config src/player.js",
|
||||
"dev:web": "nodemon -r dotenv/config src/web.js",
|
||||
"dev:player": "nodemon -r dotenv/config src/player.js",
|
||||
"docker:build": "docker build -t pulse-signage:test .",
|
||||
"docker:up": "docker-compose up -d",
|
||||
"docker:down": "docker-compose down",
|
||||
"docker:logs": "docker-compose logs -f --tail=100"
|
||||
"test": "node --test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sparticuz/chromium": "^137.0.0",
|
||||
"animate.css": "^4.1.1",
|
||||
"bootstrap-icons": "1.11.3",
|
||||
"cropperjs": "^1.6.2",
|
||||
"dotenv": "^17.4.2",
|
||||
"express": "^4.21.2",
|
||||
"handlebars": "^4.7.8",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"hls.js": "^1.5.15",
|
||||
"multer": "^2.2.0",
|
||||
"mysql2": "^3.14.3",
|
||||
"puppeteer-core": "^24.16.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"qrcode-generator": "^2.0.4",
|
||||
"sharp": "^0.35.3",
|
||||
"ws": "^8.21.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// Password hashing and session token helpers.
|
||||
|
||||
const crypto = require('crypto');
|
||||
|
||||
const PASSWORD_ITERATIONS = Number(process.env.PASSWORD_HASH_ITERATIONS || 310000);
|
||||
|
||||
+81
-5
@@ -1,17 +1,90 @@
|
||||
const db = require('./db');
|
||||
const data = require('./data');
|
||||
const player = require('./player/render');
|
||||
// Module-resolution shims for the app's `#root` and `#src` aliases.
|
||||
|
||||
const Module = require('module');
|
||||
const path = require('path');
|
||||
|
||||
const rootDir = path.resolve(__dirname, '..');
|
||||
const srcDir = __dirname;
|
||||
const resolveFilename = Module._resolveFilename;
|
||||
|
||||
Module._resolveFilename = function (request, parent, isMain, options) {
|
||||
if (request === '#root') {
|
||||
request = rootDir;
|
||||
} else if (request.startsWith('#root/')) {
|
||||
request = path.join(rootDir, request.slice('#root/'.length));
|
||||
} else if (request === '#src') {
|
||||
request = srcDir;
|
||||
} else if (request.startsWith('#src/')) {
|
||||
request = path.join(srcDir, request.slice('#src/'.length));
|
||||
}
|
||||
|
||||
return resolveFilename.call(this, request, parent, isMain, options);
|
||||
};
|
||||
|
||||
const dbCommon = require('#src/db/common');
|
||||
const db = require('#src/db');
|
||||
const dbBootstrap = require('#src/db/bootstrap');
|
||||
const data = require('#src/data');
|
||||
const player = require('#src/player/render');
|
||||
const listQuery = require('#src/web/lib/list-query');
|
||||
const { fetchPlaylistCanvasId, fetchPlaylistCanvasSignature } = require('#src/web/lib/helpers');
|
||||
|
||||
module.exports = {
|
||||
createPool: db.createPool,
|
||||
createPool: dbCommon.createPool,
|
||||
pruneStaleOnboardingDevices: dbCommon.pruneStaleOnboardingDevices,
|
||||
ensureSchema: db.ensureSchema,
|
||||
bootstrapDatabase: dbBootstrap.bootstrapDatabase,
|
||||
slugify: data.slugify,
|
||||
uniqueScreenSlug: data.uniqueScreenSlug,
|
||||
parseJsonSafe: data.parseJsonSafe,
|
||||
fetchAdminData: data.fetchAdminData,
|
||||
fetchPlaylistsPage: data.fetchPlaylistsPage,
|
||||
fetchSlidesPage: data.fetchSlidesPage,
|
||||
fetchTemplatesPage: data.fetchTemplatesPage,
|
||||
fetchCanvasSizesPage: data.fetchCanvasSizesPage,
|
||||
fetchScreensPage: data.fetchScreensPage,
|
||||
ANNOUNCEMENT_TYPES: data.ANNOUNCEMENT_TYPES,
|
||||
ANNOUNCEMENT_COLORS: data.ANNOUNCEMENT_COLORS,
|
||||
ANNOUNCEMENT_ICONS: data.ANNOUNCEMENT_ICONS,
|
||||
ANNOUNCEMENT_ICON_OPTIONS: data.ANNOUNCEMENT_ICON_OPTIONS,
|
||||
ANNOUNCEMENT_ICON_LABELS: data.ANNOUNCEMENT_ICON_LABELS,
|
||||
DEFAULT_ANNOUNCEMENT_ICON: data.DEFAULT_ANNOUNCEMENT_ICON,
|
||||
normalizeAnnouncementType: data.normalizeAnnouncementType,
|
||||
normalizeAnnouncementColor: data.normalizeAnnouncementColor,
|
||||
normalizeAnnouncementIcon: data.normalizeAnnouncementIcon,
|
||||
fetchAnnouncementsPage: data.fetchAnnouncementsPage,
|
||||
fetchAnnouncementById: data.fetchAnnouncementById,
|
||||
fetchActiveAnnouncement: data.fetchActiveAnnouncement,
|
||||
buildAnnouncementPayload: data.buildAnnouncementPayload,
|
||||
getSearchQuery: listQuery.getSearchQuery,
|
||||
getSortQuery: listQuery.getSortQuery,
|
||||
getSortDirectionQuery: listQuery.getSortDirectionQuery,
|
||||
fetchPlaylistById: data.fetchPlaylistById,
|
||||
fetchPlaylistCanvasId: fetchPlaylistCanvasId,
|
||||
fetchPlaylistCanvasSignature: fetchPlaylistCanvasSignature,
|
||||
normalizeDisplayMode: data.normalizeDisplayMode,
|
||||
fetchTimetablesData: data.fetchTimetablesData,
|
||||
fetchTimetableGroupsPage: data.fetchTimetableGroupsPage,
|
||||
fetchTimetableGroupById: data.fetchTimetableGroupById,
|
||||
fetchTimetableEntriesByGroupId: data.fetchTimetableEntriesByGroupId,
|
||||
buildTimetableGroupPayload: data.buildTimetableGroupPayload,
|
||||
fetchApiSourcesData: data.fetchApiSourcesData,
|
||||
fetchApiSourcesPage: data.fetchApiSourcesPage,
|
||||
fetchApiSourceById: data.fetchApiSourceById,
|
||||
fetchApiSourceResponse: data.fetchApiSourceResponse,
|
||||
buildApiSourcePayload: data.buildApiSourcePayload,
|
||||
fetchRssFeedsData: data.fetchRssFeedsData,
|
||||
fetchRssFeedsPage: data.fetchRssFeedsPage,
|
||||
fetchRssFeedById: data.fetchRssFeedById,
|
||||
fetchRssFeedItemsByFeedId: data.fetchRssFeedItemsByFeedId,
|
||||
normalizeRssFeedItem: data.normalizeRssFeedItem,
|
||||
buildRssFeedPayload: data.buildRssFeedPayload,
|
||||
fetchRssFeedItems: data.fetchRssFeedItems,
|
||||
replaceRssFeedItems: data.replaceRssFeedItems,
|
||||
fetchScreenById: data.fetchScreenById,
|
||||
fetchScreenEditData: data.fetchScreenEditData,
|
||||
fetchScreenPlayerUrls: data.fetchScreenPlayerUrls,
|
||||
fetchScreenPlayerRecord: data.fetchScreenPlayerRecord,
|
||||
fetchTemplateById: data.fetchTemplateById,
|
||||
fetchSlideById: data.fetchSlideById,
|
||||
fetchTemplatesData: data.fetchTemplatesData,
|
||||
@@ -21,6 +94,9 @@ module.exports = {
|
||||
buildSlidePayload: data.buildSlidePayload,
|
||||
extractTemplateRegions: data.extractTemplateRegions,
|
||||
buildTemplatePayload: data.buildTemplatePayload,
|
||||
fetchDuplicateName: data.fetchDuplicateName,
|
||||
mediaKind: player.mediaKind,
|
||||
renderPlayerPage: player.renderPlayerPage
|
||||
renderPlayerPage: player.renderPlayerPage,
|
||||
renderPlayerOnboardingLandingPage: player.renderPlayerOnboardingLandingPage,
|
||||
renderPlayerOnboardingFormPage: player.renderPlayerOnboardingFormPage
|
||||
};
|
||||
|
||||
+182
-20
@@ -1,38 +1,200 @@
|
||||
// Admin data aggregation helpers for dashboards, playlists, slides, templates, and screens.
|
||||
|
||||
const { fetchPagedRows } = require('./utils');
|
||||
|
||||
async function fetchAdminData(pool) {
|
||||
const [playlists] = await pool.query('SELECT id, name, fade_between_slides, created_at, modified_at, created_by, modified_by FROM playlists ORDER BY id DESC');
|
||||
const [canvasSizes] = await pool.query('SELECT id, name, width, height, created_at, modified_at, created_by, modified_by FROM canvas_sizes ORDER BY width ASC, height ASC, name ASC');
|
||||
const [playlists] = await pool.query('SELECT id, name, fade_between_slides, skip_unavailable_rtmp, created_at, modified_at, created_by, modified_by FROM c_playlists ORDER BY id DESC');
|
||||
const [canvasSizes] = await pool.query('SELECT id, name, width, height, created_at, modified_at, created_by, modified_by FROM c_canvas_sizes ORDER BY width ASC, height ASC, name ASC');
|
||||
const [templates] = await pool.query(`
|
||||
SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, st.created_at, st.modified_at,
|
||||
SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, st.background_color, st.created_at, st.modified_at,
|
||||
cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height
|
||||
FROM slide_templates st
|
||||
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id
|
||||
FROM c_templates st
|
||||
LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id
|
||||
ORDER BY st.id DESC
|
||||
`);
|
||||
const [templateRegions] = await pool.query('SELECT id, template_id, region_key, region_type, label, font_family, x, y, width, height, z_index, created_at, modified_at, created_by, modified_by FROM slide_template_regions ORDER BY template_id ASC, z_index ASC, id ASC');
|
||||
const [templateRegions] = await pool.query('SELECT id, template_id, region_key, region_type, label, lock_ratio, x, y, width, height, z_index, created_at, modified_at, created_by, modified_by FROM c_template_regions ORDER BY template_id ASC, z_index ASC, id ASC');
|
||||
const [slides] = await pool.query(`
|
||||
SELECT s.id, s.title, s.body, s.template_id, s.content_json, s.media_path, s.media_type, s.created_at, s.modified_at, s.created_by, s.modified_by, st.name AS template_name, cs.width AS canvas_width, cs.height AS canvas_height
|
||||
FROM slides s
|
||||
LEFT JOIN slide_templates st ON st.id = s.template_id
|
||||
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id
|
||||
SELECT s.id, s.title, s.template_id, s.content_json, s.thumbnail_path, s.created_at, s.modified_at, s.created_by, s.modified_by, st.name AS template_name, st.canvas_size_id, cs.width AS canvas_width, cs.height AS canvas_height,
|
||||
(SELECT COUNT(DISTINCT ps.playlist_id) FROM c_playlist_slides ps WHERE ps.slide_id = s.id) AS playlist_count
|
||||
FROM c_slides s
|
||||
LEFT JOIN c_templates st ON st.id = s.template_id
|
||||
LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id
|
||||
ORDER BY s.id DESC
|
||||
`);
|
||||
const [screens] = await pool.query(`
|
||||
SELECT s.id, s.name, s.slug, s.playlist_id, s.created_at, s.modified_at, s.created_by, s.modified_by, p.name AS playlist_name
|
||||
FROM screens s
|
||||
LEFT JOIN playlists p ON p.id = s.playlist_id
|
||||
SELECT s.id, s.name, s.slug, s.playlist_id, s.created_at, s.modified_at, s.created_by, s.modified_by, p.name AS playlist_name, pl.public_base_url
|
||||
FROM d_screens s
|
||||
LEFT JOIN c_playlists p ON p.id = s.playlist_id
|
||||
LEFT JOIN d_players pl ON pl.device_id = s.player_id
|
||||
ORDER BY s.id DESC
|
||||
`);
|
||||
const [playlistSlides] = await pool.query(`
|
||||
SELECT ps.id, ps.playlist_id, ps.position, ps.duration_seconds, ps.schedule_mode, ps.schedule_start_datetime, ps.schedule_end_datetime, ps.schedule_start_time, ps.schedule_end_time, ps.schedule_days_json, sl.id AS slide_id, sl.title, sl.media_path, sl.media_type, cs.width AS canvas_width, cs.height AS canvas_height
|
||||
FROM playlist_slides ps
|
||||
JOIN slides sl ON sl.id = ps.slide_id
|
||||
LEFT JOIN slide_templates st ON st.id = sl.template_id
|
||||
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id
|
||||
SELECT ps.id, ps.playlist_id, ps.position, ps.duration_seconds, ps.use_video_duration, ps.disable_audio, sl.id AS slide_id, sl.title, sl.content_json, sl.thumbnail_path, st.canvas_size_id, cs.width AS canvas_width, cs.height AS canvas_height
|
||||
FROM c_playlist_slides ps
|
||||
JOIN c_slides sl ON sl.id = ps.slide_id
|
||||
LEFT JOIN c_templates st ON st.id = sl.template_id
|
||||
LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id
|
||||
ORDER BY ps.playlist_id ASC, ps.position ASC, ps.id ASC
|
||||
`);
|
||||
return { playlists, canvasSizes, templates, templateRegions, slides, screens, playlistSlides };
|
||||
const [playlistScheduleRules] = await pool.query(`
|
||||
SELECT id, playlist_slide_id, position, start_datetime, end_datetime, start_time, end_time, schedule_days_json, created_at, modified_at, created_by, modified_by
|
||||
FROM c_playlist_slide_schedule_rules
|
||||
ORDER BY playlist_slide_id ASC, position ASC, id ASC
|
||||
`);
|
||||
|
||||
const rulesBySlideId = new Map();
|
||||
playlistScheduleRules.forEach(function (rule) {
|
||||
const slideId = Number(rule.playlist_slide_id);
|
||||
if (!rulesBySlideId.has(slideId)) {
|
||||
rulesBySlideId.set(slideId, []);
|
||||
}
|
||||
rulesBySlideId.get(slideId).push(rule);
|
||||
});
|
||||
|
||||
const playlistSlidesWithRules = playlistSlides.map(function (slide) {
|
||||
return Object.assign({}, slide, {
|
||||
scheduleRules: rulesBySlideId.get(Number(slide.id)) || []
|
||||
});
|
||||
});
|
||||
|
||||
return { playlists, canvasSizes, templates, templateRegions, slides, screens, playlistSlides: playlistSlidesWithRules };
|
||||
}
|
||||
|
||||
async function fetchPlaylistsPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
|
||||
const paged = await fetchPagedRows(pool, {
|
||||
selectSql: `SELECT p.id, p.name, p.fade_between_slides, p.skip_unavailable_rtmp, p.created_at, p.modified_at, p.created_by, p.modified_by,
|
||||
(SELECT COUNT(*) FROM c_playlist_slides ps WHERE ps.playlist_id = p.id) AS slide_count,
|
||||
(SELECT COUNT(*) FROM d_screens s WHERE s.playlist_id = p.id) AS screen_count
|
||||
FROM c_playlists p
|
||||
ORDER BY p.id DESC`,
|
||||
countSql: 'SELECT COUNT(*) AS count FROM c_playlists',
|
||||
searchColumns: ['p.name'],
|
||||
searchTerm: searchTerm,
|
||||
sortColumns: {
|
||||
name: 'p.name',
|
||||
slides: 'slide_count',
|
||||
created: 'p.created_at',
|
||||
modified: 'p.modified_at'
|
||||
},
|
||||
sortKey: sortKey,
|
||||
sortDirection: sortDirection,
|
||||
page: page,
|
||||
pageSize: pageSize
|
||||
});
|
||||
|
||||
return Object.assign({ playlists: paged.rows }, paged);
|
||||
}
|
||||
|
||||
async function fetchSlidesPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
|
||||
const paged = await fetchPagedRows(pool, {
|
||||
selectSql: `SELECT s.id, s.title, s.template_id, s.content_json, s.thumbnail_path, s.created_at, s.modified_at, s.created_by, s.modified_by, st.name AS template_name, cs.width AS canvas_width, cs.height AS canvas_height,
|
||||
(SELECT COUNT(DISTINCT ps.playlist_id) FROM c_playlist_slides ps WHERE ps.slide_id = s.id) AS playlist_count
|
||||
FROM c_slides s
|
||||
LEFT JOIN c_templates st ON st.id = s.template_id
|
||||
LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id
|
||||
ORDER BY s.id DESC`,
|
||||
countSql: 'SELECT COUNT(*) AS count FROM c_slides',
|
||||
searchColumns: ['s.title', 'st.name', 's.content_json'],
|
||||
searchTerm: searchTerm,
|
||||
sortColumns: {
|
||||
title: 's.title',
|
||||
template: 'st.name',
|
||||
created: 's.created_at',
|
||||
modified: 's.modified_at'
|
||||
},
|
||||
sortKey: sortKey,
|
||||
sortDirection: sortDirection,
|
||||
page: page,
|
||||
pageSize: pageSize
|
||||
});
|
||||
|
||||
return Object.assign({ slides: paged.rows }, paged);
|
||||
}
|
||||
|
||||
async function fetchTemplatesPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
|
||||
const paged = await fetchPagedRows(pool, {
|
||||
selectSql: `SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, st.background_color, st.created_at, st.modified_at,
|
||||
cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height,
|
||||
(SELECT COUNT(*) FROM c_template_regions str WHERE str.template_id = st.id) AS region_count,
|
||||
(SELECT COUNT(*) FROM c_slides s WHERE s.template_id = st.id) AS slide_count
|
||||
FROM c_templates st
|
||||
LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id
|
||||
ORDER BY st.id DESC`,
|
||||
countSql: 'SELECT COUNT(*) AS count FROM c_templates',
|
||||
searchColumns: ['st.name', 'st.background_image_path', 'st.background_color', 'cs.name'],
|
||||
searchTerm: searchTerm,
|
||||
sortColumns: {
|
||||
name: 'st.name',
|
||||
canvas: ['cs.width', 'cs.height', 'cs.name'],
|
||||
regions: 'region_count',
|
||||
slides: 'slide_count',
|
||||
created: 'st.created_at',
|
||||
modified: 'st.modified_at'
|
||||
},
|
||||
sortKey: sortKey,
|
||||
sortDirection: sortDirection,
|
||||
page: page,
|
||||
pageSize: pageSize
|
||||
});
|
||||
|
||||
return Object.assign({ templates: paged.rows }, paged);
|
||||
}
|
||||
|
||||
async function fetchCanvasSizesPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
|
||||
const paged = await fetchPagedRows(pool, {
|
||||
selectSql: `SELECT id, name, width, height, created_at, modified_at, created_by, modified_by,
|
||||
(SELECT COUNT(*) FROM c_templates st WHERE st.canvas_size_id = c_canvas_sizes.id) AS template_count
|
||||
FROM c_canvas_sizes
|
||||
ORDER BY width ASC, height ASC, name ASC`,
|
||||
countSql: 'SELECT COUNT(*) AS count FROM c_canvas_sizes',
|
||||
searchColumns: ['name', 'width', 'height'],
|
||||
searchTerm: searchTerm,
|
||||
sortColumns: {
|
||||
name: 'name',
|
||||
dimensions: ['width', 'height', 'name'],
|
||||
templates: 'template_count',
|
||||
created: 'created_at',
|
||||
modified: 'modified_at'
|
||||
},
|
||||
sortKey: sortKey,
|
||||
sortDirection: sortDirection,
|
||||
page: page,
|
||||
pageSize: pageSize
|
||||
});
|
||||
|
||||
return Object.assign({ canvasSizes: paged.rows }, paged);
|
||||
}
|
||||
|
||||
async function fetchScreensPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
|
||||
const paged = await fetchPagedRows(pool, {
|
||||
selectSql: `SELECT s.id, s.name, s.slug, s.playlist_id, s.created_at, s.modified_at, s.created_by, s.modified_by, p.name AS playlist_name
|
||||
FROM d_screens s
|
||||
LEFT JOIN c_playlists p ON p.id = s.playlist_id
|
||||
ORDER BY s.id DESC`,
|
||||
countSql: 'SELECT COUNT(*) AS count FROM d_screens',
|
||||
searchColumns: ['s.name', 's.slug', 'p.name'],
|
||||
searchTerm: searchTerm,
|
||||
sortColumns: {
|
||||
name: 's.name',
|
||||
url: 's.slug',
|
||||
playlist: 'p.name',
|
||||
created: 's.created_at',
|
||||
modified: 's.modified_at'
|
||||
},
|
||||
sortKey: sortKey,
|
||||
sortDirection: sortDirection,
|
||||
page: page,
|
||||
pageSize: pageSize
|
||||
});
|
||||
|
||||
return Object.assign({ screens: paged.rows }, paged);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
fetchAdminData
|
||||
fetchAdminData,
|
||||
fetchPlaylistsPage,
|
||||
fetchSlidesPage,
|
||||
fetchTemplatesPage,
|
||||
fetchCanvasSizesPage,
|
||||
fetchScreensPage
|
||||
};
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
const ANNOUNCEMENT_ICON_OPTIONS = [
|
||||
{ value: 'megaphone-fill', label: 'Megaphone' },
|
||||
{ value: 'megaphone', label: 'Megaphone outline' },
|
||||
{ value: 'bell-fill', label: 'Bell' },
|
||||
{ value: 'bell', label: 'Bell outline' },
|
||||
{ value: 'exclamation-triangle-fill', label: 'Warning' },
|
||||
{ value: 'exclamation-triangle', label: 'Warning outline' },
|
||||
{ value: 'info-circle-fill', label: 'Info' },
|
||||
{ value: 'info-circle', label: 'Info outline' },
|
||||
{ value: 'check-circle-fill', label: 'Success' },
|
||||
{ value: 'check-circle', label: 'Success outline' },
|
||||
{ value: 'lightbulb-fill', label: 'Idea' },
|
||||
{ value: 'lightbulb', label: 'Idea outline' },
|
||||
{ value: 'calendar-event-fill', label: 'Calendar' },
|
||||
{ value: 'calendar-event', label: 'Calendar outline' },
|
||||
{ value: 'clock-fill', label: 'Clock' },
|
||||
{ value: 'clock', label: 'Clock outline' },
|
||||
{ value: 'wifi-off', label: 'Wi-Fi Offline' },
|
||||
{ value: 'wifi', label: 'Wi-Fi' },
|
||||
{ value: 'hdd-network', label: 'Network' },
|
||||
{ value: 'hdd-network-fill', label: 'Network fill' },
|
||||
{ value: 'speaker-fill', label: 'Speaker' },
|
||||
{ value: 'speaker', label: 'Speaker outline' },
|
||||
{ value: 'shield-fill', label: 'Shield' },
|
||||
{ value: 'shield', label: 'Shield outline' },
|
||||
{ value: 'collection-play-fill', label: 'Playlist' },
|
||||
{ value: 'collection-play', label: 'Playlist outline' },
|
||||
{ value: 'broadcast', label: 'Broadcast' },
|
||||
{ value: 'broadcast-pin', label: 'Broadcast pin' },
|
||||
{ value: 'plug-fill', label: 'Plug' },
|
||||
{ value: 'plug', label: 'Plug outline' },
|
||||
{ value: 'lightning-charge-fill', label: 'Urgent' },
|
||||
{ value: 'lightning-charge', label: 'Urgent outline' },
|
||||
{ value: 'car-front-fill', label: 'Car Front' },
|
||||
{ value: 'car-front', label: 'Car Front outline' },
|
||||
{ value: 'lamp-fill', label: 'Lamp' },
|
||||
{ value: 'lamp', label: 'Lamp outline' },
|
||||
{ value: 'envelope-fill', label: 'Message' },
|
||||
{ value: 'envelope', label: 'Message outline' },
|
||||
{ value: 'people-fill', label: 'Audience' },
|
||||
{ value: 'people', label: 'Audience outline' },
|
||||
{ value: 'browser-chrome', label: 'Browser Chrome' },
|
||||
{ value: 'browser-edge', label: 'Browser Edge' },
|
||||
{ value: 'browser-firefox', label: 'Browser Firefox' },
|
||||
{ value: 'browser-safari', label: 'Browser Safari' },
|
||||
{ value: 'cone', label: 'Cone' },
|
||||
{ value: 'cone-striped', label: 'Cone striped' },
|
||||
{ value: 'cup-straw', label: 'Cup straw' },
|
||||
{ value: 'fire', label: 'Fire' }
|
||||
];
|
||||
|
||||
const ANNOUNCEMENT_ICON_KEYS = ANNOUNCEMENT_ICON_OPTIONS.map(function (option) {
|
||||
return option.value;
|
||||
});
|
||||
|
||||
const ANNOUNCEMENT_ICON_LABELS = ANNOUNCEMENT_ICON_OPTIONS.reduce(function (labels, option) {
|
||||
labels[option.value] = option.label;
|
||||
return labels;
|
||||
}, Object.create(null));
|
||||
|
||||
const DEFAULT_ANNOUNCEMENT_ICON = 'megaphone-fill';
|
||||
|
||||
function normalizeAnnouncementIcon(value) {
|
||||
const normalized = String(value || '').trim().toLowerCase();
|
||||
return ANNOUNCEMENT_ICON_KEYS.includes(normalized) ? normalized : DEFAULT_ANNOUNCEMENT_ICON;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ANNOUNCEMENT_ICON_OPTIONS,
|
||||
ANNOUNCEMENT_ICON_KEYS,
|
||||
ANNOUNCEMENT_ICON_LABELS,
|
||||
DEFAULT_ANNOUNCEMENT_ICON,
|
||||
normalizeAnnouncementIcon
|
||||
};
|
||||
@@ -0,0 +1,189 @@
|
||||
// Announcement data access helpers.
|
||||
|
||||
const { fetchPagedRows } = require('./utils');
|
||||
const {
|
||||
ANNOUNCEMENT_ICON_KEYS,
|
||||
DEFAULT_ANNOUNCEMENT_ICON,
|
||||
normalizeAnnouncementIcon: normalizeAnnouncementIconFromConfig
|
||||
} = require('./announcement-icons');
|
||||
|
||||
const ANNOUNCEMENT_TYPES = ['lower-third', 'fullscreen', 'top-banner'];
|
||||
const ANNOUNCEMENT_COLORS = ['primary', 'secondary', 'success', 'info', 'warning', 'danger', 'dark', 'light'];
|
||||
|
||||
function normalizeAnnouncementType(value) {
|
||||
const normalized = String(value || '').trim().toLowerCase();
|
||||
if (normalized === 'center-card') {
|
||||
return 'fullscreen';
|
||||
}
|
||||
return ANNOUNCEMENT_TYPES.includes(normalized) ? normalized : 'lower-third';
|
||||
}
|
||||
|
||||
function normalizeAnnouncementColor(value) {
|
||||
const normalized = String(value || '').trim().toLowerCase();
|
||||
return ANNOUNCEMENT_COLORS.includes(normalized) ? normalized : 'primary';
|
||||
}
|
||||
|
||||
function normalizeAnnouncementMessage(value) {
|
||||
return String(value || '').replace(/\r\n/g, '\n').trim();
|
||||
}
|
||||
|
||||
function normalizeAnnouncementShortLabel(value) {
|
||||
return String(value || '').replace(/\r\n/g, ' ').trim();
|
||||
}
|
||||
|
||||
function normalizeAnnouncementDurationSeconds(value) {
|
||||
const seconds = Math.max(0, Math.round(Number(value) || 0));
|
||||
return seconds > 0 ? seconds : null;
|
||||
}
|
||||
|
||||
function normalizeAnnouncementScreenIds(value) {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(function (screenId) {
|
||||
return Number(screenId) || 0;
|
||||
}).filter(function (screenId) {
|
||||
return screenId > 0;
|
||||
});
|
||||
}
|
||||
|
||||
return String(value || '')
|
||||
.split(',')
|
||||
.map(function (screenId) {
|
||||
return Number(screenId) || 0;
|
||||
})
|
||||
.filter(function (screenId) {
|
||||
return screenId > 0;
|
||||
});
|
||||
}
|
||||
|
||||
function buildAnnouncementPayload(input) {
|
||||
const message = normalizeAnnouncementMessage(input && input.message);
|
||||
const shortLabel = normalizeAnnouncementShortLabel(input && input.short_label);
|
||||
const announcementType = normalizeAnnouncementType(input && input.announcement_type);
|
||||
const colorKey = normalizeAnnouncementColor(input && input.color_key);
|
||||
const iconKey = normalizeAnnouncementIconFromConfig(input && input.icon_key);
|
||||
const durationSeconds = normalizeAnnouncementDurationSeconds(input && input.duration_seconds);
|
||||
|
||||
return {
|
||||
message,
|
||||
shortLabel,
|
||||
announcementType,
|
||||
colorKey,
|
||||
iconKey,
|
||||
durationSeconds,
|
||||
expiresAt: durationSeconds ? new Date(Date.now() + (durationSeconds * 1000)) : null
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeAnnouncementRow(row) {
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: Number(row.id) || null,
|
||||
message: normalizeAnnouncementMessage(row.message),
|
||||
short_label: normalizeAnnouncementShortLabel(row.short_label),
|
||||
announcement_type: normalizeAnnouncementType(row.announcement_type),
|
||||
color_key: normalizeAnnouncementColor(row.color_key),
|
||||
icon_key: normalizeAnnouncementIconFromConfig(row.icon_key),
|
||||
duration_seconds: row.duration_seconds === null || row.duration_seconds === undefined
|
||||
? null
|
||||
: Math.max(0, Math.round(Number(row.duration_seconds) || 0)),
|
||||
expires_at: row.expires_at || null,
|
||||
screen_target_count: row.screen_target_count === null || row.screen_target_count === undefined
|
||||
? null
|
||||
: Math.max(0, Math.round(Number(row.screen_target_count) || 0)),
|
||||
total_screen_count: row.total_screen_count === null || row.total_screen_count === undefined
|
||||
? null
|
||||
: Math.max(0, Math.round(Number(row.total_screen_count) || 0)),
|
||||
screen_targets_label: String(row.screen_targets_label || '').trim(),
|
||||
screen_ids: normalizeAnnouncementScreenIds(row.screen_ids),
|
||||
created_at: row.created_at || null,
|
||||
modified_at: row.modified_at || null,
|
||||
created_by: row.created_by || null,
|
||||
modified_by: row.modified_by || null
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchAnnouncementById(pool, id) {
|
||||
const [rows] = await pool.query(`
|
||||
SELECT a.id, a.message, a.short_label, a.announcement_type, a.color_key, a.icon_key, a.duration_seconds, a.expires_at, a.created_at, a.modified_at, a.created_by, a.modified_by,
|
||||
GROUP_CONCAT(DISTINCT aas.screen_id ORDER BY aas.screen_id SEPARATOR ',') AS screen_ids
|
||||
FROM d_announcements a
|
||||
LEFT JOIN d_announcement_screens aas ON aas.announcement_id = a.id
|
||||
WHERE a.id = ?
|
||||
GROUP BY a.id
|
||||
`, [id]);
|
||||
return normalizeAnnouncementRow(rows[0] || null);
|
||||
}
|
||||
|
||||
async function fetchAnnouncementsPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
|
||||
const paged = await fetchPagedRows(pool, {
|
||||
selectSql: `SELECT a.id, a.message, a.short_label, a.announcement_type, a.color_key, a.icon_key, a.duration_seconds, a.expires_at, a.created_at, a.modified_at, a.created_by, a.modified_by,
|
||||
COUNT(DISTINCT aas.screen_id) AS screen_target_count,
|
||||
(SELECT COUNT(*) FROM d_screens) AS total_screen_count,
|
||||
COALESCE(
|
||||
GROUP_CONCAT(DISTINCT COALESCE(NULLIF(TRIM(s.name), ''), s.slug) ORDER BY COALESCE(NULLIF(TRIM(s.name), ''), s.slug) SEPARATOR ', '),
|
||||
''
|
||||
) AS screen_targets_label
|
||||
FROM d_announcements a
|
||||
LEFT JOIN d_announcement_screens aas ON aas.announcement_id = a.id
|
||||
LEFT JOIN d_screens s ON s.id = aas.screen_id
|
||||
GROUP BY a.id`,
|
||||
countSql: 'SELECT COUNT(*) AS count FROM d_announcements',
|
||||
searchColumns: ['message', 'short_label', 'announcement_type', 'color_key'],
|
||||
searchTerm: searchTerm,
|
||||
sortColumns: {
|
||||
description: 'short_label',
|
||||
message: 'message',
|
||||
type: 'announcement_type',
|
||||
color: 'color_key',
|
||||
status: 'expires_at',
|
||||
expires: 'expires_at',
|
||||
created: 'created_at',
|
||||
modified: 'modified_at'
|
||||
},
|
||||
sortKey: sortKey,
|
||||
sortDirection: sortDirection,
|
||||
page: page,
|
||||
pageSize: pageSize
|
||||
});
|
||||
|
||||
return Object.assign({ announcements: (paged.rows || []).map(normalizeAnnouncementRow) }, paged);
|
||||
}
|
||||
|
||||
async function fetchActiveAnnouncement(pool, screenSlug) {
|
||||
const [rows] = await pool.query(`
|
||||
SELECT a.id, a.message, a.short_label, a.announcement_type, a.color_key, a.icon_key, a.duration_seconds, a.expires_at, a.created_at, a.modified_at, a.created_by, a.modified_by,
|
||||
GROUP_CONCAT(DISTINCT aas.screen_id ORDER BY aas.screen_id SEPARATOR ',') AS screen_ids
|
||||
FROM d_announcements a
|
||||
LEFT JOIN d_announcement_screens aas ON aas.announcement_id = a.id
|
||||
WHERE (a.expires_at IS NULL OR a.expires_at > CURRENT_TIMESTAMP)
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM d_announcement_screens restriction
|
||||
JOIN d_screens screen ON screen.id = restriction.screen_id
|
||||
WHERE restriction.announcement_id = a.id
|
||||
AND screen.slug = ?
|
||||
)
|
||||
GROUP BY a.id
|
||||
ORDER BY a.modified_at DESC, a.created_at DESC, a.id DESC
|
||||
LIMIT 1
|
||||
`, [screenSlug]);
|
||||
return normalizeAnnouncementRow(rows[0] || null);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ANNOUNCEMENT_TYPES,
|
||||
ANNOUNCEMENT_COLORS,
|
||||
ANNOUNCEMENT_ICONS: ANNOUNCEMENT_ICON_KEYS,
|
||||
DEFAULT_ANNOUNCEMENT_ICON,
|
||||
normalizeAnnouncementType,
|
||||
normalizeAnnouncementColor,
|
||||
normalizeAnnouncementIcon: normalizeAnnouncementIconFromConfig,
|
||||
normalizeAnnouncementShortLabel,
|
||||
buildAnnouncementPayload,
|
||||
fetchAnnouncementById,
|
||||
fetchAnnouncementsPage,
|
||||
fetchActiveAnnouncement
|
||||
};
|
||||
@@ -0,0 +1,258 @@
|
||||
// API source data access, pagination, and remote fetch helpers.
|
||||
|
||||
const http = require('http');
|
||||
const https = require('https');
|
||||
const { fetchPagedRows } = require('./utils');
|
||||
|
||||
function normalizeUpdateIntervalUnit(value) {
|
||||
const unit = String(value || '').trim().toLowerCase();
|
||||
return unit === 'seconds' ? 'seconds' : 'minutes';
|
||||
}
|
||||
|
||||
function normalizeAuthMethod(value) {
|
||||
const method = String(value || '').trim().toLowerCase();
|
||||
return ['basic', 'bearer', 'api_key_header'].includes(method) ? method : 'none';
|
||||
}
|
||||
|
||||
function getItemsPath(source) {
|
||||
return String(source && (source.items_path || source.itemsPath) || '').trim();
|
||||
}
|
||||
|
||||
function buildAuthHeaders(source) {
|
||||
const method = normalizeAuthMethod(source && (source.auth_method || source.authMethod));
|
||||
const headers = {};
|
||||
|
||||
if (method === 'basic') {
|
||||
const username = String(source && (source.auth_username || source.authUsername) || '').trim();
|
||||
const password = String(source && (source.auth_password || source.authPassword) || '');
|
||||
if (username || password) {
|
||||
headers.Authorization = 'Basic ' + Buffer.from(username + ':' + password, 'utf8').toString('base64');
|
||||
}
|
||||
} else if (method === 'bearer') {
|
||||
const token = String(source && (source.auth_bearer_token || source.authBearerToken) || '').trim();
|
||||
if (token) {
|
||||
headers.Authorization = 'Bearer ' + token;
|
||||
}
|
||||
} else if (method === 'api_key_header') {
|
||||
const headerName = String(source && (source.auth_header_name || source.authHeaderName) || 'X-API-Key').trim() || 'X-API-Key';
|
||||
const headerValue = String(source && (source.auth_header_value || source.authHeaderValue) || '').trim();
|
||||
if (headerValue) {
|
||||
headers[headerName] = headerValue;
|
||||
}
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
async function fetchApiSourcesData(pool) {
|
||||
const [apiSources] = await pool.query(
|
||||
'SELECT id, name, api_url, auth_method, auth_username, auth_password, auth_bearer_token, auth_header_name, auth_header_value, items_path, update_interval_value, update_interval_unit, last_pulled_at, last_pull_error, last_response_status, last_response_content_type, last_response_json, created_at, modified_at, created_by, modified_by FROM i_api_sources ORDER BY modified_at DESC, id DESC'
|
||||
);
|
||||
|
||||
return { apiSources: apiSources };
|
||||
}
|
||||
|
||||
async function fetchApiSourcesPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
|
||||
const paged = await fetchPagedRows(pool, {
|
||||
selectSql: 'SELECT id, name, api_url, auth_method, auth_username, auth_password, auth_bearer_token, auth_header_name, auth_header_value, items_path, update_interval_value, update_interval_unit, last_pulled_at, last_pull_error, last_response_status, last_response_content_type, last_response_json, created_at, modified_at, created_by, modified_by FROM i_api_sources ORDER BY modified_at DESC, id DESC',
|
||||
countSql: 'SELECT COUNT(*) AS count FROM i_api_sources',
|
||||
searchColumns: ['name', 'api_url', 'last_pull_error'],
|
||||
searchTerm: searchTerm,
|
||||
sortColumns: {
|
||||
name: 'name',
|
||||
url: 'api_url',
|
||||
interval: ['update_interval_value', 'update_interval_unit'],
|
||||
last_pulled: 'last_pulled_at',
|
||||
last_response: 'last_response_status',
|
||||
created: 'created_at',
|
||||
modified: 'modified_at'
|
||||
},
|
||||
sortKey: sortKey,
|
||||
sortDirection: sortDirection,
|
||||
page: page,
|
||||
pageSize: pageSize
|
||||
});
|
||||
|
||||
return Object.assign({ apiSources: paged.rows }, paged);
|
||||
}
|
||||
|
||||
async function fetchApiSourceById(pool, id) {
|
||||
const [rows] = await pool.query(
|
||||
'SELECT id, name, api_url, auth_method, auth_username, auth_password, auth_bearer_token, auth_header_name, auth_header_value, items_path, update_interval_value, update_interval_unit, last_pulled_at, last_pull_error, last_response_status, last_response_content_type, last_response_json, created_at, modified_at, created_by, modified_by FROM i_api_sources WHERE id = ?',
|
||||
[id]
|
||||
);
|
||||
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
async function loadUrlText(urlValue, requestOptions) {
|
||||
const extraHeaders = requestOptions && requestOptions.headers ? requestOptions.headers : {};
|
||||
if (typeof fetch === 'function') {
|
||||
const response = await fetch(urlValue, {
|
||||
headers: Object.assign({
|
||||
Accept: 'application/json, text/plain;q=0.9, */*;q=0.8',
|
||||
'User-Agent': 'Pulse Signage API Reader'
|
||||
}, extraHeaders)
|
||||
});
|
||||
|
||||
return {
|
||||
statusCode: response.status,
|
||||
contentType: response.headers.get('content-type') || '',
|
||||
bodyText: await response.text(),
|
||||
ok: response.ok
|
||||
};
|
||||
}
|
||||
|
||||
return await new Promise(function (resolve, reject) {
|
||||
const url = new URL(urlValue);
|
||||
const transport = url.protocol === 'https:' ? https : http;
|
||||
const requestHeaders = Object.assign({
|
||||
Accept: 'application/json, text/plain;q=0.9, */*;q=0.8',
|
||||
'User-Agent': 'Pulse Signage API Reader'
|
||||
}, extraHeaders);
|
||||
const request = transport.get(url, Object.assign({}, requestOptions || {}, {
|
||||
headers: requestHeaders
|
||||
}), function (response) {
|
||||
response.setEncoding('utf8');
|
||||
let body = '';
|
||||
response.on('data', function (chunk) {
|
||||
body += chunk;
|
||||
});
|
||||
response.on('end', function () {
|
||||
resolve({
|
||||
statusCode: response.statusCode || 0,
|
||||
contentType: String(response.headers['content-type'] || ''),
|
||||
bodyText: body,
|
||||
ok: !response.statusCode || response.statusCode < 400
|
||||
});
|
||||
});
|
||||
response.on('error', reject);
|
||||
});
|
||||
|
||||
request.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchApiSourceResponse(apiSource) {
|
||||
const source = apiSource && typeof apiSource === 'object' ? apiSource : { api_url: apiSource };
|
||||
const response = await loadUrlText(source.api_url, {
|
||||
headers: buildAuthHeaders(source)
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Unable to load API response (${response.statusCode}).`);
|
||||
}
|
||||
|
||||
const text = String(response.bodyText || '').trim();
|
||||
if (!text) {
|
||||
throw new Error('API response did not return JSON.');
|
||||
}
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch (_error) {
|
||||
throw new Error('API response was not valid JSON.');
|
||||
}
|
||||
|
||||
return {
|
||||
responseJson: JSON.stringify(parsed, null, 2),
|
||||
responseStatus: response.statusCode,
|
||||
responseContentType: response.contentType
|
||||
};
|
||||
}
|
||||
|
||||
function buildApiSourcePayload(req, existingApiSource) {
|
||||
const fallback = existingApiSource || {};
|
||||
const body = req && req.body ? req.body : {};
|
||||
function readBodyValue(fieldName, fallbackValue) {
|
||||
if (Object.prototype.hasOwnProperty.call(body, fieldName)) {
|
||||
return body[fieldName];
|
||||
}
|
||||
return fallbackValue;
|
||||
}
|
||||
|
||||
const name = String(req.body.name || fallback.name || '').trim();
|
||||
const apiUrl = String(req.body.api_url || req.body.apiUrl || fallback.api_url || '').trim();
|
||||
const authMethod = normalizeAuthMethod(readBodyValue('auth_method', readBodyValue('authMethod', fallback.auth_method || 'none')));
|
||||
const authUsername = String(readBodyValue('auth_username', readBodyValue('authUsername', fallback.auth_username || '')) || '').trim();
|
||||
const authPassword = String(readBodyValue('auth_password', readBodyValue('authPassword', fallback.auth_password || '')) || '');
|
||||
const authBearerToken = String(readBodyValue('auth_bearer_token', readBodyValue('authBearerToken', fallback.auth_bearer_token || '')) || '').trim();
|
||||
const authHeaderName = String(readBodyValue('auth_header_name', readBodyValue('authHeaderName', fallback.auth_header_name || 'X-API-Key')) || 'X-API-Key').trim() || 'X-API-Key';
|
||||
const authHeaderValue = String(readBodyValue('auth_header_value', readBodyValue('authHeaderValue', fallback.auth_header_value || '')) || '').trim();
|
||||
const itemsPath = String(readBodyValue('items_path', readBodyValue('itemsPath', fallback.items_path || '')) || '').trim();
|
||||
const updateIntervalValue = Math.max(1, Number(req.body.update_interval_value || req.body.updateIntervalValue || fallback.update_interval_value || 60));
|
||||
const updateIntervalUnit = normalizeUpdateIntervalUnit(req.body.update_interval_unit || req.body.updateIntervalUnit || fallback.update_interval_unit || 'minutes');
|
||||
|
||||
if (!name) {
|
||||
const error = new Error('API source name is required.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!apiUrl) {
|
||||
const error = new Error('API source URL is required.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
let parsedUrl;
|
||||
try {
|
||||
parsedUrl = new URL(apiUrl);
|
||||
} catch (_error) {
|
||||
const error = new Error('Enter a valid API URL.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
|
||||
const error = new Error('API URL must start with http or https.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!Number.isFinite(updateIntervalValue)) {
|
||||
const error = new Error('Update interval must be a number.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (authMethod === 'basic' && !authUsername && !authPassword) {
|
||||
const error = new Error('Basic auth requires a username or password.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (authMethod === 'bearer' && !authBearerToken) {
|
||||
const error = new Error('Bearer auth requires a token.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (authMethod === 'api_key_header' && !authHeaderValue) {
|
||||
const error = new Error('API key auth requires a header value.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
name: name,
|
||||
apiUrl: parsedUrl.toString(),
|
||||
authMethod: authMethod,
|
||||
authUsername: authUsername,
|
||||
authPassword: authPassword,
|
||||
authBearerToken: authBearerToken,
|
||||
authHeaderName: authHeaderName,
|
||||
authHeaderValue: authHeaderValue,
|
||||
itemsPath: itemsPath,
|
||||
updateIntervalValue: Math.floor(updateIntervalValue),
|
||||
updateIntervalUnit: updateIntervalUnit
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
fetchApiSourcesData: fetchApiSourcesData,
|
||||
fetchApiSourcesPage: fetchApiSourcesPage,
|
||||
fetchApiSourceById: fetchApiSourceById,
|
||||
fetchApiSourceResponse: fetchApiSourceResponse,
|
||||
buildApiSourcePayload: buildApiSourcePayload
|
||||
};
|
||||
@@ -1,10 +1,35 @@
|
||||
// Canvas size data access and pagination helpers.
|
||||
|
||||
const { fetchPagedRows } = require('./utils');
|
||||
|
||||
async function fetchCanvasSizesData(pool) {
|
||||
const [canvasSizes] = await pool.query('SELECT id, name, width, height, created_at, modified_at, created_by, modified_by FROM canvas_sizes ORDER BY width ASC, height ASC, name ASC');
|
||||
const [canvasSizes] = await pool.query('SELECT id, name, width, height, created_at, modified_at, created_by, modified_by FROM c_canvas_sizes ORDER BY width ASC, height ASC, name ASC');
|
||||
return { canvasSizes };
|
||||
}
|
||||
|
||||
async function fetchCanvasSizesPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
|
||||
const paged = await fetchPagedRows(pool, {
|
||||
selectSql: 'SELECT id, name, width, height, created_at, modified_at, created_by, modified_by FROM c_canvas_sizes ORDER BY width ASC, height ASC, name ASC',
|
||||
countSql: 'SELECT COUNT(*) AS count FROM c_canvas_sizes',
|
||||
searchColumns: ['name', 'width', 'height'],
|
||||
searchTerm: searchTerm,
|
||||
sortColumns: {
|
||||
name: 'name',
|
||||
dimensions: ['width', 'height', 'name'],
|
||||
created: 'created_at',
|
||||
modified: 'modified_at'
|
||||
},
|
||||
sortKey: sortKey,
|
||||
sortDirection: sortDirection,
|
||||
page: page,
|
||||
pageSize: pageSize
|
||||
});
|
||||
|
||||
return Object.assign({ canvasSizes: paged.rows }, paged);
|
||||
}
|
||||
|
||||
async function fetchCanvasSizeById(pool, id) {
|
||||
const [rows] = await pool.query('SELECT id, name, width, height, created_at, modified_at, created_by, modified_by FROM canvas_sizes WHERE id = ?', [id]);
|
||||
const [rows] = await pool.query('SELECT id, name, width, height, created_at, modified_at, created_by, modified_by FROM c_canvas_sizes WHERE id = ?', [id]);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
@@ -28,6 +53,7 @@ function buildCanvasSizePayload(req, existingCanvasSize) {
|
||||
|
||||
module.exports = {
|
||||
fetchCanvasSizesData,
|
||||
fetchCanvasSizesPage,
|
||||
fetchCanvasSizeById,
|
||||
buildCanvasSizePayload
|
||||
};
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
// Client name reservation helpers for onboarding and live connection checks.
|
||||
|
||||
const crypto = require('crypto');
|
||||
|
||||
function normalizeClientName(value) {
|
||||
return String(value || '').trim();
|
||||
}
|
||||
|
||||
function normalizeDeviceId(value) {
|
||||
return String(value || '').trim().replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 128);
|
||||
}
|
||||
|
||||
function collectLiveConnections(liveConnections) {
|
||||
return Array.isArray(liveConnections) ? liveConnections : [];
|
||||
}
|
||||
|
||||
async function isClientNameAvailable(pool, clientName, excludeDeviceId, liveConnections) {
|
||||
const normalizedName = normalizeClientName(clientName);
|
||||
if (!normalizedName) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalizedDeviceId = normalizeDeviceId(excludeDeviceId);
|
||||
const live = collectLiveConnections(liveConnections);
|
||||
const lowerName = normalizedName.toLowerCase();
|
||||
|
||||
try {
|
||||
if (pool) {
|
||||
const [deviceRows] = await pool.query(
|
||||
`SELECT device_id
|
||||
FROM d_onboarding_devices
|
||||
WHERE client_name IS NOT NULL
|
||||
AND TRIM(client_name) <> ''
|
||||
AND LOWER(TRIM(client_name)) = LOWER(TRIM(?))
|
||||
AND device_id <> ?
|
||||
LIMIT 1`,
|
||||
[normalizedName, normalizedDeviceId]
|
||||
);
|
||||
|
||||
if (deviceRows.length) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
for (const connection of live) {
|
||||
const existingName = normalizeClientName(connection && connection.clientName ? connection.clientName : '');
|
||||
if (!existingName || existingName.toLowerCase() !== lowerName) {
|
||||
continue;
|
||||
}
|
||||
const existingDeviceId = normalizeDeviceId(connection && (connection.deviceId || connection.clientId) ? (connection.deviceId || connection.clientId) : '');
|
||||
if (normalizedDeviceId && existingDeviceId && existingDeviceId === normalizedDeviceId) {
|
||||
continue;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (_error) {
|
||||
for (const connection of live) {
|
||||
const existingName = normalizeClientName(connection && connection.clientName ? connection.clientName : '');
|
||||
if (!existingName || existingName.toLowerCase() !== lowerName) {
|
||||
continue;
|
||||
}
|
||||
const existingDeviceId = normalizeDeviceId(connection && (connection.deviceId || connection.clientId) ? (connection.deviceId || connection.clientId) : '');
|
||||
if (normalizedDeviceId && existingDeviceId && existingDeviceId === normalizedDeviceId) {
|
||||
continue;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function buildClientNameLockName(clientName) {
|
||||
return `ps_client_name_${crypto.createHash('sha1').update(String(clientName || '').trim().toLowerCase()).digest('hex')}`;
|
||||
}
|
||||
|
||||
async function withClientNameReservation(pool, clientName, handler) {
|
||||
if (!pool || typeof pool.getConnection !== 'function') {
|
||||
return handler();
|
||||
}
|
||||
|
||||
const normalizedName = normalizeClientName(clientName);
|
||||
if (!normalizedName) {
|
||||
return handler();
|
||||
}
|
||||
|
||||
const connection = await pool.getConnection();
|
||||
const lockName = buildClientNameLockName(normalizedName);
|
||||
let lockAcquired = false;
|
||||
|
||||
try {
|
||||
const [lockRows] = await connection.query('SELECT GET_LOCK(?, 5) AS lock_result', [lockName]);
|
||||
const lockResult = lockRows && lockRows[0] ? Number(lockRows[0].lock_result) : 0;
|
||||
if (lockResult !== 1) {
|
||||
const error = new Error('Client name is busy. Please try again.');
|
||||
error.statusCode = 409;
|
||||
throw error;
|
||||
}
|
||||
|
||||
lockAcquired = true;
|
||||
return await handler();
|
||||
} finally {
|
||||
if (lockAcquired) {
|
||||
try {
|
||||
await connection.query('SELECT RELEASE_LOCK(?)', [lockName]);
|
||||
} catch (_error) {}
|
||||
}
|
||||
connection.release();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
normalizeClientName: normalizeClientName,
|
||||
normalizeDeviceId: normalizeDeviceId,
|
||||
collectLiveConnections: collectLiveConnections,
|
||||
isClientNameAvailable: isClientNameAvailable,
|
||||
withClientNameReservation: withClientNameReservation
|
||||
};
|
||||
+51
-4
@@ -1,19 +1,65 @@
|
||||
const { fetchAdminData } = require('./admin');
|
||||
// Aggregated exports for the shared data access layer.
|
||||
|
||||
const { fetchAdminData, fetchPlaylistsPage, fetchSlidesPage, fetchTemplatesPage, fetchCanvasSizesPage, fetchScreensPage } = require('./admin');
|
||||
const { ANNOUNCEMENT_TYPES, ANNOUNCEMENT_COLORS, ANNOUNCEMENT_ICONS, DEFAULT_ANNOUNCEMENT_ICON, normalizeAnnouncementType, normalizeAnnouncementColor, normalizeAnnouncementIcon, fetchAnnouncementsPage, fetchAnnouncementById, fetchActiveAnnouncement, buildAnnouncementPayload } = require('./announcements');
|
||||
const { ANNOUNCEMENT_ICON_OPTIONS, ANNOUNCEMENT_ICON_LABELS } = require('./announcement-icons');
|
||||
const { fetchPlaylistById } = require('./playlists');
|
||||
const { slugify, uniqueScreenSlug, fetchScreenById, fetchScreenEditData } = require('./screens');
|
||||
const { normalizeDisplayMode, fetchTimetablesData, fetchTimetableGroupsPage, fetchTimetableGroupById, fetchTimetableEntriesByGroupId, buildTimetableGroupPayload } = require('./schedules');
|
||||
const { fetchApiSourcesData, fetchApiSourcesPage, fetchApiSourceById, fetchApiSourceResponse, buildApiSourcePayload } = require('./api-sources');
|
||||
const { fetchRssFeedsData, fetchRssFeedsPage, fetchRssFeedById, fetchRssFeedItemsByFeedId, normalizeRssFeedItem, buildRssFeedPayload, fetchRssFeedItems, replaceRssFeedItems } = require('./rss-feeds');
|
||||
const { slugify, uniqueScreenSlug, fetchScreenById, fetchScreenEditData, fetchScreenPlayerUrls, fetchScreenPlayerRecord } = require('./screens');
|
||||
const { fetchTemplateById, fetchTemplatesData, extractTemplateRegions, buildTemplatePayload } = require('./templates');
|
||||
const { fetchCanvasSizesData, fetchCanvasSizeById, buildCanvasSizePayload } = require('./canvas-sizes');
|
||||
const { fetchSlideById, buildSlidePayload } = require('./slides');
|
||||
const { parseJsonSafe } = require('./utils');
|
||||
const { parseJsonSafe, fetchDuplicateName } = require('./utils');
|
||||
|
||||
module.exports = {
|
||||
slugify,
|
||||
uniqueScreenSlug,
|
||||
parseJsonSafe,
|
||||
fetchAdminData,
|
||||
fetchPlaylistsPage,
|
||||
fetchSlidesPage,
|
||||
fetchTemplatesPage,
|
||||
fetchCanvasSizesPage,
|
||||
fetchScreensPage,
|
||||
ANNOUNCEMENT_TYPES,
|
||||
ANNOUNCEMENT_COLORS,
|
||||
ANNOUNCEMENT_ICONS,
|
||||
ANNOUNCEMENT_ICON_OPTIONS,
|
||||
ANNOUNCEMENT_ICON_LABELS,
|
||||
DEFAULT_ANNOUNCEMENT_ICON,
|
||||
normalizeAnnouncementType,
|
||||
normalizeAnnouncementColor,
|
||||
normalizeAnnouncementIcon,
|
||||
fetchAnnouncementsPage,
|
||||
fetchAnnouncementById,
|
||||
fetchActiveAnnouncement,
|
||||
buildAnnouncementPayload,
|
||||
fetchPlaylistById,
|
||||
normalizeDisplayMode,
|
||||
fetchTimetablesData,
|
||||
fetchTimetableGroupsPage,
|
||||
fetchTimetableGroupById,
|
||||
fetchTimetableEntriesByGroupId,
|
||||
buildTimetableGroupPayload,
|
||||
fetchApiSourcesData,
|
||||
fetchApiSourcesPage,
|
||||
fetchApiSourceById,
|
||||
fetchApiSourceResponse,
|
||||
buildApiSourcePayload,
|
||||
fetchRssFeedsData,
|
||||
fetchRssFeedsPage,
|
||||
fetchRssFeedById,
|
||||
fetchRssFeedItemsByFeedId,
|
||||
normalizeRssFeedItem,
|
||||
buildRssFeedPayload,
|
||||
fetchRssFeedItems,
|
||||
replaceRssFeedItems,
|
||||
fetchScreenById,
|
||||
fetchScreenEditData,
|
||||
fetchScreenPlayerUrls,
|
||||
fetchScreenPlayerRecord,
|
||||
fetchTemplateById,
|
||||
fetchSlideById,
|
||||
fetchTemplatesData,
|
||||
@@ -22,5 +68,6 @@ module.exports = {
|
||||
buildCanvasSizePayload,
|
||||
buildSlidePayload,
|
||||
extractTemplateRegions,
|
||||
buildTemplatePayload
|
||||
buildTemplatePayload,
|
||||
fetchDuplicateName
|
||||
};
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// Playlist data access helpers.
|
||||
|
||||
async function fetchPlaylistById(pool, id) {
|
||||
const [rows] = await pool.query('SELECT id, name, fade_between_slides, created_at, modified_at, created_by, modified_by FROM playlists WHERE id = ?', [id]);
|
||||
const [rows] = await pool.query('SELECT id, name, fade_between_slides, skip_unavailable_rtmp, canvas_id, created_at, modified_at, created_by, modified_by FROM c_playlists WHERE id = ?', [id]);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
const QRCode = require('qrcode');
|
||||
|
||||
async function createQrCodeSvg(value) {
|
||||
const text = String(value === undefined || value === null ? '' : value).trim();
|
||||
if (!text) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return QRCode.toString(text, {
|
||||
type: 'svg',
|
||||
margin: 1,
|
||||
errorCorrectionLevel: 'M'
|
||||
});
|
||||
}
|
||||
|
||||
async function buildQrCodeContent(value) {
|
||||
const text = String(value === undefined || value === null ? '' : value).trim();
|
||||
const content = {
|
||||
type: 'qr-code',
|
||||
value: text
|
||||
};
|
||||
|
||||
if (text) {
|
||||
const svg = await createQrCodeSvg(text);
|
||||
if (svg) {
|
||||
content.qr_svg = svg;
|
||||
}
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createQrCodeSvg,
|
||||
buildQrCodeContent
|
||||
};
|
||||
@@ -0,0 +1,321 @@
|
||||
// RSS feed data access, pagination, and item normalization helpers.
|
||||
|
||||
const http = require('http');
|
||||
const https = require('https');
|
||||
const { fetchPagedRows } = require('./utils');
|
||||
|
||||
function normalizeUpdateIntervalUnit(value) {
|
||||
const unit = String(value || '').trim().toLowerCase();
|
||||
return unit === 'seconds' ? 'seconds' : 'minutes';
|
||||
}
|
||||
|
||||
async function fetchRssFeedsData(pool) {
|
||||
const [rssFeeds] = await pool.query(
|
||||
'SELECT id, name, feed_url, update_interval_value, update_interval_unit, item_limit, created_at, modified_at, created_by, modified_by FROM i_rss_feeds ORDER BY modified_at DESC, id DESC'
|
||||
);
|
||||
|
||||
return { rssFeeds: rssFeeds };
|
||||
}
|
||||
|
||||
async function fetchRssFeedsPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
|
||||
const paged = await fetchPagedRows(pool, {
|
||||
selectSql: 'SELECT id, name, feed_url, update_interval_value, update_interval_unit, item_limit, created_at, modified_at, created_by, modified_by FROM i_rss_feeds ORDER BY modified_at DESC, id DESC',
|
||||
countSql: 'SELECT COUNT(*) AS count FROM i_rss_feeds',
|
||||
searchColumns: ['name', 'feed_url'],
|
||||
searchTerm: searchTerm,
|
||||
sortColumns: {
|
||||
name: 'name',
|
||||
url: 'feed_url',
|
||||
interval: ['update_interval_value', 'update_interval_unit'],
|
||||
items: 'item_limit',
|
||||
created: 'created_at',
|
||||
modified: 'modified_at'
|
||||
},
|
||||
sortKey: sortKey,
|
||||
sortDirection: sortDirection,
|
||||
page: page,
|
||||
pageSize: pageSize
|
||||
});
|
||||
|
||||
return Object.assign({ rssFeeds: paged.rows }, paged);
|
||||
}
|
||||
|
||||
async function fetchRssFeedById(pool, id) {
|
||||
const [rows] = await pool.query(
|
||||
'SELECT id, name, feed_url, update_interval_value, update_interval_unit, item_limit, created_at, modified_at, created_by, modified_by FROM i_rss_feeds WHERE id = ?',
|
||||
[id]
|
||||
);
|
||||
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
async function fetchRssFeedItemsByFeedId(pool, rssFeedId) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT id, rss_feed_id, position, item_json, created_at, modified_at
|
||||
FROM i_rss_feed_items
|
||||
WHERE rss_feed_id = ?
|
||||
ORDER BY position ASC, id ASC`,
|
||||
[rssFeedId]
|
||||
);
|
||||
|
||||
return rows.map(function (row) {
|
||||
return normalizeRssFeedItem(row);
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeRssFeedItem(row) {
|
||||
let itemJson = null;
|
||||
if (row && row.item_json) {
|
||||
try {
|
||||
itemJson = JSON.parse(row.item_json);
|
||||
} catch (_error) {
|
||||
itemJson = null;
|
||||
}
|
||||
}
|
||||
|
||||
return Object.assign({}, row || {}, itemJson || {}, {
|
||||
itemJson: itemJson
|
||||
});
|
||||
}
|
||||
|
||||
async function loadUrlText(urlValue) {
|
||||
if (typeof fetch === 'function') {
|
||||
const response = await fetch(urlValue, {
|
||||
headers: {
|
||||
Accept: 'application/rss+xml, application/xml, text/xml;q=0.9, */*;q=0.8',
|
||||
'User-Agent': 'Pulse Signage RSS Reader'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Unable to load RSS feed (${response.status}).`);
|
||||
}
|
||||
|
||||
return await response.text();
|
||||
}
|
||||
|
||||
return await new Promise(function (resolve, reject) {
|
||||
const url = new URL(urlValue);
|
||||
const transport = url.protocol === 'https:' ? https : http;
|
||||
const request = transport.get(url, {
|
||||
headers: {
|
||||
Accept: 'application/rss+xml, application/xml, text/xml;q=0.9, */*;q=0.8',
|
||||
'User-Agent': 'Pulse Signage RSS Reader'
|
||||
}
|
||||
}, function (response) {
|
||||
if (response.statusCode && response.statusCode >= 400) {
|
||||
reject(new Error(`Unable to load RSS feed (${response.statusCode}).`));
|
||||
response.resume();
|
||||
return;
|
||||
}
|
||||
|
||||
response.setEncoding('utf8');
|
||||
let body = '';
|
||||
response.on('data', function (chunk) {
|
||||
body += chunk;
|
||||
});
|
||||
response.on('end', function () {
|
||||
resolve(body);
|
||||
});
|
||||
});
|
||||
|
||||
request.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
function decodeXmlEntities(value) {
|
||||
return String(value || '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function stripCdata(value) {
|
||||
return String(value || '').replace(/^<!\[CDATA\[|\]\]>$/g, '');
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return String(value || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function extractXmlTag(content, tagName) {
|
||||
const safeTagName = escapeRegExp(tagName);
|
||||
const pattern = new RegExp(`<${safeTagName}(?:\\s[^>]*)?>([\\s\\S]*?)<\/${safeTagName}>`, 'i');
|
||||
const match = pattern.exec(content || '');
|
||||
if (!match) {
|
||||
return '';
|
||||
}
|
||||
return decodeXmlEntities(stripCdata(match[1]).trim());
|
||||
}
|
||||
|
||||
function extractAtomLink(content) {
|
||||
const linkMatch = /<link\b[^>]*rel=["']alternate["'][^>]*href=["']([^"']+)["'][^>]*\/>/i.exec(content || '')
|
||||
|| /<link\b[^>]*href=["']([^"']+)["'][^>]*rel=["']alternate["'][^>]*\/>/i.exec(content || '')
|
||||
|| /<link\b[^>]*href=["']([^"']+)["'][^>]*>/i.exec(content || '');
|
||||
if (!linkMatch) {
|
||||
return '';
|
||||
}
|
||||
return decodeXmlEntities(String(linkMatch[1] || '').trim());
|
||||
}
|
||||
|
||||
function parseRssItems(xmlText, itemLimit) {
|
||||
const normalizedXml = String(xmlText || '');
|
||||
const itemMatches = Array.from(normalizedXml.matchAll(/<item\b[\s\S]*?<\/item>|<entry\b[\s\S]*?<\/entry>/gi)).map(function (match) {
|
||||
return String(match[0] || '');
|
||||
});
|
||||
|
||||
if (!itemMatches.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return itemMatches.slice(0, Math.max(1, Number(itemLimit) || 1)).map(function (itemXml, index) {
|
||||
const title = extractXmlTag(itemXml, 'title') || `Item ${index + 1}`;
|
||||
const link = extractXmlTag(itemXml, 'link') || extractAtomLink(itemXml);
|
||||
const pubDate = extractXmlTag(itemXml, 'pubDate') || extractXmlTag(itemXml, 'updated') || extractXmlTag(itemXml, 'dc:date');
|
||||
const description = extractXmlTag(itemXml, 'description') || extractXmlTag(itemXml, 'summary') || extractXmlTag(itemXml, 'content:encoded');
|
||||
const author = extractXmlTag(itemXml, 'author') || extractXmlTag(itemXml, 'dc:creator');
|
||||
const comments = extractXmlTag(itemXml, 'comments');
|
||||
const guidMatch = /<guid\b([^>]*)>([\s\S]*?)<\/guid>/i.exec(itemXml || '');
|
||||
const guid = guidMatch ? decodeXmlEntities(stripCdata(String(guidMatch[2] || '').trim())) : '';
|
||||
const guidIsPermaLinkMatch = guidMatch && /\bisPermaLink\s*=\s*(["']?)(true|false)\1/i.exec(String(guidMatch[1] || ''));
|
||||
const categories = Array.from(String(itemXml || '').matchAll(/<category\b([^>]*)>([\s\S]*?)<\/category>/gi)).map(function (match) {
|
||||
const categoryAttributes = String(match[1] || '');
|
||||
const categoryDomainMatch = /\bdomain\s*=\s*(["'])([^"']+)\1/i.exec(categoryAttributes);
|
||||
return {
|
||||
value: decodeXmlEntities(stripCdata(String(match[2] || '').trim())),
|
||||
domain: categoryDomainMatch ? decodeXmlEntities(String(categoryDomainMatch[2] || '').trim()) : ''
|
||||
};
|
||||
}).filter(function (category) {
|
||||
return Boolean(category.value);
|
||||
});
|
||||
const enclosureMatch = /<enclosure\b([^>]*)\/?>(?:\s*)/i.exec(itemXml || '');
|
||||
const enclosureUrlMatch = enclosureMatch ? /\burl\s*=\s*(["'])([^"']+)\1/i.exec(String(enclosureMatch[1] || '')) : null;
|
||||
const enclosureLengthMatch = enclosureMatch ? /\blength\s*=\s*(["'])([^"']+)\1/i.exec(String(enclosureMatch[1] || '')) : null;
|
||||
const enclosureTypeMatch = enclosureMatch ? /\btype\s*=\s*(["'])([^"']+)\1/i.exec(String(enclosureMatch[1] || '')) : null;
|
||||
const sourceMatch = /<source\b([^>]*)>([\s\S]*?)<\/source>/i.exec(itemXml || '');
|
||||
const sourceUrlMatch = sourceMatch ? /\burl\s*=\s*(["'])([^"']+)\1/i.exec(String(sourceMatch[1] || '')) : null;
|
||||
|
||||
const parsedItem = {
|
||||
title: title,
|
||||
link: link,
|
||||
description: description,
|
||||
author: author,
|
||||
comments: comments,
|
||||
guid: guid,
|
||||
guidIsPermaLink: guidIsPermaLinkMatch ? String(guidIsPermaLinkMatch[2] || '').toLowerCase() === 'true' : null,
|
||||
pubDate: pubDate,
|
||||
categories: categories,
|
||||
enclosure: enclosureMatch ? {
|
||||
url: enclosureUrlMatch ? decodeXmlEntities(String(enclosureUrlMatch[2] || '').trim()) : '',
|
||||
length: enclosureLengthMatch ? Number(enclosureLengthMatch[2]) || null : null,
|
||||
type: enclosureTypeMatch ? decodeXmlEntities(String(enclosureTypeMatch[2] || '').trim()) : ''
|
||||
} : null,
|
||||
source: sourceMatch ? {
|
||||
title: decodeXmlEntities(stripCdata(String(sourceMatch[2] || '').trim())),
|
||||
url: sourceUrlMatch ? decodeXmlEntities(String(sourceUrlMatch[2] || '').trim()) : ''
|
||||
} : null,
|
||||
rawXml: itemXml
|
||||
};
|
||||
|
||||
return parsedItem;
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchRssFeedItems(feedUrl, itemLimit) {
|
||||
const xmlText = await loadUrlText(feedUrl);
|
||||
return parseRssItems(xmlText, itemLimit);
|
||||
}
|
||||
|
||||
async function replaceRssFeedItems(connection, rssFeedId, items) {
|
||||
const normalizedItems = Array.isArray(items) ? items : [];
|
||||
await connection.query('DELETE FROM i_rss_feed_items WHERE rss_feed_id = ?', [rssFeedId]);
|
||||
|
||||
if (!normalizedItems.length) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const insertValues = normalizedItems.map(function (item, index) {
|
||||
return [
|
||||
rssFeedId,
|
||||
index + 1,
|
||||
JSON.stringify(item || {})
|
||||
];
|
||||
});
|
||||
|
||||
await connection.query(
|
||||
'INSERT INTO i_rss_feed_items (rss_feed_id, position, item_json) VALUES ?',
|
||||
[insertValues]
|
||||
);
|
||||
|
||||
return insertValues.length;
|
||||
}
|
||||
|
||||
function buildRssFeedPayload(req, existingRssFeed) {
|
||||
const fallback = existingRssFeed || {};
|
||||
const name = String(req.body.name || fallback.name || '').trim();
|
||||
const feedUrl = String(req.body.feed_url || req.body.feedUrl || fallback.feed_url || '').trim();
|
||||
const updateIntervalValue = Math.max(1, Number(req.body.update_interval_value || req.body.updateIntervalValue || fallback.update_interval_value || 60));
|
||||
const updateIntervalUnit = normalizeUpdateIntervalUnit(req.body.update_interval_unit || req.body.updateIntervalUnit || fallback.update_interval_unit || 'minutes');
|
||||
const itemLimit = Math.max(1, Number(req.body.item_limit || req.body.itemLimit || fallback.item_limit || 1));
|
||||
|
||||
if (!name) {
|
||||
const error = new Error('RSS feed name is required.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!feedUrl) {
|
||||
const error = new Error('RSS feed URL is required.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
let parsedUrl;
|
||||
try {
|
||||
parsedUrl = new URL(feedUrl);
|
||||
} catch (_error) {
|
||||
const error = new Error('Enter a valid RSS feed URL.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
|
||||
const error = new Error('RSS feed URL must start with http or https.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!Number.isFinite(updateIntervalValue)) {
|
||||
const error = new Error('Update interval must be a number.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!Number.isFinite(itemLimit)) {
|
||||
const error = new Error('Item count must be a number.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
name: name,
|
||||
feedUrl: parsedUrl.toString(),
|
||||
updateIntervalValue: Math.floor(updateIntervalValue),
|
||||
updateIntervalUnit: updateIntervalUnit,
|
||||
itemLimit: Math.floor(itemLimit)
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
fetchRssFeedsData,
|
||||
fetchRssFeedsPage,
|
||||
fetchRssFeedById,
|
||||
fetchRssFeedItemsByFeedId,
|
||||
normalizeRssFeedItem,
|
||||
buildRssFeedPayload,
|
||||
fetchRssFeedItems,
|
||||
replaceRssFeedItems
|
||||
};
|
||||
@@ -0,0 +1,120 @@
|
||||
// Timetable group and entry data access helpers.
|
||||
|
||||
const { fetchPagedRows } = require('./utils');
|
||||
|
||||
function normalizeDisplayMode(value) {
|
||||
const mode = String(value || 'upcoming').trim().toLowerCase();
|
||||
if (mode === 'current' || mode === 'both') {
|
||||
return mode;
|
||||
}
|
||||
return 'upcoming';
|
||||
}
|
||||
|
||||
async function fetchTimetablesData(pool) {
|
||||
const [timetableGroups] = await pool.query(`
|
||||
SELECT g.id, g.name, g.short_description, g.created_at, g.modified_at, g.created_by, g.modified_by,
|
||||
(SELECT COUNT(*) FROM i_schedule_entries e WHERE e.schedule_group_id = g.id) AS entry_count,
|
||||
(SELECT MIN(e.start_datetime) FROM i_schedule_entries e WHERE e.schedule_group_id = g.id AND e.start_datetime IS NOT NULL) AS next_start_datetime
|
||||
FROM i_schedule_groups g
|
||||
ORDER BY g.modified_at DESC, g.id DESC
|
||||
`);
|
||||
const [timetableEntries] = await pool.query(`
|
||||
SELECT id, schedule_group_id, title, short_description, start_datetime, end_datetime, created_at, modified_at, created_by, modified_by
|
||||
FROM i_schedule_entries
|
||||
ORDER BY schedule_group_id ASC, start_datetime ASC, id ASC
|
||||
`);
|
||||
|
||||
const entriesByGroupId = new Map();
|
||||
timetableEntries.forEach(function (entry) {
|
||||
const groupId = Number(entry.schedule_group_id);
|
||||
if (!entriesByGroupId.has(groupId)) {
|
||||
entriesByGroupId.set(groupId, []);
|
||||
}
|
||||
entriesByGroupId.get(groupId).push(entry);
|
||||
});
|
||||
|
||||
const groups = timetableGroups.map(function (group) {
|
||||
return Object.assign({}, group, {
|
||||
entries: entriesByGroupId.get(Number(group.id)) || []
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
timetableGroups: groups,
|
||||
timetableEntries: timetableEntries
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchTimetableGroupsPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
|
||||
const paged = await fetchPagedRows(pool, {
|
||||
selectSql: `SELECT g.id, g.name, g.short_description, g.created_at, g.modified_at, g.created_by, g.modified_by,
|
||||
(SELECT COUNT(*) FROM i_schedule_entries e WHERE e.schedule_group_id = g.id) AS entry_count,
|
||||
(SELECT MIN(e.start_datetime) FROM i_schedule_entries e WHERE e.schedule_group_id = g.id AND e.start_datetime IS NOT NULL) AS next_start_datetime
|
||||
FROM i_schedule_groups g
|
||||
ORDER BY g.modified_at DESC, g.id DESC`,
|
||||
countSql: 'SELECT COUNT(*) AS count FROM i_schedule_groups',
|
||||
searchColumns: ['g.name', 'g.short_description'],
|
||||
searchTerm: searchTerm,
|
||||
sortColumns: {
|
||||
name: 'g.name',
|
||||
description: 'g.short_description',
|
||||
entries: 'entry_count',
|
||||
next_start: 'next_start_datetime',
|
||||
created: 'g.created_at',
|
||||
modified: 'g.modified_at'
|
||||
},
|
||||
sortKey: sortKey,
|
||||
sortDirection: sortDirection,
|
||||
page: page,
|
||||
pageSize: pageSize
|
||||
});
|
||||
|
||||
return Object.assign({ timetableGroups: paged.rows }, paged);
|
||||
}
|
||||
|
||||
async function fetchTimetableGroupById(pool, id) {
|
||||
const [rows] = await pool.query(
|
||||
'SELECT id, name, short_description, created_at, modified_at, created_by, modified_by FROM i_schedule_groups WHERE id = ?',
|
||||
[id]
|
||||
);
|
||||
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
async function fetchTimetableEntriesByGroupId(pool, timetableGroupId) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT id, schedule_group_id, title, short_description, start_datetime, end_datetime, created_at, modified_at, created_by, modified_by
|
||||
FROM i_schedule_entries
|
||||
WHERE schedule_group_id = ?
|
||||
ORDER BY start_datetime ASC, id ASC`,
|
||||
[timetableGroupId]
|
||||
);
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
function buildTimetableGroupPayload(req, existingTimetableGroup) {
|
||||
const fallback = existingTimetableGroup || {};
|
||||
const name = String(req.body.name || fallback.name || '').trim();
|
||||
const shortDescription = String(req.body.short_description || req.body.shortDescription || fallback.short_description || '').trim();
|
||||
|
||||
if (!name) {
|
||||
const error = new Error('Timetable group name is required.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
name: name,
|
||||
shortDescription: shortDescription
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
normalizeDisplayMode,
|
||||
fetchTimetablesData,
|
||||
fetchTimetableGroupsPage,
|
||||
fetchTimetableGroupById,
|
||||
fetchTimetableEntriesByGroupId,
|
||||
buildTimetableGroupPayload
|
||||
};
|
||||
+64
-4
@@ -1,3 +1,5 @@
|
||||
// Screen slug helpers and screen lookup utilities.
|
||||
|
||||
function slugify(value) {
|
||||
return String(value || '')
|
||||
.toLowerCase()
|
||||
@@ -7,13 +9,67 @@ function slugify(value) {
|
||||
.replace(/-{2,}/g, '-');
|
||||
}
|
||||
|
||||
function normalizePlayerBaseUrl(value) {
|
||||
return String(value || '').trim().replace(/\/$/, '');
|
||||
}
|
||||
|
||||
function buildScreenPlayerUrl(screen, fallbackBaseUrl) {
|
||||
const slug = String(screen && screen.slug || '').trim();
|
||||
if (!slug) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const baseUrl = normalizePlayerBaseUrl(fallbackBaseUrl);
|
||||
if (!baseUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `${baseUrl}/screen/${encodeURIComponent(slug)}`;
|
||||
}
|
||||
|
||||
async function fetchScreenPlayerUrls(pool) {
|
||||
const [rows] = await pool.query(`
|
||||
SELECT s.slug, p.public_base_url
|
||||
FROM d_screens s
|
||||
LEFT JOIN d_players p ON p.device_id = s.player_id
|
||||
WHERE p.public_base_url IS NOT NULL
|
||||
AND TRIM(p.public_base_url) <> ''
|
||||
ORDER BY p.modified_at DESC, s.slug ASC
|
||||
`);
|
||||
|
||||
const playerUrls = {};
|
||||
rows.forEach(function (row) {
|
||||
const slug = String(row && row.slug || '').trim();
|
||||
const publicBaseUrl = normalizePlayerBaseUrl(row && row.public_base_url);
|
||||
const playerUrl = buildScreenPlayerUrl({ slug: slug }, publicBaseUrl);
|
||||
if (slug && playerUrl && !playerUrls[slug]) {
|
||||
playerUrls[slug] = playerUrl;
|
||||
}
|
||||
});
|
||||
|
||||
return playerUrls;
|
||||
}
|
||||
|
||||
async function fetchScreenPlayerRecord(pool, slug) {
|
||||
const [rows] = await pool.query(`
|
||||
SELECT p.device_id, p.public_base_url, p.internal_base_url, p.last_seen_at
|
||||
FROM d_screens s
|
||||
JOIN d_players p ON p.device_id = s.player_id
|
||||
WHERE s.slug = ?
|
||||
ORDER BY p.modified_at DESC, p.device_id ASC
|
||||
LIMIT 1
|
||||
`, [slug]);
|
||||
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
async function uniqueScreenSlug(pool, baseSlug, excludeId) {
|
||||
const start = baseSlug || `screen-${Date.now()}`;
|
||||
let candidate = start;
|
||||
let counter = 2;
|
||||
while (true) {
|
||||
const params = [candidate];
|
||||
let sql = 'SELECT id FROM screens WHERE slug = ?';
|
||||
let sql = 'SELECT id FROM d_screens WHERE slug = ?';
|
||||
if (excludeId !== undefined && excludeId !== null) {
|
||||
sql += ' AND id <> ?';
|
||||
params.push(excludeId);
|
||||
@@ -30,20 +86,24 @@ async function uniqueScreenSlug(pool, baseSlug, excludeId) {
|
||||
async function fetchScreenById(pool, id) {
|
||||
const [rows] = await pool.query(`
|
||||
SELECT s.id, s.name, s.slug, s.playlist_id, s.created_at, s.modified_at, s.created_by, s.modified_by, p.name AS playlist_name
|
||||
FROM screens s
|
||||
LEFT JOIN playlists p ON p.id = s.playlist_id
|
||||
FROM d_screens s
|
||||
LEFT JOIN c_playlists p ON p.id = s.playlist_id
|
||||
WHERE s.id = ?
|
||||
`, [id]);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
async function fetchScreenEditData(pool) {
|
||||
const [playlists] = await pool.query('SELECT id, name, fade_between_slides, created_at, modified_at, created_by, modified_by FROM playlists ORDER BY id DESC');
|
||||
const [playlists] = await pool.query('SELECT id, name, fade_between_slides, skip_unavailable_rtmp, created_at, modified_at, created_by, modified_by FROM c_playlists ORDER BY id DESC');
|
||||
return { playlists };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
slugify,
|
||||
normalizePlayerBaseUrl,
|
||||
buildScreenPlayerUrl,
|
||||
fetchScreenPlayerUrls,
|
||||
fetchScreenPlayerRecord,
|
||||
uniqueScreenSlug,
|
||||
fetchScreenById,
|
||||
fetchScreenEditData
|
||||
|
||||
+172
-16
@@ -1,7 +1,11 @@
|
||||
// Slide data access helpers, including rich-text normalization and payload building.
|
||||
|
||||
const { fetchTemplateById } = require('./templates');
|
||||
const { parseJsonSafe } = require('./utils');
|
||||
const { buildQrCodeContent } = require('./qr-code');
|
||||
|
||||
const ALLOWED_RICH_TEXT_TAGS = ['b', 'strong', 'i', 'em', 'u', 'br', 'p', 'div', 'ul', 'ol', 'li'];
|
||||
const DEFAULT_FONT_SIZE = 32;
|
||||
|
||||
function sanitizeRichText(html) {
|
||||
let output = String(html || '');
|
||||
@@ -24,13 +28,32 @@ function sanitizeRichText(html) {
|
||||
});
|
||||
}
|
||||
|
||||
function normalizePlainText(value) {
|
||||
return String(value === undefined || value === null ? '' : value)
|
||||
.replace(/<\s*br\s*\/?\s*>/gi, '\n')
|
||||
.replace(/<[^>]*>/g, '')
|
||||
.replace(/ /gi, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function stripEditorOnlyMarkup(value) {
|
||||
return String(value || '')
|
||||
.replace(/<pre[^>]*class="[^"]*api-region-sample-preview[^"]*"[^>]*>[\s\S]*?<\/pre>/gi, '')
|
||||
.replace(/<details[^>]*class="[^"]*api-region-sample-accordion[^"]*"[^>]*>[\s\S]*?<\/details>/gi, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function normalizeEditorMarkup(value) {
|
||||
return String(value === undefined || value === null ? '' : value).trim();
|
||||
}
|
||||
|
||||
async function fetchSlideById(pool, id) {
|
||||
const [slides] = await pool.query(`
|
||||
SELECT s.id, s.title, s.body, s.template_id, s.content_json, s.media_path, s.media_type, s.created_at, s.modified_at,
|
||||
st.name AS template_name, cs.name AS canvas_size_name, cs.width AS canvas_width, cs.height AS canvas_height
|
||||
FROM slides s
|
||||
LEFT JOIN slide_templates st ON st.id = s.template_id
|
||||
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id
|
||||
SELECT s.id, s.title, s.template_id, s.content_json, s.thumbnail_path, s.created_at, s.modified_at,
|
||||
st.name AS template_name, st.canvas_size_id, cs.name AS canvas_size_name, cs.width AS canvas_width, cs.height AS canvas_height
|
||||
FROM c_slides s
|
||||
LEFT JOIN c_templates st ON st.id = s.template_id
|
||||
LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id
|
||||
WHERE s.id = ?
|
||||
`, [id]);
|
||||
if (!slides.length) {
|
||||
@@ -52,6 +75,14 @@ function getFilesByField(files) {
|
||||
return map;
|
||||
}
|
||||
|
||||
function getSubmittedValue(body, key, fallback) {
|
||||
if (body && Object.prototype.hasOwnProperty.call(body, key)) {
|
||||
return String(body[key] || '').trim();
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function sanitizeTextColor(value, fallback) {
|
||||
const raw = String(value || '').trim();
|
||||
if (/^#[0-9a-fA-F]{6}$/.test(raw) || /^#[0-9a-fA-F]{3}$/.test(raw)) {
|
||||
@@ -60,10 +91,19 @@ function sanitizeTextColor(value, fallback) {
|
||||
return fallback || '#000000';
|
||||
}
|
||||
|
||||
function sanitizeFontSize(value, fallback) {
|
||||
const raw = String(value || '').trim();
|
||||
const parsed = Math.round(Number(raw.replace(/[^0-9.]/g, '')));
|
||||
if (Number.isFinite(parsed) && parsed > 0) {
|
||||
return parsed;
|
||||
}
|
||||
return Math.max(8, Number(fallback || DEFAULT_FONT_SIZE));
|
||||
}
|
||||
|
||||
function getTextRegionStyle(body, region, existingContent) {
|
||||
const existing = existingContent && existingContent[region.region_key] ? existingContent[region.region_key] : {};
|
||||
const fontFamily = String(body[`region_font_family_${region.id}`] || existing.font_family || region.font_family || 'Arial').trim() || 'Arial';
|
||||
const fontSize = Math.max(8, Number(body[`region_font_size_${region.id}`] || existing.font_size || 24));
|
||||
const fontSize = sanitizeFontSize(body[`region_font_size_${region.id}`], existing.font_size || DEFAULT_FONT_SIZE);
|
||||
const fontColor = sanitizeTextColor(body[`region_font_color_${region.id}`] || existing.font_color || region.font_color || '#000000');
|
||||
return {
|
||||
font_family: fontFamily,
|
||||
@@ -78,9 +118,23 @@ function buildTemplateContent(template, body, filesByField, existingContent) {
|
||||
if (region.region_type === 'image') {
|
||||
const uploaded = filesByField[`region_image_${region.id}`];
|
||||
const existing = body[`existing_region_image_${region.id}`];
|
||||
const current = existingContent && existingContent[region.region_key] ? existingContent[region.region_key].value : '';
|
||||
content[region.region_key] = {
|
||||
type: 'image',
|
||||
value: uploaded ? `/uploads/${uploaded.filename}` : String(existing || '').trim() || ((existingContent && existingContent[region.region_key]) ? existingContent[region.region_key].value : '')
|
||||
value: uploaded ? `/media/uploads/${uploaded.filename}` : getSubmittedValue(body, `existing_region_image_${region.id}`, current)
|
||||
};
|
||||
} else if (region.region_type === 'video') {
|
||||
const uploaded = filesByField[`region_video_${region.id}`];
|
||||
const existing = body[`existing_region_video_${region.id}`];
|
||||
const durationValue = body[`existing_region_video_duration_${region.id}`];
|
||||
const existingDuration = existingContent && existingContent[region.region_key] ? Number(existingContent[region.region_key].duration_seconds || 0) : 0;
|
||||
const parsedDuration = Number(durationValue || existingDuration || 0);
|
||||
const normalizedDuration = Math.round(parsedDuration * 1000) / 1000;
|
||||
const current = existingContent && existingContent[region.region_key] ? existingContent[region.region_key].value : '';
|
||||
content[region.region_key] = {
|
||||
type: 'video',
|
||||
value: uploaded ? `/media/uploads/${uploaded.filename}` : getSubmittedValue(body, `existing_region_video_${region.id}`, current),
|
||||
duration_seconds: Number.isFinite(normalizedDuration) && normalizedDuration > 0 ? normalizedDuration : null
|
||||
};
|
||||
} else if (region.region_type === 'webpage') {
|
||||
const submitted = body[`region_webpage_${region.id}`];
|
||||
@@ -89,6 +143,22 @@ function buildTemplateContent(template, body, filesByField, existingContent) {
|
||||
type: 'webpage',
|
||||
value: submitted === undefined ? current : String(submitted || '').trim()
|
||||
};
|
||||
} else if (region.region_type === 'qr-code') {
|
||||
const submitted = body[`region_qr_code_${region.id}`];
|
||||
const current = existingContent && existingContent[region.region_key] ? existingContent[region.region_key] : {};
|
||||
const nextValue = submitted === undefined ? String(current.value !== undefined ? current.value : current.qr_code || '').trim() : String(submitted || '').trim();
|
||||
content[region.region_key] = {
|
||||
type: 'qr-code',
|
||||
value: nextValue
|
||||
};
|
||||
} else if (region.region_type === 'rtmp') {
|
||||
const submitted = body[`region_rtmp_${region.id}`];
|
||||
const current = existingContent && existingContent[region.region_key] ? existingContent[region.region_key] : {};
|
||||
content[region.region_key] = {
|
||||
type: 'rtmp',
|
||||
value: submitted === undefined ? String(current.value || '').trim() : String(submitted || '').trim(),
|
||||
disable_audio: Boolean(body[`region_disable_audio_${region.id}`])
|
||||
};
|
||||
} else if (region.region_type === 'html') {
|
||||
const submitted = body[`region_html_${region.id}`];
|
||||
const current = existingContent && existingContent[region.region_key] ? existingContent[region.region_key].value : '';
|
||||
@@ -96,13 +166,93 @@ function buildTemplateContent(template, body, filesByField, existingContent) {
|
||||
type: 'html',
|
||||
value: submitted === undefined ? current : String(submitted || '')
|
||||
};
|
||||
} else if (region.region_type === 'time-date') {
|
||||
const submitted = body[`region_text_${region.id}`];
|
||||
const current = existingContent && existingContent[region.region_key] ? existingContent[region.region_key] : {};
|
||||
const timezoneValue = body[`region_timezone_${region.id}`];
|
||||
content[region.region_key] = {
|
||||
type: 'time-date',
|
||||
value: submitted === undefined ? String(current.value !== undefined ? current.value : current.text !== undefined ? current.text : '') : String(submitted || ''),
|
||||
timezone: timezoneValue === undefined || timezoneValue === null ? String(current.timezone || current.time_zone || '') : String(timezoneValue || '').trim()
|
||||
};
|
||||
} else if (region.region_type === 'rss') {
|
||||
const submitted = body[`region_text_${region.id}`];
|
||||
const current = existingContent && existingContent[region.region_key] ? existingContent[region.region_key] : {};
|
||||
const style = getTextRegionStyle(body, region, existingContent);
|
||||
const feedId = body[`region_rss_feed_id_${region.id}`];
|
||||
const itemNumber = body[`region_rss_item_number_${region.id}`];
|
||||
const parsedItemNumber = Math.max(1, Number(itemNumber || current.item_number || 1));
|
||||
content[region.region_key] = {
|
||||
type: 'rss',
|
||||
value: stripEditorOnlyMarkup(normalizeEditorMarkup(submitted === undefined ? String(current.value || '') : String(submitted || ''))),
|
||||
feed_id: feedId === undefined || feedId === null || feedId === '' ? (current.feed_id || null) : Number(feedId),
|
||||
item_number: Number.isFinite(parsedItemNumber) && parsedItemNumber > 0 ? parsedItemNumber : 1,
|
||||
variable_name: 'item',
|
||||
font_family: style.font_family,
|
||||
font_size: style.font_size,
|
||||
font_color: style.font_color
|
||||
};
|
||||
} else if (region.region_type === 'api') {
|
||||
const submitted = body[`region_text_${region.id}`];
|
||||
const current = existingContent && existingContent[region.region_key] ? existingContent[region.region_key] : {};
|
||||
const style = getTextRegionStyle(body, region, existingContent);
|
||||
const sourceId = body[`region_api_source_id_${region.id}`];
|
||||
const itemNumber = body[`region_api_item_number_${region.id}`];
|
||||
const itemsPath = body[`region_api_items_path_${region.id}`];
|
||||
const parsedItemNumber = Math.max(1, Number(itemNumber || current.item_number || 1));
|
||||
content[region.region_key] = {
|
||||
type: 'api',
|
||||
value: stripEditorOnlyMarkup(normalizeEditorMarkup(submitted === undefined ? String(current.value || '') : String(submitted || ''))),
|
||||
source_id: sourceId === undefined || sourceId === null || sourceId === '' ? (current.source_id || null) : Number(sourceId),
|
||||
item_number: Number.isFinite(parsedItemNumber) && parsedItemNumber > 0 ? parsedItemNumber : 1,
|
||||
items_path: itemsPath === undefined || itemsPath === null ? (current.items_path === undefined || current.items_path === null ? '' : String(current.items_path)) : String(itemsPath || '').trim(),
|
||||
variable_name: 'item',
|
||||
font_family: style.font_family,
|
||||
font_size: style.font_size,
|
||||
font_color: style.font_color
|
||||
};
|
||||
} else if (!['text', 'image', 'video', 'webpage', 'qr-code', 'html', 'rtmp', 'rss', 'api'].includes(String(region.region_type || '').trim())) {
|
||||
const current = existingContent && existingContent[region.region_key] && typeof existingContent[region.region_key] === 'object' ? existingContent[region.region_key] : {};
|
||||
const suffix = '_' + region.id;
|
||||
const generic = {};
|
||||
|
||||
Object.keys(body || {}).forEach((key) => {
|
||||
if (!key.startsWith('region_') || !key.endsWith(suffix)) {
|
||||
return;
|
||||
}
|
||||
const field = key.slice('region_'.length, -suffix.length);
|
||||
if (!field || field === 'type' || field === 'key' || field === 'name' || field === 'label') {
|
||||
return;
|
||||
}
|
||||
generic[field] = body[key];
|
||||
});
|
||||
|
||||
Object.keys(filesByField || {}).forEach((fieldName) => {
|
||||
if (!fieldName.startsWith('region_') || !fieldName.endsWith(suffix)) {
|
||||
return;
|
||||
}
|
||||
const field = fieldName.slice('region_'.length, -suffix.length);
|
||||
if (!field) {
|
||||
return;
|
||||
}
|
||||
generic[field] = `/media/uploads/${filesByField[fieldName].filename}`;
|
||||
});
|
||||
|
||||
Object.keys(current).forEach((key) => {
|
||||
if (generic[key] === undefined) {
|
||||
generic[key] = current[key];
|
||||
}
|
||||
});
|
||||
|
||||
generic.type = region.region_type;
|
||||
content[region.region_key] = generic;
|
||||
} else {
|
||||
const submitted = body[`region_text_${region.id}`];
|
||||
const current = existingContent && existingContent[region.region_key] ? existingContent[region.region_key].value : '';
|
||||
const style = getTextRegionStyle(body, region, existingContent);
|
||||
content[region.region_key] = {
|
||||
type: 'text',
|
||||
value: submitted === undefined ? current : String(submitted),
|
||||
value: stripEditorOnlyMarkup(normalizeEditorMarkup(submitted === undefined ? current : String(submitted || ''))),
|
||||
font_family: style.font_family,
|
||||
font_size: style.font_size,
|
||||
font_color: style.font_color
|
||||
@@ -132,23 +282,29 @@ async function buildSlidePayload(pool, req, existingSlide) {
|
||||
}
|
||||
|
||||
if (template) {
|
||||
const content = buildTemplateContent(template, req.body, filesByField, existingContent);
|
||||
await Promise.all(Object.keys(content).map(async function (regionKey) {
|
||||
const region = template.regions.find(function (item) {
|
||||
return String(item.region_key || '').trim() === regionKey;
|
||||
});
|
||||
if (!region || region.region_type !== 'qr-code') {
|
||||
return;
|
||||
}
|
||||
|
||||
content[regionKey] = await buildQrCodeContent(content[regionKey].value);
|
||||
}));
|
||||
|
||||
return {
|
||||
title,
|
||||
body: existingSlide ? existingSlide.body : null,
|
||||
templateId: template.id,
|
||||
contentJson: JSON.stringify(buildTemplateContent(template, req.body, filesByField, existingContent)),
|
||||
mediaPath: null,
|
||||
mediaType: null
|
||||
contentJson: JSON.stringify(content)
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title,
|
||||
body: existingSlide ? existingSlide.body : null,
|
||||
templateId: null,
|
||||
contentJson: existingSlide ? existingSlide.content_json : null,
|
||||
mediaPath: existingSlide ? existingSlide.media_path : null,
|
||||
mediaType: existingSlide ? existingSlide.media_type : null
|
||||
contentJson: existingSlide ? existingSlide.content_json : null
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+167
-37
@@ -1,38 +1,132 @@
|
||||
const { parseJsonSafe, readFormArray } = require('./utils');
|
||||
// Template data access helpers and region normalization logic.
|
||||
|
||||
const ALLOWED_TEMPLATE_REGION_TYPES = ['text', 'image', 'webpage', 'html'];
|
||||
const { parseJsonSafe, readFormArray } = require('./utils');
|
||||
const animationPresets = require('../web/public/js/templates/animation-presets');
|
||||
|
||||
function sanitizeBackgroundColor(value) {
|
||||
const raw = String(value || '').trim();
|
||||
if (/^#[0-9a-fA-F]{6}$/.test(raw) || /^#[0-9a-fA-F]{3}$/.test(raw)) {
|
||||
return raw;
|
||||
}
|
||||
return '#111111';
|
||||
}
|
||||
|
||||
function normalizeTemplateRegionType(value) {
|
||||
const rawType = String(value || 'text').trim();
|
||||
return ALLOWED_TEMPLATE_REGION_TYPES.includes(rawType) ? rawType : 'text';
|
||||
return rawType || 'text';
|
||||
}
|
||||
|
||||
function normalizeTemplateRegionLockRatio(value) {
|
||||
const rawRatio = String(value || '').trim();
|
||||
if (!/^\d+\s*:\s*\d+$/.test(rawRatio)) {
|
||||
return null;
|
||||
}
|
||||
return rawRatio.replace(/\s+/g, '');
|
||||
}
|
||||
|
||||
function normalizeTemplateRegionAnimation(value) {
|
||||
if (animationPresets && typeof animationPresets.normalize === 'function') {
|
||||
return animationPresets.normalize(value);
|
||||
}
|
||||
|
||||
const allowed = new Set(
|
||||
Array.isArray(animationPresets && animationPresets.allValues) && animationPresets.allValues.length
|
||||
? animationPresets.allValues
|
||||
: ['none', 'fadeIn', 'fadeInDown', 'fadeInLeft', 'fadeInRight', 'fadeInUp', 'zoomIn', 'zoomInDown', 'zoomInLeft', 'zoomInRight', 'zoomInUp', 'slideInDown', 'slideInLeft', 'slideInRight', 'slideInUp', 'fadeOut', 'fadeOutDown', 'fadeOutLeft', 'fadeOutRight', 'fadeOutUp', 'zoomOut', 'zoomOutDown', 'zoomOutLeft', 'zoomOutRight', 'zoomOutUp', 'slideOutDown', 'slideOutLeft', 'slideOutRight', 'slideOutUp', 'backInDown', 'backInLeft', 'backInRight', 'backInUp', 'backOutDown', 'backOutLeft', 'backOutRight', 'backOutUp', 'bounceIn', 'bounceInDown', 'bounceInLeft', 'bounceInRight', 'bounceInUp', 'bounceOut', 'bounceOutDown', 'bounceOutLeft', 'bounceOutRight', 'bounceOutUp', 'flip', 'flipInX', 'flipInY', 'flipOutX', 'flipOutY', 'lightSpeedInLeft', 'lightSpeedInRight', 'lightSpeedOutLeft', 'lightSpeedOutRight', 'rotateIn', 'rotateInDownLeft', 'rotateInDownRight', 'rotateInUpLeft', 'rotateInUpRight', 'rotateOut', 'rotateOutDownLeft', 'rotateOutDownRight', 'rotateOutUpLeft', 'rotateOutUpRight', 'hinge', 'jackInTheBox', 'rollIn', 'rollOut', 'flash', 'pulse', 'rubberBand', 'shakeX', 'shakeY', 'headShake', 'swing', 'tada', 'wobble', 'jello', 'heartBeat', 'bounce']
|
||||
);
|
||||
const raw = String(value || '').trim().toLowerCase();
|
||||
return allowed.has(raw) ? raw : 'none';
|
||||
}
|
||||
|
||||
function normalizeTemplateRegionAnimationStep(value, fallbackPreset) {
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
const normalized = {};
|
||||
Object.keys(value).forEach((key) => {
|
||||
normalized[key] = value[key];
|
||||
});
|
||||
normalized.preset = normalizeTemplateRegionAnimation(value.preset !== undefined ? value.preset : fallbackPreset || 'none');
|
||||
return normalized;
|
||||
}
|
||||
|
||||
return {
|
||||
preset: normalizeTemplateRegionAnimation(typeof value === 'string' ? value : fallbackPreset || 'none')
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeTemplateRegionAnimationConfig(value) {
|
||||
let raw = value;
|
||||
if (typeof raw === 'string') {
|
||||
const text = String(raw || '').trim();
|
||||
if (!text) {
|
||||
raw = null;
|
||||
} else {
|
||||
try {
|
||||
raw = JSON.parse(text);
|
||||
} catch (_error) {
|
||||
raw = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
||||
raw = {};
|
||||
}
|
||||
|
||||
return {
|
||||
intro: normalizeTemplateRegionAnimationStep(raw.intro, 'none'),
|
||||
outro: normalizeTemplateRegionAnimationStep(raw.outro !== undefined ? raw.outro : raw.out, 'none'),
|
||||
loop: normalizeTemplateRegionAnimationStep(raw.loop, 'none')
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeTemplateRegionName(value) {
|
||||
return String(value || '').trim();
|
||||
}
|
||||
|
||||
function ensureUniqueTemplateRegionNames(regions) {
|
||||
const seen = new Map();
|
||||
for (let i = 0; i < regions.length; i += 1) {
|
||||
const region = regions[i];
|
||||
const regionName = normalizeTemplateRegionName(region.region_key || region.label);
|
||||
if (!regionName) {
|
||||
continue;
|
||||
}
|
||||
const normalized = regionName.toLowerCase();
|
||||
if (seen.has(normalized)) {
|
||||
const error = new Error('Region names must be unique on this template.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
seen.set(normalized, true);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchTemplateById(pool, id) {
|
||||
const [templates] = await pool.query(`
|
||||
SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, st.created_at, st.modified_at,
|
||||
SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, st.background_color, st.created_at, st.modified_at,
|
||||
cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height
|
||||
FROM slide_templates st
|
||||
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id
|
||||
FROM c_templates st
|
||||
LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id
|
||||
WHERE st.id = ?
|
||||
`, [id]);
|
||||
if (!templates.length) {
|
||||
return null;
|
||||
}
|
||||
const template = templates[0];
|
||||
const [regions] = await pool.query('SELECT id, template_id, region_key, region_type, label, font_family, x, y, width, height, z_index, created_at, modified_at, created_by, modified_by FROM slide_template_regions WHERE template_id = ? ORDER BY z_index ASC, id ASC', [id]);
|
||||
const [regions] = await pool.query('SELECT id, template_id, region_key, region_type, label, lock_ratio, animation_json, x, y, width, height, z_index, created_at, modified_at, created_by, modified_by FROM c_template_regions WHERE template_id = ? ORDER BY z_index ASC, id ASC', [id]);
|
||||
template.regions = regions;
|
||||
return template;
|
||||
}
|
||||
|
||||
async function fetchTemplatesData(pool) {
|
||||
const [templates] = await pool.query(`
|
||||
SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, st.created_at, st.modified_at,
|
||||
SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, st.background_color, st.created_at, st.modified_at,
|
||||
cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height
|
||||
FROM slide_templates st
|
||||
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id
|
||||
FROM c_templates st
|
||||
LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id
|
||||
ORDER BY st.id DESC
|
||||
`);
|
||||
const [templateRegions] = await pool.query('SELECT id, template_id, region_key, region_type, label, font_family, x, y, width, height, z_index, created_at, modified_at, created_by, modified_by FROM slide_template_regions ORDER BY template_id ASC, z_index ASC, id ASC');
|
||||
const [templateRegions] = await pool.query('SELECT id, template_id, region_key, region_type, label, lock_ratio, animation_json, x, y, width, height, z_index, created_at, modified_at, created_by, modified_by FROM c_template_regions ORDER BY template_id ASC, z_index ASC, id ASC');
|
||||
return { templates, templateRegions };
|
||||
}
|
||||
|
||||
@@ -41,17 +135,21 @@ function extractTemplateRegions(body) {
|
||||
if (regionsJson) {
|
||||
const parsed = parseJsonSafe(regionsJson);
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed.map((region) => ({
|
||||
region_key: String(region.region_name || region.region_key || region.label || '').trim(),
|
||||
region_type: normalizeTemplateRegionType(region.region_type),
|
||||
label: String(region.region_name || region.label || region.region_key || '').trim(),
|
||||
font_family: ['text', 'html'].includes(normalizeTemplateRegionType(region.region_type)) ? String(region.font_family || '').trim() || null : null,
|
||||
x: Number(region.x || 0),
|
||||
y: Number(region.y || 0),
|
||||
width: Number(region.width || 100),
|
||||
height: Number(region.height || 100),
|
||||
z_index: Number(region.z_index || 0)
|
||||
})).filter((region) => region.region_key && region.label);
|
||||
return parsed.map((region) => {
|
||||
const regionType = normalizeTemplateRegionType(region.region_type);
|
||||
return {
|
||||
region_key: String(region.region_name || region.region_key || region.label || '').trim(),
|
||||
region_type: regionType,
|
||||
label: String(region.region_name || region.label || region.region_key || '').trim(),
|
||||
lock_ratio: normalizeTemplateRegionLockRatio(region.lock_ratio),
|
||||
animation_json: normalizeTemplateRegionAnimationConfig(region.animation_json),
|
||||
x: Number(region.x || 0),
|
||||
y: Number(region.y || 0),
|
||||
width: Number(region.width || 100),
|
||||
height: Number(region.height || 100),
|
||||
z_index: Number(region.z_index || 0)
|
||||
};
|
||||
}).filter((region) => region.region_key && region.label);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,12 +157,13 @@ function extractTemplateRegions(body) {
|
||||
const names = readFormArray(body, 'region_name[]');
|
||||
const labels = readFormArray(body, 'region_label[]');
|
||||
const types = readFormArray(body, 'region_type[]');
|
||||
const ratios = readFormArray(body, 'region_lock_ratio[]');
|
||||
const animationJsons = readFormArray(body, 'region_animation_json[]');
|
||||
const xs = readFormArray(body, 'region_x[]');
|
||||
const ys = readFormArray(body, 'region_y[]');
|
||||
const widths = readFormArray(body, 'region_width[]');
|
||||
const heights = readFormArray(body, 'region_height[]');
|
||||
const zs = readFormArray(body, 'region_z[]');
|
||||
const fonts = readFormArray(body, 'font_family[]');
|
||||
const regions = [];
|
||||
|
||||
for (let i = 0; i < keys.length; i += 1) {
|
||||
@@ -78,7 +177,8 @@ function extractTemplateRegions(body) {
|
||||
region_key: name,
|
||||
region_type: regionType,
|
||||
label: name,
|
||||
font_family: ['text', 'html'].includes(regionType) ? String(fonts[i] || 'Arial').trim() || 'Arial' : null,
|
||||
lock_ratio: normalizeTemplateRegionLockRatio(ratios[i]),
|
||||
animation_json: normalizeTemplateRegionAnimationConfig(animationJsons[i]),
|
||||
x: Number(xs[i] || 0),
|
||||
y: Number(ys[i] || 0),
|
||||
width: Number(widths[i] || 100),
|
||||
@@ -90,6 +190,43 @@ function extractTemplateRegions(body) {
|
||||
return regions;
|
||||
}
|
||||
|
||||
function extractGenericRegionContent(region, body, filesByField, existingContent) {
|
||||
const content = {};
|
||||
const current = existingContent && existingContent[region.region_key] && typeof existingContent[region.region_key] === 'object' ? existingContent[region.region_key] : {};
|
||||
const suffix = '_' + region.id;
|
||||
|
||||
Object.keys(body || {}).forEach((key) => {
|
||||
if (!key.startsWith('region_') || !key.endsWith(suffix)) {
|
||||
return;
|
||||
}
|
||||
const field = key.slice('region_'.length, -suffix.length);
|
||||
if (!field || field === 'type' || field === 'key' || field === 'name' || field === 'label') {
|
||||
return;
|
||||
}
|
||||
content[field] = body[key];
|
||||
});
|
||||
|
||||
Object.keys(filesByField || {}).forEach((fieldName) => {
|
||||
if (!fieldName.startsWith('region_') || !fieldName.endsWith(suffix)) {
|
||||
return;
|
||||
}
|
||||
const field = fieldName.slice('region_'.length, -suffix.length);
|
||||
if (!field) {
|
||||
return;
|
||||
}
|
||||
content[field] = `/media/uploads/${filesByField[fieldName].filename}`;
|
||||
});
|
||||
|
||||
Object.keys(current).forEach((key) => {
|
||||
if (content[key] === undefined) {
|
||||
content[key] = current[key];
|
||||
}
|
||||
});
|
||||
|
||||
content.type = region.region_type;
|
||||
return content;
|
||||
}
|
||||
|
||||
function getFilesByField(files) {
|
||||
const map = {};
|
||||
(files || []).forEach((file) => {
|
||||
@@ -107,8 +244,9 @@ async function buildTemplatePayload(pool, req, existingTemplate) {
|
||||
const filesByField = getFilesByField(req.files || []);
|
||||
const backgroundImage = filesByField.background_image;
|
||||
const removeBackgroundImage = Boolean(req.body.remove_background_image);
|
||||
const backgroundColor = sanitizeBackgroundColor(req.body.background_color || (existingTemplate && existingTemplate.background_color));
|
||||
const backgroundImagePath = backgroundImage
|
||||
? `/uploads/${backgroundImage.filename}`
|
||||
? `/media/uploads/${backgroundImage.filename}`
|
||||
: removeBackgroundImage
|
||||
? null
|
||||
: String(req.body.existing_background_image_path || (existingTemplate && existingTemplate.background_image_path) || '').trim() || null;
|
||||
@@ -121,7 +259,7 @@ async function buildTemplatePayload(pool, req, existingTemplate) {
|
||||
|
||||
let resolvedCanvasSizeId = canvasSizeId;
|
||||
if (resolvedCanvasSizeId) {
|
||||
const [canvasSizes] = await pool.query('SELECT id, name, width, height, created_at, modified_at, created_by, modified_by FROM canvas_sizes WHERE id = ?', [resolvedCanvasSizeId]);
|
||||
const [canvasSizes] = await pool.query('SELECT id, name, width, height, created_at, modified_at, created_by, modified_by FROM c_canvas_sizes WHERE id = ?', [resolvedCanvasSizeId]);
|
||||
const canvasSize = canvasSizes[0];
|
||||
if (!canvasSize) {
|
||||
const error = new Error('Canvas size not found.');
|
||||
@@ -140,25 +278,17 @@ async function buildTemplatePayload(pool, req, existingTemplate) {
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
regions = [{
|
||||
region_key: 'region_1',
|
||||
region_type: 'text',
|
||||
label: 'Region 1',
|
||||
font_family: 'Arial',
|
||||
x: 120,
|
||||
y: 120,
|
||||
width: Math.max(200, Math.round(canvasWidth * 0.22)),
|
||||
height: Math.max(120, Math.round(canvasHeight * 0.15)),
|
||||
z_index: 1
|
||||
}];
|
||||
}
|
||||
|
||||
ensureUniqueTemplateRegionNames(regions);
|
||||
|
||||
return {
|
||||
name,
|
||||
canvasSizeId: resolvedCanvasSizeId,
|
||||
canvasSizeWidth: canvasWidth,
|
||||
canvasSizeHeight: canvasHeight,
|
||||
backgroundImagePath,
|
||||
backgroundColor,
|
||||
regions
|
||||
};
|
||||
}
|
||||
|
||||
+277
-1
@@ -1,3 +1,5 @@
|
||||
// Shared helpers for parsing JSON, reading form arrays, and duplicate-name checks.
|
||||
|
||||
function parseJsonSafe(value) {
|
||||
if (!value) {
|
||||
return null;
|
||||
@@ -22,7 +24,281 @@ function readFormArray(body, key) {
|
||||
return [body[key]];
|
||||
}
|
||||
|
||||
function normalizePageNumber(value) {
|
||||
const pageNumber = Math.floor(Number(value) || 1);
|
||||
return Math.max(1, pageNumber);
|
||||
}
|
||||
|
||||
function escapeLikeValue(value) {
|
||||
return String(value || '').replace(/[\\%_]/g, '\\$&');
|
||||
}
|
||||
|
||||
function normalizeSortDirection(value) {
|
||||
return String(value || '').trim().toLowerCase() === 'desc' ? 'desc' : 'asc';
|
||||
}
|
||||
|
||||
function buildSortOrderClause(sortColumns, sortKey, sortDirection) {
|
||||
const normalizedSortKey = String(sortKey || '').trim();
|
||||
const normalizedSortDirection = normalizeSortDirection(sortDirection);
|
||||
const sortColumnMap = sortColumns && typeof sortColumns === 'object' ? sortColumns : {};
|
||||
const sortExpression = normalizedSortKey ? sortColumnMap[normalizedSortKey] : null;
|
||||
|
||||
if (!sortExpression) {
|
||||
return {
|
||||
clause: '',
|
||||
sortKey: normalizedSortKey,
|
||||
sortDirection: normalizedSortDirection
|
||||
};
|
||||
}
|
||||
|
||||
const expressions = Array.isArray(sortExpression) ? sortExpression : [sortExpression];
|
||||
const orderBySql = expressions.map(function (expression) {
|
||||
return `${expression} ${normalizedSortDirection.toUpperCase()}`;
|
||||
}).join(', ');
|
||||
|
||||
return {
|
||||
clause: ` ORDER BY ${orderBySql}`,
|
||||
sortKey: normalizedSortKey,
|
||||
sortDirection: normalizedSortDirection
|
||||
};
|
||||
}
|
||||
|
||||
function findTopLevelOrderByIndex(sql) {
|
||||
const text = String(sql || '');
|
||||
let depth = 0;
|
||||
let inSingleQuote = false;
|
||||
let inDoubleQuote = false;
|
||||
let inBacktick = false;
|
||||
let lastOrderByIndex = -1;
|
||||
|
||||
for (let index = 0; index < text.length; index += 1) {
|
||||
const character = text[index];
|
||||
const previousCharacter = index > 0 ? text[index - 1] : '';
|
||||
|
||||
if (inSingleQuote) {
|
||||
if (character === '\'' && previousCharacter !== '\\') {
|
||||
inSingleQuote = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inDoubleQuote) {
|
||||
if (character === '"' && previousCharacter !== '\\') {
|
||||
inDoubleQuote = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inBacktick) {
|
||||
if (character === '`') {
|
||||
inBacktick = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character === '\'') {
|
||||
inSingleQuote = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character === '"') {
|
||||
inDoubleQuote = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character === '`') {
|
||||
inBacktick = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character === '(') {
|
||||
depth += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character === ')' && depth > 0) {
|
||||
depth -= 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (depth === 0 && /[oO]/.test(character)) {
|
||||
const remaining = text.slice(index);
|
||||
if (/^order\s+by\b/i.test(remaining)) {
|
||||
lastOrderByIndex = index;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return lastOrderByIndex;
|
||||
}
|
||||
|
||||
function findTopLevelWhereIndex(sql) {
|
||||
const text = String(sql || '');
|
||||
let depth = 0;
|
||||
let inSingleQuote = false;
|
||||
let inDoubleQuote = false;
|
||||
let inBacktick = false;
|
||||
let lastWhereIndex = -1;
|
||||
|
||||
for (let index = 0; index < text.length; index += 1) {
|
||||
const character = text[index];
|
||||
const previousCharacter = index > 0 ? text[index - 1] : '';
|
||||
|
||||
if (inSingleQuote) {
|
||||
if (character === '\'' && previousCharacter !== '\\') {
|
||||
inSingleQuote = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inDoubleQuote) {
|
||||
if (character === '"' && previousCharacter !== '\\') {
|
||||
inDoubleQuote = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inBacktick) {
|
||||
if (character === '`') {
|
||||
inBacktick = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character === '\'') {
|
||||
inSingleQuote = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character === '"') {
|
||||
inDoubleQuote = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character === '`') {
|
||||
inBacktick = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character === '(') {
|
||||
depth += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character === ')' && depth > 0) {
|
||||
depth -= 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (depth === 0 && /[wW]/.test(character)) {
|
||||
const remaining = text.slice(index);
|
||||
if (/^where\b/i.test(remaining)) {
|
||||
lastWhereIndex = index;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return lastWhereIndex;
|
||||
}
|
||||
|
||||
function buildSearchFilter(searchColumns, searchTerm) {
|
||||
const columns = Array.isArray(searchColumns) ? searchColumns.map(function (column) {
|
||||
return String(column || '').trim();
|
||||
}).filter(Boolean) : [];
|
||||
const normalizedSearchTerm = String(searchTerm || '').trim().toLowerCase();
|
||||
|
||||
if (!columns.length || !normalizedSearchTerm) {
|
||||
return {
|
||||
clause: '',
|
||||
params: []
|
||||
};
|
||||
}
|
||||
|
||||
const likeValue = `%${escapeLikeValue(normalizedSearchTerm)}%`;
|
||||
return {
|
||||
clause: ` WHERE (${columns.map(function (column) {
|
||||
return `LOWER(COALESCE(CAST(${column} AS CHAR), '')) LIKE ? ESCAPE '\\\\'`;
|
||||
}).join(' OR ')})`,
|
||||
params: columns.map(function () {
|
||||
return likeValue;
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchPagedRows(pool, options) {
|
||||
const selectSql = String(options && options.selectSql || '').trim();
|
||||
const countSql = String(options && options.countSql || '').trim();
|
||||
const params = Array.isArray(options && options.params) ? options.params : [];
|
||||
const searchFilter = buildSearchFilter(options && options.searchColumns, options && options.searchTerm);
|
||||
const sortOrder = buildSortOrderClause(options && options.sortColumns, options && options.sortKey, options && options.sortDirection);
|
||||
const pageSize = Math.max(1, Number(options && options.pageSize) || 10);
|
||||
const currentPage = normalizePageNumber(options && options.page);
|
||||
|
||||
if (!selectSql || !countSql) {
|
||||
throw new Error('fetchPagedRows requires selectSql and countSql.');
|
||||
}
|
||||
const orderByIndex = findTopLevelOrderByIndex(selectSql);
|
||||
let baseSelectSql = selectSql;
|
||||
let orderBySql = '';
|
||||
|
||||
if (orderByIndex >= 0) {
|
||||
baseSelectSql = selectSql.slice(0, orderByIndex).trim();
|
||||
orderBySql = selectSql.slice(orderByIndex).trim();
|
||||
}
|
||||
|
||||
const hasTopLevelWhere = findTopLevelWhereIndex(baseSelectSql) >= 0;
|
||||
const searchClause = searchFilter.clause ? (hasTopLevelWhere ? searchFilter.clause.replace(/^\s*WHERE\s+/i, ' AND ') : searchFilter.clause) : '';
|
||||
const filteredSelectSql = `${baseSelectSql}${searchClause}`;
|
||||
const countQuery = searchFilter.clause
|
||||
? `SELECT COUNT(*) AS count FROM (${filteredSelectSql}) AS filtered_rows`
|
||||
: countSql;
|
||||
const countParams = searchFilter.clause ? params.concat(searchFilter.params) : params;
|
||||
const [countRows] = await pool.query(countQuery, countParams);
|
||||
const totalItems = Number(countRows && countRows[0] && countRows[0].count) || 0;
|
||||
const totalPages = Math.max(1, Math.ceil(totalItems / pageSize));
|
||||
const safeCurrentPage = Math.min(currentPage, totalPages);
|
||||
const offset = (safeCurrentPage - 1) * pageSize;
|
||||
|
||||
const activeOrderBySql = sortOrder.clause || (orderBySql ? ` ${orderBySql}` : '');
|
||||
const selectQuery = `${filteredSelectSql}${activeOrderBySql} LIMIT ? OFFSET ?`;
|
||||
const [rows] = await pool.query(selectQuery, params.concat(searchFilter.params, [pageSize, offset]));
|
||||
|
||||
return {
|
||||
rows: rows || [],
|
||||
totalItems: totalItems,
|
||||
totalPages: totalPages,
|
||||
currentPage: safeCurrentPage,
|
||||
pageSize: pageSize
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchDuplicateName(pool, tableName, name, excludeId, columnName) {
|
||||
const normalizedName = String(name || '').trim();
|
||||
if (!normalizedName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedColumnName = String(columnName || 'name').trim() || 'name';
|
||||
const params = [normalizedName];
|
||||
let sql = `SELECT id, \`${normalizedColumnName}\` AS name FROM \`${tableName}\` WHERE LOWER(TRIM(\`${normalizedColumnName}\`)) = LOWER(TRIM(?))`;
|
||||
if (excludeId !== undefined && excludeId !== null) {
|
||||
sql += ' AND id <> ?';
|
||||
params.push(excludeId);
|
||||
}
|
||||
sql += ' LIMIT 1';
|
||||
|
||||
const [rows] = await pool.query(sql, params);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
parseJsonSafe,
|
||||
readFormArray
|
||||
readFormArray,
|
||||
normalizePageNumber,
|
||||
normalizeSortDirection,
|
||||
buildSortOrderClause,
|
||||
findTopLevelOrderByIndex,
|
||||
buildSearchFilter,
|
||||
fetchPagedRows,
|
||||
fetchDuplicateName
|
||||
};
|
||||
|
||||
@@ -1,250 +0,0 @@
|
||||
const mysql = require('mysql2/promise');
|
||||
const { hashPassword } = require('./auth');
|
||||
|
||||
function createPool() {
|
||||
return mysql.createPool({
|
||||
host: process.env.DB_HOST || '127.0.0.1',
|
||||
port: Number(process.env.DB_PORT || 3306),
|
||||
user: process.env.DB_USER || 'signage_user',
|
||||
password: process.env.DB_PASSWORD || 'signage_password',
|
||||
database: process.env.DB_NAME || 'signage',
|
||||
waitForConnections: true,
|
||||
connectionLimit: 10,
|
||||
namedPlaceholders: true
|
||||
});
|
||||
}
|
||||
|
||||
async function addColumnIfMissing(pool, tableName, columnName, columnDefinition) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT COUNT(*) AS column_count
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = ?
|
||||
AND column_name = ?`,
|
||||
[tableName, columnName]
|
||||
);
|
||||
|
||||
if (rows.length && Number(rows[0].column_count) > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await pool.query(`ALTER TABLE \`${tableName}\` ADD COLUMN \`${columnName}\` ${columnDefinition}`);
|
||||
}
|
||||
|
||||
async function ensureSchema(pool) {
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS canvas_sizes (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
width INT NOT NULL,
|
||||
height INT NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uq_canvas_sizes_dimensions (width, height)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
await addColumnIfMissing(pool, 'canvas_sizes', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
|
||||
await addColumnIfMissing(pool, 'canvas_sizes', 'created_by', 'INT NULL');
|
||||
await addColumnIfMissing(pool, 'canvas_sizes', 'modified_by', 'INT NULL');
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS playlists (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
fade_between_slides TINYINT(1) NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
await addColumnIfMissing(pool, 'playlists', 'fade_between_slides', 'TINYINT(1) NOT NULL DEFAULT 0');
|
||||
await addColumnIfMissing(pool, 'playlists', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
|
||||
await addColumnIfMissing(pool, 'playlists', 'created_by', 'INT NULL');
|
||||
await addColumnIfMissing(pool, 'playlists', 'modified_by', 'INT NULL');
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS slide_templates (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
canvas_size_id INT NULL,
|
||||
background_image_path VARCHAR(512) NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
|
||||
await addColumnIfMissing(pool, 'slide_templates', 'canvas_size_id', 'INT NULL');
|
||||
await addColumnIfMissing(pool, 'slide_templates', 'background_image_path', 'VARCHAR(512) NULL');
|
||||
await addColumnIfMissing(pool, 'slide_templates', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
|
||||
await addColumnIfMissing(pool, 'slide_templates', 'created_by', 'INT NULL');
|
||||
await addColumnIfMissing(pool, 'slide_templates', 'modified_by', 'INT NULL');
|
||||
|
||||
await pool.query(`
|
||||
INSERT IGNORE INTO canvas_sizes (name, width, height) VALUES
|
||||
('Full HD', 1920, 1080),
|
||||
('HD', 1280, 720),
|
||||
('4K UHD', 3840, 2160),
|
||||
('Portrait Full HD', 1080, 1920),
|
||||
('Portrait HD', 720, 1280)
|
||||
`);
|
||||
|
||||
const [legacyTemplateColumns] = await pool.query(`
|
||||
SELECT COUNT(*) AS column_count
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'slide_templates'
|
||||
AND column_name IN ('canvas_width', 'canvas_height')
|
||||
`);
|
||||
if (legacyTemplateColumns[0] && Number(legacyTemplateColumns[0].column_count) === 2) {
|
||||
await pool.query(`
|
||||
UPDATE slide_templates st
|
||||
JOIN canvas_sizes cs ON cs.width = st.canvas_width AND cs.height = st.canvas_height
|
||||
SET st.canvas_size_id = cs.id
|
||||
WHERE st.canvas_size_id IS NULL
|
||||
`);
|
||||
}
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS slide_template_regions (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
template_id INT NOT NULL,
|
||||
region_key VARCHAR(100) NOT NULL,
|
||||
region_type VARCHAR(20) NOT NULL,
|
||||
label VARCHAR(255) NOT NULL,
|
||||
x INT NOT NULL DEFAULT 0,
|
||||
y INT NOT NULL DEFAULT 0,
|
||||
width INT NOT NULL DEFAULT 100,
|
||||
height INT NOT NULL DEFAULT 100,
|
||||
z_index INT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
|
||||
await addColumnIfMissing(pool, 'slide_template_regions', 'font_family', 'VARCHAR(100) NULL');
|
||||
await addColumnIfMissing(pool, 'slide_template_regions', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
|
||||
await addColumnIfMissing(pool, 'slide_template_regions', 'created_by', 'INT NULL');
|
||||
await addColumnIfMissing(pool, 'slide_template_regions', 'modified_by', 'INT NULL');
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS slides (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
body TEXT NULL,
|
||||
template_id INT NULL,
|
||||
content_json JSON NULL,
|
||||
media_path VARCHAR(512) NULL,
|
||||
media_type VARCHAR(100) NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
|
||||
await addColumnIfMissing(pool, 'slides', 'body', 'TEXT NULL');
|
||||
await addColumnIfMissing(pool, 'slides', 'template_id', 'INT NULL');
|
||||
await addColumnIfMissing(pool, 'slides', 'content_json', 'JSON NULL');
|
||||
await addColumnIfMissing(pool, 'slides', 'media_path', 'VARCHAR(512) NULL');
|
||||
await addColumnIfMissing(pool, 'slides', 'media_type', 'VARCHAR(100) NULL');
|
||||
await addColumnIfMissing(pool, 'slides', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
|
||||
await addColumnIfMissing(pool, 'slides', 'created_by', 'INT NULL');
|
||||
await addColumnIfMissing(pool, 'slides', 'modified_by', 'INT NULL');
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS playlist_slides (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
playlist_id INT NOT NULL,
|
||||
slide_id INT NOT NULL,
|
||||
position INT NOT NULL DEFAULT 0,
|
||||
duration_seconds INT NOT NULL DEFAULT 10,
|
||||
schedule_mode VARCHAR(20) NOT NULL DEFAULT 'always',
|
||||
schedule_start_datetime DATETIME NULL,
|
||||
schedule_end_datetime DATETIME NULL,
|
||||
schedule_start_time TIME NULL,
|
||||
schedule_end_time TIME NULL,
|
||||
schedule_days_json JSON NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_playlist_slides_playlist FOREIGN KEY (playlist_id) REFERENCES playlists(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_playlist_slides_slide FOREIGN KEY (slide_id) REFERENCES slides(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
|
||||
await addColumnIfMissing(pool, 'playlist_slides', 'duration_seconds', 'INT NOT NULL DEFAULT 10');
|
||||
await addColumnIfMissing(pool, 'playlist_slides', 'schedule_mode', "VARCHAR(20) NOT NULL DEFAULT 'always'");
|
||||
await addColumnIfMissing(pool, 'playlist_slides', 'schedule_start_datetime', 'DATETIME NULL');
|
||||
await addColumnIfMissing(pool, 'playlist_slides', 'schedule_end_datetime', 'DATETIME NULL');
|
||||
await addColumnIfMissing(pool, 'playlist_slides', 'schedule_start_time', 'TIME NULL');
|
||||
await addColumnIfMissing(pool, 'playlist_slides', 'schedule_end_time', 'TIME NULL');
|
||||
await addColumnIfMissing(pool, 'playlist_slides', 'schedule_days_json', 'JSON NULL');
|
||||
await addColumnIfMissing(pool, 'playlist_slides', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
|
||||
await addColumnIfMissing(pool, 'playlist_slides', 'created_by', 'INT NULL');
|
||||
await addColumnIfMissing(pool, 'playlist_slides', 'modified_by', 'INT NULL');
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS screens (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
slug VARCHAR(255) NOT NULL UNIQUE,
|
||||
playlist_id INT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_screens_playlist FOREIGN KEY (playlist_id) REFERENCES playlists(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
|
||||
await addColumnIfMissing(pool, 'screens', 'playlist_id', 'INT NULL');
|
||||
await addColumnIfMissing(pool, 'screens', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
|
||||
await addColumnIfMissing(pool, 'screens', 'created_by', 'INT NULL');
|
||||
await addColumnIfMissing(pool, 'screens', 'modified_by', 'INT NULL');
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(255) NULL,
|
||||
username VARCHAR(255) NOT NULL UNIQUE,
|
||||
password_hash CHAR(64) NOT NULL,
|
||||
password_salt VARCHAR(64) NOT NULL,
|
||||
password_iterations INT NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
await addColumnIfMissing(pool, 'users', 'name', 'VARCHAR(255) NULL');
|
||||
await addColumnIfMissing(pool, 'users', 'password_hash', 'CHAR(64) NOT NULL');
|
||||
await addColumnIfMissing(pool, 'users', 'password_salt', 'VARCHAR(64) NOT NULL');
|
||||
await addColumnIfMissing(pool, 'users', 'password_iterations', 'INT NOT NULL');
|
||||
await addColumnIfMissing(pool, 'users', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
|
||||
await addColumnIfMissing(pool, 'users', 'created_by', 'INT NULL');
|
||||
await addColumnIfMissing(pool, 'users', 'modified_by', 'INT NULL');
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS auth_sessions (
|
||||
session_hash CHAR(64) PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
expires_at DATETIME NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_used_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_auth_sessions_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
await addColumnIfMissing(pool, 'auth_sessions', 'created_by', 'INT NULL');
|
||||
await addColumnIfMissing(pool, 'auth_sessions', 'modified_by', 'INT NULL');
|
||||
|
||||
const [userCountRows] = await pool.query('SELECT COUNT(*) AS user_count FROM users');
|
||||
if (!userCountRows.length || Number(userCountRows[0].user_count) === 0) {
|
||||
const username = String(process.env.DEFAULT_ADMIN_USERNAME || 'admin').trim() || 'admin';
|
||||
const name = String(process.env.DEFAULT_ADMIN_NAME || 'Admin').trim() || 'Admin';
|
||||
const password = String(process.env.DEFAULT_ADMIN_PASSWORD || 'admin').trim() || 'admin';
|
||||
const passwordRecord = hashPassword(password);
|
||||
await pool.query(
|
||||
'INSERT INTO users (name, username, password_hash, password_salt, password_iterations, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
[name, username, passwordRecord.hash, passwordRecord.salt, passwordRecord.iterations, null, null]
|
||||
);
|
||||
}
|
||||
|
||||
await pool.query('UPDATE users SET name = username WHERE name IS NULL OR name = ""');
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createPool,
|
||||
ensureSchema
|
||||
};
|
||||
Vendored
+84
@@ -0,0 +1,84 @@
|
||||
const { hashPassword } = require('#src/auth');
|
||||
const { PERMISSIONS, DEFAULT_ROLE } = require('#src/rbac');
|
||||
|
||||
async function bootstrapDatabase(pool) {
|
||||
const [canvasSizeCountRows] = await pool.query('SELECT COUNT(*) AS canvas_size_count FROM c_canvas_sizes');
|
||||
if (!canvasSizeCountRows.length || Number(canvasSizeCountRows[0].canvas_size_count) === 0) {
|
||||
await pool.query(`
|
||||
INSERT IGNORE INTO c_canvas_sizes (name, width, height) VALUES
|
||||
('Full HD', 1920, 1080),
|
||||
('HD', 1280, 720),
|
||||
('4K UHD', 3840, 2160),
|
||||
('Portrait Full HD', 1080, 1920),
|
||||
('Portrait HD', 720, 1280)
|
||||
`);
|
||||
}
|
||||
|
||||
for (const permission of PERMISSIONS) {
|
||||
await pool.query(
|
||||
`INSERT INTO a_permissions (permission_key, name, section_name, description, created_by, modified_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE name = VALUES(name), section_name = VALUES(section_name), description = VALUES(description), modified_by = VALUES(modified_by)` ,
|
||||
[permission.key, permission.name, permission.sectionName, permission.description || null, null, null]
|
||||
);
|
||||
}
|
||||
|
||||
const [userCountRows] = await pool.query('SELECT COUNT(*) AS user_count FROM a_users');
|
||||
const username = String(process.env.DEFAULT_ADMIN_USERNAME || 'admin').trim() || 'admin';
|
||||
if (!userCountRows.length || Number(userCountRows[0].user_count) === 0) {
|
||||
const name = String(process.env.DEFAULT_ADMIN_NAME || 'Admin').trim() || 'Admin';
|
||||
const password = String(process.env.DEFAULT_ADMIN_PASSWORD || 'admin').trim() || 'admin';
|
||||
const passwordRecord = hashPassword(password);
|
||||
await pool.query(
|
||||
'INSERT IGNORE INTO a_users (name, username, password_hash, password_salt, password_iterations, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
[name, username, passwordRecord.hash, passwordRecord.salt, passwordRecord.iterations, null, null]
|
||||
);
|
||||
}
|
||||
|
||||
await pool.query('UPDATE a_users SET name = username WHERE name IS NULL OR name = ""');
|
||||
|
||||
await pool.query(
|
||||
`INSERT INTO a_roles (role_key, name, description, created_by, modified_by)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE name = VALUES(name), description = VALUES(description), modified_by = VALUES(modified_by)`,
|
||||
[DEFAULT_ROLE.key, DEFAULT_ROLE.name, DEFAULT_ROLE.description || null, null, null]
|
||||
);
|
||||
|
||||
const [defaultRoleRows] = await pool.query('SELECT id FROM a_roles WHERE role_key = ? LIMIT 1', [DEFAULT_ROLE.key]);
|
||||
const defaultRoleId = defaultRoleRows.length ? Number(defaultRoleRows[0].id) : null;
|
||||
if (defaultRoleId) {
|
||||
await pool.query(
|
||||
`INSERT IGNORE INTO a_role_permissions (role_id, permission_id, created_by, modified_by)
|
||||
SELECT ?, id, NULL, NULL FROM a_permissions`,
|
||||
[defaultRoleId]
|
||||
);
|
||||
}
|
||||
|
||||
if (!userCountRows.length || Number(userCountRows[0].user_count) === 0) {
|
||||
if (defaultRoleId) {
|
||||
await pool.query(
|
||||
`INSERT IGNORE INTO a_user_roles (user_id, role_id, created_by, modified_by)
|
||||
SELECT id, ?, NULL, NULL FROM a_users`,
|
||||
[defaultRoleId]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const [defaultAdminRows] = await pool.query('SELECT id FROM a_users WHERE username = ? LIMIT 1', [username]);
|
||||
if (defaultAdminRows.length) {
|
||||
const defaultAdminId = Number(defaultAdminRows[0].id);
|
||||
const [defaultAdminRoleRows] = await pool.query('SELECT COUNT(*) AS role_count FROM a_user_roles WHERE user_id = ?', [defaultAdminId]);
|
||||
if (!defaultAdminRoleRows.length || Number(defaultAdminRoleRows[0].role_count) === 0) {
|
||||
if (defaultRoleId) {
|
||||
await pool.query(
|
||||
'INSERT IGNORE INTO a_user_roles (user_id, role_id, created_by, modified_by) VALUES (?, ?, ?, ?)',
|
||||
[defaultAdminId, defaultRoleId, null, null]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
bootstrapDatabase
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
const mysql = require('mysql2/promise');
|
||||
|
||||
function createPool() {
|
||||
return mysql.createPool({
|
||||
host: process.env.DB_HOST || '127.0.0.1',
|
||||
port: Number(process.env.DB_PORT || 3306),
|
||||
user: process.env.DB_USER || 'signage_user',
|
||||
password: process.env.DB_PASSWORD || 'signage_password',
|
||||
database: process.env.DB_NAME || 'signage',
|
||||
timezone: 'Z',
|
||||
waitForConnections: true,
|
||||
connectionLimit: 10,
|
||||
namedPlaceholders: true
|
||||
});
|
||||
}
|
||||
|
||||
async function pruneStaleOnboardingDevices(pool) {
|
||||
await pool.query(
|
||||
`DELETE FROM d_onboarding_devices
|
||||
WHERE modified_at < (CURRENT_TIMESTAMP - INTERVAL 1 MINUTE)`
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createPool,
|
||||
pruneStaleOnboardingDevices
|
||||
};
|
||||
+369
@@ -0,0 +1,369 @@
|
||||
// Snapshot only: keep this file aligned with the current schema state.
|
||||
async function ensureSchema(pool, options) {
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS c_canvas_sizes (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
width INT NOT NULL,
|
||||
height INT NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by INT NULL,
|
||||
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
modified_by INT NULL,
|
||||
UNIQUE KEY uq_canvas_sizes_dimensions (width, height)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS c_playlists (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
fade_between_slides TINYINT(1) NOT NULL DEFAULT 0,
|
||||
skip_unavailable_rtmp TINYINT(1) NOT NULL DEFAULT 0,
|
||||
canvas_id INT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by INT NULL,
|
||||
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
modified_by INT NULL,
|
||||
CONSTRAINT fk_playlists_canvas FOREIGN KEY (canvas_id) REFERENCES c_canvas_sizes(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS c_templates (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
canvas_size_id INT NULL,
|
||||
background_image_path VARCHAR(512) NULL,
|
||||
background_color VARCHAR(32) NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by INT NULL,
|
||||
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
modified_by INT NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS c_template_regions (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
template_id INT NOT NULL,
|
||||
region_key VARCHAR(100) NOT NULL,
|
||||
region_type VARCHAR(20) NOT NULL,
|
||||
label VARCHAR(255) NOT NULL,
|
||||
font_family VARCHAR(100) NULL,
|
||||
lock_ratio VARCHAR(20) NULL,
|
||||
animation_json JSON NULL,
|
||||
x INT NOT NULL DEFAULT 0,
|
||||
y INT NOT NULL DEFAULT 0,
|
||||
width INT NOT NULL DEFAULT 100,
|
||||
height INT NOT NULL DEFAULT 100,
|
||||
z_index INT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by INT NULL,
|
||||
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
modified_by INT NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS c_slides (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
template_id INT NULL,
|
||||
content_json JSON NULL,
|
||||
thumbnail_path VARCHAR(512) NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by INT NULL,
|
||||
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
modified_by INT NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS c_playlist_slides (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
playlist_id INT NOT NULL,
|
||||
slide_id INT NOT NULL,
|
||||
position INT NOT NULL DEFAULT 0,
|
||||
duration_seconds DECIMAL(10,3) NOT NULL DEFAULT 10.000,
|
||||
use_video_duration TINYINT(1) NOT NULL DEFAULT 0,
|
||||
disable_audio TINYINT(1) NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by INT NULL,
|
||||
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
modified_by INT NULL,
|
||||
CONSTRAINT fk_playlist_slides_playlist FOREIGN KEY (playlist_id) REFERENCES c_playlists(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_playlist_slides_slide FOREIGN KEY (slide_id) REFERENCES c_slides(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS c_playlist_slide_schedule_rules (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
playlist_slide_id INT NOT NULL,
|
||||
position INT NOT NULL DEFAULT 0,
|
||||
start_datetime DATETIME NULL,
|
||||
end_datetime DATETIME NULL,
|
||||
start_time TIME NULL,
|
||||
end_time TIME NULL,
|
||||
schedule_days_json JSON NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by INT NULL,
|
||||
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
modified_by INT NULL,
|
||||
CONSTRAINT fk_playlist_slide_schedule_rules_slide FOREIGN KEY (playlist_slide_id) REFERENCES c_playlist_slides(id) ON DELETE CASCADE,
|
||||
INDEX idx_playlist_slide_schedule_rules_slide_position (playlist_slide_id, position)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS d_screens (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
slug VARCHAR(255) NOT NULL UNIQUE,
|
||||
playlist_id INT NULL,
|
||||
player_id INT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by INT NULL,
|
||||
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
modified_by INT NULL,
|
||||
CONSTRAINT fk_screens_playlist FOREIGN KEY (playlist_id) REFERENCES c_playlists(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS d_announcements (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
message TEXT NOT NULL,
|
||||
short_label VARCHAR(255) NOT NULL DEFAULT '',
|
||||
announcement_type VARCHAR(32) NOT NULL DEFAULT 'lower-third',
|
||||
color_key VARCHAR(32) NOT NULL DEFAULT 'primary',
|
||||
icon_key VARCHAR(64) NOT NULL DEFAULT 'megaphone-fill',
|
||||
duration_seconds INT NULL,
|
||||
expires_at TIMESTAMP NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by INT NULL,
|
||||
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
modified_by INT NULL,
|
||||
INDEX idx_announcements_expires_at (expires_at),
|
||||
INDEX idx_announcements_modified_at (modified_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS d_announcement_screens (
|
||||
announcement_id INT NOT NULL,
|
||||
screen_id INT NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by INT NULL,
|
||||
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
modified_by INT NULL,
|
||||
PRIMARY KEY (announcement_id, screen_id),
|
||||
INDEX idx_announcement_screens_screen_id (screen_id),
|
||||
CONSTRAINT fk_announcement_screens_announcement FOREIGN KEY (announcement_id) REFERENCES d_announcements(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_announcement_screens_screen FOREIGN KEY (screen_id) REFERENCES d_screens(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS i_rss_feeds (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
feed_url VARCHAR(1024) NOT NULL,
|
||||
update_interval_value INT NOT NULL DEFAULT 60,
|
||||
update_interval_unit VARCHAR(10) NOT NULL DEFAULT 'minutes',
|
||||
item_limit INT NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by INT NULL,
|
||||
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
modified_by INT NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS i_rss_feed_items (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
rss_feed_id INT NOT NULL,
|
||||
position INT NOT NULL,
|
||||
item_json MEDIUMTEXT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by INT NULL,
|
||||
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
modified_by INT NULL,
|
||||
CONSTRAINT fk_rss_feed_items_rss_feed FOREIGN KEY (rss_feed_id) REFERENCES i_rss_feeds(id) ON DELETE CASCADE,
|
||||
UNIQUE KEY uq_rss_feed_items_feed_position (rss_feed_id, position)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS i_api_sources (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
api_url VARCHAR(1024) NOT NULL,
|
||||
update_interval_value INT NOT NULL DEFAULT 60,
|
||||
update_interval_unit VARCHAR(10) NOT NULL DEFAULT 'minutes',
|
||||
last_pulled_at TIMESTAMP NULL,
|
||||
last_pull_error MEDIUMTEXT NULL,
|
||||
last_response_status INT NULL,
|
||||
last_response_content_type VARCHAR(255) NULL,
|
||||
last_response_json MEDIUMTEXT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by INT NULL,
|
||||
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
modified_by INT NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS i_schedule_groups (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
short_description VARCHAR(255) NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by INT NULL,
|
||||
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
modified_by INT NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS i_schedule_entries (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
schedule_group_id INT NOT NULL,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
short_description VARCHAR(255) NULL,
|
||||
start_datetime DATETIME NOT NULL,
|
||||
end_datetime DATETIME NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by INT NULL,
|
||||
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
modified_by INT NULL,
|
||||
CONSTRAINT fk_schedule_entries_group FOREIGN KEY (schedule_group_id) REFERENCES i_schedule_groups(id) ON DELETE CASCADE,
|
||||
INDEX idx_schedule_entries_group_start (schedule_group_id, start_datetime)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS d_onboarding_devices (
|
||||
device_id VARCHAR(128) PRIMARY KEY,
|
||||
client_name VARCHAR(255) NULL,
|
||||
screen_id INT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by INT NULL,
|
||||
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
modified_by INT NULL,
|
||||
CONSTRAINT fk_player_onboarding_devices_screen FOREIGN KEY (screen_id) REFERENCES d_screens(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS a_users (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(255) NULL,
|
||||
username VARCHAR(255) NOT NULL UNIQUE,
|
||||
password_hash CHAR(64) NOT NULL,
|
||||
password_salt VARCHAR(64) NOT NULL,
|
||||
password_iterations INT NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by INT NULL,
|
||||
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
modified_by INT NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS a_roles (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
role_key VARCHAR(100) NOT NULL UNIQUE,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by INT NULL,
|
||||
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
modified_by INT NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS a_permissions (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
permission_key VARCHAR(100) NOT NULL UNIQUE,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
section_name VARCHAR(255) NOT NULL,
|
||||
description TEXT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by INT NULL,
|
||||
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
modified_by INT NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS a_role_permissions (
|
||||
role_id INT NOT NULL,
|
||||
permission_id INT NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by INT NULL,
|
||||
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
modified_by INT NULL,
|
||||
PRIMARY KEY (role_id, permission_id),
|
||||
CONSTRAINT fk_role_permissions_role FOREIGN KEY (role_id) REFERENCES a_roles(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_role_permissions_permission FOREIGN KEY (permission_id) REFERENCES a_permissions(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS a_user_roles (
|
||||
user_id INT NOT NULL,
|
||||
role_id INT NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by INT NULL,
|
||||
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
modified_by INT NULL,
|
||||
PRIMARY KEY (user_id, role_id),
|
||||
CONSTRAINT fk_user_roles_user FOREIGN KEY (user_id) REFERENCES a_users(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_user_roles_role FOREIGN KEY (role_id) REFERENCES a_roles(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS a_sessions (
|
||||
session_hash CHAR(64) PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
expires_at DATETIME NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by INT NULL,
|
||||
last_used_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
modified_by INT NULL,
|
||||
CONSTRAINT fk_auth_sessions_user FOREIGN KEY (user_id) REFERENCES a_users(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS o_background_tasks (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
task_key VARCHAR(191) NULL,
|
||||
task_type VARCHAR(100) NOT NULL,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
category VARCHAR(100) NOT NULL DEFAULT 'general',
|
||||
status VARCHAR(20) NOT NULL,
|
||||
payload_json MEDIUMTEXT NULL,
|
||||
metadata_json MEDIUMTEXT NULL,
|
||||
attempts INT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
started_at TIMESTAMP NULL,
|
||||
finished_at TIMESTAMP NULL,
|
||||
error_message MEDIUMTEXT NULL,
|
||||
INDEX idx_background_tasks_status (status),
|
||||
INDEX idx_background_tasks_key (task_key),
|
||||
INDEX idx_background_tasks_type (task_type)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
|
||||
const { runMigrations } = require('./migrations');
|
||||
await runMigrations(pool, options);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ensureSchema,
|
||||
};
|
||||
@@ -0,0 +1,457 @@
|
||||
const { version: appVersion } = require('#root/package.json');
|
||||
|
||||
const VERSIONED_MIGRATIONS = [
|
||||
{
|
||||
version: '2.1.0',
|
||||
label: 'v2.1.0 player registry schema',
|
||||
run: async function (pool) {
|
||||
// Keep the player registry as the source of truth for device metadata.
|
||||
if (!(await columnExists(pool, 'd_players', 'public_base_url'))) {
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS d_players (
|
||||
device_id VARCHAR(128) PRIMARY KEY,
|
||||
public_base_url VARCHAR(512) NULL,
|
||||
internal_base_url VARCHAR(512) NULL,
|
||||
last_seen_at TIMESTAMP NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
}
|
||||
|
||||
// Seed the singleton player row used by the current one-player model.
|
||||
// For multi-player support, this seed and the hardcoded screen_id/player_id mapping will need to be replaced.
|
||||
await pool.query(`INSERT IGNORE INTO d_players (device_id) VALUES ('1')`);
|
||||
|
||||
// Store the player pointer on screens so we can resolve the player without needing a player-side screen_id.
|
||||
// This is the singleton-player shortcut; a multi-player model should make this relational instead of hardcoded to '1'.
|
||||
if (!(await columnExists(pool, 'd_screens', 'player_id'))) {
|
||||
await pool.query("ALTER TABLE d_screens ADD COLUMN player_id VARCHAR(128) NOT NULL DEFAULT '1' AFTER playlist_id");
|
||||
} else {
|
||||
await pool.query("UPDATE d_screens SET player_id = '1' WHERE player_id IS NULL OR player_id <> '1'");
|
||||
|
||||
const [playerColumnNullableRows] = await pool.query(
|
||||
`SELECT COUNT(*) AS nullable_count
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'd_screens'
|
||||
AND COLUMN_NAME = 'player_id'
|
||||
AND IS_NULLABLE = 'YES'`
|
||||
);
|
||||
if (Number(playerColumnNullableRows && playerColumnNullableRows[0] && playerColumnNullableRows[0].nullable_count) > 0) {
|
||||
await pool.query("ALTER TABLE d_screens MODIFY COLUMN player_id VARCHAR(128) NOT NULL DEFAULT '1' AFTER playlist_id");
|
||||
}
|
||||
}
|
||||
|
||||
const [screenPlayerUniqueRows] = await pool.query(
|
||||
`SELECT COUNT(*) AS index_count
|
||||
FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'd_screens'
|
||||
AND INDEX_NAME = 'uq_screens_player_id'`
|
||||
);
|
||||
if (Number(screenPlayerUniqueRows && screenPlayerUniqueRows[0] && screenPlayerUniqueRows[0].index_count) > 0) {
|
||||
await pool.query('ALTER TABLE d_screens DROP INDEX uq_screens_player_id');
|
||||
}
|
||||
|
||||
// Recreate the screen-to-player foreign key after the column exists and legacy data is copied over.
|
||||
await ensureForeignKey(pool, 'd_screens', 'fk_screens_player', 'player_id', 'd_players', 'device_id', 'RESTRICT');
|
||||
|
||||
if (await columnExists(pool, 'c_template_regions', 'font_family')) {
|
||||
await pool.query('ALTER TABLE c_template_regions DROP COLUMN font_family');
|
||||
}
|
||||
|
||||
}
|
||||
},
|
||||
{
|
||||
version: '2.1.1',
|
||||
label: 'v2.1.1 API source schema',
|
||||
run: async function (pool) {
|
||||
await ensureColumn(pool, 'i_api_sources', 'auth_method', "VARCHAR(32) NOT NULL DEFAULT 'none'", 'api_url');
|
||||
await ensureColumn(pool, 'i_api_sources', 'auth_username', 'VARCHAR(255) NULL', 'auth_method');
|
||||
await ensureColumn(pool, 'i_api_sources', 'auth_password', 'MEDIUMTEXT NULL', 'auth_username');
|
||||
await ensureColumn(pool, 'i_api_sources', 'auth_bearer_token', 'MEDIUMTEXT NULL', 'auth_password');
|
||||
await ensureColumn(pool, 'i_api_sources', 'auth_header_name', 'VARCHAR(255) NULL', 'auth_bearer_token');
|
||||
await ensureColumn(pool, 'i_api_sources', 'auth_header_value', 'MEDIUMTEXT NULL', 'auth_header_name');
|
||||
await ensureColumn(pool, 'i_api_sources', 'items_path', 'VARCHAR(255) NULL', 'auth_header_value');
|
||||
}
|
||||
},
|
||||
{
|
||||
version: '2.2.0',
|
||||
label: 'v2.2.0 announcement schema',
|
||||
run: async function (pool) {
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS d_announcements (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
message TEXT NOT NULL,
|
||||
short_label VARCHAR(255) NOT NULL DEFAULT '',
|
||||
announcement_type VARCHAR(32) NOT NULL DEFAULT 'lower-third',
|
||||
color_key VARCHAR(32) NOT NULL DEFAULT 'primary',
|
||||
icon_key VARCHAR(64) NOT NULL DEFAULT 'megaphone-fill',
|
||||
duration_seconds INT NULL,
|
||||
expires_at TIMESTAMP NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by INT NULL,
|
||||
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
modified_by INT NULL,
|
||||
INDEX idx_announcements_expires_at (expires_at),
|
||||
INDEX idx_announcements_modified_at (modified_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
}
|
||||
},
|
||||
{
|
||||
version: '2.4.0',
|
||||
label: 'v2.4.0 playlist schedule rules schema',
|
||||
run: async function (pool) {
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS c_playlist_slide_schedule_rules (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
playlist_slide_id INT NOT NULL,
|
||||
position INT NOT NULL DEFAULT 0,
|
||||
start_datetime DATETIME NULL,
|
||||
end_datetime DATETIME NULL,
|
||||
start_time TIME NULL,
|
||||
end_time TIME NULL,
|
||||
schedule_days_json JSON NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by INT NULL,
|
||||
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
modified_by INT NULL,
|
||||
CONSTRAINT fk_playlist_slide_schedule_rules_slide FOREIGN KEY (playlist_slide_id) REFERENCES c_playlist_slides(id) ON DELETE CASCADE,
|
||||
INDEX idx_playlist_slide_schedule_rules_slide_position (playlist_slide_id, position)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
|
||||
const legacyColumnNames = [
|
||||
'schedule_mode',
|
||||
'schedule_start_datetime',
|
||||
'schedule_end_datetime',
|
||||
'schedule_start_time',
|
||||
'schedule_end_time',
|
||||
'schedule_days_json',
|
||||
'schedule_rules_json'
|
||||
];
|
||||
const existingLegacyColumns = [];
|
||||
for (const columnName of legacyColumnNames) {
|
||||
if (await columnExists(pool, 'c_playlist_slides', columnName)) {
|
||||
existingLegacyColumns.push(columnName);
|
||||
}
|
||||
}
|
||||
|
||||
const selectColumns = ['id'].concat(existingLegacyColumns).join(', ');
|
||||
const [legacyRows] = await pool.query('SELECT ' + selectColumns + ' FROM c_playlist_slides');
|
||||
|
||||
for (const row of legacyRows) {
|
||||
const rules = normalizeLegacyScheduleRules(row);
|
||||
if (rules.length) {
|
||||
for (let index = 0; index < rules.length; index += 1) {
|
||||
const rule = rules[index];
|
||||
await pool.query(
|
||||
'INSERT INTO c_playlist_slide_schedule_rules (playlist_slide_id, position, start_datetime, end_datetime, start_time, end_time, schedule_days_json, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, NULL, NULL)',
|
||||
[row.id, index, rule.start_datetime || null, rule.end_datetime || null, rule.start_time || null, rule.end_time || null, rule.schedule_days_json ? JSON.stringify(rule.schedule_days_json) : null]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await dropColumnIfExists(pool, 'c_playlist_slides', 'schedule_rules_json');
|
||||
await dropColumnIfExists(pool, 'c_playlist_slides', 'schedule_mode');
|
||||
await dropColumnIfExists(pool, 'c_playlist_slides', 'schedule_start_datetime');
|
||||
await dropColumnIfExists(pool, 'c_playlist_slides', 'schedule_end_datetime');
|
||||
await dropColumnIfExists(pool, 'c_playlist_slides', 'schedule_start_time');
|
||||
await dropColumnIfExists(pool, 'c_playlist_slides', 'schedule_end_time');
|
||||
await dropColumnIfExists(pool, 'c_playlist_slides', 'schedule_days_json');
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS d_announcement_screens (
|
||||
announcement_id INT NOT NULL,
|
||||
screen_id INT NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by INT NULL,
|
||||
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
modified_by INT NULL,
|
||||
PRIMARY KEY (announcement_id, screen_id),
|
||||
INDEX idx_announcement_screens_screen_id (screen_id),
|
||||
CONSTRAINT fk_announcement_screens_announcement FOREIGN KEY (announcement_id) REFERENCES d_announcements(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_announcement_screens_screen FOREIGN KEY (screen_id) REFERENCES d_screens(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
}
|
||||
},
|
||||
{
|
||||
version: '2.4.2',
|
||||
label: 'v2.4.2 playlist canvas id schema',
|
||||
run: async function (pool) {
|
||||
await ensureColumn(pool, 'c_playlists', 'canvas_id', 'INT NULL', 'skip_unavailable_rtmp');
|
||||
await ensureForeignKey(pool, 'c_playlists', 'fk_playlists_canvas', 'canvas_id', 'c_canvas_sizes', 'id', 'SET NULL');
|
||||
await ensureColumn(pool, 'c_playlist_slides', 'disable_audio', 'TINYINT(1) NOT NULL DEFAULT 1', 'use_video_duration');
|
||||
await pool.query(`
|
||||
UPDATE c_playlists p
|
||||
JOIN (
|
||||
SELECT ps.playlist_id,
|
||||
COUNT(DISTINCT CONCAT(cs.width, 'x', cs.height)) AS signature_count,
|
||||
MIN(CONCAT(cs.width, 'x', cs.height)) AS canvas_signature
|
||||
FROM c_playlist_slides ps
|
||||
JOIN c_slides sl ON sl.id = ps.slide_id
|
||||
LEFT JOIN c_templates st ON st.id = sl.template_id
|
||||
LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id
|
||||
WHERE cs.width IS NOT NULL
|
||||
AND cs.height IS NOT NULL
|
||||
GROUP BY ps.playlist_id
|
||||
) x ON x.playlist_id = p.id
|
||||
JOIN c_canvas_sizes cs ON CONCAT(cs.width, 'x', cs.height) = x.canvas_signature
|
||||
SET p.canvas_id = cs.id
|
||||
WHERE (p.canvas_id IS NULL)
|
||||
AND x.signature_count = 1
|
||||
`);
|
||||
}
|
||||
},
|
||||
{
|
||||
version: '2.5.1',
|
||||
label: 'v2.5.1 template animation json schema',
|
||||
run: async function (pool) {
|
||||
await ensureColumn(pool, 'c_template_regions', 'animation_json', 'JSON NULL', 'lock_ratio');
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
async function columnExists(pool, tableName, columnName) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT COUNT(*) AS column_count
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = ?
|
||||
AND COLUMN_NAME = ?`,
|
||||
[tableName, columnName]
|
||||
);
|
||||
|
||||
return Number(rows && rows[0] && rows[0].column_count) > 0;
|
||||
}
|
||||
|
||||
async function ensureColumn(pool, tableName, columnName, columnDefinition, afterColumn) {
|
||||
if (await columnExists(pool, tableName, columnName)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const afterClause = afterColumn ? ' AFTER ' + afterColumn : '';
|
||||
try {
|
||||
await pool.query('ALTER TABLE ' + tableName + ' ADD COLUMN ' + columnName + ' ' + columnDefinition + afterClause);
|
||||
} catch (error) {
|
||||
if (!error || (error.code !== 'ER_DUP_FIELDNAME' && error.errno !== 1060)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureForeignKey(pool, tableName, constraintName, columnName, referencedTable, referencedColumn, onDeleteAction) {
|
||||
const [existingFkRows] = await pool.query(
|
||||
`SELECT COUNT(*) AS fk_count
|
||||
FROM information_schema.KEY_COLUMN_USAGE
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = ?
|
||||
AND COLUMN_NAME = ?
|
||||
AND REFERENCED_TABLE_NAME IS NOT NULL`,
|
||||
[tableName, columnName]
|
||||
);
|
||||
|
||||
if (Number(existingFkRows && existingFkRows[0] && existingFkRows[0].fk_count) > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [rows] = await pool.query(
|
||||
`SELECT COUNT(*) AS fk_count
|
||||
FROM information_schema.KEY_COLUMN_USAGE
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND CONSTRAINT_NAME = ?`,
|
||||
[constraintName]
|
||||
);
|
||||
|
||||
if (Number(rows && rows[0] && rows[0].fk_count) > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fallbackNames = [
|
||||
constraintName,
|
||||
tableName + '_' + columnName + '_fk',
|
||||
tableName + '_' + columnName + '_fk_2',
|
||||
tableName + '_' + columnName + '_fk_3'
|
||||
];
|
||||
|
||||
for (const candidateName of fallbackNames) {
|
||||
try {
|
||||
await pool.query('ALTER TABLE ' + tableName + ' ADD CONSTRAINT ' + candidateName + ' FOREIGN KEY (' + columnName + ') REFERENCES ' + referencedTable + '(' + referencedColumn + ') ON DELETE ' + onDeleteAction);
|
||||
return;
|
||||
} catch (error) {
|
||||
if (!error || (error.code !== 'ER_FK_DUP_NAME' && error.errno !== 1826)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function dropColumnIfExists(pool, tableName, columnName) {
|
||||
if (await columnExists(pool, tableName, columnName)) {
|
||||
try {
|
||||
await pool.query('ALTER TABLE ' + tableName + ' DROP COLUMN ' + columnName);
|
||||
} catch (error) {
|
||||
if (!error || (error.code !== 'ER_CANT_DROP_FIELD_OR_KEY' && error.errno !== 1091)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeLegacyScheduleRules(row) {
|
||||
const rules = [];
|
||||
if (!row) {
|
||||
return rules;
|
||||
}
|
||||
|
||||
if (row.schedule_rules_json) {
|
||||
try {
|
||||
const parsed = JSON.parse(row.schedule_rules_json);
|
||||
if (Array.isArray(parsed)) {
|
||||
parsed.forEach(function (rule) {
|
||||
const normalized = normalizeScheduleRule(rule);
|
||||
if (normalized) {
|
||||
rules.push(normalized);
|
||||
}
|
||||
});
|
||||
return rules;
|
||||
}
|
||||
} catch (_error) {
|
||||
// fall through to legacy columns
|
||||
}
|
||||
}
|
||||
|
||||
const mode = String(row.schedule_mode || 'always').trim().toLowerCase();
|
||||
if (mode === 'dates' && row.schedule_start_datetime && row.schedule_end_datetime) {
|
||||
rules.push({
|
||||
start_datetime: formatMigrationDateTime(row.schedule_start_datetime),
|
||||
end_datetime: formatMigrationDateTime(row.schedule_end_datetime)
|
||||
});
|
||||
return rules;
|
||||
}
|
||||
|
||||
if (mode === 'times' && row.schedule_start_time && row.schedule_end_time) {
|
||||
const days = normalizeMigrationDays(row.schedule_days_json);
|
||||
const rule = {
|
||||
start_time: formatMigrationTime(row.schedule_start_time),
|
||||
end_time: formatMigrationTime(row.schedule_end_time)
|
||||
};
|
||||
if (days.length) {
|
||||
rule.schedule_days_json = days;
|
||||
}
|
||||
rules.push(rule);
|
||||
}
|
||||
|
||||
return rules;
|
||||
}
|
||||
|
||||
function normalizeScheduleRule(rule) {
|
||||
if (!rule || typeof rule !== 'object') {
|
||||
return null;
|
||||
}
|
||||
const normalized = {};
|
||||
if (rule.start_datetime) {
|
||||
normalized.start_datetime = String(rule.start_datetime);
|
||||
}
|
||||
if (rule.end_datetime) {
|
||||
normalized.end_datetime = String(rule.end_datetime);
|
||||
}
|
||||
if (rule.start_time) {
|
||||
normalized.start_time = String(rule.start_time).slice(0, 5);
|
||||
}
|
||||
if (rule.end_time) {
|
||||
normalized.end_time = String(rule.end_time).slice(0, 5);
|
||||
}
|
||||
if (Array.isArray(rule.schedule_days_json)) {
|
||||
normalized.schedule_days_json = rule.schedule_days_json;
|
||||
} else if (Array.isArray(rule.days)) {
|
||||
normalized.schedule_days_json = rule.days;
|
||||
}
|
||||
return Object.keys(normalized).length ? normalized : null;
|
||||
}
|
||||
|
||||
function formatMigrationDateTime(value) {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return null;
|
||||
}
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
return year + '-' + month + '-' + day + 'T' + hours + ':' + minutes;
|
||||
}
|
||||
|
||||
function formatMigrationTime(value) {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
return String(value).slice(0, 5);
|
||||
}
|
||||
|
||||
function normalizeMigrationDays(value) {
|
||||
let parsed = [];
|
||||
if (Array.isArray(value)) {
|
||||
parsed = value;
|
||||
} else if (value) {
|
||||
try {
|
||||
const raw = typeof value === 'string' ? JSON.parse(value) : value;
|
||||
parsed = Array.isArray(raw) ? raw : [];
|
||||
} catch (_error) {
|
||||
parsed = [];
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(new Set(parsed.map(function (day) {
|
||||
return Number(day);
|
||||
}).filter(function (day) {
|
||||
return Number.isInteger(day) && day >= 0 && day <= 6;
|
||||
}))).sort(function (left, right) {
|
||||
return left - right;
|
||||
});
|
||||
}
|
||||
|
||||
function compareVersions(leftVersion, rightVersion) {
|
||||
const leftParts = String(leftVersion || '0.0.0').split('.').map(function (value) {
|
||||
return Number(value) || 0;
|
||||
});
|
||||
const rightParts = String(rightVersion || '0.0.0').split('.').map(function (value) {
|
||||
return Number(value) || 0;
|
||||
});
|
||||
const length = Math.max(leftParts.length, rightParts.length);
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
const leftPart = leftParts[index] || 0;
|
||||
const rightPart = rightParts[index] || 0;
|
||||
if (leftPart > rightPart) {
|
||||
return 1;
|
||||
}
|
||||
if (leftPart < rightPart) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function runMigrations(pool, options) {
|
||||
// Only run migrations that are newer than the installed schema version and not beyond the app version.
|
||||
const targetVersion = String(appVersion || '0.0.0').trim();
|
||||
const currentVersion = String(options && options.currentVersion || '0.0.0').trim();
|
||||
|
||||
for (const migration of VERSIONED_MIGRATIONS) {
|
||||
if (compareVersions(migration.version, currentVersion) > 0 && compareVersions(migration.version, targetVersion) <= 0) {
|
||||
await migration.run(pool);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
appVersion: appVersion,
|
||||
runMigrations: runMigrations
|
||||
};
|
||||
+88
-584
@@ -1,481 +1,65 @@
|
||||
const express = require('express');
|
||||
const fs = require('fs');
|
||||
const http = require('http');
|
||||
const crypto = require('crypto');
|
||||
const path = require('path');
|
||||
const { WebSocketServer, WebSocket } = require('ws');
|
||||
const common = require('./common');
|
||||
|
||||
// Playlist assembly and revision helpers.
|
||||
async function buildScreenPlaylist(pool, slug) {
|
||||
const [screenRows] = await pool.query('SELECT id, name, slug, playlist_id, created_at, modified_at, created_by, modified_by FROM screens WHERE slug = ?', [slug]);
|
||||
if (!screenRows.length) {
|
||||
return { screen: null, playlist: null, slides: [] };
|
||||
}
|
||||
|
||||
const screen = screenRows[0];
|
||||
if (!screen.playlist_id) {
|
||||
return {
|
||||
screen,
|
||||
playlist: null,
|
||||
slides: [],
|
||||
revision: getPlaylistRevision(screen, null, [], [], [])
|
||||
};
|
||||
}
|
||||
|
||||
const [playlistRows] = await pool.query('SELECT id, name, fade_between_slides, created_at, modified_at, created_by, modified_by FROM playlists WHERE id = ?', [screen.playlist_id]);
|
||||
const playlist = playlistRows[0] || null;
|
||||
const [slideRows] = await pool.query(`
|
||||
SELECT sl.id, sl.title, sl.body, sl.template_id, sl.content_json, sl.media_path, sl.media_type, sl.created_at, sl.modified_at,
|
||||
ps.position, ps.duration_seconds AS duration_seconds, ps.schedule_mode, ps.schedule_start_datetime, ps.schedule_end_datetime, ps.schedule_start_time, ps.schedule_end_time, ps.schedule_days_json,
|
||||
st.name AS template_name, cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height, cs.width AS canvas_width, cs.height AS canvas_height
|
||||
FROM playlist_slides ps
|
||||
JOIN slides sl ON sl.id = ps.slide_id
|
||||
LEFT JOIN slide_templates st ON st.id = sl.template_id
|
||||
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id
|
||||
WHERE ps.playlist_id = ?
|
||||
ORDER BY ps.position ASC, ps.id ASC
|
||||
`, [screen.playlist_id]);
|
||||
|
||||
const templateIds = slideRows
|
||||
.filter(function (slide) { return slide.template_id; })
|
||||
.map(function (slide) { return slide.template_id; });
|
||||
const templatesById = {};
|
||||
let templateRows = [];
|
||||
let regionRows = [];
|
||||
if (templateIds.length) {
|
||||
[templateRows] = await pool.query(`
|
||||
SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, st.created_at, st.modified_at,
|
||||
cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height, cs.width AS canvas_width, cs.height AS canvas_height
|
||||
FROM slide_templates st
|
||||
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id
|
||||
WHERE st.id IN (?)
|
||||
`, [templateIds]);
|
||||
[regionRows] = await pool.query('SELECT id, template_id, region_key, region_type, label, font_family, x, y, width, height, z_index, created_at, modified_at, created_by, modified_by FROM slide_template_regions WHERE template_id IN (?) ORDER BY template_id ASC, z_index ASC, id ASC', [templateIds]);
|
||||
templateRows.forEach(function (template) {
|
||||
template.regions = regionRows.filter(function (region) { return region.template_id === template.id; });
|
||||
templatesById[template.id] = template;
|
||||
});
|
||||
}
|
||||
|
||||
const slides = slideRows.map(function (slide) {
|
||||
return {
|
||||
id: slide.id,
|
||||
title: slide.title,
|
||||
body: slide.body,
|
||||
duration_seconds: slide.duration_seconds,
|
||||
schedule_mode: slide.schedule_mode,
|
||||
schedule_start_datetime: slide.schedule_start_datetime,
|
||||
schedule_end_datetime: slide.schedule_end_datetime,
|
||||
schedule_start_time: slide.schedule_start_time,
|
||||
schedule_end_time: slide.schedule_end_time,
|
||||
schedule_days_json: slide.schedule_days_json,
|
||||
media_url: slide.media_path,
|
||||
media_type: slide.media_type,
|
||||
kind: common.mediaKind(slide.media_path),
|
||||
template_id: slide.template_id,
|
||||
template: slide.template_id ? templatesById[slide.template_id] || null : null,
|
||||
content: common.parseJsonSafe(slide.content_json) || {}
|
||||
};
|
||||
});
|
||||
|
||||
const revision = getPlaylistRevision(screen, playlist, slideRows, templateRows, regionRows);
|
||||
|
||||
return { screen, playlist, slides, revision };
|
||||
}
|
||||
|
||||
function updatePlaylistRevisionHash(hash, value) {
|
||||
hash.update(String(value === null || value === undefined ? '' : value));
|
||||
hash.update('\0');
|
||||
}
|
||||
|
||||
function getPlaylistRevision(screen, playlist, slideRows, templateRows, regionRows) {
|
||||
const hash = crypto.createHash('sha1');
|
||||
|
||||
updatePlaylistRevisionHash(hash, screen && screen.id);
|
||||
updatePlaylistRevisionHash(hash, screen && screen.playlist_id);
|
||||
updatePlaylistRevisionHash(hash, screen && screen.modified_at);
|
||||
|
||||
updatePlaylistRevisionHash(hash, playlist && playlist.id);
|
||||
updatePlaylistRevisionHash(hash, playlist && playlist.modified_at);
|
||||
updatePlaylistRevisionHash(hash, playlist && playlist.fade_between_slides);
|
||||
|
||||
(Array.isArray(slideRows) ? slideRows : []).forEach(function (slide) {
|
||||
updatePlaylistRevisionHash(hash, slide.id);
|
||||
updatePlaylistRevisionHash(hash, slide.title);
|
||||
updatePlaylistRevisionHash(hash, slide.body);
|
||||
updatePlaylistRevisionHash(hash, slide.template_id);
|
||||
updatePlaylistRevisionHash(hash, slide.content_json);
|
||||
updatePlaylistRevisionHash(hash, slide.media_path);
|
||||
updatePlaylistRevisionHash(hash, slide.media_type);
|
||||
updatePlaylistRevisionHash(hash, slide.modified_at);
|
||||
updatePlaylistRevisionHash(hash, slide.position);
|
||||
updatePlaylistRevisionHash(hash, slide.duration_seconds);
|
||||
updatePlaylistRevisionHash(hash, slide.schedule_mode);
|
||||
updatePlaylistRevisionHash(hash, slide.schedule_start_datetime);
|
||||
updatePlaylistRevisionHash(hash, slide.schedule_end_datetime);
|
||||
updatePlaylistRevisionHash(hash, slide.schedule_start_time);
|
||||
updatePlaylistRevisionHash(hash, slide.schedule_end_time);
|
||||
updatePlaylistRevisionHash(hash, slide.schedule_days_json);
|
||||
});
|
||||
|
||||
(Array.isArray(templateRows) ? templateRows : []).forEach(function (template) {
|
||||
updatePlaylistRevisionHash(hash, template.id);
|
||||
updatePlaylistRevisionHash(hash, template.name);
|
||||
updatePlaylistRevisionHash(hash, template.canvas_size_id);
|
||||
updatePlaylistRevisionHash(hash, template.canvas_size_width);
|
||||
updatePlaylistRevisionHash(hash, template.canvas_size_height);
|
||||
updatePlaylistRevisionHash(hash, template.background_image_path);
|
||||
updatePlaylistRevisionHash(hash, template.modified_at);
|
||||
});
|
||||
|
||||
(Array.isArray(regionRows) ? regionRows : []).forEach(function (region) {
|
||||
updatePlaylistRevisionHash(hash, region.id);
|
||||
updatePlaylistRevisionHash(hash, region.template_id);
|
||||
updatePlaylistRevisionHash(hash, region.region_key);
|
||||
updatePlaylistRevisionHash(hash, region.region_type);
|
||||
updatePlaylistRevisionHash(hash, region.label);
|
||||
updatePlaylistRevisionHash(hash, region.font_family);
|
||||
updatePlaylistRevisionHash(hash, region.x);
|
||||
updatePlaylistRevisionHash(hash, region.y);
|
||||
updatePlaylistRevisionHash(hash, region.width);
|
||||
updatePlaylistRevisionHash(hash, region.height);
|
||||
updatePlaylistRevisionHash(hash, region.z_index);
|
||||
updatePlaylistRevisionHash(hash, region.modified_at);
|
||||
});
|
||||
|
||||
return hash.digest('hex');
|
||||
}
|
||||
const { createPlayerRuntime } = require('./player/runtime');
|
||||
const { createPlayerPlaylistService } = require('./player/playlist');
|
||||
const { createRtmpStreamService } = require('./player/modules/rtmp-streams');
|
||||
const { normalizeDeviceId, registerPlayerOnboardingRoutes, commitDeviceBinding } = require('./player/onboarding');
|
||||
const { createOnboardingStore } = require('./player/onboarding/store');
|
||||
const { registerPlayerRoutes } = require('./player/routes');
|
||||
const { ensureFontLibrary } = require('#src/web/lib/media/font-library');
|
||||
|
||||
|
||||
// Player runtime, upload API, and websocket wiring.
|
||||
// Player runtime, media API, and websocket wiring.
|
||||
async function start() {
|
||||
const app = express();
|
||||
const pool = common.createPool();
|
||||
const PORT = Number(process.env.PLAYER_PORT || 3001);
|
||||
const PORT = Number(process.env.PLAYER_PORT || 8081);
|
||||
const PLAYER_PUBLIC_BASE_URL = String(process.env.PLAYER_PUBLIC_BASE_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, '');
|
||||
const PLAYER_INTERNAL_BASE_URL = String(process.env.PLAYER_INTERNAL_BASE_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, '');
|
||||
const PLAYER_IDENTIFIER = '1';
|
||||
const ASSET_DIR = path.join(__dirname, 'player', 'public');
|
||||
const UPLOAD_DIR = path.join(__dirname, '..', 'uploads');
|
||||
const connectionsBySlug = new Map();
|
||||
const dashboardListenersBySlug = new Map();
|
||||
const MEDIA_DIR = path.join(__dirname, '..', 'media');
|
||||
const ONBOARDING_QUEUE_FILE = path.join(MEDIA_DIR, 'player-onboarding-queue.json');
|
||||
const DB_SYNC_INTERVAL_MS = Number(process.env.PLAYER_DB_SYNC_INTERVAL_MS || 15000);
|
||||
const onboardingStore = createOnboardingStore(ONBOARDING_QUEUE_FILE);
|
||||
const playerRuntime = createPlayerRuntime({
|
||||
pool: pool,
|
||||
normalizeDeviceId: normalizeDeviceId
|
||||
});
|
||||
const playerPlaylistService = createPlayerPlaylistService({
|
||||
pool: pool,
|
||||
common: common,
|
||||
snapshotDir: path.join(MEDIA_DIR, 'player-cache', 'screen-playlists')
|
||||
});
|
||||
const rtmpStreamService = createRtmpStreamService({
|
||||
mediaDir: MEDIA_DIR
|
||||
});
|
||||
const server = http.createServer(app);
|
||||
const wss = new WebSocketServer({ noServer: true });
|
||||
|
||||
playerRuntime.installWebsocket(server);
|
||||
app.use(express.json());
|
||||
|
||||
// Static assets and mirrored uploads are served from the player container.
|
||||
app.use('/assets', express.static(ASSET_DIR));
|
||||
app.use('/uploads', express.static(UPLOAD_DIR));
|
||||
|
||||
app.get('/api/uploads/config', function (_req, res) {
|
||||
res.json({
|
||||
uploadDir: UPLOAD_DIR
|
||||
});
|
||||
registerPlayerOnboardingRoutes(app, {
|
||||
pool: pool,
|
||||
common: common,
|
||||
playerRuntime: playerRuntime,
|
||||
onboardingStore: onboardingStore,
|
||||
playerPublicBaseUrl: PLAYER_PUBLIC_BASE_URL,
|
||||
playerInternalBaseUrl: PLAYER_INTERNAL_BASE_URL,
|
||||
QRCode: require('qrcode')
|
||||
});
|
||||
|
||||
app.put('/api/uploads/:filename', express.raw({ type: '*/*', limit: '100mb' }), async function (req, res, next) {
|
||||
try {
|
||||
const filename = path.basename(String(req.params.filename || '').trim());
|
||||
if (!filename) {
|
||||
return res.status(400).json({ error: 'Filename is required' });
|
||||
}
|
||||
const filePath = path.join(UPLOAD_DIR, filename);
|
||||
const body = Buffer.isBuffer(req.body) ? req.body : Buffer.from(req.body || '');
|
||||
await fs.promises.mkdir(UPLOAD_DIR, { recursive: true });
|
||||
await fs.promises.writeFile(filePath, body);
|
||||
res.json({ ok: true, filename: filename });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/uploads/:filename', async function (req, res, next) {
|
||||
try {
|
||||
const filename = path.basename(String(req.params.filename || '').trim());
|
||||
if (!filename) {
|
||||
return res.status(400).json({ error: 'Filename is required' });
|
||||
}
|
||||
const filePath = path.join(UPLOAD_DIR, filename);
|
||||
try {
|
||||
await fs.promises.unlink(filePath);
|
||||
} catch (error) {
|
||||
if (!error || error.code !== 'ENOENT') {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
res.json({ ok: true, filename: filename });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
function getConnectionBucket(slug) {
|
||||
const key = String(slug || '').trim();
|
||||
if (!key) {
|
||||
return null;
|
||||
}
|
||||
if (!connectionsBySlug.has(key)) {
|
||||
connectionsBySlug.set(key, new Map());
|
||||
}
|
||||
return connectionsBySlug.get(key);
|
||||
}
|
||||
|
||||
function removeConnection(slug, connectionId) {
|
||||
const bucket = connectionsBySlug.get(slug);
|
||||
if (!bucket) {
|
||||
return;
|
||||
}
|
||||
bucket.delete(connectionId);
|
||||
if (!bucket.size) {
|
||||
connectionsBySlug.delete(slug);
|
||||
}
|
||||
}
|
||||
|
||||
function getDashboardListenerBucket(slug) {
|
||||
const key = String(slug || '').trim();
|
||||
if (!key) {
|
||||
return null;
|
||||
}
|
||||
if (!dashboardListenersBySlug.has(key)) {
|
||||
dashboardListenersBySlug.set(key, new Set());
|
||||
}
|
||||
return dashboardListenersBySlug.get(key);
|
||||
}
|
||||
|
||||
function removeDashboardListener(slug, socket) {
|
||||
const key = String(slug || '').trim();
|
||||
const bucket = dashboardListenersBySlug.get(key);
|
||||
if (!bucket) {
|
||||
return;
|
||||
}
|
||||
bucket.delete(socket);
|
||||
if (!bucket.size) {
|
||||
dashboardListenersBySlug.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
function buildClientLabel(connection) {
|
||||
const clientId = String(connection.clientId || '').trim();
|
||||
const userAgent = String(connection.userAgent || '').trim();
|
||||
const clientIp = String(connection.clientIp || '').trim();
|
||||
const viewport = connection.viewport && typeof connection.viewport === 'object'
|
||||
? connection.viewport
|
||||
: null;
|
||||
const labelParts = [];
|
||||
|
||||
if (userAgent) {
|
||||
labelParts.push(userAgent.length > 72 ? `${userAgent.slice(0, 72)}...` : userAgent);
|
||||
}
|
||||
|
||||
if (clientId) {
|
||||
labelParts.push(`id ${clientId.slice(-6)}`);
|
||||
}
|
||||
|
||||
if (clientIp) {
|
||||
labelParts.push(clientIp);
|
||||
}
|
||||
|
||||
if (viewport && Number.isFinite(Number(viewport.width)) && Number.isFinite(Number(viewport.height))) {
|
||||
labelParts.push(`${Number(viewport.width)}x${Number(viewport.height)}`);
|
||||
}
|
||||
|
||||
if (!labelParts.length) {
|
||||
return connection.remoteAddress || 'connected client';
|
||||
}
|
||||
|
||||
return labelParts.join(' • ');
|
||||
}
|
||||
|
||||
function snapshotConnections(slug) {
|
||||
const bucket = connectionsBySlug.get(String(slug || '').trim());
|
||||
if (!bucket) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Array.from(bucket.values()).map(function (connection) {
|
||||
return {
|
||||
id: connection.id,
|
||||
clientId: connection.clientId || null,
|
||||
label: connection.label,
|
||||
userAgent: connection.userAgent || null,
|
||||
viewport: connection.viewport || null,
|
||||
page: connection.page || null,
|
||||
currentSlide: connection.currentSlide || null,
|
||||
paused: Boolean(connection.paused),
|
||||
blackout: Boolean(connection.blackout),
|
||||
currentSlideId: connection.currentSlide && connection.currentSlide.id ? connection.currentSlide.id : null,
|
||||
currentSlideTitle: connection.currentSlide && connection.currentSlide.title ? connection.currentSlide.title : null,
|
||||
clientIp: connection.clientIp || null,
|
||||
remoteAddress: connection.remoteAddress || null,
|
||||
connectedAt: connection.connectedAt ? connection.connectedAt.toISOString() : null,
|
||||
lastSeenAt: connection.lastSeenAt ? connection.lastSeenAt.toISOString() : null
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function broadcastConnectionSnapshot(slug) {
|
||||
const key = String(slug || '').trim();
|
||||
const bucket = dashboardListenersBySlug.get(key);
|
||||
if (!bucket || !bucket.size) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = JSON.stringify({
|
||||
type: 'snapshot',
|
||||
slug: key,
|
||||
connections: snapshotConnections(slug),
|
||||
sentAt: new Date().toISOString()
|
||||
});
|
||||
|
||||
bucket.forEach(function (socket) {
|
||||
if (socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(payload);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function sendCommandToConnection(slug, connectionId, commandOrPayload) {
|
||||
const bucket = connectionsBySlug.get(String(slug || '').trim());
|
||||
if (!bucket || !bucket.size) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const target = bucket.get(String(connectionId || '').trim());
|
||||
if (!target || target.socket.readyState !== WebSocket.OPEN) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const payload = typeof commandOrPayload === 'object' && commandOrPayload !== null
|
||||
? Object.assign({}, commandOrPayload)
|
||||
: { command: commandOrPayload };
|
||||
payload.type = 'command';
|
||||
payload.targetConnectionId = target.id;
|
||||
payload.sentAt = new Date().toISOString();
|
||||
|
||||
target.socket.send(JSON.stringify(payload));
|
||||
return 1;
|
||||
}
|
||||
|
||||
function broadcastCommand(slug, commandOrPayload) {
|
||||
const bucket = connectionsBySlug.get(String(slug || '').trim());
|
||||
if (!bucket || !bucket.size) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let sent = 0;
|
||||
bucket.forEach(function (connection) {
|
||||
if (connection.socket.readyState !== WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
const payload = typeof commandOrPayload === 'object' && commandOrPayload !== null
|
||||
? Object.assign({}, commandOrPayload)
|
||||
: { command: commandOrPayload };
|
||||
payload.type = 'command';
|
||||
payload.sentAt = new Date().toISOString();
|
||||
|
||||
connection.socket.send(JSON.stringify(payload));
|
||||
sent += 1;
|
||||
});
|
||||
return sent;
|
||||
}
|
||||
|
||||
app.get('/', function (_req, res) {
|
||||
res.send('Pulse Signage player service');
|
||||
});
|
||||
|
||||
// Screen playback endpoints render the active playlist for a slug.
|
||||
app.get('/screen/:slug', function (req, res) {
|
||||
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
|
||||
res.set('Pragma', 'no-cache');
|
||||
buildScreenPlaylist(pool, req.params.slug).then(function (data) {
|
||||
res.send(common.renderPlayerPage(req.params.slug, data));
|
||||
}).catch(function (error) {
|
||||
console.error(error);
|
||||
res.status(500).send('Internal server error');
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/api/screens/:slug/playlist', async function (req, res, next) {
|
||||
try {
|
||||
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
|
||||
const data = await buildScreenPlaylist(pool, req.params.slug);
|
||||
if (!data.screen) {
|
||||
return res.status(404).json({ error: 'Screen not found' });
|
||||
}
|
||||
const etag = '"' + String(data.revision || '') + '"';
|
||||
res.set('ETag', etag);
|
||||
if (String(req.headers['if-none-match'] || '').split(',').map(function (value) {
|
||||
return String(value || '').trim();
|
||||
}).includes(etag)) {
|
||||
return res.status(304).end();
|
||||
}
|
||||
res.json(data);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get(['/api/screens/:slug/connections', '/api/screens/:slug/clients'], async function (req, res, next) {
|
||||
try {
|
||||
const [screenRows] = await pool.query('SELECT id, name, slug FROM screens WHERE slug = ?', [req.params.slug]);
|
||||
if (!screenRows.length) {
|
||||
return res.status(404).json({ error: 'Screen not found' });
|
||||
}
|
||||
const connections = snapshotConnections(req.params.slug);
|
||||
res.json({
|
||||
screen: screenRows[0],
|
||||
screenSlug: req.params.slug,
|
||||
count: connections.length,
|
||||
connections: connections
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/screens/:slug/commands', async function (req, res, next) {
|
||||
try {
|
||||
const command = String((req.body && req.body.command) || req.query.command || '').trim().toLowerCase();
|
||||
const connectionId = String((req.body && (req.body.connectionId || req.body.clientId)) || req.query.connectionId || req.query.clientId || '').trim();
|
||||
const blackoutValue = req.body && Object.prototype.hasOwnProperty.call(req.body, 'blackout')
|
||||
? req.body.blackout
|
||||
: req.query.blackout;
|
||||
if (!command) {
|
||||
return res.status(400).json({ error: 'Command is required' });
|
||||
}
|
||||
if (['refresh', 'reload', 'redirect', 'pause', 'blackout', 'previous', 'next', 'left', 'right'].indexOf(command) === -1) {
|
||||
return res.status(400).json({ error: 'Unsupported command' });
|
||||
}
|
||||
|
||||
const isRedirectCommand = command === 'redirect';
|
||||
let screenRows = [];
|
||||
if (!isRedirectCommand) {
|
||||
[screenRows] = await pool.query('SELECT id, name, slug FROM screens WHERE slug = ?', [req.params.slug]);
|
||||
if (!screenRows.length) {
|
||||
return res.status(404).json({ error: 'Screen not found' });
|
||||
}
|
||||
}
|
||||
|
||||
const commandPayload = req.body && typeof req.body === 'object' && !Array.isArray(req.body)
|
||||
? Object.assign({}, req.body, { command: command })
|
||||
: command;
|
||||
if (command === 'blackout' && blackoutValue !== undefined && commandPayload && typeof commandPayload === 'object') {
|
||||
commandPayload.blackout = blackoutValue;
|
||||
}
|
||||
|
||||
const sent = connectionId
|
||||
? sendCommandToConnection(req.params.slug, connectionId, commandPayload)
|
||||
: broadcastCommand(req.params.slug, commandPayload);
|
||||
|
||||
res.json({
|
||||
screen: screenRows[0] || null,
|
||||
screenSlug: req.params.slug,
|
||||
command: command,
|
||||
connectionId: connectionId || null,
|
||||
sent: sent
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
registerPlayerRoutes(app, {
|
||||
pool: pool,
|
||||
common: common,
|
||||
mediaDir: MEDIA_DIR,
|
||||
assetDir: ASSET_DIR,
|
||||
playerRuntime: playerRuntime,
|
||||
playerPlaylistService: playerPlaylistService,
|
||||
rtmpStreamService: rtmpStreamService,
|
||||
playerPublicBaseUrl: PLAYER_PUBLIC_BASE_URL,
|
||||
playerInternalBaseUrl: PLAYER_INTERNAL_BASE_URL,
|
||||
playerIdentifier: PLAYER_IDENTIFIER
|
||||
});
|
||||
|
||||
app.use(function (error, _req, res, _next) {
|
||||
@@ -483,130 +67,49 @@ async function start() {
|
||||
res.status(error.statusCode || 500).send(error.statusCode ? error.message : 'Internal server error');
|
||||
});
|
||||
|
||||
await common.ensureSchema(pool);
|
||||
fs.mkdirSync(UPLOAD_DIR, { recursive: true });
|
||||
// Websocket upgrades split dashboard snapshots from player client sessions.
|
||||
server.on('upgrade', function (request, socket, head) {
|
||||
let pathname = '';
|
||||
try {
|
||||
pathname = new URL(request.url, 'http://localhost').pathname;
|
||||
} catch (_error) {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
const dashboardMatch = pathname.match(/^\/ws\/screens\/([^/]+)\/events$/);
|
||||
const playerMatch = pathname.match(/^\/ws\/screens\/([^/]+)$/);
|
||||
if (!dashboardMatch && !playerMatch) {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
const slug = decodeURIComponent((dashboardMatch || playerMatch)[1]);
|
||||
wss.handleUpgrade(request, socket, head, function (ws) {
|
||||
wss.emit('connection', ws, request, slug, dashboardMatch ? 'dashboard' : 'player');
|
||||
});
|
||||
});
|
||||
|
||||
wss.on('connection', function (socket, request, slug, role) {
|
||||
if (role === 'dashboard') {
|
||||
const listenerBucket = getDashboardListenerBucket(slug);
|
||||
if (!listenerBucket) {
|
||||
socket.close();
|
||||
return;
|
||||
}
|
||||
|
||||
listenerBucket.add(socket);
|
||||
socket.send(JSON.stringify({
|
||||
type: 'snapshot',
|
||||
slug: String(slug || '').trim(),
|
||||
connections: snapshotConnections(slug),
|
||||
sentAt: new Date().toISOString()
|
||||
}));
|
||||
|
||||
socket.on('close', function () {
|
||||
removeDashboardListener(slug, socket);
|
||||
});
|
||||
|
||||
socket.on('error', function () {
|
||||
removeDashboardListener(slug, socket);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const remoteAddress = request.socket && request.socket.remoteAddress ? request.socket.remoteAddress : null;
|
||||
const forwardedFor = String(request.headers['x-forwarded-for'] || '').split(',')[0].trim();
|
||||
const connectionId = crypto.randomUUID();
|
||||
const connection = {
|
||||
id: connectionId,
|
||||
slug: slug,
|
||||
socket: socket,
|
||||
clientId: null,
|
||||
userAgent: null,
|
||||
viewport: null,
|
||||
page: null,
|
||||
paused: false,
|
||||
blackout: false,
|
||||
clientIp: forwardedFor || remoteAddress,
|
||||
remoteAddress: remoteAddress,
|
||||
label: forwardedFor || remoteAddress || 'connected client',
|
||||
connectedAt: new Date(),
|
||||
lastSeenAt: new Date()
|
||||
};
|
||||
const bucket = getConnectionBucket(slug);
|
||||
|
||||
if (!bucket) {
|
||||
socket.close();
|
||||
return;
|
||||
}
|
||||
|
||||
bucket.set(connectionId, connection);
|
||||
|
||||
socket.on('message', function (rawMessage) {
|
||||
connection.lastSeenAt = new Date();
|
||||
let payload = null;
|
||||
try {
|
||||
payload = JSON.parse(String(rawMessage || ''));
|
||||
} catch (_error) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!payload || (payload.type !== 'hello' && payload.type !== 'state')) {
|
||||
return;
|
||||
}
|
||||
|
||||
connection.clientId = payload.clientId ? String(payload.clientId).trim() : connection.clientId;
|
||||
connection.userAgent = payload.userAgent ? String(payload.userAgent).trim() : connection.userAgent;
|
||||
connection.viewport = payload.viewport && typeof payload.viewport === 'object' ? payload.viewport : connection.viewport;
|
||||
connection.page = payload.page ? String(payload.page).trim() : connection.page;
|
||||
connection.paused = Boolean(payload.paused);
|
||||
connection.blackout = Boolean(payload.blackout);
|
||||
connection.clientIp = payload.clientIp ? String(payload.clientIp).trim() : connection.clientIp;
|
||||
connection.currentSlide = payload.currentSlide && typeof payload.currentSlide === 'object' ? {
|
||||
id: payload.currentSlide.id || null,
|
||||
title: payload.currentSlide.title || '',
|
||||
kind: payload.currentSlide.kind || '',
|
||||
playlistSignature: payload.currentSlide.playlistSignature || ''
|
||||
} : connection.currentSlide;
|
||||
connection.label = buildClientLabel(connection);
|
||||
connection.lastSeenAt = new Date();
|
||||
broadcastConnectionSnapshot(slug);
|
||||
});
|
||||
|
||||
socket.on('close', function () {
|
||||
removeConnection(slug, connectionId);
|
||||
broadcastConnectionSnapshot(slug);
|
||||
});
|
||||
|
||||
socket.on('error', function () {
|
||||
removeConnection(slug, connectionId);
|
||||
broadcastConnectionSnapshot(slug);
|
||||
});
|
||||
});
|
||||
fs.mkdirSync(MEDIA_DIR, { recursive: true });
|
||||
await ensureFontLibrary(MEDIA_DIR);
|
||||
|
||||
server.listen(PORT, function () {
|
||||
console.log(`Pulse Signage app listening on port ${PORT}`);
|
||||
});
|
||||
|
||||
async function syncDatabaseState() {
|
||||
try {
|
||||
await common.ensureSchema(pool, { mediaDir: MEDIA_DIR });
|
||||
await common.bootstrapDatabase(pool);
|
||||
|
||||
if (playerRuntime.snapshotAllConnections().length > 0) {
|
||||
await common.pruneStaleOnboardingDevices(pool);
|
||||
}
|
||||
|
||||
await onboardingStore.flushBindings(function (entry) {
|
||||
return commitDeviceBinding(
|
||||
pool,
|
||||
entry.deviceId,
|
||||
entry.clientName,
|
||||
entry.screenSlug,
|
||||
playerRuntime.isClientNameAvailableOnScreen,
|
||||
playerRuntime.snapshotAllConnections()
|
||||
);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
await syncDatabaseState();
|
||||
|
||||
if (PLAYER_IDENTIFIER) {
|
||||
const { upsertPlayerRegistration } = require('./player/onboarding');
|
||||
await upsertPlayerRegistration(pool, PLAYER_IDENTIFIER, PLAYER_PUBLIC_BASE_URL, PLAYER_INTERNAL_BASE_URL, null);
|
||||
}
|
||||
|
||||
setInterval(function () {
|
||||
syncDatabaseState().catch(function (error) {
|
||||
console.error(error);
|
||||
});
|
||||
}, DB_SYNC_INTERVAL_MS);
|
||||
}
|
||||
|
||||
module.exports = { start };
|
||||
@@ -617,3 +120,4 @@ if (require.main === module) {
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
<style>
|
||||
.player-announcement--fullscreen {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
overflow: hidden;
|
||||
color: {{FOREGROUND}};
|
||||
background: {{ACCENT}};
|
||||
--announcement-canvas-min: min(var(--player-canvas-width, 100vw), var(--player-canvas-height, 100vh));
|
||||
}
|
||||
|
||||
.player-announcement--fullscreen .player-announcement__body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: clamp(36px, calc(var(--announcement-canvas-min) * 0.06), 96px);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.player-announcement--fullscreen .player-announcement__stack {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
align-content: center;
|
||||
gap: clamp(12px, calc(var(--announcement-canvas-min) * 0.015), 24px);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.player-announcement--fullscreen .player-announcement__icon {
|
||||
width: clamp(6rem, calc(var(--announcement-canvas-min) * 0.16), 12rem);
|
||||
height: clamp(6rem, calc(var(--announcement-canvas-min) * 0.16), 12rem);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: clamp(4rem, calc(var(--announcement-canvas-min) * 0.16), 10rem);
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.player-announcement--fullscreen .player-announcement__message {
|
||||
width: min(100%, calc(var(--announcement-canvas-min) * 1.35));
|
||||
text-align: center;
|
||||
font-size: clamp(2rem, calc(var(--announcement-canvas-min) * 0.065), 8rem);
|
||||
font-weight: 900;
|
||||
line-height: 1.08;
|
||||
}
|
||||
</style>
|
||||
<section class="player-announcement player-announcement--fullscreen player-announcement--color-{{COLOR_KEY}} shadow-lg" style="--announcement-accent: {{ACCENT}}; --announcement-foreground: {{FOREGROUND}}; --announcement-glow: {{GLOW}};">
|
||||
<div class="player-announcement__body">
|
||||
<div class="player-announcement__stack">
|
||||
<div class="player-announcement__icon player-announcement__icon--fullscreen" aria-hidden="true"><i class="bi bi-{{ICON}}"></i></div>
|
||||
<div class="player-announcement__message">{{MESSAGE}}</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,198 @@
|
||||
<style>
|
||||
.player-announcement--lower-third {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
min-height: clamp(104px, calc(var(--announcement-canvas-min) * 0.098), 220px);
|
||||
display: flex;
|
||||
align-self: flex-end;
|
||||
align-items: stretch;
|
||||
overflow: hidden;
|
||||
color: var(--announcement-foreground, #fff);
|
||||
background: var(--announcement-accent, #0d6efd);
|
||||
--announcement-canvas-min: min(var(--player-canvas-width), var(--player-canvas-height));
|
||||
}
|
||||
|
||||
.player-announcement--lower-third .player-announcement__body {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
overflow: hidden;
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
.player-announcement--lower-third .player-announcement__icon {
|
||||
flex: 0 0 clamp(88px, calc(var(--announcement-canvas-min) * 0.08), 128px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
align-self: stretch;
|
||||
padding: clamp(8px, calc(var(--announcement-canvas-min) * 0.01), 14px);
|
||||
box-sizing: border-box;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.16), rgba(0, 0, 0, 0.12)),
|
||||
var(--announcement-accent, #0d6efd);
|
||||
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.10) inset;
|
||||
}
|
||||
|
||||
.player-announcement--lower-third .player-announcement__icon i {
|
||||
font-size: clamp(2.2rem, calc(var(--announcement-canvas-min) * 0.032), 3rem);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.player-announcement--lower-third .player-announcement__content {
|
||||
position: relative;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
padding: clamp(6px, calc(var(--announcement-canvas-min) * 0.008), 12px) 0;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.10), rgba(255, 255, 255, 0.03)),
|
||||
linear-gradient(90deg, rgba(255, 255, 255, 0.06), transparent 20%),
|
||||
var(--announcement-accent, #0d6efd);
|
||||
box-shadow:
|
||||
inset 1px 0 0 rgba(255, 255, 255, 0.10),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.12) inset;
|
||||
}
|
||||
|
||||
.player-announcement--lower-third .player-announcement__content::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: auto 0 0 0;
|
||||
height: 3px;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.68), transparent);
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.player-announcement--lower-third .player-announcement__kicker {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
margin-bottom: 6px;
|
||||
color: rgba(255, 255, 255, 0.78);
|
||||
font-size: clamp(0.8rem, calc(var(--announcement-canvas-min) * 0.009), 1rem);
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.player-announcement--lower-third .player-announcement__message {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
color: #fff;
|
||||
font-size: clamp(2.5rem, calc(var(--announcement-canvas-min) * 0.028), 3.4rem);
|
||||
font-weight: 700;
|
||||
line-height: 1.05;
|
||||
text-shadow: 0 2px 12px rgba(0, 0, 0, 0.25);
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.player-announcement--lower-third .player-announcement__message-track {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2.5rem;
|
||||
min-width: 100%;
|
||||
width: max-content;
|
||||
backface-visibility: hidden;
|
||||
will-change: transform;
|
||||
animation: lower-third-scroll var(--announcement-scroll-duration, 20s) linear infinite;
|
||||
}
|
||||
|
||||
.player-announcement--lower-third .player-announcement__message-text {
|
||||
display: inline-block;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
@media (orientation: portrait) {
|
||||
.player-announcement--lower-third {
|
||||
min-height: clamp(76px, calc(var(--announcement-canvas-min) * 0.068), 142px);
|
||||
}
|
||||
|
||||
.player-announcement--lower-third .player-announcement__icon {
|
||||
flex-basis: clamp(56px, calc(var(--announcement-canvas-min) * 0.052), 84px);
|
||||
padding: clamp(4px, calc(var(--announcement-canvas-min) * 0.005), 7px);
|
||||
}
|
||||
|
||||
.player-announcement--lower-third .player-announcement__icon i {
|
||||
font-size: clamp(1.6rem, calc(var(--announcement-canvas-min) * 0.022), 2.3rem);
|
||||
}
|
||||
|
||||
.player-announcement--lower-third .player-announcement__content {
|
||||
padding: clamp(4px, calc(var(--announcement-canvas-min) * 0.005), 7px) 0;
|
||||
}
|
||||
|
||||
.player-announcement--lower-third .player-announcement__kicker {
|
||||
margin-bottom: 2px;
|
||||
font-size: clamp(0.68rem, calc(var(--announcement-canvas-min) * 0.007), 0.86rem);
|
||||
}
|
||||
|
||||
.player-announcement--lower-third .player-announcement__message {
|
||||
font-size: clamp(1.7rem, calc(var(--announcement-canvas-min) * 0.021), 2.35rem);
|
||||
}
|
||||
|
||||
.player-announcement--lower-third .player-announcement__message-track {
|
||||
gap: 1.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes lower-third-scroll {
|
||||
from {
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
to {
|
||||
transform: translate3d(-50%, 0, 0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<section class="player-announcement player-announcement--lower-third player-announcement--color-{{COLOR_KEY}} shadow-lg" style="--announcement-accent: {{ACCENT}}; --announcement-foreground: {{FOREGROUND}}; --announcement-glow: {{GLOW}};">
|
||||
<div class="player-announcement__body">
|
||||
<div class="player-announcement__icon" aria-hidden="true">
|
||||
<i class="bi bi-{{ICON}}"></i>
|
||||
</div>
|
||||
<div class="player-announcement__content">
|
||||
<div class="player-announcement__message">
|
||||
<span class="player-announcement__message-track" data-announcement-marquee-rate="88">
|
||||
<span class="player-announcement__message-text">{{MESSAGE}}</span>
|
||||
<span class="player-announcement__message-text" aria-hidden="true">{{MESSAGE}}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<script>
|
||||
(function () {
|
||||
var currentScript = document.currentScript;
|
||||
var root = currentScript && currentScript.previousElementSibling && currentScript.previousElementSibling.classList && currentScript.previousElementSibling.classList.contains('player-announcement--lower-third')
|
||||
? currentScript.previousElementSibling
|
||||
: null;
|
||||
if (!root) {
|
||||
return;
|
||||
}
|
||||
|
||||
var track = root.querySelector('.player-announcement__message-track');
|
||||
if (!track) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (track.dataset.marqueeMeasured === 'true') {
|
||||
return;
|
||||
}
|
||||
|
||||
var rate = Number(track.getAttribute('data-announcement-marquee-rate') || 44) || 44;
|
||||
var singlePassWidth = Math.max(1, track.scrollWidth / 2);
|
||||
var durationSeconds = Math.max(8, singlePassWidth / rate);
|
||||
var durationValue = durationSeconds.toFixed(2) + 's';
|
||||
|
||||
track.dataset.marqueeMeasured = 'true';
|
||||
track.style.setProperty('--announcement-scroll-duration', durationValue);
|
||||
track.style.animationDuration = durationValue;
|
||||
}());
|
||||
</script>
|
||||
@@ -0,0 +1,200 @@
|
||||
<style>
|
||||
.player-announcement--top-banner {
|
||||
position: absolute;
|
||||
inset: 0 0 auto 0;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
min-height: clamp(104px, calc(var(--announcement-canvas-min) * 0.098), 220px);
|
||||
display: flex;
|
||||
align-self: flex-start;
|
||||
align-items: stretch;
|
||||
overflow: hidden;
|
||||
color: var(--announcement-foreground, #fff);
|
||||
background: var(--announcement-accent, #0d6efd);
|
||||
--announcement-canvas-min: min(var(--player-canvas-width), var(--player-canvas-height));
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.player-announcement--top-banner .player-announcement__body {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
overflow: hidden;
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
.player-announcement--top-banner .player-announcement__icon {
|
||||
flex: 0 0 clamp(88px, calc(var(--announcement-canvas-min) * 0.08), 128px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
align-self: stretch;
|
||||
padding: clamp(8px, calc(var(--announcement-canvas-min) * 0.01), 14px);
|
||||
box-sizing: border-box;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.16), rgba(0, 0, 0, 0.12)),
|
||||
var(--announcement-accent, #0d6efd);
|
||||
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.10) inset;
|
||||
}
|
||||
|
||||
.player-announcement--top-banner .player-announcement__icon i {
|
||||
font-size: clamp(2.2rem, calc(var(--announcement-canvas-min) * 0.032), 3rem);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.player-announcement--top-banner .player-announcement__content {
|
||||
position: relative;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
padding: clamp(6px, calc(var(--announcement-canvas-min) * 0.008), 12px) 0;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.10), rgba(255, 255, 255, 0.03)),
|
||||
linear-gradient(90deg, rgba(255, 255, 255, 0.06), transparent 20%),
|
||||
var(--announcement-accent, #0d6efd);
|
||||
box-shadow:
|
||||
inset 1px 0 0 rgba(255, 255, 255, 0.10),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.12) inset;
|
||||
}
|
||||
|
||||
.player-announcement--top-banner .player-announcement__content::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: auto 0 0 0;
|
||||
height: 3px;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.68), transparent);
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.player-announcement--top-banner .player-announcement__kicker {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
margin-bottom: 6px;
|
||||
color: rgba(255, 255, 255, 0.78);
|
||||
font-size: clamp(0.8rem, calc(var(--announcement-canvas-min) * 0.009), 1rem);
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.player-announcement--top-banner .player-announcement__message {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
color: #fff;
|
||||
font-size: clamp(2.5rem, calc(var(--announcement-canvas-min) * 0.028), 3.4rem);
|
||||
font-weight: 700;
|
||||
line-height: 1.05;
|
||||
text-shadow: 0 2px 12px rgba(0, 0, 0, 0.25);
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.player-announcement--top-banner .player-announcement__message-track {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2.5rem;
|
||||
min-width: 100%;
|
||||
width: max-content;
|
||||
backface-visibility: hidden;
|
||||
will-change: transform;
|
||||
animation: top-banner-scroll var(--announcement-scroll-duration, 20s) linear infinite;
|
||||
}
|
||||
|
||||
.player-announcement--top-banner .player-announcement__message-text {
|
||||
display: inline-block;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
@media (orientation: portrait) {
|
||||
.player-announcement--top-banner {
|
||||
min-height: clamp(76px, calc(var(--announcement-canvas-min) * 0.068), 142px);
|
||||
}
|
||||
|
||||
.player-announcement--top-banner .player-announcement__icon {
|
||||
flex-basis: clamp(56px, calc(var(--announcement-canvas-min) * 0.052), 84px);
|
||||
padding: clamp(4px, calc(var(--announcement-canvas-min) * 0.005), 7px);
|
||||
}
|
||||
|
||||
.player-announcement--top-banner .player-announcement__icon i {
|
||||
font-size: clamp(1.6rem, calc(var(--announcement-canvas-min) * 0.022), 2.3rem);
|
||||
}
|
||||
|
||||
.player-announcement--top-banner .player-announcement__content {
|
||||
padding: clamp(4px, calc(var(--announcement-canvas-min) * 0.005), 7px) 0;
|
||||
}
|
||||
|
||||
.player-announcement--top-banner .player-announcement__kicker {
|
||||
margin-bottom: 2px;
|
||||
font-size: clamp(0.68rem, calc(var(--announcement-canvas-min) * 0.007), 0.86rem);
|
||||
}
|
||||
|
||||
.player-announcement--top-banner .player-announcement__message {
|
||||
font-size: clamp(1.7rem, calc(var(--announcement-canvas-min) * 0.021), 2.35rem);
|
||||
}
|
||||
|
||||
.player-announcement--top-banner .player-announcement__message-track {
|
||||
gap: 1.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes top-banner-scroll {
|
||||
from {
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
to {
|
||||
transform: translate3d(-50%, 0, 0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<section class="player-announcement player-announcement--top-banner player-announcement--color-{{COLOR_KEY}} shadow-lg" style="--announcement-accent: {{ACCENT}}; --announcement-foreground: {{FOREGROUND}}; --announcement-glow: {{GLOW}};">
|
||||
<div class="player-announcement__body">
|
||||
<div class="player-announcement__icon" aria-hidden="true">
|
||||
<i class="bi bi-{{ICON}}"></i>
|
||||
</div>
|
||||
<div class="player-announcement__content">
|
||||
<div class="player-announcement__message">
|
||||
<span class="player-announcement__message-track" data-announcement-marquee-rate="88">
|
||||
<span class="player-announcement__message-text">{{MESSAGE}}</span>
|
||||
<span class="player-announcement__message-text" aria-hidden="true">{{MESSAGE}}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<script>
|
||||
(function () {
|
||||
var currentScript = document.currentScript;
|
||||
var root = currentScript && currentScript.previousElementSibling && currentScript.previousElementSibling.classList && currentScript.previousElementSibling.classList.contains('player-announcement--top-banner')
|
||||
? currentScript.previousElementSibling
|
||||
: null;
|
||||
if (!root) {
|
||||
return;
|
||||
}
|
||||
|
||||
var track = root.querySelector('.player-announcement__message-track');
|
||||
if (!track) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (track.dataset.marqueeMeasured === 'true') {
|
||||
return;
|
||||
}
|
||||
|
||||
var rate = Number(track.getAttribute('data-announcement-marquee-rate') || 44) || 44;
|
||||
var singlePassWidth = Math.max(1, track.scrollWidth / 2);
|
||||
var durationSeconds = Math.max(8, singlePassWidth / rate);
|
||||
var durationValue = durationSeconds.toFixed(2) + 's';
|
||||
|
||||
track.dataset.marqueeMeasured = 'true';
|
||||
track.style.setProperty('--announcement-scroll-duration', durationValue);
|
||||
track.style.animationDuration = durationValue;
|
||||
}());
|
||||
</script>
|
||||
@@ -0,0 +1,454 @@
|
||||
// RTMP stream session management and ffmpeg probe orchestration.
|
||||
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { spawn } = require('child_process');
|
||||
|
||||
function createRtmpStreamService(options) {
|
||||
const mediaDir = options && options.mediaDir ? options.mediaDir : null;
|
||||
const ffmpegPath = options && options.ffmpegPath ? options.ffmpegPath : 'ffmpeg';
|
||||
const ffprobePath = options && options.ffprobePath ? options.ffprobePath : 'ffprobe';
|
||||
const probeIntervalMs = options && options.probeIntervalMs ? Number(options.probeIntervalMs) : 2000;
|
||||
const sessions = new Map();
|
||||
const watchedSources = new Set();
|
||||
const sourceStatusCache = new Map();
|
||||
const sourceProbePromises = new Map();
|
||||
let probeTimer = null;
|
||||
|
||||
if (!mediaDir) {
|
||||
throw new Error('mediaDir is required');
|
||||
}
|
||||
|
||||
const cacheRoot = path.join(mediaDir, 'rtmp-cache');
|
||||
|
||||
function normalizeSourceUrl(value) {
|
||||
const sourceUrl = String(value || '').trim();
|
||||
if (!sourceUrl || !/^rtmps?:\/\//i.test(sourceUrl)) {
|
||||
const error = new Error('A valid RTMP URL is required.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
return sourceUrl;
|
||||
}
|
||||
|
||||
function getSessionKey(sourceUrl, disableAudio) {
|
||||
return crypto.createHash('sha1').update(String(sourceUrl)).update('\0').update(disableAudio ? '1' : '0').digest('hex');
|
||||
}
|
||||
|
||||
async function ensureDirectory(dirPath) {
|
||||
await fs.promises.mkdir(dirPath, { recursive: true });
|
||||
}
|
||||
|
||||
async function waitForFile(filePath, timeoutMs) {
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
try {
|
||||
const stat = await fs.promises.stat(filePath);
|
||||
if (stat.isFile() && stat.size > 0) {
|
||||
return true;
|
||||
}
|
||||
} catch (_error) {
|
||||
// keep waiting
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function runCommand(command, args, timeoutMs) {
|
||||
return new Promise(function (resolve) {
|
||||
var child = spawn(command, args, {
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
});
|
||||
var stdout = '';
|
||||
var stderr = '';
|
||||
var finished = false;
|
||||
var timer = null;
|
||||
|
||||
function done(result) {
|
||||
if (finished) {
|
||||
return;
|
||||
}
|
||||
finished = true;
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
resolve(result);
|
||||
}
|
||||
|
||||
child.stdout.on('data', function (chunk) {
|
||||
stdout += String(chunk || '');
|
||||
});
|
||||
|
||||
child.stderr.on('data', function (chunk) {
|
||||
stderr += String(chunk || '');
|
||||
});
|
||||
|
||||
child.on('error', function (error) {
|
||||
done({ ok: false, error: error, stdout: stdout, stderr: stderr, timedOut: false });
|
||||
});
|
||||
|
||||
child.on('exit', function (code, signal) {
|
||||
done({ ok: code === 0, code: code, signal: signal, stdout: stdout, stderr: stderr, timedOut: false });
|
||||
});
|
||||
|
||||
timer = setTimeout(function () {
|
||||
try {
|
||||
child.kill('SIGKILL');
|
||||
} catch (_error) {
|
||||
// ignore timeout cleanup errors
|
||||
}
|
||||
done({ ok: false, code: null, signal: 'SIGKILL', stdout: stdout, stderr: stderr, timedOut: true });
|
||||
}, Math.max(1000, Number(timeoutMs || 0) || 4000));
|
||||
});
|
||||
}
|
||||
|
||||
async function probeSourceUrl(sourceUrl) {
|
||||
const normalizedSource = normalizeSourceUrl(sourceUrl);
|
||||
const result = await runCommand(ffprobePath, [
|
||||
'-hide_banner',
|
||||
'-loglevel', 'error',
|
||||
'-rw_timeout', '3000000',
|
||||
'-show_streams',
|
||||
'-of', 'json',
|
||||
normalizedSource
|
||||
], 4000);
|
||||
|
||||
if (!result.ok) {
|
||||
return {
|
||||
live: false,
|
||||
timedOut: Boolean(result.timedOut),
|
||||
stderr: String(result.stderr || '').trim()
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(String(result.stdout || '{}'));
|
||||
const streams = Array.isArray(parsed && parsed.streams) ? parsed.streams : [];
|
||||
return {
|
||||
live: streams.length > 0,
|
||||
timedOut: false,
|
||||
stderr: String(result.stderr || '').trim()
|
||||
};
|
||||
} catch (_error) {
|
||||
return {
|
||||
live: false,
|
||||
timedOut: false,
|
||||
stderr: String(result.stderr || '').trim()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function getSourceStatus(sourceUrl) {
|
||||
return sourceStatusCache.get(String(sourceUrl || '').trim()) || null;
|
||||
}
|
||||
|
||||
function setSourceStatus(sourceUrl, nextStatus) {
|
||||
const normalizedSource = String(sourceUrl || '').trim();
|
||||
if (!normalizedSource) {
|
||||
return null;
|
||||
}
|
||||
const status = Object.assign({
|
||||
live: false,
|
||||
probing: false,
|
||||
checkedAt: Date.now(),
|
||||
timedOut: false,
|
||||
stderr: ''
|
||||
}, nextStatus || {});
|
||||
status.live = Boolean(status.live);
|
||||
status.probing = Boolean(status.probing);
|
||||
status.checkedAt = Number(status.checkedAt || Date.now());
|
||||
sourceStatusCache.set(normalizedSource, status);
|
||||
return status;
|
||||
}
|
||||
|
||||
function registerSourceWatch(sourceUrl) {
|
||||
const normalizedSource = normalizeSourceUrl(sourceUrl);
|
||||
watchedSources.add(normalizedSource);
|
||||
return normalizedSource;
|
||||
}
|
||||
|
||||
async function refreshSourceStatus(sourceUrl) {
|
||||
const normalizedSource = registerSourceWatch(sourceUrl);
|
||||
const existingPromise = sourceProbePromises.get(normalizedSource) || null;
|
||||
if (existingPromise) {
|
||||
return existingPromise;
|
||||
}
|
||||
|
||||
setSourceStatus(normalizedSource, Object.assign({}, getSourceStatus(normalizedSource) || {}, {
|
||||
probing: true
|
||||
}));
|
||||
|
||||
const probePromise = probeSourceUrl(normalizedSource).then(function (probe) {
|
||||
return setSourceStatus(normalizedSource, {
|
||||
live: Boolean(probe.live),
|
||||
probing: false,
|
||||
checkedAt: Date.now(),
|
||||
timedOut: Boolean(probe.timedOut),
|
||||
stderr: String(probe.stderr || '')
|
||||
});
|
||||
}).catch(function (error) {
|
||||
return setSourceStatus(normalizedSource, {
|
||||
live: false,
|
||||
probing: false,
|
||||
checkedAt: Date.now(),
|
||||
timedOut: false,
|
||||
stderr: String(error && error.message ? error.message : '')
|
||||
});
|
||||
}).finally(function () {
|
||||
sourceProbePromises.delete(normalizedSource);
|
||||
});
|
||||
|
||||
sourceProbePromises.set(normalizedSource, probePromise);
|
||||
return probePromise;
|
||||
}
|
||||
|
||||
function scheduleSourceRefresh(sourceUrl) {
|
||||
const normalizedSource = registerSourceWatch(sourceUrl);
|
||||
const currentStatus = getSourceStatus(normalizedSource);
|
||||
if (currentStatus && currentStatus.probing) {
|
||||
return sourceProbePromises.get(normalizedSource) || Promise.resolve(currentStatus);
|
||||
}
|
||||
return refreshSourceStatus(normalizedSource);
|
||||
}
|
||||
|
||||
function isSourceStatusFresh(status) {
|
||||
if (!status || !status.checkedAt) {
|
||||
return false;
|
||||
}
|
||||
return Date.now() - Number(status.checkedAt || 0) < probeIntervalMs;
|
||||
}
|
||||
|
||||
function sweepWatchedSources() {
|
||||
watchedSources.forEach(function (sourceUrl) {
|
||||
const status = getSourceStatus(sourceUrl);
|
||||
if (!status || !isSourceStatusFresh(status) || status.live === false) {
|
||||
scheduleSourceRefresh(sourceUrl);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (probeIntervalMs > 0) {
|
||||
probeTimer = setInterval(function () {
|
||||
sweepWatchedSources();
|
||||
}, probeIntervalMs);
|
||||
if (probeTimer && typeof probeTimer.unref === 'function') {
|
||||
probeTimer.unref();
|
||||
}
|
||||
}
|
||||
|
||||
async function isSessionLive(session) {
|
||||
if (!session || !session.process) {
|
||||
return false;
|
||||
}
|
||||
if (session.exitCode !== undefined && session.exitCode !== null) {
|
||||
return false;
|
||||
}
|
||||
if (session.exitSignal !== undefined && session.exitSignal !== null) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const stat = await fs.promises.stat(session.manifestPath);
|
||||
return stat.isFile() && stat.size > 0;
|
||||
} catch (_error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function buildPlaylistUrl(key) {
|
||||
return '/api/rtmp/streams/' + encodeURIComponent(key) + '/index.m3u8';
|
||||
}
|
||||
|
||||
async function ensureSession(sourceUrl, disableAudio) {
|
||||
const normalizedSource = normalizeSourceUrl(sourceUrl);
|
||||
const normalizedDisableAudio = Boolean(disableAudio);
|
||||
const key = getSessionKey(normalizedSource, normalizedDisableAudio);
|
||||
const directory = path.join(cacheRoot, key);
|
||||
const manifestPath = path.join(directory, 'index.m3u8');
|
||||
|
||||
const existingSession = sessions.get(key) || null;
|
||||
if (existingSession && existingSession.process && existingSession.exitCode === null && existingSession.exitSignal === null) {
|
||||
return existingSession;
|
||||
}
|
||||
|
||||
if (existingSession) {
|
||||
sessions.delete(key);
|
||||
try {
|
||||
if (existingSession.process && typeof existingSession.process.kill === 'function') {
|
||||
existingSession.process.kill('SIGKILL');
|
||||
}
|
||||
} catch (_error) {
|
||||
// ignore cleanup errors
|
||||
}
|
||||
try {
|
||||
await fs.promises.rm(directory, { recursive: true, force: true });
|
||||
} catch (_error2) {
|
||||
// ignore cleanup errors
|
||||
}
|
||||
}
|
||||
|
||||
await ensureDirectory(directory);
|
||||
|
||||
const args = [
|
||||
'-hide_banner',
|
||||
'-loglevel', 'warning',
|
||||
'-nostdin',
|
||||
'-i', normalizedSource,
|
||||
'-fflags', '+genpts',
|
||||
'-f', 'hls',
|
||||
'-hls_time', '1',
|
||||
'-hls_init_time', '1',
|
||||
'-hls_list_size', '8',
|
||||
'-hls_flags', 'delete_segments+append_list+omit_endlist+independent_segments+program_date_time',
|
||||
'-hls_segment_filename', path.join(directory, 'segment-%05d.ts')
|
||||
];
|
||||
|
||||
if (normalizedDisableAudio) {
|
||||
args.push('-an');
|
||||
args.push('-c:v', 'copy');
|
||||
} else {
|
||||
args.push('-c', 'copy');
|
||||
}
|
||||
|
||||
args.push(manifestPath);
|
||||
|
||||
const child = spawn(ffmpegPath, args, {
|
||||
stdio: ['ignore', 'ignore', 'pipe']
|
||||
});
|
||||
|
||||
child.stderr.on('data', function (chunk) {
|
||||
const message = String(chunk || '').trim();
|
||||
if (message) {
|
||||
console.error('[rtmp]', message);
|
||||
}
|
||||
});
|
||||
|
||||
child.on('exit', function (code, signal) {
|
||||
const session = sessions.get(key);
|
||||
if (session && session.process === child) {
|
||||
session.process = null;
|
||||
session.exitCode = code;
|
||||
session.exitSignal = signal;
|
||||
}
|
||||
});
|
||||
|
||||
const session = {
|
||||
key: key,
|
||||
sourceUrl: normalizedSource,
|
||||
disableAudio: normalizedDisableAudio,
|
||||
directory: directory,
|
||||
manifestPath: manifestPath,
|
||||
playlistUrl: buildPlaylistUrl(key),
|
||||
process: child,
|
||||
exitCode: null,
|
||||
exitSignal: null,
|
||||
ready: waitForFile(manifestPath, 5000)
|
||||
};
|
||||
|
||||
sessions.set(key, session);
|
||||
return session;
|
||||
}
|
||||
|
||||
async function getPlaylistUrl(sourceUrl, disableAudio) {
|
||||
const session = await ensureSession(sourceUrl, disableAudio);
|
||||
await session.ready.catch(function () {
|
||||
return false;
|
||||
});
|
||||
return session.playlistUrl + '?t=' + Date.now();
|
||||
}
|
||||
|
||||
async function getSessionStatus(sourceUrl, disableAudio) {
|
||||
const normalizedSource = normalizeSourceUrl(sourceUrl);
|
||||
const normalizedDisableAudio = Boolean(disableAudio);
|
||||
registerSourceWatch(normalizedSource);
|
||||
|
||||
let sourceStatus = getSourceStatus(normalizedSource);
|
||||
if (!sourceStatus) {
|
||||
scheduleSourceRefresh(normalizedSource).catch(function (_error) {
|
||||
return null;
|
||||
});
|
||||
sourceStatus = getSourceStatus(normalizedSource);
|
||||
} else if (!isSourceStatusFresh(sourceStatus) && !sourceStatus.probing) {
|
||||
scheduleSourceRefresh(normalizedSource).catch(function (_error) {
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
if (!sourceStatus || !sourceStatus.live) {
|
||||
return {
|
||||
session: null,
|
||||
ready: false,
|
||||
live: false,
|
||||
probing: Boolean(sourceStatus && sourceStatus.probing),
|
||||
checkedAt: sourceStatus ? sourceStatus.checkedAt : null,
|
||||
timedOut: Boolean(sourceStatus && sourceStatus.timedOut),
|
||||
stderr: String(sourceStatus && sourceStatus.stderr || '')
|
||||
};
|
||||
}
|
||||
|
||||
const session = await ensureSession(normalizedSource, normalizedDisableAudio);
|
||||
const ready = await session.ready.catch(function () {
|
||||
return false;
|
||||
});
|
||||
const live = ready && Boolean(session && session.process && session.exitCode === null && session.exitSignal === null);
|
||||
return {
|
||||
session: session,
|
||||
ready: ready,
|
||||
live: live,
|
||||
probing: Boolean(sourceStatus && sourceStatus.probing),
|
||||
checkedAt: sourceStatus ? sourceStatus.checkedAt : null,
|
||||
timedOut: false,
|
||||
stderr: ''
|
||||
};
|
||||
}
|
||||
|
||||
function getSessionByKey(key) {
|
||||
return sessions.get(String(key || '').trim()) || null;
|
||||
}
|
||||
|
||||
async function getManifestFilePath(key) {
|
||||
const session = getSessionByKey(key);
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
if (!session.process || session.exitCode !== null || session.exitSignal !== null) {
|
||||
return null;
|
||||
}
|
||||
const live = await isSessionLive(session);
|
||||
if (!live) {
|
||||
return null;
|
||||
}
|
||||
return session.manifestPath;
|
||||
}
|
||||
|
||||
async function getSegmentFilePath(key, fileName) {
|
||||
const session = getSessionByKey(key);
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
const segmentName = path.basename(String(fileName || '').trim());
|
||||
if (!segmentName || segmentName === 'index.m3u8') {
|
||||
return null;
|
||||
}
|
||||
return path.join(session.directory, segmentName);
|
||||
}
|
||||
|
||||
return {
|
||||
ensureSession: ensureSession,
|
||||
getPlaylistUrl: getPlaylistUrl,
|
||||
getSessionByKey: getSessionByKey,
|
||||
isSessionLive: isSessionLive,
|
||||
getSessionStatus: getSessionStatus,
|
||||
refreshSourceStatus: refreshSourceStatus,
|
||||
scheduleSourceRefresh: scheduleSourceRefresh,
|
||||
getSourceStatus: getSourceStatus,
|
||||
getManifestFilePath: getManifestFilePath,
|
||||
getSegmentFilePath: getSegmentFilePath
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createRtmpStreamService: createRtmpStreamService
|
||||
};
|
||||
@@ -0,0 +1,363 @@
|
||||
// Player onboarding routes and signup flow helpers.
|
||||
|
||||
const { isClientNameAvailable, withClientNameReservation } = require('#src/data/client-name-check');
|
||||
const { getSharedSecret, verifyPageAuthToken } = require('#src/request-auth');
|
||||
const { isTransientDbError } = require('./store');
|
||||
|
||||
const ONBOARDING_SIGNUP_LIMIT_WINDOW_MS = 5 * 60 * 1000;
|
||||
const ONBOARDING_SIGNUP_LIMIT_MAX_ATTEMPTS = 8;
|
||||
const onboardingSignupAttempts = new Map();
|
||||
|
||||
function normalizeDeviceId(value) {
|
||||
return String(value || '').trim().replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 128);
|
||||
}
|
||||
|
||||
function getPublicBaseUrl(req, configuredUrl) {
|
||||
const configured = String(configuredUrl || process.env.PLAYER_PUBLIC_BASE_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, '');
|
||||
if (configured) {
|
||||
return configured;
|
||||
}
|
||||
const forwardedProto = String(req.headers['x-forwarded-proto'] || '').trim().split(',')[0];
|
||||
const protocol = forwardedProto || (req.socket && req.socket.encrypted ? 'https' : 'http');
|
||||
const forwardedHost = String(req.headers['x-forwarded-host'] || '').trim().split(',')[0];
|
||||
const host = forwardedHost || String(req.headers.host || '').trim();
|
||||
return `${protocol}://${host}`.replace(/\/$/, '');
|
||||
}
|
||||
|
||||
function getRequestIp(req) {
|
||||
const forwardedFor = String(req && req.headers && req.headers['x-forwarded-for'] || '').trim().split(',')[0];
|
||||
if (forwardedFor) {
|
||||
return forwardedFor;
|
||||
}
|
||||
|
||||
const remoteAddress = req && req.socket && req.socket.remoteAddress ? String(req.socket.remoteAddress).trim() : '';
|
||||
if (!remoteAddress) {
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
return remoteAddress.toLowerCase().startsWith('::ffff:') ? remoteAddress.slice(7) : remoteAddress;
|
||||
}
|
||||
|
||||
function getOnboardingLimitKey(req, deviceId) {
|
||||
return [getRequestIp(req), normalizeDeviceId(deviceId) || 'anonymous'].join('|');
|
||||
}
|
||||
|
||||
function getPlayerPublicBaseUrl(req, configuredUrl) {
|
||||
return getPublicBaseUrl(req, configuredUrl);
|
||||
}
|
||||
|
||||
function getPlayerInternalBaseUrl(configuredUrl) {
|
||||
const configured = String(configuredUrl || process.env.PLAYER_INTERNAL_BASE_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, '');
|
||||
return configured || null;
|
||||
}
|
||||
|
||||
function clearOnboardingSignupAttempts() {
|
||||
if (onboardingSignupAttempts.size > 1000) {
|
||||
onboardingSignupAttempts.clear();
|
||||
}
|
||||
}
|
||||
|
||||
function isOnboardingSignupRateLimited(req, deviceId) {
|
||||
const now = Date.now();
|
||||
const key = getOnboardingLimitKey(req, deviceId);
|
||||
const attempts = onboardingSignupAttempts.get(key) || [];
|
||||
const windowStart = now - ONBOARDING_SIGNUP_LIMIT_WINDOW_MS;
|
||||
const recentAttempts = attempts.filter(function (timestamp) {
|
||||
return timestamp >= windowStart;
|
||||
});
|
||||
|
||||
if (recentAttempts.length >= ONBOARDING_SIGNUP_LIMIT_MAX_ATTEMPTS) {
|
||||
onboardingSignupAttempts.set(key, recentAttempts);
|
||||
return Math.max(1, Math.ceil((recentAttempts[0] + ONBOARDING_SIGNUP_LIMIT_WINDOW_MS - now) / 1000));
|
||||
}
|
||||
|
||||
recentAttempts.push(now);
|
||||
onboardingSignupAttempts.set(key, recentAttempts);
|
||||
clearOnboardingSignupAttempts();
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function getOnboardingStatus(pool, deviceId) {
|
||||
const normalizedDeviceId = normalizeDeviceId(deviceId);
|
||||
if (!normalizedDeviceId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [rows] = await pool.query(
|
||||
`SELECT d.device_id, d.client_name, d.screen_id, s.name AS screen_name, s.slug AS screen_slug, s.playlist_id
|
||||
FROM d_onboarding_devices d
|
||||
LEFT JOIN d_screens s ON s.id = d.screen_id
|
||||
WHERE d.device_id = ?`,
|
||||
[normalizedDeviceId]
|
||||
);
|
||||
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
async function commitDeviceBinding(pool, deviceId, clientName, screenSlug, isNameAvailableOnScreen, liveConnections) {
|
||||
const normalizedDeviceId = normalizeDeviceId(deviceId);
|
||||
const normalizedClientName = String(clientName || '').trim();
|
||||
const normalizedScreenSlug = String(screenSlug || '').trim();
|
||||
if (!normalizedDeviceId) {
|
||||
throw new Error('Device ID is required.');
|
||||
}
|
||||
if (!normalizedClientName) {
|
||||
throw new Error('Client name is required.');
|
||||
}
|
||||
|
||||
if (!normalizedScreenSlug) {
|
||||
throw new Error('Screen is required.');
|
||||
}
|
||||
|
||||
return withClientNameReservation(pool, normalizedClientName, async function () {
|
||||
const [screenRows] = await pool.query('SELECT id, name, slug FROM d_screens WHERE slug = ?', [normalizedScreenSlug]);
|
||||
if (!screenRows.length) {
|
||||
throw new Error('Screen not found.');
|
||||
}
|
||||
const screen = screenRows[0];
|
||||
|
||||
const available = await isClientNameAvailable(pool, normalizedClientName, normalizedDeviceId, liveConnections);
|
||||
if (!available) {
|
||||
const error = new Error('Client name already exists.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
await pool.query(
|
||||
'INSERT INTO d_onboarding_devices (device_id, client_name, screen_id) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE client_name = VALUES(client_name), screen_id = VALUES(screen_id), modified_at = CURRENT_TIMESTAMP',
|
||||
[normalizedDeviceId, normalizedClientName, screen.id]
|
||||
);
|
||||
|
||||
return getOnboardingStatus(pool, normalizedDeviceId);
|
||||
});
|
||||
}
|
||||
|
||||
async function upsertPlayerRegistration(pool, deviceId, publicBaseUrl, internalBaseUrl, screenSlug) {
|
||||
const normalizedDeviceId = normalizeDeviceId(deviceId);
|
||||
const normalizedPublicBaseUrl = String(publicBaseUrl || '').trim().replace(/\/$/, '');
|
||||
const normalizedInternalBaseUrl = String(internalBaseUrl || '').trim().replace(/\/$/, '');
|
||||
if (!normalizedDeviceId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
await pool.query(
|
||||
`INSERT INTO d_players (device_id, public_base_url, internal_base_url, last_seen_at)
|
||||
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
|
||||
ON DUPLICATE KEY UPDATE public_base_url = VALUES(public_base_url), internal_base_url = VALUES(internal_base_url), last_seen_at = CURRENT_TIMESTAMP, modified_at = CURRENT_TIMESTAMP`,
|
||||
[normalizedDeviceId, normalizedPublicBaseUrl || null, normalizedInternalBaseUrl || null]
|
||||
);
|
||||
|
||||
return {
|
||||
device_id: normalizedDeviceId,
|
||||
public_base_url: normalizedPublicBaseUrl || null,
|
||||
internal_base_url: normalizedInternalBaseUrl || null
|
||||
};
|
||||
}
|
||||
|
||||
async function bindPlayerToScreen(pool, deviceId, screenSlug) {
|
||||
const normalizedDeviceId = normalizeDeviceId(deviceId);
|
||||
const normalizedScreenSlug = String(screenSlug || '').trim();
|
||||
if (!normalizedDeviceId || !normalizedScreenSlug) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [screenRows] = await pool.query('SELECT id FROM d_screens WHERE slug = ? LIMIT 1', [normalizedScreenSlug]);
|
||||
if (!screenRows.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const screenId = Number(screenRows[0].id);
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
await connection.beginTransaction();
|
||||
// Current model: every screen binds to the shared player row '1'.
|
||||
// If we introduce multiple players, resolve the correct player row here instead of hardcoding it.
|
||||
await connection.query("UPDATE d_screens SET player_id = '1', modified_at = CURRENT_TIMESTAMP WHERE id = ?", [screenId]);
|
||||
await connection.commit();
|
||||
} catch (error) {
|
||||
await connection.rollback();
|
||||
throw error;
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
|
||||
return {
|
||||
screen_id: screenId,
|
||||
screen_slug: normalizedScreenSlug,
|
||||
player_id: '1'
|
||||
};
|
||||
}
|
||||
|
||||
async function bindDeviceToScreen(pool, deviceId, clientName, screenSlug, isNameAvailableOnScreen, playerRuntime, onboardingStore) {
|
||||
const liveConnections = playerRuntime && typeof playerRuntime.snapshotAllConnections === 'function'
|
||||
? playerRuntime.snapshotAllConnections()
|
||||
: [];
|
||||
|
||||
try {
|
||||
return await commitDeviceBinding(pool, deviceId, clientName, screenSlug, isNameAvailableOnScreen, liveConnections);
|
||||
} catch (error) {
|
||||
if (!isTransientDbError(error)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (onboardingStore && typeof onboardingStore.enqueueBinding === 'function') {
|
||||
await onboardingStore.enqueueBinding({
|
||||
deviceId: deviceId,
|
||||
clientName: clientName,
|
||||
screenSlug: screenSlug,
|
||||
queuedAt: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
device_id: normalizeDeviceId(deviceId),
|
||||
client_name: String(clientName || '').trim(),
|
||||
screen_slug: String(screenSlug || '').trim(),
|
||||
queued: true
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function registerPlayerOnboardingRoutes(app, options) {
|
||||
const pool = options && options.pool ? options.pool : null;
|
||||
const common = options && options.common ? options.common : null;
|
||||
const playerRuntime = options && options.playerRuntime ? options.playerRuntime : null;
|
||||
const QRCode = options && options.QRCode ? options.QRCode : null;
|
||||
const onboardingStore = options && options.onboardingStore ? options.onboardingStore : null;
|
||||
|
||||
if (!app || !pool || !common || !playerRuntime || !QRCode) {
|
||||
throw new Error('registerPlayerOnboardingRoutes requires app, pool, common, playerRuntime, and QRCode.');
|
||||
}
|
||||
|
||||
const sharedSecret = getSharedSecret();
|
||||
|
||||
function requireOnboardingPageAuth(req, res, next) {
|
||||
if (!sharedSecret) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const token = String(req.headers['x-pulse-page-auth'] || '').trim();
|
||||
const payload = verifyPageAuthToken(token);
|
||||
if (!payload || String(payload.scope || '').trim() !== 'onboarding') {
|
||||
return res.status(401).json({ error: 'Onboarding page authentication required.' });
|
||||
}
|
||||
|
||||
req.playerPageAuth = payload;
|
||||
next();
|
||||
}
|
||||
|
||||
app.get('/', function (_req, res) {
|
||||
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
|
||||
res.send(common.renderPlayerOnboardingLandingPage());
|
||||
});
|
||||
|
||||
app.get('/onboard', async function (req, res, next) {
|
||||
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
|
||||
try {
|
||||
res.send(common.renderPlayerOnboardingFormPage(String(req.query.deviceId || '').trim()));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/onboarding/status', function (req, res, next) {
|
||||
if (sharedSecret) {
|
||||
const token = String(req.headers['x-pulse-page-auth'] || '').trim();
|
||||
const payload = verifyPageAuthToken(token);
|
||||
if (!payload || ['onboarding', 'player'].indexOf(String(payload.scope || '').trim()) === -1) {
|
||||
return res.status(401).json({ error: 'Onboarding page authentication required.' });
|
||||
}
|
||||
req.playerPageAuth = payload;
|
||||
}
|
||||
next();
|
||||
}, async function (req, res, next) {
|
||||
try {
|
||||
const status = await getOnboardingStatus(pool, req.query.deviceId);
|
||||
res.json({
|
||||
deviceId: normalizeDeviceId(req.query.deviceId),
|
||||
onboarded: Boolean(status && status.screen_id),
|
||||
clientName: status ? status.client_name : null,
|
||||
screenId: status ? status.screen_id : null,
|
||||
screenSlug: status ? status.screen_slug : null,
|
||||
screenName: status ? status.screen_name : null,
|
||||
playerUrl: status && status.screen_slug ? `${getPublicBaseUrl(req)}/screen/${encodeURIComponent(status.screen_slug)}` : null
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/onboarding/screens', requireOnboardingPageAuth, async function (_req, res, next) {
|
||||
try {
|
||||
const [rows] = await pool.query('SELECT id, name, slug FROM d_screens ORDER BY name ASC, id ASC');
|
||||
res.json({ screens: rows });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/onboarding/qr', async function (req, res, next) {
|
||||
try {
|
||||
const deviceId = normalizeDeviceId(req.query.deviceId);
|
||||
if (!deviceId) {
|
||||
return res.status(400).json({ error: 'Device ID is required' });
|
||||
}
|
||||
const onboardingUrl = `${getPublicBaseUrl(req, options && options.playerPublicBaseUrl)}/onboard?deviceId=${encodeURIComponent(deviceId)}`;
|
||||
const svg = await QRCode.toString(onboardingUrl, { type: 'svg', margin: 1, errorCorrectionLevel: 'M' });
|
||||
res.set('Content-Type', 'image/svg+xml; charset=utf-8');
|
||||
res.set('Cache-Control', 'no-store');
|
||||
res.send(svg);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/onboarding', requireOnboardingPageAuth, async function (req, res, next) {
|
||||
try {
|
||||
const deviceId = normalizeDeviceId(req.body && req.body.deviceId);
|
||||
const clientName = String((req.body && req.body.clientName) || '').trim();
|
||||
const screenSlug = String((req.body && req.body.screenSlug) || '').trim();
|
||||
const retryAfterSeconds = isOnboardingSignupRateLimited(req, deviceId);
|
||||
if (retryAfterSeconds) {
|
||||
res.set('Retry-After', String(retryAfterSeconds));
|
||||
return res.status(429).json({ error: 'Too many onboarding attempts. Please try again later.' });
|
||||
}
|
||||
if (!deviceId) {
|
||||
return res.status(400).json({ error: 'Device ID is required' });
|
||||
}
|
||||
if (!clientName) {
|
||||
return res.status(400).json({ error: 'Client name is required' });
|
||||
}
|
||||
if (!screenSlug) {
|
||||
return res.status(400).json({ error: 'Screen is required' });
|
||||
}
|
||||
|
||||
const status = await bindDeviceToScreen(pool, deviceId, clientName, screenSlug, playerRuntime.isClientNameAvailableOnScreen, playerRuntime, onboardingStore);
|
||||
await bindPlayerToScreen(pool, deviceId, screenSlug);
|
||||
res.json({
|
||||
deviceId: deviceId,
|
||||
clientName: status ? status.client_name : clientName,
|
||||
screenId: status && status.screen_id ? status.screen_id : null,
|
||||
screenSlug: status ? status.screen_slug : screenSlug,
|
||||
screenName: status && status.screen_name ? status.screen_name : null,
|
||||
playerUrl: status && status.screen_slug ? `${getPublicBaseUrl(req)}/screen/${encodeURIComponent(status.screen_slug)}` : `${getPublicBaseUrl(req)}/screen/${encodeURIComponent(screenSlug)}`,
|
||||
queued: Boolean(status && status.queued)
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
normalizeDeviceId: normalizeDeviceId,
|
||||
getPublicBaseUrl: getPublicBaseUrl,
|
||||
getPlayerPublicBaseUrl: getPlayerPublicBaseUrl,
|
||||
getPlayerInternalBaseUrl: getPlayerInternalBaseUrl,
|
||||
getOnboardingStatus: getOnboardingStatus,
|
||||
commitDeviceBinding: commitDeviceBinding,
|
||||
upsertPlayerRegistration: upsertPlayerRegistration,
|
||||
bindPlayerToScreen: bindPlayerToScreen,
|
||||
bindDeviceToScreen: bindDeviceToScreen,
|
||||
registerPlayerOnboardingRoutes: registerPlayerOnboardingRoutes
|
||||
};
|
||||
@@ -0,0 +1,88 @@
|
||||
<script>
|
||||
(function () {
|
||||
var deviceKey = "pulse-signage-player-device-id";
|
||||
var clientNameKey = "pulse-signage-player-client-name";
|
||||
var screenKey = "pulse-signage-player-screen-slug";
|
||||
var deviceId = {{DEVICE_ID_JSON}};
|
||||
var form = document.getElementById("onboarding-form");
|
||||
var message = document.getElementById("onboarding-message");
|
||||
var screenSelect = document.getElementById("onboarding-screen-select");
|
||||
function setMessage(value) { if (message) { message.textContent = value || ""; } }
|
||||
function parseResponseError(response) {
|
||||
return response.text().then(function (text) {
|
||||
var fallbackMessage = text && text.trim() ? text.trim() : "Unable to save onboarding.";
|
||||
try {
|
||||
var payload = JSON.parse(text);
|
||||
return payload && payload.error ? payload.error : fallbackMessage;
|
||||
} catch (_error) {
|
||||
return fallbackMessage;
|
||||
}
|
||||
});
|
||||
}
|
||||
function loadScreens() {
|
||||
return fetch("/api/onboarding/screens", { cache: "no-store" })
|
||||
.then(function (response) { return response.ok ? response.json() : null; })
|
||||
.then(function (payload) {
|
||||
var screens = payload && Array.isArray(payload.screens) ? payload.screens : [];
|
||||
if (!screenSelect) { return screens; }
|
||||
while (screenSelect.firstChild) { screenSelect.removeChild(screenSelect.firstChild); }
|
||||
var placeholder = document.createElement("option");
|
||||
placeholder.value = "";
|
||||
placeholder.textContent = "Select a screen";
|
||||
screenSelect.appendChild(placeholder);
|
||||
screens.forEach(function (screen) {
|
||||
var option = document.createElement("option");
|
||||
option.value = String(screen && screen.slug ? screen.slug : "");
|
||||
option.textContent = String(screen && (screen.name || screen.slug) ? (screen.name || screen.slug) : "Screen");
|
||||
screenSelect.appendChild(option);
|
||||
});
|
||||
return screens;
|
||||
});
|
||||
}
|
||||
if (!deviceId) { setMessage("Missing device id. Scan the QR code from the player screen again."); return; }
|
||||
try { window.localStorage.setItem(deviceKey, deviceId); } catch (_error) {}
|
||||
loadScreens().then(function () {
|
||||
try {
|
||||
var storedClientName = window.localStorage.getItem(clientNameKey) || "";
|
||||
var storedScreenSlug = window.localStorage.getItem(screenKey) || "";
|
||||
var clientNameInput = form.querySelector("input[name=\"clientName\"]");
|
||||
if (clientNameInput && storedClientName) { clientNameInput.value = storedClientName; }
|
||||
if (screenSelect && storedScreenSlug) { screenSelect.value = storedScreenSlug; }
|
||||
} catch (_error) {}
|
||||
});
|
||||
form.addEventListener("submit", function (event) {
|
||||
event.preventDefault();
|
||||
var formData = new FormData(form);
|
||||
var clientName = String(formData.get("clientName") || "").trim();
|
||||
var screenSlug = String(formData.get("screenSlug") || "").trim();
|
||||
if (!clientName) { setMessage("Client name is required."); return; }
|
||||
if (!screenSlug) { setMessage("Screen is required."); return; }
|
||||
setMessage("Saving client...");
|
||||
fetch("/api/onboarding", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||||
body: JSON.stringify({ deviceId: deviceId, clientName: clientName, screenSlug: screenSlug })
|
||||
})
|
||||
.then(function (response) {
|
||||
if (response.ok) {
|
||||
return response.json();
|
||||
}
|
||||
return parseResponseError(response).then(function (messageText) {
|
||||
throw new Error(messageText);
|
||||
});
|
||||
})
|
||||
.then(function (payload) {
|
||||
if (!payload || !payload.screenSlug) { throw new Error("Unable to save onboarding."); }
|
||||
if (payload.clientName) { try { window.localStorage.setItem(clientNameKey, payload.clientName); } catch (_error) {} }
|
||||
try { window.localStorage.setItem(screenKey, payload.screenSlug); } catch (_error) {}
|
||||
setMessage("Onboarding complete.");
|
||||
if (form) {
|
||||
Array.prototype.slice.call(form.querySelectorAll("input, select, button")).forEach(function (control) {
|
||||
control.disabled = true;
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(function (error) { setMessage(error && error.message ? error.message : "Unable to save onboarding."); });
|
||||
});
|
||||
}());
|
||||
</script>
|
||||
@@ -0,0 +1,141 @@
|
||||
<script>
|
||||
(function () {
|
||||
var deviceKey = "pulse-signage-player-device-id";
|
||||
var clientNameKey = "pulse-signage-player-client-name";
|
||||
function getClientNameStorageKey(_screenSlug) {
|
||||
return clientNameKey;
|
||||
}
|
||||
var screenKey = "pulse-signage-player-screen-slug";
|
||||
var qr = document.getElementById("onboarding-qr");
|
||||
var status = document.getElementById("onboarding-status");
|
||||
var localForm = document.getElementById("onboarding-local-form");
|
||||
var localMessage = document.getElementById("onboarding-message");
|
||||
var localScreenSelect = document.getElementById("onboarding-screen-select");
|
||||
function parseResponseError(response) {
|
||||
return response.text().then(function (text) {
|
||||
var fallbackMessage = text && text.trim() ? text.trim() : "Unable to save onboarding.";
|
||||
try {
|
||||
var payload = JSON.parse(text);
|
||||
return payload && payload.error ? payload.error : fallbackMessage;
|
||||
} catch (_error) {
|
||||
return fallbackMessage;
|
||||
}
|
||||
});
|
||||
}
|
||||
function getDeviceId() {
|
||||
var stored = "";
|
||||
try { stored = window.localStorage.getItem(deviceKey) || ""; } catch (_error) { stored = ""; }
|
||||
if (stored) { return stored; }
|
||||
var next = (window.crypto && window.crypto.randomUUID ? window.crypto.randomUUID() : "device-" + Date.now() + "-" + Math.random().toString(16).slice(2));
|
||||
try { window.localStorage.setItem(deviceKey, next); } catch (_error2) {}
|
||||
return next;
|
||||
}
|
||||
function setStatus(message) { if (status) { status.textContent = message; } }
|
||||
function setLocalMessage(message) { if (localMessage) { localMessage.textContent = message || ""; } }
|
||||
function setSelectOptions(select, screens, selectedSlug) {
|
||||
if (!select) { return; }
|
||||
while (select.firstChild) { select.removeChild(select.firstChild); }
|
||||
var placeholder = document.createElement("option");
|
||||
placeholder.value = "";
|
||||
placeholder.textContent = "Select a screen";
|
||||
select.appendChild(placeholder);
|
||||
(Array.isArray(screens) ? screens : []).forEach(function (screen) {
|
||||
var option = document.createElement("option");
|
||||
option.value = String(screen && screen.slug ? screen.slug : "");
|
||||
option.textContent = String(screen && (screen.name || screen.slug) ? (screen.name || screen.slug) : "Screen");
|
||||
if (selectedSlug && String(option.value) === String(selectedSlug)) {
|
||||
option.selected = true;
|
||||
}
|
||||
select.appendChild(option);
|
||||
});
|
||||
}
|
||||
function loadScreens(selectedSlug) {
|
||||
return fetch("/api/onboarding/screens", { cache: "no-store" })
|
||||
.then(function (response) { return response.ok ? response.json() : null; })
|
||||
.then(function (payload) {
|
||||
var screens = payload && Array.isArray(payload.screens) ? payload.screens : [];
|
||||
setSelectOptions(localScreenSelect, screens, selectedSlug);
|
||||
return screens;
|
||||
})
|
||||
.catch(function () { setSelectOptions(localScreenSelect, [], selectedSlug); return []; });
|
||||
}
|
||||
function loadQr(deviceId) {
|
||||
if (qr) { qr.src = "/api/onboarding/qr?deviceId=" + encodeURIComponent(deviceId); }
|
||||
}
|
||||
function submitOnboarding(deviceId, clientName, screenSlug) {
|
||||
return fetch("/api/onboarding", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||||
body: JSON.stringify({ deviceId: deviceId, clientName: clientName, screenSlug: screenSlug })
|
||||
})
|
||||
.then(function (response) {
|
||||
if (response.ok) {
|
||||
return response.json();
|
||||
}
|
||||
return parseResponseError(response).then(function (messageText) {
|
||||
throw new Error(messageText);
|
||||
});
|
||||
})
|
||||
.then(function (payload) {
|
||||
if (!payload || !payload.screenSlug) { throw new Error("Unable to save onboarding."); }
|
||||
if (payload.clientName) { try { window.localStorage.setItem(clientNameKey, payload.clientName); } catch (_error) {} }
|
||||
if (payload.clientName && payload.screenSlug) { try { window.localStorage.setItem(getClientNameStorageKey(payload.screenSlug), payload.clientName); } catch (_error2) {} }
|
||||
try { window.localStorage.setItem(screenKey, payload.screenSlug); } catch (_error) {}
|
||||
setLocalMessage("Onboarding complete.");
|
||||
if (localForm) {
|
||||
Array.prototype.slice.call(localForm.querySelectorAll("input, select, button")).forEach(function (control) {
|
||||
control.disabled = true;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
function redirectIfOnboarded(deviceId) {
|
||||
return fetch("/api/onboarding/status?deviceId=" + encodeURIComponent(deviceId), { cache: "no-store" })
|
||||
.then(function (response) { return response.ok ? response.json() : null; })
|
||||
.then(function (payload) {
|
||||
if (payload && payload.onboarded && payload.screenSlug) {
|
||||
if (payload.clientName) { try { window.localStorage.setItem(clientNameKey, payload.clientName); } catch (_error) {} }
|
||||
if (payload.clientName && payload.screenSlug) { try { window.localStorage.setItem(getClientNameStorageKey(payload.screenSlug), payload.clientName); } catch (_error2) {} }
|
||||
try { window.localStorage.setItem(screenKey, payload.screenSlug); } catch (_error) {}
|
||||
window.location.replace("/screen/" + encodeURIComponent(payload.screenSlug));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
})
|
||||
.catch(function () { return false; });
|
||||
}
|
||||
var deviceId = getDeviceId();
|
||||
if (localForm) {
|
||||
localForm.addEventListener("submit", function (event) {
|
||||
event.preventDefault();
|
||||
var formData = new FormData(localForm);
|
||||
var clientName = String(formData.get("clientName") || "").trim();
|
||||
var screenSlug = String(formData.get("screenSlug") || "").trim();
|
||||
if (!clientName) { setLocalMessage("Client name is required."); return; }
|
||||
if (!screenSlug) { setLocalMessage("Screen is required."); return; }
|
||||
setLocalMessage("Saving client...");
|
||||
submitOnboarding(deviceId, clientName, screenSlug).catch(function (error) {
|
||||
setLocalMessage(error && error.message ? error.message : "Unable to save onboarding.");
|
||||
});
|
||||
});
|
||||
}
|
||||
loadScreens().then(function () {
|
||||
try {
|
||||
var storedScreenSlug = window.localStorage.getItem(screenKey) || "";
|
||||
var storedClientName = window.localStorage.getItem(clientNameKey) || "";
|
||||
if (!storedClientName && storedScreenSlug) { storedClientName = window.localStorage.getItem(getClientNameStorageKey(storedScreenSlug)) || ""; }
|
||||
if (storedClientName && localForm) {
|
||||
var clientNameInput = localForm.querySelector("input[name=\"clientName\"]");
|
||||
if (clientNameInput && !clientNameInput.value) { clientNameInput.value = storedClientName; }
|
||||
}
|
||||
if (storedScreenSlug && localScreenSelect) { localScreenSelect.value = storedScreenSlug; }
|
||||
} catch (_error) {}
|
||||
});
|
||||
redirectIfOnboarded(deviceId).then(function (redirected) {
|
||||
if (redirected) { return; }
|
||||
loadQr(deviceId);
|
||||
setStatus("Waiting for onboarding to finish.");
|
||||
window.setInterval(function () { redirectIfOnboarded(deviceId); }, 2000);
|
||||
});
|
||||
}());
|
||||
</script>
|
||||
@@ -0,0 +1,100 @@
|
||||
// File-backed storage for onboarding device mappings.
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
function isTransientDbError(error) {
|
||||
const code = String(error && error.code ? error.code : '').trim();
|
||||
return [
|
||||
'ECONNREFUSED',
|
||||
'ECONNRESET',
|
||||
'ETIMEDOUT',
|
||||
'EPIPE',
|
||||
'ENOTFOUND',
|
||||
'PROTOCOL_CONNECTION_LOST',
|
||||
'POOL_CLOSED',
|
||||
'ERR_POOL_CLOSED'
|
||||
].indexOf(code) !== -1;
|
||||
}
|
||||
|
||||
function createOnboardingStore(filePath) {
|
||||
const normalizedFilePath = String(filePath || '').trim();
|
||||
|
||||
async function readEntries() {
|
||||
try {
|
||||
const raw = await fs.promises.readFile(normalizedFilePath, 'utf8');
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch (error) {
|
||||
if (error && error.code === 'ENOENT') {
|
||||
return [];
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeEntries(entries) {
|
||||
await fs.promises.mkdir(path.dirname(normalizedFilePath), { recursive: true });
|
||||
const tempPath = `${normalizedFilePath}.tmp`;
|
||||
await fs.promises.writeFile(tempPath, JSON.stringify(Array.isArray(entries) ? entries : [], null, 2), 'utf8');
|
||||
await fs.promises.rename(tempPath, normalizedFilePath);
|
||||
}
|
||||
|
||||
async function enqueueBinding(entry) {
|
||||
const normalizedEntry = {
|
||||
deviceId: String(entry && entry.deviceId ? entry.deviceId : '').trim(),
|
||||
clientName: String(entry && entry.clientName ? entry.clientName : '').trim(),
|
||||
screenSlug: String(entry && entry.screenSlug ? entry.screenSlug : '').trim(),
|
||||
queuedAt: String(entry && entry.queuedAt ? entry.queuedAt : new Date().toISOString())
|
||||
};
|
||||
|
||||
if (!normalizedEntry.deviceId || !normalizedEntry.clientName || !normalizedEntry.screenSlug) {
|
||||
return readEntries();
|
||||
}
|
||||
|
||||
const entries = await readEntries();
|
||||
const nextEntries = entries.filter(function (queuedEntry) {
|
||||
return String(queuedEntry && queuedEntry.deviceId ? queuedEntry.deviceId : '').trim() !== normalizedEntry.deviceId;
|
||||
});
|
||||
nextEntries.push(normalizedEntry);
|
||||
await writeEntries(nextEntries);
|
||||
return nextEntries;
|
||||
}
|
||||
|
||||
async function flushBindings(applyBinding) {
|
||||
const entries = await readEntries();
|
||||
if (!entries.length) {
|
||||
return { flushed: 0, remaining: 0 };
|
||||
}
|
||||
|
||||
const remaining = [];
|
||||
let flushed = 0;
|
||||
for (let index = 0; index < entries.length; index += 1) {
|
||||
const entry = entries[index];
|
||||
try {
|
||||
await applyBinding(entry);
|
||||
flushed += 1;
|
||||
} catch (error) {
|
||||
if (isTransientDbError(error)) {
|
||||
remaining.push.apply(remaining, entries.slice(index));
|
||||
break;
|
||||
}
|
||||
remaining.push.apply(remaining, entries.slice(index + 1));
|
||||
}
|
||||
}
|
||||
|
||||
await writeEntries(remaining);
|
||||
return { flushed: flushed, remaining: remaining.length };
|
||||
}
|
||||
|
||||
return {
|
||||
enqueueBinding: enqueueBinding,
|
||||
flushBindings: flushBindings,
|
||||
readEntries: readEntries
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createOnboardingStore: createOnboardingStore,
|
||||
isTransientDbError: isTransientDbError
|
||||
};
|
||||
@@ -0,0 +1,98 @@
|
||||
<script>
|
||||
let onboardingClientName = null;
|
||||
let onboardingClientNameSyncPromise = null;
|
||||
const onboardingClientNameStorageKey = 'pulse-signage-player-client-name';
|
||||
const onboardingDeviceIdStorageKey = 'pulse-signage-player-device-id';
|
||||
|
||||
function getOnboardingDeviceId() {
|
||||
try {
|
||||
var storedDeviceId = window.localStorage.getItem(onboardingDeviceIdStorageKey) || '';
|
||||
return String(storedDeviceId || '').trim();
|
||||
} catch (_error) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// Return the onboarding client name when one was assigned, otherwise a stable client id.
|
||||
function getOnboardingClientName() {
|
||||
if (onboardingClientName) {
|
||||
return onboardingClientName;
|
||||
}
|
||||
try {
|
||||
var storedClientName = window.localStorage.getItem(onboardingClientNameStorageKey);
|
||||
if (storedClientName) {
|
||||
onboardingClientName = storedClientName;
|
||||
try {
|
||||
window.localStorage.setItem('pulse-signage-player-client-name', storedClientName);
|
||||
} catch (_mirrorError) {
|
||||
// ignore storage errors
|
||||
}
|
||||
return onboardingClientName;
|
||||
}
|
||||
var genericClientName = window.localStorage.getItem('pulse-signage-player-client-name');
|
||||
if (genericClientName) {
|
||||
onboardingClientName = genericClientName;
|
||||
try {
|
||||
window.localStorage.setItem(onboardingClientNameStorageKey, genericClientName);
|
||||
} catch (_error) {
|
||||
// ignore storage errors
|
||||
}
|
||||
return onboardingClientName;
|
||||
}
|
||||
} catch (_error) {
|
||||
// fall through to client id generation
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function applyOnboardingClientName(renamedClientName, socket) {
|
||||
var normalizedName = String(renamedClientName || '').trim();
|
||||
if (!normalizedName) {
|
||||
return;
|
||||
}
|
||||
onboardingClientName = normalizedName;
|
||||
try {
|
||||
window.localStorage.setItem('pulse-signage-player-client-name', normalizedName);
|
||||
window.localStorage.setItem(onboardingClientNameStorageKey, normalizedName);
|
||||
} catch (_error) {
|
||||
// ignore storage errors
|
||||
}
|
||||
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||
sendCommandState(socket);
|
||||
}
|
||||
}
|
||||
|
||||
function syncOnboardingClientNameFromServer(socket) {
|
||||
var deviceId = getOnboardingDeviceId();
|
||||
if (!deviceId) {
|
||||
return Promise.resolve(getOnboardingClientName());
|
||||
}
|
||||
|
||||
if (onboardingClientNameSyncPromise) {
|
||||
return onboardingClientNameSyncPromise;
|
||||
}
|
||||
|
||||
onboardingClientNameSyncPromise = fetch('/api/onboarding/status?deviceId=' + encodeURIComponent(deviceId), {
|
||||
cache: 'no-store'
|
||||
}).then(function (response) {
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
return response.json().catch(function () {
|
||||
return null;
|
||||
});
|
||||
}).then(function (payload) {
|
||||
var serverName = payload && payload.clientName ? String(payload.clientName).trim() : '';
|
||||
if (serverName) {
|
||||
applyOnboardingClientName(serverName, null);
|
||||
}
|
||||
return onboardingClientName || getOnboardingClientName();
|
||||
}).catch(function () {
|
||||
return onboardingClientName || getOnboardingClientName();
|
||||
}).finally(function () {
|
||||
onboardingClientNameSyncPromise = null;
|
||||
});
|
||||
|
||||
return onboardingClientNameSyncPromise;
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,260 @@
|
||||
// Announcement overlay polling and rendering helpers.
|
||||
|
||||
(function () {
|
||||
if (window.__pulseThumbnailPreview) {
|
||||
return;
|
||||
}
|
||||
|
||||
var announcementLayer = null;
|
||||
var announcementRefreshTimer = null;
|
||||
var announcementPollTimer = null;
|
||||
var announcementSocket = null;
|
||||
var announcementReconnectTimer = null;
|
||||
var announcementRequestId = 0;
|
||||
var announcementEtag = '';
|
||||
var announcementHideTimer = null;
|
||||
var announcementRevealTimer = null;
|
||||
var announcementFadeDurationMs = 220;
|
||||
var announcementSocketPath = '/ws/screens/' + encodeURIComponent(window.slug || '') + '/announcements';
|
||||
|
||||
function normalizeAnnouncementType(value) {
|
||||
var normalized = String(value || '').trim().toLowerCase();
|
||||
if (normalized === 'center-card') {
|
||||
return 'fullscreen';
|
||||
}
|
||||
if (['lower-third', 'fullscreen', 'top-banner'].indexOf(normalized) !== -1) {
|
||||
return normalized;
|
||||
}
|
||||
return 'lower-third';
|
||||
}
|
||||
|
||||
function getAnnouncementLayer() {
|
||||
if (announcementLayer) {
|
||||
return announcementLayer;
|
||||
}
|
||||
announcementLayer = document.getElementById('player-announcement-layer');
|
||||
if (!announcementLayer) {
|
||||
announcementLayer = document.createElement('div');
|
||||
announcementLayer.id = 'player-announcement-layer';
|
||||
announcementLayer.className = 'player-announcement-layer';
|
||||
announcementLayer.setAttribute('aria-live', 'polite');
|
||||
announcementLayer.setAttribute('aria-atomic', 'true');
|
||||
document.body.appendChild(announcementLayer);
|
||||
}
|
||||
return announcementLayer;
|
||||
}
|
||||
|
||||
function clearAnnouncementHideTimer() {
|
||||
if (announcementHideTimer) {
|
||||
window.clearTimeout(announcementHideTimer);
|
||||
announcementHideTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function clearAnnouncementRevealTimer() {
|
||||
if (announcementRevealTimer) {
|
||||
window.cancelAnimationFrame(announcementRevealTimer);
|
||||
announcementRevealTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function activateInjectedScripts(layer) {
|
||||
if (!layer) {
|
||||
return;
|
||||
}
|
||||
|
||||
var scripts = layer.querySelectorAll('script');
|
||||
scripts.forEach(function (originalScript) {
|
||||
var replacement = document.createElement('script');
|
||||
|
||||
Array.prototype.slice.call(originalScript.attributes || []).forEach(function (attribute) {
|
||||
replacement.setAttribute(attribute.name, attribute.value);
|
||||
});
|
||||
|
||||
replacement.text = originalScript.textContent || '';
|
||||
originalScript.parentNode.replaceChild(replacement, originalScript);
|
||||
});
|
||||
}
|
||||
|
||||
function hideAnnouncement() {
|
||||
var layer = getAnnouncementLayer();
|
||||
clearAnnouncementRevealTimer();
|
||||
clearAnnouncementHideTimer();
|
||||
layer.classList.add('is-closing');
|
||||
layer.classList.remove('is-visible');
|
||||
announcementHideTimer = window.setTimeout(function () {
|
||||
layer.innerHTML = '';
|
||||
layer.hidden = true;
|
||||
layer.classList.remove('is-closing');
|
||||
announcementHideTimer = null;
|
||||
}, announcementFadeDurationMs);
|
||||
}
|
||||
|
||||
function renderAnnouncement(announcement) {
|
||||
var layer = getAnnouncementLayer();
|
||||
if (!announcement || !announcement.message) {
|
||||
hideAnnouncement();
|
||||
return;
|
||||
}
|
||||
|
||||
var type = normalizeAnnouncementType(announcement.announcement_type);
|
||||
|
||||
clearAnnouncementRevealTimer();
|
||||
clearAnnouncementHideTimer();
|
||||
layer.hidden = false;
|
||||
layer.className = 'player-announcement-layer player-announcement-layer--' + type;
|
||||
layer.classList.remove('is-closing');
|
||||
layer.innerHTML = window.renderPlayerAnnouncementMarkup ? window.renderPlayerAnnouncementMarkup(announcement) : '';
|
||||
activateInjectedScripts(layer);
|
||||
announcementRevealTimer = window.requestAnimationFrame(function () {
|
||||
announcementRevealTimer = null;
|
||||
layer.classList.add('is-visible');
|
||||
});
|
||||
}
|
||||
|
||||
function scheduleAnnouncementRefresh(delayMs) {
|
||||
if (announcementRefreshTimer) {
|
||||
window.clearTimeout(announcementRefreshTimer);
|
||||
}
|
||||
announcementRefreshTimer = window.setTimeout(function () {
|
||||
announcementRefreshTimer = null;
|
||||
void refreshAnnouncementOverlay();
|
||||
}, Math.max(0, Number(delayMs || 0)));
|
||||
}
|
||||
|
||||
async function refreshAnnouncementOverlay() {
|
||||
var requestId = ++announcementRequestId;
|
||||
var request = new XMLHttpRequest();
|
||||
var url = window.location.origin + '/api/screens/' + encodeURIComponent(window.slug || '') + '/announcement?ts=' + Date.now();
|
||||
request.open('GET', url, true);
|
||||
request.timeout = 2500;
|
||||
if (window.__pulsePageAuthToken) {
|
||||
request.setRequestHeader('x-pulse-page-auth', window.__pulsePageAuthToken);
|
||||
}
|
||||
if (announcementEtag) {
|
||||
request.setRequestHeader('If-None-Match', announcementEtag);
|
||||
}
|
||||
request.onreadystatechange = function () {
|
||||
if (request.readyState !== 4 || requestId !== announcementRequestId) {
|
||||
return;
|
||||
}
|
||||
if (request.status === 304) {
|
||||
return;
|
||||
}
|
||||
if (request.status < 200 || request.status >= 300) {
|
||||
scheduleAnnouncementRefresh(15000);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
var responseEtag = String(request.getResponseHeader('ETag') || '').trim();
|
||||
var data = JSON.parse(request.responseText || '{}');
|
||||
var announcement = data && data.announcement ? data.announcement : null;
|
||||
|
||||
if (responseEtag) {
|
||||
announcementEtag = responseEtag;
|
||||
}
|
||||
|
||||
if (!announcement) {
|
||||
hideAnnouncement();
|
||||
} else {
|
||||
renderAnnouncement(announcement);
|
||||
}
|
||||
} catch (_error) {
|
||||
scheduleAnnouncementRefresh(15000);
|
||||
}
|
||||
};
|
||||
request.onerror = function () {
|
||||
scheduleAnnouncementRefresh(15000);
|
||||
};
|
||||
request.ontimeout = function () {
|
||||
scheduleAnnouncementRefresh(15000);
|
||||
};
|
||||
request.send();
|
||||
}
|
||||
|
||||
function scheduleAnnouncementPolling() {
|
||||
if (announcementPollTimer) {
|
||||
window.clearInterval(announcementPollTimer);
|
||||
}
|
||||
announcementPollTimer = window.setInterval(function () {
|
||||
void refreshAnnouncementOverlay();
|
||||
}, 15000);
|
||||
}
|
||||
|
||||
function scheduleAnnouncementReconnect() {
|
||||
if (announcementReconnectTimer) {
|
||||
return;
|
||||
}
|
||||
announcementReconnectTimer = window.setTimeout(function () {
|
||||
announcementReconnectTimer = null;
|
||||
connectAnnouncementSocket();
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function handleAnnouncementSocketMessage(rawMessage) {
|
||||
var payload;
|
||||
try {
|
||||
payload = JSON.parse(String(rawMessage || ''));
|
||||
} catch (_error) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!payload || payload.type !== 'announcement-refresh') {
|
||||
return;
|
||||
}
|
||||
|
||||
void refreshAnnouncementOverlay();
|
||||
}
|
||||
|
||||
function connectAnnouncementSocket() {
|
||||
if (!window.WebSocket) {
|
||||
return;
|
||||
}
|
||||
if (announcementSocket && (announcementSocket.readyState === WebSocket.OPEN || announcementSocket.readyState === WebSocket.CONNECTING)) {
|
||||
return;
|
||||
}
|
||||
|
||||
var socket = new WebSocket(new URL(announcementSocketPath, window.location.origin).toString());
|
||||
announcementSocket = socket;
|
||||
|
||||
socket.onopen = function () {
|
||||
void refreshAnnouncementOverlay();
|
||||
};
|
||||
|
||||
socket.onmessage = function (event) {
|
||||
handleAnnouncementSocketMessage(event.data);
|
||||
};
|
||||
|
||||
socket.onclose = function () {
|
||||
announcementSocket = null;
|
||||
scheduleAnnouncementReconnect();
|
||||
};
|
||||
|
||||
socket.onerror = function () {
|
||||
try {
|
||||
socket.close();
|
||||
} catch (_error) {
|
||||
// ignore socket close errors
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
window.refreshAnnouncementOverlay = refreshAnnouncementOverlay;
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
void refreshAnnouncementOverlay();
|
||||
scheduleAnnouncementPolling();
|
||||
connectAnnouncementSocket();
|
||||
}, { once: true });
|
||||
} else {
|
||||
void refreshAnnouncementOverlay();
|
||||
scheduleAnnouncementPolling();
|
||||
connectAnnouncementSocket();
|
||||
}
|
||||
|
||||
window.addEventListener('focus', function () {
|
||||
void refreshAnnouncementOverlay();
|
||||
});
|
||||
})();
|
||||
+120
-1217
File diff suppressed because it is too large
Load Diff
@@ -5,10 +5,13 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>{{TITLE}}</title>
|
||||
<link rel="icon" type="image/png" href="/assets/favicon.png" />
|
||||
<link rel="stylesheet" href="/assets/css/player.css" />
|
||||
<link rel="stylesheet" href="/assets/adminlte/bootstrap-icons/css/bootstrap-icons.min.css" />
|
||||
<link rel="stylesheet" href="/assets/vendor/animate.css/animate.min.css" />
|
||||
<link rel="stylesheet" href="/assets/css/player.css?v=36" />
|
||||
{{{STYLESHEETS}}}
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"><div class="empty">Loading screen...</div></div>
|
||||
<body class="{{BODY_CLASS}}">
|
||||
{{{BODY}}}
|
||||
{{SCRIPT_BLOCK}}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
// Player playlist assembly, snapshot persistence, and playlist revision helpers.
|
||||
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
function createPlayerPlaylistService(options) {
|
||||
const pool = options && options.pool ? options.pool : null;
|
||||
const common = options && options.common ? options.common : null;
|
||||
const snapshotDir = options && options.snapshotDir ? options.snapshotDir : null;
|
||||
|
||||
if (!pool) {
|
||||
throw new Error('pool is required');
|
||||
}
|
||||
if (!common) {
|
||||
throw new Error('common is required');
|
||||
}
|
||||
|
||||
function getSnapshotFilePath(slug) {
|
||||
if (!snapshotDir) {
|
||||
return null;
|
||||
}
|
||||
const normalizedSlug = String(slug || '').trim();
|
||||
if (!normalizedSlug) {
|
||||
return null;
|
||||
}
|
||||
return path.join(snapshotDir, `${normalizedSlug}.json`);
|
||||
}
|
||||
|
||||
async function readSnapshot(slug) {
|
||||
const filePath = getSnapshotFilePath(slug);
|
||||
if (!filePath) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const raw = await fs.promises.readFile(filePath, 'utf8');
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
return null;
|
||||
}
|
||||
return parsed;
|
||||
} catch (error) {
|
||||
if (error && error.code === 'ENOENT') {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeSnapshot(slug, payload) {
|
||||
const filePath = getSnapshotFilePath(slug);
|
||||
if (!filePath) {
|
||||
return;
|
||||
}
|
||||
await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
|
||||
await fs.promises.writeFile(filePath, JSON.stringify(payload, null, 2), 'utf8');
|
||||
}
|
||||
|
||||
async function buildScreenPlaylist(slug) {
|
||||
try {
|
||||
const [screenRows] = await pool.query('SELECT id, name, slug, playlist_id, created_at, modified_at, created_by, modified_by FROM d_screens WHERE slug = ?', [slug]);
|
||||
if (!screenRows.length) {
|
||||
return { screen: null, playlist: null, slides: [], rssFeeds: [], apiSources: [], timetableGroups: [] };
|
||||
}
|
||||
|
||||
const screen = screenRows[0];
|
||||
if (!screen.playlist_id) {
|
||||
const payloadWithoutPlaylist = {
|
||||
screen: screen,
|
||||
playlist: null,
|
||||
slides: [],
|
||||
rssFeeds: [],
|
||||
apiSources: [],
|
||||
timetableGroups: [],
|
||||
revision: getPlaylistRevision(screen, null, [], [], [], [], [], [])
|
||||
};
|
||||
await writeSnapshot(slug, payloadWithoutPlaylist);
|
||||
return payloadWithoutPlaylist;
|
||||
}
|
||||
|
||||
const [playlistRows] = await pool.query('SELECT id, name, fade_between_slides, skip_unavailable_rtmp, created_at, modified_at, created_by, modified_by FROM c_playlists WHERE id = ?', [screen.playlist_id]);
|
||||
const playlist = playlistRows[0] || null;
|
||||
const [slideRows] = await pool.query(`
|
||||
SELECT sl.id, sl.title, sl.template_id, sl.content_json, sl.created_at, sl.modified_at,
|
||||
ps.position, ps.duration_seconds AS duration_seconds, ps.use_video_duration, ps.disable_audio,
|
||||
st.name AS template_name, cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height, cs.width AS canvas_width, cs.height AS canvas_height
|
||||
FROM c_playlist_slides ps
|
||||
JOIN c_slides sl ON sl.id = ps.slide_id
|
||||
LEFT JOIN c_templates st ON st.id = sl.template_id
|
||||
LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id
|
||||
WHERE ps.playlist_id = ?
|
||||
ORDER BY ps.position ASC, ps.id ASC
|
||||
`, [screen.playlist_id]);
|
||||
const [scheduleRuleRows] = await pool.query(`
|
||||
SELECT r.id, r.playlist_slide_id, r.position, r.start_datetime, r.end_datetime, r.start_time, r.end_time, r.schedule_days_json, r.created_at, r.modified_at, r.created_by, r.modified_by
|
||||
FROM c_playlist_slide_schedule_rules r
|
||||
JOIN c_playlist_slides ps ON ps.id = r.playlist_slide_id
|
||||
WHERE ps.playlist_id = ?
|
||||
ORDER BY r.playlist_slide_id ASC, r.position ASC, r.id ASC
|
||||
`, [screen.playlist_id]);
|
||||
const rulesBySlideId = {};
|
||||
scheduleRuleRows.forEach(function (rule) {
|
||||
const slideId = Number(rule.playlist_slide_id);
|
||||
if (!rulesBySlideId[slideId]) {
|
||||
rulesBySlideId[slideId] = [];
|
||||
}
|
||||
rulesBySlideId[slideId].push(rule);
|
||||
});
|
||||
|
||||
const templateIds = slideRows
|
||||
.filter(function (slide) { return slide.template_id; })
|
||||
.map(function (slide) { return slide.template_id; });
|
||||
const templatesById = {};
|
||||
let templateRows = [];
|
||||
let regionRows = [];
|
||||
if (templateIds.length) {
|
||||
[templateRows] = await pool.query(`
|
||||
SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, st.background_color, st.created_at, st.modified_at,
|
||||
cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height, cs.width AS canvas_width, cs.height AS canvas_height
|
||||
FROM c_templates st
|
||||
LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id
|
||||
WHERE st.id IN (?)
|
||||
`, [templateIds]);
|
||||
[regionRows] = await pool.query('SELECT id, template_id, region_key, region_type, label, lock_ratio, animation_json, x, y, width, height, z_index, created_at, modified_at, created_by, modified_by FROM c_template_regions WHERE template_id IN (?) ORDER BY template_id ASC, z_index ASC, id ASC', [templateIds]);
|
||||
templateRows.forEach(function (template) {
|
||||
template.regions = regionRows.filter(function (region) { return region.template_id === template.id; });
|
||||
templatesById[template.id] = template;
|
||||
});
|
||||
}
|
||||
|
||||
function getVideoRegionDurationSeconds(contentJson) {
|
||||
if (!contentJson) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = common.parseJsonSafe(contentJson) || {};
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const videoRegion = Object.keys(parsed).map(function (key) { return parsed[key]; }).find(function (region) {
|
||||
return region && typeof region === 'object' && String(region.type || '').trim().toLowerCase() === 'video' && Number(region.duration_seconds || 0) > 0;
|
||||
});
|
||||
|
||||
const duration = Math.round(Number(videoRegion && videoRegion.duration_seconds || 0) * 1000) / 1000;
|
||||
return Number.isFinite(duration) && duration > 0 ? duration : null;
|
||||
} catch (_error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const slides = slideRows.map(function (slide) {
|
||||
const storedDuration = Number(slide.duration_seconds || 0);
|
||||
const videoDuration = slide.use_video_duration ? getVideoRegionDurationSeconds(slide.content_json) : null;
|
||||
const disableAudio = slide.disable_audio === undefined || slide.disable_audio === null ? true : Boolean(slide.disable_audio);
|
||||
const videoCacheBust = String(slide.modified_at || slide.content_json || slide.id || '');
|
||||
const content = common.parseJsonSafe(slide.content_json) || {};
|
||||
Object.keys(content).forEach(function (key) {
|
||||
const region = content[key];
|
||||
if (region && typeof region === 'object') {
|
||||
const regionType = String(region.type || '').trim().toLowerCase();
|
||||
if (regionType === 'video' || regionType === 'rtmp') {
|
||||
region.disable_audio = disableAudio;
|
||||
}
|
||||
}
|
||||
if (region && typeof region === 'object' && String(region.type || '').trim().toLowerCase() === 'video') {
|
||||
region.cache_bust = videoCacheBust;
|
||||
}
|
||||
});
|
||||
return {
|
||||
id: slide.id,
|
||||
title: slide.title,
|
||||
modified_at: slide.modified_at,
|
||||
duration_seconds: videoDuration || storedDuration,
|
||||
use_video_duration: Boolean(slide.use_video_duration),
|
||||
disable_audio: disableAudio,
|
||||
scheduleRules: rulesBySlideId[Number(slide.id)] || [],
|
||||
template_id: slide.template_id,
|
||||
template: slide.template_id ? templatesById[slide.template_id] || null : null,
|
||||
content: content
|
||||
};
|
||||
});
|
||||
|
||||
let rssFeeds = [];
|
||||
if (typeof common.fetchRssFeedsData === 'function' && typeof common.fetchRssFeedItemsByFeedId === 'function') {
|
||||
const rssData = await common.fetchRssFeedsData(pool);
|
||||
const feedRows = Array.isArray(rssData && rssData.rssFeeds) ? rssData.rssFeeds : [];
|
||||
rssFeeds = await Promise.all(feedRows.map(async function (feed) {
|
||||
const items = await common.fetchRssFeedItemsByFeedId(pool, feed.id);
|
||||
return Object.assign({}, feed, { items: items.map(function (item) {
|
||||
return typeof common.normalizeRssFeedItem === 'function' ? common.normalizeRssFeedItem(item) : item;
|
||||
}) });
|
||||
}));
|
||||
}
|
||||
|
||||
let apiSources = [];
|
||||
if (typeof common.fetchApiSourcesData === 'function') {
|
||||
const apiData = await common.fetchApiSourcesData(pool);
|
||||
const sourceRows = Array.isArray(apiData && apiData.apiSources) ? apiData.apiSources : [];
|
||||
apiSources = sourceRows.map(function (source) {
|
||||
return Object.assign({}, source, {
|
||||
responseJson: common.parseJsonSafe ? common.parseJsonSafe(source.last_response_json) : null
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
let timetableGroups = [];
|
||||
if (typeof common.fetchTimetablesData === 'function') {
|
||||
const timetableData = await common.fetchTimetablesData(pool);
|
||||
timetableGroups = Array.isArray(timetableData && timetableData.timetableGroups) ? timetableData.timetableGroups : [];
|
||||
}
|
||||
|
||||
const revision = getPlaylistRevision(screen, playlist, slideRows, scheduleRuleRows, templateRows, regionRows, rssFeeds, apiSources, timetableGroups);
|
||||
const payload = { screen: screen, playlist: playlist, slides: slides, rssFeeds: rssFeeds, apiSources: apiSources, timetableGroups: timetableGroups, revision: revision };
|
||||
await writeSnapshot(slug, payload);
|
||||
return payload;
|
||||
} catch (error) {
|
||||
const snapshot = await readSnapshot(slug);
|
||||
if (snapshot) {
|
||||
return snapshot;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function updatePlaylistRevisionHash(hash, value) {
|
||||
hash.update(String(value === null || value === undefined ? '' : value));
|
||||
hash.update('\0');
|
||||
}
|
||||
|
||||
function getPlaylistRevision(screen, playlist, slideRows, scheduleRuleRows, templateRows, regionRows, rssFeeds, apiSources, timetableGroups) {
|
||||
const hash = crypto.createHash('sha1');
|
||||
|
||||
updatePlaylistRevisionHash(hash, screen && screen.id);
|
||||
updatePlaylistRevisionHash(hash, screen && screen.playlist_id);
|
||||
updatePlaylistRevisionHash(hash, screen && screen.modified_at);
|
||||
|
||||
updatePlaylistRevisionHash(hash, playlist && playlist.id);
|
||||
updatePlaylistRevisionHash(hash, playlist && playlist.modified_at);
|
||||
updatePlaylistRevisionHash(hash, playlist && playlist.fade_between_slides);
|
||||
updatePlaylistRevisionHash(hash, playlist && playlist.skip_unavailable_rtmp);
|
||||
|
||||
(Array.isArray(slideRows) ? slideRows : []).forEach(function (slide) {
|
||||
updatePlaylistRevisionHash(hash, slide.id);
|
||||
updatePlaylistRevisionHash(hash, slide.title);
|
||||
updatePlaylistRevisionHash(hash, slide.template_id);
|
||||
updatePlaylistRevisionHash(hash, slide.content_json);
|
||||
updatePlaylistRevisionHash(hash, slide.modified_at);
|
||||
updatePlaylistRevisionHash(hash, slide.position);
|
||||
updatePlaylistRevisionHash(hash, slide.duration_seconds);
|
||||
updatePlaylistRevisionHash(hash, slide.use_video_duration);
|
||||
updatePlaylistRevisionHash(hash, slide.disable_audio);
|
||||
});
|
||||
|
||||
(Array.isArray(scheduleRuleRows) ? scheduleRuleRows : []).forEach(function (rule) {
|
||||
updatePlaylistRevisionHash(hash, rule.id);
|
||||
updatePlaylistRevisionHash(hash, rule.playlist_slide_id);
|
||||
updatePlaylistRevisionHash(hash, rule.position);
|
||||
updatePlaylistRevisionHash(hash, rule.start_datetime);
|
||||
updatePlaylistRevisionHash(hash, rule.end_datetime);
|
||||
updatePlaylistRevisionHash(hash, rule.start_time);
|
||||
updatePlaylistRevisionHash(hash, rule.end_time);
|
||||
updatePlaylistRevisionHash(hash, rule.schedule_days_json);
|
||||
});
|
||||
|
||||
(Array.isArray(templateRows) ? templateRows : []).forEach(function (template) {
|
||||
updatePlaylistRevisionHash(hash, template.id);
|
||||
updatePlaylistRevisionHash(hash, template.name);
|
||||
updatePlaylistRevisionHash(hash, template.canvas_size_id);
|
||||
updatePlaylistRevisionHash(hash, template.canvas_size_width);
|
||||
updatePlaylistRevisionHash(hash, template.canvas_size_height);
|
||||
updatePlaylistRevisionHash(hash, template.background_image_path);
|
||||
updatePlaylistRevisionHash(hash, template.background_color);
|
||||
updatePlaylistRevisionHash(hash, template.modified_at);
|
||||
});
|
||||
|
||||
(Array.isArray(regionRows) ? regionRows : []).forEach(function (region) {
|
||||
updatePlaylistRevisionHash(hash, region.id);
|
||||
updatePlaylistRevisionHash(hash, region.template_id);
|
||||
updatePlaylistRevisionHash(hash, region.region_key);
|
||||
updatePlaylistRevisionHash(hash, region.region_type);
|
||||
updatePlaylistRevisionHash(hash, region.label);
|
||||
updatePlaylistRevisionHash(hash, region.font_family);
|
||||
updatePlaylistRevisionHash(hash, region.x);
|
||||
updatePlaylistRevisionHash(hash, region.y);
|
||||
updatePlaylistRevisionHash(hash, region.width);
|
||||
updatePlaylistRevisionHash(hash, region.height);
|
||||
updatePlaylistRevisionHash(hash, region.z_index);
|
||||
updatePlaylistRevisionHash(hash, region.modified_at);
|
||||
});
|
||||
|
||||
updatePlaylistRevisionHash(hash, JSON.stringify(rssFeeds || []));
|
||||
updatePlaylistRevisionHash(hash, JSON.stringify(apiSources || []));
|
||||
updatePlaylistRevisionHash(hash, JSON.stringify(timetableGroups || []));
|
||||
|
||||
return hash.digest('hex');
|
||||
}
|
||||
|
||||
return {
|
||||
buildScreenPlaylist: buildScreenPlaylist
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createPlayerPlaylistService: createPlayerPlaylistService
|
||||
};
|
||||
@@ -9,6 +9,34 @@ body {
|
||||
font-family: Arial, sans-serif;
|
||||
}
|
||||
|
||||
body.onboarding-page {
|
||||
background:
|
||||
radial-gradient(circle at top, rgba(82, 144, 255, 0.28), transparent 32%),
|
||||
radial-gradient(circle at bottom right, rgba(34, 197, 94, 0.18), transparent 26%),
|
||||
linear-gradient(160deg, #09111f 0%, #0b1323 52%, #111827 100%);
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
body.onboarding-page #app {
|
||||
display: none;
|
||||
}
|
||||
|
||||
body.thumbnail-preview *,
|
||||
body.thumbnail-preview *::before,
|
||||
body.thumbnail-preview *::after {
|
||||
animation: none !important;
|
||||
animation-delay: 0s !important;
|
||||
animation-duration: 0s !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
body.thumbnail-preview .player-announcement-layer,
|
||||
body.thumbnail-preview .player-offline-banner {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
#app {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@@ -19,6 +47,166 @@ body {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.onboarding-shell {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: clamp(16px, 3vw, 40px);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.onboarding-card {
|
||||
width: min(100%, 1040px);
|
||||
padding: clamp(20px, 3vw, 40px);
|
||||
border-radius: 30px;
|
||||
background: rgba(10, 17, 30, 0.82);
|
||||
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||
box-shadow: 0 28px 80px rgba(0, 0, 0, 0.45);
|
||||
backdrop-filter: blur(14px);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.onboarding-card h1 {
|
||||
margin: 0 0 12px;
|
||||
font-size: clamp(2rem, 4vw, 3.2rem);
|
||||
line-height: 1.05;
|
||||
}
|
||||
|
||||
.onboarding-kicker {
|
||||
margin: 0 0 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.14em;
|
||||
color: #8ab4ff;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.onboarding-copy {
|
||||
margin: 0 0 28px;
|
||||
color: #cbd5e1;
|
||||
font-size: 1.03rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.onboarding-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(280px, 1fr) minmax(320px, 1fr);
|
||||
gap: clamp(20px, 3vw, 32px);
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.onboarding-qr-pane {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.onboarding-qr-frame {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 22px;
|
||||
border-radius: 26px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.onboarding-qr-frame img {
|
||||
width: min(100%, 320px);
|
||||
aspect-ratio: 1;
|
||||
display: block;
|
||||
background: #fff;
|
||||
border-radius: 18px;
|
||||
}
|
||||
|
||||
.onboarding-form {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.onboarding-form--local {
|
||||
align-content: start;
|
||||
padding: 22px;
|
||||
border-radius: 26px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.onboarding-form label {
|
||||
display: grid;
|
||||
gap: 9px;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.onboarding-form input[type="text"] {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
min-height: 48px;
|
||||
padding: 12px 16px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.28);
|
||||
background: rgba(15, 23, 42, 0.9);
|
||||
color: #f8fafc;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.onboarding-form select {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
min-height: 48px;
|
||||
padding: 12px 16px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.28);
|
||||
background: rgba(15, 23, 42, 0.9);
|
||||
color: #f8fafc;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.onboarding-form input[type="text"]::placeholder {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.onboarding-form button {
|
||||
appearance: none;
|
||||
border: 0;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, #60a5fa, #22c55e);
|
||||
color: #08111f;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
min-height: 48px;
|
||||
padding: 12px 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.onboarding-status {
|
||||
margin-top: 8px;
|
||||
min-height: 1.4em;
|
||||
color: #cbd5e1;
|
||||
font-size: 0.96rem;
|
||||
}
|
||||
|
||||
.onboarding-card--landing .onboarding-status {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (max-width: 860px), (orientation: portrait) {
|
||||
.onboarding-shell {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.onboarding-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.onboarding-card {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.onboarding-qr-frame img {
|
||||
width: min(100%, 280px);
|
||||
}
|
||||
}
|
||||
|
||||
.slide-shell {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
@@ -26,6 +214,8 @@ body {
|
||||
height: 100%;
|
||||
opacity: 0;
|
||||
transition: opacity 560ms ease;
|
||||
will-change: opacity;
|
||||
backface-visibility: hidden;
|
||||
}
|
||||
|
||||
.slide-shell.is-visible {
|
||||
@@ -47,6 +237,31 @@ body.screen-blackout #app {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.player-announcement-layer {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: var(--player-canvas-width, 100vw);
|
||||
height: var(--player-canvas-height, 100vh);
|
||||
max-width: 100vw;
|
||||
max-height: 100vh;
|
||||
transform: translate3d(-50%, -50%, 0);
|
||||
backface-visibility: hidden;
|
||||
z-index: 9999;
|
||||
pointer-events: none;
|
||||
display: flex;
|
||||
opacity: 0;
|
||||
transition: opacity 220ms ease;
|
||||
}
|
||||
|
||||
.player-announcement-layer.is-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.player-announcement-layer.is-closing {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.slide {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@@ -139,7 +354,9 @@ body.screen-blackout #app {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.template-region.text {
|
||||
.template-region.text,
|
||||
.template-region.api,
|
||||
.template-region.rss {
|
||||
color: #fff;
|
||||
display: block;
|
||||
padding: 0;
|
||||
@@ -149,38 +366,51 @@ body.screen-blackout #app {
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.template-region.text .template-region-text-scale {
|
||||
.template-region .template-region-text-scale {
|
||||
display: block;
|
||||
transform-origin: top left;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.template-region.text .template-region-text-scale > * {
|
||||
margin: 0;
|
||||
.template-region .template-region-text-scale > * {
|
||||
margin: 1em 0;
|
||||
}
|
||||
|
||||
.template-region.text .template-region-text-scale > * + * {
|
||||
margin-top: 0.5em;
|
||||
.template-region .template-region-text-scale > *:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.template-region.text .template-region-text-scale ul,
|
||||
.template-region.text .template-region-text-scale ol {
|
||||
.template-region .template-region-text-scale > *:last-child {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
.template-region .template-region-text-scale ul,
|
||||
.template-region .template-region-text-scale ol {
|
||||
padding-left: 1.2em;
|
||||
}
|
||||
|
||||
.template-region.text .template-region-text-scale code {
|
||||
.template-region .template-region-text-scale code {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.template-region.text > * {
|
||||
.template-region.text > *,
|
||||
.template-region.api > *,
|
||||
.template-region.rss > * {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.template-region.text > * + * {
|
||||
.template-region.text > * + *,
|
||||
.template-region.api > * + *,
|
||||
.template-region.rss > * + * {
|
||||
margin-top: 0.5em;
|
||||
}
|
||||
|
||||
.template-region.text ul,
|
||||
.template-region.text ol {
|
||||
.template-region.text ol,
|
||||
.template-region.api ul,
|
||||
.template-region.api ol,
|
||||
.template-region.rss ul,
|
||||
.template-region.rss ol {
|
||||
padding-left: 1.2em;
|
||||
}
|
||||
|
||||
@@ -191,6 +421,19 @@ body.screen-blackout #app {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.template-region.video {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.template-region.video video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
pointer-events: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.template-region.webpage iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@@ -200,6 +443,14 @@ body.screen-blackout #app {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.template-region.qr-code img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.template-region.html iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@@ -209,6 +460,24 @@ body.screen-blackout #app {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.template-region.rtmp {
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.template-region.rtmp video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.template-region-rtmp-placeholder {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.template-region-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@@ -220,29 +489,6 @@ body.screen-blackout #app {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.template-region.text table,
|
||||
.template-region.text th,
|
||||
.template-region.text td {
|
||||
border: 1px solid rgba(255, 255, 255, 0.35);
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.template-region.text table {
|
||||
width: 100%;
|
||||
border-spacing: 0;
|
||||
}
|
||||
|
||||
.template-region.text th,
|
||||
.template-region.text td {
|
||||
padding: 0.35em 0.5em;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.template-region.text th {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.webpage-preloads {
|
||||
position: fixed;
|
||||
width: 1px;
|
||||
@@ -260,3 +506,20 @@ body.screen-blackout #app {
|
||||
border: 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.template-region table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
border-spacing: 0;
|
||||
}
|
||||
|
||||
.template-region th,
|
||||
.template-region td {
|
||||
padding: 0.35em 0.5em;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.template-region th {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 332 KiB After Width: | Height: | Size: 19 KiB |
@@ -0,0 +1,80 @@
|
||||
(function () {
|
||||
var ANNOUNCEMENT_TYPES = ['lower-third', 'fullscreen', 'top-banner'];
|
||||
var ANNOUNCEMENT_ICONS = Array.isArray(window.pulseAnnouncementIconKeys) ? window.pulseAnnouncementIconKeys : [];
|
||||
var DEFAULT_ANNOUNCEMENT_ICON = String(window.pulseAnnouncementDefaultIconKey || ANNOUNCEMENT_ICONS[0] || '').trim().toLowerCase();
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function normalizeAnnouncementType(value) {
|
||||
var normalized = String(value || '').trim().toLowerCase();
|
||||
if (normalized === 'center-card') {
|
||||
return 'fullscreen';
|
||||
}
|
||||
return ANNOUNCEMENT_TYPES.indexOf(normalized) !== -1 ? normalized : 'lower-third';
|
||||
}
|
||||
|
||||
function normalizeAnnouncementColor(value) {
|
||||
var normalized = String(value || '').trim().toLowerCase();
|
||||
if (['primary', 'secondary', 'success', 'info', 'warning', 'danger', 'dark', 'light'].indexOf(normalized) !== -1) {
|
||||
return normalized;
|
||||
}
|
||||
return 'primary';
|
||||
}
|
||||
|
||||
function normalizeAnnouncementIcon(value) {
|
||||
var normalized = String(value || '').trim().toLowerCase();
|
||||
return ANNOUNCEMENT_ICONS.indexOf(normalized) !== -1 ? normalized : DEFAULT_ANNOUNCEMENT_ICON;
|
||||
}
|
||||
|
||||
function getAnnouncementColorTokens(colorKey) {
|
||||
var tokens = {
|
||||
primary: { accent: '#0d6efd', foreground: '#ffffff', glow: 'rgba(13, 110, 253, 0.32)' },
|
||||
secondary: { accent: '#6c757d', foreground: '#ffffff', glow: 'rgba(108, 117, 125, 0.30)' },
|
||||
success: { accent: '#198754', foreground: '#ffffff', glow: 'rgba(25, 135, 84, 0.34)' },
|
||||
info: { accent: '#0dcaf0', foreground: '#052c36', glow: 'rgba(13, 202, 240, 0.28)' },
|
||||
warning: { accent: '#ffc107', foreground: '#201400', glow: 'rgba(255, 193, 7, 0.30)' },
|
||||
danger: { accent: '#dc3545', foreground: '#ffffff', glow: 'rgba(220, 53, 69, 0.34)' },
|
||||
dark: { accent: '#212529', foreground: '#ffffff', glow: 'rgba(33, 37, 41, 0.34)' },
|
||||
light: { accent: '#f8f9fa', foreground: '#1f2937', glow: 'rgba(248, 249, 250, 0.34)' }
|
||||
};
|
||||
|
||||
return tokens[colorKey] || tokens.primary;
|
||||
}
|
||||
|
||||
function replaceTemplate(template, replacements) {
|
||||
return String(template || '').replace(/{{([A-Z0-9_]+)}}/g, function (_match, key) {
|
||||
return Object.prototype.hasOwnProperty.call(replacements, key) ? String(replacements[key]) : '';
|
||||
});
|
||||
}
|
||||
|
||||
function renderPlayerAnnouncementMarkup(announcement) {
|
||||
var normalizedType = normalizeAnnouncementType(announcement && announcement.announcement_type);
|
||||
var colorKey = normalizeAnnouncementColor(announcement && announcement.color_key);
|
||||
var colorTokens = getAnnouncementColorTokens(colorKey);
|
||||
var iconKey = normalizeAnnouncementIcon(announcement && announcement.icon_key);
|
||||
var text = escapeHtml(String(announcement && announcement.message || '').trim()).replace(/\n/g, '<br />');
|
||||
var titleText = escapeHtml(String(announcement && announcement.short_label || 'Announcement').trim() || 'Announcement');
|
||||
var templates = window.playerAnnouncementTemplates || {};
|
||||
var templateName = normalizedType === 'fullscreen' ? 'fullscreen' : normalizedType === 'top-banner' ? 'topBanner' : 'lowerThird';
|
||||
var template = templates[templateName] || '';
|
||||
|
||||
return replaceTemplate(template, {
|
||||
ACCENT: colorTokens.accent,
|
||||
FOREGROUND: colorTokens.foreground,
|
||||
GLOW: colorTokens.glow,
|
||||
ICON: iconKey,
|
||||
COLOR_KEY: colorKey,
|
||||
TITLE: titleText,
|
||||
MESSAGE: text
|
||||
});
|
||||
}
|
||||
|
||||
window.renderPlayerAnnouncementMarkup = renderPlayerAnnouncementMarkup;
|
||||
})();
|
||||
@@ -0,0 +1,558 @@
|
||||
// Capture the current viewport dimensions.
|
||||
function getCurrentViewport() {
|
||||
return {
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight
|
||||
};
|
||||
}
|
||||
|
||||
var slideOutroTimers = [];
|
||||
|
||||
// Command websocket and player-state helpers.
|
||||
// Send the current playback state to the command websocket.
|
||||
function sendCommandState(currentSlide) {
|
||||
if (!commandSocket || commandSocket.readyState !== WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
commandSocket.send(JSON.stringify({
|
||||
type: 'state',
|
||||
clientId: getCommandClientId(),
|
||||
clientName: getOnboardingClientName() || null,
|
||||
deviceId: getOnboardingDeviceId() || null,
|
||||
userAgent: window.navigator.userAgent || '',
|
||||
page: window.location.href,
|
||||
viewport: getCurrentViewport(),
|
||||
paused: isPaused,
|
||||
blackout: isBlackout,
|
||||
currentSlide: currentSlide ? {
|
||||
id: currentSlide.id || null,
|
||||
title: currentSlide.title || '',
|
||||
kind: currentSlide.kind || '',
|
||||
playlistSignature: currentPlaylistSignature || ''
|
||||
} : null
|
||||
}));
|
||||
}
|
||||
|
||||
// Debounce command-state updates during rapid changes.
|
||||
function scheduleCommandStateUpdate() {
|
||||
if (commandStateTimer) {
|
||||
window.clearTimeout(commandStateTimer);
|
||||
}
|
||||
commandStateTimer = window.setTimeout(function () {
|
||||
commandStateTimer = null;
|
||||
sendCommandState(lastRenderedSlide);
|
||||
}, 300);
|
||||
}
|
||||
|
||||
// Debounce rerenders after viewport changes.
|
||||
function scheduleViewportRenderUpdate() {
|
||||
if (viewportRenderTimer) {
|
||||
window.clearTimeout(viewportRenderTimer);
|
||||
}
|
||||
viewportRenderTimer = window.setTimeout(function () {
|
||||
viewportRenderTimer = null;
|
||||
if (slides.length) {
|
||||
var activeSlides = getCurrentActiveSlides();
|
||||
if (getCurrentRenderKey(activeSlides) === lastRenderedViewKey) {
|
||||
return;
|
||||
}
|
||||
showCurrent();
|
||||
}
|
||||
}, 150);
|
||||
}
|
||||
|
||||
// Cancel the current slide-advance timer.
|
||||
function clearSlideTimer() {
|
||||
if (timer) {
|
||||
window.clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
clearSlideOutroTimer();
|
||||
}
|
||||
|
||||
// Cancel any pending outro triggers for the current slide.
|
||||
function clearSlideOutroTimer() {
|
||||
if (!Array.isArray(slideOutroTimers) || !slideOutroTimers.length) {
|
||||
slideOutroTimers = [];
|
||||
return;
|
||||
}
|
||||
|
||||
slideOutroTimers.forEach(function (timerId) {
|
||||
window.clearTimeout(timerId);
|
||||
});
|
||||
slideOutroTimers = [];
|
||||
}
|
||||
|
||||
// Return the rendered slide root that is currently on screen.
|
||||
function getCurrentSlideRoot() {
|
||||
var shells = Array.prototype.slice.call(app ? app.querySelectorAll('.slide-shell') : []);
|
||||
if (shells.length) {
|
||||
return shells[shells.length - 1];
|
||||
}
|
||||
return app && app.firstElementChild ? app.firstElementChild : app;
|
||||
}
|
||||
|
||||
// Schedule the outgoing slide animation for each region so it finishes before removal.
|
||||
function scheduleSlideOutro(holdDelayMs) {
|
||||
clearSlideOutroTimer();
|
||||
|
||||
var currentRoot = getCurrentSlideRoot();
|
||||
if (!currentRoot || typeof getRegionAnimationPhaseTimings !== 'function' || typeof playRegionAnimation !== 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
var regionTimings = getRegionAnimationPhaseTimings(currentRoot, 'outro');
|
||||
if (!regionTimings.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
var slideDurationMs = Math.max(1, Math.round(Number(holdDelayMs || 0)));
|
||||
slideOutroTimers = regionTimings.map(function (entry) {
|
||||
var timingMs = Math.max(0, Math.round(Number(entry && entry.timingMs || 0)));
|
||||
var triggerDelayMs = Math.max(0, slideDurationMs - timingMs);
|
||||
return window.setTimeout(function () {
|
||||
if (!entry || !entry.element || !entry.element.isConnected) {
|
||||
return;
|
||||
}
|
||||
playRegionAnimation(entry.element, 'outro');
|
||||
}, triggerDelayMs);
|
||||
});
|
||||
}
|
||||
|
||||
// Schedule the next slide transition.
|
||||
function scheduleSlideAdvance(delayMs) {
|
||||
clearSlideTimer();
|
||||
var holdDelayMs = Math.max(1, Number(delayMs || 0));
|
||||
slideExpiresAt = Date.now() + holdDelayMs;
|
||||
scheduleSlideOutro(holdDelayMs);
|
||||
timer = window.setTimeout(function () {
|
||||
timer = null;
|
||||
slideExpiresAt = null;
|
||||
pausedRemainingMs = null;
|
||||
clearSlideOutroTimer();
|
||||
const activeSlides = getCurrentActiveSlides();
|
||||
if (activeSlides.length < 2) {
|
||||
refresh();
|
||||
return;
|
||||
}
|
||||
if (index >= activeSlides.length) {
|
||||
index = 0;
|
||||
}
|
||||
index = (index + 1) % activeSlides.length;
|
||||
showCurrent();
|
||||
}, holdDelayMs);
|
||||
}
|
||||
|
||||
// Return the slide duration without shifting it for fade timing.
|
||||
function getSlideHoldDelay(delayMs) {
|
||||
var holdDelayMs = Math.max(1, Number(delayMs || 0));
|
||||
return holdDelayMs;
|
||||
}
|
||||
|
||||
// Cancel any pending fade-transition cleanup.
|
||||
function clearSlideTransitionTimer() {
|
||||
if (slideTransitionTimer) {
|
||||
window.clearTimeout(slideTransitionTimer);
|
||||
slideTransitionTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function getPlayerRegionModules() {
|
||||
if (!window.pulsePlayerRegionTypes || typeof window.pulsePlayerRegionTypes.list !== 'function') {
|
||||
return [];
|
||||
}
|
||||
|
||||
return window.pulsePlayerRegionTypes.list();
|
||||
}
|
||||
|
||||
function isThumbnailPreview() {
|
||||
return Boolean(window.__pulseThumbnailPreview);
|
||||
}
|
||||
|
||||
function runRegionLifecycle(root, lifecycleName) {
|
||||
if (!root) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isThumbnailPreview() && lifecycleName === 'initRegion') {
|
||||
return;
|
||||
}
|
||||
|
||||
getPlayerRegionModules().forEach(function (entry) {
|
||||
var module = entry && entry.definition ? entry.definition : null;
|
||||
if (!module || typeof module[lifecycleName] !== 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
module[lifecycleName](root);
|
||||
} catch (_error) {
|
||||
// Keep slide rendering resilient if a region lifecycle hook fails.
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function destroyRegionInstances(root) {
|
||||
runRegionLifecycle(root, 'destroyRegion');
|
||||
}
|
||||
|
||||
function initializeRegionInstances(root) {
|
||||
runRegionLifecycle(root, 'initRegion');
|
||||
}
|
||||
|
||||
// Swap slide markup with optional fade animation.
|
||||
function renderSlideMarkup(markup, shouldFade) {
|
||||
clearSlideTransitionTimer();
|
||||
destroyRegionInstances(app);
|
||||
if (typeof destroyRtmpRegions === 'function') {
|
||||
destroyRtmpRegions(app);
|
||||
}
|
||||
|
||||
function initializeRenderedVideoPlayback(root, delayMs) {
|
||||
if (!root) {
|
||||
return;
|
||||
}
|
||||
|
||||
var startDelayMs = Math.max(0, Number(delayMs || 0));
|
||||
var videos = root.querySelectorAll('.template-region.video video');
|
||||
Array.prototype.forEach.call(videos, function (video) {
|
||||
if (!video) {
|
||||
return;
|
||||
}
|
||||
|
||||
var playbackScheduled = false;
|
||||
|
||||
video.autoplay = true;
|
||||
video.loop = true;
|
||||
video.muted = !(video.dataset && video.dataset.disableAudio === '0');
|
||||
video.playsInline = true;
|
||||
|
||||
function startPlayback() {
|
||||
var playPromise = video.play && video.play();
|
||||
if (playPromise && typeof playPromise.catch === 'function') {
|
||||
playPromise.catch(function () {
|
||||
return null;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function schedulePlaybackStart() {
|
||||
if (playbackScheduled) {
|
||||
return;
|
||||
}
|
||||
playbackScheduled = true;
|
||||
if (startDelayMs > 0) {
|
||||
window.setTimeout(startPlayback, startDelayMs);
|
||||
return;
|
||||
}
|
||||
startPlayback();
|
||||
}
|
||||
|
||||
if (video.readyState >= 2) {
|
||||
schedulePlaybackStart();
|
||||
return;
|
||||
}
|
||||
|
||||
video.addEventListener('canplay', schedulePlaybackStart, { once: true });
|
||||
video.addEventListener('loadedmetadata', function () {
|
||||
if (video.readyState >= 2) {
|
||||
schedulePlaybackStart();
|
||||
}
|
||||
}, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
if (!shouldFade) {
|
||||
app.innerHTML = markup;
|
||||
if (typeof syncRtmpRegions === 'function') {
|
||||
syncRtmpRegions(app);
|
||||
}
|
||||
if (!isThumbnailPreview()) {
|
||||
initializeRegionInstances(app);
|
||||
}
|
||||
initializeRenderedVideoPlayback(app);
|
||||
if (typeof playRegionAnimations === 'function') {
|
||||
window.requestAnimationFrame(function () {
|
||||
playRegionAnimations(app, 'intro');
|
||||
});
|
||||
}
|
||||
return app.firstElementChild;
|
||||
}
|
||||
|
||||
var topLevelChildren = Array.prototype.slice.call(app.children || []);
|
||||
var existingShells = topLevelChildren.filter(function (child) {
|
||||
return child && child.classList && child.classList.contains('slide-shell');
|
||||
});
|
||||
var previousShell = existingShells.length ? existingShells[existingShells.length - 1] : app.firstElementChild;
|
||||
|
||||
if (existingShells.length > 1) {
|
||||
existingShells.slice(0, -1).forEach(function (shell) {
|
||||
if (shell && shell.parentNode) {
|
||||
shell.parentNode.removeChild(shell);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function pauseRenderedVideoPlayback(root, delayMs) {
|
||||
if (!root) {
|
||||
return;
|
||||
}
|
||||
|
||||
var pauseDelayMs = Math.max(0, Number(delayMs || 0));
|
||||
var videos = root.querySelectorAll('.template-region.video video, .slide-media video');
|
||||
Array.prototype.forEach.call(videos, function (video) {
|
||||
if (!video || typeof video.pause !== 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
window.setTimeout(function () {
|
||||
try {
|
||||
video.pause();
|
||||
} catch (_error) {
|
||||
// Ignore pause errors from detached or unsupported media elements.
|
||||
}
|
||||
}, pauseDelayMs);
|
||||
});
|
||||
}
|
||||
|
||||
var nextShell = document.createElement('div');
|
||||
nextShell.className = 'slide-shell';
|
||||
nextShell.style.opacity = '0';
|
||||
nextShell.innerHTML = markup;
|
||||
|
||||
if (!previousShell || (previousShell.classList && previousShell.classList.contains('empty'))) {
|
||||
app.innerHTML = '';
|
||||
nextShell.style.opacity = '1';
|
||||
app.appendChild(nextShell);
|
||||
if (typeof syncRtmpRegions === 'function') {
|
||||
syncRtmpRegions(nextShell);
|
||||
}
|
||||
initializeRegionInstances(nextShell);
|
||||
initializeRenderedVideoPlayback(nextShell, slideFadeDurationMs / 2);
|
||||
return nextShell;
|
||||
}
|
||||
|
||||
if (!previousShell.classList.contains('slide-shell')) {
|
||||
previousShell.classList.add('slide-shell');
|
||||
}
|
||||
previousShell.style.opacity = '1';
|
||||
pauseRenderedVideoPlayback(previousShell, slideFadeDurationMs / 2);
|
||||
|
||||
app.appendChild(nextShell);
|
||||
void nextShell.offsetHeight;
|
||||
window.requestAnimationFrame(function () {
|
||||
nextShell.style.opacity = '1';
|
||||
previousShell.style.opacity = '0';
|
||||
if (typeof playRegionAnimations === 'function') {
|
||||
playRegionAnimations(nextShell, 'intro');
|
||||
}
|
||||
});
|
||||
|
||||
if (typeof syncRtmpRegions === 'function') {
|
||||
syncRtmpRegions(nextShell);
|
||||
}
|
||||
|
||||
if (!isThumbnailPreview()) {
|
||||
initializeRegionInstances(nextShell);
|
||||
}
|
||||
initializeRenderedVideoPlayback(nextShell, slideFadeDurationMs / 2);
|
||||
|
||||
slideTransitionTimer = window.setTimeout(function () {
|
||||
if (previousShell && previousShell.parentNode) {
|
||||
previousShell.parentNode.removeChild(previousShell);
|
||||
}
|
||||
if (nextShell) {
|
||||
nextShell.style.opacity = '1';
|
||||
}
|
||||
slideTransitionTimer = null;
|
||||
}, slideFadeDurationMs);
|
||||
|
||||
return nextShell;
|
||||
}
|
||||
|
||||
// Mirror blackout state onto the document body.
|
||||
function syncBlackoutState() {
|
||||
document.body.classList.toggle('screen-blackout', isBlackout);
|
||||
}
|
||||
|
||||
// Apply pause state and preserve remaining slide time.
|
||||
function setPaused(nextPaused) {
|
||||
var normalized = Boolean(nextPaused);
|
||||
if (isPaused === normalized) {
|
||||
return;
|
||||
}
|
||||
if (normalized) {
|
||||
pausedRemainingMs = slideExpiresAt ? Math.max(0, slideExpiresAt - Date.now()) : null;
|
||||
isPaused = true;
|
||||
clearSlideTimer();
|
||||
sendCommandState(lastRenderedSlide);
|
||||
return;
|
||||
}
|
||||
|
||||
isPaused = false;
|
||||
sendCommandState(lastRenderedSlide);
|
||||
if (!slides.length || !lastRenderedSlide) {
|
||||
return;
|
||||
}
|
||||
if (pausedRemainingMs !== null) {
|
||||
scheduleSlideAdvance(pausedRemainingMs);
|
||||
pausedRemainingMs = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply blackout state and notify the server.
|
||||
function setBlackout(nextBlackout) {
|
||||
var normalized = Boolean(nextBlackout);
|
||||
if (isBlackout === normalized) {
|
||||
return;
|
||||
}
|
||||
isBlackout = normalized;
|
||||
syncBlackoutState();
|
||||
sendCommandState(lastRenderedSlide);
|
||||
}
|
||||
|
||||
// Coerce command payload values into booleans or null.
|
||||
function normalizeBoolean(value) {
|
||||
if (value === true || value === false) {
|
||||
return value;
|
||||
}
|
||||
if (value === null || value === undefined) {
|
||||
return null;
|
||||
}
|
||||
var normalized = String(value).trim().toLowerCase();
|
||||
if (['1', 'true', 'yes', 'on'].indexOf(normalized) !== -1) {
|
||||
return true;
|
||||
}
|
||||
if (['0', 'false', 'no', 'off', ''].indexOf(normalized) !== -1) {
|
||||
return false;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Move to the previous or next active slide.
|
||||
function navigateSlides(offset) {
|
||||
const manualSlides = getCurrentActiveSlides();
|
||||
if (!manualSlides.length) {
|
||||
return;
|
||||
}
|
||||
let currentIndex = manualSlides.findIndex(function (slide) {
|
||||
return slide && lastRenderedSlide && slide.id === lastRenderedSlide.id;
|
||||
});
|
||||
if (currentIndex < 0) {
|
||||
currentIndex = Math.min(Math.max(index, 0), manualSlides.length - 1);
|
||||
}
|
||||
const nextIndex = (currentIndex + offset + manualSlides.length) % manualSlides.length;
|
||||
clearSlideTimer();
|
||||
applyPendingPlaylistUpdate();
|
||||
renderSlideAtIndex(manualSlides, nextIndex);
|
||||
}
|
||||
|
||||
// Route incoming websocket command messages.
|
||||
function handleCommandMessage(rawMessage) {
|
||||
var payload;
|
||||
try {
|
||||
payload = JSON.parse(String(rawMessage || ''));
|
||||
} catch (_error) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!payload || payload.type !== 'command') {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (payload.command) {
|
||||
case 'refresh':
|
||||
refresh();
|
||||
return;
|
||||
case 'setclientname':
|
||||
if (payload.clientName) {
|
||||
applyOnboardingClientName(payload.clientName, commandSocket);
|
||||
}
|
||||
return;
|
||||
case 'redirect':
|
||||
if (payload.url) {
|
||||
window.location.replace(String(payload.url));
|
||||
}
|
||||
return;
|
||||
case 'pause':
|
||||
var desiredPause = normalizeBoolean(payload.paused);
|
||||
if (desiredPause !== null) {
|
||||
setPaused(desiredPause);
|
||||
} else {
|
||||
setPaused(!isPaused);
|
||||
}
|
||||
return;
|
||||
case 'blackout':
|
||||
var desiredBlackout = normalizeBoolean(payload.blackout);
|
||||
if (desiredBlackout !== null) {
|
||||
setBlackout(desiredBlackout);
|
||||
} else {
|
||||
setBlackout(!isBlackout);
|
||||
}
|
||||
return;
|
||||
case 'previous':
|
||||
navigateSlides(-1);
|
||||
return;
|
||||
case 'next':
|
||||
navigateSlides(1);
|
||||
return;
|
||||
case 'reload':
|
||||
window.location.reload();
|
||||
return;
|
||||
case 'announcement-refresh':
|
||||
if (typeof refreshAnnouncementOverlay === 'function') {
|
||||
refreshAnnouncementOverlay();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Retry the command websocket after a disconnect.
|
||||
function scheduleCommandReconnect() {
|
||||
if (commandReconnectTimer) {
|
||||
return;
|
||||
}
|
||||
commandReconnectTimer = window.setTimeout(function () {
|
||||
commandReconnectTimer = null;
|
||||
connectCommandSocket();
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
// Open and wire the command websocket connection.
|
||||
function connectCommandSocket() {
|
||||
if (!window.WebSocket) {
|
||||
return;
|
||||
}
|
||||
if (commandSocket && (commandSocket.readyState === WebSocket.OPEN || commandSocket.readyState === WebSocket.CONNECTING)) {
|
||||
return;
|
||||
}
|
||||
var socket = new WebSocket(new URL(commandSocketPath, window.location.origin).toString());
|
||||
commandSocket = socket;
|
||||
|
||||
socket.onopen = function () {
|
||||
if (typeof syncOnboardingClientNameFromServer === 'function') {
|
||||
syncOnboardingClientNameFromServer(socket).then(function () {
|
||||
sendCommandState(socket);
|
||||
});
|
||||
return;
|
||||
}
|
||||
sendCommandState(socket);
|
||||
};
|
||||
|
||||
socket.onmessage = function (event) {
|
||||
handleCommandMessage(event.data);
|
||||
};
|
||||
|
||||
socket.onclose = function () {
|
||||
commandSocket = null;
|
||||
scheduleCommandReconnect();
|
||||
};
|
||||
|
||||
socket.onerror = function () {
|
||||
try {
|
||||
socket.close();
|
||||
} catch (_error) {
|
||||
// ignore socket close errors
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
// Show or hide the offline status banner.
|
||||
function setOfflineBannerVisible(visible, message) {
|
||||
if (window.__pulseThumbnailPreview) {
|
||||
return;
|
||||
}
|
||||
var normalizedVisible = Boolean(visible);
|
||||
var bannerMessage = String(message || 'Offline mode: using cached playlist.').trim();
|
||||
if (normalizedVisible) {
|
||||
if (!offlineBanner) {
|
||||
offlineBanner = document.createElement('div');
|
||||
offlineBanner.className = 'player-offline-banner';
|
||||
offlineBanner.style.position = 'fixed';
|
||||
offlineBanner.style.right = '0';
|
||||
offlineBanner.style.bottom = '0';
|
||||
offlineBanner.style.left = 'auto';
|
||||
offlineBanner.style.top = 'auto';
|
||||
offlineBanner.style.width = '1.25rem';
|
||||
offlineBanner.style.height = '1.25rem';
|
||||
offlineBanner.style.zIndex = '9999';
|
||||
offlineBanner.style.background = 'linear-gradient(135deg, #ff4d4f 0%, #b00020 100%)';
|
||||
offlineBanner.style.clipPath = 'circle(100% at 100% 100%)';
|
||||
offlineBanner.style.webkitClipPath = 'circle(100% at 100% 100%)';
|
||||
offlineBanner.style.boxShadow = '0 0 0 1px rgba(0, 0, 0, 0.16), 0 4px 12px rgba(0, 0, 0, 0.18)';
|
||||
offlineBanner.style.pointerEvents = 'none';
|
||||
document.body.appendChild(offlineBanner);
|
||||
}
|
||||
offlineBanner.textContent = '';
|
||||
offlineBanner.setAttribute('aria-label', bannerMessage);
|
||||
offlineBanner.setAttribute('role', 'img');
|
||||
offlineBanner.title = bannerMessage;
|
||||
offlineBannerVisible = true;
|
||||
return;
|
||||
}
|
||||
|
||||
offlineBannerVisible = false;
|
||||
if (offlineBanner && offlineBanner.parentNode) {
|
||||
offlineBanner.parentNode.removeChild(offlineBanner);
|
||||
}
|
||||
offlineBanner = null;
|
||||
}
|
||||
|
||||
// Update the offline banner based on connectivity or playlist availability.
|
||||
function syncOfflineBanner() {
|
||||
if (window.__pulseThumbnailPreview) {
|
||||
return;
|
||||
}
|
||||
if (!window.navigator.onLine) {
|
||||
setOfflineBannerVisible(true, 'Offline mode: using cached playlist.');
|
||||
return;
|
||||
}
|
||||
if (offlineBannerVisible) {
|
||||
setOfflineBannerVisible(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Clear any pending playlist refresh retry.
|
||||
function clearRefreshRetry() {
|
||||
if (refreshRetryTimer) {
|
||||
window.clearTimeout(refreshRetryTimer);
|
||||
refreshRetryTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Retry playlist refresh with a short backoff while the player is offline.
|
||||
function scheduleRefreshRetry() {
|
||||
if (window.__pulseThumbnailPreview) {
|
||||
return;
|
||||
}
|
||||
if (refreshRetryTimer) {
|
||||
return;
|
||||
}
|
||||
if (window.navigator.onLine === false) {
|
||||
refreshRetryDelayMs = refreshRetryDelayMs ? Math.min(refreshRetryDelayMs * 2, 15000) : 3000;
|
||||
} else {
|
||||
refreshRetryDelayMs = refreshRetryDelayMs ? Math.min(refreshRetryDelayMs * 2, 8000) : 3000;
|
||||
}
|
||||
refreshRetryTimer = window.setTimeout(function () {
|
||||
refreshRetryTimer = null;
|
||||
refresh();
|
||||
}, refreshRetryDelayMs);
|
||||
}
|
||||
|
||||
function clearScreenWakeLockRetry() {
|
||||
if (screenWakeLockRetryTimer) {
|
||||
window.clearTimeout(screenWakeLockRetryTimer);
|
||||
screenWakeLockRetryTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function supportsScreenWakeLock() {
|
||||
return Boolean(window.navigator && window.navigator.wakeLock && typeof window.navigator.wakeLock.request === 'function');
|
||||
}
|
||||
|
||||
function scheduleScreenWakeLockRetry() {
|
||||
if (screenWakeLockRetryTimer) {
|
||||
return;
|
||||
}
|
||||
if (!supportsScreenWakeLock() || document.visibilityState !== 'visible') {
|
||||
return;
|
||||
}
|
||||
screenWakeLockRetryTimer = window.setTimeout(function () {
|
||||
screenWakeLockRetryTimer = null;
|
||||
acquireScreenWakeLock();
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
function releaseScreenWakeLock() {
|
||||
if (screenWakeLock && typeof screenWakeLock.release === 'function') {
|
||||
try {
|
||||
screenWakeLock.release();
|
||||
} catch (_error) {
|
||||
// ignore wake lock release errors
|
||||
}
|
||||
}
|
||||
screenWakeLock = null;
|
||||
screenWakeLockRequestPromise = null;
|
||||
clearScreenWakeLockRetry();
|
||||
}
|
||||
|
||||
function acquireScreenWakeLock() {
|
||||
if (!supportsScreenWakeLock() || document.visibilityState !== 'visible') {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
if (screenWakeLockRequestPromise) {
|
||||
return screenWakeLockRequestPromise;
|
||||
}
|
||||
if (screenWakeLock && screenWakeLock.released === false) {
|
||||
return Promise.resolve(screenWakeLock);
|
||||
}
|
||||
|
||||
screenWakeLockRequestPromise = window.navigator.wakeLock.request('screen').then(function (sentinel) {
|
||||
screenWakeLock = sentinel;
|
||||
screenWakeLock.addEventListener('release', function () {
|
||||
screenWakeLock = null;
|
||||
if (document.visibilityState === 'visible') {
|
||||
scheduleScreenWakeLockRetry();
|
||||
}
|
||||
});
|
||||
clearScreenWakeLockRetry();
|
||||
return screenWakeLock;
|
||||
}).catch(function (error) {
|
||||
screenWakeLock = null;
|
||||
if (error && error.name !== 'NotAllowedError') {
|
||||
scheduleScreenWakeLockRetry();
|
||||
}
|
||||
return null;
|
||||
}).finally(function () {
|
||||
screenWakeLockRequestPromise = null;
|
||||
});
|
||||
|
||||
return screenWakeLockRequestPromise;
|
||||
}
|
||||
|
||||
function syncScreenWakeLock() {
|
||||
if (!supportsScreenWakeLock()) {
|
||||
return;
|
||||
}
|
||||
if (document.visibilityState === 'visible') {
|
||||
acquireScreenWakeLock();
|
||||
return;
|
||||
}
|
||||
releaseScreenWakeLock();
|
||||
}
|
||||
|
||||
// Clear the retry cadence after a successful refresh.
|
||||
function markRefreshHealthy() {
|
||||
refreshRetryDelayMs = 0;
|
||||
clearRefreshRetry();
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
// Render the slide at the requested index within the active set.
|
||||
async function renderSlideAtIndex(sourceSlides, targetIndex) {
|
||||
const availableSlides = Array.isArray(sourceSlides) ? sourceSlides : [];
|
||||
if (!availableSlides.length) {
|
||||
renderEmpty(slides.length ? 'No slides are scheduled for this time.' : 'No slides assigned to this screen yet.');
|
||||
lastRenderedViewKey = getCurrentRenderKey(availableSlides);
|
||||
return false;
|
||||
}
|
||||
|
||||
let normalizedIndex = Number(targetIndex || 0);
|
||||
if (!Number.isFinite(normalizedIndex) || normalizedIndex < 0 || normalizedIndex >= availableSlides.length) {
|
||||
}
|
||||
|
||||
var slideCount = availableSlides.length;
|
||||
var currentIndex = normalizedIndex;
|
||||
var slide = null;
|
||||
var attempts = 0;
|
||||
var renderRequestId = ++slideRenderRequestId;
|
||||
|
||||
while (attempts < slideCount) {
|
||||
slide = availableSlides[currentIndex];
|
||||
if (!slide) {
|
||||
break;
|
||||
}
|
||||
if (currentPlaylistSkipUnavailableRtmp && typeof probeRtmpSlideAvailability === 'function') {
|
||||
var isAvailable = await probeRtmpSlideAvailability(slide);
|
||||
if (!isAvailable) {
|
||||
if (renderRequestId !== slideRenderRequestId) {
|
||||
return false;
|
||||
}
|
||||
currentIndex = (currentIndex + 1) % slideCount;
|
||||
attempts += 1;
|
||||
slide = null;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (renderRequestId !== slideRenderRequestId) {
|
||||
break;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (!slide) {
|
||||
if (currentPlaylistSkipUnavailableRtmp) {
|
||||
renderEmpty(slides.length ? 'No RTMP slides are currently available.' : 'No slides assigned to this screen yet.');
|
||||
lastRenderedViewKey = getCurrentRenderKey(availableSlides);
|
||||
scheduleRefreshRetry();
|
||||
return false;
|
||||
}
|
||||
renderEmpty(slides.length ? 'No slides are scheduled for this time.' : 'No slides assigned to this screen yet.');
|
||||
return false;
|
||||
}
|
||||
|
||||
index = currentIndex;
|
||||
var markup = buildSlideMarkup(slide);
|
||||
renderSlideMarkup(markup, currentPlaylistFadeBetweenSlides);
|
||||
sendCommandState(slide);
|
||||
if (!isPaused) {
|
||||
scheduleSlideAdvance(getSlideHoldDelay(Math.max(1, Number(slide.duration_seconds || 10)) * 1000));
|
||||
}
|
||||
lastRenderedViewKey = getCurrentRenderKey(availableSlides);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Promote a deferred playlist update at the next safe point.
|
||||
function applyPendingPlaylistUpdate() {
|
||||
if (!pendingPlaylistUpdate) {
|
||||
return false;
|
||||
}
|
||||
slides = pendingPlaylistUpdate.slides;
|
||||
currentPlaylistSignature = pendingPlaylistUpdate.signature;
|
||||
currentPlaylistFadeBetweenSlides = pendingPlaylistUpdate.fadeBetweenSlides;
|
||||
currentPlaylistSkipUnavailableRtmp = Boolean(pendingPlaylistUpdate.skipUnavailableRtmp);
|
||||
pendingPlaylistUpdate = null;
|
||||
clearActiveSlidesCache();
|
||||
slideMarkupCache = Object.create(null);
|
||||
templateLayoutCache = Object.create(null);
|
||||
templateRenderPlanCache = Object.create(null);
|
||||
renderCacheViewportKey = window.innerWidth + 'x' + window.innerHeight;
|
||||
index = 0;
|
||||
logDebug('Applied updated playlist on slide transition.');
|
||||
return true;
|
||||
}
|
||||
|
||||
// Render the current active slide or the empty state.
|
||||
function showCurrent() {
|
||||
clearSlideTimer();
|
||||
applyPendingPlaylistUpdate();
|
||||
const activeSlides = getCurrentActiveSlides();
|
||||
syncWebpagePreloads(activeSlides, index);
|
||||
if (typeof syncRtmpWarmups === 'function') {
|
||||
syncRtmpWarmups(activeSlides, index);
|
||||
}
|
||||
if (!activeSlides.length) {
|
||||
renderEmpty(slides.length ? 'No slides are scheduled for this time.' : 'No slides assigned to this screen yet.');
|
||||
lastRenderedViewKey = getCurrentRenderKey(activeSlides);
|
||||
return;
|
||||
}
|
||||
void renderSlideAtIndex(activeSlides, index);
|
||||
lastRenderedViewKey = getCurrentRenderKey(activeSlides);
|
||||
}
|
||||
|
||||
// Skip the current slide when an RTMP feed is unavailable and the playlist asks for it.
|
||||
function handleRtmpPlaybackFailure(message, options) {
|
||||
if (!currentPlaylistSkipUnavailableRtmp) {
|
||||
return false;
|
||||
}
|
||||
|
||||
var silent = Boolean(options && options.silent);
|
||||
const activeSlides = getCurrentActiveSlides();
|
||||
if (!activeSlides.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
clearSlideTimer();
|
||||
if (!silent) {
|
||||
logDebug('Skipping RTMP slide because the stream is unavailable.', String(message || ''), 'error');
|
||||
}
|
||||
|
||||
const nextIndex = (Number(index || 0) + 1) % activeSlides.length;
|
||||
window.setTimeout(function () {
|
||||
if (!getCurrentActiveSlides().length) {
|
||||
return;
|
||||
}
|
||||
void renderSlideAtIndex(getCurrentActiveSlides(), nextIndex);
|
||||
}, 0);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Fetch the latest playlist and queue any updates.
|
||||
function refresh() {
|
||||
var request = new XMLHttpRequest();
|
||||
var url = window.location.origin + '/api/screens/' + encodeURIComponent(slug) + '/playlist?ts=' + Date.now();
|
||||
request.open('GET', url, true);
|
||||
request.timeout = 2500;
|
||||
if (window.__pulsePageAuthToken) {
|
||||
request.setRequestHeader('x-pulse-page-auth', window.__pulsePageAuthToken);
|
||||
}
|
||||
if (currentPlaylistEtag) {
|
||||
request.setRequestHeader('If-None-Match', currentPlaylistEtag);
|
||||
}
|
||||
request.onreadystatechange = function () {
|
||||
if (request.readyState !== 4) {
|
||||
return;
|
||||
}
|
||||
if (request.status === 304) {
|
||||
markRefreshHealthy();
|
||||
setOfflineBannerVisible(false);
|
||||
if (lastRenderedSlide && getCurrentActiveSlides().length < 2) {
|
||||
scheduleSlideAdvance(getSlideHoldDelay(Math.max(1, Number(lastRenderedSlide.duration_seconds || 10)) * 1000));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (request.status < 200 || request.status >= 300) {
|
||||
setOfflineBannerVisible(true);
|
||||
scheduleRefreshRetry();
|
||||
logDebug(
|
||||
'Screen not found or playlist unavailable.',
|
||||
['URL: ' + url, 'Status: ' + request.status + ' ' + request.statusText, 'Response: ' + String(request.responseText || '').slice(0, 1000)].join(' | '),
|
||||
'error'
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const responseEtag = String(request.getResponseHeader('ETag') || '').trim();
|
||||
const data = JSON.parse(request.responseText || '{}');
|
||||
const nextSignature = getPlaylistRevision(data);
|
||||
const nextSlides = Array.isArray(data.slides) ? data.slides.map(normalizeSlide) : [];
|
||||
const nextFadeBetweenSlides = Boolean(data && data.playlist && data.playlist.fade_between_slides);
|
||||
const nextSkipUnavailableRtmp = Boolean(data && data.playlist && data.playlist.skip_unavailable_rtmp);
|
||||
savePlaylistSnapshot({
|
||||
slides: nextSlides,
|
||||
signature: nextSignature,
|
||||
fadeBetweenSlides: nextFadeBetweenSlides,
|
||||
skipUnavailableRtmp: nextSkipUnavailableRtmp,
|
||||
etag: responseEtag
|
||||
});
|
||||
markRefreshHealthy();
|
||||
setOfflineBannerVisible(false);
|
||||
const currentActiveSlides = getCurrentActiveSlides();
|
||||
if (responseEtag) {
|
||||
currentPlaylistEtag = responseEtag;
|
||||
}
|
||||
if (!currentPlaylistSignature) {
|
||||
slides = nextSlides;
|
||||
currentPlaylistSignature = nextSignature;
|
||||
currentPlaylistFadeBetweenSlides = nextFadeBetweenSlides;
|
||||
currentPlaylistSkipUnavailableRtmp = nextSkipUnavailableRtmp;
|
||||
index = 0;
|
||||
showCurrent();
|
||||
sendCommandState(lastRenderedSlide);
|
||||
return;
|
||||
}
|
||||
if (nextSignature === currentPlaylistSignature || (pendingPlaylistUpdate && nextSignature === pendingPlaylistUpdate.signature)) {
|
||||
if (currentActiveSlides.length < 2 && lastRenderedSlide) {
|
||||
scheduleSlideAdvance(getSlideHoldDelay(Math.max(1, Number(lastRenderedSlide.duration_seconds || 10)) * 1000));
|
||||
}
|
||||
return;
|
||||
}
|
||||
syncWebpagePreloads(getActiveSlidesFrom(nextSlides), index);
|
||||
if (typeof syncRtmpWarmups === 'function') {
|
||||
syncRtmpWarmups(getActiveSlidesFrom(nextSlides), index);
|
||||
}
|
||||
if (currentActiveSlides.length < 2) {
|
||||
slides = nextSlides;
|
||||
currentPlaylistSignature = nextSignature;
|
||||
currentPlaylistFadeBetweenSlides = nextFadeBetweenSlides;
|
||||
currentPlaylistSkipUnavailableRtmp = nextSkipUnavailableRtmp;
|
||||
pendingPlaylistUpdate = null;
|
||||
index = 0;
|
||||
showCurrent();
|
||||
sendCommandState(lastRenderedSlide);
|
||||
return;
|
||||
}
|
||||
pendingPlaylistUpdate = {
|
||||
slides: nextSlides,
|
||||
signature: nextSignature,
|
||||
fadeBetweenSlides: nextFadeBetweenSlides,
|
||||
skipUnavailableRtmp: nextSkipUnavailableRtmp
|
||||
};
|
||||
logDebug('Playlist update detected; applying on next slide transition.');
|
||||
} catch (_error) {
|
||||
logDebug(
|
||||
'Unable to load screen playlist.',
|
||||
['URL: ' + url, 'Response: ' + String(request.responseText || '').slice(0, 1000)].join(' | '),
|
||||
'error'
|
||||
);
|
||||
setOfflineBannerVisible(true);
|
||||
scheduleRefreshRetry();
|
||||
}
|
||||
};
|
||||
request.onerror = function () {
|
||||
logDebug(
|
||||
'Unable to load screen playlist.',
|
||||
['URL: ' + url, 'Network error during request.'].join(' | '),
|
||||
'error'
|
||||
);
|
||||
setOfflineBannerVisible(true);
|
||||
scheduleRefreshRetry();
|
||||
if (lastRenderedSlide && getCurrentActiveSlides().length < 2) {
|
||||
scheduleSlideAdvance(getSlideHoldDelay(Math.max(1, Number(lastRenderedSlide.duration_seconds || 10)) * 1000));
|
||||
}
|
||||
};
|
||||
request.ontimeout = function () {
|
||||
logDebug(
|
||||
'Playlist refresh timed out.',
|
||||
['URL: ' + url, 'Timeout after ' + request.timeout + 'ms'].join(' | '),
|
||||
'error'
|
||||
);
|
||||
setOfflineBannerVisible(true);
|
||||
scheduleRefreshRetry();
|
||||
if (lastRenderedSlide && getCurrentActiveSlides().length < 2) {
|
||||
scheduleSlideAdvance(getSlideHoldDelay(Math.max(1, Number(lastRenderedSlide.duration_seconds || 10)) * 1000));
|
||||
}
|
||||
};
|
||||
request.send();
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
// Collect unique webpage URLs from the slide list.
|
||||
function getWebpageUrls(sourceSlides) {
|
||||
const urls = [];
|
||||
(Array.isArray(sourceSlides) ? sourceSlides : []).forEach(function (slide) {
|
||||
const content = slide && slide.content ? slide.content : {};
|
||||
const regions = slide && slide.template && Array.isArray(slide.template.regions) ? slide.template.regions : [];
|
||||
regions.forEach(function (region) {
|
||||
if (region.region_type !== 'webpage') {
|
||||
return;
|
||||
}
|
||||
const regionContent = content[region.region_key] || {};
|
||||
const url = String(regionContent.value || '').trim();
|
||||
if (url && urls.indexOf(url) === -1) {
|
||||
urls.push(url);
|
||||
}
|
||||
});
|
||||
});
|
||||
return urls;
|
||||
}
|
||||
|
||||
// Filter the slides down to those that are active right now.
|
||||
function getActiveSlidesFrom(sourceSlides) {
|
||||
const now = new Date();
|
||||
return (Array.isArray(sourceSlides) ? sourceSlides : []).filter(function (slide) {
|
||||
return isSlideActive(slide, now);
|
||||
});
|
||||
}
|
||||
|
||||
// Build a cache key for the active slide set.
|
||||
function getActiveSlidesCacheKey() {
|
||||
const now = new Date();
|
||||
return [
|
||||
currentPlaylistSignature || '',
|
||||
now.getFullYear(),
|
||||
now.getMonth(),
|
||||
now.getDate(),
|
||||
now.getHours(),
|
||||
now.getMinutes(),
|
||||
now.getSeconds()
|
||||
].join('|');
|
||||
}
|
||||
|
||||
// Return the cached active slide set for the current playlist and second.
|
||||
function getCurrentActiveSlides() {
|
||||
const cacheKey = getActiveSlidesCacheKey();
|
||||
if (cacheKey !== activeSlidesCacheKey) {
|
||||
activeSlidesCacheValue = getActiveSlidesFrom(slides);
|
||||
activeSlidesCacheKey = cacheKey;
|
||||
}
|
||||
return activeSlidesCacheValue;
|
||||
}
|
||||
|
||||
// Clear the cached active slide set.
|
||||
function clearActiveSlidesCache() {
|
||||
activeSlidesCacheKey = '';
|
||||
activeSlidesCacheValue = [];
|
||||
}
|
||||
|
||||
// Reset render caches when the viewport changes.
|
||||
function syncRenderCacheViewport() {
|
||||
var viewportKey = window.innerWidth + 'x' + window.innerHeight;
|
||||
if (renderCacheViewportKey === viewportKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
renderCacheViewportKey = viewportKey;
|
||||
slideMarkupCache = Object.create(null);
|
||||
templateLayoutCache = Object.create(null);
|
||||
templateRenderPlanCache = Object.create(null);
|
||||
}
|
||||
|
||||
// Build a signature for the currently rendered view.
|
||||
function getCurrentRenderKey(activeSlides) {
|
||||
const viewportKey = window.innerWidth + 'x' + window.innerHeight;
|
||||
const availableSlides = Array.isArray(activeSlides) ? activeSlides : [];
|
||||
if (!availableSlides.length) {
|
||||
return [currentPlaylistSignature || '', viewportKey, 'empty', slides.length ? 'scheduled' : 'assigned'].join('|');
|
||||
}
|
||||
let normalizedIndex = Number(index || 0);
|
||||
if (!Number.isFinite(normalizedIndex) || normalizedIndex < 0 || normalizedIndex >= availableSlides.length) {
|
||||
normalizedIndex = 0;
|
||||
}
|
||||
const slide = availableSlides[normalizedIndex];
|
||||
return [currentPlaylistSignature || '', viewportKey, 'slide', slide && slide.id ? slide.id : ''].join('|');
|
||||
}
|
||||
|
||||
// Pick the current slide and the next slide for webpage preloading.
|
||||
function getWebpagePreloadSlides(sourceSlides, targetIndex) {
|
||||
const availableSlides = Array.isArray(sourceSlides) ? sourceSlides : [];
|
||||
if (!availableSlides.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let normalizedIndex = Number(targetIndex || 0);
|
||||
if (!Number.isFinite(normalizedIndex) || normalizedIndex < 0 || normalizedIndex >= availableSlides.length) {
|
||||
normalizedIndex = 0;
|
||||
}
|
||||
|
||||
const preloadSlides = [];
|
||||
const currentSlide = availableSlides[normalizedIndex];
|
||||
const nextSlide = availableSlides[normalizedIndex + 1];
|
||||
|
||||
if (currentSlide) {
|
||||
preloadSlides.push(currentSlide);
|
||||
}
|
||||
if (nextSlide && nextSlide !== currentSlide) {
|
||||
preloadSlides.push(nextSlide);
|
||||
}
|
||||
|
||||
return preloadSlides;
|
||||
}
|
||||
|
||||
// Mount hidden iframe preloads for the chosen webpage URLs.
|
||||
function syncWebpagePreloads(sourceSlides, targetIndex) {
|
||||
const urls = getWebpageUrls(getWebpagePreloadSlides(sourceSlides, targetIndex));
|
||||
const signature = urls.join('\n');
|
||||
if (signature === preloadSignature) {
|
||||
return;
|
||||
}
|
||||
if (!urls.length) {
|
||||
preloadSignature = '';
|
||||
if (preloadContainer) {
|
||||
preloadContainer.innerHTML = '';
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!preloadContainer) {
|
||||
preloadContainer = document.createElement('div');
|
||||
preloadContainer.className = 'webpage-preloads';
|
||||
preloadContainer.setAttribute('aria-hidden', 'true');
|
||||
document.body.appendChild(preloadContainer);
|
||||
}
|
||||
preloadContainer.innerHTML = urls.map(function (url) {
|
||||
return '<iframe class="webpage-preload-frame" src="' + escapeHtml(url) + '" title="Webpage preload" tabindex="-1" loading="eager" referrerpolicy="no-referrer" scrolling="no"></iframe>';
|
||||
}).join('');
|
||||
preloadSignature = signature;
|
||||
}
|
||||
|
||||
// Return a stable client id for this browser session.
|
||||
function getCommandClientId() {
|
||||
if (commandClientId) {
|
||||
return commandClientId;
|
||||
}
|
||||
try {
|
||||
var storedClientId = window.localStorage.getItem(commandClientStorageKey);
|
||||
if (storedClientId) {
|
||||
commandClientId = storedClientId;
|
||||
return commandClientId;
|
||||
}
|
||||
} catch (_error) {
|
||||
// fall through to ephemeral ID generation
|
||||
}
|
||||
commandClientId = (window.crypto && window.crypto.randomUUID ? window.crypto.randomUUID() : 'client-' + Date.now() + '-' + Math.random().toString(16).slice(2));
|
||||
try {
|
||||
window.localStorage.setItem(commandClientStorageKey, commandClientId);
|
||||
} catch (_error2) {
|
||||
// ignore storage errors
|
||||
}
|
||||
return commandClientId;
|
||||
}
|
||||
|
||||
// Load the most recent playlist snapshot from browser storage.
|
||||
function loadPlaylistSnapshot() {
|
||||
try {
|
||||
var raw = window.localStorage.getItem(playlistSnapshotStorageKey);
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
var parsed = JSON.parse(raw);
|
||||
if (!parsed || !Array.isArray(parsed.slides)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
slides: parsed.slides.map(normalizeSlide),
|
||||
signature: String(parsed.signature || ''),
|
||||
fadeBetweenSlides: Boolean(parsed.fadeBetweenSlides),
|
||||
etag: String(parsed.etag || '')
|
||||
};
|
||||
} catch (_error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Save the latest playlist snapshot for offline recovery.
|
||||
function savePlaylistSnapshot(data) {
|
||||
try {
|
||||
window.localStorage.setItem(playlistSnapshotStorageKey, JSON.stringify({
|
||||
slides: Array.isArray(data && data.slides) ? data.slides : [],
|
||||
signature: String(data && data.signature || ''),
|
||||
fadeBetweenSlides: Boolean(data && data.fadeBetweenSlides),
|
||||
etag: String(data && data.etag || ''),
|
||||
savedAt: new Date().toISOString()
|
||||
}));
|
||||
} catch (_error) {
|
||||
// ignore storage errors
|
||||
}
|
||||
}
|
||||
|
||||
// Apply a playlist snapshot to the current in-memory state.
|
||||
function applyPlaylistSnapshot(data) {
|
||||
if (!data || !Array.isArray(data.slides)) {
|
||||
return false;
|
||||
}
|
||||
slides = data.slides.map(normalizeSlide);
|
||||
currentPlaylistSignature = String(data.signature || '');
|
||||
currentPlaylistFadeBetweenSlides = Boolean(data.fadeBetweenSlides);
|
||||
currentPlaylistEtag = String(data.etag || '');
|
||||
pendingPlaylistUpdate = null;
|
||||
clearActiveSlidesCache();
|
||||
slideMarkupCache = Object.create(null);
|
||||
templateLayoutCache = Object.create(null);
|
||||
templateRenderPlanCache = Object.create(null);
|
||||
renderCacheViewportKey = '';
|
||||
index = 0;
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,940 @@
|
||||
// General sanitization and sizing helpers.
|
||||
// Strip unsupported characters from a font family string.
|
||||
function sanitizeFontFamily(value) {
|
||||
return String(value || '').replace(/[^a-zA-Z0-9 ,\"-]/g, '').trim();
|
||||
}
|
||||
|
||||
// Clamp font size to the supported range.
|
||||
function sanitizeFontSize(value) {
|
||||
return Math.max(8, Number(value || 0) || 24);
|
||||
}
|
||||
|
||||
// Validate a text color and fall back when needed.
|
||||
function sanitizeTextColor(value, fallback) {
|
||||
var raw = String(value || '').trim();
|
||||
if (/^#[0-9a-fA-F]{6}$/.test(raw) || /^#[0-9a-fA-F]{3}$/.test(raw)) {
|
||||
return raw;
|
||||
}
|
||||
return fallback || '#000000';
|
||||
}
|
||||
|
||||
// Read the template's canvas dimensions with safe defaults.
|
||||
function getTemplateCanvasSize(template) {
|
||||
return {
|
||||
width: Math.max(1, Number(template.canvas_size_width || 1920)),
|
||||
height: Math.max(1, Number(template.canvas_size_height || 1080))
|
||||
};
|
||||
}
|
||||
|
||||
// Read the server-supplied playlist revision, or fall back to the ETag.
|
||||
function getPlaylistRevision(data) {
|
||||
if (data && data.revision) {
|
||||
return String(data.revision);
|
||||
}
|
||||
if (data && data.playlist && data.playlist.revision) {
|
||||
return String(data.playlist.revision);
|
||||
}
|
||||
if (currentPlaylistEtag) {
|
||||
return String(currentPlaylistEtag).replace(/^"|"$/g, '');
|
||||
}
|
||||
return String(Date.now());
|
||||
}
|
||||
|
||||
// Scale a canvas to fit within the viewport.
|
||||
function fitCanvasSize(canvasWidth, canvasHeight, maxWidth, maxHeight) {
|
||||
var width = Math.max(1, Number(canvasWidth || 0) || 1920);
|
||||
var height = Math.max(1, Number(canvasHeight || 0) || 1080);
|
||||
var viewportWidth = Math.max(1, Number(maxWidth || 0) || width);
|
||||
var viewportHeight = Math.max(1, Number(maxHeight || 0) || height);
|
||||
var scale = Math.min(viewportWidth / width, viewportHeight / height);
|
||||
return {
|
||||
width: Math.round(width * scale),
|
||||
height: Math.round(height * scale)
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePlayerAnimationStep(value, fallbackPreset) {
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
return {
|
||||
preset: String(value.preset || fallbackPreset || 'none').trim(),
|
||||
duration_ms: Number.isFinite(Number(value.duration_ms)) && Number(value.duration_ms) > 0 ? Math.round(Number(value.duration_ms)) : null,
|
||||
delay_ms: Number.isFinite(Number(value.delay_ms)) && Number(value.delay_ms) >= 0 ? Math.round(Number(value.delay_ms)) : null,
|
||||
iterations: Number.isFinite(Number(value.iterations)) && Number(value.iterations) > 0 ? Math.round(Number(value.iterations)) : null
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
preset: String(typeof value === 'string' ? value : fallbackPreset || 'none').trim(),
|
||||
duration_ms: null,
|
||||
delay_ms: null,
|
||||
iterations: null
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePlayerAnimationConfig(value) {
|
||||
var raw = value;
|
||||
if (typeof raw === 'string') {
|
||||
var text = String(raw || '').trim();
|
||||
if (!text) {
|
||||
raw = null;
|
||||
} else {
|
||||
try {
|
||||
raw = JSON.parse(text);
|
||||
} catch (_error) {
|
||||
raw = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
||||
raw = {};
|
||||
}
|
||||
|
||||
return {
|
||||
intro: normalizePlayerAnimationStep(raw.intro, 'none'),
|
||||
outro: normalizePlayerAnimationStep(raw.outro !== undefined ? raw.outro : raw.out, 'none'),
|
||||
loop: normalizePlayerAnimationStep(raw.loop, 'none')
|
||||
};
|
||||
}
|
||||
|
||||
function hasPlayerAnimation(config) {
|
||||
return Boolean(config && ['intro', 'outro', 'loop'].some(function (stepName) {
|
||||
return String((config[stepName] && config[stepName].preset) || '').trim() && String((config[stepName] && config[stepName].preset) || '').trim() !== 'none';
|
||||
}));
|
||||
}
|
||||
|
||||
function decorateRegionMarkup(markup, region) {
|
||||
if (!markup || !region || !hasPlayerAnimation(region.animationConfig)) {
|
||||
return markup;
|
||||
}
|
||||
|
||||
var animationJson = escapeHtml(JSON.stringify(region.animationConfig));
|
||||
return markup.replace(/^(\s*)<([a-z0-9-]+)(\s[^>]*)?>/i, function (match, leadingWhitespace, tagName, attributes) {
|
||||
return leadingWhitespace + '<' + tagName + (attributes || '') + ' data-animation-json="' + animationJson + '">';
|
||||
});
|
||||
}
|
||||
|
||||
function clearRegionAnimationClasses(root) {
|
||||
if (!root) {
|
||||
return;
|
||||
}
|
||||
|
||||
var elements = [];
|
||||
if (typeof root.matches === 'function' && root.matches('[data-animation-json]')) {
|
||||
elements.push(root);
|
||||
}
|
||||
Array.prototype.forEach.call(root.querySelectorAll('[data-animation-json]'), function (element) {
|
||||
elements.push(element);
|
||||
});
|
||||
|
||||
elements.forEach(function (element) {
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
element.classList.remove('animate__animated', 'animate__infinite');
|
||||
element.classList.remove('animate__repeat-1', 'animate__repeat-2', 'animate__repeat-3');
|
||||
Array.prototype.slice.call(element.classList || []).forEach(function (className) {
|
||||
if (String(className || '').indexOf('animate__') === 0) {
|
||||
element.classList.remove(className);
|
||||
}
|
||||
});
|
||||
element.style.removeProperty('--animate-duration');
|
||||
element.style.removeProperty('--animate-delay');
|
||||
element.style.removeProperty('--animate-repeat');
|
||||
});
|
||||
}
|
||||
|
||||
function isAttentionSeekerAnimation(preset) {
|
||||
return ['bounce', 'flash', 'pulse', 'rubberBand', 'shakeX', 'shakeY', 'headShake', 'swing', 'tada', 'wobble', 'jello', 'heartBeat'].indexOf(String(preset || '').trim()) !== -1;
|
||||
}
|
||||
|
||||
function applyAnimationStep(element, step, phase) {
|
||||
if (!element || !step) {
|
||||
return;
|
||||
}
|
||||
|
||||
var preset = String(step.preset || 'none').trim();
|
||||
if (!preset || preset === 'none') {
|
||||
return;
|
||||
}
|
||||
|
||||
element.classList.add('animate__animated', 'animate__' + preset);
|
||||
element.style.setProperty('--animate-duration', String(Math.max(1, Number(step.duration_ms || 0) || 1000)) + 'ms');
|
||||
if (Number(step.delay_ms || 0) > 0) {
|
||||
element.style.setProperty('--animate-delay', String(Math.max(0, Number(step.delay_ms || 0))) + 'ms');
|
||||
} else {
|
||||
element.style.removeProperty('--animate-delay');
|
||||
}
|
||||
|
||||
if (phase === 'loop') {
|
||||
var repeatCount = Number(step.iterations);
|
||||
if (!Number.isFinite(repeatCount) || repeatCount < 1) {
|
||||
repeatCount = 1;
|
||||
}
|
||||
if (repeatCount > 1) {
|
||||
element.classList.add('animate__repeat-1');
|
||||
}
|
||||
element.style.setProperty('--animate-repeat', String(repeatCount));
|
||||
return;
|
||||
}
|
||||
|
||||
if (isAttentionSeekerAnimation(preset) && Number(step.iterations || 0) > 1) {
|
||||
element.style.setProperty('--animate-repeat', String(Math.max(1, Number(step.iterations || 1))));
|
||||
}
|
||||
}
|
||||
|
||||
function getAnimationStepTimingMs(step) {
|
||||
if (!step) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
var preset = String(step.preset || 'none').trim();
|
||||
if (!preset || preset === 'none') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
var durationMs = Number(step.duration_ms);
|
||||
if (!Number.isFinite(durationMs) || durationMs <= 0) {
|
||||
durationMs = 1000;
|
||||
}
|
||||
|
||||
var delayMs = Number(step.delay_ms);
|
||||
if (!Number.isFinite(delayMs) || delayMs < 0) {
|
||||
delayMs = 0;
|
||||
}
|
||||
|
||||
var iterations = Number(step.iterations);
|
||||
if (!Number.isFinite(iterations) || iterations < 1) {
|
||||
iterations = 1;
|
||||
}
|
||||
|
||||
return delayMs + (durationMs * iterations);
|
||||
}
|
||||
|
||||
function getRegionAnimationPhaseTimingMs(root, phase) {
|
||||
var timings = getRegionAnimationPhaseTimings(root, phase);
|
||||
return timings.reduce(function (maxTimingMs, entry) {
|
||||
return Math.max(maxTimingMs, Number(entry && entry.timingMs || 0));
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function getRegionAnimationPhaseTimings(root, phase) {
|
||||
if (!root || isThumbnailPreview()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
var normalizedPhase = String(phase || 'intro').trim().toLowerCase();
|
||||
if (normalizedPhase !== 'intro' && normalizedPhase !== 'outro') {
|
||||
normalizedPhase = 'intro';
|
||||
}
|
||||
|
||||
var timings = [];
|
||||
Array.prototype.forEach.call(root.querySelectorAll('[data-animation-json]'), function (element) {
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
|
||||
var config;
|
||||
try {
|
||||
config = normalizePlayerAnimationConfig(element.getAttribute('data-animation-json') || '{}');
|
||||
} catch (_error) {
|
||||
config = null;
|
||||
}
|
||||
|
||||
if (!config) {
|
||||
return;
|
||||
}
|
||||
|
||||
var timingMs = getAnimationStepTimingMs(normalizedPhase === 'outro' ? config.outro : config.intro);
|
||||
if (timingMs > 0) {
|
||||
timings.push({
|
||||
element: element,
|
||||
timingMs: timingMs
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return timings;
|
||||
}
|
||||
|
||||
function playRegionAnimation(element, phase) {
|
||||
if (!element || isThumbnailPreview()) {
|
||||
return;
|
||||
}
|
||||
|
||||
var normalizedPhase = String(phase || 'intro').trim().toLowerCase();
|
||||
if (normalizedPhase !== 'intro' && normalizedPhase !== 'outro') {
|
||||
normalizedPhase = 'intro';
|
||||
}
|
||||
|
||||
var config;
|
||||
try {
|
||||
config = normalizePlayerAnimationConfig(element.getAttribute('data-animation-json') || '{}');
|
||||
} catch (_error) {
|
||||
config = null;
|
||||
}
|
||||
|
||||
if (!config) {
|
||||
return;
|
||||
}
|
||||
|
||||
element.dataset.animationPhase = normalizedPhase;
|
||||
clearRegionAnimationClasses(element);
|
||||
|
||||
if (normalizedPhase === 'outro') {
|
||||
applyAnimationStep(element, config.outro, 'outro');
|
||||
return;
|
||||
}
|
||||
|
||||
if (config.intro && String(config.intro.preset || '').trim() && String(config.intro.preset || '').trim() !== 'none') {
|
||||
applyAnimationStep(element, config.intro, 'intro');
|
||||
if (config.loop && String(config.loop.preset || '').trim() && String(config.loop.preset || '').trim() !== 'none') {
|
||||
element.addEventListener('animationend', function handleAnimationEnd(event) {
|
||||
if (event.target !== element) {
|
||||
return;
|
||||
}
|
||||
if (String(element.dataset.animationPhase || '').trim() !== 'intro') {
|
||||
return;
|
||||
}
|
||||
element.removeEventListener('animationend', handleAnimationEnd);
|
||||
clearRegionAnimationClasses(element);
|
||||
applyAnimationStep(element, config.loop, 'loop');
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
applyAnimationStep(element, config.loop, 'loop');
|
||||
}
|
||||
|
||||
function playRegionAnimations(root, phase) {
|
||||
if (!root || isThumbnailPreview()) {
|
||||
return;
|
||||
}
|
||||
|
||||
var normalizedPhase = String(phase || 'intro').trim().toLowerCase();
|
||||
if (normalizedPhase !== 'intro' && normalizedPhase !== 'outro') {
|
||||
normalizedPhase = 'intro';
|
||||
}
|
||||
|
||||
var elements = Array.prototype.slice.call(root.querySelectorAll('[data-animation-json]'));
|
||||
elements.forEach(function (element) {
|
||||
playRegionAnimation(element, normalizedPhase);
|
||||
});
|
||||
}
|
||||
|
||||
function setPlayerCanvasDimensions(canvasWidth, canvasHeight) {
|
||||
if (!document || !document.documentElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
var width = Math.max(1, Math.round(Number(canvasWidth) || 0) || 0);
|
||||
var height = Math.max(1, Math.round(Number(canvasHeight) || 0) || 0);
|
||||
document.documentElement.style.setProperty('--player-canvas-width', width + 'px');
|
||||
document.documentElement.style.setProperty('--player-canvas-height', height + 'px');
|
||||
}
|
||||
|
||||
const ALLOWED_RICH_TEXT_TAGS = ['a', 'b', 'blockquote', 'br', 'code', 'div', 'em', 'figure', 'figcaption', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'li', 'ol', 'p', 'pre', 'span', 'strong', 'sub', 'sup', 'table', 'tbody', 'td', 'th', 'thead', 'tr', 'u', 'ul'];
|
||||
|
||||
function sanitizeRichTextAttributes(tagName, attrText) {
|
||||
const allowedAttributes = {
|
||||
a: ['href', 'title', 'target', 'rel', 'class', 'style'],
|
||||
blockquote: ['class', 'style'],
|
||||
div: ['class', 'style'],
|
||||
figure: ['class', 'style'],
|
||||
figcaption: ['class', 'style'],
|
||||
h1: ['class', 'style'],
|
||||
h2: ['class', 'style'],
|
||||
h3: ['class', 'style'],
|
||||
h4: ['class', 'style'],
|
||||
h5: ['class', 'style'],
|
||||
h6: ['class', 'style'],
|
||||
li: ['class', 'style'],
|
||||
ol: ['class', 'style', 'start'],
|
||||
p: ['class', 'style'],
|
||||
pre: ['class', 'style'],
|
||||
span: ['class', 'style'],
|
||||
table: ['class', 'style'],
|
||||
td: ['class', 'style', 'colspan', 'rowspan'],
|
||||
th: ['class', 'style', 'colspan', 'rowspan', 'scope'],
|
||||
tr: ['class', 'style'],
|
||||
ul: ['class', 'style']
|
||||
};
|
||||
const allowed = allowedAttributes[tagName] || [];
|
||||
if (!allowed.length) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const attrs = [];
|
||||
String(attrText || '').replace(/([a-zA-Z0-9:-]+)(?:\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>/=`]+)))?/g, (_full, key, _valuePart, doubleQuoted, singleQuoted, bareValue) => {
|
||||
const lowerKey = String(key || '').toLowerCase();
|
||||
if (!allowed.includes(lowerKey)) {
|
||||
return '';
|
||||
}
|
||||
const value = doubleQuoted !== undefined ? doubleQuoted : singleQuoted !== undefined ? singleQuoted : bareValue !== undefined ? bareValue : '';
|
||||
if (lowerKey === 'href' && /^(?:\s*javascript:|\s*data:)/i.test(String(value || ''))) {
|
||||
return '';
|
||||
}
|
||||
if (lowerKey === 'style' && /(?:expression\s*\(|javascript:|url\s*\()/i.test(String(value || ''))) {
|
||||
return '';
|
||||
}
|
||||
if (lowerKey === 'target') {
|
||||
const targetValue = String(value || '').trim();
|
||||
if (targetValue === '_blank') {
|
||||
attrs.push(' target="_blank"');
|
||||
if (!attrs.includes(' rel="noreferrer noopener"')) {
|
||||
attrs.push(' rel="noreferrer noopener"');
|
||||
}
|
||||
return '';
|
||||
}
|
||||
}
|
||||
attrs.push(' ' + lowerKey + '="' + escapeHtml(value) + '"');
|
||||
return '';
|
||||
});
|
||||
|
||||
return attrs.join('');
|
||||
}
|
||||
|
||||
// Remove unsafe markup while preserving richer rich-text formatting.
|
||||
function sanitizeRichText(html) {
|
||||
var output = String(html || '');
|
||||
output = output.replace(/<script[\s\S]*?<\/script>/gi, '');
|
||||
output = output.replace(/<style[\s\S]*?<\/style>/gi, '');
|
||||
return output.replace(/<[^>]+>/g, function (tag) {
|
||||
var match = tag.match(/^<\s*(\/?)\s*([a-z0-9]+)([\s\S]*?)(\/?)>$/i);
|
||||
if (!match) {
|
||||
return '';
|
||||
}
|
||||
var closing = Boolean(match[1]);
|
||||
var name = String(match[2] || '').toLowerCase();
|
||||
var attrText = String(match[3] || '');
|
||||
if (ALLOWED_RICH_TEXT_TAGS.indexOf(name) === -1) {
|
||||
return '';
|
||||
}
|
||||
if (closing) {
|
||||
return '</' + name + '>';
|
||||
}
|
||||
return '<' + name + sanitizeRichTextAttributes(name, attrText) + '>';
|
||||
});
|
||||
}
|
||||
|
||||
// Render a single Editor.js block to HTML.
|
||||
function renderEditorJsBlock(block) {
|
||||
if (!block || !block.type || !block.data) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (block.type === 'header') {
|
||||
var level = Math.max(1, Math.min(6, Number(block.data.level || 2)));
|
||||
return '<h' + level + '>' + sanitizeRichText(block.data.text || '') + '</h' + level + '>';
|
||||
}
|
||||
|
||||
if (block.type === 'list') {
|
||||
var tag = block.data.style === 'ordered' ? 'ol' : 'ul';
|
||||
var items = Array.isArray(block.data.items) ? block.data.items : [];
|
||||
return '<' + tag + ' style="list-style-type:' + (tag === 'ol' ? 'decimal' : 'disc') + ';padding-left:1.4em;">' + items.map(function (item) { return renderEditorJsListItem(item, tag); }).join('') + '</' + tag + '>';
|
||||
}
|
||||
|
||||
if (block.type === 'delimiter') {
|
||||
return '<hr />';
|
||||
}
|
||||
|
||||
if (block.type === 'code') {
|
||||
return '<pre><code>' + escapeHtml(block.data.code || '') + '</code></pre>';
|
||||
}
|
||||
|
||||
if (block.type === 'table') {
|
||||
return renderEditorJsTable(block.data);
|
||||
}
|
||||
|
||||
if (block.type === 'paragraph') {
|
||||
return '<p>' + sanitizeRichText(block.data.text || '') + '</p>';
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
// Render a list item and any nested sub-items.
|
||||
function renderEditorJsListItem(item, tag) {
|
||||
if (item && typeof item === 'object') {
|
||||
var content = item.content !== undefined ? item.content : (item.text !== undefined ? item.text : item.value !== undefined ? item.value : '');
|
||||
var children = Array.isArray(item.items) ? item.items : Array.isArray(item.subItems) ? item.subItems : Array.isArray(item.children) ? item.children : [];
|
||||
var nested = children.length ? '<' + tag + ' style="list-style-type:' + (tag === 'ol' ? 'decimal' : 'disc') + ';padding-left:1.4em;">' + children.map(function (child) { return renderEditorJsListItem(child, tag); }).join('') + '</' + tag + '>' : '';
|
||||
return '<li>' + sanitizeRichText(content || '') + nested + '</li>';
|
||||
}
|
||||
return '<li>' + sanitizeRichText(item || '') + '</li>';
|
||||
}
|
||||
|
||||
// Render an Editor.js table block.
|
||||
function renderEditorJsTable(data) {
|
||||
var rows = Array.isArray(data.content) ? data.content : Array.isArray(data.rows) ? data.rows : [];
|
||||
if (!rows.length) {
|
||||
return '';
|
||||
}
|
||||
var hasHeadings = Boolean(data.withHeadings);
|
||||
var tableRows = rows.map(function (row, rowIndex) {
|
||||
var cells = Array.isArray(row) ? row : [];
|
||||
var cellTag = hasHeadings && rowIndex === 0 ? 'th' : 'td';
|
||||
var cellAttrs = cellTag === 'th' ? ' scope="col"' : '';
|
||||
return '<tr>' + cells.map(function (cell) {
|
||||
return '<' + cellTag + cellAttrs + '>' + sanitizeRichText(cell || '') + '</' + cellTag + '>';
|
||||
}).join('') + '</tr>';
|
||||
}).join('');
|
||||
return '<table class="ck-content-table">' + tableRows + '</table>';
|
||||
}
|
||||
|
||||
function wrapRichTextParagraph(html) {
|
||||
var raw = String(html || '').trim();
|
||||
if (!raw) {
|
||||
return '';
|
||||
}
|
||||
if (/^<\s*(?:p|div|h[1-6]|ul|ol|pre|table|blockquote|figure|hr|li)\b/i.test(raw)) {
|
||||
return raw;
|
||||
}
|
||||
return '<p>' + raw + '</p>';
|
||||
}
|
||||
|
||||
// Render Editor.js JSON or plain content safely.
|
||||
function renderEditorJsContent(value) {
|
||||
if (value && typeof value === 'object') {
|
||||
if (Array.isArray(value.blocks)) {
|
||||
return value.blocks.map(renderEditorJsBlock).join('');
|
||||
}
|
||||
if (value.value !== undefined) {
|
||||
return renderEditorJsContent(value.value);
|
||||
}
|
||||
}
|
||||
var raw = String(value || '');
|
||||
try {
|
||||
var parsed = JSON.parse(raw);
|
||||
if (parsed && Array.isArray(parsed.blocks)) {
|
||||
return parsed.blocks.map(renderEditorJsBlock).join('');
|
||||
}
|
||||
} catch (_error) {
|
||||
// fall through to legacy HTML rendering
|
||||
}
|
||||
return wrapRichTextParagraph(sanitizeRichText(raw));
|
||||
}
|
||||
|
||||
// Parse string values that look like JSON.
|
||||
function parseMaybeJson(value) {
|
||||
if (typeof value !== 'string') {
|
||||
return value;
|
||||
}
|
||||
var raw = value.trim();
|
||||
if (!raw) {
|
||||
return value;
|
||||
}
|
||||
if (raw.charAt(0) !== '{' && raw.charAt(0) !== '[') {
|
||||
return value;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch (_error) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize a slide region's stored content value.
|
||||
function normalizeContentValue(value) {
|
||||
var normalized;
|
||||
if (!value || typeof value !== 'object') {
|
||||
return {
|
||||
type: 'text',
|
||||
value: parseMaybeJson(value)
|
||||
};
|
||||
}
|
||||
normalized = {};
|
||||
Object.keys(value).forEach(function (key) {
|
||||
normalized[key] = value[key];
|
||||
});
|
||||
if (normalized.value !== undefined) {
|
||||
normalized.value = parseMaybeJson(normalized.value);
|
||||
}
|
||||
if (normalized.font_family !== undefined && normalized.font_family !== null) {
|
||||
normalized.font_family = sanitizeFontFamily(normalized.font_family);
|
||||
}
|
||||
if (normalized.font_size !== undefined && normalized.font_size !== null) {
|
||||
normalized.font_size = sanitizeFontSize(normalized.font_size);
|
||||
}
|
||||
if (normalized.font_color !== undefined && normalized.font_color !== null) {
|
||||
normalized.font_color = sanitizeTextColor(normalized.font_color);
|
||||
}
|
||||
if (!normalized.type) {
|
||||
normalized.type = 'text';
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
// Normalize a slide and its nested region content.
|
||||
function normalizeSlide(slide) {
|
||||
var normalized = {};
|
||||
var content;
|
||||
Object.keys(slide || {}).forEach(function (key) {
|
||||
normalized[key] = slide[key];
|
||||
});
|
||||
normalized.content = {};
|
||||
content = slide && slide.content && typeof slide.content === 'object' ? slide.content : {};
|
||||
Object.keys(content).forEach(function (regionKey) {
|
||||
normalized.content[regionKey] = normalizeContentValue(content[regionKey]);
|
||||
});
|
||||
return normalized;
|
||||
}
|
||||
|
||||
// Parse the stored schedule-day list into numbers.
|
||||
function parseScheduleDays(value) {
|
||||
if (!value) {
|
||||
return [];
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(function (item) { return Number(item); }).filter(function (item) { return !Number.isNaN(item); });
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return Array.isArray(parsed) ? parsed.map(function (item) { return Number(item); }).filter(function (item) { return !Number.isNaN(item); }) : [];
|
||||
} catch (_error) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Convert a HH:MM time string to minutes since midnight.
|
||||
function parseTimeToMinutes(value) {
|
||||
const raw = String(value || '').trim();
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
const match = raw.match(/^(\d{2}):(\d{2})/);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
return Number(match[1]) * 60 + Number(match[2]);
|
||||
}
|
||||
|
||||
function normalizeScheduleRule(rule) {
|
||||
const input = rule && typeof rule === 'object' ? rule : {};
|
||||
const startDatetime = String(input.start_datetime || input.startDateTime || '').trim();
|
||||
const endDatetime = String(input.end_datetime || input.endDateTime || '').trim();
|
||||
const startTime = String(input.start_time || input.startTime || '').trim();
|
||||
const endTime = String(input.end_time || input.endTime || '').trim();
|
||||
const days = parseScheduleDays(input.days);
|
||||
|
||||
if (startDatetime || endDatetime) {
|
||||
if (!startDatetime || !endDatetime) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (startTime || endTime) {
|
||||
if (!startTime || !endTime) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (!startDatetime && !endDatetime && !startTime && !endTime && !days.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
start_datetime: startDatetime || null,
|
||||
end_datetime: endDatetime || null,
|
||||
start_time: startTime || null,
|
||||
end_time: endTime || null,
|
||||
days: days
|
||||
};
|
||||
}
|
||||
|
||||
function parseScheduleRules(value) {
|
||||
let rawRules = [];
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
rawRules = value;
|
||||
} else {
|
||||
const raw = String(value || '').trim();
|
||||
if (!raw) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
rawRules = Array.isArray(parsed) ? parsed : [];
|
||||
} catch (_error) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
return rawRules.map(normalizeScheduleRule).filter(Boolean);
|
||||
}
|
||||
|
||||
function matchesScheduleRule(rule, now) {
|
||||
const normalized = normalizeScheduleRule(rule);
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (normalized.start_datetime && normalized.end_datetime) {
|
||||
const start = new Date(normalized.start_datetime);
|
||||
const end = new Date(normalized.end_datetime);
|
||||
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) {
|
||||
return false;
|
||||
}
|
||||
if (now < start || now > end) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (normalized.days.length && normalized.days.indexOf(now.getDay()) === -1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (normalized.start_time && normalized.end_time) {
|
||||
const startMinutes = parseTimeToMinutes(normalized.start_time);
|
||||
const endMinutes = parseTimeToMinutes(normalized.end_time);
|
||||
if (startMinutes === null || endMinutes === null) {
|
||||
return false;
|
||||
}
|
||||
const nowMinutes = now.getHours() * 60 + now.getMinutes();
|
||||
if (startMinutes <= endMinutes) {
|
||||
return nowMinutes >= startMinutes && nowMinutes <= endMinutes;
|
||||
}
|
||||
return nowMinutes >= startMinutes || nowMinutes <= endMinutes;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Determine whether a slide should be shown at the current time.
|
||||
function isSlideActive(slide, now) {
|
||||
const rules = Array.isArray(slide.scheduleRules) ? slide.scheduleRules : [];
|
||||
if (!rules.length) {
|
||||
return true;
|
||||
}
|
||||
return rules.some(function (rule) {
|
||||
return matchesScheduleRule(rule, now);
|
||||
});
|
||||
}
|
||||
// Build the cache key for a template layout.
|
||||
function getTemplateLayoutCacheKey(template) {
|
||||
var viewportKey = window.innerWidth + 'x' + window.innerHeight;
|
||||
return [currentPlaylistSignature || '', template && template.id ? template.id : '', viewportKey].join('|');
|
||||
}
|
||||
|
||||
// Build or reuse layout metadata for a template.
|
||||
function getTemplateLayout(template) {
|
||||
if (!template || !template.id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
syncRenderCacheViewport();
|
||||
|
||||
var cacheKey = getTemplateLayoutCacheKey(template);
|
||||
if (Object.prototype.hasOwnProperty.call(templateLayoutCache, cacheKey)) {
|
||||
return templateLayoutCache[cacheKey];
|
||||
}
|
||||
|
||||
var templateCanvas = getTemplateCanvasSize(template);
|
||||
var canvasSize = fitCanvasSize(templateCanvas.width, templateCanvas.height, window.innerWidth, window.innerHeight);
|
||||
var canvasScale = canvasSize.width / templateCanvas.width;
|
||||
var regions = (template.regions || []).map(function (region) {
|
||||
var left = (Number(region.x) / templateCanvas.width) * 100;
|
||||
var top = (Number(region.y) / templateCanvas.height) * 100;
|
||||
var width = (Number(region.width) / templateCanvas.width) * 100;
|
||||
var height = (Number(region.height) / templateCanvas.height) * 100;
|
||||
var baseStyle = 'left:' + left + '%;top:' + top + '%;width:' + width + '%;height:' + height + '%;z-index:' + Number(region.z_index || 0) + ';';
|
||||
var pixelWidth = Math.max(1, Math.round(Number(region.width || 0) || 1));
|
||||
var pixelHeight = Math.max(1, Math.round(Number(region.height || 0) || 1));
|
||||
|
||||
return {
|
||||
regionKey: region.region_key,
|
||||
regionType: region.region_type,
|
||||
label: region.label,
|
||||
animationJson: region.animation_json || null,
|
||||
baseStyle: baseStyle,
|
||||
pixelWidth: pixelWidth,
|
||||
pixelHeight: pixelHeight,
|
||||
fontFamily: region.font_family || null,
|
||||
fontSize: region.font_size || null,
|
||||
fontColor: region.font_color || null,
|
||||
canvasScale: canvasScale
|
||||
};
|
||||
});
|
||||
|
||||
var layout = {
|
||||
canvasWidth: canvasSize.width,
|
||||
canvasHeight: canvasSize.height,
|
||||
background: template.background_image_path ? '<img class="template-background" src="' + escapeHtml(template.background_image_path) + '" alt="" />' : '',
|
||||
backgroundColor: template.background_color || '#111111',
|
||||
regions: regions
|
||||
};
|
||||
|
||||
templateLayoutCache[cacheKey] = layout;
|
||||
return layout;
|
||||
}
|
||||
|
||||
// Build a dark backdrop style for template and media canvases.
|
||||
function buildBackdropStyle(backgroundColor, backgroundImagePath) {
|
||||
var color = String(backgroundColor || '#111111').trim() || '#111111';
|
||||
var style = 'background-color:' + escapeHtml(color) + ';';
|
||||
|
||||
if (backgroundImagePath) {
|
||||
style += 'background-image:url("' + escapeHtml(backgroundImagePath) + '");';
|
||||
style += 'background-position:center;background-size:contain;background-repeat:no-repeat;';
|
||||
return style;
|
||||
}
|
||||
|
||||
style += 'background-image:none;background-position:center;background-size:cover;background-repeat:no-repeat;';
|
||||
return style;
|
||||
}
|
||||
|
||||
// Build the cache key for a template render plan.
|
||||
function getTemplateRenderPlanCacheKey(template) {
|
||||
return getTemplateLayoutCacheKey(template);
|
||||
}
|
||||
|
||||
// Build or reuse the render plan for a template.
|
||||
function getTemplateRenderPlan(template) {
|
||||
if (!template || !template.id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
syncRenderCacheViewport();
|
||||
|
||||
var cacheKey = getTemplateRenderPlanCacheKey(template);
|
||||
if (Object.prototype.hasOwnProperty.call(templateRenderPlanCache, cacheKey)) {
|
||||
return templateRenderPlanCache[cacheKey];
|
||||
}
|
||||
|
||||
var layout = getTemplateLayout(template);
|
||||
function getPlayerRegionModule(regionType) {
|
||||
return window.pulsePlayerRegionTypes && typeof window.pulsePlayerRegionTypes.get === 'function' ? window.pulsePlayerRegionTypes.get(regionType) : null;
|
||||
}
|
||||
|
||||
var plan = {
|
||||
layout: layout,
|
||||
renderRegion: function (region, regionContent) {
|
||||
var regionModule = getPlayerRegionModule(region.regionType);
|
||||
if (regionModule && typeof regionModule.renderRegion === 'function') {
|
||||
return regionModule.renderRegion(region, regionContent);
|
||||
}
|
||||
if (region.regionType === 'image') {
|
||||
return renderImageRegion(region, regionContent);
|
||||
}
|
||||
if (region.regionType === 'video') {
|
||||
return renderVideoRegion(region, regionContent);
|
||||
}
|
||||
if (region.regionType === 'webpage') {
|
||||
return renderWebpageRegion(region, regionContent);
|
||||
}
|
||||
if (region.regionType === 'rtmp') {
|
||||
return renderRtmpRegion(region, regionContent);
|
||||
}
|
||||
if (region.regionType === 'rss') {
|
||||
return renderRssRegion(region, regionContent);
|
||||
}
|
||||
if (region.regionType === 'api') {
|
||||
return renderApiRegion(region, regionContent);
|
||||
}
|
||||
if (region.regionType === 'html') {
|
||||
return renderHtmlRegion(region, regionContent);
|
||||
}
|
||||
return renderTextRegion(region, regionContent);
|
||||
}
|
||||
};
|
||||
|
||||
templateRenderPlanCache[cacheKey] = plan;
|
||||
return plan;
|
||||
}
|
||||
|
||||
// Render a template-based slide using the cached layout.
|
||||
function renderTemplateSlideMarkup(slide) {
|
||||
const template = slide.template;
|
||||
const content = slide.content || {};
|
||||
const plan = getTemplateRenderPlan(template);
|
||||
const layout = plan ? plan.layout : null;
|
||||
const regions = layout ? layout.regions.map(function (region) {
|
||||
const regionContent = content[region.regionKey] || {};
|
||||
const markup = plan.renderRegion(region, regionContent);
|
||||
return decorateRegionMarkup(markup, {
|
||||
animationConfig: normalizePlayerAnimationConfig(region.animationJson)
|
||||
});
|
||||
}).join('') : '';
|
||||
const stageStyle = layout ? buildBackdropStyle(layout.backgroundColor || '#111111', template.background_image_path) : '';
|
||||
if (layout) {
|
||||
setPlayerCanvasDimensions(layout.canvasWidth, layout.canvasHeight);
|
||||
}
|
||||
return renderSlideShell(slide, '', (layout ? layout.canvasWidth : 0) + 'px', (layout ? layout.canvasHeight : 0) + 'px', '<div class="template-stage" style="' + stageStyle + '">' + (layout ? layout.background : '') + regions + '</div>');
|
||||
}
|
||||
|
||||
// Media rendering helpers.
|
||||
// Build the direct media element for a slide.
|
||||
function renderMediaSlideContent(slide) {
|
||||
if (slide.kind === 'image') {
|
||||
return '<img src="' + escapeHtml(slide.media_url) + '" alt="slide" />';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
// Render a slide that contains direct media content.
|
||||
function renderMediaSlideMarkup(slide) {
|
||||
const canvasSize = fitCanvasSize(16, 9, window.innerWidth, window.innerHeight);
|
||||
const media = renderMediaSlideContent(slide);
|
||||
const canvasStyle = slide.kind === 'image' ? buildBackdropStyle('#111111', slide.media_url) : '';
|
||||
setPlayerCanvasDimensions(canvasSize.width, canvasSize.height);
|
||||
return renderSlideShell(slide, 'slide-media', canvasSize.width + 'px', canvasSize.height + 'px', media, canvasStyle);
|
||||
}
|
||||
|
||||
// Render the shared slide shell around slide-specific inner content.
|
||||
function renderSlideShell(slide, canvasClass, canvasWidth, canvasHeight, innerHtml, canvasStyle) {
|
||||
const body = slide.body ? '<div class="body">' + escapeHtml(slide.body) + '</div>' : '';
|
||||
const className = canvasClass ? 'slide-canvas ' + canvasClass : 'slide-canvas';
|
||||
const style = 'width:' + canvasWidth + ';height:' + canvasHeight + ';' + (canvasStyle || '');
|
||||
return '<div class="slide"><div class="' + className + '" style="' + style + '">' + innerHtml + body + '</div></div>';
|
||||
}
|
||||
|
||||
// Build the cache key for rendered slide markup.
|
||||
function getSlideMarkupCacheKey(slide) {
|
||||
var viewportKey = window.innerWidth + 'x' + window.innerHeight;
|
||||
return [currentPlaylistSignature || '', slide && slide.id ? slide.id : '', slide && slide.template_id ? slide.template_id : '', slide && slide.modified_at ? slide.modified_at : '', viewportKey, videoRegionRenderVersion || 0].join('|');
|
||||
}
|
||||
|
||||
function notifyVideoRegionSourceReady() {
|
||||
if (typeof videoRegionRenderVersion === 'number') {
|
||||
videoRegionRenderVersion += 1;
|
||||
}
|
||||
slideMarkupCache = Object.create(null);
|
||||
if (typeof showCurrent === 'function' && slides && slides.length) {
|
||||
showCurrent();
|
||||
}
|
||||
}
|
||||
|
||||
window.notifyVideoRegionSourceReady = notifyVideoRegionSourceReady;
|
||||
|
||||
// Look up a previously rendered slide in the cache.
|
||||
function getCachedSlideMarkup(slide) {
|
||||
var cacheKey = getSlideMarkupCacheKey(slide);
|
||||
return Object.prototype.hasOwnProperty.call(slideMarkupCache, cacheKey) ? slideMarkupCache[cacheKey] : null;
|
||||
}
|
||||
|
||||
// Store rendered slide markup in the cache.
|
||||
function setCachedSlideMarkup(slide, markup) {
|
||||
syncRenderCacheViewport();
|
||||
slideMarkupCache[getSlideMarkupCacheKey(slide)] = markup;
|
||||
}
|
||||
|
||||
// Slide rendering and markup cache helpers.
|
||||
// Choose the right slide renderer and cache the result.
|
||||
function buildSlideMarkup(slide) {
|
||||
lastRenderedSlide = slide || null;
|
||||
syncBlackoutState();
|
||||
var cachedMarkup = getCachedSlideMarkup(slide);
|
||||
if (cachedMarkup) {
|
||||
return cachedMarkup;
|
||||
}
|
||||
|
||||
var markup = '';
|
||||
if (slide.template_id && slide.template) {
|
||||
markup = renderTemplateSlideMarkup(slide);
|
||||
setCachedSlideMarkup(slide, markup);
|
||||
return markup;
|
||||
}
|
||||
|
||||
markup = renderMediaSlideMarkup(slide);
|
||||
setCachedSlideMarkup(slide, markup);
|
||||
return markup;
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
// Service worker cache strategy for player pages, assets, media, and playlists.
|
||||
|
||||
const CACHE_VERSION = 'v38';
|
||||
const PAGE_CACHE = `pulse-signage-player-pages-${CACHE_VERSION}`;
|
||||
const ASSET_CACHE = `pulse-signage-player-assets-${CACHE_VERSION}`;
|
||||
const MEDIA_CACHE = `pulse-signage-player-media-${CACHE_VERSION}`;
|
||||
const PLAYLIST_CACHE = `pulse-signage-player-playlists-${CACHE_VERSION}`;
|
||||
|
||||
function normalizeRequest(request) {
|
||||
const url = new URL(request.url);
|
||||
return new Request(`${url.origin}${url.pathname}`, {
|
||||
method: 'GET',
|
||||
headers: request.headers,
|
||||
mode: 'same-origin',
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
}
|
||||
|
||||
function shouldBypassCache(request) {
|
||||
if (!request) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (request.cache === 'reload' || request.cache === 'no-store') {
|
||||
return true;
|
||||
}
|
||||
|
||||
const cacheControl = String(request.headers.get('cache-control') || '').toLowerCase();
|
||||
const pragma = String(request.headers.get('pragma') || '').toLowerCase();
|
||||
return cacheControl.includes('no-cache') || cacheControl.includes('max-age=0') || pragma.includes('no-cache');
|
||||
}
|
||||
|
||||
async function cacheResponse(cacheName, request, response, cacheKeyRequest) {
|
||||
if (!response || !response.ok) {
|
||||
return;
|
||||
}
|
||||
const cache = await caches.open(cacheName);
|
||||
await cache.put(cacheKeyRequest || request, response.clone());
|
||||
}
|
||||
|
||||
async function networkFirst(request, cacheName, cacheKeyRequest) {
|
||||
try {
|
||||
const response = await fetch(request);
|
||||
if (response && response.ok) {
|
||||
await cacheResponse(cacheName, request, response, cacheKeyRequest);
|
||||
return response;
|
||||
}
|
||||
if (response && response.status === 304) {
|
||||
const cached = await caches.match(cacheKeyRequest || request);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
const cached = await caches.match(cacheKeyRequest || request);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
return response;
|
||||
} catch (_error) {
|
||||
const cached = await caches.match(cacheKeyRequest || request);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
throw _error;
|
||||
}
|
||||
}
|
||||
|
||||
async function cacheFirst(request, cacheName) {
|
||||
const cached = await caches.match(request);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
const response = await fetch(request);
|
||||
if (response && response.ok) {
|
||||
await cacheResponse(cacheName, request, response);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
async function staleWhileRevalidate(request, cacheName) {
|
||||
const cached = await caches.match(request);
|
||||
const networkPromise = fetch(request).then(async function (response) {
|
||||
if (response && response.ok) {
|
||||
await cacheResponse(cacheName, request, response);
|
||||
}
|
||||
return response;
|
||||
}).catch(function () {
|
||||
return null;
|
||||
});
|
||||
|
||||
if (cached) {
|
||||
networkPromise.catch(function () {
|
||||
return null;
|
||||
});
|
||||
return cached;
|
||||
}
|
||||
|
||||
const networkResponse = await networkPromise;
|
||||
if (networkResponse) {
|
||||
return networkResponse;
|
||||
}
|
||||
|
||||
return new Response('', { status: 504, statusText: 'Offline' });
|
||||
}
|
||||
|
||||
async function networkOnly(request) {
|
||||
return fetch(request);
|
||||
}
|
||||
|
||||
self.addEventListener('install', function (event) {
|
||||
self.skipWaiting();
|
||||
event.waitUntil(Promise.resolve());
|
||||
});
|
||||
|
||||
self.addEventListener('activate', function (event) {
|
||||
event.waitUntil((async function () {
|
||||
const expected = [PAGE_CACHE, ASSET_CACHE, MEDIA_CACHE, PLAYLIST_CACHE];
|
||||
const keys = await caches.keys();
|
||||
await Promise.all(keys.filter(function (key) {
|
||||
return expected.indexOf(key) === -1;
|
||||
}).map(function (key) {
|
||||
return caches.delete(key);
|
||||
}));
|
||||
await self.clients.claim();
|
||||
})());
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', function (event) {
|
||||
const request = event.request;
|
||||
if (request.method !== 'GET') {
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
if (url.origin !== self.location.origin) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === '/sw.js') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldBypassCache(request)) {
|
||||
event.respondWith(networkOnly(request));
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith('/assets/')) {
|
||||
event.respondWith(cacheFirst(request, ASSET_CACHE));
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith('/media/')) {
|
||||
event.respondWith(networkOnly(request));
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.mode === 'navigate' || url.pathname === '/' || url.pathname === '/onboard' || /^\/screen\/[^/]+$/.test(url.pathname)) {
|
||||
event.respondWith(networkFirst(request, PAGE_CACHE, normalizeRequest(request)));
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith('/api/screens/') && url.pathname.endsWith('/playlist')) {
|
||||
event.respondWith(networkFirst(request, PLAYLIST_CACHE, normalizeRequest(request)));
|
||||
}
|
||||
});
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,137 @@
|
||||
// API region helpers for resolving source data and nested values.
|
||||
|
||||
var registry = window.pulsePlayerRegionTypes;
|
||||
var placeholderUtils = window.placeholderUtils || {};
|
||||
|
||||
function getApiSourceById(sourceId) {
|
||||
var sources = Array.isArray(initialData && initialData.apiSources) ? initialData.apiSources : [];
|
||||
var normalizedId = Number(sourceId || 0);
|
||||
return sources.find(function (source) {
|
||||
return Number(source.id) === normalizedId;
|
||||
}) || null;
|
||||
}
|
||||
|
||||
function getApiSourceItemsPath(source, overridePath) {
|
||||
if (overridePath === undefined || overridePath === null) {
|
||||
return String(source && (source.items_path || source.itemsPath) || '').trim();
|
||||
}
|
||||
|
||||
return String(overridePath || '').trim();
|
||||
}
|
||||
|
||||
function getApiSourceItems(sourceId, itemsPathOverride) {
|
||||
var source = getApiSourceById(sourceId);
|
||||
var responseJson = source && source.responseJson && typeof source.responseJson === 'object' ? source.responseJson : null;
|
||||
var itemsPath = getApiSourceItemsPath(source, itemsPathOverride);
|
||||
if (itemsPath !== undefined && itemsPath !== null && itemsPath !== '') {
|
||||
var current = responseJson;
|
||||
String(itemsPath).split('.').forEach(function (segment) {
|
||||
if (current === undefined || current === null) {
|
||||
current = '';
|
||||
return;
|
||||
}
|
||||
current = current[segment];
|
||||
});
|
||||
return Array.isArray(current) ? current : [];
|
||||
}
|
||||
if (Array.isArray(responseJson)) {
|
||||
return responseJson;
|
||||
}
|
||||
if (responseJson && Array.isArray(responseJson.items)) {
|
||||
return responseJson.items;
|
||||
}
|
||||
if (responseJson && Array.isArray(responseJson.results)) {
|
||||
return responseJson.results;
|
||||
}
|
||||
if (responseJson && Array.isArray(responseJson.data)) {
|
||||
return responseJson.data;
|
||||
}
|
||||
return responseJson ? [responseJson] : [];
|
||||
}
|
||||
|
||||
function getApiItem(sourceId, itemNumber, itemsPathOverride) {
|
||||
var items = getApiSourceItems(sourceId, itemsPathOverride);
|
||||
var parsedItemNumber = Math.max(1, Number(itemNumber || 1));
|
||||
var index = Number.isFinite(parsedItemNumber) && parsedItemNumber > 0 ? parsedItemNumber - 1 : 0;
|
||||
return items[index] || null;
|
||||
}
|
||||
|
||||
function substituteApiVariables(html, item) {
|
||||
var source = String(html || '');
|
||||
return source.replace(/\{\{\s*([^{}]+?)\s*\}\}/g, function (_match, expression) {
|
||||
if (!item || typeof item !== 'object') {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (typeof placeholderUtils.resolvePlaceholderExpression !== 'function' || typeof placeholderUtils.formatPlaceholderValue !== 'function') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return escapeHtml(placeholderUtils.formatPlaceholderValue(placeholderUtils.resolvePlaceholderExpression(item, expression)));
|
||||
});
|
||||
}
|
||||
|
||||
function getApiPreviewFallback(item) {
|
||||
if (!item || typeof item !== 'object') {
|
||||
return '';
|
||||
}
|
||||
|
||||
var title = String(item.title || item.name || '').trim();
|
||||
var description = String(item.description || item.summary || item.text || '').trim();
|
||||
var summary = [];
|
||||
if (title) {
|
||||
summary.push('<h3>' + escapeHtml(title) + '</h3>');
|
||||
}
|
||||
if (description) {
|
||||
summary.push('<div>' + sanitizeRichText(description) + '</div>');
|
||||
}
|
||||
if (!summary.length) {
|
||||
return '';
|
||||
}
|
||||
return summary.join('');
|
||||
}
|
||||
|
||||
function normalizeRenderableValue(value) {
|
||||
if (value && typeof value === 'object') {
|
||||
if (value.value !== undefined) {
|
||||
return value.value;
|
||||
}
|
||||
if (value.text !== undefined) {
|
||||
return value.text;
|
||||
}
|
||||
if (value.html !== undefined) {
|
||||
return value.html;
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch (_error) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function renderApiRegion(region, regionContent) {
|
||||
var content = normalizeRenderableValue(regionContent && regionContent.value !== undefined ? regionContent.value : '');
|
||||
var sourceId = regionContent && regionContent.source_id !== undefined ? regionContent.source_id : null;
|
||||
var itemNumber = regionContent && regionContent.item_number !== undefined ? regionContent.item_number : 1;
|
||||
var itemsPath = regionContent && regionContent.items_path !== undefined ? regionContent.items_path : '';
|
||||
var fontFamily = sanitizeFontFamily(regionContent.font_family || region.fontFamily);
|
||||
var fontSize = sanitizeFontSize(regionContent.font_size || region.fontSize);
|
||||
var fontColor = sanitizeTextColor(regionContent.font_color || region.fontColor);
|
||||
var item = getApiItem(sourceId, itemNumber, itemsPath);
|
||||
var body = item ? substituteApiVariables(content, item) : '';
|
||||
if (!body && item) {
|
||||
body = getApiPreviewFallback(item);
|
||||
}
|
||||
var contentStyle = 'width:' + region.pixelWidth + 'px;height:' + region.pixelHeight + 'px;transform:scale(' + region.canvasScale + ');transform-origin:top left;' + (fontFamily ? 'font-family:' + escapeHtml(fontFamily) + ';' : '') + 'font-size:' + fontSize + 'px;color:' + escapeHtml(fontColor) + ';';
|
||||
if (!body) {
|
||||
return '';
|
||||
}
|
||||
var renderedBody = renderEditorJsContent(body);
|
||||
return renderedBody ? '<div class="template-region api" style="' + region.baseStyle + '"><div class="template-region-text-scale" style="' + contentStyle + '">' + renderedBody + '</div></div>' : '';
|
||||
}
|
||||
|
||||
registry.register('api', {
|
||||
renderRegion: renderApiRegion
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
// HTML region rendering with sandboxed iframe output.
|
||||
|
||||
var registry = window.pulsePlayerRegionTypes;
|
||||
|
||||
function renderHtmlRegionContent(value) {
|
||||
var html = String(value || '').trim();
|
||||
if (!html) {
|
||||
return '';
|
||||
}
|
||||
return '<iframe class="template-region-html-frame" sandbox="" scrolling="no" srcdoc="<!doctype html><html><head><style>html,body{margin:0;width:100%;height:100%;overflow:hidden;background:transparent;}</style></head><body>' + escapeHtml(html) + '</body></html>" title="HTML region" loading="eager"></iframe>';
|
||||
}
|
||||
|
||||
function renderHtmlRegion(region, regionContent) {
|
||||
return '<div class="template-region html" style="' + region.baseStyle + '">' + renderHtmlRegionContent(regionContent.value || '') + '</div>';
|
||||
}
|
||||
|
||||
registry.register('html', {
|
||||
renderRegion: renderHtmlRegion
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
// Image region rendering for direct slide media references.
|
||||
|
||||
var registry = window.pulsePlayerRegionTypes;
|
||||
|
||||
function renderImageRegion(region, regionContent) {
|
||||
var src = regionContent.value || '';
|
||||
if (!String(src || '').trim()) {
|
||||
return '';
|
||||
}
|
||||
return '<div class="template-region image" style="' + region.baseStyle + '"><img src="' + escapeHtml(src) + '" alt="' + escapeHtml(region.label) + '" /></div>';
|
||||
}
|
||||
|
||||
registry.register('image', {
|
||||
renderRegion: renderImageRegion
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
// QR code region rendering for embedded URL-to-image output.
|
||||
|
||||
var registry = window.pulsePlayerRegionTypes;
|
||||
|
||||
function renderQrCodeRegion(region, regionContent) {
|
||||
var svg = String(regionContent && regionContent.qr_svg || '').trim();
|
||||
if (!svg) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return '<div class="template-region qr-code" style="' + region.baseStyle + '"><img class="template-region-qr-code-image" src="data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg) + '" alt="QR code" /></div>';
|
||||
}
|
||||
|
||||
registry.register('qr-code', {
|
||||
renderRegion: renderQrCodeRegion
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
// RSS region helpers for resolving feeds, items, and nested values.
|
||||
|
||||
var registry = window.pulsePlayerRegionTypes;
|
||||
|
||||
function getDefaultStyle() {
|
||||
return {
|
||||
font_family: 'Arial',
|
||||
font_size: 32,
|
||||
font_color: '#000000'
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeRenderableValue(value) {
|
||||
if (value && typeof value === 'object') {
|
||||
if (value.value !== undefined) {
|
||||
return value.value;
|
||||
}
|
||||
if (value.text !== undefined) {
|
||||
return value.text;
|
||||
}
|
||||
if (value.html !== undefined) {
|
||||
return value.html;
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch (_error) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function getRssFeedById(feedId) {
|
||||
var feeds = Array.isArray(initialData && initialData.rssFeeds) ? initialData.rssFeeds : [];
|
||||
var normalizedId = Number(feedId || 0);
|
||||
return feeds.find(function (feed) {
|
||||
return Number(feed.id) === normalizedId;
|
||||
}) || null;
|
||||
}
|
||||
|
||||
function getRssFeedItem(feedId, itemNumber) {
|
||||
var feed = getRssFeedById(feedId);
|
||||
var items = feed && Array.isArray(feed.items) ? feed.items : [];
|
||||
var parsedItemNumber = Math.max(1, Number(itemNumber || 1));
|
||||
var index = Number.isFinite(parsedItemNumber) && parsedItemNumber > 0 ? parsedItemNumber - 1 : 0;
|
||||
return items[index] || null;
|
||||
}
|
||||
|
||||
function resolveRssPath(value, path) {
|
||||
var current = value;
|
||||
if (!path) {
|
||||
return current;
|
||||
}
|
||||
|
||||
String(path).split('.').forEach(function (segment) {
|
||||
if (current === undefined || current === null) {
|
||||
current = '';
|
||||
return;
|
||||
}
|
||||
current = current[segment];
|
||||
});
|
||||
|
||||
return current === undefined || current === null ? '' : current;
|
||||
}
|
||||
|
||||
function substituteRssVariables(html, item) {
|
||||
var source = String(html || '');
|
||||
return source.replace(/\{\{\s*([a-zA-Z0-9_]+)(?:\.([a-zA-Z0-9_.]+))?\s*\}\}/g, function (_match, tokenName, tokenPath) {
|
||||
if (!item || typeof item !== 'object') {
|
||||
return '';
|
||||
}
|
||||
var key = tokenPath ? tokenName + '.' + tokenPath : tokenName;
|
||||
return escapeHtml(resolveRssPath(item, key || ''));
|
||||
});
|
||||
}
|
||||
|
||||
function renderRssRegion(region, regionContent) {
|
||||
var content = normalizeRenderableValue(regionContent && regionContent.value !== undefined ? regionContent.value : '');
|
||||
var feedId = regionContent && regionContent.feed_id !== undefined ? regionContent.feed_id : null;
|
||||
var itemNumber = regionContent && regionContent.item_number !== undefined ? regionContent.item_number : 1;
|
||||
var defaultStyle = getDefaultStyle();
|
||||
var fontFamily = sanitizeFontFamily(regionContent.font_family || region.fontFamily || defaultStyle.font_family);
|
||||
var fontSize = sanitizeFontSize(regionContent.font_size || region.fontSize || defaultStyle.font_size);
|
||||
var fontColor = sanitizeTextColor(regionContent.font_color || region.fontColor || defaultStyle.font_color);
|
||||
var item = getRssFeedItem(feedId, itemNumber);
|
||||
var body = item ? substituteRssVariables(content, item) : '';
|
||||
if (!body && item) {
|
||||
var summaryParts = [];
|
||||
if (item.title) {
|
||||
summaryParts.push('<h3>' + escapeHtml(item.title) + '</h3>');
|
||||
}
|
||||
if (item.description) {
|
||||
summaryParts.push('<div>' + sanitizeRichText(item.description) + '</div>');
|
||||
}
|
||||
body = summaryParts.join('');
|
||||
}
|
||||
var contentStyle = 'width:' + region.pixelWidth + 'px;height:' + region.pixelHeight + 'px;transform:scale(' + region.canvasScale + ');transform-origin:top left;' + (fontFamily ? 'font-family:' + escapeHtml(fontFamily) + ';' : '') + 'font-size:' + fontSize + 'px;color:' + escapeHtml(fontColor) + ';';
|
||||
if (!body) {
|
||||
return '';
|
||||
}
|
||||
var renderedBody = renderEditorJsContent(body);
|
||||
return renderedBody ? '<div class="template-region rss" style="' + region.baseStyle + '"><div class="template-region-text-scale" style="' + contentStyle + '">' + renderedBody + '</div></div>' : '';
|
||||
}
|
||||
|
||||
registry.register('rss', {
|
||||
getDefaultStyle: getDefaultStyle,
|
||||
renderRegion: renderRssRegion
|
||||
});
|
||||
@@ -0,0 +1,831 @@
|
||||
var registry = window.pulsePlayerRegionTypes;
|
||||
|
||||
function renderRtmpRegion(region, regionContent) {
|
||||
var url = String(regionContent.value || '').trim();
|
||||
var disableAudio = regionContent.disable_audio === undefined ? true : Boolean(regionContent.disable_audio);
|
||||
var skipUnavailable = Boolean(currentPlaylistSkipUnavailableRtmp);
|
||||
if (!url) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return '<div class="template-region rtmp" style="' + region.baseStyle + '"><video class="template-region-rtmp-video" data-rtmp-source="' + escapeHtml(url) + '" data-rtmp-disable-audio="' + (disableAudio ? '1' : '0') + '" data-rtmp-skip-unavailable="' + (skipUnavailable ? '1' : '0') + '" autoplay playsinline preload="auto" tabindex="-1" disablepictureinpicture' + (disableAudio ? ' muted' : '') + '></video><div class="template-region-placeholder template-region-rtmp-placeholder">Loading RTMP stream...</div></div>';
|
||||
}
|
||||
|
||||
function getRtmpSessionUrl(sourceUrl, disableAudio) {
|
||||
return '/api/rtmp/session?source=' + encodeURIComponent(sourceUrl) + '&disableAudio=' + (disableAudio ? '1' : '0');
|
||||
}
|
||||
|
||||
var rtmpAvailabilityCache = Object.create(null);
|
||||
var rtmpAvailabilityRetryMs = 5000;
|
||||
var rtmpWarmupTimers = Object.create(null);
|
||||
var rtmpBrowserReadyCache = Object.create(null);
|
||||
var rtmpBrowserWarmupTimers = Object.create(null);
|
||||
var rtmpBrowserWarmupContainer = null;
|
||||
var rtmpBrowserWarmupStates = Object.create(null);
|
||||
|
||||
function getRtmpAvailabilityKey(sourceUrl, disableAudio) {
|
||||
return String(sourceUrl || '').trim() + '\n' + (disableAudio ? '1' : '0');
|
||||
}
|
||||
|
||||
function setRtmpAvailability(sourceUrl, disableAudio, available) {
|
||||
var key = getRtmpAvailabilityKey(sourceUrl, disableAudio);
|
||||
rtmpAvailabilityCache[key] = {
|
||||
available: available === null ? null : Boolean(available),
|
||||
pending: available === null,
|
||||
checkedAt: Date.now()
|
||||
};
|
||||
}
|
||||
|
||||
function getRtmpAvailability(sourceUrl, disableAudio) {
|
||||
return rtmpAvailabilityCache[getRtmpAvailabilityKey(sourceUrl, disableAudio)] || null;
|
||||
}
|
||||
|
||||
function isRtmpAvailabilityStale(status) {
|
||||
if (!status || !status.checkedAt) {
|
||||
return true;
|
||||
}
|
||||
return Date.now() - Number(status.checkedAt || 0) >= rtmpAvailabilityRetryMs;
|
||||
}
|
||||
|
||||
function isRtmpSlideUnavailable(slide) {
|
||||
if (!slide || !slide.template || !Array.isArray(slide.template.regions)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
var content = slide.content || {};
|
||||
return slide.template.regions.some(function (region) {
|
||||
if (region.region_type !== 'rtmp') {
|
||||
return false;
|
||||
}
|
||||
|
||||
var regionContent = content[region.region_key] || {};
|
||||
var sourceUrl = String(regionContent.value || '').trim();
|
||||
if (!sourceUrl) {
|
||||
return false;
|
||||
}
|
||||
|
||||
var disableAudio = regionContent.disable_audio === undefined ? true : Boolean(regionContent.disable_audio);
|
||||
var serverStatus = getRtmpAvailability(sourceUrl, disableAudio);
|
||||
return Boolean(!serverStatus || isRtmpAvailabilityStale(serverStatus) || serverStatus.available !== true);
|
||||
});
|
||||
}
|
||||
|
||||
function clearRtmpAvailabilityForSlide(slide) {
|
||||
if (!slide || !slide.template || !Array.isArray(slide.template.regions)) {
|
||||
return;
|
||||
}
|
||||
|
||||
var content = slide.content || {};
|
||||
slide.template.regions.forEach(function (region) {
|
||||
if (region.region_type !== 'rtmp') {
|
||||
return;
|
||||
}
|
||||
|
||||
var regionContent = content[region.region_key] || {};
|
||||
var sourceUrl = String(regionContent.value || '').trim();
|
||||
if (!sourceUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
var disableAudio = regionContent.disable_audio === undefined ? true : Boolean(regionContent.disable_audio);
|
||||
setRtmpAvailability(sourceUrl, disableAudio, null);
|
||||
});
|
||||
}
|
||||
|
||||
function setRtmpBrowserReady(sourceUrl, disableAudio, ready) {
|
||||
var key = getRtmpAvailabilityKey(sourceUrl, disableAudio);
|
||||
rtmpBrowserReadyCache[key] = {
|
||||
ready: ready === null ? null : Boolean(ready),
|
||||
checkedAt: Date.now()
|
||||
};
|
||||
}
|
||||
|
||||
function getRtmpBrowserReady(sourceUrl, disableAudio) {
|
||||
return rtmpBrowserReadyCache[getRtmpAvailabilityKey(sourceUrl, disableAudio)] || null;
|
||||
}
|
||||
|
||||
function isRtmpBrowserReadyStale(status) {
|
||||
if (!status || !status.checkedAt) {
|
||||
return true;
|
||||
}
|
||||
return Date.now() - Number(status.checkedAt || 0) >= rtmpAvailabilityRetryMs;
|
||||
}
|
||||
|
||||
function ensureRtmpBrowserWarmupContainer() {
|
||||
if (rtmpBrowserWarmupContainer) {
|
||||
return rtmpBrowserWarmupContainer;
|
||||
}
|
||||
|
||||
rtmpBrowserWarmupContainer = document.createElement('div');
|
||||
rtmpBrowserWarmupContainer.className = 'rtmp-browser-warmups';
|
||||
rtmpBrowserWarmupContainer.setAttribute('aria-hidden', 'true');
|
||||
document.body.appendChild(rtmpBrowserWarmupContainer);
|
||||
return rtmpBrowserWarmupContainer;
|
||||
}
|
||||
|
||||
function getRtmpBrowserWarmupState(sourceUrl, disableAudio) {
|
||||
return rtmpBrowserWarmupStates[getRtmpAvailabilityKey(sourceUrl, disableAudio)] || null;
|
||||
}
|
||||
|
||||
function clearRtmpBrowserWarmupState(sourceUrl, disableAudio) {
|
||||
var key = getRtmpAvailabilityKey(sourceUrl, disableAudio);
|
||||
var state = rtmpBrowserWarmupStates[key];
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.video && state.video.__rtmpBrowserWarmupTimer) {
|
||||
window.clearTimeout(state.video.__rtmpBrowserWarmupTimer);
|
||||
state.video.__rtmpBrowserWarmupTimer = null;
|
||||
}
|
||||
if (state.video && state.video.__rtmpHls) {
|
||||
try {
|
||||
state.video.__rtmpHls.destroy();
|
||||
} catch (_error) {
|
||||
// ignore cleanup errors
|
||||
}
|
||||
state.video.__rtmpHls = null;
|
||||
}
|
||||
if (state.wrapper && state.wrapper.parentNode) {
|
||||
state.wrapper.parentNode.removeChild(state.wrapper);
|
||||
}
|
||||
delete rtmpBrowserWarmupStates[key];
|
||||
}
|
||||
|
||||
function warmupRtmpBrowser(sourceUrl, disableAudio) {
|
||||
var key = getRtmpAvailabilityKey(sourceUrl, disableAudio);
|
||||
var existing = rtmpBrowserWarmupStates[key];
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
var wrapper = document.createElement('div');
|
||||
wrapper.className = 'rtmp-browser-warmup-entry';
|
||||
wrapper.setAttribute('aria-hidden', 'true');
|
||||
|
||||
var video = document.createElement('video');
|
||||
video.className = 'template-region-rtmp-video';
|
||||
video.dataset.rtmpSource = sourceUrl;
|
||||
video.dataset.rtmpDisableAudio = disableAudio ? '1' : '0';
|
||||
video.autoplay = true;
|
||||
video.playsInline = true;
|
||||
video.preload = 'auto';
|
||||
video.tabIndex = -1;
|
||||
video.disablePictureInPicture = true;
|
||||
video.muted = disableAudio;
|
||||
|
||||
wrapper.appendChild(video);
|
||||
ensureRtmpBrowserWarmupContainer().appendChild(wrapper);
|
||||
|
||||
var state = {
|
||||
wrapper: wrapper,
|
||||
video: video,
|
||||
sourceUrl: sourceUrl,
|
||||
disableAudio: disableAudio
|
||||
};
|
||||
rtmpBrowserWarmupStates[key] = state;
|
||||
setRtmpBrowserReady(sourceUrl, disableAudio, null);
|
||||
|
||||
var markReady = function () {
|
||||
setRtmpBrowserReady(sourceUrl, disableAudio, true);
|
||||
clearRtmpBrowserWarmupState(sourceUrl, disableAudio);
|
||||
};
|
||||
|
||||
var markNotReady = function () {
|
||||
setRtmpBrowserReady(sourceUrl, disableAudio, false);
|
||||
};
|
||||
|
||||
video.addEventListener('canplay', markReady, { once: true });
|
||||
video.addEventListener('playing', markReady, { once: true });
|
||||
video.addEventListener('error', markNotReady, { once: true });
|
||||
|
||||
if (window.Hls && window.Hls.isSupported && window.Hls.isSupported()) {
|
||||
var hls = new window.Hls({
|
||||
enableWorker: true,
|
||||
lowLatencyMode: true,
|
||||
liveSyncDurationCount: 4,
|
||||
liveMaxLatencyDurationCount: 8,
|
||||
maxBufferLength: 10,
|
||||
maxLiveSyncPlaybackRate: 1,
|
||||
backBufferLength: 10
|
||||
});
|
||||
video.__rtmpHls = hls;
|
||||
hls.attachMedia(video);
|
||||
hls.on(window.Hls.Events.MEDIA_ATTACHED, function () {
|
||||
requestWarmupPlaylist(sourceUrl, disableAudio, video, markNotReady);
|
||||
});
|
||||
hls.on(window.Hls.Events.ERROR, function (_event, data) {
|
||||
if (data && data.fatal) {
|
||||
markNotReady();
|
||||
clearRtmpBrowserWarmupState(sourceUrl, disableAudio);
|
||||
}
|
||||
});
|
||||
return state;
|
||||
}
|
||||
|
||||
if (video.canPlayType && video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
requestWarmupPlaylist(sourceUrl, disableAudio, video, markNotReady);
|
||||
return state;
|
||||
}
|
||||
|
||||
markNotReady();
|
||||
return state;
|
||||
}
|
||||
|
||||
function requestWarmupPlaylist(sourceUrl, disableAudio, video, onFailure) {
|
||||
fetch(getRtmpSessionUrl(sourceUrl, disableAudio), {
|
||||
credentials: 'same-origin'
|
||||
}).then(function (response) {
|
||||
return response.json().catch(function () {
|
||||
return null;
|
||||
}).then(function (payload) {
|
||||
return {
|
||||
response: response,
|
||||
payload: payload
|
||||
};
|
||||
});
|
||||
}).then(function (result) {
|
||||
if (!result || !result.response) {
|
||||
throw new Error('Unable to initialize RTMP stream.');
|
||||
}
|
||||
|
||||
var response = result.response;
|
||||
var payload = result.payload || null;
|
||||
var playlistUrl = payload && payload.playlistUrl ? String(payload.playlistUrl).trim() : '';
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 503 && payload && payload.probing) {
|
||||
setRtmpBrowserReady(sourceUrl, disableAudio, null);
|
||||
if (video && !video.__rtmpBrowserWarmupTimer) {
|
||||
video.__rtmpBrowserWarmupTimer = window.setTimeout(function () {
|
||||
video.__rtmpBrowserWarmupTimer = null;
|
||||
requestWarmupPlaylist(sourceUrl, disableAudio, video, onFailure);
|
||||
}, 500);
|
||||
}
|
||||
return;
|
||||
}
|
||||
throw new Error('Unable to initialize RTMP stream.');
|
||||
}
|
||||
|
||||
if (!playlistUrl) {
|
||||
throw new Error('RTMP playlist URL was not returned.');
|
||||
}
|
||||
|
||||
setRtmpBrowserReady(sourceUrl, disableAudio, false);
|
||||
|
||||
if (video.__rtmpHls && window.Hls && window.Hls.isSupported && window.Hls.isSupported()) {
|
||||
video.__rtmpHls.loadSource(playlistUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
if (video) {
|
||||
video.src = playlistUrl;
|
||||
video.play().catch(function () {
|
||||
return null;
|
||||
});
|
||||
}
|
||||
}).catch(function () {
|
||||
setRtmpBrowserReady(sourceUrl, disableAudio, false);
|
||||
if (typeof onFailure === 'function') {
|
||||
onFailure();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function getRtmpWarmupSlides(sourceSlides, targetIndex) {
|
||||
var availableSlides = Array.isArray(sourceSlides) ? sourceSlides : [];
|
||||
if (!availableSlides.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
var normalizedIndex = Number(targetIndex || 0);
|
||||
if (!Number.isFinite(normalizedIndex) || normalizedIndex < 0 || normalizedIndex >= availableSlides.length) {
|
||||
normalizedIndex = 0;
|
||||
}
|
||||
|
||||
var warmupSlides = [];
|
||||
var currentSlide = availableSlides[normalizedIndex];
|
||||
var nextSlide = availableSlides[normalizedIndex + 1];
|
||||
|
||||
if (currentSlide) {
|
||||
warmupSlides.push(currentSlide);
|
||||
}
|
||||
if (nextSlide && nextSlide !== currentSlide) {
|
||||
warmupSlides.push(nextSlide);
|
||||
}
|
||||
|
||||
return warmupSlides;
|
||||
}
|
||||
|
||||
function getRtmpWarmupEntries(sourceSlides) {
|
||||
var entries = [];
|
||||
var seen = Object.create(null);
|
||||
|
||||
(Array.isArray(sourceSlides) ? sourceSlides : []).forEach(function (slide) {
|
||||
var content = slide && slide.content ? slide.content : {};
|
||||
var regions = slide && slide.template && Array.isArray(slide.template.regions) ? slide.template.regions : [];
|
||||
regions.forEach(function (region) {
|
||||
if (region.region_type !== 'rtmp') {
|
||||
return;
|
||||
}
|
||||
|
||||
var regionContent = content[region.region_key] || {};
|
||||
var url = String(regionContent.value || '').trim();
|
||||
if (!url) {
|
||||
return;
|
||||
}
|
||||
|
||||
var disableAudio = regionContent.disable_audio === undefined ? true : Boolean(regionContent.disable_audio);
|
||||
var key = getRtmpAvailabilityKey(url, disableAudio);
|
||||
if (seen[key]) {
|
||||
return;
|
||||
}
|
||||
|
||||
seen[key] = true;
|
||||
entries.push({
|
||||
url: url,
|
||||
disableAudio: disableAudio,
|
||||
key: key
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
function syncRtmpWarmups(sourceSlides, targetIndex) {
|
||||
var entries = getRtmpWarmupEntries(getRtmpWarmupSlides(sourceSlides, targetIndex));
|
||||
|
||||
entries.forEach(function (entry) {
|
||||
var status = getRtmpAvailability(entry.url, entry.disableAudio);
|
||||
if (status && !isRtmpAvailabilityStale(status) && status.available === true) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (rtmpWarmupTimers[entry.key]) {
|
||||
return;
|
||||
}
|
||||
|
||||
function requestWarmup() {
|
||||
fetch(getRtmpSessionUrl(entry.url, entry.disableAudio), {
|
||||
credentials: 'same-origin'
|
||||
}).then(function (response) {
|
||||
return response.json().catch(function () {
|
||||
return null;
|
||||
}).then(function (payload) {
|
||||
return {
|
||||
response: response,
|
||||
payload: payload
|
||||
};
|
||||
});
|
||||
}).then(function (result) {
|
||||
rtmpWarmupTimers[entry.key] = null;
|
||||
if (!result || !result.response) {
|
||||
setRtmpAvailability(entry.url, entry.disableAudio, false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result.response.ok) {
|
||||
if (result.response.status === 503 && result.payload && result.payload.probing) {
|
||||
setRtmpAvailability(entry.url, entry.disableAudio, null);
|
||||
rtmpWarmupTimers[entry.key] = window.setTimeout(function () {
|
||||
rtmpWarmupTimers[entry.key] = null;
|
||||
requestWarmup();
|
||||
}, 500);
|
||||
return;
|
||||
}
|
||||
setRtmpAvailability(entry.url, entry.disableAudio, false);
|
||||
return;
|
||||
}
|
||||
|
||||
setRtmpAvailability(entry.url, entry.disableAudio, true);
|
||||
}).catch(function () {
|
||||
rtmpWarmupTimers[entry.key] = null;
|
||||
setRtmpAvailability(entry.url, entry.disableAudio, false);
|
||||
});
|
||||
}
|
||||
|
||||
setRtmpAvailability(entry.url, entry.disableAudio, null);
|
||||
requestWarmup();
|
||||
});
|
||||
}
|
||||
|
||||
async function probeRtmpSlideAvailability(slide) {
|
||||
var entries = getRtmpWarmupEntries([slide]);
|
||||
if (!entries.length) {
|
||||
return true;
|
||||
}
|
||||
|
||||
var results = await Promise.all(entries.map(function (entry) {
|
||||
setRtmpAvailability(entry.url, entry.disableAudio, null);
|
||||
return fetch(getRtmpSessionUrl(entry.url, entry.disableAudio), {
|
||||
credentials: 'same-origin'
|
||||
}).then(function (response) {
|
||||
return response.json().catch(function () {
|
||||
return null;
|
||||
}).then(function (payload) {
|
||||
return {
|
||||
response: response,
|
||||
payload: payload
|
||||
};
|
||||
});
|
||||
}).then(function (result) {
|
||||
if (!result || !result.response) {
|
||||
setRtmpAvailability(entry.url, entry.disableAudio, false);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!result.response.ok) {
|
||||
if (result.response.status === 503 && result.payload && result.payload.probing) {
|
||||
setRtmpAvailability(entry.url, entry.disableAudio, null);
|
||||
return false;
|
||||
}
|
||||
setRtmpAvailability(entry.url, entry.disableAudio, false);
|
||||
return false;
|
||||
}
|
||||
|
||||
setRtmpAvailability(entry.url, entry.disableAudio, true);
|
||||
return true;
|
||||
}).catch(function () {
|
||||
setRtmpAvailability(entry.url, entry.disableAudio, false);
|
||||
return false;
|
||||
});
|
||||
}));
|
||||
|
||||
return results.every(function (value) {
|
||||
return Boolean(value);
|
||||
});
|
||||
}
|
||||
|
||||
function syncRtmpBrowserWarmups(sourceSlides, targetIndex) {
|
||||
return;
|
||||
}
|
||||
|
||||
function isRtmpBrowserUnavailable(slide) {
|
||||
if (!slide || !slide.template || !Array.isArray(slide.template.regions)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
var content = slide.content || {};
|
||||
return slide.template.regions.some(function (region) {
|
||||
if (region.region_type !== 'rtmp') {
|
||||
return false;
|
||||
}
|
||||
|
||||
var regionContent = content[region.region_key] || {};
|
||||
var sourceUrl = String(regionContent.value || '').trim();
|
||||
if (!sourceUrl) {
|
||||
return false;
|
||||
}
|
||||
|
||||
var disableAudio = regionContent.disable_audio === undefined ? true : Boolean(regionContent.disable_audio);
|
||||
var status = getRtmpBrowserReady(sourceUrl, disableAudio);
|
||||
return Boolean(!status || isRtmpBrowserReadyStale(status) || status.ready !== true);
|
||||
});
|
||||
}
|
||||
|
||||
function bindRtmpVideoPlaceholder(video, placeholder) {
|
||||
if (!video) {
|
||||
return;
|
||||
}
|
||||
|
||||
var markReady = function () {
|
||||
if (placeholder) {
|
||||
placeholder.style.display = 'none';
|
||||
}
|
||||
};
|
||||
|
||||
video.addEventListener('canplay', markReady, { once: true });
|
||||
video.addEventListener('playing', markReady, { once: true });
|
||||
if (video.readyState >= 2) {
|
||||
markReady();
|
||||
}
|
||||
}
|
||||
|
||||
function startRtmpPlayback(video, sourceUrl, disableAudio, skipUnavailable, placeholder, isPreload, onFailure) {
|
||||
var startupTimeoutMs = skipUnavailable ? 6000 : 0;
|
||||
var startupTimer = null;
|
||||
var probeRetryTimer = null;
|
||||
var webReceiveTimer = null;
|
||||
var webReceiveRetryUsed = false;
|
||||
|
||||
function failPlayback(message, silent) {
|
||||
if (video.dataset.rtmpFailureHandled === '1') {
|
||||
return;
|
||||
}
|
||||
video.dataset.rtmpFailureHandled = '1';
|
||||
if (typeof onFailure === 'function') {
|
||||
onFailure(message, silent);
|
||||
return;
|
||||
}
|
||||
if (!isPreload && skipUnavailable && typeof handleRtmpPlaybackFailure === 'function' && handleRtmpPlaybackFailure(message, { silent: Boolean(silent) })) {
|
||||
return;
|
||||
}
|
||||
if (placeholder) {
|
||||
placeholder.style.display = 'flex';
|
||||
placeholder.textContent = message;
|
||||
}
|
||||
}
|
||||
|
||||
function clearStartupTimer() {
|
||||
if (startupTimer) {
|
||||
window.clearTimeout(startupTimer);
|
||||
startupTimer = null;
|
||||
}
|
||||
if (video.__rtmpStartupTimer) {
|
||||
video.__rtmpStartupTimer = null;
|
||||
}
|
||||
if (probeRetryTimer) {
|
||||
window.clearTimeout(probeRetryTimer);
|
||||
probeRetryTimer = null;
|
||||
}
|
||||
if (video.__rtmpProbeRetryTimer) {
|
||||
video.__rtmpProbeRetryTimer = null;
|
||||
}
|
||||
if (webReceiveTimer) {
|
||||
window.clearTimeout(webReceiveTimer);
|
||||
webReceiveTimer = null;
|
||||
}
|
||||
if (video.__rtmpWebReceiveTimer) {
|
||||
video.__rtmpWebReceiveTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function tryPlay() {
|
||||
if (video && typeof video.play === 'function') {
|
||||
video.play().catch(function () {
|
||||
return null;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function markReceiving() {
|
||||
if (webReceiveTimer) {
|
||||
window.clearTimeout(webReceiveTimer);
|
||||
webReceiveTimer = null;
|
||||
}
|
||||
if (video.__rtmpWebReceiveTimer) {
|
||||
video.__rtmpWebReceiveTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleReceiveRetry() {
|
||||
if (isPreload || webReceiveRetryUsed || video.dataset.rtmpFailureHandled === '1') {
|
||||
return;
|
||||
}
|
||||
if (webReceiveTimer) {
|
||||
return;
|
||||
}
|
||||
webReceiveTimer = window.setTimeout(function () {
|
||||
webReceiveTimer = null;
|
||||
video.__rtmpWebReceiveTimer = null;
|
||||
if (video.dataset.rtmpFailureHandled === '1' || webReceiveRetryUsed) {
|
||||
return;
|
||||
}
|
||||
if (video.readyState >= 2) {
|
||||
return;
|
||||
}
|
||||
webReceiveRetryUsed = true;
|
||||
clearStartupTimer();
|
||||
try {
|
||||
if (video.__rtmpHls) {
|
||||
video.__rtmpHls.destroy();
|
||||
}
|
||||
} catch (_error) {
|
||||
// ignore cleanup errors
|
||||
}
|
||||
video.__rtmpHls = null;
|
||||
requestSession();
|
||||
}, 1200);
|
||||
video.__rtmpWebReceiveTimer = webReceiveTimer;
|
||||
}
|
||||
|
||||
function retryWebReceiveOrFail(message, silent) {
|
||||
if (isPreload || video.dataset.rtmpFailureHandled === '1') {
|
||||
return;
|
||||
}
|
||||
if (!webReceiveRetryUsed) {
|
||||
webReceiveRetryUsed = true;
|
||||
clearStartupTimer();
|
||||
if (webReceiveTimer) {
|
||||
window.clearTimeout(webReceiveTimer);
|
||||
webReceiveTimer = null;
|
||||
}
|
||||
if (video.__rtmpWebReceiveTimer) {
|
||||
video.__rtmpWebReceiveTimer = null;
|
||||
}
|
||||
window.setTimeout(function () {
|
||||
requestSession();
|
||||
}, 500);
|
||||
return;
|
||||
}
|
||||
failPlayback(message, silent);
|
||||
}
|
||||
|
||||
video.dataset.rtmpInitialized = '1';
|
||||
video.muted = disableAudio;
|
||||
video.controls = false;
|
||||
video.playsInline = true;
|
||||
video.autoplay = true;
|
||||
|
||||
bindRtmpVideoPlaceholder(video, placeholder);
|
||||
video.addEventListener('canplay', markReceiving, { once: true });
|
||||
video.addEventListener('playing', markReceiving, { once: true });
|
||||
|
||||
if (startupTimeoutMs > 0) {
|
||||
startupTimer = window.setTimeout(function () {
|
||||
startupTimer = null;
|
||||
video.__rtmpStartupTimer = null;
|
||||
failPlayback('Unable to load RTMP stream.', true);
|
||||
}, startupTimeoutMs);
|
||||
video.__rtmpStartupTimer = startupTimer;
|
||||
}
|
||||
|
||||
function retryWhileProbing() {
|
||||
if (probeRetryTimer || video.dataset.rtmpFailureHandled === '1') {
|
||||
return;
|
||||
}
|
||||
probeRetryTimer = window.setTimeout(function () {
|
||||
probeRetryTimer = null;
|
||||
video.__rtmpProbeRetryTimer = null;
|
||||
requestSession();
|
||||
}, 500);
|
||||
video.__rtmpProbeRetryTimer = probeRetryTimer;
|
||||
}
|
||||
|
||||
function requestSession() {
|
||||
fetch(getRtmpSessionUrl(sourceUrl, disableAudio), {
|
||||
credentials: 'same-origin'
|
||||
}).then(function (response) {
|
||||
return response.json().catch(function () {
|
||||
return null;
|
||||
}).then(function (payload) {
|
||||
return {
|
||||
response: response,
|
||||
payload: payload
|
||||
};
|
||||
});
|
||||
}).then(function (result) {
|
||||
if (!result || !result.response) {
|
||||
throw new Error('Unable to initialize RTMP stream.');
|
||||
}
|
||||
|
||||
var response = result.response;
|
||||
var payload = result.payload || null;
|
||||
var playlistUrl = payload && payload.playlistUrl ? String(payload.playlistUrl).trim() : '';
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 503 && payload && payload.probing) {
|
||||
setRtmpAvailability(sourceUrl, disableAudio, null);
|
||||
retryWhileProbing();
|
||||
return null;
|
||||
}
|
||||
setRtmpAvailability(sourceUrl, disableAudio, false);
|
||||
retryWebReceiveOrFail('Unable to load RTMP stream.', true);
|
||||
return null;
|
||||
}
|
||||
|
||||
setRtmpAvailability(sourceUrl, disableAudio, true);
|
||||
if (!playlistUrl) {
|
||||
throw new Error('RTMP playlist URL was not returned.');
|
||||
}
|
||||
|
||||
if (window.Hls && window.Hls.isSupported && window.Hls.isSupported()) {
|
||||
var hls = new window.Hls({
|
||||
enableWorker: true,
|
||||
lowLatencyMode: true,
|
||||
liveSyncDurationCount: 4,
|
||||
liveMaxLatencyDurationCount: 8,
|
||||
maxBufferLength: 20,
|
||||
maxLiveSyncPlaybackRate: 1,
|
||||
backBufferLength: 30
|
||||
});
|
||||
video.__rtmpHls = hls;
|
||||
hls.attachMedia(video);
|
||||
hls.on(window.Hls.Events.MEDIA_ATTACHED, function () {
|
||||
hls.loadSource(playlistUrl);
|
||||
scheduleReceiveRetry();
|
||||
});
|
||||
hls.on(window.Hls.Events.MANIFEST_PARSED, function () {
|
||||
tryPlay();
|
||||
});
|
||||
hls.on(window.Hls.Events.ERROR, function (_event, data) {
|
||||
if (data && data.fatal) {
|
||||
clearStartupTimer();
|
||||
setRtmpAvailability(sourceUrl, disableAudio, false);
|
||||
try {
|
||||
hls.destroy();
|
||||
} catch (_error) {
|
||||
// ignore cleanup errors
|
||||
}
|
||||
video.__rtmpHls = null;
|
||||
retryWebReceiveOrFail('RTMP playback failed.', true);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (video.canPlayType && video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
video.src = playlistUrl;
|
||||
tryPlay();
|
||||
scheduleReceiveRetry();
|
||||
return;
|
||||
}
|
||||
|
||||
failPlayback('RTMP playback is not supported in this browser.', true);
|
||||
return null;
|
||||
}).catch(function () {
|
||||
clearStartupTimer();
|
||||
setRtmpAvailability(sourceUrl, disableAudio, false);
|
||||
retryWebReceiveOrFail('Unable to load RTMP stream.', true);
|
||||
});
|
||||
}
|
||||
|
||||
requestSession();
|
||||
}
|
||||
|
||||
function syncRtmpRegions(root) {
|
||||
if (!root) {
|
||||
return;
|
||||
}
|
||||
|
||||
var videos = root.querySelectorAll('video[data-rtmp-source]');
|
||||
Array.prototype.forEach.call(videos, function (video) {
|
||||
if (!video || video.dataset.rtmpInitialized === '1') {
|
||||
return;
|
||||
}
|
||||
|
||||
var sourceUrl = String(video.dataset.rtmpSource || '').trim();
|
||||
var disableAudio = String(video.dataset.rtmpDisableAudio || '1') !== '0';
|
||||
var region = video.parentNode;
|
||||
var placeholder = region ? region.querySelector('.template-region-rtmp-placeholder') : null;
|
||||
|
||||
if (!sourceUrl) {
|
||||
if (placeholder) {
|
||||
placeholder.textContent = 'RTMP stream';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
var skipUnavailable = String(video.dataset.rtmpSkipUnavailable || '0') === '1';
|
||||
var startupTimer = null;
|
||||
|
||||
if (skipUnavailable) {
|
||||
var preflightStatus = getRtmpAvailability(sourceUrl, disableAudio);
|
||||
if (preflightStatus && !isRtmpAvailabilityStale(preflightStatus) && preflightStatus.available === false) {
|
||||
failPlayback('Unable to load RTMP stream.', true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function failPlayback(message, silent) {
|
||||
if (video.dataset.rtmpFailureHandled === '1') {
|
||||
return;
|
||||
}
|
||||
video.dataset.rtmpFailureHandled = '1';
|
||||
if (skipUnavailable && typeof handleRtmpPlaybackFailure === 'function' && handleRtmpPlaybackFailure(message, { silent: Boolean(silent) })) {
|
||||
return;
|
||||
}
|
||||
if (placeholder) {
|
||||
placeholder.style.display = 'flex';
|
||||
placeholder.textContent = message;
|
||||
}
|
||||
}
|
||||
|
||||
startRtmpPlayback(video, sourceUrl, disableAudio, skipUnavailable, placeholder, false);
|
||||
});
|
||||
}
|
||||
|
||||
function destroyRtmpRegions(root) {
|
||||
if (!root) {
|
||||
return;
|
||||
}
|
||||
|
||||
var videos = root.querySelectorAll('video[data-rtmp-source]');
|
||||
Array.prototype.forEach.call(videos, function (video) {
|
||||
if (video.__rtmpStartupTimer) {
|
||||
window.clearTimeout(video.__rtmpStartupTimer);
|
||||
video.__rtmpStartupTimer = null;
|
||||
}
|
||||
if (video.__rtmpProbeRetryTimer) {
|
||||
window.clearTimeout(video.__rtmpProbeRetryTimer);
|
||||
video.__rtmpProbeRetryTimer = null;
|
||||
}
|
||||
if (video.__rtmpWebReceiveTimer) {
|
||||
window.clearTimeout(video.__rtmpWebReceiveTimer);
|
||||
video.__rtmpWebReceiveTimer = null;
|
||||
}
|
||||
if (video.__rtmpHls) {
|
||||
try {
|
||||
video.__rtmpHls.destroy();
|
||||
} catch (_error) {
|
||||
// ignore cleanup errors
|
||||
}
|
||||
video.__rtmpHls = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
registry.register('rtmp', {
|
||||
renderRegion: renderRtmpRegion
|
||||
});
|
||||
@@ -0,0 +1,253 @@
|
||||
// Timetable region rendering for live playback.
|
||||
|
||||
var registry = window.pulsePlayerRegionTypes;
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value === undefined || value === null ? '' : value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function sanitizeRichTextAttributes(tagName, attrText) {
|
||||
var allowedAttributes = {
|
||||
a: ['href', 'title', 'target', 'rel', 'class', 'style'],
|
||||
blockquote: ['class', 'style'],
|
||||
col: ['class', 'style', 'span', 'width'],
|
||||
colgroup: ['class', 'style', 'span'],
|
||||
div: ['class', 'style'],
|
||||
figure: ['class', 'style'],
|
||||
figcaption: ['class', 'style'],
|
||||
h1: ['class', 'style'],
|
||||
h2: ['class', 'style'],
|
||||
h3: ['class', 'style'],
|
||||
h4: ['class', 'style'],
|
||||
h5: ['class', 'style'],
|
||||
h6: ['class', 'style'],
|
||||
li: ['class', 'style'],
|
||||
ol: ['class', 'style', 'start'],
|
||||
p: ['class', 'style'],
|
||||
pre: ['class', 'style'],
|
||||
span: ['class', 'style'],
|
||||
table: ['class', 'style'],
|
||||
tbody: ['class', 'style'],
|
||||
td: ['class', 'style', 'colspan', 'rowspan'],
|
||||
th: ['class', 'style', 'colspan', 'rowspan', 'scope'],
|
||||
thead: ['class', 'style'],
|
||||
tr: ['class', 'style'],
|
||||
ul: ['class', 'style']
|
||||
};
|
||||
var allowed = allowedAttributes[tagName] || [];
|
||||
if (!allowed.length) {
|
||||
return '';
|
||||
}
|
||||
|
||||
var attrs = [];
|
||||
String(attrText || '').replace(/([a-zA-Z0-9:-]+)(?:\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>/=`]+)))?/g, function (_full, key, _valuePart, doubleQuoted, singleQuoted, bareValue) {
|
||||
var lowerKey = String(key || '').toLowerCase();
|
||||
if (allowed.indexOf(lowerKey) === -1) {
|
||||
return '';
|
||||
}
|
||||
|
||||
var value = doubleQuoted !== undefined ? doubleQuoted : singleQuoted !== undefined ? singleQuoted : bareValue !== undefined ? bareValue : '';
|
||||
if (lowerKey === 'href' && /^(?:\s*javascript:|\s*data:)/i.test(String(value || ''))) {
|
||||
return '';
|
||||
}
|
||||
if (lowerKey === 'style' && /(?:expression\s*\(|javascript:|url\s*\()/i.test(String(value || ''))) {
|
||||
return '';
|
||||
}
|
||||
if (lowerKey === 'target') {
|
||||
var targetValue = String(value || '').trim();
|
||||
if (targetValue === '_blank') {
|
||||
attrs.push(' target="_blank"');
|
||||
if (attrs.indexOf(' rel="noreferrer noopener"') === -1) {
|
||||
attrs.push(' rel="noreferrer noopener"');
|
||||
}
|
||||
return '';
|
||||
}
|
||||
}
|
||||
attrs.push(' ' + lowerKey + '="' + escapeHtml(value) + '"');
|
||||
return '';
|
||||
});
|
||||
|
||||
return attrs.join('');
|
||||
}
|
||||
|
||||
function sanitizeRichText(html) {
|
||||
var output = String(html || '');
|
||||
output = output.replace(/<script[\s\S]*?<\/script>/gi, '');
|
||||
output = output.replace(/<style[\s\S]*?<\/style>/gi, '');
|
||||
return output.replace(/<[^>]+>/g, function (tag) {
|
||||
var match = tag.match(/^<\s*(\/?)\s*([a-z0-9]+)([\s\S]*?)(\/?)>$/i);
|
||||
if (!match) {
|
||||
return '';
|
||||
}
|
||||
var closing = Boolean(match[1]);
|
||||
var name = String(match[2] || '').toLowerCase();
|
||||
var allowed = ['a', 'b', 'blockquote', 'br', 'code', 'col', 'colgroup', 'div', 'em', 'figure', 'figcaption', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'li', 'ol', 'p', 'pre', 'span', 'strong', 'sub', 'sup', 'table', 'tbody', 'td', 'th', 'thead', 'tr', 'u', 'ul'];
|
||||
if (allowed.indexOf(name) === -1) {
|
||||
return '';
|
||||
}
|
||||
if (closing) {
|
||||
return '</' + name + '>';
|
||||
}
|
||||
return '<' + name + sanitizeRichTextAttributes(name, String(match[3] || '')) + '>';
|
||||
});
|
||||
}
|
||||
|
||||
function substituteTimetableVariables(html, entry) {
|
||||
var source = String(html || '');
|
||||
return source.replace(/\{\{\s*([^{}]+?)\s*\}\}/g, function (_match, expression) {
|
||||
if (!entry || typeof entry !== 'object') {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (!window.placeholderUtils || typeof window.placeholderUtils.resolvePlaceholderExpression !== 'function' || typeof window.placeholderUtils.formatPlaceholderValue !== 'function') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return escapeHtml(window.placeholderUtils.formatPlaceholderValue(window.placeholderUtils.resolvePlaceholderExpression(entry, expression)));
|
||||
});
|
||||
}
|
||||
|
||||
function renderTemplate(template, context) {
|
||||
var source = String(template || '');
|
||||
if (!source) {
|
||||
return '';
|
||||
}
|
||||
return substituteTimetableVariables(source, context);
|
||||
}
|
||||
|
||||
function getTimetableGroups() {
|
||||
return Array.isArray(initialData && initialData.timetableGroups) ? initialData.timetableGroups : [];
|
||||
}
|
||||
|
||||
function getGroupById(groupId, groups) {
|
||||
var normalizedId = Number(groupId || 0);
|
||||
return (Array.isArray(groups) ? groups : getTimetableGroups()).find(function (group) {
|
||||
return Number(group.id) === normalizedId;
|
||||
}) || null;
|
||||
}
|
||||
|
||||
function getEntries(groupId, groups) {
|
||||
var group = getGroupById(groupId, groups);
|
||||
return group && Array.isArray(group.entries) ? group.entries : [];
|
||||
}
|
||||
|
||||
function toDate(value) {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
var date = value instanceof Date ? value : new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
|
||||
function isUpcoming(entry, now) {
|
||||
var start = toDate(entry && entry.start_datetime);
|
||||
return Boolean(start && now < start);
|
||||
}
|
||||
|
||||
function isLive(entry, now) {
|
||||
var start = toDate(entry && entry.start_datetime);
|
||||
var end = toDate(entry && entry.end_datetime);
|
||||
return Boolean(start && end && now >= start && now < end);
|
||||
}
|
||||
|
||||
function getVisibleEntries(groupId, displayMode, maxItems, groups) {
|
||||
var now = new Date();
|
||||
var entries = getEntries(groupId, groups).slice().sort(function (left, right) {
|
||||
var leftStart = toDate(left && left.start_datetime);
|
||||
var rightStart = toDate(right && right.start_datetime);
|
||||
return (leftStart ? leftStart.getTime() : 0) - (rightStart ? rightStart.getTime() : 0) || Number(left.id || 0) - Number(right.id || 0);
|
||||
});
|
||||
var mode = String(displayMode || 'upcoming').trim().toLowerCase();
|
||||
|
||||
entries = entries.filter(function (entry) {
|
||||
if (mode === 'current') {
|
||||
return isLive(entry, now);
|
||||
}
|
||||
if (mode === 'both') {
|
||||
return isUpcoming(entry, now) || isLive(entry, now);
|
||||
}
|
||||
return isUpcoming(entry, now);
|
||||
});
|
||||
|
||||
if (!entries.length && mode === 'upcoming') {
|
||||
entries = getEntries(groupId, groups).filter(function (entry) {
|
||||
return isLive(entry, now);
|
||||
});
|
||||
}
|
||||
|
||||
return entries.slice(0, Math.max(1, Number(maxItems || 5)));
|
||||
}
|
||||
|
||||
function formatDateTime(value) {
|
||||
var date = toDate(value);
|
||||
if (!date) {
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
month: 'short',
|
||||
day: '2-digit',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit'
|
||||
}).format(date);
|
||||
} catch (_error) {
|
||||
return date.toLocaleString();
|
||||
}
|
||||
}
|
||||
|
||||
function getDefaultStyle() {
|
||||
return {
|
||||
font_family: 'Arial',
|
||||
font_size: 28,
|
||||
font_color: '#000000'
|
||||
};
|
||||
}
|
||||
|
||||
function getTextStyle(region, regionContent) {
|
||||
var current = regionContent && typeof regionContent === 'object' ? regionContent : {};
|
||||
var defaultStyle = getDefaultStyle();
|
||||
return {
|
||||
font_family: String(current.font_family || region.font_family || defaultStyle.font_family || 'Arial').trim() || 'Arial',
|
||||
font_size: Math.max(8, Number(current.font_size || region.font_size || defaultStyle.font_size || 28)),
|
||||
font_color: String(current.font_color || region.font_color || defaultStyle.font_color || '#000000').trim() || '#000000'
|
||||
};
|
||||
}
|
||||
|
||||
function renderRegion(region, regionContent) {
|
||||
var value = String(regionContent && (regionContent.value !== undefined ? regionContent.value : regionContent.text !== undefined ? regionContent.text : '') || '').trim();
|
||||
var style = getTextStyle(region, regionContent || {});
|
||||
var groups = getTimetableGroups();
|
||||
var groupId = regionContent && regionContent.timetable_group_id !== undefined ? regionContent.timetable_group_id : '';
|
||||
var displayMode = regionContent && regionContent.display_mode ? regionContent.display_mode : 'upcoming';
|
||||
var maxItems = regionContent && regionContent.max_items !== undefined ? regionContent.max_items : 5;
|
||||
var group = getGroupById(groupId, groups);
|
||||
var entries = getVisibleEntries(groupId, displayMode, maxItems, groups);
|
||||
var width = Math.max(1, Math.round(Number(region && region.pixelWidth ? region.pixelWidth : 0) || 1));
|
||||
var height = Math.max(1, Math.round(Number(region && region.pixelHeight ? region.pixelHeight : 0) || 1));
|
||||
var canvasScale = Number(region && region.canvasScale ? region.canvasScale : 1) || 1;
|
||||
var contentStyle = 'width:' + width + 'px;height:' + height + 'px;transform:scale(' + canvasScale + ');transform-origin:top left;' + (style.font_family ? 'font-family:' + escapeHtml(style.font_family) + ';' : '') + (style.font_size ? 'font-size:' + Math.max(1, Math.round(Number(style.font_size))) + 'px;' : '') + (style.font_color ? 'color:' + escapeHtml(style.font_color) + ';' : '');
|
||||
|
||||
if (!value) {
|
||||
return '<div class="template-region timetable" style="' + region.baseStyle + '"><div class="template-region-text-scale" style="' + contentStyle + ' overflow:hidden;"></div></div>';
|
||||
}
|
||||
|
||||
return '<div class="template-region timetable" style="' + region.baseStyle + '"><div class="template-region-text-scale" style="' + contentStyle + ' overflow:hidden;">' + entries.map(function (entry, index) {
|
||||
return '<div class="timetable-region-entry" data-timetable-entry-index="' + index + '">' + sanitizeRichText(substituteTimetableVariables(value, Object.assign({}, entry || {}, {
|
||||
start: entry && entry.start_datetime !== undefined ? entry.start_datetime : '',
|
||||
end: entry && entry.end_datetime !== undefined ? entry.end_datetime : '',
|
||||
group: group || {},
|
||||
entries: entries,
|
||||
index: index + 1
|
||||
}))) + '</div>';
|
||||
}).join('') + '</div></div>';
|
||||
}
|
||||
|
||||
registry.register('timetable', {
|
||||
renderRegion: renderRegion
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
// Text region rendering with font and color normalization.
|
||||
|
||||
var registry = window.pulsePlayerRegionTypes;
|
||||
|
||||
function getDefaultStyle() {
|
||||
return {
|
||||
font_family: 'Arial',
|
||||
font_size: 32,
|
||||
font_color: '#000000'
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeRenderableValue(value) {
|
||||
if (value && typeof value === 'object') {
|
||||
if (value.value !== undefined) {
|
||||
return value.value;
|
||||
}
|
||||
if (value.text !== undefined) {
|
||||
return value.text;
|
||||
}
|
||||
if (value.html !== undefined) {
|
||||
return value.html;
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch (_error) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function renderTextRegion(region, regionContent) {
|
||||
var rawValue = normalizeRenderableValue(regionContent && regionContent.value !== undefined ? regionContent.value : '');
|
||||
if (!String(rawValue || '').trim()) {
|
||||
return '';
|
||||
}
|
||||
|
||||
var defaultStyle = getDefaultStyle();
|
||||
var fontFamily = sanitizeFontFamily(regionContent.font_family || region.fontFamily || defaultStyle.font_family);
|
||||
var fontSize = sanitizeFontSize(regionContent.font_size || region.fontSize || defaultStyle.font_size);
|
||||
var fontColor = sanitizeTextColor(regionContent.font_color || region.fontColor || defaultStyle.font_color);
|
||||
var contentStyle = 'width:' + region.pixelWidth + 'px;height:' + region.pixelHeight + 'px;transform:scale(' + region.canvasScale + ');transform-origin:top left;' + (fontFamily ? 'font-family:' + escapeHtml(fontFamily) + ';' : '') + 'font-size:' + fontSize + 'px;color:' + escapeHtml(fontColor) + ';';
|
||||
var renderedBody = renderEditorJsContent(rawValue);
|
||||
return renderedBody ? '<div class="template-region text" style="' + region.baseStyle + '"><div class="template-region-text-scale" style="' + contentStyle + '">' + renderedBody + '</div></div>' : '';
|
||||
}
|
||||
|
||||
registry.register('text', {
|
||||
getDefaultStyle: getDefaultStyle,
|
||||
renderRegion: renderTextRegion
|
||||
});
|
||||
@@ -0,0 +1,337 @@
|
||||
// Time/date region rendering and live updates.
|
||||
|
||||
var registry = window.pulsePlayerRegionTypes;
|
||||
var DEFAULT_FORMAT = '{{hh}}:{{mm}}';
|
||||
var DEFAULT_STYLE = {
|
||||
font_family: 'Arial',
|
||||
font_size: 32,
|
||||
font_color: '#000000'
|
||||
};
|
||||
var timeDateFormatterCache = Object.create(null);
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value === undefined || value === null ? '' : value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function sanitizeTagAttributes(tagName, attrText) {
|
||||
var allowedAttributes = {
|
||||
a: ['href', 'title', 'target', 'rel', 'class', 'style'],
|
||||
blockquote: ['class', 'style'],
|
||||
div: ['class', 'style'],
|
||||
figure: ['class', 'style'],
|
||||
figcaption: ['class', 'style'],
|
||||
h1: ['class', 'style'],
|
||||
h2: ['class', 'style'],
|
||||
h3: ['class', 'style'],
|
||||
h4: ['class', 'style'],
|
||||
h5: ['class', 'style'],
|
||||
h6: ['class', 'style'],
|
||||
li: ['class', 'style'],
|
||||
ol: ['class', 'style', 'start'],
|
||||
p: ['class', 'style'],
|
||||
pre: ['class', 'style'],
|
||||
span: ['class', 'style'],
|
||||
table: ['class', 'style'],
|
||||
td: ['class', 'style', 'colspan', 'rowspan'],
|
||||
th: ['class', 'style', 'colspan', 'rowspan', 'scope'],
|
||||
tr: ['class', 'style'],
|
||||
ul: ['class', 'style']
|
||||
};
|
||||
var allowed = allowedAttributes[tagName] || [];
|
||||
if (!allowed.length) {
|
||||
return '';
|
||||
}
|
||||
|
||||
var attrs = [];
|
||||
String(attrText || '').replace(/([a-zA-Z0-9:-]+)(?:\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>/=`]+)))?/g, function (_full, key, _valuePart, doubleQuoted, singleQuoted, bareValue) {
|
||||
var lowerKey = String(key || '').toLowerCase();
|
||||
if (allowed.indexOf(lowerKey) === -1) {
|
||||
return '';
|
||||
}
|
||||
|
||||
var value = doubleQuoted !== undefined ? doubleQuoted : singleQuoted !== undefined ? singleQuoted : bareValue !== undefined ? bareValue : '';
|
||||
if (lowerKey === 'href' && /^(?:\s*javascript:|\s*data:)/i.test(String(value || ''))) {
|
||||
return '';
|
||||
}
|
||||
if (lowerKey === 'style' && /(?:expression\s*\(|javascript:|url\s*\()/i.test(String(value || ''))) {
|
||||
return '';
|
||||
}
|
||||
if (lowerKey === 'target') {
|
||||
var targetValue = String(value || '').trim();
|
||||
if (targetValue === '_blank') {
|
||||
attrs.push(' target="_blank"');
|
||||
if (attrs.indexOf(' rel="noreferrer noopener"') === -1) {
|
||||
attrs.push(' rel="noreferrer noopener"');
|
||||
}
|
||||
return '';
|
||||
}
|
||||
}
|
||||
attrs.push(' ' + lowerKey + '="' + escapeHtml(value) + '"');
|
||||
return '';
|
||||
});
|
||||
|
||||
return attrs.join('');
|
||||
}
|
||||
|
||||
function sanitizePreviewHtml(html) {
|
||||
var output = String(html || '');
|
||||
output = output.replace(/<script[\s\S]*?<\/script>/gi, '');
|
||||
output = output.replace(/<style[\s\S]*?<\/style>/gi, '');
|
||||
return output.replace(/<[^>]+>/g, function (tag) {
|
||||
var match = tag.match(/^<\s*(\/?)\s*([a-z0-9]+)([\s\S]*?)(\/?)>$/i);
|
||||
if (!match) {
|
||||
return '';
|
||||
}
|
||||
|
||||
var closing = Boolean(match[1]);
|
||||
var name = String(match[2] || '').toLowerCase();
|
||||
var allowed = ['a', 'b', 'blockquote', 'br', 'code', 'div', 'em', 'figure', 'figcaption', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'li', 'ol', 'p', 'pre', 'span', 'strong', 'sub', 'sup', 'table', 'tbody', 'td', 'th', 'thead', 'tr', 'u', 'ul'];
|
||||
if (allowed.indexOf(name) === -1) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (closing) {
|
||||
return '</' + name + '>';
|
||||
}
|
||||
|
||||
return '<' + name + sanitizeTagAttributes(name, String(match[3] || '')) + '>';
|
||||
});
|
||||
}
|
||||
|
||||
function getDefaultStyle() {
|
||||
return {
|
||||
font_family: DEFAULT_STYLE.font_family,
|
||||
font_size: DEFAULT_STYLE.font_size,
|
||||
font_color: DEFAULT_STYLE.font_color
|
||||
};
|
||||
}
|
||||
|
||||
function getDefaultTimeZone() {
|
||||
try {
|
||||
return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
|
||||
} catch (_error) {
|
||||
return 'UTC';
|
||||
}
|
||||
}
|
||||
|
||||
function resolveTimeZone(value) {
|
||||
var raw = String(value || '').trim();
|
||||
if (!raw) {
|
||||
return getDefaultTimeZone();
|
||||
}
|
||||
|
||||
try {
|
||||
new Intl.DateTimeFormat('en-GB', { timeZone: raw }).format(new Date());
|
||||
return raw;
|
||||
} catch (_error) {
|
||||
return getDefaultTimeZone();
|
||||
}
|
||||
}
|
||||
|
||||
function getFormatter(key, options) {
|
||||
if (!timeDateFormatterCache[key]) {
|
||||
timeDateFormatterCache[key] = new Intl.DateTimeFormat('en-GB', options);
|
||||
}
|
||||
|
||||
return timeDateFormatterCache[key];
|
||||
}
|
||||
|
||||
function getFormattedParts(timeZone, date) {
|
||||
var targetDate = date instanceof Date ? date : new Date();
|
||||
var resolvedTimeZone = resolveTimeZone(timeZone);
|
||||
var numericParts = getFormatter('numeric:' + resolvedTimeZone, {
|
||||
timeZone: resolvedTimeZone,
|
||||
hour12: false,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric'
|
||||
}).formatToParts(targetDate);
|
||||
var weekdayLong = getFormatter('weekday-long:' + resolvedTimeZone, {
|
||||
timeZone: resolvedTimeZone,
|
||||
weekday: 'long'
|
||||
}).formatToParts(targetDate);
|
||||
var weekdayShort = getFormatter('weekday-short:' + resolvedTimeZone, {
|
||||
timeZone: resolvedTimeZone,
|
||||
weekday: 'short'
|
||||
}).formatToParts(targetDate);
|
||||
var monthLong = getFormatter('month-long:' + resolvedTimeZone, {
|
||||
timeZone: resolvedTimeZone,
|
||||
month: 'long'
|
||||
}).formatToParts(targetDate);
|
||||
var monthShort = getFormatter('month-short:' + resolvedTimeZone, {
|
||||
timeZone: resolvedTimeZone,
|
||||
month: 'short'
|
||||
}).formatToParts(targetDate);
|
||||
var ampm = getFormatter('ampm:' + resolvedTimeZone, {
|
||||
timeZone: resolvedTimeZone,
|
||||
hour12: true,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
}).formatToParts(targetDate);
|
||||
var timezoneShort = getFormatter('tz-short:' + resolvedTimeZone, {
|
||||
timeZone: resolvedTimeZone,
|
||||
timeZoneName: 'short'
|
||||
}).formatToParts(targetDate);
|
||||
|
||||
function getPart(parts, type) {
|
||||
var match = parts.find(function (part) {
|
||||
return part && part.type === type;
|
||||
});
|
||||
return match ? String(match.value || '') : '';
|
||||
}
|
||||
|
||||
function toTitleCase(value) {
|
||||
return String(value || '').toLowerCase().replace(/\b([a-z])/g, function (match, letter) {
|
||||
return String(letter || '').toUpperCase();
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
h: String(Number(getPart(numericParts, 'hour')) || 0),
|
||||
hh: getPart(numericParts, 'hour'),
|
||||
m: String(Number(getPart(numericParts, 'minute')) || 0),
|
||||
mm: getPart(numericParts, 'minute'),
|
||||
s: String(Number(getPart(numericParts, 'second')) || 0),
|
||||
ss: getPart(numericParts, 'second'),
|
||||
d: String(Number(getPart(numericParts, 'day')) || 0),
|
||||
dd: getPart(numericParts, 'day'),
|
||||
M: String(Number(getPart(numericParts, 'month')) || 0),
|
||||
MM: getPart(numericParts, 'month'),
|
||||
y: String(Number(getPart(numericParts, 'year')) || 0),
|
||||
yyyy: getPart(numericParts, 'year'),
|
||||
yy: String(Number(String(getPart(numericParts, 'year')).slice(-2)) || 0).padStart(2, '0'),
|
||||
ddd: toTitleCase(getPart(weekdayShort, 'weekday')),
|
||||
dddd: toTitleCase(getPart(weekdayLong, 'weekday')),
|
||||
MMM: toTitleCase(getPart(monthShort, 'month')),
|
||||
MMMM: toTitleCase(getPart(monthLong, 'month')),
|
||||
a: toTitleCase(getPart(ampm, 'dayPeriod')),
|
||||
tz: resolvedTimeZone,
|
||||
tz_short: getPart(timezoneShort, 'timeZoneName'),
|
||||
date: getPart(numericParts, 'year') + '-' + getPart(numericParts, 'month') + '-' + getPart(numericParts, 'day'),
|
||||
time: getPart(numericParts, 'hour') + ':' + getPart(numericParts, 'minute') + ':' + getPart(numericParts, 'second')
|
||||
};
|
||||
}
|
||||
|
||||
function resolveTimeDatePlaceholder(values, expression) {
|
||||
if (typeof placeholderUtils.resolvePlaceholderExpression === 'function' && typeof placeholderUtils.formatPlaceholderValue === 'function') {
|
||||
return placeholderUtils.formatPlaceholderValue(placeholderUtils.resolvePlaceholderExpression(values, expression));
|
||||
}
|
||||
|
||||
var parsed = String(expression || '').trim();
|
||||
return Object.prototype.hasOwnProperty.call(values, parsed) ? values[parsed] : '';
|
||||
}
|
||||
|
||||
function renderTemplate(format, timeZone, date) {
|
||||
var template = String(format || '').trim() || DEFAULT_FORMAT;
|
||||
var values = getFormattedParts(timeZone, date);
|
||||
return template.replace(/\{\{\s*([a-zA-Z0-9_.()\-]+)\s*\}\}/g, function (_match, key) {
|
||||
return String(resolveTimeDatePlaceholder(values, key) || '');
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
function getTextStyle(regionContent, region) {
|
||||
var defaultStyle = getDefaultStyle();
|
||||
return {
|
||||
font_family: regionContent.font_family || region.fontFamily || defaultStyle.font_family,
|
||||
font_size: regionContent.font_size || region.fontSize || defaultStyle.font_size,
|
||||
font_color: regionContent.font_color || region.fontColor || defaultStyle.font_color
|
||||
};
|
||||
}
|
||||
|
||||
function renderTimeDateRegion(region, regionContent) {
|
||||
var content = regionContent && typeof regionContent === 'object' ? regionContent : {};
|
||||
var format = String(content.value !== undefined ? content.value : content.text || '').trim() || DEFAULT_FORMAT;
|
||||
var timeZone = resolveTimeZone(content.timezone || content.time_zone || '');
|
||||
var style = getTextStyle(content, region);
|
||||
var fontFamily = style.font_family ? 'font-family:' + escapeHtml(style.font_family) + ';' : '';
|
||||
var fontSize = style.font_size ? 'font-size:' + Math.max(1, Math.round(Number(style.font_size))) + 'px;' : '';
|
||||
var fontColor = style.font_color ? 'color:' + escapeHtml(style.font_color) + ';' : '';
|
||||
var contentStyle = 'width:' + region.pixelWidth + 'px;height:' + region.pixelHeight + 'px;transform:scale(' + region.canvasScale + ');transform-origin:top left;' + (fontFamily ? fontFamily : '') + fontSize + fontColor + 'white-space:pre-wrap;line-height:1.1;';
|
||||
var renderedText = renderTemplate(format, timeZone, new Date());
|
||||
return '<div class="template-region time-date" data-time-date-format="' + escapeHtml(format) + '" data-time-date-timezone="' + escapeHtml(timeZone) + '" style="' + region.baseStyle + '"><div class="template-region-text-scale" style="' + contentStyle + '">' + renderEditorJsContent(renderedText) + '</div></div>';
|
||||
}
|
||||
|
||||
function updateTimeDateRegion(element) {
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
|
||||
var format = String(element.dataset.timeDateFormat || '').trim() || DEFAULT_FORMAT;
|
||||
var timeZone = String(element.dataset.timeDateTimezone || '').trim();
|
||||
var scaleWrapper = element.querySelector('.template-region-text-scale');
|
||||
if (!scaleWrapper) {
|
||||
return;
|
||||
}
|
||||
|
||||
scaleWrapper.innerHTML = renderEditorJsContent(renderTemplate(format, timeZone, new Date()));
|
||||
}
|
||||
|
||||
function scheduleTimeDateRegionUpdate(element) {
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (element.__timeDateTimer) {
|
||||
window.clearTimeout(element.__timeDateTimer);
|
||||
element.__timeDateTimer = null;
|
||||
}
|
||||
|
||||
function tick() {
|
||||
if (!element.isConnected) {
|
||||
if (element.__timeDateTimer) {
|
||||
window.clearTimeout(element.__timeDateTimer);
|
||||
element.__timeDateTimer = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
updateTimeDateRegion(element);
|
||||
element.__timeDateTimer = window.setTimeout(tick, Math.max(100, 1000 - (Date.now() % 1000)));
|
||||
}
|
||||
|
||||
tick();
|
||||
}
|
||||
|
||||
function destroyTimeDateRegions(root) {
|
||||
if (!root) {
|
||||
return;
|
||||
}
|
||||
|
||||
Array.prototype.forEach.call(root.querySelectorAll('.template-region.time-date'), function (element) {
|
||||
if (element && element.__timeDateTimer) {
|
||||
window.clearTimeout(element.__timeDateTimer);
|
||||
element.__timeDateTimer = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function initializeTimeDateRegions(root) {
|
||||
if (!root) {
|
||||
return;
|
||||
}
|
||||
|
||||
Array.prototype.forEach.call(root.querySelectorAll('.template-region.time-date'), function (element) {
|
||||
scheduleTimeDateRegionUpdate(element);
|
||||
});
|
||||
}
|
||||
|
||||
registry.register('time-date', {
|
||||
label: 'Time / Date',
|
||||
getDefaultStyle: getDefaultStyle,
|
||||
getDefaultRegionSize: function () {
|
||||
return { width: 420, height: 180 };
|
||||
},
|
||||
renderRegion: renderTimeDateRegion,
|
||||
initRegion: initializeTimeDateRegions,
|
||||
destroyRegion: destroyTimeDateRegions
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
// Video region playback, retry, and source probe helpers.
|
||||
|
||||
var videoRegionLastGoodSrcCache = Object.create(null);
|
||||
var videoRegionProbeStateCache = Object.create(null);
|
||||
var videoRegionProbeTimerCache = Object.create(null);
|
||||
var VIDEO_REGION_RETRY_DELAY_MS = 5000;
|
||||
|
||||
function getVideoRegionCacheKey(region) {
|
||||
return String(region && (region.regionKey || region.label) || '').trim();
|
||||
}
|
||||
|
||||
function isDirectlyRenderableSource(src) {
|
||||
return /^(?:https?:)?\/\//i.test(src) || /^data:/i.test(src) || /^blob:/i.test(src) || /^\/media\//i.test(src);
|
||||
}
|
||||
|
||||
function appendCacheBust(src, cacheBust) {
|
||||
var key = String(cacheBust || '').trim();
|
||||
var raw = String(src || '').trim();
|
||||
if (!raw || !key) {
|
||||
return raw;
|
||||
}
|
||||
return raw + (raw.indexOf('?') === -1 ? '?' : '&') + 'v=' + encodeURIComponent(key);
|
||||
}
|
||||
|
||||
function getVideoSourceAvailability(src) {
|
||||
return videoRegionProbeStateCache[String(src || '').trim()] || null;
|
||||
}
|
||||
|
||||
function setVideoSourceAvailability(src, available) {
|
||||
var key = String(src || '').trim();
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
videoRegionProbeStateCache[key] = {
|
||||
available: available === null ? null : Boolean(available),
|
||||
checkedAt: Date.now()
|
||||
};
|
||||
}
|
||||
|
||||
function logVideoRegionStatus(message, details, level) {
|
||||
if (typeof logDebug === 'function') {
|
||||
logDebug(message, details || '', level || 'info');
|
||||
}
|
||||
}
|
||||
|
||||
function triggerVideoRegionSourceRefresh() {
|
||||
if (typeof window.notifyVideoRegionSourceReady === 'function') {
|
||||
window.notifyVideoRegionSourceReady();
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof videoRegionRenderVersion === 'number') {
|
||||
videoRegionRenderVersion += 1;
|
||||
}
|
||||
slideMarkupCache = Object.create(null);
|
||||
if (typeof showCurrent === 'function' && slides && slides.length) {
|
||||
showCurrent();
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleVideoSourceProbe(regionKey, src, isRetry) {
|
||||
var key = String(src || '').trim();
|
||||
if (!regionKey || !key || isDirectlyRenderableSource(key)) {
|
||||
if (key) {
|
||||
setVideoSourceAvailability(key, true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (videoRegionProbeTimerCache[key]) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isRetry) {
|
||||
logVideoRegionStatus('Video source changed; probing mirrored file before swapping.', 'region=' + regionKey + ' src=' + key);
|
||||
} else {
|
||||
logVideoRegionStatus('Video source still unavailable; retrying mirrored file probe.', 'region=' + regionKey + ' src=' + key, 'warn');
|
||||
}
|
||||
|
||||
videoRegionProbeTimerCache[key] = window.setTimeout(function () {
|
||||
delete videoRegionProbeTimerCache[key];
|
||||
fetch(key, {
|
||||
method: 'HEAD',
|
||||
cache: 'no-store',
|
||||
credentials: 'same-origin'
|
||||
}).then(function (response) {
|
||||
if (response && response.ok) {
|
||||
setVideoSourceAvailability(key, true);
|
||||
if (videoRegionLastGoodSrcCache[regionKey] !== key) {
|
||||
videoRegionLastGoodSrcCache[regionKey] = key;
|
||||
logVideoRegionStatus('Mirrored video is ready; switching to the new source.', 'region=' + regionKey + ' src=' + key);
|
||||
triggerVideoRegionSourceRefresh();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setVideoSourceAvailability(key, false);
|
||||
logVideoRegionStatus('Mirrored video probe returned unavailable; keeping the old source for now.', 'region=' + regionKey + ' src=' + key, 'warn');
|
||||
scheduleVideoSourceProbe(regionKey, key, true);
|
||||
}).catch(function () {
|
||||
setVideoSourceAvailability(key, false);
|
||||
logVideoRegionStatus('Mirrored video probe failed; keeping the old source for now.', 'region=' + regionKey + ' src=' + key, 'warn');
|
||||
scheduleVideoSourceProbe(regionKey, key, true);
|
||||
});
|
||||
}, isRetry ? VIDEO_REGION_RETRY_DELAY_MS : 0);
|
||||
}
|
||||
|
||||
function renderVideoRegion(region, regionContent) {
|
||||
var requestedSrc = String(regionContent && regionContent.value || '').trim();
|
||||
var requestedSrcVersioned = appendCacheBust(requestedSrc, regionContent && regionContent.cache_bust);
|
||||
var regionKey = getVideoRegionCacheKey(region);
|
||||
var cachedSrc = regionKey ? String(videoRegionLastGoodSrcCache[regionKey] || '').trim() : '';
|
||||
var cachedSrcVersioned = appendCacheBust(cachedSrc, regionContent && regionContent.cache_bust);
|
||||
var disableAudio = regionContent && regionContent.disable_audio === undefined ? true : Boolean(regionContent && regionContent.disable_audio);
|
||||
|
||||
if (!requestedSrc) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (isDirectlyRenderableSource(requestedSrc)) {
|
||||
if (regionKey) {
|
||||
videoRegionLastGoodSrcCache[regionKey] = requestedSrc;
|
||||
}
|
||||
setVideoSourceAvailability(requestedSrc, true);
|
||||
return '<div class="template-region video" style="' + region.baseStyle + '"><video src="' + escapeHtml(requestedSrcVersioned) + '" title="' + escapeHtml(region.label) + '" data-disable-audio="' + (disableAudio ? '1' : '0') + '" autoplay' + (disableAudio ? ' muted' : '') + ' loop playsinline preload="auto" disablepictureinpicture oncanplay="this.play().catch(function () {})" onloadedmetadata="this.play().catch(function () {})"></video></div>';
|
||||
}
|
||||
|
||||
var requestedState = getVideoSourceAvailability(requestedSrc);
|
||||
var requestedReady = Boolean(requestedState && requestedState.available === true);
|
||||
|
||||
if (requestedReady) {
|
||||
if (regionKey) {
|
||||
videoRegionLastGoodSrcCache[regionKey] = requestedSrc;
|
||||
}
|
||||
return '<div class="template-region video" style="' + region.baseStyle + '"><video src="' + escapeHtml(requestedSrcVersioned) + '" title="' + escapeHtml(region.label) + '" data-disable-audio="' + (disableAudio ? '1' : '0') + '" autoplay' + (disableAudio ? ' muted' : '') + ' loop playsinline preload="auto" disablepictureinpicture oncanplay="this.play().catch(function () {})" onloadedmetadata="this.play().catch(function () {})"></video></div>';
|
||||
}
|
||||
|
||||
scheduleVideoSourceProbe(regionKey, requestedSrc, false);
|
||||
|
||||
if (cachedSrc) {
|
||||
if (cachedSrc !== requestedSrc) {
|
||||
logVideoRegionStatus('Keeping the previous playable video until the new mirrored file finishes transferring.', 'region=' + regionKey + ' old=' + cachedSrc + ' new=' + requestedSrc);
|
||||
}
|
||||
return '<div class="template-region video" style="' + region.baseStyle + '"><video src="' + escapeHtml(cachedSrcVersioned) + '" title="' + escapeHtml(region.label) + '" data-disable-audio="' + (disableAudio ? '1' : '0') + '" autoplay' + (disableAudio ? ' muted' : '') + ' loop playsinline preload="auto" disablepictureinpicture oncanplay="this.play().catch(function () {})" onloadedmetadata="this.play().catch(function () {})"></video></div>';
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
if (window.pulsePlayerRegionTypes && typeof window.pulsePlayerRegionTypes.register === 'function') {
|
||||
window.pulsePlayerRegionTypes.register('video', {
|
||||
renderRegion: renderVideoRegion
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// Webpage region rendering for embedded live URLs.
|
||||
|
||||
var registry = window.pulsePlayerRegionTypes;
|
||||
|
||||
function renderWebpageRegion(region, regionContent) {
|
||||
var url = String(regionContent.value || '').trim();
|
||||
if (!url) {
|
||||
return '';
|
||||
}
|
||||
return '<div class="template-region webpage" style="' + region.baseStyle + '"><iframe src="' + escapeHtml(url) + '" title="' + escapeHtml(region.label) + '" loading="eager" referrerpolicy="no-referrer" scrolling="no"></iframe></div>';
|
||||
}
|
||||
|
||||
registry.register('webpage', {
|
||||
renderRegion: renderWebpageRegion
|
||||
});
|
||||
@@ -0,0 +1,506 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const announcementIcons = require('#src/data/announcement-icons');
|
||||
|
||||
function mediaKind(mediaPath) {
|
||||
const ext = path.extname(mediaPath || '').toLowerCase();
|
||||
if (['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg'].includes(ext)) {
|
||||
return 'image';
|
||||
}
|
||||
if (['.mp4', '.webm', '.ogg'].includes(ext)) {
|
||||
return 'video';
|
||||
}
|
||||
if (ext === '.pdf') {
|
||||
return 'pdf';
|
||||
}
|
||||
return 'file';
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function sanitizeFontFamily(value) {
|
||||
return String(value || '').replace(/[^a-zA-Z0-9 ,"-]/g, '').trim();
|
||||
}
|
||||
|
||||
function sanitizeFontSize(value) {
|
||||
return Math.max(8, Number(value || 0) || 24);
|
||||
}
|
||||
|
||||
function sanitizeTextColor(value, fallback) {
|
||||
const raw = String(value || '').trim();
|
||||
if (/^#[0-9a-fA-F]{6}$/.test(raw) || /^#[0-9a-fA-F]{3}$/.test(raw)) {
|
||||
return raw;
|
||||
}
|
||||
return fallback || '#000000';
|
||||
}
|
||||
|
||||
const ALLOWED_RICH_TEXT_TAGS = ['a', 'b', 'blockquote', 'br', 'code', 'div', 'em', 'figure', 'figcaption', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'li', 'ol', 'p', 'pre', 'span', 'strong', 'sub', 'sup', 'table', 'tbody', 'td', 'th', 'thead', 'tr', 'u', 'ul'];
|
||||
|
||||
function sanitizeRichTextAttributes(tagName, attrText) {
|
||||
const allowedAttributes = {
|
||||
a: ['href', 'title', 'target', 'rel', 'class', 'style'],
|
||||
blockquote: ['class', 'style'],
|
||||
div: ['class', 'style'],
|
||||
figure: ['class', 'style'],
|
||||
figcaption: ['class', 'style'],
|
||||
h1: ['class', 'style'],
|
||||
h2: ['class', 'style'],
|
||||
h3: ['class', 'style'],
|
||||
h4: ['class', 'style'],
|
||||
h5: ['class', 'style'],
|
||||
h6: ['class', 'style'],
|
||||
li: ['class', 'style'],
|
||||
ol: ['class', 'style', 'start'],
|
||||
p: ['class', 'style'],
|
||||
pre: ['class', 'style'],
|
||||
span: ['class', 'style'],
|
||||
table: ['class', 'style'],
|
||||
td: ['class', 'style', 'colspan', 'rowspan'],
|
||||
th: ['class', 'style', 'colspan', 'rowspan', 'scope'],
|
||||
tr: ['class', 'style'],
|
||||
ul: ['class', 'style']
|
||||
};
|
||||
const allowed = allowedAttributes[tagName] || [];
|
||||
if (!allowed.length) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const attrs = [];
|
||||
String(attrText || '').replace(/([a-zA-Z0-9:-]+)(?:\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>/=`]+)))?/g, (_full, key, _valuePart, doubleQuoted, singleQuoted, bareValue) => {
|
||||
const lowerKey = String(key || '').toLowerCase();
|
||||
if (!allowed.includes(lowerKey)) {
|
||||
return '';
|
||||
}
|
||||
const value = doubleQuoted !== undefined ? doubleQuoted : singleQuoted !== undefined ? singleQuoted : bareValue !== undefined ? bareValue : '';
|
||||
if (lowerKey === 'href' && /^(?:\s*javascript:|\s*data:)/i.test(String(value || ''))) {
|
||||
return '';
|
||||
}
|
||||
if (lowerKey === 'style' && /(?:expression\s*\(|javascript:|url\s*\()/i.test(String(value || ''))) {
|
||||
return '';
|
||||
}
|
||||
if (lowerKey === 'target') {
|
||||
const targetValue = String(value || '').trim();
|
||||
if (targetValue === '_blank') {
|
||||
attrs.push(' target="_blank"');
|
||||
if (!attrs.includes(' rel="noreferrer noopener"')) {
|
||||
attrs.push(' rel="noreferrer noopener"');
|
||||
}
|
||||
return '';
|
||||
}
|
||||
}
|
||||
attrs.push(' ' + lowerKey + '="' + escapeHtml(value) + '"');
|
||||
return '';
|
||||
});
|
||||
|
||||
return attrs.join('');
|
||||
}
|
||||
|
||||
function safeJsonForScript(value) {
|
||||
return JSON.stringify(value === undefined ? null : value).replace(/</g, '\\u003c');
|
||||
}
|
||||
|
||||
function parseMaybeJson(value) {
|
||||
if (typeof value !== 'string') {
|
||||
return value;
|
||||
}
|
||||
const raw = value.trim();
|
||||
if (!raw) {
|
||||
return value;
|
||||
}
|
||||
if (raw[0] !== '{' && raw[0] !== '[') {
|
||||
return value;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch (_error) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeContentValue(value) {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return {
|
||||
type: 'text',
|
||||
value: parseMaybeJson(value)
|
||||
};
|
||||
}
|
||||
const normalized = {};
|
||||
Object.keys(value).forEach(function (key) {
|
||||
normalized[key] = value[key];
|
||||
});
|
||||
if (normalized.value !== undefined) {
|
||||
normalized.value = parseMaybeJson(normalized.value);
|
||||
} else if (normalized.font_family !== undefined && normalized.font_family !== null) {
|
||||
normalized.font_family = sanitizeFontFamily(normalized.font_family);
|
||||
} else if (normalized.font_size !== undefined && normalized.font_size !== null) {
|
||||
normalized.font_size = sanitizeFontSize(normalized.font_size);
|
||||
} else if (normalized.font_color !== undefined && normalized.font_color !== null) {
|
||||
normalized.font_color = sanitizeTextColor(normalized.font_color);
|
||||
} else if (!normalized.type) {
|
||||
normalized.type = 'text';
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeSlide(slide) {
|
||||
const normalized = {};
|
||||
Object.keys(slide || {}).forEach(function (key) {
|
||||
normalized[key] = slide[key];
|
||||
});
|
||||
normalized.content = {};
|
||||
const content = slide && slide.content && typeof slide.content === 'object' ? slide.content : {};
|
||||
Object.keys(content).forEach(function (regionKey) {
|
||||
normalized.content[regionKey] = normalizeContentValue(content[regionKey]);
|
||||
});
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function sanitizeRichText(html) {
|
||||
let output = String(html || '');
|
||||
output = output.replace(/<script[\s\S]*?<\/script>/gi, '');
|
||||
output = output.replace(/<style[\s\S]*?<\/style>/gi, '');
|
||||
return output.replace(/<[^>]+>/g, (tag) => {
|
||||
const match = tag.match(/^<\s*(\/?)\s*([a-z0-9]+)([\s\S]*?)(\/?)>$/i);
|
||||
if (!match) {
|
||||
return '';
|
||||
}
|
||||
const closing = Boolean(match[1]);
|
||||
const name = String(match[2] || '').toLowerCase();
|
||||
const attrText = String(match[3] || '');
|
||||
if (!ALLOWED_RICH_TEXT_TAGS.includes(name)) {
|
||||
return '';
|
||||
}
|
||||
if (closing) {
|
||||
return `</${name}>`;
|
||||
}
|
||||
return `<${name}${sanitizeRichTextAttributes(name, attrText)}>`;
|
||||
});
|
||||
}
|
||||
|
||||
function renderEditorJsBlock(block) {
|
||||
if (!block || !block.type || !block.data) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (block.type === 'header') {
|
||||
const level = Math.max(1, Math.min(6, Number(block.data.level || 2)));
|
||||
return '<h' + level + '>' + sanitizeRichText(block.data.text || '') + '</h' + level + '>';
|
||||
} else if (block.type === 'list') {
|
||||
const tag = block.data.style === 'ordered' ? 'ol' : 'ul';
|
||||
const items = Array.isArray(block.data.items) ? block.data.items : [];
|
||||
return '<' + tag + ' style="list-style-type:' + (tag === 'ol' ? 'decimal' : 'disc') + ';padding-left:1.4em;">' + items.map((item) => renderEditorJsListItem(item, tag)).join('') + '</' + tag + '>';
|
||||
} else if (block.type === 'delimiter') {
|
||||
return '<hr />';
|
||||
} else if (block.type === 'code') {
|
||||
return '<pre><code>' + escapeHtml(block.data.code || '') + '</code></pre>';
|
||||
} else if (block.type === 'table') {
|
||||
return renderEditorJsTable(block.data);
|
||||
} else if (block.type === 'paragraph') {
|
||||
return '<p>' + sanitizeRichText(block.data.text || '') + '</p>';
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function renderEditorJsListItem(item, tag) {
|
||||
if (item && typeof item === 'object') {
|
||||
const content = item.content !== undefined ? item.content : (item.text !== undefined ? item.text : item.value !== undefined ? item.value : '');
|
||||
const children = Array.isArray(item.items) ? item.items : Array.isArray(item.subItems) ? item.subItems : Array.isArray(item.children) ? item.children : [];
|
||||
const nested = children.length ? '<' + tag + ' style="list-style-type:' + (tag === 'ol' ? 'decimal' : 'disc') + ';padding-left:1.4em;">' + children.map((child) => renderEditorJsListItem(child, tag)).join('') + '</' + tag + '>' : '';
|
||||
return '<li>' + sanitizeRichText(content || '') + nested + '</li>';
|
||||
}
|
||||
return '<li>' + sanitizeRichText(item || '') + '</li>';
|
||||
}
|
||||
|
||||
function renderEditorJsTable(data) {
|
||||
const rows = Array.isArray(data.content) ? data.content : Array.isArray(data.rows) ? data.rows : [];
|
||||
if (!rows.length) {
|
||||
return '';
|
||||
}
|
||||
const hasHeadings = Boolean(data.withHeadings);
|
||||
const tableRows = rows.map(function (row, rowIndex) {
|
||||
const cells = Array.isArray(row) ? row : [];
|
||||
const cellTag = hasHeadings && rowIndex === 0 ? 'th' : 'td';
|
||||
const cellAttrs = cellTag === 'th' ? ' scope="col"' : '';
|
||||
return '<tr>' + cells.map(function (cell) {
|
||||
return '<' + cellTag + cellAttrs + '>' + sanitizeRichText(cell || '') + '</' + cellTag + '>';
|
||||
}).join('') + '</tr>';
|
||||
}).join('');
|
||||
return '<table class="ck-content-table">' + tableRows + '</table>';
|
||||
}
|
||||
|
||||
function wrapRichTextParagraph(html) {
|
||||
const raw = String(html || '').trim();
|
||||
if (!raw) {
|
||||
return '';
|
||||
}
|
||||
if (/^<\s*(?:p|div|h[1-6]|ul|ol|pre|table|blockquote|figure|hr|li)\b/i.test(raw)) {
|
||||
return raw;
|
||||
}
|
||||
return '<p>' + raw + '</p>';
|
||||
}
|
||||
|
||||
function renderEditorJsContent(value) {
|
||||
if (value && typeof value === 'object') {
|
||||
if (Array.isArray(value.blocks)) {
|
||||
return value.blocks.map(renderEditorJsBlock).join('');
|
||||
}
|
||||
if (value.value !== undefined) {
|
||||
return renderEditorJsContent(value.value);
|
||||
}
|
||||
}
|
||||
const raw = String(value || '');
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed && Array.isArray(parsed.blocks)) {
|
||||
return parsed.blocks.map(renderEditorJsBlock).join('');
|
||||
}
|
||||
} catch (_error) {
|
||||
// fall through to legacy HTML rendering
|
||||
}
|
||||
return wrapRichTextParagraph(sanitizeRichText(raw));
|
||||
}
|
||||
|
||||
function renderHtmlRegionContent(value) {
|
||||
const html = String(value || '').trim();
|
||||
if (!html) {
|
||||
return '<div class="template-region-placeholder">HTML</div>';
|
||||
}
|
||||
return '<iframe class="template-region-html-frame" sandbox="" srcdoc="' + escapeHtml(html) + '" title="HTML region" loading="eager"></iframe>';
|
||||
}
|
||||
|
||||
function fitCanvasSize(canvasWidth, canvasHeight, maxWidth, maxHeight) {
|
||||
const width = Math.max(1, Number(canvasWidth || 0) || 1920);
|
||||
const height = Math.max(1, Number(canvasHeight || 0) || 1080);
|
||||
const viewportWidth = Math.max(1, Number(maxWidth || 0) || width);
|
||||
const viewportHeight = Math.max(1, Number(maxHeight || 0) || height);
|
||||
const scale = Math.min(viewportWidth / width, viewportHeight / height);
|
||||
return {
|
||||
width: Math.round(width * scale),
|
||||
height: Math.round(height * scale)
|
||||
};
|
||||
}
|
||||
|
||||
const playerPageTemplatePath = path.join(__dirname, 'player-page.template.html');
|
||||
const playerClientNameScriptPath = path.join(__dirname, 'player-client-name.script.html');
|
||||
const playerPageOfflineScriptPath = path.join(__dirname, 'public', 'js', 'player-page-offline.js');
|
||||
const playerPagePlaylistScriptPath = path.join(__dirname, 'public', 'js', 'player-page-playlist.js');
|
||||
const playerPageCommandsScriptPath = path.join(__dirname, 'public', 'js', 'player-page-commands.js');
|
||||
const playerPageRenderingScriptPath = path.join(__dirname, 'public', 'js', 'player-page-rendering.js');
|
||||
const playerPagePlaybackScriptPath = path.join(__dirname, 'public', 'js', 'player-page-playback.js');
|
||||
const playerAnnouncementTemplatesScriptPath = path.join(__dirname, 'public', 'js', 'player-announcement-templates.js');
|
||||
const playerAnnouncementLowerThirdTemplatePath = path.join(__dirname, 'announcement-templates', 'lower-third.template.html');
|
||||
const playerAnnouncementTopBannerTemplatePath = path.join(__dirname, 'announcement-templates', 'top-banner.template.html');
|
||||
const playerAnnouncementFullscreenTemplatePath = path.join(__dirname, 'announcement-templates', 'fullscreen.template.html');
|
||||
const playerPageAnnouncementsScriptPath = path.join(__dirname, 'player-page-announcements.js');
|
||||
const playerPageScriptPath = path.join(__dirname, 'player-page.script.html');
|
||||
const playerRegionScriptDir = path.join(__dirname, 'regions');
|
||||
const playerOnboardingLandingScriptPath = path.join(__dirname, 'onboarding', 'player-onboarding-landing.script.html');
|
||||
const playerOnboardingFormScriptPath = path.join(__dirname, 'onboarding', 'player-onboarding-form.script.html');
|
||||
let playerPageTemplateCache = null;
|
||||
let playerClientNameScriptCache = null;
|
||||
let playerPageOfflineScriptCache = null;
|
||||
let playerPagePlaylistScriptCache = null;
|
||||
let playerPageCommandsScriptCache = null;
|
||||
let playerPageRenderingScriptCache = null;
|
||||
let playerPagePlaybackScriptCache = null;
|
||||
let playerAnnouncementTemplatesScriptCache = null;
|
||||
let playerAnnouncementTemplatesDataScriptCache = null;
|
||||
let playerPageAnnouncementsScriptCache = null;
|
||||
let playerPageScriptCache = null;
|
||||
let playerRegionScriptsCache = null;
|
||||
let playerOnboardingLandingScriptCache = null;
|
||||
let playerOnboardingFormScriptCache = null;
|
||||
|
||||
function loadTemplate(filePath, cache) {
|
||||
const stat = fs.statSync(filePath);
|
||||
if (cache.value && cache.mtimeMs === stat.mtimeMs) {
|
||||
return cache.value;
|
||||
}
|
||||
const compiled = require('handlebars').compile(fs.readFileSync(filePath, 'utf8'));
|
||||
cache.value = compiled;
|
||||
cache.mtimeMs = stat.mtimeMs;
|
||||
return compiled;
|
||||
}
|
||||
|
||||
function getPlayerPageTemplate() {
|
||||
return loadTemplate(playerPageTemplatePath, playerPageTemplateCache || (playerPageTemplateCache = {}));
|
||||
}
|
||||
|
||||
function getPlayerClientNameScript() {
|
||||
return loadTemplate(playerClientNameScriptPath, playerClientNameScriptCache || (playerClientNameScriptCache = {}));
|
||||
}
|
||||
|
||||
function getPlayerPageOfflineScript() {
|
||||
return loadTemplate(playerPageOfflineScriptPath, playerPageOfflineScriptCache || (playerPageOfflineScriptCache = {}));
|
||||
}
|
||||
|
||||
function getPlayerPagePlaylistScript() {
|
||||
return loadTemplate(playerPagePlaylistScriptPath, playerPagePlaylistScriptCache || (playerPagePlaylistScriptCache = {}));
|
||||
}
|
||||
|
||||
function getPlayerPageCommandsScript() {
|
||||
return loadTemplate(playerPageCommandsScriptPath, playerPageCommandsScriptCache || (playerPageCommandsScriptCache = {}));
|
||||
}
|
||||
|
||||
function getPlayerPageRenderingScript() {
|
||||
return loadTemplate(playerPageRenderingScriptPath, playerPageRenderingScriptCache || (playerPageRenderingScriptCache = {}));
|
||||
}
|
||||
|
||||
function getPlayerPagePlaybackScript() {
|
||||
return loadTemplate(playerPagePlaybackScriptPath, playerPagePlaybackScriptCache || (playerPagePlaybackScriptCache = {}));
|
||||
}
|
||||
|
||||
function getPlayerAnnouncementTemplatesScript() {
|
||||
if (playerAnnouncementTemplatesScriptCache && playerAnnouncementTemplatesScriptCache.path === playerAnnouncementTemplatesScriptPath) {
|
||||
return playerAnnouncementTemplatesScriptCache.value;
|
||||
}
|
||||
|
||||
const value = fs.readFileSync(playerAnnouncementTemplatesScriptPath, 'utf8').trim();
|
||||
playerAnnouncementTemplatesScriptCache = {
|
||||
path: playerAnnouncementTemplatesScriptPath,
|
||||
value: value
|
||||
};
|
||||
return value;
|
||||
}
|
||||
|
||||
function getAnnouncementIconsDataScript() {
|
||||
return [
|
||||
'(function () {',
|
||||
' window.pulseAnnouncementIconKeys = ' + safeJsonForScript(announcementIcons.ANNOUNCEMENT_ICON_KEYS) + ';',
|
||||
' window.pulseAnnouncementDefaultIconKey = ' + safeJsonForScript(announcementIcons.DEFAULT_ANNOUNCEMENT_ICON) + ';',
|
||||
'}());'
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function loadAnnouncementTemplate(filePath) {
|
||||
return fs.readFileSync(filePath, 'utf8').trim();
|
||||
}
|
||||
|
||||
function getPlayerAnnouncementTemplatesDataScript() {
|
||||
const templatePaths = [
|
||||
playerAnnouncementLowerThirdTemplatePath,
|
||||
playerAnnouncementTopBannerTemplatePath,
|
||||
playerAnnouncementFullscreenTemplatePath
|
||||
];
|
||||
const signature = templatePaths.map(function (filePath) {
|
||||
return fs.statSync(filePath).mtimeMs;
|
||||
}).join('|');
|
||||
|
||||
if (playerAnnouncementTemplatesDataScriptCache && playerAnnouncementTemplatesDataScriptCache.signature === signature) {
|
||||
return playerAnnouncementTemplatesDataScriptCache.value;
|
||||
}
|
||||
|
||||
const value = [
|
||||
'(function () {',
|
||||
' window.playerAnnouncementTemplates = {',
|
||||
' lowerThird: ' + safeJsonForScript(loadAnnouncementTemplate(playerAnnouncementLowerThirdTemplatePath)) + ',',
|
||||
' topBanner: ' + safeJsonForScript(loadAnnouncementTemplate(playerAnnouncementTopBannerTemplatePath)) + ',',
|
||||
' fullscreen: ' + safeJsonForScript(loadAnnouncementTemplate(playerAnnouncementFullscreenTemplatePath)),
|
||||
' };',
|
||||
'}());'
|
||||
].join('\n');
|
||||
|
||||
playerAnnouncementTemplatesDataScriptCache = {
|
||||
signature: signature,
|
||||
value: value
|
||||
};
|
||||
return value;
|
||||
}
|
||||
|
||||
function getPlayerPageAnnouncementsScript() {
|
||||
return loadTemplate(playerPageAnnouncementsScriptPath, playerPageAnnouncementsScriptCache || (playerPageAnnouncementsScriptCache = {}));
|
||||
}
|
||||
|
||||
function getPlayerPageScript() {
|
||||
return loadTemplate(playerPageScriptPath, playerPageScriptCache || (playerPageScriptCache = {}));
|
||||
}
|
||||
|
||||
function getPlayerRegionScriptPaths() {
|
||||
if (!fs.existsSync(playerRegionScriptDir)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return fs.readdirSync(playerRegionScriptDir, { withFileTypes: true })
|
||||
.filter(function (entry) {
|
||||
return entry.isFile() && entry.name.toLowerCase().endsWith('.js');
|
||||
})
|
||||
.map(function (entry) {
|
||||
return path.join(playerRegionScriptDir, entry.name);
|
||||
})
|
||||
.sort(function (left, right) {
|
||||
return left.localeCompare(right);
|
||||
});
|
||||
}
|
||||
|
||||
function getPlayerRegionScripts() {
|
||||
const sharedPlaceholderUtilsPath = path.join(__dirname, '..', 'web', 'public', 'js', 'shared', 'placeholder-utils.js');
|
||||
const playerRegionScriptPaths = getPlayerRegionScriptPaths();
|
||||
const scriptPaths = [sharedPlaceholderUtilsPath].concat(playerRegionScriptPaths);
|
||||
const statSignature = scriptPaths.map(function (filePath) {
|
||||
return fs.statSync(filePath).mtimeMs;
|
||||
}).join('|');
|
||||
if (playerRegionScriptsCache && playerRegionScriptsCache.signature === statSignature) {
|
||||
return playerRegionScriptsCache.value;
|
||||
}
|
||||
|
||||
const value = scriptPaths.map(function (filePath) {
|
||||
return fs.readFileSync(filePath, 'utf8').trim();
|
||||
}).join('\n\n');
|
||||
playerRegionScriptsCache = {
|
||||
signature: statSignature,
|
||||
value: value
|
||||
};
|
||||
return value;
|
||||
}
|
||||
|
||||
function getPlayerOnboardingLandingScript() {
|
||||
return loadTemplate(playerOnboardingLandingScriptPath, playerOnboardingLandingScriptCache || (playerOnboardingLandingScriptCache = {}));
|
||||
}
|
||||
|
||||
function getPlayerOnboardingFormScript() {
|
||||
return loadTemplate(playerOnboardingFormScriptPath, playerOnboardingFormScriptCache || (playerOnboardingFormScriptCache = {}));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
mediaKind: mediaKind,
|
||||
escapeHtml: escapeHtml,
|
||||
sanitizeFontFamily: sanitizeFontFamily,
|
||||
sanitizeFontSize: sanitizeFontSize,
|
||||
sanitizeTextColor: sanitizeTextColor,
|
||||
sanitizeRichTextAttributes: sanitizeRichTextAttributes,
|
||||
safeJsonForScript: safeJsonForScript,
|
||||
parseMaybeJson: parseMaybeJson,
|
||||
normalizeContentValue: normalizeContentValue,
|
||||
normalizeSlide: normalizeSlide,
|
||||
sanitizeRichText: sanitizeRichText,
|
||||
renderEditorJsBlock: renderEditorJsBlock,
|
||||
renderEditorJsListItem: renderEditorJsListItem,
|
||||
renderEditorJsTable: renderEditorJsTable,
|
||||
renderEditorJsContent: renderEditorJsContent,
|
||||
renderHtmlRegionContent: renderHtmlRegionContent,
|
||||
fitCanvasSize: fitCanvasSize,
|
||||
loadTemplate: loadTemplate,
|
||||
getPlayerPageTemplate: getPlayerPageTemplate,
|
||||
getPlayerClientNameScript: getPlayerClientNameScript,
|
||||
getPlayerPageOfflineScript: getPlayerPageOfflineScript,
|
||||
getPlayerPagePlaylistScript: getPlayerPagePlaylistScript,
|
||||
getPlayerPageCommandsScript: getPlayerPageCommandsScript,
|
||||
getPlayerPageRenderingScript: getPlayerPageRenderingScript,
|
||||
getPlayerPagePlaybackScript: getPlayerPagePlaybackScript,
|
||||
getAnnouncementIconsDataScript: getAnnouncementIconsDataScript,
|
||||
getPlayerAnnouncementTemplatesDataScript: getPlayerAnnouncementTemplatesDataScript,
|
||||
getPlayerAnnouncementTemplatesScript: getPlayerAnnouncementTemplatesScript,
|
||||
getPlayerPageAnnouncementsScript: getPlayerPageAnnouncementsScript,
|
||||
getPlayerPageScript: getPlayerPageScript,
|
||||
getPlayerRegionScripts: getPlayerRegionScripts,
|
||||
getPlayerOnboardingLandingScript: getPlayerOnboardingLandingScript,
|
||||
getPlayerOnboardingFormScript: getPlayerOnboardingFormScript
|
||||
};
|
||||
+140
-292
@@ -1,320 +1,168 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
// Player page rendering and bootstrap script assembly.
|
||||
|
||||
const Handlebars = require('handlebars');
|
||||
const path = require('path');
|
||||
const { mediaKind, safeJsonForScript, getPlayerPageTemplate, getPlayerClientNameScript, getPlayerPageOfflineScript, getPlayerPagePlaylistScript, getPlayerPageCommandsScript, getPlayerPageRenderingScript, getPlayerPagePlaybackScript, getAnnouncementIconsDataScript, getPlayerAnnouncementTemplatesDataScript, getPlayerAnnouncementTemplatesScript, getPlayerPageAnnouncementsScript, getPlayerPageScript, getPlayerRegionScripts, getPlayerOnboardingLandingScript, getPlayerOnboardingFormScript } = require('./render-helpers');
|
||||
const { createThumbnailPreviewBootstrapScript } = require('./thumbnail-preview');
|
||||
const { createPageAuthBundle, createPageFetchAuthScript } = require('#src/request-auth');
|
||||
const { getFontStylesheetHref } = require('#src/web/lib/media/font-library');
|
||||
|
||||
function mediaKind(mediaPath) {
|
||||
const ext = path.extname(mediaPath || '').toLowerCase();
|
||||
if (['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg'].includes(ext)) {
|
||||
return 'image';
|
||||
}
|
||||
if (['.mp4', '.webm', '.ogg'].includes(ext)) {
|
||||
return 'video';
|
||||
}
|
||||
if (ext === '.pdf') {
|
||||
return 'pdf';
|
||||
}
|
||||
return 'file';
|
||||
}
|
||||
const PLAYER_MEDIA_DIR = path.join(__dirname, '..', '..', 'media');
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function sanitizeFontFamily(value) {
|
||||
return String(value || '').replace(/[^a-zA-Z0-9 ,"-]/g, '').trim();
|
||||
}
|
||||
|
||||
function sanitizeFontSize(value) {
|
||||
return Math.max(8, Number(value || 0) || 24);
|
||||
}
|
||||
|
||||
function sanitizeTextColor(value, fallback) {
|
||||
const raw = String(value || '').trim();
|
||||
if (/^#[0-9a-fA-F]{6}$/.test(raw) || /^#[0-9a-fA-F]{3}$/.test(raw)) {
|
||||
return raw;
|
||||
}
|
||||
return fallback || '#000000';
|
||||
}
|
||||
|
||||
const ALLOWED_RICH_TEXT_TAGS = ['a', 'b', 'blockquote', 'br', 'code', 'div', 'em', 'figure', 'figcaption', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'li', 'ol', 'p', 'pre', 'span', 'strong', 'sub', 'sup', 'table', 'tbody', 'td', 'th', 'thead', 'tr', 'u', 'ul'];
|
||||
|
||||
function sanitizeRichTextAttributes(tagName, attrText) {
|
||||
const allowedAttributes = {
|
||||
a: ['href', 'title', 'target', 'rel', 'class', 'style'],
|
||||
blockquote: ['class', 'style'],
|
||||
div: ['class', 'style'],
|
||||
figure: ['class', 'style'],
|
||||
figcaption: ['class', 'style'],
|
||||
h1: ['class', 'style'],
|
||||
h2: ['class', 'style'],
|
||||
h3: ['class', 'style'],
|
||||
h4: ['class', 'style'],
|
||||
h5: ['class', 'style'],
|
||||
h6: ['class', 'style'],
|
||||
li: ['class', 'style'],
|
||||
ol: ['class', 'style', 'start'],
|
||||
p: ['class', 'style'],
|
||||
pre: ['class', 'style'],
|
||||
span: ['class', 'style'],
|
||||
table: ['class', 'style'],
|
||||
td: ['class', 'style', 'colspan', 'rowspan'],
|
||||
th: ['class', 'style', 'colspan', 'rowspan', 'scope'],
|
||||
tr: ['class', 'style'],
|
||||
ul: ['class', 'style']
|
||||
};
|
||||
const allowed = allowedAttributes[tagName] || [];
|
||||
if (!allowed.length) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const attrs = [];
|
||||
String(attrText || '').replace(/([a-zA-Z0-9:-]+)(?:\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>/=`]+)))?/g, (_full, key, _valuePart, doubleQuoted, singleQuoted, bareValue) => {
|
||||
const lowerKey = String(key || '').toLowerCase();
|
||||
if (!allowed.includes(lowerKey)) {
|
||||
return '';
|
||||
}
|
||||
const value = doubleQuoted !== undefined ? doubleQuoted : singleQuoted !== undefined ? singleQuoted : bareValue !== undefined ? bareValue : '';
|
||||
if (lowerKey === 'href' && /^(?:\s*javascript:|\s*data:)/i.test(String(value || ''))) {
|
||||
return '';
|
||||
}
|
||||
if (lowerKey === 'style' && /(?:expression\s*\(|javascript:|url\s*\()/i.test(String(value || ''))) {
|
||||
return '';
|
||||
}
|
||||
if (lowerKey === 'target') {
|
||||
const targetValue = String(value || '').trim();
|
||||
if (targetValue === '_blank') {
|
||||
attrs.push(' target="_blank"');
|
||||
if (!attrs.includes(' rel="noreferrer noopener"')) {
|
||||
attrs.push(' rel="noreferrer noopener"');
|
||||
}
|
||||
return '';
|
||||
}
|
||||
}
|
||||
attrs.push(' ' + lowerKey + '="' + escapeHtml(value) + '"');
|
||||
return '';
|
||||
});
|
||||
|
||||
return attrs.join('');
|
||||
}
|
||||
|
||||
function safeJsonForScript(value) {
|
||||
return JSON.stringify(value === undefined ? null : value).replace(/</g, '\\u003c');
|
||||
}
|
||||
|
||||
function parseMaybeJson(value) {
|
||||
if (typeof value !== 'string') {
|
||||
return value;
|
||||
}
|
||||
const raw = value.trim();
|
||||
if (!raw) {
|
||||
return value;
|
||||
}
|
||||
if (raw[0] !== '{' && raw[0] !== '[') {
|
||||
return value;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch (_error) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeContentValue(value) {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return {
|
||||
type: 'text',
|
||||
value: parseMaybeJson(value)
|
||||
};
|
||||
}
|
||||
const normalized = {};
|
||||
Object.keys(value).forEach(function (key) {
|
||||
normalized[key] = value[key];
|
||||
});
|
||||
if (normalized.value !== undefined) {
|
||||
normalized.value = parseMaybeJson(normalized.value);
|
||||
} else if (normalized.font_family !== undefined && normalized.font_family !== null) {
|
||||
normalized.font_family = sanitizeFontFamily(normalized.font_family);
|
||||
} else if (normalized.font_size !== undefined && normalized.font_size !== null) {
|
||||
normalized.font_size = sanitizeFontSize(normalized.font_size);
|
||||
} else if (normalized.font_color !== undefined && normalized.font_color !== null) {
|
||||
normalized.font_color = sanitizeTextColor(normalized.font_color);
|
||||
} else if (!normalized.type) {
|
||||
normalized.type = 'text';
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeSlide(slide) {
|
||||
const normalized = {};
|
||||
Object.keys(slide || {}).forEach(function (key) {
|
||||
normalized[key] = slide[key];
|
||||
});
|
||||
normalized.content = {};
|
||||
const content = slide && slide.content && typeof slide.content === 'object' ? slide.content : {};
|
||||
Object.keys(content).forEach(function (regionKey) {
|
||||
normalized.content[regionKey] = normalizeContentValue(content[regionKey]);
|
||||
});
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function sanitizeRichText(html) {
|
||||
let output = String(html || '');
|
||||
output = output.replace(/<script[\s\S]*?<\/script>/gi, '');
|
||||
output = output.replace(/<style[\s\S]*?<\/style>/gi, '');
|
||||
return output.replace(/<[^>]+>/g, (tag) => {
|
||||
const match = tag.match(/^<\s*(\/?)\s*([a-z0-9]+)([\s\S]*?)(\/?)>$/i);
|
||||
if (!match) {
|
||||
return '';
|
||||
}
|
||||
const closing = Boolean(match[1]);
|
||||
const name = String(match[2] || '').toLowerCase();
|
||||
const attrText = String(match[3] || '');
|
||||
if (!ALLOWED_RICH_TEXT_TAGS.includes(name)) {
|
||||
return '';
|
||||
}
|
||||
if (closing) {
|
||||
return `</${name}>`;
|
||||
}
|
||||
return `<${name}${sanitizeRichTextAttributes(name, attrText)}>`;
|
||||
function renderPage(template, options) {
|
||||
const stylesheetLinks = Array.isArray(options && options.stylesheets) ? options.stylesheets : [];
|
||||
return template({
|
||||
TITLE: options.title,
|
||||
BODY_CLASS: options.bodyClass || '',
|
||||
BODY: new Handlebars.SafeString(options.body || ''),
|
||||
STYLESHEETS: new Handlebars.SafeString(stylesheetLinks.map(function (href) {
|
||||
return `<link rel="stylesheet" href="${Handlebars.escapeExpression(href)}" />`;
|
||||
}).join('')),
|
||||
SCRIPT_BLOCK: new Handlebars.SafeString(options.script || '')
|
||||
});
|
||||
}
|
||||
|
||||
function renderEditorJsBlock(block) {
|
||||
if (!block || !block.type || !block.data) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (block.type === 'header') {
|
||||
const level = Math.max(1, Math.min(6, Number(block.data.level || 2)));
|
||||
return '<h' + level + '>' + sanitizeRichText(block.data.text || '') + '</h' + level + '>';
|
||||
} else if (block.type === 'list') {
|
||||
const tag = block.data.style === 'ordered' ? 'ol' : 'ul';
|
||||
const items = Array.isArray(block.data.items) ? block.data.items : [];
|
||||
return '<' + tag + ' style="list-style-type:' + (tag === 'ol' ? 'decimal' : 'disc') + ';padding-left:1.4em;">' + items.map((item) => renderEditorJsListItem(item, tag)).join('') + '</' + tag + '>';
|
||||
} else if (block.type === 'delimiter') {
|
||||
return '<hr />';
|
||||
} else if (block.type === 'code') {
|
||||
return '<pre><code>' + escapeHtml(block.data.code || '') + '</code></pre>';
|
||||
} else if (block.type === 'table') {
|
||||
return renderEditorJsTable(block.data);
|
||||
} else if (block.type === 'paragraph') {
|
||||
return '<p>' + sanitizeRichText(block.data.text || '') + '</p>';
|
||||
}
|
||||
|
||||
return '';
|
||||
function getPlayerServiceWorkerRegistrationScript() {
|
||||
return [
|
||||
'<script>',
|
||||
' if ("serviceWorker" in navigator) {',
|
||||
' window.addEventListener("load", function () {',
|
||||
' navigator.serviceWorker.register("/sw.js?v=38").catch(function () {',
|
||||
' return null;',
|
||||
' });',
|
||||
' });',
|
||||
' }',
|
||||
'</script>'
|
||||
].join('');
|
||||
}
|
||||
|
||||
function renderEditorJsListItem(item, tag) {
|
||||
if (item && typeof item === 'object') {
|
||||
const content = item.content !== undefined ? item.content : (item.text !== undefined ? item.text : item.value !== undefined ? item.value : '');
|
||||
const children = Array.isArray(item.items) ? item.items : Array.isArray(item.subItems) ? item.subItems : Array.isArray(item.children) ? item.children : [];
|
||||
const nested = children.length ? '<' + tag + ' style="list-style-type:' + (tag === 'ol' ? 'decimal' : 'disc') + ';padding-left:1.4em;">' + children.map((child) => renderEditorJsListItem(child, tag)).join('') + '</' + tag + '>' : '';
|
||||
return '<li>' + sanitizeRichText(content || '') + nested + '</li>';
|
||||
}
|
||||
return '<li>' + sanitizeRichText(item || '') + '</li>';
|
||||
function renderOnboardingLandingBody() {
|
||||
return [
|
||||
'<main class="onboarding-shell">',
|
||||
' <section class="onboarding-card onboarding-card--landing">',
|
||||
' <p class="onboarding-kicker">Pulse Signage</p>',
|
||||
' <h1>Onboard this player</h1>',
|
||||
' <p class="onboarding-copy">Choose an existing screen, name the client, and either scan the QR code or finish right here with a keyboard and mouse.</p>',
|
||||
' <div class="onboarding-layout">',
|
||||
' <div class="onboarding-qr-pane">',
|
||||
' <div class="onboarding-qr-frame">',
|
||||
' <img id="onboarding-qr" alt="Onboarding QR code" />',
|
||||
' </div>',
|
||||
' <div id="onboarding-status" class="onboarding-status">Preparing onboarding link...</div>',
|
||||
' </div>',
|
||||
' <form id="onboarding-local-form" class="onboarding-form onboarding-form--local">',
|
||||
' <label>',
|
||||
' <span>Client name</span>',
|
||||
' <input name="clientName" type="text" maxlength="255" required placeholder="Lobby player" autocomplete="off" />',
|
||||
' </label>',
|
||||
' <label>',
|
||||
' <span>Screen</span>',
|
||||
' <select name="screenSlug" id="onboarding-screen-select" required>',
|
||||
' <option value="">Loading screens...</option>',
|
||||
' </select>',
|
||||
' </label>',
|
||||
' <button type="submit">Save client</button>',
|
||||
' <div id="onboarding-message" class="onboarding-status"></div>',
|
||||
' </form>',
|
||||
' </div>',
|
||||
' </section>',
|
||||
'</main>'
|
||||
].join('');
|
||||
}
|
||||
|
||||
function renderEditorJsTable(data) {
|
||||
const rows = Array.isArray(data.content) ? data.content : Array.isArray(data.rows) ? data.rows : [];
|
||||
if (!rows.length) {
|
||||
return '';
|
||||
}
|
||||
const hasHeadings = Boolean(data.withHeadings);
|
||||
const tableRows = rows.map(function (row, rowIndex) {
|
||||
const cells = Array.isArray(row) ? row : [];
|
||||
const cellTag = hasHeadings && rowIndex === 0 ? 'th' : 'td';
|
||||
const cellAttrs = cellTag === 'th' ? ' scope="col"' : '';
|
||||
return '<tr>' + cells.map(function (cell) {
|
||||
return '<' + cellTag + cellAttrs + '>' + sanitizeRichText(cell || '') + '</' + cellTag + '>';
|
||||
}).join('') + '</tr>';
|
||||
}).join('');
|
||||
return '<table class="ck-content-table">' + tableRows + '</table>';
|
||||
function renderOnboardingFormBody(deviceId) {
|
||||
return [
|
||||
'<main class="onboarding-shell">',
|
||||
' <section class="onboarding-card onboarding-card--form">',
|
||||
' <p class="onboarding-kicker">Pulse Signage</p>',
|
||||
' <h1>Name this client</h1>',
|
||||
' <p class="onboarding-copy">Pick an existing screen and give this player a friendly name that will persist after refreshes.</p>',
|
||||
' <form id="onboarding-form" class="onboarding-form">',
|
||||
' <label>',
|
||||
' <span>Client name</span>',
|
||||
' <input name="clientName" type="text" maxlength="255" required placeholder="Lobby player" />',
|
||||
' </label>',
|
||||
' <label>',
|
||||
' <span>Screen</span>',
|
||||
' <select name="screenSlug" id="onboarding-screen-select" required>',
|
||||
' <option value="">Loading screens...</option>',
|
||||
' </select>',
|
||||
' </label>',
|
||||
' <input type="hidden" name="deviceId" value="' + Handlebars.escapeExpression(deviceId || '') + '" />',
|
||||
' <button type="submit">Save client</button>',
|
||||
' <div id="onboarding-message" class="onboarding-status"></div>',
|
||||
' </form>',
|
||||
' </section>',
|
||||
'</main>'
|
||||
].join('');
|
||||
}
|
||||
|
||||
function renderEditorJsContent(value) {
|
||||
if (value && typeof value === 'object') {
|
||||
if (Array.isArray(value.blocks)) {
|
||||
return value.blocks.map(renderEditorJsBlock).join('');
|
||||
}
|
||||
if (value.value !== undefined) {
|
||||
return renderEditorJsContent(value.value);
|
||||
}
|
||||
}
|
||||
const raw = String(value || '');
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed && Array.isArray(parsed.blocks)) {
|
||||
return parsed.blocks.map(renderEditorJsBlock).join('');
|
||||
}
|
||||
} catch (_error) {
|
||||
// fall through to legacy HTML rendering
|
||||
}
|
||||
return sanitizeRichText(raw);
|
||||
|
||||
function renderOnboardingLandingScript() {
|
||||
return getPlayerOnboardingLandingScript()();
|
||||
}
|
||||
|
||||
function renderHtmlRegionContent(value) {
|
||||
const html = String(value || '').trim();
|
||||
if (!html) {
|
||||
return '<div class="template-region-placeholder">HTML</div>';
|
||||
}
|
||||
return '<iframe class="template-region-html-frame" sandbox="" srcdoc="' + escapeHtml(html) + '" title="HTML region" loading="eager"></iframe>';
|
||||
}
|
||||
|
||||
function fitCanvasSize(canvasWidth, canvasHeight, maxWidth, maxHeight) {
|
||||
const width = Math.max(1, Number(canvasWidth || 0) || 1920);
|
||||
const height = Math.max(1, Number(canvasHeight || 0) || 1080);
|
||||
const viewportWidth = Math.max(1, Number(maxWidth || 0) || width);
|
||||
const viewportHeight = Math.max(1, Number(maxHeight || 0) || height);
|
||||
const scale = Math.min(viewportWidth / width, viewportHeight / height);
|
||||
return {
|
||||
width: Math.round(width * scale),
|
||||
height: Math.round(height * scale)
|
||||
};
|
||||
}
|
||||
|
||||
const playerPageTemplatePath = path.join(__dirname, 'player-page.template.html');
|
||||
const playerPageScriptPath = path.join(__dirname, 'player-page.script.html');
|
||||
let playerPageTemplateCache = null;
|
||||
let playerPageScriptCache = null;
|
||||
|
||||
function loadTemplate(filePath, cache) {
|
||||
const stat = fs.statSync(filePath);
|
||||
if (cache.value && cache.mtimeMs === stat.mtimeMs) {
|
||||
return cache.value;
|
||||
}
|
||||
const compiled = Handlebars.compile(fs.readFileSync(filePath, 'utf8'));
|
||||
cache.value = compiled;
|
||||
cache.mtimeMs = stat.mtimeMs;
|
||||
return compiled;
|
||||
}
|
||||
|
||||
function getPlayerPageTemplate() {
|
||||
return loadTemplate(playerPageTemplatePath, playerPageTemplateCache || (playerPageTemplateCache = {}));
|
||||
}
|
||||
|
||||
function getPlayerPageScript() {
|
||||
return loadTemplate(playerPageScriptPath, playerPageScriptCache || (playerPageScriptCache = {}));
|
||||
function renderOnboardingFormScript(deviceId) {
|
||||
return getPlayerOnboardingFormScript()({
|
||||
DEVICE_ID_JSON: new Handlebars.SafeString(JSON.stringify(deviceId || ''))
|
||||
});
|
||||
}
|
||||
|
||||
function renderPlayerPage(slug, initialData) {
|
||||
const onboardingScript = getPlayerClientNameScript()();
|
||||
const offlineScript = getPlayerPageOfflineScript()();
|
||||
const playlistScript = getPlayerPagePlaylistScript()();
|
||||
const commandScript = getPlayerPageCommandsScript()();
|
||||
const renderingScript = getPlayerPageRenderingScript()();
|
||||
const playbackScript = getPlayerPagePlaybackScript()();
|
||||
const serviceWorkerScript = getPlayerServiceWorkerRegistrationScript();
|
||||
const template = getPlayerPageTemplate();
|
||||
const hlsScriptTag = '<script src="/assets/vendor/hls.min.js"></script>';
|
||||
const pageAuthToken = createPageAuthBundle({ scope: 'player', slug: String(slug || '').trim() });
|
||||
const fontStylesheetHref = getFontStylesheetHref(PLAYER_MEDIA_DIR);
|
||||
const bodyClass = [initialData && initialData.thumbnailPreview ? 'thumbnail-preview' : '', ''].join(' ').trim();
|
||||
const script = getPlayerPageScript()({
|
||||
SLUG_JSON: new Handlebars.SafeString(JSON.stringify(slug)),
|
||||
INITIAL_DATA_JSON: new Handlebars.SafeString(safeJsonForScript(initialData || null))
|
||||
INITIAL_DATA_JSON: new Handlebars.SafeString(safeJsonForScript(initialData || null)),
|
||||
REGION_SCRIPTS: new Handlebars.SafeString(getPlayerRegionScripts())
|
||||
});
|
||||
|
||||
return template({
|
||||
TITLE: 'Screen ' + slug,
|
||||
SCRIPT_BLOCK: new Handlebars.SafeString(script)
|
||||
return renderPage(template, {
|
||||
title: 'Screen ' + slug,
|
||||
bodyClass: bodyClass,
|
||||
body: '<div id="app"><div class="empty">Loading screen...</div></div>',
|
||||
stylesheets: fontStylesheetHref ? [fontStylesheetHref] : [],
|
||||
script: createPageFetchAuthScript(pageAuthToken, '/ws/screens/' + encodeURIComponent(slug || '')) + hlsScriptTag + serviceWorkerScript + createThumbnailPreviewBootstrapScript(initialData) + '<script>' + offlineScript + '</script>' + '<script>' + playlistScript + '</script>' + '<script>' + commandScript + '</script>' + '<script>' + renderingScript + '</script>' + '<script>' + playbackScript + '</script>' + '<script>' + getAnnouncementIconsDataScript() + '</script>' + '<script>' + getPlayerAnnouncementTemplatesDataScript() + '</script>' + '<script>' + getPlayerAnnouncementTemplatesScript() + '</script>' + onboardingScript + script + '<script>' + getPlayerPageAnnouncementsScript()() + '</script>'
|
||||
});
|
||||
}
|
||||
|
||||
function renderPlayerOnboardingLandingPage() {
|
||||
const pageAuthToken = createPageAuthBundle({ scope: 'onboarding' });
|
||||
const fontStylesheetHref = getFontStylesheetHref(PLAYER_MEDIA_DIR);
|
||||
return renderPage(getPlayerPageTemplate(), {
|
||||
title: 'Onboard player',
|
||||
bodyClass: 'onboarding-page',
|
||||
body: renderOnboardingLandingBody(),
|
||||
stylesheets: fontStylesheetHref ? [fontStylesheetHref] : [],
|
||||
script: createPageFetchAuthScript(pageAuthToken) + getPlayerServiceWorkerRegistrationScript() + renderOnboardingLandingScript()
|
||||
});
|
||||
}
|
||||
|
||||
function renderPlayerOnboardingFormPage(deviceId) {
|
||||
const pageAuthToken = createPageAuthBundle({ scope: 'onboarding', deviceId: String(deviceId || '').trim() });
|
||||
const fontStylesheetHref = getFontStylesheetHref(PLAYER_MEDIA_DIR);
|
||||
return renderPage(getPlayerPageTemplate(), {
|
||||
title: 'Onboard screen',
|
||||
bodyClass: 'onboarding-page',
|
||||
body: renderOnboardingFormBody(deviceId),
|
||||
stylesheets: fontStylesheetHref ? [fontStylesheetHref] : [],
|
||||
script: createPageFetchAuthScript(pageAuthToken) + getPlayerServiceWorkerRegistrationScript() + renderOnboardingFormScript(deviceId)
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
mediaKind,
|
||||
renderPlayerPage
|
||||
renderPlayerPage,
|
||||
renderPlayerOnboardingLandingPage,
|
||||
renderPlayerOnboardingFormPage
|
||||
};
|
||||
|
||||
@@ -0,0 +1,431 @@
|
||||
// Player route registration and filesystem-backed media helpers.
|
||||
|
||||
const fs = require('fs');
|
||||
const express = require('express');
|
||||
const path = require('path');
|
||||
const { getSharedSecret, createPageAuthBundle, verifyPageAuthToken, verifyRequestAuth } = require('#src/request-auth');
|
||||
const { buildThumbnailPreviewData } = require('./thumbnail-preview');
|
||||
|
||||
const TRANSIENT_DB_ERROR_CODES = ['ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'EPIPE', 'ENOTFOUND', 'PROTOCOL_CONNECTION_LOST', 'POOL_CLOSED', 'ERR_POOL_CLOSED'];
|
||||
|
||||
function isTransientDbError(error) {
|
||||
return Boolean(error && TRANSIENT_DB_ERROR_CODES.indexOf(String(error.code || '').trim()) !== -1);
|
||||
}
|
||||
|
||||
function registerPlayerRoutes(app, options) {
|
||||
const pool = options && options.pool ? options.pool : null;
|
||||
const common = options && options.common ? options.common : null;
|
||||
const mediaDir = options && options.mediaDir ? options.mediaDir : null;
|
||||
const assetDir = options && options.assetDir ? options.assetDir : null;
|
||||
const playerRuntime = options && options.playerRuntime ? options.playerRuntime : null;
|
||||
const playerPlaylistService = options && options.playerPlaylistService ? options.playerPlaylistService : null;
|
||||
const rtmpStreamService = options && options.rtmpStreamService ? options.rtmpStreamService : null;
|
||||
const playerPublicBaseUrl = String(options && options.playerPublicBaseUrl || process.env.PLAYER_PUBLIC_BASE_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, '');
|
||||
const playerInternalBaseUrl = String(options && options.playerInternalBaseUrl || process.env.PLAYER_INTERNAL_BASE_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, '');
|
||||
const playerIdentifier = String(options && options.playerIdentifier || '1').trim() || '1';
|
||||
|
||||
if (!app || !pool || !common || !mediaDir || !assetDir || !playerRuntime || !playerPlaylistService || !rtmpStreamService) {
|
||||
throw new Error('registerPlayerRoutes requires app, pool, common, mediaDir, assetDir, playerRuntime, playerPlaylistService, and rtmpStreamService.');
|
||||
}
|
||||
|
||||
const sharedSecret = getSharedSecret();
|
||||
|
||||
function requirePageAuth(allowedScopes) {
|
||||
return function (req, res, next) {
|
||||
if (!sharedSecret) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const token = String(req.headers['x-pulse-page-auth'] || '').trim();
|
||||
const payload = verifyPageAuthToken(token);
|
||||
if (!payload) {
|
||||
return res.status(401).json({ error: 'Page authentication required.' });
|
||||
}
|
||||
|
||||
const scopes = Array.isArray(allowedScopes) ? allowedScopes : [];
|
||||
if (scopes.length && scopes.indexOf(String(payload.scope || '').trim()) === -1) {
|
||||
return res.status(403).json({ error: 'Page authentication scope is not allowed for this route.' });
|
||||
}
|
||||
|
||||
req.playerPageAuth = payload;
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
function requireRequestAuth(req, res, next) {
|
||||
if (!sharedSecret) {
|
||||
return next();
|
||||
}
|
||||
|
||||
if (!verifyRequestAuth(req)) {
|
||||
return res.status(401).json({ error: 'Request authentication required.' });
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
function resolveMediaFilePath(fileName) {
|
||||
const relativePath = path.normalize(String(fileName || '').trim()).replace(/^([\\/])+/, '');
|
||||
if (!relativePath || relativePath === '.' || relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const resolvedMediaDir = path.resolve(mediaDir);
|
||||
const resolvedFilePath = path.resolve(mediaDir, relativePath);
|
||||
if (resolvedFilePath !== resolvedMediaDir && !resolvedFilePath.startsWith(resolvedMediaDir + path.sep)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return resolvedFilePath;
|
||||
}
|
||||
|
||||
app.use('/assets', express.static(assetDir));
|
||||
app.use('/assets/adminlte/bootstrap-icons', express.static(path.join(__dirname, '..', 'web', 'public', 'adminlte', 'bootstrap-icons')));
|
||||
app.use('/media', express.static(mediaDir));
|
||||
app.use('/assets/vendor', express.static(path.join(__dirname, '..', '..', 'node_modules', 'hls.js', 'dist')));
|
||||
|
||||
app.get('/sw.js', function (_req, res) {
|
||||
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
|
||||
res.type('application/javascript');
|
||||
res.sendFile(path.join(__dirname, 'public', 'sw.js'));
|
||||
});
|
||||
|
||||
app.post('/api/auth/page', function (req, res, next) {
|
||||
try {
|
||||
if (!sharedSecret) {
|
||||
return res.status(404).json({ error: 'Page authentication is disabled.' });
|
||||
}
|
||||
|
||||
const token = String(req.headers['x-pulse-page-auth'] || '').trim();
|
||||
const payload = verifyPageAuthToken(token);
|
||||
if (!payload || ['player', 'onboarding'].indexOf(String(payload.scope || '').trim()) === -1) {
|
||||
return res.status(401).json({ error: 'Page authentication required.' });
|
||||
}
|
||||
|
||||
const tokenBundle = createPageAuthBundle({
|
||||
scope: payload.scope,
|
||||
slug: payload.slug || null,
|
||||
deviceId: payload.deviceId || null
|
||||
});
|
||||
res.json(tokenBundle);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/media/config', requireRequestAuth, function (_req, res) {
|
||||
res.json({
|
||||
mediaDir: mediaDir,
|
||||
uploadDir: path.join(mediaDir, 'uploads')
|
||||
});
|
||||
});
|
||||
|
||||
app.put('/api/media/:filename', express.raw({ type: '*/*', limit: '1gb' }), requireRequestAuth, async function (req, res, next) {
|
||||
try {
|
||||
const filePath = resolveMediaFilePath(req.params.filename);
|
||||
if (!filePath) {
|
||||
return res.status(400).json({ error: 'Filename is required' });
|
||||
}
|
||||
const body = Buffer.isBuffer(req.body) ? req.body : Buffer.from(req.body || '');
|
||||
await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
|
||||
await fs.promises.writeFile(filePath, body);
|
||||
res.json({ ok: true, filename: req.params.filename });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/media/:filename', requireRequestAuth, async function (req, res, next) {
|
||||
try {
|
||||
const filePath = resolveMediaFilePath(req.params.filename);
|
||||
if (!filePath) {
|
||||
return res.status(400).json({ error: 'Filename is required' });
|
||||
}
|
||||
try {
|
||||
await fs.promises.unlink(filePath);
|
||||
} catch (error) {
|
||||
if (!error || error.code !== 'ENOENT') {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
res.json({ ok: true, filename: req.params.filename });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/rtmp/session', requirePageAuth(['player']), async function (req, res, next) {
|
||||
try {
|
||||
const source = String(req.query.source || '').trim();
|
||||
const disableAudio = String(req.query.disableAudio || '').trim().toLowerCase();
|
||||
const useMutedOutput = disableAudio === '1' || disableAudio === 'true' || disableAudio === 'yes' || disableAudio === 'on';
|
||||
const status = await rtmpStreamService.getSessionStatus(source, useMutedOutput);
|
||||
const session = status.session;
|
||||
const ready = Boolean(status.ready);
|
||||
const live = Boolean(status.live);
|
||||
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
|
||||
if (!ready || !live) {
|
||||
return res.status(503).json({
|
||||
ready: false,
|
||||
live: false,
|
||||
timedOut: Boolean(status.timedOut),
|
||||
stderr: String(status.stderr || '')
|
||||
});
|
||||
}
|
||||
res.json({
|
||||
key: session.key,
|
||||
playlistUrl: session.playlistUrl,
|
||||
disableAudio: session.disableAudio,
|
||||
ready: true,
|
||||
live: true
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/rtmp/streams/:key/index.m3u8', async function (req, res, next) {
|
||||
try {
|
||||
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
|
||||
const manifestPath = await rtmpStreamService.getManifestFilePath(req.params.key);
|
||||
if (!manifestPath) {
|
||||
return res.status(404).send('Stream not found');
|
||||
}
|
||||
res.sendFile(manifestPath);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/rtmp/streams/:key/:fileName', async function (req, res, next) {
|
||||
try {
|
||||
const segmentPath = await rtmpStreamService.getSegmentFilePath(req.params.key, req.params.fileName);
|
||||
if (!segmentPath) {
|
||||
return res.status(404).send('Stream not found');
|
||||
}
|
||||
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
|
||||
res.sendFile(segmentPath);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/screen/:slug', function (req, res) {
|
||||
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
|
||||
res.set('Pragma', 'no-cache');
|
||||
const { bindPlayerToScreen } = require('./onboarding');
|
||||
if (playerIdentifier) {
|
||||
void bindPlayerToScreen(pool, playerIdentifier, req.params.slug)
|
||||
.catch(function (error) {
|
||||
console.error(error);
|
||||
});
|
||||
}
|
||||
playerPlaylistService.buildScreenPlaylist(req.params.slug).then(function (data) {
|
||||
res.send(common.renderPlayerPage(req.params.slug, data));
|
||||
}).catch(function (error) {
|
||||
console.error(error);
|
||||
res.set('X-Player-Offline', '1');
|
||||
res.send(common.renderPlayerPage(req.params.slug, null));
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/api/internal/slide-thumbnails/:id/preview', requireRequestAuth, async function (req, res, next) {
|
||||
try {
|
||||
const slide = await common.fetchSlideById(pool, Number(req.params.id));
|
||||
if (!slide) {
|
||||
return res.status(404).send('Slide not found');
|
||||
}
|
||||
|
||||
const data = buildThumbnailPreviewData(slide);
|
||||
|
||||
if (typeof common.fetchRssFeedsData === 'function' && typeof common.fetchRssFeedItemsByFeedId === 'function') {
|
||||
const rssData = await common.fetchRssFeedsData(pool);
|
||||
data.rssFeeds = await Promise.all((rssData.rssFeeds || []).map(async function (feed) {
|
||||
const items = await common.fetchRssFeedItemsByFeedId(pool, feed.id);
|
||||
return Object.assign({}, feed, {
|
||||
items: items.map(function (item) {
|
||||
return typeof common.normalizeRssFeedItem === 'function' ? common.normalizeRssFeedItem(item) : item;
|
||||
})
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
if (typeof common.fetchApiSourcesData === 'function') {
|
||||
const apiData = await common.fetchApiSourcesData(pool);
|
||||
data.apiSources = (apiData.apiSources || []).map(function (source) {
|
||||
return Object.assign({}, source, {
|
||||
responseJson: typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(source.last_response_json) : null
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof common.fetchTimetablesData === 'function') {
|
||||
const timetableData = await common.fetchTimetablesData(pool);
|
||||
data.timetableGroups = Array.isArray(timetableData && timetableData.timetableGroups) ? timetableData.timetableGroups : [];
|
||||
}
|
||||
|
||||
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
|
||||
res.send(common.renderPlayerPage('slide-thumbnail-preview-' + slide.id, data));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/screens/:slug/playlist', requirePageAuth(['player']), async function (req, res, next) {
|
||||
try {
|
||||
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
|
||||
const data = await playerPlaylistService.buildScreenPlaylist(req.params.slug);
|
||||
if (!data.screen) {
|
||||
return res.status(404).json({ error: 'Screen not found' });
|
||||
}
|
||||
const etag = '"' + String(data.revision || '') + '"';
|
||||
res.set('ETag', etag);
|
||||
if (String(req.headers['if-none-match'] || '').split(',').map(function (value) {
|
||||
return String(value || '').trim();
|
||||
}).includes(etag)) {
|
||||
return res.status(304).end();
|
||||
}
|
||||
res.json(data);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/screens/:slug/announcement', requirePageAuth(['player']), async function (req, res, next) {
|
||||
try {
|
||||
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
|
||||
const announcement = typeof common.fetchActiveAnnouncement === 'function'
|
||||
? await common.fetchActiveAnnouncement(pool, req.params.slug)
|
||||
: null;
|
||||
const revision = announcement
|
||||
? [announcement.id, announcement.modified_at || '', announcement.expires_at || '', announcement.enabled ? '1' : '0'].join(':')
|
||||
: 'none';
|
||||
const etag = '"' + String(revision || 'none') + '"';
|
||||
res.set('ETag', etag);
|
||||
if (String(req.headers['if-none-match'] || '').split(',').map(function (value) {
|
||||
return String(value || '').trim();
|
||||
}).includes(etag)) {
|
||||
return res.status(304).end();
|
||||
}
|
||||
res.json({
|
||||
announcement: announcement,
|
||||
revision: revision
|
||||
});
|
||||
} catch (error) {
|
||||
if (isTransientDbError(error)) {
|
||||
return res.status(503).json({ error: 'Announcement state unavailable.' });
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/screens/:slug/announcements/refresh', requireRequestAuth, async function (req, res, next) {
|
||||
try {
|
||||
const sent = typeof playerRuntime.broadcastAnnouncementRefresh === 'function'
|
||||
? playerRuntime.broadcastAnnouncementRefresh(req.params.slug)
|
||||
: 0;
|
||||
res.json({ ok: true, screenSlug: req.params.slug, sent: sent });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get(['/api/screens/:slug/connections', '/api/screens/:slug/clients'], requireRequestAuth, async function (req, res, next) {
|
||||
try {
|
||||
const connections = playerRuntime.snapshotConnections(req.params.slug);
|
||||
let screen = null;
|
||||
let screenLookupFailed = false;
|
||||
try {
|
||||
const [screenRows] = await pool.query('SELECT id, name, slug FROM d_screens WHERE slug = ?', [req.params.slug]);
|
||||
screen = screenRows[0] || null;
|
||||
} catch (error) {
|
||||
screenLookupFailed = isTransientDbError(error);
|
||||
if (!screenLookupFailed) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
res.json({
|
||||
screen: screen,
|
||||
screenSlug: req.params.slug,
|
||||
count: connections.length,
|
||||
connections: connections,
|
||||
degraded: screenLookupFailed
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/screens/:slug/commands', requireRequestAuth, async function (req, res, next) {
|
||||
try {
|
||||
const command = String((req.body && req.body.command) || req.query.command || '').trim().toLowerCase();
|
||||
const connectionId = String((req.body && (req.body.connectionId || req.body.clientId)) || req.query.connectionId || req.query.clientId || '').trim();
|
||||
const blackoutValue = req.body && Object.prototype.hasOwnProperty.call(req.body, 'blackout')
|
||||
? req.body.blackout
|
||||
: req.query.blackout;
|
||||
if (!command) {
|
||||
return res.status(400).json({ error: 'Command is required' });
|
||||
}
|
||||
if (['refresh', 'reload', 'redirect', 'pause', 'blackout', 'previous', 'next', 'setclientname'].indexOf(command) === -1) {
|
||||
return res.status(400).json({ error: 'Unsupported command' });
|
||||
}
|
||||
|
||||
const liveConnections = playerRuntime.snapshotConnections(req.params.slug);
|
||||
const isRedirectCommand = command === 'redirect';
|
||||
let screen = null;
|
||||
let screenLookupFailed = false;
|
||||
if (!isRedirectCommand) {
|
||||
try {
|
||||
const [screenRows] = await pool.query('SELECT id, name, slug FROM d_screens WHERE slug = ?', [req.params.slug]);
|
||||
screen = screenRows[0] || null;
|
||||
} catch (error) {
|
||||
screenLookupFailed = isTransientDbError(error);
|
||||
if (!screenLookupFailed) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!screen && liveConnections.length) {
|
||||
screen = {
|
||||
name: req.params.slug,
|
||||
slug: req.params.slug
|
||||
};
|
||||
}
|
||||
|
||||
const commandPayload = req.body && typeof req.body === 'object' && !Array.isArray(req.body)
|
||||
? Object.assign({}, req.body, { command: command })
|
||||
: command;
|
||||
if (command === 'blackout' && blackoutValue !== undefined && commandPayload && typeof commandPayload === 'object') {
|
||||
commandPayload.blackout = blackoutValue;
|
||||
}
|
||||
|
||||
const sent = connectionId
|
||||
? await playerRuntime.sendCommandToConnection(req.params.slug, connectionId, commandPayload)
|
||||
: await playerRuntime.broadcastCommand(req.params.slug, commandPayload);
|
||||
|
||||
if (!screen && !screenLookupFailed && !liveConnections.length) {
|
||||
return res.status(404).json({ error: 'Screen not found' });
|
||||
}
|
||||
|
||||
if (!screen && screenLookupFailed && !liveConnections.length) {
|
||||
return res.status(503).json({ error: 'Screen metadata unavailable while the database is down.' });
|
||||
}
|
||||
|
||||
res.json({
|
||||
screen: screen,
|
||||
screenSlug: req.params.slug,
|
||||
command: command,
|
||||
connectionId: connectionId || null,
|
||||
sent: sent,
|
||||
degraded: screenLookupFailed
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
registerPlayerRoutes: registerPlayerRoutes
|
||||
};
|
||||
@@ -0,0 +1,519 @@
|
||||
// Player runtime state, websocket connections, and request-auth enforcement.
|
||||
|
||||
const crypto = require('crypto');
|
||||
const { WebSocketServer, WebSocket } = require('ws');
|
||||
const { isClientNameAvailable } = require('#src/data/client-name-check');
|
||||
const { verifyPageAuthToken, verifyRequestAuth } = require('#src/request-auth');
|
||||
const PAGE_AUTH_COOKIE_NAME = 'pulse_page_auth';
|
||||
|
||||
function normalizePlayerPublicBaseUrl(pageUrl) {
|
||||
const value = String(pageUrl || '').trim();
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return new URL(value).origin.replace(/\/$/, '');
|
||||
} catch (_error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function createPlayerRuntime(options) {
|
||||
const pool = options && options.pool ? options.pool : null;
|
||||
const normalizeDeviceId = typeof options.normalizeDeviceId === 'function'
|
||||
? options.normalizeDeviceId
|
||||
: function (value) {
|
||||
return String(value || '').trim().replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 128);
|
||||
};
|
||||
const connectionsBySlug = new Map();
|
||||
const dashboardListenersBySlug = new Map();
|
||||
const announcementListenersBySlug = new Map();
|
||||
const wss = new WebSocketServer({ noServer: true });
|
||||
|
||||
function normalizeClientIp(value) {
|
||||
const ip = String(value || '').trim();
|
||||
if (!ip) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (ip.toLowerCase().startsWith('::ffff:')) {
|
||||
return ip.slice(7).trim() || null;
|
||||
}
|
||||
|
||||
return ip;
|
||||
}
|
||||
|
||||
function parseCookies(cookieHeader) {
|
||||
return String(cookieHeader || '').split(/;\s*/).reduce(function (cookies, pair) {
|
||||
if (!pair) {
|
||||
return cookies;
|
||||
}
|
||||
const separatorIndex = pair.indexOf('=');
|
||||
if (separatorIndex === -1) {
|
||||
return cookies;
|
||||
}
|
||||
const name = decodeURIComponent(pair.slice(0, separatorIndex).trim());
|
||||
const value = decodeURIComponent(pair.slice(separatorIndex + 1).trim());
|
||||
if (name) {
|
||||
cookies[name] = value;
|
||||
}
|
||||
return cookies;
|
||||
}, {});
|
||||
}
|
||||
|
||||
function readPageAuthToken(request) {
|
||||
const queryToken = String(new URL(request.url, 'http://localhost').searchParams.get('auth') || '').trim();
|
||||
if (queryToken) {
|
||||
return queryToken;
|
||||
}
|
||||
|
||||
const cookies = parseCookies(request.headers && request.headers.cookie || '');
|
||||
return String(cookies[PAGE_AUTH_COOKIE_NAME] || '').trim();
|
||||
}
|
||||
|
||||
function getConnectionBucket(slug) {
|
||||
const key = String(slug || '').trim();
|
||||
if (!key) {
|
||||
return null;
|
||||
}
|
||||
if (!connectionsBySlug.has(key)) {
|
||||
connectionsBySlug.set(key, new Map());
|
||||
}
|
||||
return connectionsBySlug.get(key);
|
||||
}
|
||||
|
||||
function removeConnection(slug, connectionId) {
|
||||
const bucket = connectionsBySlug.get(String(slug || '').trim());
|
||||
if (!bucket) {
|
||||
return;
|
||||
}
|
||||
bucket.delete(connectionId);
|
||||
if (!bucket.size) {
|
||||
connectionsBySlug.delete(String(slug || '').trim());
|
||||
}
|
||||
}
|
||||
|
||||
function getDashboardListenerBucket(slug) {
|
||||
const key = String(slug || '').trim();
|
||||
if (!key) {
|
||||
return null;
|
||||
}
|
||||
if (!dashboardListenersBySlug.has(key)) {
|
||||
dashboardListenersBySlug.set(key, new Set());
|
||||
}
|
||||
return dashboardListenersBySlug.get(key);
|
||||
}
|
||||
|
||||
function removeDashboardListener(slug, socket) {
|
||||
const key = String(slug || '').trim();
|
||||
const bucket = dashboardListenersBySlug.get(key);
|
||||
if (!bucket) {
|
||||
return;
|
||||
}
|
||||
bucket.delete(socket);
|
||||
if (!bucket.size) {
|
||||
dashboardListenersBySlug.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
function getAnnouncementListenerBucket(slug) {
|
||||
const key = String(slug || '').trim();
|
||||
if (!key) {
|
||||
return null;
|
||||
}
|
||||
if (!announcementListenersBySlug.has(key)) {
|
||||
announcementListenersBySlug.set(key, new Set());
|
||||
}
|
||||
return announcementListenersBySlug.get(key);
|
||||
}
|
||||
|
||||
function removeAnnouncementListener(slug, socket) {
|
||||
const key = String(slug || '').trim();
|
||||
const bucket = announcementListenersBySlug.get(key);
|
||||
if (!bucket) {
|
||||
return;
|
||||
}
|
||||
bucket.delete(socket);
|
||||
if (!bucket.size) {
|
||||
announcementListenersBySlug.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
function buildClientLabel(connection) {
|
||||
const clientName = String(connection.clientName || '').trim();
|
||||
const clientId = String(connection.clientId || '').trim();
|
||||
const userAgent = String(connection.userAgent || '').trim();
|
||||
const clientIp = String(connection.clientIp || '').trim();
|
||||
const viewport = connection.viewport && typeof connection.viewport === 'object'
|
||||
? connection.viewport
|
||||
: null;
|
||||
const labelParts = [];
|
||||
|
||||
if (userAgent) {
|
||||
labelParts.push(userAgent.length > 72 ? `${userAgent.slice(0, 72)}...` : userAgent);
|
||||
}
|
||||
|
||||
if (clientName) {
|
||||
labelParts.push(clientName);
|
||||
} else if (clientId) {
|
||||
labelParts.push(`id ${clientId.slice(-6)}`);
|
||||
}
|
||||
|
||||
if (clientIp) {
|
||||
labelParts.push(clientIp);
|
||||
}
|
||||
|
||||
if (viewport && Number.isFinite(Number(viewport.width)) && Number.isFinite(Number(viewport.height))) {
|
||||
labelParts.push(`${Number(viewport.width)}x${Number(viewport.height)}`);
|
||||
}
|
||||
|
||||
if (!labelParts.length) {
|
||||
return connection.remoteAddress || 'connected client';
|
||||
}
|
||||
|
||||
return labelParts.join(' • ');
|
||||
}
|
||||
|
||||
function snapshotConnections(slug) {
|
||||
const bucket = connectionsBySlug.get(String(slug || '').trim());
|
||||
if (!bucket) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Array.from(bucket.values()).map(function (connection) {
|
||||
return {
|
||||
id: connection.id,
|
||||
clientId: connection.clientId || null,
|
||||
clientName: connection.clientName || null,
|
||||
deviceId: connection.deviceId || null,
|
||||
label: connection.label,
|
||||
userAgent: connection.userAgent || null,
|
||||
viewport: connection.viewport || null,
|
||||
page: connection.page || null,
|
||||
playerPublicBaseUrl: connection.playerPublicBaseUrl || null,
|
||||
currentSlide: connection.currentSlide || null,
|
||||
paused: Boolean(connection.paused),
|
||||
blackout: Boolean(connection.blackout),
|
||||
currentSlideId: connection.currentSlide && connection.currentSlide.id ? connection.currentSlide.id : null,
|
||||
currentSlideTitle: connection.currentSlide && connection.currentSlide.title ? connection.currentSlide.title : null,
|
||||
clientIp: connection.clientIp || null,
|
||||
remoteAddress: connection.remoteAddress || null,
|
||||
connectedAt: connection.connectedAt ? connection.connectedAt.toISOString() : null,
|
||||
lastSeenAt: connection.lastSeenAt ? connection.lastSeenAt.toISOString() : null
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function snapshotAllConnections() {
|
||||
const allConnections = [];
|
||||
for (const bucket of connectionsBySlug.values()) {
|
||||
if (!bucket || typeof bucket.values !== 'function') {
|
||||
continue;
|
||||
}
|
||||
for (const connection of bucket.values()) {
|
||||
allConnections.push({
|
||||
clientId: connection.clientId || null,
|
||||
clientName: connection.clientName || null,
|
||||
deviceId: connection.deviceId || null
|
||||
});
|
||||
}
|
||||
}
|
||||
return allConnections;
|
||||
}
|
||||
|
||||
async function isClientNameAvailableOnScreen(poolArg, clientName, excludeDeviceId) {
|
||||
return isClientNameAvailable(poolArg || pool, clientName, excludeDeviceId, snapshotAllConnections());
|
||||
}
|
||||
|
||||
function broadcastConnectionSnapshot(slug) {
|
||||
const key = String(slug || '').trim();
|
||||
const bucket = dashboardListenersBySlug.get(key);
|
||||
if (!bucket || !bucket.size) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = JSON.stringify({
|
||||
type: 'snapshot',
|
||||
slug: key,
|
||||
connections: snapshotConnections(slug),
|
||||
sentAt: new Date().toISOString()
|
||||
});
|
||||
|
||||
bucket.forEach(function (socket) {
|
||||
if (socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(payload);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function broadcastAnnouncementRefresh(slug) {
|
||||
const key = String(slug || '').trim();
|
||||
const bucket = announcementListenersBySlug.get(key);
|
||||
if (!bucket || !bucket.size) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const payload = JSON.stringify({
|
||||
type: 'announcement-refresh',
|
||||
slug: key,
|
||||
sentAt: new Date().toISOString()
|
||||
});
|
||||
|
||||
let sent = 0;
|
||||
bucket.forEach(function (socket) {
|
||||
if (socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(payload);
|
||||
sent += 1;
|
||||
}
|
||||
});
|
||||
|
||||
return sent;
|
||||
}
|
||||
|
||||
async function sendCommandToConnection(slug, connectionId, commandOrPayload) {
|
||||
const bucket = connectionsBySlug.get(String(slug || '').trim());
|
||||
if (!bucket || !bucket.size) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const target = bucket.get(String(connectionId || '').trim());
|
||||
if (!target || target.socket.readyState !== WebSocket.OPEN) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const payload = typeof commandOrPayload === 'object' && commandOrPayload !== null
|
||||
? Object.assign({}, commandOrPayload)
|
||||
: { command: commandOrPayload };
|
||||
payload.type = 'command';
|
||||
payload.targetConnectionId = target.id;
|
||||
payload.sentAt = new Date().toISOString();
|
||||
|
||||
target.socket.send(JSON.stringify(payload));
|
||||
return 1;
|
||||
}
|
||||
|
||||
async function broadcastCommand(slug, commandOrPayload) {
|
||||
const bucket = connectionsBySlug.get(String(slug || '').trim());
|
||||
if (!bucket || !bucket.size) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let sent = 0;
|
||||
bucket.forEach(function (connection) {
|
||||
if (connection.socket.readyState !== WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
const payload = typeof commandOrPayload === 'object' && commandOrPayload !== null
|
||||
? Object.assign({}, commandOrPayload)
|
||||
: { command: commandOrPayload };
|
||||
payload.type = 'command';
|
||||
payload.sentAt = new Date().toISOString();
|
||||
|
||||
connection.socket.send(JSON.stringify(payload));
|
||||
sent += 1;
|
||||
});
|
||||
|
||||
return sent;
|
||||
}
|
||||
|
||||
function handleUpgrade(request, socket, head) {
|
||||
let pathname = '';
|
||||
try {
|
||||
pathname = new URL(request.url, 'http://localhost').pathname;
|
||||
} catch (_error) {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
const dashboardMatch = pathname.match(/^\/ws\/screens\/([^/]+)\/events$/);
|
||||
const playerMatch = pathname.match(/^\/ws\/screens\/([^/]+)$/);
|
||||
const announcementMatch = pathname.match(/^\/ws\/screens\/([^/]+)\/announcements$/);
|
||||
|
||||
if (!dashboardMatch && !playerMatch && !announcementMatch) {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
if (dashboardMatch) {
|
||||
if (!verifyRequestAuth(request)) {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (playerMatch) {
|
||||
const authToken = readPageAuthToken(request);
|
||||
const payload = verifyPageAuthToken(authToken);
|
||||
if (!payload || String(payload.scope || '').trim() !== 'player') {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (announcementMatch) {
|
||||
const authToken = readPageAuthToken(request);
|
||||
const payload = verifyPageAuthToken(authToken);
|
||||
if (!payload || String(payload.scope || '').trim() !== 'player') {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const slug = decodeURIComponent((dashboardMatch || playerMatch || announcementMatch)[1]);
|
||||
wss.handleUpgrade(request, socket, head, function (ws) {
|
||||
wss.emit('connection', ws, request, slug, dashboardMatch ? 'dashboard' : announcementMatch ? 'announcements' : 'player');
|
||||
});
|
||||
}
|
||||
|
||||
wss.on('connection', function (socket, request, slug, role) {
|
||||
if (role === 'dashboard') {
|
||||
const listenerBucket = getDashboardListenerBucket(slug);
|
||||
if (!listenerBucket) {
|
||||
socket.close();
|
||||
return;
|
||||
}
|
||||
|
||||
listenerBucket.add(socket);
|
||||
socket.send(JSON.stringify({
|
||||
type: 'snapshot',
|
||||
slug: String(slug || '').trim(),
|
||||
connections: snapshotConnections(slug),
|
||||
sentAt: new Date().toISOString()
|
||||
}));
|
||||
|
||||
socket.on('close', function () {
|
||||
removeDashboardListener(slug, socket);
|
||||
});
|
||||
|
||||
socket.on('error', function () {
|
||||
removeDashboardListener(slug, socket);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (role === 'announcements') {
|
||||
const listenerBucket = getAnnouncementListenerBucket(slug);
|
||||
if (!listenerBucket) {
|
||||
socket.close();
|
||||
return;
|
||||
}
|
||||
|
||||
listenerBucket.add(socket);
|
||||
socket.send(JSON.stringify({
|
||||
type: 'announcement-ready',
|
||||
slug: String(slug || '').trim(),
|
||||
sentAt: new Date().toISOString()
|
||||
}));
|
||||
|
||||
socket.on('close', function () {
|
||||
removeAnnouncementListener(slug, socket);
|
||||
});
|
||||
|
||||
socket.on('error', function () {
|
||||
removeAnnouncementListener(slug, socket);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const remoteAddress = request.socket && request.socket.remoteAddress ? request.socket.remoteAddress : null;
|
||||
const forwardedFor = normalizeClientIp(String(request.headers['x-forwarded-for'] || '').split(',')[0]);
|
||||
const normalizedRemoteAddress = normalizeClientIp(remoteAddress);
|
||||
const connectionId = crypto.randomUUID();
|
||||
const connection = {
|
||||
id: connectionId,
|
||||
slug: slug,
|
||||
socket: socket,
|
||||
clientId: null,
|
||||
clientName: null,
|
||||
deviceId: null,
|
||||
userAgent: null,
|
||||
viewport: null,
|
||||
page: null,
|
||||
paused: false,
|
||||
blackout: false,
|
||||
playerPublicBaseUrl: null,
|
||||
clientIp: forwardedFor || normalizedRemoteAddress,
|
||||
remoteAddress: normalizedRemoteAddress,
|
||||
label: forwardedFor || normalizedRemoteAddress || 'connected client',
|
||||
connectedAt: new Date(),
|
||||
lastSeenAt: new Date()
|
||||
};
|
||||
const bucket = getConnectionBucket(slug);
|
||||
|
||||
if (!bucket) {
|
||||
socket.close();
|
||||
return;
|
||||
}
|
||||
|
||||
bucket.set(connectionId, connection);
|
||||
|
||||
socket.on('message', function (rawMessage) {
|
||||
connection.lastSeenAt = new Date();
|
||||
let payload = null;
|
||||
try {
|
||||
payload = JSON.parse(String(rawMessage || ''));
|
||||
} catch (_error) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!payload || payload.type !== 'state') {
|
||||
return;
|
||||
}
|
||||
|
||||
connection.clientId = payload.clientId ? String(payload.clientId).trim() : connection.clientId;
|
||||
connection.clientName = payload.clientName ? String(payload.clientName).trim() : connection.clientName;
|
||||
connection.deviceId = payload.deviceId ? normalizeDeviceId(payload.deviceId) || connection.deviceId : connection.deviceId;
|
||||
if (!connection.clientName && connection.clientId) {
|
||||
connection.clientName = connection.clientId;
|
||||
}
|
||||
connection.userAgent = payload.userAgent ? String(payload.userAgent).trim() : connection.userAgent;
|
||||
connection.viewport = payload.viewport && typeof payload.viewport === 'object' ? payload.viewport : connection.viewport;
|
||||
connection.page = payload.page ? String(payload.page).trim() : connection.page;
|
||||
const nextPlayerPublicBaseUrl = normalizePlayerPublicBaseUrl(payload.page);
|
||||
if (nextPlayerPublicBaseUrl && nextPlayerPublicBaseUrl !== connection.playerPublicBaseUrl) {
|
||||
connection.playerPublicBaseUrl = nextPlayerPublicBaseUrl;
|
||||
}
|
||||
connection.paused = Boolean(payload.paused);
|
||||
connection.blackout = Boolean(payload.blackout);
|
||||
connection.clientIp = payload.clientIp ? normalizeClientIp(payload.clientIp) || connection.clientIp : connection.clientIp;
|
||||
connection.currentSlide = payload.currentSlide && typeof payload.currentSlide === 'object' ? {
|
||||
id: payload.currentSlide.id || null,
|
||||
title: payload.currentSlide.title || '',
|
||||
kind: payload.currentSlide.kind || '',
|
||||
playlistSignature: payload.currentSlide.playlistSignature || ''
|
||||
} : connection.currentSlide;
|
||||
connection.label = buildClientLabel(connection);
|
||||
connection.lastSeenAt = new Date();
|
||||
broadcastConnectionSnapshot(slug);
|
||||
});
|
||||
|
||||
socket.on('close', function () {
|
||||
removeConnection(slug, connectionId);
|
||||
broadcastConnectionSnapshot(slug);
|
||||
});
|
||||
|
||||
socket.on('error', function () {
|
||||
removeConnection(slug, connectionId);
|
||||
broadcastConnectionSnapshot(slug);
|
||||
});
|
||||
});
|
||||
|
||||
function installWebsocket(server) {
|
||||
server.on('upgrade', handleUpgrade);
|
||||
}
|
||||
|
||||
return {
|
||||
installWebsocket: installWebsocket,
|
||||
broadcastAnnouncementRefresh: broadcastAnnouncementRefresh,
|
||||
snapshotConnections: snapshotConnections,
|
||||
snapshotAllConnections: snapshotAllConnections,
|
||||
isClientNameAvailableOnScreen: isClientNameAvailableOnScreen,
|
||||
sendCommandToConnection: sendCommandToConnection,
|
||||
broadcastCommand: broadcastCommand
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createPlayerRuntime: createPlayerRuntime
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
// Thumbnail preview data and bootstrap script helpers for slide thumbnails.
|
||||
|
||||
function buildThumbnailPreviewData(slide) {
|
||||
return {
|
||||
thumbnailPreview: true,
|
||||
screen: {
|
||||
id: slide.id,
|
||||
name: slide.title,
|
||||
slug: 'slide-thumbnail-preview-' + slide.id,
|
||||
playlist_id: null
|
||||
},
|
||||
playlist: {
|
||||
fade_between_slides: false
|
||||
},
|
||||
slides: [slide],
|
||||
rssFeeds: [],
|
||||
apiSources: [],
|
||||
timetableGroups: [],
|
||||
revision: String(slide.modified_at || slide.id || Date.now())
|
||||
};
|
||||
}
|
||||
|
||||
function createThumbnailPreviewBootstrapScript(initialData) {
|
||||
return initialData && initialData.thumbnailPreview ? '<script>window.__pulseThumbnailPreview = true;</script>' : '';
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildThumbnailPreviewData: buildThumbnailPreviewData,
|
||||
createThumbnailPreviewBootstrapScript: createThumbnailPreviewBootstrapScript
|
||||
};
|
||||
+316
@@ -0,0 +1,316 @@
|
||||
// Permission section definitions and normalization helpers for RBAC.
|
||||
|
||||
const PERMISSION_SECTIONS = [
|
||||
{
|
||||
key: 'dashboard',
|
||||
order: 10,
|
||||
name: 'Dashboard',
|
||||
sectionName: 'Main navigation',
|
||||
actions: [
|
||||
{ key: 'read', name: 'Read', description: 'Access the dashboard overview.' },
|
||||
{ key: 'allow', name: 'Allow', description: 'Send global player commands.' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'clients',
|
||||
order: 20,
|
||||
name: 'Connected clients',
|
||||
sectionName: 'Main navigation',
|
||||
actions: [
|
||||
{ key: 'read', name: 'Read', description: 'View connected player clients and live status.' },
|
||||
{ key: 'allow', name: 'Allow', description: 'Use the connected client command buttons.' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'screens',
|
||||
order: 30,
|
||||
name: 'Screens',
|
||||
sectionName: 'Content',
|
||||
actions: [
|
||||
{ key: 'read', name: 'Read', description: 'View the screen list and open screen details.' },
|
||||
{ key: 'create', name: 'Create', description: 'Create new screens.' },
|
||||
{ key: 'update', name: 'Update', description: 'Update screens.' },
|
||||
{ key: 'delete', name: 'Delete', description: 'Delete screens.' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'playlists',
|
||||
order: 40,
|
||||
name: 'Playlists',
|
||||
sectionName: 'Content',
|
||||
actions: [
|
||||
{ key: 'read', name: 'Read', description: 'View playlists and playlist contents.' },
|
||||
{ key: 'create', name: 'Create', description: 'Create new playlists.' },
|
||||
{ key: 'update', name: 'Update', description: 'Update playlists and playlist slides.' },
|
||||
{ key: 'delete', name: 'Delete', description: 'Delete playlists.' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'slides',
|
||||
order: 50,
|
||||
name: 'Slides',
|
||||
sectionName: 'Content',
|
||||
actions: [
|
||||
{ key: 'read', name: 'Read', description: 'View slides.' },
|
||||
{ key: 'create', name: 'Create', description: 'Create new slides.' },
|
||||
{ key: 'update', name: 'Update', description: 'Update slide content.' },
|
||||
{ key: 'delete', name: 'Delete', description: 'Delete slides.' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'templates',
|
||||
order: 60,
|
||||
name: 'Slide templates',
|
||||
sectionName: 'Content',
|
||||
actions: [
|
||||
{ key: 'read', name: 'Read', description: 'View slide templates.' },
|
||||
{ key: 'create', name: 'Create', description: 'Create new slide templates.' },
|
||||
{ key: 'update', name: 'Update', description: 'Update slide templates.' },
|
||||
{ key: 'delete', name: 'Delete', description: 'Delete slide templates.' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'canvas-sizes',
|
||||
order: 70,
|
||||
name: 'Canvas sizes',
|
||||
sectionName: 'Content',
|
||||
actions: [
|
||||
{ key: 'read', name: 'Read', description: 'View canvas sizes.' },
|
||||
{ key: 'create', name: 'Create', description: 'Create new canvas sizes.' },
|
||||
{ key: 'update', name: 'Update', description: 'Update canvas sizes.' },
|
||||
{ key: 'delete', name: 'Delete', description: 'Delete canvas sizes.' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'announcements',
|
||||
order: 80,
|
||||
name: 'Announcements',
|
||||
sectionName: 'Content',
|
||||
actions: [
|
||||
{ key: 'read', name: 'Read', description: 'View announcements.' },
|
||||
{ key: 'create', name: 'Create', description: 'Create new announcements.' },
|
||||
{ key: 'update', name: 'Update', description: 'Update announcements.' },
|
||||
{ key: 'delete', name: 'Delete', description: 'Delete announcements.' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'rss-feeds',
|
||||
order: 90,
|
||||
name: 'RSS feeds',
|
||||
sectionName: 'Data Sources',
|
||||
actions: [
|
||||
{ key: 'read', name: 'Read', description: 'View configured RSS feeds.' },
|
||||
{ key: 'create', name: 'Create', description: 'Create new RSS feeds.' },
|
||||
{ key: 'update', name: 'Update', description: 'Update RSS feeds.' },
|
||||
{ key: 'delete', name: 'Delete', description: 'Delete RSS feeds.' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'api-sources',
|
||||
order: 100,
|
||||
name: 'API sources',
|
||||
sectionName: 'Data Sources',
|
||||
actions: [
|
||||
{ key: 'read', name: 'Read', description: 'View configured API sources.' },
|
||||
{ key: 'create', name: 'Create', description: 'Create new API sources.' },
|
||||
{ key: 'update', name: 'Update', description: 'Update API sources.' },
|
||||
{ key: 'delete', name: 'Delete', description: 'Delete API sources.' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'timetables',
|
||||
order: 110,
|
||||
name: 'Timetables',
|
||||
sectionName: 'Data Sources',
|
||||
actions: [
|
||||
{ key: 'read', name: 'Read', description: 'View configured timetable groups.' },
|
||||
{ key: 'create', name: 'Create', description: 'Create new timetable groups.' },
|
||||
{ key: 'update', name: 'Update', description: 'Update timetable groups and entries.' },
|
||||
{ key: 'delete', name: 'Delete', description: 'Delete timetable groups.' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'users',
|
||||
order: 120,
|
||||
name: 'Users',
|
||||
sectionName: 'Settings',
|
||||
actions: [
|
||||
{ key: 'read', name: 'Read', description: 'View users and role assignments.' },
|
||||
{ key: 'create', name: 'Create', description: 'Create new users.' },
|
||||
{ key: 'update', name: 'Update', description: 'Update users, passwords, and role assignments.' },
|
||||
{ key: 'delete', name: 'Delete', description: 'Delete users.' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'rbac',
|
||||
order: 130,
|
||||
name: 'Roles and permissions',
|
||||
sectionName: 'Settings',
|
||||
actions: [
|
||||
{ key: 'read', name: 'Read', description: 'View roles and permissions.' },
|
||||
{ key: 'create', name: 'Create', description: 'Create new roles.' },
|
||||
{ key: 'update', name: 'Update', description: 'Update role details and permissions.' },
|
||||
{ key: 'delete', name: 'Delete', description: 'Delete roles.' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'fonts',
|
||||
order: 140,
|
||||
name: 'Fonts',
|
||||
sectionName: 'Settings',
|
||||
actions: [
|
||||
{ key: 'read', name: 'Read', description: 'View managed fonts.' },
|
||||
{ key: 'create', name: 'Create', description: 'Upload managed fonts.' },
|
||||
{ key: 'delete', name: 'Delete', description: 'Remove managed fonts.' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'background-tasks',
|
||||
order: 150,
|
||||
name: 'Task Queue',
|
||||
sectionName: 'Settings',
|
||||
actions: [
|
||||
{ key: 'read', name: 'Read', description: 'View queued background tasks.' },
|
||||
{ key: 'allow', name: 'Allow', description: 'Manage queued background tasks and clear finished items.' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'scheduled-tasks',
|
||||
order: 160,
|
||||
name: 'Scheduled tasks',
|
||||
sectionName: 'Settings',
|
||||
actions: [
|
||||
{ key: 'read', name: 'Read', description: 'View scheduled refresh tasks.' },
|
||||
{ key: 'allow', name: 'Allow', description: 'Run scheduled refreshes manually.' }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
const PERMISSIONS = PERMISSION_SECTIONS.flatMap(function (section) {
|
||||
return section.actions.map(function (action) {
|
||||
return {
|
||||
key: `${section.key}.${action.key}`,
|
||||
name: section.name,
|
||||
sectionOrder: section.order,
|
||||
actionName: action.name,
|
||||
sectionName: section.sectionName,
|
||||
sectionKey: section.key,
|
||||
actionKey: action.key,
|
||||
description: action.description
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const DEFAULT_ROLE = {
|
||||
key: 'administrators',
|
||||
name: 'Administrators',
|
||||
description: 'Full access to the admin interface.'
|
||||
};
|
||||
|
||||
function normalizePermissionKey(permissionKey) {
|
||||
return String(permissionKey || '').trim();
|
||||
}
|
||||
|
||||
const PERMISSION_DENIED_MESSAGE = 'You do not have permissions to do this.';
|
||||
|
||||
function normalizePermissionKeys(permissionKeys) {
|
||||
const normalized = [];
|
||||
|
||||
(Array.isArray(permissionKeys) ? permissionKeys : []).forEach(function (permissionKey) {
|
||||
const normalizedPermissionKey = normalizePermissionKey(permissionKey);
|
||||
if (!normalizedPermissionKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const parts = normalizedPermissionKey.split('.');
|
||||
if (parts.length !== 2) {
|
||||
normalized.push(normalizedPermissionKey);
|
||||
return;
|
||||
}
|
||||
|
||||
const sectionKey = parts[0];
|
||||
const actionKey = parts[1];
|
||||
normalized.push(`${sectionKey}.${actionKey}`);
|
||||
|
||||
if (actionKey === 'create' || actionKey === 'update' || actionKey === 'delete') {
|
||||
normalized.push(`${sectionKey}.read`);
|
||||
}
|
||||
if (actionKey === 'manage') {
|
||||
normalized.push(`${sectionKey}.read`);
|
||||
normalized.push(`${sectionKey}.create`);
|
||||
normalized.push(`${sectionKey}.update`);
|
||||
normalized.push(`${sectionKey}.delete`);
|
||||
}
|
||||
if (actionKey === 'view') {
|
||||
normalized.push(`${sectionKey}.read`);
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(new Set(normalized));
|
||||
}
|
||||
|
||||
function hasPermission(currentUser, permissionKey) {
|
||||
const normalizedPermissionKey = normalizePermissionKey(permissionKey);
|
||||
if (!normalizedPermissionKey || !currentUser) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const permissionKeys = Array.isArray(currentUser.permissionKeys)
|
||||
? currentUser.permissionKeys
|
||||
: Array.isArray(currentUser.permissions)
|
||||
? currentUser.permissions
|
||||
: [];
|
||||
|
||||
return normalizePermissionKeys(permissionKeys).includes(normalizedPermissionKey);
|
||||
}
|
||||
|
||||
function hasAnyPermission(currentUser, permissionKeys) {
|
||||
const normalizedPermissionKeys = normalizePermissionKeys(permissionKeys);
|
||||
if (!normalizedPermissionKeys.length || !currentUser) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return normalizedPermissionKeys.some(function (permissionKey) {
|
||||
return hasPermission(currentUser, permissionKey);
|
||||
});
|
||||
}
|
||||
|
||||
function requirePermission(permissionKey, options) {
|
||||
const normalizedPermissionKey = normalizePermissionKey(permissionKey);
|
||||
if (!normalizedPermissionKey) {
|
||||
throw new Error('requirePermission requires a permission key.');
|
||||
}
|
||||
|
||||
const setAuthMessageCookie = options && options.setAuthMessageCookie;
|
||||
|
||||
return function (req, res, next) {
|
||||
if (!req.currentUser) {
|
||||
if (typeof setAuthMessageCookie === 'function') {
|
||||
setAuthMessageCookie(res, 'Please sign in to continue.');
|
||||
return res.redirect('/login');
|
||||
}
|
||||
|
||||
return res.redirect('/login');
|
||||
}
|
||||
|
||||
if (hasPermission(req.currentUser, normalizedPermissionKey)) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const error = new Error(PERMISSION_DENIED_MESSAGE);
|
||||
error.statusCode = 403;
|
||||
error.expose = true;
|
||||
next(error);
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
PERMISSIONS,
|
||||
PERMISSION_SECTIONS,
|
||||
DEFAULT_ROLE,
|
||||
hasPermission,
|
||||
hasAnyPermission,
|
||||
requirePermission,
|
||||
normalizePermissionKeys,
|
||||
PERMISSION_DENIED_MESSAGE
|
||||
};
|
||||
@@ -0,0 +1,429 @@
|
||||
// Page and request authentication signing helpers.
|
||||
|
||||
const crypto = require('crypto');
|
||||
|
||||
const PAGE_TOKEN_HEADER = 'x-pulse-page-auth';
|
||||
const REQUEST_TIMESTAMP_HEADER = 'x-pulse-request-timestamp';
|
||||
const REQUEST_SIGNATURE_HEADER = 'x-pulse-request-signature';
|
||||
const PAGE_TOKEN_TTL_MS = 12 * 60 * 60 * 1000;
|
||||
const REQUEST_AUTH_MAX_SKEW_MS = 5 * 60 * 1000;
|
||||
|
||||
function getSharedSecret() {
|
||||
return String(process.env.PULSE_SIGNAGE_SHARED_SECRET || process.env.PLAYER_API_SHARED_SECRET || process.env.PLAYER_SHARED_SECRET || '').trim();
|
||||
}
|
||||
|
||||
function toBase64Url(value) {
|
||||
return Buffer.from(String(value || ''), 'utf8').toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
|
||||
}
|
||||
|
||||
function fromBase64Url(value) {
|
||||
const normalized = String(value || '').replace(/-/g, '+').replace(/_/g, '/');
|
||||
const padded = normalized + '='.repeat((4 - (normalized.length % 4)) % 4);
|
||||
return Buffer.from(padded, 'base64').toString('utf8');
|
||||
}
|
||||
|
||||
function canonicalize(value) {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(canonicalize);
|
||||
}
|
||||
|
||||
if (Buffer.isBuffer(value)) {
|
||||
return value.toString('base64');
|
||||
}
|
||||
|
||||
if (value && typeof value === 'object') {
|
||||
if (value instanceof Date) {
|
||||
return value.toISOString();
|
||||
}
|
||||
|
||||
const canonical = {};
|
||||
Object.keys(value).sort().forEach(function (key) {
|
||||
const normalizedValue = canonicalize(value[key]);
|
||||
if (normalizedValue !== undefined) {
|
||||
canonical[key] = normalizedValue;
|
||||
}
|
||||
});
|
||||
return canonical;
|
||||
}
|
||||
|
||||
return value === undefined ? undefined : value;
|
||||
}
|
||||
|
||||
function normalizeRequestPath(pathValue) {
|
||||
const value = String(pathValue || '').trim();
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
return decodeURIComponent(value);
|
||||
} catch (_error) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function stableJson(value) {
|
||||
return JSON.stringify(canonicalize(value));
|
||||
}
|
||||
|
||||
function hashPayload(value) {
|
||||
const normalized = Buffer.isBuffer(value) ? value : Buffer.from(stableJson(value === undefined ? null : value) || '', 'utf8');
|
||||
return crypto.createHash('sha256').update(normalized).digest('hex');
|
||||
}
|
||||
|
||||
function normalizeRequestAuthBody(req) {
|
||||
if (!req) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const body = req.body;
|
||||
if (body === undefined || body === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Buffer.isBuffer(body)) {
|
||||
return body;
|
||||
}
|
||||
|
||||
if (typeof body === 'object' && !Array.isArray(body)) {
|
||||
const hasFields = Object.keys(body).length > 0;
|
||||
if (!hasFields) {
|
||||
const contentLength = Number(String(req.headers && req.headers['content-length'] || '').trim() || 0);
|
||||
const contentType = String(req.headers && req.headers['content-type'] || '').trim();
|
||||
if (!contentLength || !contentType) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
function getRequestPath(req) {
|
||||
const explicitPath = String(req && req.path ? req.path : '').trim();
|
||||
if (explicitPath) {
|
||||
return normalizeRequestPath(explicitPath);
|
||||
}
|
||||
|
||||
const rawUrl = String(req && req.url ? req.url : '').trim();
|
||||
if (!rawUrl) {
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
return normalizeRequestPath(new URL(rawUrl, 'http://localhost').pathname);
|
||||
} catch (_error) {
|
||||
return normalizeRequestPath(rawUrl.split('?')[0] || '');
|
||||
}
|
||||
}
|
||||
|
||||
function signText(secret, text) {
|
||||
return crypto.createHmac('sha256', String(secret || '')).update(String(text || ''), 'utf8').digest('hex');
|
||||
}
|
||||
|
||||
function timingSafeEqualHex(expectedHex, actualHex) {
|
||||
const expected = Buffer.from(String(expectedHex || ''), 'hex');
|
||||
const actual = Buffer.from(String(actualHex || ''), 'hex');
|
||||
return expected.length === actual.length && expected.length > 0 && crypto.timingSafeEqual(expected, actual);
|
||||
}
|
||||
|
||||
function buildPageAuthPayload(payload) {
|
||||
const issuedAt = Date.now();
|
||||
const normalizedPayload = canonicalize(payload || {});
|
||||
return Object.assign({}, normalizedPayload, {
|
||||
issuedAt: issuedAt,
|
||||
expiresAt: issuedAt + PAGE_TOKEN_TTL_MS
|
||||
});
|
||||
}
|
||||
|
||||
function createPageAuthBundle(payload) {
|
||||
const secret = getSharedSecret();
|
||||
if (!secret) {
|
||||
return {
|
||||
token: '',
|
||||
issuedAt: null,
|
||||
expiresAt: null
|
||||
};
|
||||
}
|
||||
|
||||
const payloadWithExpiry = buildPageAuthPayload(payload);
|
||||
const encodedPayload = toBase64Url(stableJson(payloadWithExpiry));
|
||||
const signature = signText(secret, `page\n${encodedPayload}`);
|
||||
return {
|
||||
token: `${encodedPayload}.${signature}`,
|
||||
issuedAt: payloadWithExpiry.issuedAt,
|
||||
expiresAt: payloadWithExpiry.expiresAt
|
||||
};
|
||||
}
|
||||
|
||||
function createPageAuthToken(payload) {
|
||||
return createPageAuthBundle(payload).token;
|
||||
}
|
||||
|
||||
function verifyPageAuthToken(token) {
|
||||
const secret = getSharedSecret();
|
||||
if (!secret) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedToken = String(token || '').trim();
|
||||
if (!normalizedToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const separatorIndex = normalizedToken.lastIndexOf('.');
|
||||
if (separatorIndex <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const payloadPart = normalizedToken.slice(0, separatorIndex);
|
||||
const signaturePart = normalizedToken.slice(separatorIndex + 1);
|
||||
const expectedSignature = signText(secret, `page\n${payloadPart}`);
|
||||
if (!timingSafeEqualHex(expectedSignature, signaturePart)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(fromBase64Url(payloadPart));
|
||||
const now = Date.now();
|
||||
const issuedAt = Number(payload && payload.issuedAt);
|
||||
const expiresAt = Number(payload && payload.expiresAt);
|
||||
if (!Number.isFinite(issuedAt) || !Number.isFinite(expiresAt)) {
|
||||
return null;
|
||||
}
|
||||
if (issuedAt > now + REQUEST_AUTH_MAX_SKEW_MS) {
|
||||
return null;
|
||||
}
|
||||
if (expiresAt <= now) {
|
||||
return null;
|
||||
}
|
||||
return payload;
|
||||
} catch (_error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function createRequestAuthHeaders(options) {
|
||||
const secret = getSharedSecret();
|
||||
if (!secret) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const method = String(options && options.method || 'GET').trim().toUpperCase();
|
||||
const pathname = normalizeRequestPath(options && options.pathname || '');
|
||||
const timestamp = String(options && options.timestamp || Date.now()).trim();
|
||||
const bodyDigest = hashPayload(options && Object.prototype.hasOwnProperty.call(options, 'body') ? options.body : null);
|
||||
const signature = signText(secret, `request\n${method}\n${pathname}\n${timestamp}\n${bodyDigest}`);
|
||||
return {
|
||||
[REQUEST_TIMESTAMP_HEADER]: timestamp,
|
||||
[REQUEST_SIGNATURE_HEADER]: signature
|
||||
};
|
||||
}
|
||||
|
||||
function verifyRequestAuth(req) {
|
||||
const secret = getSharedSecret();
|
||||
if (!secret) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const timestamp = String(req && req.headers ? req.headers[REQUEST_TIMESTAMP_HEADER] || '' : '').trim();
|
||||
const signature = String(req && req.headers ? req.headers[REQUEST_SIGNATURE_HEADER] || '' : '').trim();
|
||||
if (!timestamp || !signature) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const parsedTimestamp = Number(timestamp);
|
||||
if (!Number.isFinite(parsedTimestamp)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
if (Math.abs(now - parsedTimestamp) > REQUEST_AUTH_MAX_SKEW_MS) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const expectedSignature = signText(secret, `request\n${String(req.method || 'GET').trim().toUpperCase()}\n${getRequestPath(req)}\n${timestamp}\n${hashPayload(normalizeRequestAuthBody(req))}`);
|
||||
return timingSafeEqualHex(expectedSignature, signature);
|
||||
}
|
||||
|
||||
function createPageFetchAuthScript(token, cookiePath) {
|
||||
const normalizedToken = String(token && typeof token === 'object' ? token.token : token || '').trim();
|
||||
if (!normalizedToken) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const normalizedCookiePath = String(cookiePath || '').trim();
|
||||
const pageAuthExpiresAt = token && typeof token === 'object' && Number.isFinite(Number(token.expiresAt))
|
||||
? Number(token.expiresAt)
|
||||
: null;
|
||||
const renewSkewMs = REQUEST_AUTH_MAX_SKEW_MS;
|
||||
|
||||
return [
|
||||
'<script>',
|
||||
' (function () {',
|
||||
' var pageAuthToken = ' + JSON.stringify(normalizedToken) + ';',
|
||||
' var pageAuthExpiresAt = ' + JSON.stringify(pageAuthExpiresAt) + ';',
|
||||
' var pageAuthCookiePath = ' + JSON.stringify(normalizedCookiePath) + ';',
|
||||
' var pageAuthCookieName = "pulse_page_auth";',
|
||||
' var pageAuthRenewalTimer = null;',
|
||||
' var pageAuthRenewalInFlight = null;',
|
||||
' var pageAuthRenewalSkewMs = ' + JSON.stringify(renewSkewMs) + ';',
|
||||
' var originalFetch = window.fetch && window.fetch.bind(window);',
|
||||
' if (!originalFetch) {',
|
||||
' return;',
|
||||
' }',
|
||||
' function schedulePageAuthRenewal() {',
|
||||
' if (pageAuthRenewalTimer) {',
|
||||
' clearTimeout(pageAuthRenewalTimer);',
|
||||
' pageAuthRenewalTimer = null;',
|
||||
' }',
|
||||
' if (!pageAuthToken || !pageAuthExpiresAt) {',
|
||||
' return;',
|
||||
' }',
|
||||
' var delayMs = pageAuthExpiresAt - Date.now() - pageAuthRenewalSkewMs;',
|
||||
' if (!Number.isFinite(delayMs) || delayMs < 1000) {',
|
||||
' delayMs = 1000;',
|
||||
' }',
|
||||
' pageAuthRenewalTimer = setTimeout(function () {',
|
||||
' renewPageAuthToken().catch(function () {',
|
||||
' return null;',
|
||||
' });',
|
||||
' }, delayMs);',
|
||||
' }',
|
||||
' function writePageAuthCookie(nextToken, nextExpiresAt) {',
|
||||
' if (!pageAuthCookiePath) {',
|
||||
' return;',
|
||||
' }',
|
||||
' var cookieParts = [pageAuthCookieName + "=" + encodeURIComponent(String(nextToken || "").trim()), "Path=" + pageAuthCookiePath, "SameSite=Lax"];',
|
||||
' var expiresInMs = Number(nextExpiresAt || 0) - Date.now();',
|
||||
' if (Number.isFinite(expiresInMs) && expiresInMs > 0) {',
|
||||
' cookieParts.push("Max-Age=" + Math.max(1, Math.floor(expiresInMs / 1000)));',
|
||||
' } else {',
|
||||
' cookieParts.push("Max-Age=0");',
|
||||
' }',
|
||||
' if (window.location.protocol === "https:") {',
|
||||
' cookieParts.push("Secure");',
|
||||
' }',
|
||||
' document.cookie = cookieParts.join("; ");',
|
||||
' }',
|
||||
' function setPageAuthToken(nextToken, nextExpiresAt) {',
|
||||
' pageAuthToken = String(nextToken || "").trim();',
|
||||
' pageAuthExpiresAt = Number(nextExpiresAt || 0) || null;',
|
||||
' window.__pulsePageAuthToken = pageAuthToken;',
|
||||
' window.__pulsePageAuthExpiresAt = pageAuthExpiresAt;',
|
||||
' writePageAuthCookie(pageAuthToken, pageAuthExpiresAt);',
|
||||
' schedulePageAuthRenewal();',
|
||||
' }',
|
||||
' async function renewPageAuthToken() {',
|
||||
' if (!pageAuthToken || pageAuthRenewalInFlight) {',
|
||||
' return pageAuthToken;',
|
||||
' }',
|
||||
' pageAuthRenewalInFlight = originalFetch("/api/auth/page", {',
|
||||
' method: "POST",',
|
||||
' headers: new Headers({',
|
||||
' "Accept": "application/json",',
|
||||
' ' + JSON.stringify(PAGE_TOKEN_HEADER) + ': pageAuthToken',
|
||||
' })',
|
||||
' }).then(function (response) {',
|
||||
' if (!response.ok) {',
|
||||
' return null;',
|
||||
' }',
|
||||
' return response.json().catch(function () {',
|
||||
' return null;',
|
||||
' });',
|
||||
' }).then(function (payload) {',
|
||||
' if (!payload || !payload.token) {',
|
||||
' return null;',
|
||||
' }',
|
||||
' setPageAuthToken(payload.token, payload.expiresAt);',
|
||||
' return pageAuthToken;',
|
||||
' }).finally(function () {',
|
||||
' pageAuthRenewalInFlight = null;',
|
||||
' });',
|
||||
' return pageAuthRenewalInFlight;',
|
||||
' }',
|
||||
' window.__pulseSetPageAuthToken = setPageAuthToken;',
|
||||
' window.__pulseRenewPageAuthToken = renewPageAuthToken;',
|
||||
' window.__pulsePageAuthToken = pageAuthToken;',
|
||||
' window.__pulsePageAuthExpiresAt = pageAuthExpiresAt;',
|
||||
' writePageAuthCookie(pageAuthToken, pageAuthExpiresAt);',
|
||||
' window.addEventListener("focus", function () {',
|
||||
' schedulePageAuthRenewal();',
|
||||
' });',
|
||||
' document.addEventListener("visibilitychange", function () {',
|
||||
' if (document.visibilityState === "visible") {',
|
||||
' schedulePageAuthRenewal();',
|
||||
' }',
|
||||
' });',
|
||||
' window.fetch = function (input, init) {',
|
||||
' try {',
|
||||
' var requestUrl = input instanceof Request ? new URL(input.url, window.location.href) : new URL(String(input), window.location.href);',
|
||||
' if (requestUrl.origin === window.location.origin && requestUrl.pathname.indexOf("/api/") === 0) {',
|
||||
' var isRenewalRequest = requestUrl.pathname === "/api/auth/page";',
|
||||
' var requestInit = init ? Object.assign({}, init) : {};',
|
||||
' var headers = input instanceof Request ? new Headers(input.headers) : new Headers(requestInit.headers || {});',
|
||||
' headers.set(' + JSON.stringify(PAGE_TOKEN_HEADER) + ', pageAuthToken);',
|
||||
' if (input instanceof Request) {',
|
||||
' requestInit = { headers: headers };',
|
||||
' var firstResponse = originalFetch(new Request(input, requestInit));',
|
||||
' return firstResponse.then(function (response) {',
|
||||
' if (response.status !== 401 || isRenewalRequest || !pageAuthToken) {',
|
||||
' return response;',
|
||||
' }',
|
||||
' return renewPageAuthToken().then(function (nextToken) {',
|
||||
' if (!nextToken) {',
|
||||
' return response;',
|
||||
' }',
|
||||
' var retryHeaders = new Headers(input.headers);',
|
||||
' retryHeaders.set(' + JSON.stringify(PAGE_TOKEN_HEADER) + ', nextToken);',
|
||||
' return originalFetch(new Request(input, { headers: retryHeaders }));',
|
||||
' }).catch(function () {',
|
||||
' return response;',
|
||||
' });',
|
||||
' });',
|
||||
' }',
|
||||
' requestInit.headers = headers;',
|
||||
' var firstRequest = originalFetch(input, requestInit);',
|
||||
' return firstRequest.then(function (response) {',
|
||||
' if (response.status !== 401 || isRenewalRequest || !pageAuthToken) {',
|
||||
' return response;',
|
||||
' }',
|
||||
' return renewPageAuthToken().then(function (nextToken) {',
|
||||
' if (!nextToken) {',
|
||||
' return response;',
|
||||
' }',
|
||||
' var retryRequestInit = init ? Object.assign({}, init) : {};',
|
||||
' var retryHeaders = new Headers(retryRequestInit.headers || {});',
|
||||
' retryHeaders.set(' + JSON.stringify(PAGE_TOKEN_HEADER) + ', nextToken);',
|
||||
' retryRequestInit.headers = retryHeaders;',
|
||||
' return originalFetch(input, retryRequestInit);',
|
||||
' }).catch(function () {',
|
||||
' return response;',
|
||||
' });',
|
||||
' });',
|
||||
' }',
|
||||
' } catch (_error) {',
|
||||
' return originalFetch(input, init);',
|
||||
' }',
|
||||
' return originalFetch(input, init);',
|
||||
' };',
|
||||
' schedulePageAuthRenewal();',
|
||||
' }());',
|
||||
'</script>'
|
||||
].join('');
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getSharedSecret: getSharedSecret,
|
||||
PAGE_TOKEN_HEADER: PAGE_TOKEN_HEADER,
|
||||
REQUEST_TIMESTAMP_HEADER: REQUEST_TIMESTAMP_HEADER,
|
||||
REQUEST_SIGNATURE_HEADER: REQUEST_SIGNATURE_HEADER,
|
||||
PAGE_TOKEN_TTL_MS: PAGE_TOKEN_TTL_MS,
|
||||
REQUEST_AUTH_MAX_SKEW_MS: REQUEST_AUTH_MAX_SKEW_MS,
|
||||
createPageAuthToken: createPageAuthToken,
|
||||
createPageAuthBundle: createPageAuthBundle,
|
||||
verifyPageAuthToken: verifyPageAuthToken,
|
||||
createRequestAuthHeaders: createRequestAuthHeaders,
|
||||
verifyRequestAuth: verifyRequestAuth,
|
||||
createPageFetchAuthScript: createPageFetchAuthScript
|
||||
};
|
||||
+188
-2256
File diff suppressed because it is too large
Load Diff
Vendored
+277
@@ -0,0 +1,277 @@
|
||||
// Web-to-player websocket bridge and dashboard state synchronizer.
|
||||
|
||||
const { WebSocketServer, WebSocket } = require('ws');
|
||||
const { createDashboardStateService } = require('./lib/dashboard-state');
|
||||
const { createUploadSyncService } = require('./lib/media');
|
||||
const { createRequestAuthHeaders } = require('#src/request-auth');
|
||||
|
||||
function createWebBootstrap(options) {
|
||||
const pool = options && options.pool;
|
||||
const common = options && options.common;
|
||||
const configuredPlayerInternalBaseUrl = String(options && options.playerInternalBaseUrl || '').replace(/\/$/, '');
|
||||
const playerPublicBaseUrl = String(options && options.playerPublicBaseUrl || '').replace(/\/$/, '');
|
||||
const uploadDir = String(options && options.uploadDir || '').trim();
|
||||
const dashboardRefreshIntervalMs = 5000;
|
||||
const formatDashboardDate = options && options.formatDashboardDate;
|
||||
const notifyPlayerScreens = options && options.notifyPlayerScreens;
|
||||
const backgroundTaskQueue = options && options.backgroundTaskQueue;
|
||||
|
||||
if (!pool || !common || !uploadDir || typeof formatDashboardDate !== 'function' || typeof notifyPlayerScreens !== 'function') {
|
||||
throw new Error('createWebBootstrap requires the web bootstrap dependencies.');
|
||||
}
|
||||
|
||||
const dashboardWs = new WebSocketServer({ noServer: true });
|
||||
const dashboardClients = new Set();
|
||||
const playerSnapshotCache = new Map();
|
||||
const playerSnapshotSockets = new Map();
|
||||
let dashboardRefreshInFlight = null;
|
||||
let broadcastDashboardState = null;
|
||||
let playerInternalBaseUrl = null;
|
||||
let playerInternalBaseUrlPromise = null;
|
||||
|
||||
async function getPlayerInternalBaseUrl() {
|
||||
if (playerInternalBaseUrl) {
|
||||
return playerInternalBaseUrl;
|
||||
}
|
||||
|
||||
if (playerInternalBaseUrlPromise) {
|
||||
return playerInternalBaseUrlPromise;
|
||||
}
|
||||
|
||||
playerInternalBaseUrlPromise = (async function () {
|
||||
if (!pool) {
|
||||
return configuredPlayerInternalBaseUrl || null;
|
||||
}
|
||||
|
||||
try {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT internal_base_url
|
||||
FROM d_players
|
||||
WHERE device_id = '1'
|
||||
LIMIT 1`
|
||||
);
|
||||
const resolvedBaseUrl = String(rows && rows[0] && rows[0].internal_base_url || '').trim().replace(/\/$/, '');
|
||||
return resolvedBaseUrl || configuredPlayerInternalBaseUrl || null;
|
||||
} catch (_error) {
|
||||
return configuredPlayerInternalBaseUrl || null;
|
||||
}
|
||||
})().then(function (baseUrl) {
|
||||
playerInternalBaseUrl = baseUrl || null;
|
||||
playerInternalBaseUrlPromise = null;
|
||||
return playerInternalBaseUrl;
|
||||
}, function () {
|
||||
playerInternalBaseUrlPromise = null;
|
||||
return configuredPlayerInternalBaseUrl || null;
|
||||
});
|
||||
|
||||
return playerInternalBaseUrlPromise;
|
||||
}
|
||||
|
||||
async function getPlayerSnapshotSocketUrl(slug) {
|
||||
const resolvedPlayerInternalBaseUrl = await getPlayerInternalBaseUrl();
|
||||
if (!resolvedPlayerInternalBaseUrl) {
|
||||
throw new Error('Unable to resolve the player internal base URL.');
|
||||
}
|
||||
|
||||
const url = new URL(resolvedPlayerInternalBaseUrl.replace(/^http/, 'ws'));
|
||||
url.pathname = `/ws/screens/${encodeURIComponent(slug)}/events`;
|
||||
url.search = '';
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function storePlayerSnapshot(slug, connections) {
|
||||
const normalizedSlug = String(slug || '').trim();
|
||||
const normalizedConnections = Array.isArray(connections) ? connections : [];
|
||||
playerSnapshotCache.set(normalizedSlug, {
|
||||
slug: normalizedSlug,
|
||||
count: normalizedConnections.length,
|
||||
connections: normalizedConnections
|
||||
});
|
||||
}
|
||||
|
||||
function clearPlayerSnapshotSocket(slug) {
|
||||
const key = String(slug || '').trim();
|
||||
playerSnapshotSockets.delete(key);
|
||||
}
|
||||
|
||||
function ensurePlayerSnapshotSubscription(slug) {
|
||||
const key = String(slug || '').trim();
|
||||
if (!key || playerSnapshotSockets.has(key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
playerSnapshotSockets.set(key, null);
|
||||
const socketUrlPromise = getPlayerSnapshotSocketUrl(key);
|
||||
const authHeaders = createRequestAuthHeaders({
|
||||
method: 'GET',
|
||||
pathname: `/ws/screens/${encodeURIComponent(key)}/events`
|
||||
});
|
||||
socketUrlPromise.then(function (socketUrl) {
|
||||
const socket = new WebSocket(socketUrl, {
|
||||
headers: authHeaders
|
||||
});
|
||||
playerSnapshotSockets.set(key, socket);
|
||||
|
||||
socket.onmessage = function (event) {
|
||||
try {
|
||||
const payload = JSON.parse(String(event.data || '{}'));
|
||||
if (!payload || payload.type !== 'snapshot' || payload.slug !== key) {
|
||||
return;
|
||||
}
|
||||
storePlayerSnapshot(key, payload.connections || []);
|
||||
if (broadcastDashboardState) {
|
||||
broadcastDashboardState().catch(function (error) {
|
||||
console.error(error);
|
||||
});
|
||||
}
|
||||
} catch (_error) {
|
||||
// Ignore malformed player snapshot payloads.
|
||||
}
|
||||
};
|
||||
|
||||
socket.onclose = function () {
|
||||
clearPlayerSnapshotSocket(key);
|
||||
setTimeout(function () {
|
||||
ensurePlayerSnapshotSubscription(key);
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
socket.onerror = function () {
|
||||
try {
|
||||
socket.close();
|
||||
} catch (_error) {
|
||||
// ignore close errors
|
||||
}
|
||||
};
|
||||
}).catch(function (error) {
|
||||
clearPlayerSnapshotSocket(key);
|
||||
console.error(error);
|
||||
});
|
||||
}
|
||||
|
||||
const dashboardStateService = createDashboardStateService({
|
||||
pool: pool,
|
||||
common: common,
|
||||
playerSnapshotCache: playerSnapshotCache,
|
||||
playerSnapshotSockets: playerSnapshotSockets,
|
||||
ensurePlayerSnapshotSubscription: ensurePlayerSnapshotSubscription,
|
||||
playerPublicBaseUrl: playerPublicBaseUrl,
|
||||
formatDashboardDate: formatDashboardDate
|
||||
});
|
||||
const buildDashboardState = dashboardStateService.buildDashboardState;
|
||||
|
||||
const uploadSyncService = createUploadSyncService({
|
||||
pool: pool,
|
||||
common: common,
|
||||
playerInternalBaseUrl: configuredPlayerInternalBaseUrl,
|
||||
playerSnapshotCache: playerSnapshotCache,
|
||||
notifyPlayerScreens: notifyPlayerScreens,
|
||||
backgroundTaskQueue: backgroundTaskQueue
|
||||
});
|
||||
|
||||
const upload = uploadSyncService.createUploadMiddleware(uploadDir);
|
||||
const collectUploadReferencesFromSlide = uploadSyncService.collectUploadReferencesFromSlide;
|
||||
const collectUploadReferencesFromTemplate = uploadSyncService.collectUploadReferencesFromTemplate;
|
||||
const collectUploadReferencesFromPayload = uploadSyncService.collectUploadReferencesFromPayload;
|
||||
const removeUnusedUploadFiles = uploadSyncService.removeUnusedUploadFiles;
|
||||
const collectUploadPathsFromDirectory = uploadSyncService.collectUploadPathsFromDirectory;
|
||||
const syncPlaylistUploadsOnChange = uploadSyncService.syncPlaylistUploadsOnChange;
|
||||
const runMediaSyncTask = uploadSyncService.runMediaSyncTask;
|
||||
|
||||
async function sendDashboardStateToSocket(socket) {
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
const state = await buildDashboardState();
|
||||
socket.send(JSON.stringify({ type: 'dashboard-state', state: state }));
|
||||
}
|
||||
|
||||
broadcastDashboardState = async function () {
|
||||
if (dashboardRefreshInFlight) {
|
||||
return dashboardRefreshInFlight;
|
||||
}
|
||||
|
||||
dashboardRefreshInFlight = (async function () {
|
||||
const state = await buildDashboardState();
|
||||
const payload = JSON.stringify({ type: 'dashboard-state', state: state });
|
||||
for (const socket of dashboardClients) {
|
||||
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(payload);
|
||||
}
|
||||
}
|
||||
return state;
|
||||
})().finally(function () {
|
||||
dashboardRefreshInFlight = null;
|
||||
});
|
||||
|
||||
return dashboardRefreshInFlight;
|
||||
};
|
||||
|
||||
function installDashboardWebsocket(server, loadCurrentUser) {
|
||||
server.on('upgrade', async function (request, socket, head) {
|
||||
let pathname = '';
|
||||
try {
|
||||
pathname = new URL(request.url, 'http://localhost').pathname;
|
||||
} catch (_error) {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname !== '/ws/dashboard') {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const currentUser = await loadCurrentUser(pool, request);
|
||||
if (!currentUser) {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
} catch (_error) {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
dashboardWs.handleUpgrade(request, socket, head, function (ws) {
|
||||
dashboardWs.emit('connection', ws, request);
|
||||
});
|
||||
});
|
||||
|
||||
dashboardWs.on('connection', function (socket) {
|
||||
dashboardClients.add(socket);
|
||||
sendDashboardStateToSocket(socket);
|
||||
|
||||
socket.on('close', function () {
|
||||
dashboardClients.delete(socket);
|
||||
});
|
||||
|
||||
socket.on('error', function () {
|
||||
dashboardClients.delete(socket);
|
||||
});
|
||||
});
|
||||
|
||||
setInterval(function () {
|
||||
broadcastDashboardState().catch(function (error) {
|
||||
console.error(error);
|
||||
});
|
||||
}, dashboardRefreshIntervalMs);
|
||||
}
|
||||
|
||||
return {
|
||||
upload: upload,
|
||||
uploadSyncService: uploadSyncService,
|
||||
buildDashboardState: buildDashboardState,
|
||||
collectUploadReferencesFromSlide: collectUploadReferencesFromSlide,
|
||||
collectUploadReferencesFromTemplate: collectUploadReferencesFromTemplate,
|
||||
collectUploadReferencesFromPayload: collectUploadReferencesFromPayload,
|
||||
removeUnusedUploadFiles: removeUnusedUploadFiles,
|
||||
collectUploadPathsFromDirectory: collectUploadPathsFromDirectory,
|
||||
syncPlaylistUploadsOnChange: syncPlaylistUploadsOnChange,
|
||||
runMediaSyncTask: runMediaSyncTask,
|
||||
broadcastDashboardState: broadcastDashboardState,
|
||||
installDashboardWebsocket: installDashboardWebsocket
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createWebBootstrap };
|
||||
@@ -0,0 +1,188 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// General-purpose helpers used across the admin UI.
|
||||
function registerGeneralHelpers(Handlebars) {
|
||||
Handlebars.registerHelper('eq', function (left, right) {
|
||||
return left === right;
|
||||
});
|
||||
|
||||
Handlebars.registerHelper('gt', function (left, right) {
|
||||
return left > right;
|
||||
});
|
||||
|
||||
Handlebars.registerHelper('lt', function (left, right) {
|
||||
return left < right;
|
||||
});
|
||||
|
||||
Handlebars.registerHelper('json', function (value) {
|
||||
return new Handlebars.SafeString(JSON.stringify(value).replace(/</g, '\\u003c'));
|
||||
});
|
||||
|
||||
Handlebars.registerHelper('truncateText', function (value, maxLength) {
|
||||
const text = String(value || '').trim();
|
||||
const limit = Number(maxLength);
|
||||
if (!text || !Number.isFinite(limit) || limit <= 0 || text.length <= limit) {
|
||||
return text;
|
||||
}
|
||||
|
||||
return `${text.slice(0, Math.max(0, limit - 1)).trimEnd()}…`;
|
||||
});
|
||||
}
|
||||
|
||||
// URL helpers for generating links and external URL checks.
|
||||
function registerUrlHelpers(Handlebars) {
|
||||
|
||||
Handlebars.registerHelper('isExternalUrl', function (value) {
|
||||
return /^https?:\/\//i.test(String(value || ''));
|
||||
});
|
||||
|
||||
Handlebars.registerHelper('assetHref', function (value) {
|
||||
const href = String(value || '').trim();
|
||||
if (!href) {
|
||||
return '';
|
||||
}
|
||||
if (/^https?:\/\//i.test(href) || href.startsWith('/')) {
|
||||
return href;
|
||||
}
|
||||
return `/assets/${href}`;
|
||||
});
|
||||
|
||||
Handlebars.registerHelper('playerUrl', function (slug) {
|
||||
const base = (process.env.PLAYER_PUBLIC_BASE_URL || process.env.PLAYER_BASE_URL || 'http://localhost:3001').replace(/\/$/, '');
|
||||
return `${base}/screen/${encodeURIComponent(slug)}`;
|
||||
});
|
||||
}
|
||||
|
||||
// Permission helpers delegate to the RBAC layer.
|
||||
function registerPermissionHelpers(Handlebars, deps) {
|
||||
const hasPermission = deps && deps.hasPermission;
|
||||
const hasAnyPermission = deps && deps.hasAnyPermission;
|
||||
|
||||
Handlebars.registerHelper('hasPermission', function (currentUser, permissionKey) {
|
||||
return hasPermission(currentUser, permissionKey);
|
||||
});
|
||||
|
||||
Handlebars.registerHelper('anyPermission', function (currentUser) {
|
||||
const args = Array.prototype.slice.call(arguments, 1);
|
||||
args.pop();
|
||||
return hasAnyPermission(currentUser, args);
|
||||
});
|
||||
}
|
||||
|
||||
function registerUserHelpers(Handlebars) {
|
||||
Handlebars.registerHelper('userInitial', function (name) {
|
||||
const text = String(name || '').trim();
|
||||
return text ? text.charAt(0).toUpperCase() : 'A';
|
||||
});
|
||||
}
|
||||
|
||||
// Action button helpers keep form submit controls consistent.
|
||||
function registerActionHelpers(Handlebars) {
|
||||
Handlebars.registerHelper('saveActionButtons', function (options) {
|
||||
const hash = options && options.hash ? options.hash : {};
|
||||
const formId = String(hash.formId || '').trim();
|
||||
|
||||
const save = {
|
||||
label: String(hash.saveLabel || 'Save'),
|
||||
className: String(hash.saveButtonClass || 'btn btn-success'),
|
||||
actionName: String(hash.saveActionName || 'save_action'),
|
||||
actionValue: String(hash.saveActionValue || 'save'),
|
||||
closeLabel: String(hash.saveAndCloseLabel || 'Save and Close'),
|
||||
closeValue: String(hash.saveAndCloseValue || 'close'),
|
||||
newLabel: String(hash.saveAndNewLabel || 'Save and New'),
|
||||
newValue: String(hash.saveAndNewValue || 'new')
|
||||
};
|
||||
|
||||
const showSaveAndClose = hash.showSaveAndClose === undefined ? true : String(hash.showSaveAndClose).toLowerCase() !== 'false';
|
||||
const showSaveAndNew = hash.showSaveAndNew === undefined ? true : String(hash.showSaveAndNew).toLowerCase() !== 'false';
|
||||
const showDelete = hash.showDelete === undefined ? true : String(hash.showDelete).toLowerCase() !== 'false';
|
||||
const hasSecondaryActions = showSaveAndClose || showSaveAndNew;
|
||||
const ariaLabel = String(hash.ariaLabel || 'Save actions');
|
||||
const saveUrl = String(hash.saveUrl || '').trim();
|
||||
|
||||
const cancel = {
|
||||
label: String(hash.cancelLabel || 'Cancel'),
|
||||
className: String(hash.cancelClass || 'btn btn-warning'),
|
||||
url: String(hash.cancelUrl || '').trim(),
|
||||
confirmMessage: String(hash.cancelConfirmMessage || hash.cancelConfirm || 'You have unsaved changes. Leave this page?')
|
||||
};
|
||||
|
||||
const deleteAction = {
|
||||
label: String(hash.deleteLabel || 'Delete'),
|
||||
className: String(hash.deleteClass || 'btn btn-danger'),
|
||||
url: String(hash.deleteUrl || '').trim(),
|
||||
confirmMessage: String(hash.deleteConfirmMessage || '').trim(),
|
||||
disabled: hash.deleteDisabled === undefined ? false : String(hash.deleteDisabled).toLowerCase() !== 'false',
|
||||
title: String(hash.deleteTitle || '')
|
||||
};
|
||||
|
||||
const resolvedDeleteUrl = deleteAction.url || (!deleteAction.disabled && saveUrl ? saveUrl.replace(/\/+$/, '') + '/delete' : '');
|
||||
|
||||
if (!formId) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const escape = Handlebars.escapeExpression;
|
||||
const formAttr = ` form="${escape(formId)}"`;
|
||||
|
||||
const parts = [
|
||||
'<div class="btn-group save-action-group" role="group">',
|
||||
`<button type="submit" class="${escape(save.className)}"${formAttr} name="${escape(save.actionName)}" value="${escape(save.actionValue)}">${escape(save.label)}</button>`,
|
||||
hasSecondaryActions ? `<button type="button" class="btn btn-success dropdown-toggle dropdown-toggle-split rounded-0" data-bs-toggle="dropdown" aria-expanded="false"><span class="visually-hidden">${escape(ariaLabel)}</span></button>` : '',
|
||||
hasSecondaryActions ? '<div class="dropdown-menu dropdown-menu-end">' : '',
|
||||
hasSecondaryActions && showSaveAndClose ? `<button type="submit" class="dropdown-item"${formAttr} name="${escape(save.actionName)}" value="${escape(save.closeValue)}">${escape(save.closeLabel)}</button>` : '',
|
||||
hasSecondaryActions && showSaveAndNew ? `<button type="submit" class="dropdown-item"${formAttr} name="${escape(save.actionName)}" value="${escape(save.newValue)}">${escape(save.newLabel)}</button>` : '',
|
||||
hasSecondaryActions ? '</div>' : '',
|
||||
];
|
||||
|
||||
if (cancel.url) {
|
||||
parts.push(`<a class="${escape(cancel.className)}" href="${escape(cancel.url)}" data-confirm-unsaved="${escape(cancel.confirmMessage)}">${escape(cancel.label)}</a>`);
|
||||
}
|
||||
|
||||
if (showDelete && (deleteAction.disabled || resolvedDeleteUrl)) {
|
||||
const deleteActionAttr = resolvedDeleteUrl ? ` data-delete-action-url="${escape(resolvedDeleteUrl)}"` : '';
|
||||
const confirmAttr = deleteAction.confirmMessage ? ` data-confirm-message="${escape(deleteAction.confirmMessage)}"` : '';
|
||||
const titleAttr = deleteAction.disabled && deleteAction.title ? ` title="${escape(deleteAction.title)}"` : '';
|
||||
parts.push(`<button type="button" class="${escape(deleteAction.className)}"${deleteActionAttr}${confirmAttr}${deleteAction.disabled ? ' disabled' : ''}${titleAttr}>${escape(deleteAction.label)}</button>`);
|
||||
}
|
||||
|
||||
parts.push('</div>');
|
||||
|
||||
return new Handlebars.SafeString(parts.join(''));
|
||||
});
|
||||
}
|
||||
|
||||
// Shared partials are registered once at startup.
|
||||
function registerPartials(Handlebars, viewsRoot) {
|
||||
Handlebars.registerPartial('modal-shell', fs.readFileSync(path.join(viewsRoot, 'shared', 'modal-shell.hbs'), 'utf8'));
|
||||
Handlebars.registerPartial('table-pagination', fs.readFileSync(path.join(viewsRoot, 'shared', 'table', 'table-pagination.hbs'), 'utf8'));
|
||||
Handlebars.registerPartial('playlists/form', fs.readFileSync(path.join(viewsRoot, 'signage', 'playlists', 'form.hbs'), 'utf8'));
|
||||
Handlebars.registerPartial('signage/playlists/slide-row', fs.readFileSync(path.join(viewsRoot, 'signage', 'playlists', 'slide-row.hbs'), 'utf8'));
|
||||
Handlebars.registerPartial('signage/playlists/schedule-rule-card', fs.readFileSync(path.join(viewsRoot, 'signage', 'playlists', 'schedule-rule-card.hbs'), 'utf8'));
|
||||
Handlebars.registerPartial('signage/templates/animation-advanced-modal', fs.readFileSync(path.join(viewsRoot, 'signage', 'templates', 'animation-advanced-modal.hbs'), 'utf8'));
|
||||
Handlebars.registerPartial('data-sources/api-sources/form', fs.readFileSync(path.join(viewsRoot, 'data-sources', 'api-sources', 'form.hbs'), 'utf8'));
|
||||
Handlebars.registerPartial('data-sources/rss-feeds/form', fs.readFileSync(path.join(viewsRoot, 'data-sources', 'rss-feeds', 'form.hbs'), 'utf8'));
|
||||
}
|
||||
|
||||
// One entry point keeps Handlebars bootstrap centralized.
|
||||
function registerHandlebars(Handlebars, deps) {
|
||||
const viewsRoot = String(deps && deps.viewsRoot || '').trim();
|
||||
const hasPermission = deps && deps.hasPermission;
|
||||
const hasAnyPermission = deps && deps.hasAnyPermission;
|
||||
|
||||
if (!Handlebars || !viewsRoot || typeof hasPermission !== 'function' || typeof hasAnyPermission !== 'function') {
|
||||
throw new Error('registerHandlebars requires Handlebars, viewsRoot, hasPermission, and hasAnyPermission.');
|
||||
}
|
||||
|
||||
registerGeneralHelpers(Handlebars);
|
||||
registerUrlHelpers(Handlebars);
|
||||
registerPermissionHelpers(Handlebars, { hasPermission: hasPermission, hasAnyPermission: hasAnyPermission });
|
||||
registerUserHelpers(Handlebars);
|
||||
registerActionHelpers(Handlebars);
|
||||
registerPartials(Handlebars, viewsRoot);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
registerHandlebars: registerHandlebars
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
// Entry point for the web auth helper modules.
|
||||
|
||||
module.exports = {
|
||||
createSessionService: require('./session').createSessionService,
|
||||
rbacData: require('./rbac-data')
|
||||
};
|
||||
@@ -0,0 +1,279 @@
|
||||
// RBAC data access helpers for roles, permissions, and user-role links.
|
||||
|
||||
const { PERMISSIONS, normalizePermissionKeys } = require('#src/rbac');
|
||||
const { fetchPagedRows } = require('#src/data/utils');
|
||||
|
||||
function parseCsvIds(value) {
|
||||
return String(value || '')
|
||||
.split(',')
|
||||
.map(function (item) {
|
||||
return Number(item);
|
||||
})
|
||||
.filter(function (item) {
|
||||
return Number.isInteger(item) && item > 0;
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchPermissions(pool) {
|
||||
const [rows] = await pool.query('SELECT id, permission_key, name, section_name, description FROM a_permissions ORDER BY section_name ASC, name ASC');
|
||||
return rows || [];
|
||||
}
|
||||
|
||||
async function fetchRoles(pool) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT r.id, r.role_key, r.name, r.description, r.created_at, r.modified_at,
|
||||
(SELECT COUNT(*) FROM a_user_roles ur WHERE ur.role_id = r.id) AS user_count,
|
||||
(SELECT COUNT(*) FROM a_role_permissions rp WHERE rp.role_id = r.id) AS permission_count
|
||||
FROM a_roles r
|
||||
ORDER BY r.name ASC`
|
||||
);
|
||||
return rows || [];
|
||||
}
|
||||
|
||||
async function fetchRolesPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
|
||||
const paged = await fetchPagedRows(pool, {
|
||||
selectSql: `SELECT r.id, r.role_key, r.name, r.description, r.created_at, r.modified_at,
|
||||
(SELECT COUNT(*) FROM a_user_roles ur WHERE ur.role_id = r.id) AS user_count,
|
||||
(SELECT COUNT(*) FROM a_role_permissions rp WHERE rp.role_id = r.id) AS permission_count
|
||||
FROM a_roles r
|
||||
ORDER BY r.name ASC`,
|
||||
countSql: 'SELECT COUNT(*) AS count FROM a_roles',
|
||||
searchColumns: ['r.role_key', 'r.name', 'r.description'],
|
||||
searchTerm: searchTerm,
|
||||
sortColumns: {
|
||||
role: 'r.name',
|
||||
description: 'r.description',
|
||||
users: 'user_count',
|
||||
permissions: 'permission_count',
|
||||
created: 'r.created_at',
|
||||
modified: 'r.modified_at'
|
||||
},
|
||||
sortKey: sortKey,
|
||||
sortDirection: sortDirection,
|
||||
page: page,
|
||||
pageSize: pageSize
|
||||
});
|
||||
|
||||
return Object.assign({ roles: paged.rows }, paged);
|
||||
}
|
||||
|
||||
async function fetchRoleById(pool, roleId) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT r.id, r.role_key, r.name, r.description, r.created_at, r.modified_at,
|
||||
(SELECT COUNT(*) FROM a_user_roles ur WHERE ur.role_id = r.id) AS user_count,
|
||||
(SELECT COUNT(*) FROM a_role_permissions rp WHERE rp.role_id = r.id) AS permission_count
|
||||
FROM a_roles r
|
||||
WHERE r.id = ?
|
||||
LIMIT 1`,
|
||||
[roleId]
|
||||
);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
async function fetchRolePermissionKeys(pool, roleId) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT p.permission_key
|
||||
FROM a_role_permissions rp
|
||||
JOIN a_permissions p ON p.id = rp.permission_id
|
||||
WHERE rp.role_id = ?
|
||||
ORDER BY p.section_name ASC, p.name ASC`,
|
||||
[roleId]
|
||||
);
|
||||
return (rows || []).map(function (row) {
|
||||
return String(row.permission_key || '').trim();
|
||||
}).filter(Boolean);
|
||||
}
|
||||
|
||||
async function fetchRoleUserIds(pool, roleId) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT ur.user_id
|
||||
FROM a_user_roles ur
|
||||
WHERE ur.role_id = ?
|
||||
ORDER BY ur.user_id ASC`,
|
||||
[roleId]
|
||||
);
|
||||
return (rows || []).map(function (row) {
|
||||
return Number(row.user_id);
|
||||
}).filter(function (userId) {
|
||||
return Number.isInteger(userId) && userId > 0;
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchRolesForUser(pool, userId) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT r.id, r.role_key, r.name, r.description
|
||||
FROM a_user_roles ur
|
||||
JOIN a_roles r ON r.id = ur.role_id
|
||||
WHERE ur.user_id = ?
|
||||
ORDER BY r.name ASC`,
|
||||
[userId]
|
||||
);
|
||||
return rows || [];
|
||||
}
|
||||
|
||||
async function fetchUsersWithRoles(pool) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT u.id, u.name, u.username, u.created_at, u.modified_at,
|
||||
COALESCE(role_data.role_names, '') AS role_names,
|
||||
COALESCE(role_data.role_ids_csv, '') AS role_ids_csv
|
||||
FROM a_users u
|
||||
LEFT JOIN (
|
||||
SELECT ur.user_id,
|
||||
GROUP_CONCAT(DISTINCT r.name ORDER BY r.name SEPARATOR ', ') AS role_names,
|
||||
GROUP_CONCAT(DISTINCT r.id ORDER BY r.name SEPARATOR ',') AS role_ids_csv
|
||||
FROM a_user_roles ur
|
||||
JOIN a_roles r ON r.id = ur.role_id
|
||||
GROUP BY ur.user_id
|
||||
) role_data ON role_data.user_id = u.id
|
||||
ORDER BY u.id ASC`
|
||||
);
|
||||
|
||||
return (rows || []).map(function (row) {
|
||||
return Object.assign({}, row, {
|
||||
roleIds: parseCsvIds(row.role_ids_csv),
|
||||
roleNames: String(row.role_names || '').trim()
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchUsersWithRolesPage(pool, page, pageSize, searchTerm, sortKey, sortDirection, options) {
|
||||
const excludedUserId = Number(options && options.excludeUserId);
|
||||
const hasExcludedUserId = Number.isInteger(excludedUserId) && excludedUserId > 0;
|
||||
const whereSql = hasExcludedUserId ? 'WHERE u.id <> ?' : '';
|
||||
const queryArgs = hasExcludedUserId ? [excludedUserId] : [];
|
||||
const paged = await fetchPagedRows(pool, {
|
||||
selectSql: `SELECT u.id, u.name, u.username, u.created_at, u.modified_at,
|
||||
COALESCE(role_data.role_names, '') AS role_names,
|
||||
COALESCE(role_data.role_ids_csv, '') AS role_ids_csv
|
||||
FROM a_users u
|
||||
LEFT JOIN (
|
||||
SELECT ur.user_id,
|
||||
GROUP_CONCAT(DISTINCT r.name ORDER BY r.name SEPARATOR ', ') AS role_names,
|
||||
GROUP_CONCAT(DISTINCT r.id ORDER BY r.name SEPARATOR ',') AS role_ids_csv
|
||||
FROM a_user_roles ur
|
||||
JOIN a_roles r ON r.id = ur.role_id
|
||||
GROUP BY ur.user_id
|
||||
) role_data ON role_data.user_id = u.id
|
||||
${whereSql}
|
||||
ORDER BY u.id ASC`,
|
||||
countSql: `SELECT COUNT(*) AS count FROM a_users u ${whereSql}`,
|
||||
params: queryArgs,
|
||||
searchColumns: ['u.name', 'u.username', 'role_data.role_names'],
|
||||
searchTerm: searchTerm,
|
||||
sortColumns: {
|
||||
user: 'u.username',
|
||||
name: 'u.name',
|
||||
roles: 'role_names',
|
||||
created: 'u.created_at',
|
||||
modified: 'u.modified_at'
|
||||
},
|
||||
sortKey: sortKey,
|
||||
sortDirection: sortDirection,
|
||||
page: page,
|
||||
pageSize: pageSize
|
||||
});
|
||||
|
||||
return {
|
||||
users: (paged.rows || []).map(function (row) {
|
||||
return Object.assign({}, row, {
|
||||
roleIds: parseCsvIds(row.role_ids_csv),
|
||||
roleNames: String(row.role_names || '').trim()
|
||||
});
|
||||
}),
|
||||
totalItems: paged.totalItems,
|
||||
totalPages: paged.totalPages,
|
||||
currentPage: paged.currentPage,
|
||||
pageSize: paged.pageSize
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchUserWithRoles(pool, userId) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT u.id, u.name, u.username, u.created_at, u.modified_at,
|
||||
COALESCE(role_data.role_names, '') AS role_names,
|
||||
COALESCE(role_data.role_ids_csv, '') AS role_ids_csv
|
||||
FROM a_users u
|
||||
LEFT JOIN (
|
||||
SELECT ur.user_id,
|
||||
GROUP_CONCAT(DISTINCT r.name ORDER BY r.name SEPARATOR ', ') AS role_names,
|
||||
GROUP_CONCAT(DISTINCT r.id ORDER BY r.name SEPARATOR ',') AS role_ids_csv
|
||||
FROM a_user_roles ur
|
||||
JOIN a_roles r ON r.id = ur.role_id
|
||||
GROUP BY ur.user_id
|
||||
) role_data ON role_data.user_id = u.id
|
||||
WHERE u.id = ?
|
||||
LIMIT 1`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
if (!rows.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Object.assign({}, rows[0], {
|
||||
roleIds: parseCsvIds(rows[0].role_ids_csv),
|
||||
roleNames: String(rows[0].role_names || '').trim()
|
||||
});
|
||||
}
|
||||
|
||||
async function syncUserRoles(pool, userId, roleIds) {
|
||||
const uniqueRoleIds = Array.from(new Set((Array.isArray(roleIds) ? roleIds : []).map(function (roleId) {
|
||||
return Number(roleId);
|
||||
}).filter(function (roleId) {
|
||||
return Number.isInteger(roleId) && roleId > 0;
|
||||
})));
|
||||
|
||||
await pool.query('DELETE FROM a_user_roles WHERE user_id = ?', [userId]);
|
||||
for (const roleId of uniqueRoleIds) {
|
||||
await pool.query('INSERT IGNORE INTO a_user_roles (user_id, role_id, created_by, modified_by) VALUES (?, ?, ?, ?)', [userId, roleId, null, null]);
|
||||
}
|
||||
}
|
||||
|
||||
async function syncRoleUsers(pool, roleId, userIds) {
|
||||
const uniqueUserIds = Array.from(new Set((Array.isArray(userIds) ? userIds : []).map(function (userId) {
|
||||
return Number(userId);
|
||||
}).filter(function (userId) {
|
||||
return Number.isInteger(userId) && userId > 0;
|
||||
})));
|
||||
|
||||
await pool.query('DELETE FROM a_user_roles WHERE role_id = ?', [roleId]);
|
||||
for (const userId of uniqueUserIds) {
|
||||
await pool.query('INSERT IGNORE INTO a_user_roles (user_id, role_id, created_by, modified_by) VALUES (?, ?, ?, ?)', [userId, roleId, null, null]);
|
||||
}
|
||||
}
|
||||
|
||||
async function syncRolePermissions(pool, roleId, permissionKeys) {
|
||||
const uniquePermissionKeys = normalizePermissionKeys(permissionKeys);
|
||||
|
||||
if (!uniquePermissionKeys.length) {
|
||||
await pool.query('DELETE FROM a_role_permissions WHERE role_id = ?', [roleId]);
|
||||
return;
|
||||
}
|
||||
|
||||
const [permissionRows] = await pool.query('SELECT id, permission_key FROM a_permissions WHERE permission_key IN (?)', [uniquePermissionKeys]);
|
||||
if (permissionRows.length !== uniquePermissionKeys.length) {
|
||||
throw new Error('One or more selected permissions are invalid.');
|
||||
}
|
||||
|
||||
await pool.query('DELETE FROM a_role_permissions WHERE role_id = ?', [roleId]);
|
||||
for (const permissionRow of permissionRows) {
|
||||
await pool.query('INSERT IGNORE INTO a_role_permissions (role_id, permission_id, created_by, modified_by) VALUES (?, ?, ?, ?)', [roleId, Number(permissionRow.id), null, null]);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
PERMISSIONS,
|
||||
fetchPermissions,
|
||||
fetchRoles,
|
||||
fetchRolesPage,
|
||||
fetchRoleById,
|
||||
fetchRolePermissionKeys,
|
||||
fetchRoleUserIds,
|
||||
fetchRolesForUser,
|
||||
fetchUsersWithRoles,
|
||||
fetchUsersWithRolesPage,
|
||||
fetchUserWithRoles,
|
||||
syncUserRoles,
|
||||
syncRoleUsers,
|
||||
syncRolePermissions
|
||||
};
|
||||
@@ -0,0 +1,161 @@
|
||||
// Session cookie and current-user helpers for the web auth flow.
|
||||
|
||||
const { normalizePermissionKeys } = require('#src/rbac');
|
||||
|
||||
function createSessionService(options) {
|
||||
const sessionCookieName = String(options && options.sessionCookieName || '').trim();
|
||||
const sessionMaxAgeMs = Number(options && options.sessionMaxAgeMs);
|
||||
const hashSessionToken = options && options.hashSessionToken;
|
||||
const createSessionToken = options && options.createSessionToken;
|
||||
const authMessageCookieName = 'pulse_auth_message';
|
||||
|
||||
if (!sessionCookieName || !Number.isFinite(sessionMaxAgeMs) || typeof hashSessionToken !== 'function' || typeof createSessionToken !== 'function') {
|
||||
throw new Error('createSessionService requires the session dependencies.');
|
||||
}
|
||||
|
||||
function parseCookies(cookieHeader) {
|
||||
return String(cookieHeader || '').split(/;\s*/).reduce(function (cookies, pair) {
|
||||
if (!pair) {
|
||||
return cookies;
|
||||
}
|
||||
const separatorIndex = pair.indexOf('=');
|
||||
if (separatorIndex === -1) {
|
||||
return cookies;
|
||||
}
|
||||
const name = decodeURIComponent(pair.slice(0, separatorIndex).trim());
|
||||
const value = decodeURIComponent(pair.slice(separatorIndex + 1).trim());
|
||||
if (name) {
|
||||
cookies[name] = value;
|
||||
}
|
||||
return cookies;
|
||||
}, {});
|
||||
}
|
||||
|
||||
function serializeCookie(name, value, options) {
|
||||
const parts = [`${encodeURIComponent(name)}=${encodeURIComponent(value)}`];
|
||||
if (options && options.maxAge !== undefined) {
|
||||
parts.push(`Max-Age=${Math.max(0, Math.trunc(Number(options.maxAge) / 1000))}`);
|
||||
}
|
||||
parts.push('Path=/');
|
||||
parts.push('HttpOnly');
|
||||
parts.push('SameSite=Lax');
|
||||
return parts.join('; ');
|
||||
}
|
||||
|
||||
function appendCookieHeader(res, cookieValue) {
|
||||
const existingCookieHeader = res.getHeader('Set-Cookie');
|
||||
if (!existingCookieHeader) {
|
||||
res.setHeader('Set-Cookie', cookieValue);
|
||||
return;
|
||||
}
|
||||
|
||||
const cookies = Array.isArray(existingCookieHeader) ? existingCookieHeader.slice() : [existingCookieHeader];
|
||||
cookies.push(cookieValue);
|
||||
res.setHeader('Set-Cookie', cookies);
|
||||
}
|
||||
|
||||
function clearSessionCookie(res) {
|
||||
appendCookieHeader(res, serializeCookie(sessionCookieName, '', { maxAge: 0 }));
|
||||
}
|
||||
|
||||
function setSessionCookie(res, token) {
|
||||
appendCookieHeader(res, serializeCookie(sessionCookieName, token, { maxAge: sessionMaxAgeMs }));
|
||||
}
|
||||
|
||||
function setAuthMessageCookie(res, message) {
|
||||
appendCookieHeader(res, serializeCookie(authMessageCookieName, message, { maxAge: 60 * 1000 }));
|
||||
}
|
||||
|
||||
function consumeAuthMessageCookie(req, res) {
|
||||
const cookies = parseCookies(req.headers.cookie || '');
|
||||
const message = String(cookies[authMessageCookieName] || '').trim();
|
||||
if (message) {
|
||||
appendCookieHeader(res, serializeCookie(authMessageCookieName, '', { maxAge: 0 }));
|
||||
}
|
||||
return message;
|
||||
}
|
||||
|
||||
async function loadCurrentUser(pool, req) {
|
||||
const cookies = parseCookies(req.headers.cookie || '');
|
||||
const token = cookies[sessionCookieName];
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tokenHash = hashSessionToken(token);
|
||||
const [rows] = await pool.query(
|
||||
`SELECT s.user_id, u.id, u.name, u.username
|
||||
FROM a_sessions s
|
||||
JOIN a_users u ON u.id = s.user_id
|
||||
WHERE s.session_hash = ?
|
||||
AND s.expires_at > NOW()
|
||||
LIMIT 1`,
|
||||
[tokenHash]
|
||||
);
|
||||
if (!rows.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const userId = Number(rows[0].id);
|
||||
const [roleRows] = await pool.query(
|
||||
`SELECT r.role_key
|
||||
FROM a_user_roles ur
|
||||
JOIN a_roles r ON r.id = ur.role_id
|
||||
WHERE ur.user_id = ?
|
||||
ORDER BY r.name ASC`,
|
||||
[userId]
|
||||
);
|
||||
const [permissionRows] = await pool.query(
|
||||
`SELECT p.permission_key
|
||||
FROM a_user_roles ur
|
||||
JOIN a_role_permissions rp ON rp.role_id = ur.role_id
|
||||
JOIN a_permissions p ON p.id = rp.permission_id
|
||||
WHERE ur.user_id = ?
|
||||
ORDER BY p.section_name ASC, p.name ASC`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
await pool.query('UPDATE a_sessions SET last_used_at = CURRENT_TIMESTAMP, modified_by = ? WHERE session_hash = ?', [rows[0].user_id, tokenHash]);
|
||||
return Object.assign({}, rows[0], {
|
||||
roleKeys: roleRows.map(function (row) {
|
||||
return String(row.role_key || '').trim();
|
||||
}).filter(Boolean),
|
||||
permissionKeys: normalizePermissionKeys(permissionRows.map(function (row) {
|
||||
return String(row.permission_key || '').trim();
|
||||
}))
|
||||
});
|
||||
}
|
||||
|
||||
async function createUserSession(pool, userId) {
|
||||
const token = createSessionToken();
|
||||
const tokenHash = hashSessionToken(token);
|
||||
const expiresAt = new Date(Date.now() + sessionMaxAgeMs);
|
||||
await pool.query(
|
||||
'INSERT INTO a_sessions (session_hash, user_id, expires_at, created_by, modified_by) VALUES (?, ?, ?, ?, ?)',
|
||||
[tokenHash, userId, expiresAt, userId, userId]
|
||||
);
|
||||
return token;
|
||||
}
|
||||
|
||||
function requireAuth(req, res, next) {
|
||||
if (req.currentUser) {
|
||||
return next();
|
||||
}
|
||||
setAuthMessageCookie(res, 'Please sign in to continue.');
|
||||
res.redirect('/login');
|
||||
}
|
||||
|
||||
return {
|
||||
parseCookies: parseCookies,
|
||||
serializeCookie: serializeCookie,
|
||||
clearSessionCookie: clearSessionCookie,
|
||||
setSessionCookie: setSessionCookie,
|
||||
setAuthMessageCookie: setAuthMessageCookie,
|
||||
consumeAuthMessageCookie: consumeAuthMessageCookie,
|
||||
loadCurrentUser: loadCurrentUser,
|
||||
createUserSession: createUserSession,
|
||||
requireAuth: requireAuth
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createSessionService };
|
||||
@@ -0,0 +1,99 @@
|
||||
// Shared loader for task folders under background-tasks.
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
function getTaskExport(taskModule) {
|
||||
return typeof taskModule === 'function'
|
||||
? taskModule
|
||||
: taskModule && typeof taskModule === 'object'
|
||||
? Object.values(taskModule).find(function (value) {
|
||||
return typeof value === 'function';
|
||||
})
|
||||
: null;
|
||||
}
|
||||
|
||||
function loadTaskModules(directory, options) {
|
||||
const taskFiles = fs.readdirSync(directory)
|
||||
.filter(function (fileName) {
|
||||
return fileName.toLowerCase().endsWith('.js') && fileName !== 'index.js';
|
||||
})
|
||||
.sort(function (left, right) {
|
||||
return left.localeCompare(right);
|
||||
});
|
||||
|
||||
return Promise.all(taskFiles.map(function (fileName) {
|
||||
const taskModule = require(path.join(directory, fileName));
|
||||
const exported = getTaskExport(taskModule);
|
||||
|
||||
if (typeof exported === 'function') {
|
||||
return exported(options);
|
||||
}
|
||||
return null;
|
||||
}));
|
||||
}
|
||||
|
||||
// Register ad hoc task handlers that execute queued background work.
|
||||
function registerAdhocTasks(options) {
|
||||
const backgroundTaskDirectory = path.join(__dirname, 'tasks-adhoc');
|
||||
if (!options || !options.pool || !options.common || !options.backgroundTaskQueue || !options.captureSlideThumbnail || !options.uploadSyncService || !String(options.mediaDir || '').trim()) {
|
||||
throw new Error('registerAdhocTasks requires the background task dependencies.');
|
||||
}
|
||||
|
||||
loadTaskModules(backgroundTaskDirectory, options);
|
||||
}
|
||||
|
||||
// Register one-time startup work that should run during boot only.
|
||||
function registerStartupTasks(options) {
|
||||
const backgroundTaskDirectory = path.join(__dirname, 'tasks-startup');
|
||||
|
||||
if (!options || !options.pool || !options.common || !options.backgroundTaskQueue || !String(options.mediaDir || '').trim()) {
|
||||
throw new Error('registerStartupTasks requires the startup task dependencies.');
|
||||
}
|
||||
|
||||
async function initialize() {
|
||||
await loadTaskModules(backgroundTaskDirectory, options);
|
||||
}
|
||||
|
||||
return {
|
||||
initialize: initialize
|
||||
};
|
||||
}
|
||||
|
||||
// Register recurring maintenance and data-source refresh tasks.
|
||||
function registerScheduledTasks(options) {
|
||||
const backgroundTaskDirectory = path.join(__dirname, 'tasks-scheduled');
|
||||
|
||||
if (!options || !options.pool || !options.common || !options.backgroundTaskQueue || !options.uploadSyncService || !String(options.mediaDir || '').trim()) {
|
||||
throw new Error('registerScheduledTasks requires the background task dependencies.');
|
||||
}
|
||||
|
||||
async function initialize() {
|
||||
await loadTaskModules(backgroundTaskDirectory, options);
|
||||
}
|
||||
|
||||
return {
|
||||
initialize: initialize
|
||||
};
|
||||
}
|
||||
|
||||
async function initializeBackgroundTasks(options) {
|
||||
registerAdhocTasks(options);
|
||||
|
||||
const backgroundTaskStartup = registerStartupTasks(options);
|
||||
if (backgroundTaskStartup && typeof backgroundTaskStartup.initialize === 'function') {
|
||||
await backgroundTaskStartup.initialize();
|
||||
}
|
||||
|
||||
const backgroundTaskScheduling = registerScheduledTasks(options);
|
||||
if (backgroundTaskScheduling && typeof backgroundTaskScheduling.initialize === 'function') {
|
||||
await backgroundTaskScheduling.initialize();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
registerAdhocTasks: registerAdhocTasks,
|
||||
registerStartupTasks: registerStartupTasks,
|
||||
registerScheduledTasks: registerScheduledTasks,
|
||||
initializeBackgroundTasks: initializeBackgroundTasks
|
||||
};
|
||||
@@ -0,0 +1,719 @@
|
||||
// Database-backed background task queue.
|
||||
|
||||
function normalizeText(value) {
|
||||
return String(value || '').trim();
|
||||
}
|
||||
|
||||
function toIsoDate(value) {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? '' : date.toISOString();
|
||||
}
|
||||
|
||||
function normalizeIntervalMs(value, unit) {
|
||||
const numericValue = Math.max(1, Number(value) || 0);
|
||||
const normalizedUnit = String(unit || 'minutes').trim().toLowerCase();
|
||||
if (normalizedUnit === 'seconds') {
|
||||
return numericValue * 1000;
|
||||
}
|
||||
return numericValue * 60 * 1000;
|
||||
}
|
||||
|
||||
function createBackgroundTaskQueue(options) {
|
||||
const pool = options && options.pool;
|
||||
const maxConcurrent = Math.max(1, Number(options && options.maxConcurrent) || 1);
|
||||
const taskHandlers = new Map();
|
||||
const recurringJobsByKey = new Map();
|
||||
let activeCount = 0;
|
||||
let drainScheduled = false;
|
||||
let initializationPromise = null;
|
||||
|
||||
if (!pool) {
|
||||
throw new Error('createBackgroundTaskQueue requires a database pool.');
|
||||
}
|
||||
|
||||
function buildSnapshot(task) {
|
||||
return {
|
||||
id: Number(task && task.id) || 0,
|
||||
key: String(task && task.key || '').trim(),
|
||||
taskType: String(task && task.taskType || '').trim(),
|
||||
title: String(task && task.title || 'Background task').trim() || 'Background task',
|
||||
category: String(task && task.category || 'general').trim() || 'general',
|
||||
status: String(task && task.status || 'queued').trim() || 'queued',
|
||||
createdAt: String(task && task.createdAt || ''),
|
||||
startedAt: String(task && task.startedAt || ''),
|
||||
finishedAt: String(task && task.finishedAt || ''),
|
||||
errorMessage: String(task && task.errorMessage || ''),
|
||||
attempts: Math.max(0, Number(task && task.attempts) || 0),
|
||||
metadata: task && task.metadata ? task.metadata : {},
|
||||
payload: task && task.payload !== undefined ? task.payload : null
|
||||
};
|
||||
}
|
||||
|
||||
function parseJsonValue(value, fallback) {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
if (typeof value === 'object') {
|
||||
return value;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(String(value));
|
||||
} catch (_error) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function stringifyJsonValue(value) {
|
||||
if (value === undefined || value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function hydrateTaskRow(row) {
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: Number(row.id),
|
||||
key: String(row.task_key || '').trim(),
|
||||
taskType: String(row.task_type || '').trim(),
|
||||
title: String(row.title || 'Background task').trim() || 'Background task',
|
||||
category: String(row.category || 'general').trim() || 'general',
|
||||
status: String(row.status || 'queued').trim() || 'queued',
|
||||
createdAt: row.created_at ? new Date(row.created_at).toISOString() : '',
|
||||
startedAt: row.started_at ? new Date(row.started_at).toISOString() : '',
|
||||
finishedAt: row.finished_at ? new Date(row.finished_at).toISOString() : '',
|
||||
errorMessage: String(row.error_message || ''),
|
||||
attempts: Math.max(0, Number(row.attempts) || 0),
|
||||
metadata: parseJsonValue(row.metadata_json, {}),
|
||||
payload: parseJsonValue(row.payload_json, null)
|
||||
};
|
||||
}
|
||||
|
||||
function buildTaskRecord(task) {
|
||||
return {
|
||||
task_key: task.key || null,
|
||||
task_type: task.taskType || null,
|
||||
title: task.title,
|
||||
category: task.category || 'general',
|
||||
status: task.status,
|
||||
payload_json: stringifyJsonValue(task.payload),
|
||||
metadata_json: stringifyJsonValue(task.metadata),
|
||||
attempts: Math.max(0, Number(task.attempts) || 0),
|
||||
created_at: task.createdAt ? new Date(task.createdAt) : new Date(),
|
||||
started_at: task.startedAt ? new Date(task.startedAt) : null,
|
||||
finished_at: task.finishedAt ? new Date(task.finishedAt) : null,
|
||||
error_message: task.errorMessage || null
|
||||
};
|
||||
}
|
||||
|
||||
async function persistTaskInsert(task) {
|
||||
const normalizedTaskType = normalizeText(task && task.taskType);
|
||||
if (!normalizedTaskType) {
|
||||
throw new Error('Background tasks require a task type.');
|
||||
}
|
||||
|
||||
const record = buildTaskRecord(Object.assign({}, task, {
|
||||
taskType: normalizedTaskType
|
||||
}));
|
||||
const [result] = await pool.query(
|
||||
`INSERT INTO o_background_tasks (
|
||||
task_key,
|
||||
task_type,
|
||||
title,
|
||||
category,
|
||||
status,
|
||||
payload_json,
|
||||
metadata_json,
|
||||
attempts,
|
||||
created_at,
|
||||
started_at,
|
||||
finished_at,
|
||||
error_message
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
record.task_key,
|
||||
record.task_type,
|
||||
record.title,
|
||||
record.category,
|
||||
record.status,
|
||||
record.payload_json,
|
||||
record.metadata_json,
|
||||
record.attempts,
|
||||
record.created_at,
|
||||
record.started_at,
|
||||
record.finished_at,
|
||||
record.error_message
|
||||
]
|
||||
);
|
||||
|
||||
task.id = Number(result.insertId);
|
||||
return task;
|
||||
}
|
||||
|
||||
async function persistTaskUpdate(task) {
|
||||
if (!task || !Number.isInteger(Number(task.id)) || Number(task.id) <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const record = buildTaskRecord(task);
|
||||
await pool.query(
|
||||
`UPDATE o_background_tasks
|
||||
SET task_key = ?, task_type = ?, title = ?, category = ?, status = ?, payload_json = ?, metadata_json = ?, attempts = ?, started_at = ?, finished_at = ?, error_message = ?
|
||||
WHERE id = ?`,
|
||||
[
|
||||
record.task_key,
|
||||
record.task_type,
|
||||
record.title,
|
||||
record.category,
|
||||
record.status,
|
||||
record.payload_json,
|
||||
record.metadata_json,
|
||||
record.attempts,
|
||||
record.started_at,
|
||||
record.finished_at,
|
||||
record.error_message,
|
||||
Number(task.id)
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
async function persistTaskDelete(taskId) {
|
||||
const numericTaskId = Number(taskId);
|
||||
if (!Number.isFinite(numericTaskId) || numericTaskId <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await pool.query('DELETE FROM o_background_tasks WHERE id = ?', [numericTaskId]);
|
||||
}
|
||||
|
||||
async function fetchTaskById(taskId) {
|
||||
const numericTaskId = Number(taskId);
|
||||
if (!Number.isFinite(numericTaskId) || numericTaskId <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [rows] = await pool.query(
|
||||
`SELECT id, task_key, task_type, title, category, status, payload_json, metadata_json, attempts, created_at, started_at, finished_at, error_message
|
||||
FROM o_background_tasks
|
||||
WHERE id = ?
|
||||
LIMIT 1`,
|
||||
[numericTaskId]
|
||||
);
|
||||
|
||||
return hydrateTaskRow(rows && rows[0]) || null;
|
||||
}
|
||||
|
||||
async function fetchQueuedTaskCount() {
|
||||
const [rows] = await pool.query(`SELECT COUNT(*) AS count FROM o_background_tasks WHERE status = 'queued'`);
|
||||
return Number(rows && rows[0] && rows[0].count) || 0;
|
||||
}
|
||||
|
||||
async function claimNextTask() {
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
await connection.beginTransaction();
|
||||
const [rows] = await connection.query(
|
||||
`SELECT id, task_key, task_type, title, category, status, payload_json, metadata_json, attempts, created_at, started_at, finished_at, error_message
|
||||
FROM o_background_tasks
|
||||
WHERE status = 'queued'
|
||||
ORDER BY created_at ASC, id ASC
|
||||
LIMIT 1
|
||||
FOR UPDATE SKIP LOCKED`
|
||||
);
|
||||
|
||||
const row = rows && rows[0] ? rows[0] : null;
|
||||
if (!row) {
|
||||
await connection.commit();
|
||||
return null;
|
||||
}
|
||||
|
||||
const nextAttempts = Math.max(0, Number(row.attempts) || 0) + 1;
|
||||
await connection.query(
|
||||
`UPDATE o_background_tasks
|
||||
SET status = 'running', started_at = NOW(), attempts = ?, error_message = NULL
|
||||
WHERE id = ?`,
|
||||
[nextAttempts, row.id]
|
||||
);
|
||||
await connection.commit();
|
||||
|
||||
return hydrateTaskRow(Object.assign({}, row, {
|
||||
status: 'running',
|
||||
started_at: new Date(),
|
||||
attempts: nextAttempts,
|
||||
error_message: null
|
||||
}));
|
||||
} catch (error) {
|
||||
try {
|
||||
await connection.rollback();
|
||||
} catch (_rollbackError) {
|
||||
// Ignore rollback failures and surface the original error.
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
}
|
||||
|
||||
async function syncRecurringTaskState(task, status, errorMessage) {
|
||||
const recurringKey = String(task && task.metadata && task.metadata.recurringKey || '').trim();
|
||||
if (!recurringKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const job = recurringJobsByKey.get(recurringKey);
|
||||
if (!job) {
|
||||
return;
|
||||
}
|
||||
|
||||
job.lastRunAt = toIsoDate(new Date());
|
||||
job.lastStatus = String(status || '');
|
||||
job.lastError = errorMessage ? String(errorMessage) : '';
|
||||
}
|
||||
|
||||
function setTaskHandler(taskType, handler) {
|
||||
const normalizedTaskType = normalizeText(taskType);
|
||||
if (!normalizedTaskType || typeof handler !== 'function') {
|
||||
return false;
|
||||
}
|
||||
|
||||
taskHandlers.set(normalizedTaskType, handler);
|
||||
return true;
|
||||
}
|
||||
|
||||
setTaskHandler('recurring-run', async function (task) {
|
||||
const recurringKey = String(task && task.metadata && task.metadata.recurringKey || '').trim();
|
||||
if (!recurringKey) {
|
||||
throw new Error('Recurring task key is required.');
|
||||
}
|
||||
|
||||
const job = recurringJobsByKey.get(recurringKey);
|
||||
if (!job || typeof job.run !== 'function') {
|
||||
throw new Error('Recurring task not found.');
|
||||
}
|
||||
|
||||
return job.run(task);
|
||||
});
|
||||
|
||||
function scheduleDrain() {
|
||||
if (drainScheduled) {
|
||||
return;
|
||||
}
|
||||
|
||||
drainScheduled = true;
|
||||
setTimeout(function () {
|
||||
drainScheduled = false;
|
||||
processQueue().catch(function (error) {
|
||||
console.error(error);
|
||||
});
|
||||
}, 0);
|
||||
}
|
||||
|
||||
async function initialize() {
|
||||
if (initializationPromise) {
|
||||
return initializationPromise;
|
||||
}
|
||||
|
||||
initializationPromise = (async function () {
|
||||
await pool.query(
|
||||
`UPDATE o_background_tasks
|
||||
SET status = 'queued', started_at = NULL, finished_at = NULL, error_message = NULL
|
||||
WHERE status = 'running'`
|
||||
);
|
||||
|
||||
if (await fetchQueuedTaskCount() > 0) {
|
||||
scheduleDrain();
|
||||
}
|
||||
})();
|
||||
|
||||
return initializationPromise;
|
||||
}
|
||||
|
||||
async function processQueue() {
|
||||
while (activeCount < maxConcurrent) {
|
||||
const task = await claimNextTask();
|
||||
if (!task) {
|
||||
break;
|
||||
}
|
||||
|
||||
activeCount += 1;
|
||||
|
||||
(async function () {
|
||||
try {
|
||||
const handler = taskHandlers.get(task.taskType);
|
||||
if (!handler) {
|
||||
throw new Error('No handler registered for task type ' + task.taskType + '.');
|
||||
}
|
||||
|
||||
await handler(task);
|
||||
task.status = 'completed';
|
||||
task.finishedAt = toIsoDate(new Date());
|
||||
task.errorMessage = '';
|
||||
await persistTaskUpdate(task);
|
||||
await syncRecurringTaskState(task, task.status, '');
|
||||
} catch (error) {
|
||||
task.status = 'failed';
|
||||
task.errorMessage = String(error && error.message ? error.message : 'Background task failed.');
|
||||
task.finishedAt = toIsoDate(new Date());
|
||||
try {
|
||||
await persistTaskUpdate(task);
|
||||
} catch (persistError) {
|
||||
console.error(persistError);
|
||||
}
|
||||
await syncRecurringTaskState(task, task.status, task.errorMessage);
|
||||
} finally {
|
||||
activeCount = Math.max(0, activeCount - 1);
|
||||
scheduleDrain();
|
||||
}
|
||||
})().catch(function (error) {
|
||||
console.error(error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function enqueueTask(definition) {
|
||||
const normalizedKey = normalizeText(definition && definition.key);
|
||||
const normalizedTitle = normalizeText(definition && definition.title) || 'Background task';
|
||||
const normalizedTaskType = normalizeText(definition && definition.taskType);
|
||||
if (!normalizedTaskType) {
|
||||
throw new Error('Background tasks require a task type.');
|
||||
}
|
||||
|
||||
const task = {
|
||||
id: 0,
|
||||
key: normalizedKey,
|
||||
taskType: normalizedTaskType,
|
||||
title: normalizedTitle,
|
||||
category: normalizeText(definition && definition.category) || 'general',
|
||||
status: 'queued',
|
||||
createdAt: toIsoDate(new Date()),
|
||||
startedAt: '',
|
||||
finishedAt: '',
|
||||
errorMessage: '',
|
||||
attempts: 0,
|
||||
metadata: definition && definition.metadata ? definition.metadata : {},
|
||||
payload: definition && definition.payload !== undefined ? definition.payload : null
|
||||
};
|
||||
|
||||
await persistTaskInsert(task);
|
||||
scheduleDrain();
|
||||
return buildSnapshot(task);
|
||||
}
|
||||
|
||||
async function enqueueTaskAndWait(definition) {
|
||||
const snapshot = await enqueueTask(definition);
|
||||
const taskId = snapshot && snapshot.id ? snapshot.id : null;
|
||||
if (!taskId) {
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
while (true) {
|
||||
const currentTask = await fetchTaskById(taskId);
|
||||
if (!currentTask) {
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
if (currentTask.status !== 'queued' && currentTask.status !== 'running') {
|
||||
return buildSnapshot(currentTask);
|
||||
}
|
||||
|
||||
await new Promise(function (resolve) {
|
||||
setTimeout(resolve, 250);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function clearRecurringTimer(job) {
|
||||
if (job && job.timerId) {
|
||||
clearTimeout(job.timerId);
|
||||
job.timerId = null;
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleRecurringRun(job, delayMs) {
|
||||
if (!job || job.enabled === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearRecurringTimer(job);
|
||||
const safeDelay = Math.max(1, Number(delayMs) || job.intervalMs || 0);
|
||||
job.nextRunAt = toIsoDate(new Date(Date.now() + safeDelay));
|
||||
job.timerId = setTimeout(function () {
|
||||
job.timerId = null;
|
||||
triggerRecurringJob(job.key).catch(function (error) {
|
||||
console.error(error);
|
||||
});
|
||||
}, safeDelay);
|
||||
}
|
||||
|
||||
async function triggerRecurringJob(recurringKey) {
|
||||
const job = recurringJobsByKey.get(recurringKey);
|
||||
if (!job || job.enabled === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await enqueueTask({
|
||||
key: `${job.key}:${Date.now()}`,
|
||||
title: job.title,
|
||||
category: job.category,
|
||||
taskType: 'recurring-run',
|
||||
metadata: Object.assign({}, job.metadata || {}, {
|
||||
recurringKey: job.key,
|
||||
recurringTitle: job.title
|
||||
}),
|
||||
payload: Object.assign({}, job.payload || {}, {
|
||||
recurringKey: job.key
|
||||
})
|
||||
});
|
||||
|
||||
scheduleRecurringRun(job, job.intervalMs);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function runRecurringTask(recurringKey) {
|
||||
const normalizedKey = normalizeText(recurringKey);
|
||||
if (!normalizedKey || !recurringJobsByKey.has(normalizedKey)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return triggerRecurringJob(normalizedKey);
|
||||
}
|
||||
|
||||
function registerRecurringTask(definition) {
|
||||
const normalizedKey = normalizeText(definition && definition.key);
|
||||
if (!normalizedKey) {
|
||||
throw new Error('Recurring tasks require a key.');
|
||||
}
|
||||
|
||||
const intervalMs = Math.max(1000, Number(definition && definition.intervalMs) || 0);
|
||||
if (!Number.isFinite(intervalMs) || intervalMs < 1000) {
|
||||
throw new Error('Recurring tasks require a valid interval.');
|
||||
}
|
||||
|
||||
const job = recurringJobsByKey.get(normalizedKey) || {
|
||||
key: normalizedKey,
|
||||
lastRunAt: '',
|
||||
lastStatus: '',
|
||||
lastError: '',
|
||||
nextRunAt: '',
|
||||
timerId: null,
|
||||
enabled: true
|
||||
};
|
||||
|
||||
clearRecurringTimer(job);
|
||||
job.title = normalizeText(definition && definition.title) || 'Background task';
|
||||
job.category = normalizeText(definition && definition.category) || 'general';
|
||||
job.intervalMs = intervalMs;
|
||||
job.metadata = definition && definition.metadata ? definition.metadata : {};
|
||||
job.payload = definition && definition.payload ? definition.payload : null;
|
||||
job.run = typeof definition.run === 'function' ? definition.run : function () {
|
||||
return Promise.resolve();
|
||||
};
|
||||
job.enabled = definition && definition.enabled === false ? false : true;
|
||||
recurringJobsByKey.set(normalizedKey, job);
|
||||
|
||||
if (job.enabled) {
|
||||
scheduleRecurringRun(job, intervalMs);
|
||||
}
|
||||
|
||||
return buildRecurringSnapshot(job);
|
||||
}
|
||||
|
||||
function removeRecurringTask(recurringKey) {
|
||||
const normalizedKey = normalizeText(recurringKey);
|
||||
const job = recurringJobsByKey.get(normalizedKey);
|
||||
if (!job) {
|
||||
return false;
|
||||
}
|
||||
|
||||
clearRecurringTimer(job);
|
||||
recurringJobsByKey.delete(normalizedKey);
|
||||
return true;
|
||||
}
|
||||
|
||||
function buildRecurringSnapshot(job, state) {
|
||||
const latestState = state || null;
|
||||
const activeTaskId = latestState && (latestState.status === 'queued' || latestState.status === 'running')
|
||||
? latestState.id
|
||||
: null;
|
||||
const lastRunAt = latestState
|
||||
? latestState.finishedAt || latestState.startedAt || latestState.createdAt || ''
|
||||
: job.lastRunAt || '';
|
||||
|
||||
return {
|
||||
key: job.key,
|
||||
title: job.title,
|
||||
category: job.category,
|
||||
intervalMs: job.intervalMs,
|
||||
enabled: job.enabled !== false,
|
||||
activeTaskId: activeTaskId,
|
||||
createdAt: latestState ? latestState.createdAt || '' : job.createdAt || '',
|
||||
nextRunAt: job.nextRunAt || '',
|
||||
lastRunAt: lastRunAt || '',
|
||||
lastStatus: latestState ? latestState.status || '' : job.lastStatus || '',
|
||||
lastError: latestState ? latestState.errorMessage || '' : job.lastError || '',
|
||||
metadata: job.metadata || {}
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchRecurringTaskStates() {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT id, status, created_at, started_at, finished_at, error_message, metadata_json
|
||||
FROM o_background_tasks
|
||||
WHERE JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.recurringKey')) IS NOT NULL
|
||||
ORDER BY created_at DESC, id DESC`
|
||||
);
|
||||
|
||||
const statesByKey = new Map();
|
||||
for (const row of rows || []) {
|
||||
const metadata = parseJsonValue(row.metadata_json, {});
|
||||
const recurringKey = normalizeText(metadata && metadata.recurringKey);
|
||||
if (!recurringKey || statesByKey.has(recurringKey)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
statesByKey.set(recurringKey, {
|
||||
id: Number(row.id),
|
||||
status: String(row.status || ''),
|
||||
createdAt: row.created_at ? new Date(row.created_at).toISOString() : '',
|
||||
startedAt: row.started_at ? new Date(row.started_at).toISOString() : '',
|
||||
finishedAt: row.finished_at ? new Date(row.finished_at).toISOString() : '',
|
||||
errorMessage: String(row.error_message || '')
|
||||
});
|
||||
}
|
||||
|
||||
return statesByKey;
|
||||
}
|
||||
|
||||
async function listTasks() {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT id, task_key, task_type, title, category, status, payload_json, metadata_json, attempts, created_at, started_at, finished_at, error_message
|
||||
FROM o_background_tasks
|
||||
ORDER BY
|
||||
CASE status
|
||||
WHEN 'running' THEN 0
|
||||
WHEN 'queued' THEN 1
|
||||
WHEN 'failed' THEN 2
|
||||
WHEN 'completed' THEN 3
|
||||
WHEN 'canceled' THEN 4
|
||||
ELSE 5
|
||||
END,
|
||||
COALESCE(started_at, created_at) DESC,
|
||||
id DESC`
|
||||
);
|
||||
|
||||
return (rows || []).map(hydrateTaskRow);
|
||||
}
|
||||
|
||||
async function listRecurringTasks() {
|
||||
const statesByKey = await fetchRecurringTaskStates();
|
||||
return Array.from(recurringJobsByKey.values())
|
||||
.slice()
|
||||
.sort(function (left, right) {
|
||||
return left.key.localeCompare(right.key);
|
||||
})
|
||||
.map(function (job) {
|
||||
return buildRecurringSnapshot(job, statesByKey.get(job.key) || null);
|
||||
});
|
||||
}
|
||||
|
||||
async function clearFinishedTasks() {
|
||||
const [result] = await pool.query(
|
||||
`DELETE FROM o_background_tasks
|
||||
WHERE status IN ('completed', 'failed', 'canceled')`
|
||||
);
|
||||
return Number(result && result.affectedRows) || 0;
|
||||
}
|
||||
|
||||
async function cancelTask(taskId) {
|
||||
const numericTaskId = Number(taskId);
|
||||
if (!Number.isFinite(numericTaskId) || numericTaskId <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const [result] = await pool.query(
|
||||
`UPDATE o_background_tasks
|
||||
SET status = 'canceled',
|
||||
finished_at = NOW(),
|
||||
error_message = 'Task canceled.'
|
||||
WHERE id = ?
|
||||
AND status = 'queued'`,
|
||||
[numericTaskId]
|
||||
);
|
||||
return Number(result && result.affectedRows) > 0;
|
||||
}
|
||||
|
||||
async function retryTask(taskId) {
|
||||
const task = await fetchTaskById(taskId);
|
||||
if (!task || task.status !== 'failed') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return enqueueTask({
|
||||
key: task.key,
|
||||
title: task.title,
|
||||
category: task.category,
|
||||
taskType: task.taskType,
|
||||
metadata: task.metadata,
|
||||
payload: task.payload
|
||||
});
|
||||
}
|
||||
|
||||
async function getSummary() {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT
|
||||
SUM(status = 'queued') AS queued,
|
||||
SUM(status = 'running') AS running,
|
||||
SUM(status = 'completed') AS completed,
|
||||
SUM(status = 'failed') AS failed,
|
||||
SUM(status = 'canceled') AS canceled,
|
||||
COUNT(*) AS total
|
||||
FROM o_background_tasks`
|
||||
);
|
||||
const row = rows && rows[0] ? rows[0] : {};
|
||||
|
||||
return {
|
||||
activeCount: activeCount,
|
||||
counts: {
|
||||
queued: Number(row.queued) || 0,
|
||||
running: Number(row.running) || 0,
|
||||
completed: Number(row.completed) || 0,
|
||||
failed: Number(row.failed) || 0,
|
||||
canceled: Number(row.canceled) || 0
|
||||
},
|
||||
scheduledCount: recurringJobsByKey.size,
|
||||
total: Number(row.total) || 0
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
enqueueTask: enqueueTask,
|
||||
enqueueTaskAndWait: enqueueTaskAndWait,
|
||||
initialize: initialize,
|
||||
setTaskHandler: setTaskHandler,
|
||||
registerRecurringTask: registerRecurringTask,
|
||||
removeRecurringTask: removeRecurringTask,
|
||||
runRecurringTask: runRecurringTask,
|
||||
listTasks: listTasks,
|
||||
listRecurringTasks: listRecurringTasks,
|
||||
getTaskById: fetchTaskById,
|
||||
getSummary: getSummary,
|
||||
cancelTask: cancelTask,
|
||||
retryTask: retryTask,
|
||||
clearFinishedTasks: clearFinishedTasks
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createBackgroundTaskQueue: createBackgroundTaskQueue,
|
||||
normalizeIntervalMs: normalizeIntervalMs
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
const { refreshApiSource, refreshRssFeed } = require('../../data-source-refresh');
|
||||
|
||||
const TASK = {
|
||||
taskType: 'data-source-refresh'
|
||||
};
|
||||
|
||||
function registerDataSourceRefreshTask(options) {
|
||||
const backgroundTaskQueue = options && options.backgroundTaskQueue;
|
||||
const pool = options && options.pool;
|
||||
const common = options && options.common;
|
||||
|
||||
if (!backgroundTaskQueue || !pool || !common) {
|
||||
throw new Error('registerDataSourceRefreshTask requires the data source refresh dependencies.');
|
||||
}
|
||||
|
||||
backgroundTaskQueue.setTaskHandler(TASK.taskType, async function (task) {
|
||||
const payload = task && task.payload ? task.payload : {};
|
||||
const sourceType = String(payload.sourceType || '').trim();
|
||||
const sourceId = Number(payload.sourceId || 0);
|
||||
|
||||
// Resolve the source record at execution time so stale queued tasks fail cleanly.
|
||||
if (sourceType === 'api-source') {
|
||||
const apiSource = await common.fetchApiSourceById(pool, sourceId);
|
||||
if (!apiSource) {
|
||||
throw new Error('API source not found.');
|
||||
}
|
||||
return refreshApiSource(pool, common, apiSource, Number(payload.actorId) || null);
|
||||
}
|
||||
|
||||
if (sourceType === 'rss-feed') {
|
||||
const rssFeed = await common.fetchRssFeedById(pool, sourceId);
|
||||
if (!rssFeed) {
|
||||
throw new Error('RSS feed not found.');
|
||||
}
|
||||
return refreshRssFeed(pool, common, rssFeed.id, rssFeed.feed_url, rssFeed.item_limit, Number(payload.actorId) || null);
|
||||
}
|
||||
|
||||
throw new Error('Unsupported data source refresh task.');
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { registerDataSourceRefreshTask };
|
||||
@@ -0,0 +1,39 @@
|
||||
function registerFontSyncTask(options) {
|
||||
const backgroundTaskQueue = options && options.backgroundTaskQueue;
|
||||
const uploadSyncService = options && options.uploadSyncService;
|
||||
|
||||
if (!backgroundTaskQueue || !uploadSyncService) {
|
||||
throw new Error('registerFontSyncTask requires the font sync dependencies.');
|
||||
}
|
||||
|
||||
backgroundTaskQueue.setTaskHandler('font-sync', async function (task) {
|
||||
const payload = task && task.payload ? task.payload : task || {};
|
||||
const uploadDir = String(payload.uploadDir || '').trim();
|
||||
const operations = Array.isArray(payload.operations)
|
||||
? payload.operations
|
||||
: Array.isArray(payload.uploadPaths)
|
||||
? payload.uploadPaths.map(function (uploadPath) {
|
||||
return { type: String(payload.action || 'put').trim().toLowerCase(), uploadPath: uploadPath };
|
||||
})
|
||||
: [];
|
||||
|
||||
if (!uploadDir || !operations.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (let i = 0; i < operations.length; i += 1) {
|
||||
const operation = operations[i] || {};
|
||||
const uploadPath = String(operation.uploadPath || '').trim();
|
||||
if (!uploadPath) {
|
||||
continue;
|
||||
}
|
||||
if (String(operation.type || '').trim().toLowerCase() === 'delete') {
|
||||
await uploadSyncService.removeUploadFileFromPlayer(uploadPath, uploadDir);
|
||||
} else {
|
||||
await uploadSyncService.pushUploadFileToPlayer(uploadPath, uploadDir);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { registerFontSyncTask };
|
||||
@@ -0,0 +1,20 @@
|
||||
const TASK = {
|
||||
taskType: 'media-sync'
|
||||
};
|
||||
|
||||
function registerMediaSyncTask(options) {
|
||||
const backgroundTaskQueue = options && options.backgroundTaskQueue;
|
||||
const uploadSyncService = options && options.uploadSyncService;
|
||||
const runMediaSyncTask = uploadSyncService && uploadSyncService.runMediaSyncTask;
|
||||
|
||||
if (!backgroundTaskQueue || typeof runMediaSyncTask !== 'function') {
|
||||
throw new Error('registerMediaSyncTask requires the media sync dependencies.');
|
||||
}
|
||||
|
||||
// Media sync is just a pass-through to the shared media sync runner.
|
||||
backgroundTaskQueue.setTaskHandler(TASK.taskType, function (task) {
|
||||
return runMediaSyncTask(task && task.payload ? task.payload : task);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { registerMediaSyncTask };
|
||||
@@ -0,0 +1,51 @@
|
||||
const TASK = {
|
||||
taskType: 'slide-thumbnail-refresh'
|
||||
};
|
||||
|
||||
async function fetchPlayerInternalBaseUrl(pool) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT internal_base_url
|
||||
FROM d_players
|
||||
WHERE device_id = '1'
|
||||
LIMIT 1`
|
||||
);
|
||||
|
||||
return String(rows && rows[0] && rows[0].internal_base_url || '').trim().replace(/\/$/, '') || null;
|
||||
}
|
||||
|
||||
function registerSlideThumbnailRefreshTask(options) {
|
||||
const backgroundTaskQueue = options && options.backgroundTaskQueue;
|
||||
const captureSlideThumbnail = options && options.captureSlideThumbnail;
|
||||
const pool = options && options.pool;
|
||||
const common = options && options.common;
|
||||
const mediaDir = String(options && options.mediaDir || '').trim();
|
||||
|
||||
if (!backgroundTaskQueue || typeof captureSlideThumbnail !== 'function' || !pool || !common || !mediaDir) {
|
||||
throw new Error('registerSlideThumbnailRefreshTask requires the slide thumbnail dependencies.');
|
||||
}
|
||||
|
||||
// Refresh the slide thumbnail for one slide id.
|
||||
backgroundTaskQueue.setTaskHandler(TASK.taskType, async function (task) {
|
||||
const payload = task && task.payload ? task.payload : {};
|
||||
const slideId = Number(payload.slideId || 0);
|
||||
if (!Number.isFinite(slideId) || slideId <= 0) {
|
||||
throw new Error('Slide id is required.');
|
||||
}
|
||||
|
||||
const playerInternalBaseUrl = await fetchPlayerInternalBaseUrl(pool);
|
||||
if (!playerInternalBaseUrl) {
|
||||
throw new Error('Player internal base URL is required.');
|
||||
}
|
||||
|
||||
return captureSlideThumbnail({
|
||||
pool: pool,
|
||||
common: common,
|
||||
mediaDir: mediaDir,
|
||||
baseUrl: playerInternalBaseUrl,
|
||||
slideId: slideId,
|
||||
previousThumbnailPath: payload.previousThumbnailPath || null
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { registerSlideThumbnailRefreshTask };
|
||||
@@ -0,0 +1,65 @@
|
||||
const TASK = {
|
||||
taskType: 'template-slide-thumbnail-refresh'
|
||||
};
|
||||
|
||||
async function fetchPlayerInternalBaseUrl(pool) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT internal_base_url
|
||||
FROM d_players
|
||||
WHERE device_id = '1'
|
||||
LIMIT 1`
|
||||
);
|
||||
|
||||
return String(rows && rows[0] && rows[0].internal_base_url || '').trim().replace(/\/$/, '') || null;
|
||||
}
|
||||
|
||||
function registerTemplateSlideThumbnailRefreshTask(options) {
|
||||
const backgroundTaskQueue = options && options.backgroundTaskQueue;
|
||||
const captureSlideThumbnail = options && options.captureSlideThumbnail;
|
||||
const pool = options && options.pool;
|
||||
const common = options && options.common;
|
||||
const mediaDir = String(options && options.mediaDir || '').trim();
|
||||
|
||||
if (!backgroundTaskQueue || typeof captureSlideThumbnail !== 'function' || !pool || !common || !mediaDir) {
|
||||
throw new Error('registerTemplateSlideThumbnailRefreshTask requires the template thumbnail dependencies.');
|
||||
}
|
||||
|
||||
// Walk every slide in the template and regenerate its thumbnail.
|
||||
backgroundTaskQueue.setTaskHandler(TASK.taskType, async function (task) {
|
||||
const payload = task && task.payload ? task.payload : {};
|
||||
const templateId = Number(payload.templateId || 0);
|
||||
if (!Number.isFinite(templateId) || templateId <= 0) {
|
||||
throw new Error('Template id is required.');
|
||||
}
|
||||
|
||||
const playerInternalBaseUrl = await fetchPlayerInternalBaseUrl(pool);
|
||||
if (!playerInternalBaseUrl) {
|
||||
throw new Error('Player internal base URL is required.');
|
||||
}
|
||||
|
||||
const [slides] = await pool.query(
|
||||
'SELECT id, thumbnail_path FROM c_slides WHERE template_id = ? ORDER BY id ASC',
|
||||
[templateId]
|
||||
);
|
||||
|
||||
for (const slide of slides || []) {
|
||||
const slideId = Number(slide && slide.id || 0);
|
||||
if (!Number.isFinite(slideId) || slideId <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await captureSlideThumbnail({
|
||||
pool: pool,
|
||||
common: common,
|
||||
mediaDir: mediaDir,
|
||||
baseUrl: playerInternalBaseUrl,
|
||||
slideId: slideId,
|
||||
previousThumbnailPath: slide && slide.thumbnail_path ? slide.thumbnail_path : null
|
||||
});
|
||||
}
|
||||
|
||||
return { templateId: templateId, slideCount: Array.isArray(slides) ? slides.length : 0 };
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { registerTemplateSlideThumbnailRefreshTask };
|
||||
@@ -0,0 +1,139 @@
|
||||
const TASK = {
|
||||
key: 'recurring-data-source-refreshes',
|
||||
title: 'Recurring data source refreshes',
|
||||
category: 'data-source',
|
||||
trigger: 'scheduled recurring task definitions from the database',
|
||||
purpose: 'keep API sources and RSS feeds refreshed on their configured intervals.',
|
||||
taskType: 'data-source-refresh',
|
||||
intervalMs: null
|
||||
};
|
||||
|
||||
const { normalizeIntervalMs } = require('../queue');
|
||||
const { refreshApiSource, refreshRssFeed } = require('../../data-source-refresh');
|
||||
|
||||
function registerRecurringDataSourceRefreshes(options) {
|
||||
const pool = options && options.pool;
|
||||
const common = options && options.common;
|
||||
const backgroundTaskQueue = options && options.backgroundTaskQueue;
|
||||
|
||||
if (!pool || !common || !backgroundTaskQueue) {
|
||||
throw new Error('registerRecurringDataSourceRefreshes requires the recurring refresh dependencies.');
|
||||
}
|
||||
|
||||
return (async function () {
|
||||
const apiSourcesData = await common.fetchApiSourcesData(pool);
|
||||
(apiSourcesData.apiSources || []).forEach(function (apiSource) {
|
||||
backgroundTaskQueue.registerRecurringTask({
|
||||
key: 'api-source-refresh:' + Number(apiSource.id),
|
||||
title: 'API source refresh',
|
||||
category: TASK.category,
|
||||
intervalMs: normalizeIntervalMs(apiSource.update_interval_value, apiSource.update_interval_unit),
|
||||
metadata: {
|
||||
sourceType: 'api-source',
|
||||
sourceId: Number(apiSource.id),
|
||||
sourceName: apiSource.name
|
||||
},
|
||||
run: function () {
|
||||
return refreshApiSource(pool, common, apiSource, null);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const rssFeedsData = await common.fetchRssFeedsData(pool);
|
||||
(rssFeedsData.rssFeeds || []).forEach(function (rssFeed) {
|
||||
backgroundTaskQueue.registerRecurringTask({
|
||||
key: 'rss-feed-refresh:' + Number(rssFeed.id),
|
||||
title: 'RSS feed refresh',
|
||||
category: TASK.category,
|
||||
intervalMs: normalizeIntervalMs(rssFeed.update_interval_value, rssFeed.update_interval_unit),
|
||||
metadata: {
|
||||
sourceType: 'rss-feed',
|
||||
sourceId: Number(rssFeed.id),
|
||||
sourceName: rssFeed.name
|
||||
},
|
||||
run: function () {
|
||||
return refreshRssFeed(pool, common, rssFeed.id, rssFeed.feed_url, rssFeed.item_limit, null);
|
||||
}
|
||||
});
|
||||
});
|
||||
})();
|
||||
}
|
||||
|
||||
function createDataSourceTaskService(options) {
|
||||
const pool = options && options.pool;
|
||||
const common = options && options.common;
|
||||
const backgroundTaskQueue = options && options.backgroundTaskQueue;
|
||||
|
||||
if (!pool || !common || !backgroundTaskQueue) {
|
||||
throw new Error('createDataSourceTaskService requires the data source task dependencies.');
|
||||
}
|
||||
|
||||
function formatRecurringKey(sourceType, id) {
|
||||
return sourceType + '-refresh:' + Number(id);
|
||||
}
|
||||
|
||||
function buildRecurringTitle(sourceType) {
|
||||
return sourceType === 'rss-feed' ? 'RSS feed refresh' : 'API source refresh';
|
||||
}
|
||||
|
||||
function registerRecurringRefresh(sourceType, id, name, intervalValue, intervalUnit, run) {
|
||||
backgroundTaskQueue.registerRecurringTask({
|
||||
key: formatRecurringKey(sourceType, id),
|
||||
title: buildRecurringTitle(sourceType),
|
||||
category: 'data-source',
|
||||
intervalMs: normalizeIntervalMs(intervalValue, intervalUnit),
|
||||
metadata: {
|
||||
sourceType: sourceType,
|
||||
sourceId: Number(id),
|
||||
sourceName: name
|
||||
},
|
||||
run: run
|
||||
});
|
||||
}
|
||||
|
||||
function removeRecurringRefresh(sourceType, id) {
|
||||
backgroundTaskQueue.removeRecurringTask(formatRecurringKey(sourceType, id));
|
||||
}
|
||||
|
||||
async function getTaskStatusById(taskId) {
|
||||
if (!backgroundTaskQueue || typeof backgroundTaskQueue.getTaskById !== 'function') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const task = await backgroundTaskQueue.getTaskById(taskId);
|
||||
if (!task) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: task.id,
|
||||
key: task.key,
|
||||
status: task.status,
|
||||
finishedAt: task.finishedAt || '',
|
||||
errorMessage: task.errorMessage || ''
|
||||
};
|
||||
}
|
||||
|
||||
async function refreshApiSourceInBackground(apiSourceId, actorId) {
|
||||
return refreshApiSource(pool, common, apiSourceId, actorId);
|
||||
}
|
||||
|
||||
async function refreshRssFeedInBackground(rssFeedId, feedUrl, itemLimit, actorId) {
|
||||
return refreshRssFeed(pool, common, rssFeedId, feedUrl, itemLimit, actorId);
|
||||
}
|
||||
|
||||
return {
|
||||
formatRecurringKey: formatRecurringKey,
|
||||
buildRecurringTitle: buildRecurringTitle,
|
||||
registerRecurringRefresh: registerRecurringRefresh,
|
||||
removeRecurringRefresh: removeRecurringRefresh,
|
||||
getTaskStatusById: getTaskStatusById,
|
||||
refreshApiSourceInBackground: refreshApiSourceInBackground,
|
||||
refreshRssFeedInBackground: refreshRssFeedInBackground
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
registerRecurringDataSourceRefreshes: registerRecurringDataSourceRefreshes,
|
||||
createDataSourceTaskService: createDataSourceTaskService
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
const TASK = {
|
||||
key: 'font-sweep',
|
||||
title: 'Font sweep',
|
||||
category: 'cleanup',
|
||||
trigger: 'recurring scheduled task, daily',
|
||||
purpose: 'reconcile managed fonts on the player and remove stale font files.',
|
||||
taskType: 'recurring-run',
|
||||
intervalMs: 24 * 60 * 60 * 1000
|
||||
};
|
||||
|
||||
const {
|
||||
collectFontLibraryDirectoryUploadPaths,
|
||||
collectFontLibrarySyncOperations
|
||||
} = require('../../media/font-library');
|
||||
|
||||
function registerFontSweepTask(options) {
|
||||
const backgroundTaskQueue = options && options.backgroundTaskQueue;
|
||||
const uploadSyncService = options && options.uploadSyncService;
|
||||
const pushUploadFileToPlayer = uploadSyncService && uploadSyncService.pushUploadFileToPlayer;
|
||||
const removeUploadFileFromPlayer = uploadSyncService && uploadSyncService.removeUploadFileFromPlayer;
|
||||
const mediaDir = String(options && options.mediaDir || '').trim();
|
||||
|
||||
if (!backgroundTaskQueue || typeof pushUploadFileToPlayer !== 'function' || typeof removeUploadFileFromPlayer !== 'function' || !mediaDir) {
|
||||
throw new Error('registerFontSweepTask requires the font sweep dependencies.');
|
||||
}
|
||||
|
||||
backgroundTaskQueue.registerRecurringTask({
|
||||
key: TASK.key,
|
||||
title: TASK.title,
|
||||
category: TASK.category,
|
||||
intervalMs: TASK.intervalMs,
|
||||
metadata: {
|
||||
mediaDir: mediaDir
|
||||
},
|
||||
run: async function () {
|
||||
const desiredOperations = collectFontLibrarySyncOperations(mediaDir);
|
||||
const desiredUploadPaths = new Set(desiredOperations.map(function (operation) {
|
||||
return operation && operation.uploadPath ? operation.uploadPath : '';
|
||||
}).filter(Boolean));
|
||||
const currentUploadPaths = await collectFontLibraryDirectoryUploadPaths(mediaDir);
|
||||
|
||||
for (let i = 0; i < desiredOperations.length; i += 1) {
|
||||
const operation = desiredOperations[i] || {};
|
||||
const uploadPath = String(operation.uploadPath || '').trim();
|
||||
if (!uploadPath) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (String(operation.type || '').trim().toLowerCase() === 'delete') {
|
||||
await removeUploadFileFromPlayer(uploadPath, mediaDir);
|
||||
} else {
|
||||
await pushUploadFileToPlayer(uploadPath, mediaDir);
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < currentUploadPaths.length; i += 1) {
|
||||
const uploadPath = String(currentUploadPaths[i] || '').trim();
|
||||
if (!uploadPath || desiredUploadPaths.has(uploadPath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await removeUploadFileFromPlayer(uploadPath, mediaDir);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { registerFontSweepTask };
|
||||
@@ -0,0 +1,42 @@
|
||||
const TASK = {
|
||||
key: 'unused-upload-sweep',
|
||||
title: 'Unused upload sweep',
|
||||
category: 'cleanup',
|
||||
trigger: 'recurring scheduled task, daily',
|
||||
purpose: 'remove uploaded media files that are no longer referenced.',
|
||||
taskType: 'recurring-run',
|
||||
intervalMs: 24 * 60 * 60 * 1000
|
||||
};
|
||||
|
||||
function registerUnusedUploadSweepTask(options) {
|
||||
const backgroundTaskQueue = options && options.backgroundTaskQueue;
|
||||
const uploadSyncService = options && options.uploadSyncService;
|
||||
const collectUploadPathsFromDirectory = uploadSyncService && uploadSyncService.collectUploadPathsFromDirectory;
|
||||
const removeUnusedUploadFiles = uploadSyncService && uploadSyncService.removeUnusedUploadFiles;
|
||||
const pool = options && options.pool;
|
||||
const mediaDir = String(options && options.mediaDir || '').trim();
|
||||
|
||||
if (!backgroundTaskQueue || typeof collectUploadPathsFromDirectory !== 'function' || typeof removeUnusedUploadFiles !== 'function' || !pool || !mediaDir) {
|
||||
throw new Error('registerUnusedUploadSweepTask requires the unused upload sweep dependencies.');
|
||||
}
|
||||
|
||||
backgroundTaskQueue.registerRecurringTask({
|
||||
key: TASK.key,
|
||||
title: TASK.title,
|
||||
category: TASK.category,
|
||||
intervalMs: TASK.intervalMs,
|
||||
metadata: {
|
||||
mediaDir: mediaDir
|
||||
},
|
||||
run: async function () {
|
||||
const uploadPaths = await collectUploadPathsFromDirectory(mediaDir);
|
||||
if (!uploadPaths.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
await removeUnusedUploadFiles(pool, mediaDir, uploadPaths);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { registerUnusedUploadSweepTask };
|
||||
@@ -0,0 +1,76 @@
|
||||
const { refreshApiSource, refreshRssFeed } = require('../../data-source-refresh');
|
||||
|
||||
const TASK = {
|
||||
key: 'startup-data-source-refresh',
|
||||
category: 'data-source',
|
||||
};
|
||||
|
||||
function scheduleStartupDataSourceRefreshes(options) {
|
||||
const backgroundTaskQueue = options && options.backgroundTaskQueue;
|
||||
const pool = options && options.pool;
|
||||
const common = options && options.common;
|
||||
const dataSourceStartupRefreshStaggerMs = Math.max(100, Number(options && options.dataSourceStartupRefreshStaggerMs || 250));
|
||||
|
||||
if (!backgroundTaskQueue || !pool || !common) {
|
||||
throw new Error('scheduleStartupDataSourceRefreshes requires the startup refresh dependencies.');
|
||||
}
|
||||
|
||||
const staggerMs = Math.max(100, Number(dataSourceStartupRefreshStaggerMs || 250));
|
||||
|
||||
function buildStartupSource(type, id, name, run) {
|
||||
return {
|
||||
type: type,
|
||||
id: Number(id),
|
||||
name: name,
|
||||
run: run
|
||||
};
|
||||
}
|
||||
|
||||
return (async function () {
|
||||
const apiSourcesData = await common.fetchApiSourcesData(pool);
|
||||
const rssFeedsData = await common.fetchRssFeedsData(pool);
|
||||
const startupSources = [];
|
||||
|
||||
(apiSourcesData.apiSources || []).forEach(function (apiSource) {
|
||||
startupSources.push(buildStartupSource('api-source', apiSource.id, apiSource.name, function () {
|
||||
return refreshApiSource(pool, common, apiSource, null);
|
||||
}));
|
||||
});
|
||||
|
||||
(rssFeedsData.rssFeeds || []).forEach(function (rssFeed) {
|
||||
startupSources.push(buildStartupSource('rss-feed', rssFeed.id, rssFeed.name, function () {
|
||||
return refreshRssFeed(pool, common, rssFeed.id, rssFeed.feed_url, rssFeed.item_limit, null);
|
||||
}));
|
||||
});
|
||||
|
||||
// Stagger startup refreshes to avoid a burst against the DB/player.
|
||||
startupSources.forEach(function (source, index) {
|
||||
const startupDelayMs = index * staggerMs;
|
||||
|
||||
setTimeout(function () {
|
||||
backgroundTaskQueue.enqueueTask({
|
||||
key: TASK.key + ':' + source.type + ':' + source.id + ':' + Date.now(),
|
||||
title: source.type === 'rss-feed' ? 'RSS feed refresh' : 'API source refresh',
|
||||
category: TASK.category,
|
||||
taskType: 'data-source-refresh',
|
||||
metadata: {
|
||||
sourceType: source.type,
|
||||
sourceId: source.id,
|
||||
sourceName: source.name,
|
||||
startupRefresh: true
|
||||
},
|
||||
payload: {
|
||||
sourceType: source.type,
|
||||
sourceId: source.id,
|
||||
sourceName: source.name,
|
||||
startupRefresh: true
|
||||
}
|
||||
}).catch(function (error) {
|
||||
console.warn('Unable to queue startup refresh for ' + source.type + ' ' + source.id + ':', error);
|
||||
});
|
||||
}, startupDelayMs);
|
||||
});
|
||||
})();
|
||||
}
|
||||
|
||||
module.exports = { scheduleStartupDataSourceRefreshes };
|
||||
@@ -0,0 +1,32 @@
|
||||
const { collectFontLibrarySyncOperations } = require('../../media/font-library');
|
||||
|
||||
const TASK = {
|
||||
key: 'initial-font-sync',
|
||||
category: 'fonts'
|
||||
};
|
||||
|
||||
function registerInitialFontSyncTask(options) {
|
||||
const backgroundTaskQueue = options && options.backgroundTaskQueue;
|
||||
const mediaDir = String(options && options.mediaDir || '').trim();
|
||||
|
||||
if (!backgroundTaskQueue || !mediaDir) {
|
||||
throw new Error('registerInitialFontSyncTask requires the initial font sync dependencies.');
|
||||
}
|
||||
|
||||
return backgroundTaskQueue.enqueueTask({
|
||||
key: TASK.key,
|
||||
title: 'Initial font sync',
|
||||
category: TASK.category,
|
||||
taskType: 'font-sync',
|
||||
payload: {
|
||||
mode: 'initial',
|
||||
uploadDir: mediaDir,
|
||||
operations: collectFontLibrarySyncOperations(mediaDir)
|
||||
},
|
||||
persist: true
|
||||
}).catch(function (error) {
|
||||
console.warn('Unable to queue initial font sync:', error);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { registerInitialFontSyncTask };
|
||||
@@ -0,0 +1,29 @@
|
||||
const TASK = {
|
||||
key: 'initial-media-sync',
|
||||
category: 'media-sync',
|
||||
};
|
||||
|
||||
function registerInitialMediaSyncTask(options) {
|
||||
const backgroundTaskQueue = options && options.backgroundTaskQueue;
|
||||
const mediaDir = String(options && options.mediaDir || '').trim();
|
||||
|
||||
if (!backgroundTaskQueue || !mediaDir) {
|
||||
throw new Error('registerInitialMediaSyncTask requires the initial media sync dependencies.');
|
||||
}
|
||||
|
||||
return backgroundTaskQueue.enqueueTask({
|
||||
key: TASK.key,
|
||||
title: 'Initial media sync',
|
||||
category: TASK.category,
|
||||
taskType: 'media-sync',
|
||||
payload: {
|
||||
mode: 'initial',
|
||||
uploadDir: mediaDir
|
||||
},
|
||||
persist: true
|
||||
}).catch(function (error) {
|
||||
console.warn('Unable to queue initial media sync:', error);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { registerInitialMediaSyncTask };
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user