Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e95928f31c | ||
|
|
2b9cabdab2 | ||
|
|
a9d1d45d78 | ||
|
|
d06da6fea2 | ||
|
|
90db0f933d | ||
|
|
d6417b667c |
+2
-3
@@ -1,5 +1,5 @@
|
||||
PULSE_SIGNAGE_IMAGE=git.lzstealth.com/lzstealth/pulse-signage:latest
|
||||
PULSE_SIGNAGE_SHARED_SECRET=9f4c2d8b7a1e4c0f8b2d6a9e1c7f3b5d4a8e6c1f0b9d7a3c5e2f1a6b8d4c0e7
|
||||
PULSE_SIGNAGE_SHARED_SECRET=
|
||||
|
||||
DB_HOST=mysql
|
||||
DB_PORT=3306
|
||||
@@ -8,11 +8,10 @@ DB_USER=pulse-signage
|
||||
DB_PASSWORD=signage_password
|
||||
MYSQL_ROOT_PASSWORD=root_password
|
||||
|
||||
PLAYER_INTERNAL_BASE_URL=http://player:8081
|
||||
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,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.
|
||||
@@ -1,5 +1,7 @@
|
||||
node_modules/
|
||||
media/
|
||||
!src/web/lib/media/
|
||||
!src/web/lib/media/**
|
||||
docker-compose.dev.yml
|
||||
.vscode/
|
||||
.env
|
||||
|
||||
+141
@@ -2,6 +2,147 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## 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
|
||||
|
||||
@@ -1,105 +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
|
||||
- Manage roles and permissions for the admin web UI
|
||||
- Upload images and other media for use in slides and templates
|
||||
- View live screen connections and send player commands
|
||||
## What It Handles
|
||||
|
||||
Admin permissions are split into CRUD actions per section, so you can grant read-only, editor, creator, or delete access separately.
|
||||
- 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.
|
||||
|
||||
## What You Need
|
||||
## Simple Deployment
|
||||
|
||||
- Docker and Docker Compose
|
||||
- A MySQL database
|
||||
Pulse Signage ships with a Docker Compose stack for the published image:
|
||||
|
||||
## First-Time Setup
|
||||
- Published-image stack: `docker compose -f docker-compose.yml up -d`
|
||||
|
||||
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.
|
||||
Use this stack when you want to run the tagged image instead of building from source.
|
||||
|
||||
## Environment File
|
||||
|
||||
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`
|
||||
|
||||
The first admin account is placed into the built-in `Administrators` role, which has full web-admin access through the CRUD permissions.
|
||||
## In Practice
|
||||
|
||||
You can change the initial admin credentials with these optional environment variables:
|
||||
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.
|
||||
|
||||
- `DEFAULT_ADMIN_USERNAME`
|
||||
- `DEFAULT_ADMIN_NAME`
|
||||
- `DEFAULT_ADMIN_PASSWORD`
|
||||
|
||||
## Configuration
|
||||
|
||||
The app reads its settings from environment variables. The list below matches the provided `.env.example` file.
|
||||
|
||||
### Compose and Shared Settings
|
||||
|
||||
- `PULSE_SIGNAGE_IMAGE` - image tag used by the published Compose setup, default `git.lzstealth.com/lzstealth/pulse-signage:latest`
|
||||
- `PULSE_SIGNAGE_SHARED_SECRET` - shared secret used to sign player page API fetches and server-to-player requests; page tokens auto-renew while the page stays active and request signatures must be fresh; leave unset to keep the auth checks disabled for compatibility
|
||||
|
||||
### Database Connection
|
||||
|
||||
- `DB_HOST` - MySQL host, default `mysql`
|
||||
- `DB_PORT` - MySQL port, default `3306`
|
||||
- `DB_NAME` - database name, default `pulse-signage`
|
||||
- `DB_USER` - database user, default `pulse-signage`
|
||||
- `DB_PASSWORD` - database password, default `signage_password`
|
||||
- `MYSQL_ROOT_PASSWORD` - root password for the bundled MySQL container, default `root_password`
|
||||
|
||||
### Player App
|
||||
|
||||
- `PLAYER_INTERNAL_BASE_URL` - internal player URL used by the web app, default `http://player:8081`
|
||||
- `PLAYER_PUBLIC_BASE_URL` - public player URL used by browser-facing links, default `http://localhost:8081`
|
||||
|
||||
### Web App
|
||||
|
||||
- `SESSION_MAX_AGE_DAYS` - admin session lifetime in days, default `14`
|
||||
- `DASHBOARD_REFRESH_INTERVAL_MS` - dashboard refresh interval in milliseconds, default `2000`
|
||||
- `DEFAULT_ADMIN_USERNAME` - username used for the initial admin account, default `admin`
|
||||
- `DEFAULT_ADMIN_NAME` - display name used for the initial admin account, default `Admin`
|
||||
- `DEFAULT_ADMIN_PASSWORD` - password used for the initial admin account, default `admin`
|
||||
- `PASSWORD_HASH_ITERATIONS` - password hash iteration count, default `310000`
|
||||
|
||||
## Docker Compose
|
||||
|
||||
The repository includes a `docker-compose.yml` file that starts three services:
|
||||
|
||||
- `web` - the admin app exposed on container port `8080`
|
||||
- `player` - the signage player exposed on container port `8081`
|
||||
- `mysql` - the database on port `3306`
|
||||
|
||||
Change the published host ports in Docker Compose if you want different external ports; the app containers listen on `8080` and `8081` internally.
|
||||
|
||||
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 `media` volume for stored assets and a `mysql_data` volume for database persistence.
|
||||
|
||||
In Docker, media storage is controlled by the `media` volume mount at `/app/media`.
|
||||
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`.
|
||||
- If `PULSE_SIGNAGE_SHARED_SECRET` is set, the web and player containers must use the same value, and any reverse proxy in front of the player must forward `/api/media/...` without rewriting the path.
|
||||
- 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 page uses the browser Screen Wake Lock API when available, but kiosk mode and OS sleep settings still matter because the browser can deny or release the wake lock.
|
||||
|
||||
## Documentation
|
||||
|
||||
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.
|
||||
|
||||
Database schema details live in [docs/schema.md](docs/schema.md). It summarizes the current tables created at startup.
|
||||
|
||||
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.
|
||||
The player and admin pieces stay linked, so changes made in the dashboard can flow out to screens quickly and consistently.
|
||||
+1
-5
@@ -1,7 +1,6 @@
|
||||
services:
|
||||
web:
|
||||
image: ${PULSE_SIGNAGE_IMAGE:-git.lzstealth.com/lzstealth/pulse-signage:latest}
|
||||
container_name: signage-web
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8080:8080"
|
||||
@@ -11,8 +10,6 @@ services:
|
||||
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}
|
||||
@@ -31,12 +28,12 @@ services:
|
||||
|
||||
player:
|
||||
image: ${PULSE_SIGNAGE_IMAGE:-git.lzstealth.com/lzstealth/pulse-signage:latest}
|
||||
container_name: signage-player
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8081:8081"
|
||||
environment:
|
||||
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}
|
||||
@@ -54,7 +51,6 @@ services:
|
||||
|
||||
mysql:
|
||||
image: mysql:8.4
|
||||
container_name: signage-mysql
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MYSQL_DATABASE: ${DB_NAME:-signage}
|
||||
|
||||
+124
-14
@@ -47,7 +47,7 @@ Primary keys are `id` unless noted otherwise. Timestamps are stored as `created_
|
||||
|
||||
### `a_sessions`
|
||||
|
||||
- `session_hash`, `user_id`, `expires_at`, `created_at`, `created_by`, `modified_at`, `modified_by`
|
||||
- `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`
|
||||
@@ -55,11 +55,12 @@ Primary keys are `id` unless noted otherwise. Timestamps are stored as `created_
|
||||
## Content
|
||||
|
||||
- `c_canvas_sizes` - reusable canvas presets for templates.
|
||||
- `c_playlists` - playlist definitions and playback options.
|
||||
- `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, timing, and schedule rules.
|
||||
- `c_playlist_slides` - ordered playlist items and timing.
|
||||
- `c_playlist_slide_schedule_rules` - rule rows attached to playlist slides.
|
||||
|
||||
### `c_canvas_sizes`
|
||||
|
||||
@@ -68,7 +69,9 @@ Primary keys are `id` unless noted otherwise. Timestamps are stored as `created_
|
||||
|
||||
### `c_playlists`
|
||||
|
||||
- `id`, `name`, `fade_between_slides`, `skip_unavailable_rtmp`, `created_at`, `created_by`, `modified_at`, `modified_by`
|
||||
- `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`
|
||||
|
||||
@@ -90,10 +93,37 @@ Primary keys are `id` unless noted otherwise. Timestamps are stored as `created_
|
||||
|
||||
### `c_playlist_slides`
|
||||
|
||||
- `id`, `playlist_id`, `slide_id`, `position`, `duration_seconds`, `use_video_duration`, `schedule_mode`, `schedule_start_datetime`, `schedule_end_datetime`, `schedule_start_time`, `schedule_end_time`, `schedule_days_json`, `created_at`, `created_by`, `modified_at`, `modified_by`
|
||||
- `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`
|
||||
|
||||
@@ -101,11 +131,9 @@ Primary keys are `id` unless noted otherwise. Timestamps are stored as `created_
|
||||
- `slug` is unique.
|
||||
- Foreign keys:
|
||||
- `playlist_id` -> `c_playlists.id` with `ON DELETE SET NULL`
|
||||
- `player_id` -> `d_players.id` with `ON DELETE SET NULL`
|
||||
- `player_id` -> `d_players.id` with `ON DELETE RESTRICT`
|
||||
|
||||
## Onboarding
|
||||
|
||||
- `d_onboarding_devices` - device-to-screen bindings and onboarded client names.
|
||||
- `player_id` is required and defaults to `'1'` for the current singleton-player model.
|
||||
|
||||
### `d_onboarding_devices`
|
||||
|
||||
@@ -114,11 +142,30 @@ Primary keys are `id` unless noted otherwise. Timestamps are stored as `created_
|
||||
- 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`
|
||||
|
||||
@@ -134,7 +181,19 @@ Primary keys are `id` unless noted otherwise. Timestamps are stored as `created_
|
||||
|
||||
### `i_api_sources`
|
||||
|
||||
- `id`, `name`, `api_url`, `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`
|
||||
- `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
|
||||
|
||||
@@ -148,11 +207,60 @@ Primary keys are `id` unless noted otherwise. Timestamps are stored as `created_
|
||||
## Notes
|
||||
|
||||
- The schema is initialized with `CREATE TABLE IF NOT EXISTS`, so new installs can start from an empty database.
|
||||
- `src/db/index.js` also seeds default permissions and the default administrator role.
|
||||
- `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
|
||||
@@ -160,17 +268,19 @@ erDiagram
|
||||
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
|
||||
O_BACKGROUND_TASKS {
|
||||
BIGINT id
|
||||
}
|
||||
I_SCHEDULE_GROUPS ||--o{ I_SCHEDULE_ENTRIES : contains
|
||||
```
|
||||
@@ -24,6 +24,13 @@ 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
|
||||
@@ -113,6 +120,26 @@ Moves to the next slide.
|
||||
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
|
||||
|
||||
Generated
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "pulse-signage",
|
||||
"version": "2.0.0",
|
||||
"version": "2.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "pulse-signage",
|
||||
"version": "2.0.0",
|
||||
"version": "2.4.2",
|
||||
"private": false,
|
||||
"description": "Pulse Signage application with MySQL and media storage",
|
||||
"repository": {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// Password hashing and session token helpers.
|
||||
|
||||
const crypto = require('crypto');
|
||||
|
||||
const PASSWORD_ITERATIONS = Number(process.env.PASSWORD_HASH_ITERATIONS || 310000);
|
||||
|
||||
+53
-17
@@ -1,20 +1,33 @@
|
||||
const dbCommon = require('./db/common');
|
||||
const db = require('./db');
|
||||
const dbBootstrap = require('./db/bootstrap');
|
||||
const data = require('./data');
|
||||
const player = require('./player/render');
|
||||
// Module-resolution shims for the app's `#root` and `#src` aliases.
|
||||
|
||||
function getSearchQuery(req) {
|
||||
return String(req && req.query && req.query.search || '').trim();
|
||||
}
|
||||
const Module = require('module');
|
||||
const path = require('path');
|
||||
|
||||
function getSortQuery(req) {
|
||||
return String(req && req.query && req.query.sort || '').trim();
|
||||
}
|
||||
const rootDir = path.resolve(__dirname, '..');
|
||||
const srcDir = __dirname;
|
||||
const resolveFilename = Module._resolveFilename;
|
||||
|
||||
function getSortDirectionQuery(req) {
|
||||
return String(req && req.query && req.query.direction || '').trim().toLowerCase() === 'desc' ? 'desc' : 'asc';
|
||||
}
|
||||
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: dbCommon.createPool,
|
||||
@@ -30,10 +43,31 @@ module.exports = {
|
||||
fetchTemplatesPage: data.fetchTemplatesPage,
|
||||
fetchCanvasSizesPage: data.fetchCanvasSizesPage,
|
||||
fetchScreensPage: data.fetchScreensPage,
|
||||
getSearchQuery: getSearchQuery,
|
||||
getSortQuery: getSortQuery,
|
||||
getSortDirectionQuery: getSortDirectionQuery,
|
||||
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,
|
||||
@@ -49,6 +83,8 @@ module.exports = {
|
||||
replaceRssFeedItems: data.replaceRssFeedItems,
|
||||
fetchScreenById: data.fetchScreenById,
|
||||
fetchScreenEditData: data.fetchScreenEditData,
|
||||
fetchScreenPlayerUrls: data.fetchScreenPlayerUrls,
|
||||
fetchScreenPlayerRecord: data.fetchScreenPlayerRecord,
|
||||
fetchTemplateById: data.fetchTemplateById,
|
||||
fetchSlideById: data.fetchSlideById,
|
||||
fetchTemplatesData: data.fetchTemplatesData,
|
||||
|
||||
+29
-5
@@ -1,3 +1,5 @@
|
||||
// Admin data aggregation helpers for dashboards, playlists, slides, templates, and screens.
|
||||
|
||||
const { fetchPagedRows } = require('./utils');
|
||||
|
||||
async function fetchAdminData(pool) {
|
||||
@@ -10,9 +12,9 @@ async function fetchAdminData(pool) {
|
||||
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, 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 [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.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 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
|
||||
@@ -26,20 +28,42 @@ async function fetchAdminData(pool) {
|
||||
ORDER BY s.id DESC
|
||||
`);
|
||||
const [playlistSlides] = await pool.query(`
|
||||
SELECT ps.id, ps.playlist_id, ps.position, ps.duration_seconds, ps.use_video_duration, 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.content_json, sl.thumbnail_path, cs.width AS canvas_width, cs.height AS canvas_height
|
||||
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 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',
|
||||
|
||||
@@ -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
|
||||
};
|
||||
+96
-14
@@ -1,3 +1,5 @@
|
||||
// API source data access, pagination, and remote fetch helpers.
|
||||
|
||||
const http = require('http');
|
||||
const https = require('https');
|
||||
const { fetchPagedRows } = require('./utils');
|
||||
@@ -7,9 +9,44 @@ function normalizeUpdateIntervalUnit(value) {
|
||||
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, 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'
|
||||
'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 };
|
||||
@@ -17,7 +54,7 @@ async function fetchApiSourcesData(pool) {
|
||||
|
||||
async function fetchApiSourcesPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
|
||||
const paged = await fetchPagedRows(pool, {
|
||||
selectSql: 'SELECT id, name, api_url, 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',
|
||||
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,
|
||||
@@ -41,20 +78,21 @@ async function fetchApiSourcesPage(pool, page, pageSize, searchTerm, sortKey, so
|
||||
|
||||
async function fetchApiSourceById(pool, id) {
|
||||
const [rows] = await pool.query(
|
||||
'SELECT id, name, api_url, 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 = ?',
|
||||
'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) {
|
||||
async function loadUrlText(urlValue, requestOptions) {
|
||||
const extraHeaders = requestOptions && requestOptions.headers ? requestOptions.headers : {};
|
||||
if (typeof fetch === 'function') {
|
||||
const response = await fetch(urlValue, {
|
||||
headers: {
|
||||
headers: Object.assign({
|
||||
Accept: 'application/json, text/plain;q=0.9, */*;q=0.8',
|
||||
'User-Agent': 'Pulse Signage API Reader'
|
||||
}
|
||||
}, extraHeaders)
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -68,12 +106,13 @@ async function loadUrlText(urlValue) {
|
||||
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/json, text/plain;q=0.9, */*;q=0.8',
|
||||
'User-Agent': 'Pulse Signage API Reader'
|
||||
}
|
||||
}, function (response) {
|
||||
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) {
|
||||
@@ -94,8 +133,11 @@ async function loadUrlText(urlValue) {
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchApiSourceResponse(apiUrl) {
|
||||
const response = await loadUrlText(apiUrl);
|
||||
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}).`);
|
||||
}
|
||||
@@ -121,8 +163,23 @@ async function fetchApiSourceResponse(apiUrl) {
|
||||
|
||||
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');
|
||||
|
||||
@@ -159,9 +216,34 @@ function buildApiSourcePayload(req, existingApiSource) {
|
||||
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
|
||||
};
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// Canvas size data access and pagination helpers.
|
||||
|
||||
const { fetchPagedRows } = require('./utils');
|
||||
|
||||
async function fetchCanvasSizesData(pool) {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// Client name reservation helpers for onboarding and live connection checks.
|
||||
|
||||
const crypto = require('crypto');
|
||||
|
||||
function normalizeClientName(value) {
|
||||
|
||||
+27
-1
@@ -1,8 +1,13 @@
|
||||
// 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 { 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 } = require('./screens');
|
||||
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');
|
||||
@@ -18,7 +23,26 @@ module.exports = {
|
||||
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,
|
||||
@@ -34,6 +58,8 @@ module.exports = {
|
||||
replaceRssFeedItems,
|
||||
fetchScreenById,
|
||||
fetchScreenEditData,
|
||||
fetchScreenPlayerUrls,
|
||||
fetchScreenPlayerRecord,
|
||||
fetchTemplateById,
|
||||
fetchSlideById,
|
||||
fetchTemplatesData,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// Playlist data access helpers.
|
||||
|
||||
async function fetchPlaylistById(pool, id) {
|
||||
const [rows] = 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 = ?', [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;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// RSS feed data access, pagination, and item normalization helpers.
|
||||
|
||||
const http = require('http');
|
||||
const https = require('https');
|
||||
const { fetchPagedRows } = require('./utils');
|
||||
@@ -50,7 +52,7 @@ async function fetchRssFeedById(pool, id) {
|
||||
async function fetchRssFeedItemsByFeedId(pool, rssFeedId) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT id, rss_feed_id, position, item_json, created_at, modified_at
|
||||
FROM rss_feed_items
|
||||
FROM i_rss_feed_items
|
||||
WHERE rss_feed_id = ?
|
||||
ORDER BY position ASC, id ASC`,
|
||||
[rssFeedId]
|
||||
@@ -229,7 +231,7 @@ async function fetchRssFeedItems(feedUrl, itemLimit) {
|
||||
|
||||
async function replaceRssFeedItems(connection, rssFeedId, items) {
|
||||
const normalizedItems = Array.isArray(items) ? items : [];
|
||||
await connection.query('DELETE FROM rss_feed_items WHERE rss_feed_id = ?', [rssFeedId]);
|
||||
await connection.query('DELETE FROM i_rss_feed_items WHERE rss_feed_id = ?', [rssFeedId]);
|
||||
|
||||
if (!normalizedItems.length) {
|
||||
return 0;
|
||||
@@ -244,7 +246,7 @@ async function replaceRssFeedItems(connection, rssFeedId, items) {
|
||||
});
|
||||
|
||||
await connection.query(
|
||||
'INSERT INTO rss_feed_items (rss_feed_id, position, item_json) VALUES ?',
|
||||
'INSERT INTO i_rss_feed_items (rss_feed_id, position, item_json) VALUES ?',
|
||||
[insertValues]
|
||||
);
|
||||
|
||||
|
||||
@@ -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
|
||||
};
|
||||
@@ -1,3 +1,5 @@
|
||||
// Screen slug helpers and screen lookup utilities.
|
||||
|
||||
function slugify(value) {
|
||||
return String(value || '')
|
||||
.toLowerCase()
|
||||
@@ -7,6 +9,60 @@ 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;
|
||||
@@ -44,6 +100,10 @@ async function fetchScreenEditData(pool) {
|
||||
|
||||
module.exports = {
|
||||
slugify,
|
||||
normalizePlayerBaseUrl,
|
||||
buildScreenPlayerUrl,
|
||||
fetchScreenPlayerUrls,
|
||||
fetchScreenPlayerRecord,
|
||||
uniqueScreenSlug,
|
||||
fetchScreenById,
|
||||
fetchScreenEditData
|
||||
|
||||
+83
-6
@@ -1,3 +1,5 @@
|
||||
// Slide data access helpers, including rich-text normalization and payload building.
|
||||
|
||||
const { fetchTemplateById } = require('./templates');
|
||||
const { parseJsonSafe } = require('./utils');
|
||||
|
||||
@@ -25,10 +27,29 @@ 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.template_id, s.content_json, s.thumbnail_path, 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
|
||||
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
|
||||
@@ -53,6 +74,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)) {
|
||||
@@ -88,9 +117,10 @@ 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 ? `/media/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}`];
|
||||
@@ -99,9 +129,10 @@ function buildTemplateContent(template, body, filesByField, existingContent) {
|
||||
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}` : String(existing || '').trim() || ((existingContent && existingContent[region.region_key]) ? existingContent[region.region_key].value : ''),
|
||||
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') {
|
||||
@@ -126,6 +157,15 @@ 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] : {};
|
||||
@@ -135,7 +175,7 @@ function buildTemplateContent(template, body, filesByField, existingContent) {
|
||||
const parsedItemNumber = Math.max(1, Number(itemNumber || current.item_number || 1));
|
||||
content[region.region_key] = {
|
||||
type: 'rss',
|
||||
value: submitted === undefined ? String(current.value || '') : String(submitted || ''),
|
||||
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',
|
||||
@@ -149,24 +189,61 @@ function buildTemplateContent(template, body, filesByField, existingContent) {
|
||||
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: submitted === undefined ? String(current.value || '') : String(submitted || ''),
|
||||
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', '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
|
||||
|
||||
+42
-9
@@ -1,7 +1,6 @@
|
||||
const { parseJsonSafe, readFormArray } = require('./utils');
|
||||
// Template data access helpers and region normalization logic.
|
||||
|
||||
const ALLOWED_TEMPLATE_REGION_TYPES = ['text', 'image', 'video', 'webpage', 'html', 'rtmp', 'rss', 'api'];
|
||||
const FONT_FAMILY_REGION_TYPES = ['text', 'html', 'rss', 'api'];
|
||||
const { parseJsonSafe, readFormArray } = require('./utils');
|
||||
|
||||
function sanitizeBackgroundColor(value) {
|
||||
const raw = String(value || '').trim();
|
||||
@@ -13,7 +12,7 @@ function sanitizeBackgroundColor(value) {
|
||||
|
||||
function normalizeTemplateRegionType(value) {
|
||||
const rawType = String(value || 'text').trim();
|
||||
return ALLOWED_TEMPLATE_REGION_TYPES.includes(rawType) ? rawType : 'text';
|
||||
return rawType || 'text';
|
||||
}
|
||||
|
||||
function normalizeTemplateRegionLockRatio(value) {
|
||||
@@ -58,7 +57,7 @@ async function fetchTemplateById(pool, id) {
|
||||
return null;
|
||||
}
|
||||
const template = templates[0];
|
||||
const [regions] = await pool.query('SELECT id, template_id, region_key, region_type, label, font_family, lock_ratio, 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]);
|
||||
const [regions] = 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 WHERE template_id = ? ORDER BY z_index ASC, id ASC', [id]);
|
||||
template.regions = regions;
|
||||
return template;
|
||||
}
|
||||
@@ -71,7 +70,7 @@ async function fetchTemplatesData(pool) {
|
||||
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, 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 [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');
|
||||
return { templates, templateRegions };
|
||||
}
|
||||
|
||||
@@ -86,7 +85,6 @@ function extractTemplateRegions(body) {
|
||||
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(),
|
||||
font_family: FONT_FAMILY_REGION_TYPES.includes(regionType) ? String(region.font_family || '').trim() || null : null,
|
||||
lock_ratio: normalizeTemplateRegionLockRatio(region.lock_ratio),
|
||||
x: Number(region.x || 0),
|
||||
y: Number(region.y || 0),
|
||||
@@ -108,7 +106,6 @@ function extractTemplateRegions(body) {
|
||||
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) {
|
||||
@@ -122,7 +119,6 @@ function extractTemplateRegions(body) {
|
||||
region_key: name,
|
||||
region_type: regionType,
|
||||
label: name,
|
||||
font_family: FONT_FAMILY_REGION_TYPES.includes(regionType) ? String(fonts[i] || 'Arial').trim() || 'Arial' : null,
|
||||
lock_ratio: normalizeTemplateRegionLockRatio(ratios[i]),
|
||||
x: Number(xs[i] || 0),
|
||||
y: Number(ys[i] || 0),
|
||||
@@ -135,6 +131,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) => {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// Shared helpers for parsing JSON, reading form arrays, and duplicate-name checks.
|
||||
|
||||
function parseJsonSafe(value) {
|
||||
if (!value) {
|
||||
return null;
|
||||
|
||||
Vendored
+14
-2
@@ -1,7 +1,19 @@
|
||||
const { hashPassword } = require('../auth');
|
||||
const { PERMISSIONS, DEFAULT_ROLE } = require('../rbac');
|
||||
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 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)
|
||||
|
||||
@@ -7,6 +7,7 @@ function createPool() {
|
||||
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
|
||||
|
||||
+90
-16
@@ -1,3 +1,4 @@
|
||||
// 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 (
|
||||
@@ -19,10 +20,12 @@ async function ensureSchema(pool, options) {
|
||||
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
|
||||
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
|
||||
`);
|
||||
|
||||
@@ -40,15 +43,6 @@ async function ensureSchema(pool, options) {
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
|
||||
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)
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS c_template_regions (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
@@ -92,12 +86,7 @@ async function ensureSchema(pool, options) {
|
||||
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,
|
||||
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,
|
||||
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,
|
||||
@@ -107,6 +96,25 @@ async function ensureSchema(pool, options) {
|
||||
) 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,
|
||||
@@ -122,6 +130,40 @@ async function ensureSchema(pool, options) {
|
||||
) 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,
|
||||
@@ -171,6 +213,35 @@ async function ensureSchema(pool, options) {
|
||||
) 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,
|
||||
@@ -287,6 +358,9 @@ async function ensureSchema(pool, options) {
|
||||
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 = {
|
||||
|
||||
+413
-3
@@ -1,7 +1,417 @@
|
||||
const { version: appVersion } = require('../../package.json');
|
||||
const { version: appVersion } = require('#root/package.json');
|
||||
|
||||
async function runMigrations(_pool, _options) {
|
||||
return;
|
||||
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.
|
||||
const [screenPlayerFkRows] = await pool.query(
|
||||
`SELECT COUNT(*) AS fk_count
|
||||
FROM information_schema.KEY_COLUMN_USAGE
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'd_screens'
|
||||
AND CONSTRAINT_NAME = 'fk_screens_player'`
|
||||
);
|
||||
if (Number(screenPlayerFkRows && screenPlayerFkRows[0] && screenPlayerFkRows[0].fk_count) === 0) {
|
||||
await pool.query('ALTER TABLE d_screens ADD CONSTRAINT fk_screens_player FOREIGN KEY (player_id) REFERENCES d_players(device_id) ON DELETE 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
|
||||
`);
|
||||
}
|
||||
},
|
||||
];
|
||||
|
||||
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 : '';
|
||||
await pool.query('ALTER TABLE ' + tableName + ' ADD COLUMN ' + columnName + ' ' + columnDefinition + afterClause);
|
||||
}
|
||||
|
||||
async function ensureForeignKey(pool, tableName, constraintName, columnName, referencedTable, referencedColumn, onDeleteAction) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT COUNT(*) AS fk_count
|
||||
FROM information_schema.KEY_COLUMN_USAGE
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = ?
|
||||
AND CONSTRAINT_NAME = ?`,
|
||||
[tableName, constraintName]
|
||||
);
|
||||
|
||||
if (Number(rows && rows[0] && rows[0].fk_count) > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await pool.query('ALTER TABLE ' + tableName + ' ADD CONSTRAINT ' + constraintName + ' FOREIGN KEY (' + columnName + ') REFERENCES ' + referencedTable + '(' + referencedColumn + ') ON DELETE ' + onDeleteAction);
|
||||
}
|
||||
|
||||
async function dropColumnIfExists(pool, tableName, columnName) {
|
||||
if (await columnExists(pool, tableName, columnName)) {
|
||||
await pool.query('ALTER TABLE ' + tableName + ' DROP COLUMN ' + columnName);
|
||||
}
|
||||
}
|
||||
|
||||
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 = {
|
||||
|
||||
+17
-1
@@ -9,6 +9,7 @@ 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, media API, and websocket wiring.
|
||||
@@ -16,6 +17,9 @@ async function start() {
|
||||
const app = express();
|
||||
const pool = common.createPool();
|
||||
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 MEDIA_DIR = path.join(__dirname, '..', 'media');
|
||||
const ONBOARDING_QUEUE_FILE = path.join(MEDIA_DIR, 'player-onboarding-queue.json');
|
||||
@@ -41,6 +45,8 @@ async function start() {
|
||||
common: common,
|
||||
playerRuntime: playerRuntime,
|
||||
onboardingStore: onboardingStore,
|
||||
playerPublicBaseUrl: PLAYER_PUBLIC_BASE_URL,
|
||||
playerInternalBaseUrl: PLAYER_INTERNAL_BASE_URL,
|
||||
QRCode: require('qrcode')
|
||||
});
|
||||
registerPlayerRoutes(app, {
|
||||
@@ -50,7 +56,10 @@ async function start() {
|
||||
assetDir: ASSET_DIR,
|
||||
playerRuntime: playerRuntime,
|
||||
playerPlaylistService: playerPlaylistService,
|
||||
rtmpStreamService: rtmpStreamService
|
||||
rtmpStreamService: rtmpStreamService,
|
||||
playerPublicBaseUrl: PLAYER_PUBLIC_BASE_URL,
|
||||
playerInternalBaseUrl: PLAYER_INTERNAL_BASE_URL,
|
||||
playerIdentifier: PLAYER_IDENTIFIER
|
||||
});
|
||||
|
||||
app.use(function (error, _req, res, _next) {
|
||||
@@ -59,6 +68,7 @@ async function start() {
|
||||
});
|
||||
|
||||
fs.mkdirSync(MEDIA_DIR, { recursive: true });
|
||||
await ensureFontLibrary(MEDIA_DIR);
|
||||
|
||||
server.listen(PORT, function () {
|
||||
console.log(`Pulse Signage app listening on port ${PORT}`);
|
||||
@@ -89,6 +99,12 @@ async function start() {
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@@ -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,197 @@
|
||||
<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;
|
||||
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: translateX(0);
|
||||
}
|
||||
to {
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
</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,199 @@
|
||||
<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;
|
||||
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: translateX(0);
|
||||
}
|
||||
to {
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
</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>
|
||||
@@ -1,3 +1,5 @@
|
||||
// RTMP stream session management and ffmpeg probe orchestration.
|
||||
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
const { isClientNameAvailable, withClientNameReservation } = require('../../data/client-name-check');
|
||||
const { getSharedSecret, verifyPageAuthToken } = require('../../request-auth');
|
||||
// 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;
|
||||
@@ -10,8 +12,8 @@ function normalizeDeviceId(value) {
|
||||
return String(value || '').trim().replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 128);
|
||||
}
|
||||
|
||||
function getPublicBaseUrl(req) {
|
||||
const configured = String(process.env.PLAYER_PUBLIC_BASE_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, '');
|
||||
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;
|
||||
}
|
||||
@@ -40,6 +42,15 @@ 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();
|
||||
@@ -121,6 +132,62 @@ async function commitDeviceBinding(pool, deviceId, clientName, screenSlug, isNam
|
||||
});
|
||||
}
|
||||
|
||||
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()
|
||||
@@ -184,9 +251,13 @@ function registerPlayerOnboardingRoutes(app, options) {
|
||||
res.send(common.renderPlayerOnboardingLandingPage());
|
||||
});
|
||||
|
||||
app.get('/onboard', function (req, res) {
|
||||
app.get('/onboard', async function (req, res, next) {
|
||||
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
|
||||
res.send(common.renderPlayerOnboardingFormPage(String(req.query.deviceId || '').trim()));
|
||||
try {
|
||||
res.send(common.renderPlayerOnboardingFormPage(String(req.query.deviceId || '').trim()));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/onboarding/status', function (req, res, next) {
|
||||
@@ -231,7 +302,7 @@ function registerPlayerOnboardingRoutes(app, options) {
|
||||
if (!deviceId) {
|
||||
return res.status(400).json({ error: 'Device ID is required' });
|
||||
}
|
||||
const onboardingUrl = `${getPublicBaseUrl(req)}/onboard?deviceId=${encodeURIComponent(deviceId)}`;
|
||||
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');
|
||||
@@ -262,6 +333,7 @@ function registerPlayerOnboardingRoutes(app, options) {
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -280,8 +352,12 @@ function registerPlayerOnboardingRoutes(app, options) {
|
||||
module.exports = {
|
||||
normalizeDeviceId: normalizeDeviceId,
|
||||
getPublicBaseUrl: getPublicBaseUrl,
|
||||
getPlayerPublicBaseUrl: getPlayerPublicBaseUrl,
|
||||
getPlayerInternalBaseUrl: getPlayerInternalBaseUrl,
|
||||
getOnboardingStatus: getOnboardingStatus,
|
||||
commitDeviceBinding: commitDeviceBinding,
|
||||
upsertPlayerRegistration: upsertPlayerRegistration,
|
||||
bindPlayerToScreen: bindPlayerToScreen,
|
||||
bindDeviceToScreen: bindDeviceToScreen,
|
||||
registerPlayerOnboardingRoutes: registerPlayerOnboardingRoutes
|
||||
};
|
||||
@@ -1,3 +1,5 @@
|
||||
// File-backed storage for onboarding device mappings.
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
// Announcement overlay polling and rendering helpers.
|
||||
|
||||
(function () {
|
||||
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 socketUrl = new URL(announcementSocketPath, window.location.origin);
|
||||
if (window.__pulsePageAuthToken) {
|
||||
socketUrl.searchParams.set('auth', window.__pulsePageAuthToken);
|
||||
}
|
||||
|
||||
var socket = new WebSocket(socketUrl.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();
|
||||
});
|
||||
})();
|
||||
@@ -1,7 +1,41 @@
|
||||
<script>
|
||||
const slug = {{SLUG_JSON}};
|
||||
const initialData = {{INITIAL_DATA_JSON}};
|
||||
window.slug = slug;
|
||||
window.initialData = initialData;
|
||||
const app = document.getElementById('app');
|
||||
(function () {
|
||||
var root = window;
|
||||
var registry = root.pulsePlayerRegionTypes && typeof root.pulsePlayerRegionTypes === 'object' ? root.pulsePlayerRegionTypes : {};
|
||||
|
||||
function normalizeType(type) {
|
||||
return String(type || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function register(type, definition) {
|
||||
registry[normalizeType(type)] = definition || {};
|
||||
return registry[normalizeType(type)];
|
||||
}
|
||||
|
||||
function get(type) {
|
||||
return registry[normalizeType(type)] || null;
|
||||
}
|
||||
|
||||
function list() {
|
||||
return Object.keys(registry).map(function (type) {
|
||||
return {
|
||||
type: type,
|
||||
definition: registry[type] || {}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
root.pulsePlayerRegionTypes = {
|
||||
register: register,
|
||||
get: get,
|
||||
list: list
|
||||
};
|
||||
}());
|
||||
let slides = Array.isArray(initialData && initialData.slides) ? initialData.slides.map(normalizeSlide) : [];
|
||||
let currentPlaylistSignature = '';
|
||||
let currentPlaylistEtag = '';
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
<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/css/player.css?v=36" />
|
||||
{{{STYLESHEETS}}}
|
||||
</head>
|
||||
<body class="{{BODY_CLASS}}">
|
||||
{{{BODY}}}
|
||||
|
||||
+55
-19
@@ -1,3 +1,5 @@
|
||||
// Player playlist assembly, snapshot persistence, and playlist revision helpers.
|
||||
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
@@ -58,7 +60,7 @@ function createPlayerPlaylistService(options) {
|
||||
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: [] };
|
||||
return { screen: null, playlist: null, slides: [], rssFeeds: [], apiSources: [], timetableGroups: [] };
|
||||
}
|
||||
|
||||
const screen = screenRows[0];
|
||||
@@ -67,7 +69,10 @@ function createPlayerPlaylistService(options) {
|
||||
screen: screen,
|
||||
playlist: null,
|
||||
slides: [],
|
||||
revision: getPlaylistRevision(screen, null, [], [], [], [], [])
|
||||
rssFeeds: [],
|
||||
apiSources: [],
|
||||
timetableGroups: [],
|
||||
revision: getPlaylistRevision(screen, null, [], [], [], [], [], [])
|
||||
};
|
||||
await writeSnapshot(slug, payloadWithoutPlaylist);
|
||||
return payloadWithoutPlaylist;
|
||||
@@ -77,7 +82,7 @@ function createPlayerPlaylistService(options) {
|
||||
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.schedule_mode, ps.schedule_start_datetime, ps.schedule_end_datetime, ps.schedule_start_time, ps.schedule_end_time, ps.schedule_days_json,
|
||||
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
|
||||
@@ -86,6 +91,21 @@ function createPlayerPlaylistService(options) {
|
||||
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; })
|
||||
@@ -101,7 +121,7 @@ function createPlayerPlaylistService(options) {
|
||||
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, font_family, lock_ratio, 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]);
|
||||
[regionRows] = 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 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;
|
||||
@@ -133,10 +153,17 @@ function createPlayerPlaylistService(options) {
|
||||
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;
|
||||
}
|
||||
@@ -147,12 +174,8 @@ function createPlayerPlaylistService(options) {
|
||||
modified_at: slide.modified_at,
|
||||
duration_seconds: videoDuration || storedDuration,
|
||||
use_video_duration: Boolean(slide.use_video_duration),
|
||||
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,
|
||||
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
|
||||
@@ -182,8 +205,14 @@ function createPlayerPlaylistService(options) {
|
||||
});
|
||||
}
|
||||
|
||||
const revision = getPlaylistRevision(screen, playlist, slideRows, templateRows, regionRows, rssFeeds, apiSources);
|
||||
const payload = { screen: screen, playlist: playlist, slides: slides, rssFeeds: rssFeeds, apiSources: apiSources, revision: revision };
|
||||
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) {
|
||||
@@ -200,7 +229,7 @@ function createPlayerPlaylistService(options) {
|
||||
hash.update('\0');
|
||||
}
|
||||
|
||||
function getPlaylistRevision(screen, playlist, slideRows, templateRows, regionRows, rssFeeds, apiSources) {
|
||||
function getPlaylistRevision(screen, playlist, slideRows, scheduleRuleRows, templateRows, regionRows, rssFeeds, apiSources, timetableGroups) {
|
||||
const hash = crypto.createHash('sha1');
|
||||
|
||||
updatePlaylistRevisionHash(hash, screen && screen.id);
|
||||
@@ -221,12 +250,18 @@ function createPlayerPlaylistService(options) {
|
||||
updatePlaylistRevisionHash(hash, slide.position);
|
||||
updatePlaylistRevisionHash(hash, slide.duration_seconds);
|
||||
updatePlaylistRevisionHash(hash, slide.use_video_duration);
|
||||
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);
|
||||
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) {
|
||||
@@ -257,6 +292,7 @@ function createPlayerPlaylistService(options) {
|
||||
|
||||
updatePlaylistRevisionHash(hash, JSON.stringify(rssFeeds || []));
|
||||
updatePlaylistRevisionHash(hash, JSON.stringify(apiSources || []));
|
||||
updatePlaylistRevisionHash(hash, JSON.stringify(timetableGroups || []));
|
||||
|
||||
return hash.digest('hex');
|
||||
}
|
||||
|
||||
@@ -220,6 +220,30 @@ 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: translate(-50%, -50%);
|
||||
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%;
|
||||
@@ -312,7 +336,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;
|
||||
@@ -322,38 +348,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;
|
||||
}
|
||||
|
||||
@@ -424,29 +463,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;
|
||||
@@ -464,3 +480,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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
})();
|
||||
@@ -106,9 +106,45 @@ function clearSlideTransitionTimer() {
|
||||
}
|
||||
}
|
||||
|
||||
function getPlayerRegionModules() {
|
||||
if (!window.pulsePlayerRegionTypes || typeof window.pulsePlayerRegionTypes.list !== 'function') {
|
||||
return [];
|
||||
}
|
||||
|
||||
return window.pulsePlayerRegionTypes.list();
|
||||
}
|
||||
|
||||
function runRegionLifecycle(root, lifecycleName) {
|
||||
if (!root) {
|
||||
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);
|
||||
}
|
||||
@@ -129,7 +165,7 @@ function renderSlideMarkup(markup, shouldFade) {
|
||||
|
||||
video.autoplay = true;
|
||||
video.loop = true;
|
||||
video.muted = true;
|
||||
video.muted = !(video.dataset && video.dataset.disableAudio === '0');
|
||||
video.playsInline = true;
|
||||
|
||||
function startPlayback() {
|
||||
@@ -172,6 +208,7 @@ function renderSlideMarkup(markup, shouldFade) {
|
||||
if (typeof syncRtmpRegions === 'function') {
|
||||
syncRtmpRegions(app);
|
||||
}
|
||||
initializeRegionInstances(app);
|
||||
initializeRenderedVideoPlayback(app);
|
||||
return app.firstElementChild;
|
||||
}
|
||||
@@ -224,6 +261,7 @@ function renderSlideMarkup(markup, shouldFade) {
|
||||
if (typeof syncRtmpRegions === 'function') {
|
||||
syncRtmpRegions(nextShell);
|
||||
}
|
||||
initializeRegionInstances(nextShell);
|
||||
initializeRenderedVideoPlayback(nextShell, slideFadeDurationMs / 2);
|
||||
return nextShell;
|
||||
}
|
||||
@@ -245,6 +283,8 @@ function renderSlideMarkup(markup, shouldFade) {
|
||||
syncRtmpRegions(nextShell);
|
||||
}
|
||||
|
||||
initializeRegionInstances(nextShell);
|
||||
|
||||
initializeRenderedVideoPlayback(nextShell, slideFadeDurationMs / 2);
|
||||
|
||||
slideTransitionTimer = window.setTimeout(function () {
|
||||
@@ -389,6 +429,11 @@ function handleCommandMessage(rawMessage) {
|
||||
case 'reload':
|
||||
window.location.reload();
|
||||
return;
|
||||
case 'announcement-refresh':
|
||||
if (typeof refreshAnnouncementOverlay === 'function') {
|
||||
refreshAnnouncementOverlay();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -53,6 +53,17 @@ function fitCanvasSize(canvasWidth, canvasHeight, maxWidth, maxHeight) {
|
||||
};
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -114,7 +125,7 @@ function sanitizeRichTextAttributes(tagName, attrText) {
|
||||
return attrs.join('');
|
||||
}
|
||||
|
||||
// Remove unsafe markup while preserving richer CKEditor formatting.
|
||||
// Remove unsafe markup while preserving richer rich-text formatting.
|
||||
function sanitizeRichText(html) {
|
||||
var output = String(html || '');
|
||||
output = output.replace(/<script[\s\S]*?<\/script>/gi, '');
|
||||
@@ -202,6 +213,17 @@ function renderEditorJsTable(data) {
|
||||
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') {
|
||||
@@ -221,7 +243,7 @@ function renderEditorJsContent(value) {
|
||||
} catch (_error) {
|
||||
// fall through to legacy HTML rendering
|
||||
}
|
||||
return sanitizeRichText(raw);
|
||||
return wrapRichTextParagraph(sanitizeRichText(raw));
|
||||
}
|
||||
|
||||
// Parse string values that look like JSON.
|
||||
@@ -318,31 +340,82 @@ function parseTimeToMinutes(value) {
|
||||
return Number(match[1]) * 60 + Number(match[2]);
|
||||
}
|
||||
|
||||
// Determine whether a slide should be shown at the current time.
|
||||
function isSlideActive(slide, now) {
|
||||
const mode = String(slide.schedule_mode || 'always');
|
||||
if (mode === 'always') {
|
||||
return true;
|
||||
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 (mode === 'dates') {
|
||||
const start = slide.schedule_start_datetime ? new Date(slide.schedule_start_datetime) : null;
|
||||
const end = slide.schedule_end_datetime ? new Date(slide.schedule_end_datetime) : null;
|
||||
if (!start || !end || Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) {
|
||||
return false;
|
||||
if (startTime || endTime) {
|
||||
if (!startTime || !endTime) {
|
||||
return null;
|
||||
}
|
||||
return now >= start && now <= end;
|
||||
}
|
||||
if (mode === 'times') {
|
||||
const days = parseScheduleDays(slide.schedule_days_json);
|
||||
if (!days.length) {
|
||||
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;
|
||||
}
|
||||
const day = now.getDay();
|
||||
if (days.indexOf(day) === -1) {
|
||||
if (now < start || now > end) {
|
||||
return false;
|
||||
}
|
||||
const startMinutes = parseTimeToMinutes(slide.schedule_start_time);
|
||||
const endMinutes = parseTimeToMinutes(slide.schedule_end_time);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -352,8 +425,20 @@ function isSlideActive(slide, now) {
|
||||
}
|
||||
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;
|
||||
@@ -415,15 +500,14 @@ function getTemplateLayout(template) {
|
||||
function buildBackdropStyle(backgroundColor, backgroundImagePath) {
|
||||
var color = String(backgroundColor || '#111111').trim() || '#111111';
|
||||
var style = 'background-color:' + escapeHtml(color) + ';';
|
||||
var gradient = 'linear-gradient(rgba(0, 0, 0, 0.42), rgba(0, 0, 0, 0.42))';
|
||||
|
||||
if (backgroundImagePath) {
|
||||
style += 'background-image:' + gradient + ',url("' + escapeHtml(backgroundImagePath) + '");';
|
||||
style += 'background-position:center,center;background-size:100% 100%,cover;background-repeat:no-repeat,no-repeat;';
|
||||
style += 'background-image:url("' + escapeHtml(backgroundImagePath) + '");';
|
||||
style += 'background-position:center;background-size:contain;background-repeat:no-repeat;';
|
||||
return style;
|
||||
}
|
||||
|
||||
style += 'background-image:' + gradient + ';background-position:center;background-size:100% 100%;background-repeat:no-repeat;';
|
||||
style += 'background-image:none;background-position:center;background-size:cover;background-repeat:no-repeat;';
|
||||
return style;
|
||||
}
|
||||
|
||||
@@ -446,9 +530,17 @@ function getTemplateRenderPlan(template) {
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -489,6 +581,9 @@ function renderTemplateSlideMarkup(slide) {
|
||||
return plan.renderRegion(region, regionContent);
|
||||
}).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>');
|
||||
}
|
||||
|
||||
@@ -506,6 +601,7 @@ 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);
|
||||
}
|
||||
|
||||
|
||||
+22
-1
@@ -1,4 +1,6 @@
|
||||
const CACHE_VERSION = 'v2';
|
||||
// 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}`;
|
||||
@@ -14,6 +16,20 @@ function normalizeRequest(request) {
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -124,6 +140,11 @@ self.addEventListener('fetch', function (event) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldBypassCache(request)) {
|
||||
event.respondWith(networkOnly(request));
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith('/assets/')) {
|
||||
event.respondWith(cacheFirst(request, ASSET_CACHE));
|
||||
return;
|
||||
|
||||
+63
-26
@@ -1,3 +1,8 @@
|
||||
// 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);
|
||||
@@ -6,9 +11,29 @@ function getApiSourceById(sourceId) {
|
||||
}) || null;
|
||||
}
|
||||
|
||||
function getApiSourceItems(sourceId) {
|
||||
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;
|
||||
}
|
||||
@@ -24,38 +49,25 @@ function getApiSourceItems(sourceId) {
|
||||
return responseJson ? [responseJson] : [];
|
||||
}
|
||||
|
||||
function getApiItem(sourceId, itemNumber) {
|
||||
var items = getApiSourceItems(sourceId);
|
||||
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 resolveApiPath(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 substituteApiVariables(html, item) {
|
||||
var source = String(html || '');
|
||||
return source.replace(/\{\{\s*([a-zA-Z0-9_]+)(?:\.([a-zA-Z0-9_.]+))?\s*\}\}/g, function (_match, tokenName, tokenPath) {
|
||||
return source.replace(/\{\{\s*([^{}]+?)\s*\}\}/g, function (_match, expression) {
|
||||
if (!item || typeof item !== 'object') {
|
||||
return '';
|
||||
}
|
||||
var key = tokenName === 'item' && tokenPath ? tokenPath : tokenName;
|
||||
return escapeHtml(resolveApiPath(item, key || ''));
|
||||
|
||||
if (typeof placeholderUtils.resolvePlaceholderExpression !== 'function' || typeof placeholderUtils.formatPlaceholderValue !== 'function') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return escapeHtml(placeholderUtils.formatPlaceholderValue(placeholderUtils.resolvePlaceholderExpression(item, expression)));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -79,14 +91,35 @@ function getApiPreviewFallback(item) {
|
||||
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 = regionContent && regionContent.value !== undefined ? regionContent.value : '';
|
||||
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);
|
||||
var item = getApiItem(sourceId, itemNumber, itemsPath);
|
||||
var body = item ? substituteApiVariables(content, item) : '';
|
||||
if (!body && item) {
|
||||
body = getApiPreviewFallback(item);
|
||||
@@ -97,4 +130,8 @@ function renderApiRegion(region, regionContent) {
|
||||
}
|
||||
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
|
||||
});
|
||||
@@ -1,3 +1,7 @@
|
||||
// HTML region rendering with sandboxed iframe output.
|
||||
|
||||
var registry = window.pulsePlayerRegionTypes;
|
||||
|
||||
function renderHtmlRegionContent(value) {
|
||||
var html = String(value || '').trim();
|
||||
if (!html) {
|
||||
@@ -8,4 +12,8 @@ function renderHtmlRegionContent(value) {
|
||||
|
||||
function renderHtmlRegion(region, regionContent) {
|
||||
return '<div class="template-region html" style="' + region.baseStyle + '">' + renderHtmlRegionContent(regionContent.value || '') + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
registry.register('html', {
|
||||
renderRegion: renderHtmlRegion
|
||||
});
|
||||
@@ -1,7 +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
|
||||
});
|
||||
@@ -1,3 +1,35 @@
|
||||
// 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);
|
||||
@@ -37,18 +69,19 @@ function substituteRssVariables(html, item) {
|
||||
if (!item || typeof item !== 'object') {
|
||||
return '';
|
||||
}
|
||||
var key = tokenName === 'item' && tokenPath ? tokenPath : tokenName;
|
||||
var key = tokenPath ? tokenName + '.' + tokenPath : tokenName;
|
||||
return escapeHtml(resolveRssPath(item, key || ''));
|
||||
});
|
||||
}
|
||||
|
||||
function renderRssRegion(region, regionContent) {
|
||||
var content = regionContent && regionContent.value !== undefined ? regionContent.value : '';
|
||||
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 fontFamily = sanitizeFontFamily(regionContent.font_family || region.fontFamily);
|
||||
var fontSize = sanitizeFontSize(regionContent.font_size || region.fontSize);
|
||||
var fontColor = sanitizeTextColor(regionContent.font_color || region.fontColor);
|
||||
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) {
|
||||
@@ -68,3 +101,8 @@ function renderRssRegion(region, regionContent) {
|
||||
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
|
||||
});
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
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);
|
||||
@@ -6,7 +8,7 @@ function renderRtmpRegion(region, regionContent) {
|
||||
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></video><div class="template-region-placeholder template-region-rtmp-placeholder">Loading RTMP stream...</div></div>';
|
||||
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) {
|
||||
@@ -822,4 +824,8 @@ function destroyRtmpRegions(root) {
|
||||
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
|
||||
});
|
||||
@@ -1,13 +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 = regionContent && regionContent.value !== undefined ? regionContent.value : '';
|
||||
var rawValue = normalizeRenderableValue(regionContent && regionContent.value !== undefined ? regionContent.value : '');
|
||||
if (!String(rawValue || '').trim()) {
|
||||
return '';
|
||||
}
|
||||
|
||||
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 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
|
||||
});
|
||||
@@ -1,3 +1,5 @@
|
||||
// Video region playback, retry, and source probe helpers.
|
||||
|
||||
var videoRegionLastGoodSrcCache = Object.create(null);
|
||||
var videoRegionProbeStateCache = Object.create(null);
|
||||
var videoRegionProbeTimerCache = Object.create(null);
|
||||
@@ -109,6 +111,7 @@ function renderVideoRegion(region, regionContent) {
|
||||
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 '';
|
||||
@@ -119,7 +122,7 @@ function renderVideoRegion(region, regionContent) {
|
||||
videoRegionLastGoodSrcCache[regionKey] = requestedSrc;
|
||||
}
|
||||
setVideoSourceAvailability(requestedSrc, true);
|
||||
return '<div class="template-region video" style="' + region.baseStyle + '"><video src="' + escapeHtml(requestedSrcVersioned) + '" title="' + escapeHtml(region.label) + '" autoplay muted loop playsinline preload="auto" disablepictureinpicture oncanplay="this.play().catch(function () {})" onloadedmetadata="this.play().catch(function () {})"></video></div>';
|
||||
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);
|
||||
@@ -129,7 +132,7 @@ function renderVideoRegion(region, regionContent) {
|
||||
if (regionKey) {
|
||||
videoRegionLastGoodSrcCache[regionKey] = requestedSrc;
|
||||
}
|
||||
return '<div class="template-region video" style="' + region.baseStyle + '"><video src="' + escapeHtml(requestedSrcVersioned) + '" title="' + escapeHtml(region.label) + '" autoplay muted loop playsinline preload="auto" disablepictureinpicture oncanplay="this.play().catch(function () {})" onloadedmetadata="this.play().catch(function () {})"></video></div>';
|
||||
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);
|
||||
@@ -138,8 +141,14 @@ function renderVideoRegion(region, regionContent) {
|
||||
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) + '" autoplay muted loop playsinline preload="auto" disablepictureinpicture oncanplay="this.play().catch(function () {})" onloadedmetadata="this.play().catch(function () {})"></video></div>';
|
||||
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
|
||||
});
|
||||
}
|
||||
@@ -1,7 +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
|
||||
});
|
||||
+109
-13
@@ -1,5 +1,6 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const announcementIcons = require('#src/data/announcement-icons');
|
||||
|
||||
function mediaKind(mediaPath) {
|
||||
const ext = path.extname(mediaPath || '').toLowerCase();
|
||||
@@ -235,6 +236,17 @@ function renderEditorJsTable(data) {
|
||||
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)) {
|
||||
@@ -253,7 +265,7 @@ function renderEditorJsContent(value) {
|
||||
} catch (_error) {
|
||||
// fall through to legacy HTML rendering
|
||||
}
|
||||
return sanitizeRichText(raw);
|
||||
return wrapRichTextParagraph(sanitizeRichText(raw));
|
||||
}
|
||||
|
||||
function renderHtmlRegionContent(value) {
|
||||
@@ -283,17 +295,13 @@ const playerPagePlaylistScriptPath = path.join(__dirname, 'public', 'js', 'playe
|
||||
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 playerRegionScriptPaths = [
|
||||
path.join(__dirname, 'regions', 'image.js'),
|
||||
path.join(__dirname, 'regions', 'video.js'),
|
||||
path.join(__dirname, 'regions', 'webpage.js'),
|
||||
path.join(__dirname, 'regions', 'html.js'),
|
||||
path.join(__dirname, 'regions', 'rtmp.js'),
|
||||
path.join(__dirname, 'regions', 'rss.js'),
|
||||
path.join(__dirname, 'regions', 'api.js'),
|
||||
path.join(__dirname, 'regions', 'text.js')
|
||||
];
|
||||
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;
|
||||
@@ -303,6 +311,9 @@ 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;
|
||||
@@ -347,19 +358,100 @@ 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 statSignature = playerRegionScriptPaths.map(function (filePath) {
|
||||
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 = playerRegionScriptPaths.map(function (filePath) {
|
||||
const value = scriptPaths.map(function (filePath) {
|
||||
return fs.readFileSync(filePath, 'utf8').trim();
|
||||
}).join('\n\n');
|
||||
playerRegionScriptsCache = {
|
||||
@@ -403,6 +495,10 @@ module.exports = {
|
||||
getPlayerPageCommandsScript: getPlayerPageCommandsScript,
|
||||
getPlayerPageRenderingScript: getPlayerPageRenderingScript,
|
||||
getPlayerPagePlaybackScript: getPlayerPagePlaybackScript,
|
||||
getAnnouncementIconsDataScript: getAnnouncementIconsDataScript,
|
||||
getPlayerAnnouncementTemplatesDataScript: getPlayerAnnouncementTemplatesDataScript,
|
||||
getPlayerAnnouncementTemplatesScript: getPlayerAnnouncementTemplatesScript,
|
||||
getPlayerPageAnnouncementsScript: getPlayerPageAnnouncementsScript,
|
||||
getPlayerPageScript: getPlayerPageScript,
|
||||
getPlayerRegionScripts: getPlayerRegionScripts,
|
||||
getPlayerOnboardingLandingScript: getPlayerOnboardingLandingScript,
|
||||
|
||||
+20
-4
@@ -1,13 +1,23 @@
|
||||
// Player page rendering and bootstrap script assembly.
|
||||
|
||||
const Handlebars = require('handlebars');
|
||||
const { mediaKind, safeJsonForScript, getPlayerPageTemplate, getPlayerClientNameScript, getPlayerPageOfflineScript, getPlayerPagePlaylistScript, getPlayerPageCommandsScript, getPlayerPageRenderingScript, getPlayerPagePlaybackScript, getPlayerPageScript, getPlayerRegionScripts, getPlayerOnboardingLandingScript, getPlayerOnboardingFormScript } = require('./render-helpers');
|
||||
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('../request-auth');
|
||||
const { createPageAuthBundle, createPageFetchAuthScript } = require('#src/request-auth');
|
||||
const { getFontStylesheetHref } = require('#src/web/lib/media/font-library');
|
||||
|
||||
const PLAYER_MEDIA_DIR = path.join(__dirname, '..', '..', 'media');
|
||||
|
||||
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 || '')
|
||||
});
|
||||
}
|
||||
@@ -17,7 +27,7 @@ function getPlayerServiceWorkerRegistrationScript() {
|
||||
'<script>',
|
||||
' if ("serviceWorker" in navigator) {',
|
||||
' window.addEventListener("load", function () {',
|
||||
' navigator.serviceWorker.register("/sw.js").catch(function () {',
|
||||
' navigator.serviceWorker.register("/sw.js?v=38").catch(function () {',
|
||||
' return null;',
|
||||
' });',
|
||||
' });',
|
||||
@@ -109,6 +119,7 @@ function renderPlayerPage(slug, initialData) {
|
||||
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 script = getPlayerPageScript()({
|
||||
SLUG_JSON: new Handlebars.SafeString(JSON.stringify(slug)),
|
||||
INITIAL_DATA_JSON: new Handlebars.SafeString(safeJsonForScript(initialData || null)),
|
||||
@@ -118,26 +129,31 @@ function renderPlayerPage(slug, initialData) {
|
||||
return renderPage(template, {
|
||||
title: 'Screen ' + slug,
|
||||
body: '<div id="app"><div class="empty">Loading screen...</div></div>',
|
||||
script: createPageFetchAuthScript(pageAuthToken) + hlsScriptTag + serviceWorkerScript + createThumbnailPreviewBootstrapScript(initialData) + '<script>' + offlineScript + '</script>' + '<script>' + playlistScript + '</script>' + '<script>' + commandScript + '</script>' + '<script>' + renderingScript + '</script>' + '<script>' + playbackScript + '</script>' + onboardingScript + script
|
||||
stylesheets: fontStylesheetHref ? [fontStylesheetHref] : [],
|
||||
script: createPageFetchAuthScript(pageAuthToken) + 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)
|
||||
});
|
||||
}
|
||||
|
||||
+58
-1
@@ -1,7 +1,9 @@
|
||||
// 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('../request-auth');
|
||||
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'];
|
||||
@@ -18,6 +20,9 @@ function registerPlayerRoutes(app, options) {
|
||||
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.');
|
||||
@@ -75,6 +80,7 @@ function registerPlayerRoutes(app, options) {
|
||||
}
|
||||
|
||||
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')));
|
||||
|
||||
@@ -207,6 +213,13 @@ function registerPlayerRoutes(app, options) {
|
||||
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) {
|
||||
@@ -246,6 +259,11 @@ function registerPlayerRoutes(app, options) {
|
||||
});
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -273,6 +291,45 @@ function registerPlayerRoutes(app, options) {
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
+110
-5
@@ -1,7 +1,22 @@
|
||||
// Player runtime state, websocket connections, and request-auth enforcement.
|
||||
|
||||
const crypto = require('crypto');
|
||||
const { WebSocketServer, WebSocket } = require('ws');
|
||||
const { isClientNameAvailable } = require('../data/client-name-check');
|
||||
const { verifyPageAuthToken, verifyRequestAuth } = require('../request-auth');
|
||||
const { isClientNameAvailable } = require('#src/data/client-name-check');
|
||||
const { verifyPageAuthToken, verifyRequestAuth } = require('#src/request-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;
|
||||
@@ -12,6 +27,7 @@ function createPlayerRuntime(options) {
|
||||
};
|
||||
const connectionsBySlug = new Map();
|
||||
const dashboardListenersBySlug = new Map();
|
||||
const announcementListenersBySlug = new Map();
|
||||
const wss = new WebSocketServer({ noServer: true });
|
||||
|
||||
function normalizeClientIp(value) {
|
||||
@@ -72,6 +88,29 @@ function createPlayerRuntime(options) {
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
@@ -123,6 +162,7 @@ function createPlayerRuntime(options) {
|
||||
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),
|
||||
@@ -178,6 +218,30 @@ function createPlayerRuntime(options) {
|
||||
});
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -235,7 +299,9 @@ function createPlayerRuntime(options) {
|
||||
|
||||
const dashboardMatch = pathname.match(/^\/ws\/screens\/([^/]+)\/events$/);
|
||||
const playerMatch = pathname.match(/^\/ws\/screens\/([^/]+)$/);
|
||||
if (!dashboardMatch && !playerMatch) {
|
||||
const announcementMatch = pathname.match(/^\/ws\/screens\/([^/]+)\/announcements$/);
|
||||
|
||||
if (!dashboardMatch && !playerMatch && !announcementMatch) {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
@@ -256,9 +322,18 @@ function createPlayerRuntime(options) {
|
||||
}
|
||||
}
|
||||
|
||||
const slug = decodeURIComponent((dashboardMatch || playerMatch)[1]);
|
||||
if (announcementMatch) {
|
||||
const authToken = String(new URL(request.url, 'http://localhost').searchParams.get('auth') || '').trim();
|
||||
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' : 'player');
|
||||
wss.emit('connection', ws, request, slug, dashboardMatch ? 'dashboard' : announcementMatch ? 'announcements' : 'player');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -288,6 +363,30 @@ function createPlayerRuntime(options) {
|
||||
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);
|
||||
@@ -304,6 +403,7 @@ function createPlayerRuntime(options) {
|
||||
page: null,
|
||||
paused: false,
|
||||
blackout: false,
|
||||
playerPublicBaseUrl: null,
|
||||
clientIp: forwardedFor || normalizedRemoteAddress,
|
||||
remoteAddress: normalizedRemoteAddress,
|
||||
label: forwardedFor || normalizedRemoteAddress || 'connected client',
|
||||
@@ -341,6 +441,10 @@ function createPlayerRuntime(options) {
|
||||
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;
|
||||
@@ -372,6 +476,7 @@ function createPlayerRuntime(options) {
|
||||
|
||||
return {
|
||||
installWebsocket: installWebsocket,
|
||||
broadcastAnnouncementRefresh: broadcastAnnouncementRefresh,
|
||||
snapshotConnections: snapshotConnections,
|
||||
snapshotAllConnections: snapshotAllConnections,
|
||||
isClientNameAvailableOnScreen: isClientNameAvailableOnScreen,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// Thumbnail preview data and bootstrap script helpers for slide thumbnails.
|
||||
|
||||
function buildThumbnailPreviewData(slide) {
|
||||
return {
|
||||
thumbnailPreview: true,
|
||||
@@ -13,6 +15,7 @@ function buildThumbnailPreviewData(slide) {
|
||||
slides: [slide],
|
||||
rssFeeds: [],
|
||||
apiSources: [],
|
||||
timetableGroups: [],
|
||||
revision: String(slide.modified_at || slide.id || Date.now())
|
||||
};
|
||||
}
|
||||
|
||||
+71
-24
@@ -1,3 +1,5 @@
|
||||
// Permission section definitions and normalization helpers for RBAC.
|
||||
|
||||
const PERMISSION_SECTIONS = [
|
||||
{
|
||||
key: 'dashboard',
|
||||
@@ -79,9 +81,21 @@ const PERMISSION_SECTIONS = [
|
||||
{ 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: 75,
|
||||
order: 90,
|
||||
name: 'RSS feeds',
|
||||
sectionName: 'Data Sources',
|
||||
actions: [
|
||||
@@ -93,7 +107,7 @@ const PERMISSION_SECTIONS = [
|
||||
},
|
||||
{
|
||||
key: 'api-sources',
|
||||
order: 76,
|
||||
order: 100,
|
||||
name: 'API sources',
|
||||
sectionName: 'Data Sources',
|
||||
actions: [
|
||||
@@ -104,28 +118,20 @@ const PERMISSION_SECTIONS = [
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'background-tasks',
|
||||
order: 100,
|
||||
name: 'Background tasks',
|
||||
sectionName: 'Settings',
|
||||
key: 'timetables',
|
||||
order: 110,
|
||||
name: 'Timetables',
|
||||
sectionName: 'Data Sources',
|
||||
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: 101,
|
||||
name: 'Scheduled tasks',
|
||||
sectionName: 'Settings',
|
||||
actions: [
|
||||
{ key: 'read', name: 'Read', description: 'View scheduled refresh tasks.' },
|
||||
{ key: 'allow', name: 'Allow', description: 'Run scheduled refreshes manually.' }
|
||||
{ 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: 80,
|
||||
order: 120,
|
||||
name: 'Users',
|
||||
sectionName: 'Settings',
|
||||
actions: [
|
||||
@@ -137,7 +143,7 @@ const PERMISSION_SECTIONS = [
|
||||
},
|
||||
{
|
||||
key: 'rbac',
|
||||
order: 90,
|
||||
order: 130,
|
||||
name: 'Roles and permissions',
|
||||
sectionName: 'Settings',
|
||||
actions: [
|
||||
@@ -146,6 +152,37 @@ const PERMISSION_SECTIONS = [
|
||||
{ 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.' }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
@@ -174,6 +211,8 @@ function normalizePermissionKey(permissionKey) {
|
||||
return String(permissionKey || '').trim();
|
||||
}
|
||||
|
||||
const PERMISSION_DENIED_MESSAGE = 'You do not have permissions to do this.';
|
||||
|
||||
function normalizePermissionKeys(permissionKeys) {
|
||||
const normalized = [];
|
||||
|
||||
@@ -236,22 +275,29 @@ function hasAnyPermission(currentUser, permissionKeys) {
|
||||
});
|
||||
}
|
||||
|
||||
function requirePermission(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) {
|
||||
return res.redirect('/login?message=' + encodeURIComponent('Please sign in to continue.'));
|
||||
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('You do not have permission to access this area.');
|
||||
const error = new Error(PERMISSION_DENIED_MESSAGE);
|
||||
error.statusCode = 403;
|
||||
error.expose = true;
|
||||
next(error);
|
||||
@@ -265,5 +311,6 @@ module.exports = {
|
||||
hasPermission,
|
||||
hasAnyPermission,
|
||||
requirePermission,
|
||||
normalizePermissionKeys
|
||||
normalizePermissionKeys,
|
||||
PERMISSION_DENIED_MESSAGE
|
||||
};
|
||||
@@ -1,3 +1,5 @@
|
||||
// Page and request authentication signing helpers.
|
||||
|
||||
const crypto = require('crypto');
|
||||
|
||||
const PAGE_TOKEN_HEADER = 'x-pulse-page-auth';
|
||||
|
||||
+94
-239
@@ -1,32 +1,30 @@
|
||||
// Main web application bootstrap, route wiring, and lifecycle setup.
|
||||
|
||||
const express = require('express');
|
||||
const http = require('http');
|
||||
const multer = require('multer');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const common = require('./common');
|
||||
const { verifyPassword, createSessionToken, hashSessionToken, hashPassword } = require('./auth');
|
||||
const pages = require('./web/pages');
|
||||
const registerAuthRoutes = require('./web/routes/auth');
|
||||
const registerAdminPagesRoutes = require('./web/routes/admin/pages');
|
||||
const registerAdminAccountRoutes = require('./web/routes/admin/account');
|
||||
const registerAdminUsersRoutes = require('./web/routes/admin/users');
|
||||
const registerAdminManageRoutes = require('./web/routes/admin/manage');
|
||||
const registerAdminScreenCommandRoutes = require('./web/routes/admin/client-commands');
|
||||
const registerAdminContentRoutes = require('./web/routes/admin/content');
|
||||
const registerAdminDataSourceRoutes = require('./web/routes/data-sources');
|
||||
const registerAdminSettingsRoutes = require('./web/routes/settings/background-tasks');
|
||||
const { createBackgroundTaskQueue } = require('./web/lib/background-task-queue');
|
||||
const { createBackgroundTaskSetup } = require('./web/lib/background-task-setup');
|
||||
const { captureSlideThumbnail } = require('./web/lib/slide-thumbnails');
|
||||
const { refreshApiSource, refreshRssFeed } = require('./web/lib/data-source-refresh');
|
||||
const { createWebBootstrap } = require('./web/bootstrap');
|
||||
const { requirePermission } = require('./rbac');
|
||||
const rbacData = require('./web/lib/rbac-data');
|
||||
const { createPlayerActionService } = require('./web/lib/player-actions');
|
||||
const { isClientNameAvailable, withClientNameReservation } = require('./data/client-name-check');
|
||||
const { createSessionService } = require('./web/lib/session');
|
||||
const { hasAnyPermission } = require('./rbac');
|
||||
const { verifyPassword, createSessionToken, hashSessionToken, hashPassword } = require('#src/auth');
|
||||
const pages = require('#src/web/pages');
|
||||
const registerMiddleware = require('#src/web/middleware');
|
||||
const { createBackgroundTaskQueue } = require('#src/web/lib/background-tasks/queue');
|
||||
const { initializeBackgroundTasks } = require('#src/web/lib/background-tasks');
|
||||
const { createDataSourceTaskService } = require('#src/web/lib/background-tasks/tasks-scheduled/data-source-refresh');
|
||||
const { captureSlideThumbnail } = require('#src/web/lib/media');
|
||||
const { createWebBootstrap } = require('#src/web/bootstrap');
|
||||
const { registerRoutes } = require('#src/web/routes/register');
|
||||
const { createNotifyPlayerScreens } = require('#src/web/lib/notify-player-screens');
|
||||
const { initializeWebServer } = require('#src/web/lib/web-startup');
|
||||
const { createWebConfig } = require('#src/web/lib/config');
|
||||
const { requirePermission: createRequirePermission, PERMISSION_DENIED_MESSAGE } = require('#src/rbac');
|
||||
const { rbacData } = require('#src/web/lib/auth');
|
||||
const { createPlayerActionService } = require('#src/web/lib/player-actions');
|
||||
const { isClientNameAvailable, withClientNameReservation } = require('#src/data/client-name-check');
|
||||
const { createSessionService } = require('#src/web/lib/auth');
|
||||
const { hasAnyPermission } = require('#src/rbac');
|
||||
const { ensureFontLibrary } = require('#src/web/lib/media/font-library');
|
||||
const {
|
||||
formatDashboardDate,
|
||||
readArrayField,
|
||||
@@ -35,63 +33,46 @@ const {
|
||||
normalizeScheduleMode,
|
||||
getAuditUserId,
|
||||
getCanvasSignature,
|
||||
fetchPlaylistCanvasId,
|
||||
fetchPlaylistCanvasSignature,
|
||||
fetchScreensByPlaylistId,
|
||||
fetchScreensBySlideId,
|
||||
fetchScreensByTemplateId,
|
||||
fetchOrderedPlaylistSlides,
|
||||
redirectAfterSave
|
||||
} = require('./web/lib/helpers');
|
||||
const PLAYER_INTERNAL_BASE_URL = (process.env.PLAYER_INTERNAL_BASE_URL || process.env.PLAYER_BASE_URL || 'http://player:8081').replace(/\/$/, '');
|
||||
const PLAYER_PUBLIC_BASE_URL = (process.env.PLAYER_PUBLIC_BASE_URL || process.env.PLAYER_BASE_URL || 'http://localhost:8081').replace(/\/$/, '');
|
||||
const PLAYER_WS_BASE_URL = PLAYER_INTERNAL_BASE_URL.replace(/^http/, 'ws');
|
||||
const SESSION_COOKIE_NAME = 'digital_signage_session';
|
||||
const SESSION_MAX_AGE_DAYS = Number(process.env.SESSION_MAX_AGE_DAYS || 14);
|
||||
const SESSION_MAX_AGE_MS = (Number.isFinite(SESSION_MAX_AGE_DAYS) && SESSION_MAX_AGE_DAYS > 0 ? SESSION_MAX_AGE_DAYS : 14) * 24 * 60 * 60 * 1000;
|
||||
} = require('#src/web/lib/helpers');
|
||||
|
||||
async function start() {
|
||||
// Core app and filesystem setup.
|
||||
const app = express();
|
||||
const server = http.createServer(app);
|
||||
const pool = common.createPool();
|
||||
const PORT = Number(process.env.WEB_PORT || 8080);
|
||||
const MEDIA_DIR = path.join(__dirname, '..', 'media');
|
||||
const UPLOADS_DIR = path.join(MEDIA_DIR, 'uploads');
|
||||
const THUMBNAILS_DIR = path.join(MEDIA_DIR, 'thumbnails');
|
||||
const ASSET_DIR = path.join(__dirname, 'web', 'public');
|
||||
const WEB_BASE_URL = (process.env.WEB_PUBLIC_BASE_URL || process.env.WEB_BASE_URL || ('http://127.0.0.1:' + PORT)).replace(/\/$/, '');
|
||||
const DATA_SOURCE_STARTUP_REFRESH_STAGGER_MS = Math.max(100, Number(process.env.DATA_SOURCE_STARTUP_REFRESH_STAGGER_MS || 250));
|
||||
const webConfig = createWebConfig();
|
||||
|
||||
// Background services and request forwarding helpers.
|
||||
const backgroundTaskQueue = createBackgroundTaskQueue({
|
||||
pool: pool,
|
||||
maxConcurrent: 1
|
||||
});
|
||||
const playerActionService = createPlayerActionService({
|
||||
const dataSourceTasks = createDataSourceTaskService({
|
||||
pool: pool,
|
||||
common: common,
|
||||
playerInternalBaseUrl: PLAYER_INTERNAL_BASE_URL
|
||||
backgroundTaskQueue: backgroundTaskQueue
|
||||
});
|
||||
const notifyPlayerScreens = function (slugs, commandOrPayload) {
|
||||
const uniqueSlugs = Array.from(new Set((slugs || []).map(function (slug) {
|
||||
return String(slug || '').trim();
|
||||
}).filter(Boolean)));
|
||||
const playerActionService = createPlayerActionService({
|
||||
pool: pool,
|
||||
common: common,
|
||||
playerInternalBaseUrl: webConfig.playerInternalBaseUrl
|
||||
});
|
||||
const notifyPlayerScreens = createNotifyPlayerScreens(playerActionService.forwardPlayerCommand);
|
||||
|
||||
if (!uniqueSlugs.length) {
|
||||
return Promise.resolve(0);
|
||||
}
|
||||
|
||||
return Promise.allSettled(uniqueSlugs.map(function (slug) {
|
||||
return playerActionService.forwardPlayerCommand(slug, commandOrPayload || 'refresh');
|
||||
})).then(function (results) {
|
||||
return results.filter(function (result) {
|
||||
return result.status === 'fulfilled';
|
||||
}).length;
|
||||
});
|
||||
};
|
||||
// Centralized dashboard/player bootstrap.
|
||||
const webBootstrap = createWebBootstrap({
|
||||
pool: pool,
|
||||
common: common,
|
||||
playerInternalBaseUrl: PLAYER_INTERNAL_BASE_URL,
|
||||
playerPublicBaseUrl: PLAYER_PUBLIC_BASE_URL,
|
||||
uploadDir: UPLOADS_DIR,
|
||||
dashboardRefreshIntervalMs: Number(process.env.DASHBOARD_REFRESH_INTERVAL_MS || 2000),
|
||||
playerInternalBaseUrl: webConfig.playerInternalBaseUrl,
|
||||
playerPublicBaseUrl: webConfig.playerPublicBaseUrl,
|
||||
uploadDir: webConfig.uploadsDir,
|
||||
formatDashboardDate: formatDashboardDate,
|
||||
notifyPlayerScreens: notifyPlayerScreens,
|
||||
backgroundTaskQueue: backgroundTaskQueue,
|
||||
@@ -102,214 +83,84 @@ async function start() {
|
||||
const collectUploadReferencesFromTemplate = webBootstrap.collectUploadReferencesFromTemplate;
|
||||
const collectUploadReferencesFromPayload = webBootstrap.collectUploadReferencesFromPayload;
|
||||
const removeUnusedUploadFiles = webBootstrap.removeUnusedUploadFiles;
|
||||
const collectUploadPathsFromDirectory = webBootstrap.collectUploadPathsFromDirectory;
|
||||
const syncPlaylistUploadsOnChange = webBootstrap.syncPlaylistUploadsOnChange;
|
||||
const syncExistingUploadsToPlayer = webBootstrap.syncExistingUploadsToPlayer;
|
||||
const runMediaSyncTask = webBootstrap.runMediaSyncTask;
|
||||
const backgroundTaskSetup = createBackgroundTaskSetup({
|
||||
pool: pool,
|
||||
common: common,
|
||||
backgroundTaskQueue: backgroundTaskQueue,
|
||||
collectUploadPathsFromDirectory: collectUploadPathsFromDirectory,
|
||||
removeUnusedUploadFiles: removeUnusedUploadFiles,
|
||||
captureSlideThumbnail: captureSlideThumbnail,
|
||||
refreshApiSource: refreshApiSource,
|
||||
refreshRssFeed: refreshRssFeed,
|
||||
runMediaSyncTask: runMediaSyncTask,
|
||||
mediaDir: MEDIA_DIR,
|
||||
uploadsDir: UPLOADS_DIR,
|
||||
playerInternalBaseUrl: PLAYER_INTERNAL_BASE_URL,
|
||||
dataSourceStartupRefreshStaggerMs: DATA_SOURCE_STARTUP_REFRESH_STAGGER_MS
|
||||
});
|
||||
|
||||
// Background task initialization.
|
||||
// Session and auth helpers for route registration.
|
||||
const broadcastDashboardState = webBootstrap.broadcastDashboardState;
|
||||
const sessionService = createSessionService({
|
||||
sessionCookieName: SESSION_COOKIE_NAME,
|
||||
sessionMaxAgeMs: SESSION_MAX_AGE_MS,
|
||||
sessionCookieName: webConfig.sessionCookieName,
|
||||
sessionMaxAgeMs: webConfig.sessionMaxAgeMs,
|
||||
hashSessionToken: hashSessionToken,
|
||||
createSessionToken: createSessionToken
|
||||
});
|
||||
const parseCookies = sessionService.parseCookies;
|
||||
const createUserSession = sessionService.createUserSession;
|
||||
const clearSessionCookie = sessionService.clearSessionCookie;
|
||||
const setSessionCookie = sessionService.setSessionCookie;
|
||||
const setAuthMessageCookie = sessionService.setAuthMessageCookie;
|
||||
const consumeAuthMessageCookie = sessionService.consumeAuthMessageCookie;
|
||||
const loadCurrentUser = sessionService.loadCurrentUser;
|
||||
const createUserSession = sessionService.createUserSession;
|
||||
const requireAuth = sessionService.requireAuth;
|
||||
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
app.use(express.json());
|
||||
app.use('/assets', express.static(ASSET_DIR));
|
||||
app.use('/assets/vendor/cropperjs', express.static(path.join(__dirname, '..', 'node_modules', 'cropperjs', 'dist')));
|
||||
app.use('/media', express.static(MEDIA_DIR));
|
||||
fs.mkdirSync(UPLOADS_DIR, { recursive: true });
|
||||
fs.mkdirSync(THUMBNAILS_DIR, { recursive: true });
|
||||
|
||||
app.use(async function (req, _res, next) {
|
||||
try {
|
||||
req.currentUser = await loadCurrentUser(pool, req);
|
||||
next();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.use(function (req, res, next) {
|
||||
if (req.path === '/' || req.path === '/login' || req.path === '/logout' || req.path.indexOf('/api/internal/slide-thumbnails/') === 0) {
|
||||
return next();
|
||||
}
|
||||
|
||||
return requireAuth(req, res, next);
|
||||
});
|
||||
|
||||
registerAuthRoutes(app, {
|
||||
// Middleware and route registration.
|
||||
registerMiddleware(app, {
|
||||
pool: pool,
|
||||
loadCurrentUser: loadCurrentUser,
|
||||
requireAuth: requireAuth,
|
||||
MEDIA_DIR: webConfig.mediaDir,
|
||||
UPLOADS_DIR: webConfig.uploadsDir,
|
||||
THUMBNAILS_DIR: webConfig.thumbnailsDir,
|
||||
ASSET_DIR: webConfig.assetDir
|
||||
});
|
||||
|
||||
registerRoutes(app, {
|
||||
pool: pool,
|
||||
common: common,
|
||||
pages: pages,
|
||||
upload: upload,
|
||||
uploadDir: webConfig.uploadsDir,
|
||||
createUserSession: createUserSession,
|
||||
setSessionCookie: setSessionCookie,
|
||||
clearSessionCookie: clearSessionCookie,
|
||||
setAuthMessageCookie: setAuthMessageCookie,
|
||||
consumeAuthMessageCookie: consumeAuthMessageCookie,
|
||||
parseCookies: parseCookies,
|
||||
hashSessionToken: hashSessionToken,
|
||||
verifyPassword: verifyPassword,
|
||||
sessionCookieName: SESSION_COOKIE_NAME
|
||||
});
|
||||
|
||||
registerAdminPagesRoutes(app, {
|
||||
pool: pool,
|
||||
common: common,
|
||||
pages: pages,
|
||||
requirePermission: requirePermission,
|
||||
buildDashboardState: webBootstrap.buildDashboardState
|
||||
});
|
||||
|
||||
registerAdminAccountRoutes(app, {
|
||||
pool: pool,
|
||||
common: common,
|
||||
pages: pages,
|
||||
formatDashboardDate: formatDashboardDate,
|
||||
getAuditUserId: getAuditUserId,
|
||||
verifyPassword: verifyPassword,
|
||||
hashPassword: hashPassword,
|
||||
createUserSession: createUserSession,
|
||||
setSessionCookie: setSessionCookie
|
||||
});
|
||||
|
||||
registerAdminUsersRoutes(app, {
|
||||
pool: pool,
|
||||
common: common,
|
||||
pages: pages,
|
||||
sessionCookieName: webConfig.sessionCookieName,
|
||||
formatDashboardDate: formatDashboardDate,
|
||||
getAuditUserId: getAuditUserId,
|
||||
hashPassword: hashPassword,
|
||||
readArrayField: readArrayField,
|
||||
rbacData: rbacData,
|
||||
requirePermission: requirePermission
|
||||
});
|
||||
|
||||
registerAdminManageRoutes(app, {
|
||||
pool: pool,
|
||||
common: common,
|
||||
pages: pages,
|
||||
fetchOrderedPlaylistSlides: fetchOrderedPlaylistSlides,
|
||||
fetchScreensByPlaylistId: fetchScreensByPlaylistId,
|
||||
fetchScreensBySlideId: fetchScreensBySlideId,
|
||||
fetchScreensByTemplateId: fetchScreensByTemplateId,
|
||||
fetchPlaylistCanvasId: fetchPlaylistCanvasId,
|
||||
fetchPlaylistCanvasSignature: fetchPlaylistCanvasSignature,
|
||||
getCanvasSignature: getCanvasSignature,
|
||||
normalizeScheduleMode: normalizeScheduleMode,
|
||||
parseDateTimeLocal: parseDateTimeLocal,
|
||||
parseTimeLocal: parseTimeLocal,
|
||||
readArrayField: readArrayField,
|
||||
getAuditUserId: getAuditUserId,
|
||||
redirectAfterSave: redirectAfterSave,
|
||||
notifyPlayerScreens: notifyPlayerScreens,
|
||||
broadcastDashboardState: broadcastDashboardState,
|
||||
getScreenDeleteBlockMessage: playerActionService.getScreenDeleteBlockMessage,
|
||||
getScreenConnections: playerActionService.getScreenConnections,
|
||||
getPlaylistDeleteBlockMessage: playerActionService.getPlaylistDeleteBlockMessage,
|
||||
forwardPlayerCommand: playerActionService.forwardPlayerCommand,
|
||||
playerPublicBaseUrl: PLAYER_PUBLIC_BASE_URL,
|
||||
requirePermission: requirePermission
|
||||
});
|
||||
|
||||
registerAdminScreenCommandRoutes(app, {
|
||||
pool: pool,
|
||||
forwardPlayerCommand: playerActionService.forwardPlayerCommand,
|
||||
getScreenConnections: playerActionService.getScreenConnections,
|
||||
dataSourceTasks: dataSourceTasks,
|
||||
playerActionService: playerActionService,
|
||||
isClientNameAvailable: isClientNameAvailable,
|
||||
withClientNameReservation: withClientNameReservation,
|
||||
broadcastDashboardState: broadcastDashboardState,
|
||||
requirePermission: requirePermission
|
||||
});
|
||||
|
||||
const registerAdminRbacRoutes = require('./web/routes/admin/rbac');
|
||||
registerAdminRbacRoutes(app, {
|
||||
pool: pool,
|
||||
common: common,
|
||||
pages: pages,
|
||||
getAuditUserId: getAuditUserId,
|
||||
rbacData: rbacData,
|
||||
readArrayField: readArrayField,
|
||||
permissions: require('./rbac').PERMISSIONS,
|
||||
normalizePermissionKeys: require('./rbac').normalizePermissionKeys,
|
||||
requirePermission: requirePermission
|
||||
});
|
||||
|
||||
registerAdminContentRoutes(app, {
|
||||
pool: pool,
|
||||
common: common,
|
||||
pages: pages,
|
||||
upload: upload,
|
||||
uploadDir: UPLOADS_DIR,
|
||||
fetchScreensBySlideId: fetchScreensBySlideId,
|
||||
fetchScreensByTemplateId: fetchScreensByTemplateId,
|
||||
requirePermission: function (permissionKey, options) {
|
||||
return createRequirePermission(permissionKey, Object.assign({ setAuthMessageCookie: setAuthMessageCookie }, options));
|
||||
},
|
||||
hasAnyPermission: hasAnyPermission,
|
||||
backgroundTaskQueue: backgroundTaskQueue,
|
||||
collectUploadReferencesFromSlide: collectUploadReferencesFromSlide,
|
||||
collectUploadReferencesFromTemplate: collectUploadReferencesFromTemplate,
|
||||
collectUploadReferencesFromPayload: collectUploadReferencesFromPayload,
|
||||
removeUnusedUploadFiles: removeUnusedUploadFiles,
|
||||
syncPlaylistUploadsOnChange: syncPlaylistUploadsOnChange,
|
||||
getAuditUserId: getAuditUserId,
|
||||
redirectAfterSave: redirectAfterSave,
|
||||
notifyPlayerScreens: notifyPlayerScreens,
|
||||
broadcastDashboardState: broadcastDashboardState,
|
||||
backgroundTaskQueue: backgroundTaskQueue,
|
||||
hasAnyPermission: hasAnyPermission,
|
||||
getSlideDeleteBlockMessage: playerActionService.getSlideDeleteBlockMessage,
|
||||
getTemplateDeleteBlockMessage: playerActionService.getTemplateDeleteBlockMessage,
|
||||
getCanvasSizeDeleteBlockMessage: playerActionService.getCanvasSizeDeleteBlockMessage,
|
||||
requirePermission: requirePermission
|
||||
});
|
||||
|
||||
registerAdminDataSourceRoutes(app, {
|
||||
pool: pool,
|
||||
common: common,
|
||||
pages: pages,
|
||||
formatDashboardDate: formatDashboardDate,
|
||||
getAuditUserId: getAuditUserId,
|
||||
redirectAfterSave: redirectAfterSave,
|
||||
fetchRssFeedItems: common.fetchRssFeedItems,
|
||||
backgroundTaskQueue: backgroundTaskQueue,
|
||||
requirePermission: requirePermission
|
||||
});
|
||||
|
||||
registerAdminSettingsRoutes(app, {
|
||||
pages: pages,
|
||||
backgroundTaskQueue: backgroundTaskQueue
|
||||
});
|
||||
|
||||
|
||||
app.use(function (req, res, next) {
|
||||
const pathName = String(req.originalUrl || '');
|
||||
const wantsHtml = !pathName.startsWith('/api/') && (!req.accepts || req.accepts('html'));
|
||||
|
||||
if (!wantsHtml) {
|
||||
return next();
|
||||
}
|
||||
|
||||
return res.status(404).send(pages.renderErrorPage({
|
||||
statusCode: 404,
|
||||
title: 'Not found',
|
||||
errorTitle: 'Oops! Page not found.',
|
||||
message: 'We could not find the page you were looking for.',
|
||||
backUrl: req.currentUser ? '/dashboard' : '/login',
|
||||
backLabel: req.currentUser ? 'Back to dashboard' : 'Sign in'
|
||||
}, req.currentUser));
|
||||
buildDashboardState: webBootstrap.buildDashboardState
|
||||
});
|
||||
|
||||
app.use(function (error, req, res, _next) {
|
||||
@@ -321,7 +172,7 @@ async function start() {
|
||||
if (wantsHtml && pages.renderErrorPage) {
|
||||
const isPermissionError = statusCode === 403;
|
||||
const message = isPermissionError
|
||||
? String(error && error.message ? error.message : 'You do not have permission to access this area.')
|
||||
? String(error && error.message ? error.message : PERMISSION_DENIED_MESSAGE)
|
||||
: String(error && error.message ? error.message : 'An unexpected error occurred.');
|
||||
const title = isPermissionError
|
||||
? 'Access denied'
|
||||
@@ -343,20 +194,24 @@ async function start() {
|
||||
res.status(statusCode).send(statusCode >= 500 ? 'Internal server error' : String(error && error.message ? error.message : 'Error'));
|
||||
});
|
||||
|
||||
// Ensure schema and mirror media before the web service starts handling traffic.
|
||||
await common.ensureSchema(pool, { mediaDir: MEDIA_DIR });
|
||||
await common.bootstrapDatabase(pool);
|
||||
await backgroundTaskQueue.initialize();
|
||||
await backgroundTaskSetup.initialize();
|
||||
|
||||
fs.mkdirSync(MEDIA_DIR, { recursive: true });
|
||||
await syncExistingUploadsToPlayer(pool, MEDIA_DIR).catch(function (error) {
|
||||
console.warn('Unable to sync existing media to player:', error);
|
||||
// Bootstrap and startup tasks.
|
||||
fs.mkdirSync(webConfig.mediaDir, { recursive: true });
|
||||
await ensureFontLibrary(webConfig.mediaDir);
|
||||
await initializeWebServer({
|
||||
common: common,
|
||||
pool: pool,
|
||||
mediaDir: webConfig.mediaDir,
|
||||
backgroundTaskQueue: backgroundTaskQueue,
|
||||
webBootstrap: webBootstrap,
|
||||
loadCurrentUser: loadCurrentUser,
|
||||
initializeBackgroundTasks: initializeBackgroundTasks,
|
||||
captureSlideThumbnail: captureSlideThumbnail,
|
||||
server: server,
|
||||
dataSourceStartupRefreshStaggerMs: webConfig.dataSourceStartupRefreshStaggerMs
|
||||
});
|
||||
webBootstrap.installDashboardWebsocket(server, loadCurrentUser);
|
||||
|
||||
server.listen(PORT, function () {
|
||||
console.log(`Pulse Signage app listening on port ${PORT}`);
|
||||
server.listen(webConfig.port, function () {
|
||||
console.log(`Pulse Signage app listening on port ${webConfig.port}`);
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
Vendored
+94
-42
@@ -1,15 +1,17 @@
|
||||
// Web-to-player websocket bridge and dashboard state synchronizer.
|
||||
|
||||
const { WebSocketServer, WebSocket } = require('ws');
|
||||
const { createDashboardStateService } = require('./lib/dashboard-state');
|
||||
const { createUploadSyncService } = require('./lib/upload-sync');
|
||||
const { createRequestAuthHeaders } = require('../request-auth');
|
||||
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 playerInternalBaseUrl = String(options && options.playerInternalBaseUrl || '').replace(/\/$/, '');
|
||||
const configuredPlayerInternalBaseUrl = String(options && options.playerInternalBaseUrl || '').replace(/\/$/, '');
|
||||
const playerPublicBaseUrl = String(options && options.playerPublicBaseUrl || '').replace(/\/$/, '');
|
||||
const uploadDir = String(options && options.uploadDir || '').trim();
|
||||
const dashboardRefreshIntervalMs = Number(options && options.dashboardRefreshIntervalMs || 2000);
|
||||
const dashboardRefreshIntervalMs = 5000;
|
||||
const formatDashboardDate = options && options.formatDashboardDate;
|
||||
const notifyPlayerScreens = options && options.notifyPlayerScreens;
|
||||
const backgroundTaskQueue = options && options.backgroundTaskQueue;
|
||||
@@ -24,9 +26,54 @@ function createWebBootstrap(options) {
|
||||
const playerSnapshotSockets = new Map();
|
||||
let dashboardRefreshInFlight = null;
|
||||
let broadcastDashboardState = null;
|
||||
let playerInternalBaseUrl = null;
|
||||
let playerInternalBaseUrlPromise = null;
|
||||
|
||||
function getPlayerSnapshotSocketUrl(slug) {
|
||||
const url = new URL(playerInternalBaseUrl.replace(/^http/, 'ws'));
|
||||
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();
|
||||
@@ -53,47 +100,53 @@ function createWebBootstrap(options) {
|
||||
return;
|
||||
}
|
||||
|
||||
const socketUrl = getPlayerSnapshotSocketUrl(key);
|
||||
playerSnapshotSockets.set(key, null);
|
||||
const socketUrlPromise = getPlayerSnapshotSocketUrl(key);
|
||||
const authHeaders = createRequestAuthHeaders({
|
||||
method: 'GET',
|
||||
pathname: `/ws/screens/${encodeURIComponent(key)}/events`
|
||||
});
|
||||
const socket = new WebSocket(socketUrl, {
|
||||
headers: authHeaders
|
||||
});
|
||||
playerSnapshotSockets.set(key, socket);
|
||||
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;
|
||||
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.
|
||||
}
|
||||
storePlayerSnapshot(key, payload.connections || []);
|
||||
if (broadcastDashboardState) {
|
||||
broadcastDashboardState().catch(function (error) {
|
||||
console.error(error);
|
||||
});
|
||||
}
|
||||
} catch (_error) {
|
||||
// Ignore malformed player snapshot payloads.
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
socket.onclose = function () {
|
||||
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);
|
||||
setTimeout(function () {
|
||||
ensurePlayerSnapshotSubscription(key);
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
socket.onerror = function () {
|
||||
try {
|
||||
socket.close();
|
||||
} catch (_error) {
|
||||
// ignore close errors
|
||||
}
|
||||
};
|
||||
console.error(error);
|
||||
});
|
||||
}
|
||||
|
||||
const dashboardStateService = createDashboardStateService({
|
||||
@@ -110,7 +163,7 @@ function createWebBootstrap(options) {
|
||||
const uploadSyncService = createUploadSyncService({
|
||||
pool: pool,
|
||||
common: common,
|
||||
playerInternalBaseUrl: playerInternalBaseUrl,
|
||||
playerInternalBaseUrl: configuredPlayerInternalBaseUrl,
|
||||
playerSnapshotCache: playerSnapshotCache,
|
||||
notifyPlayerScreens: notifyPlayerScreens,
|
||||
backgroundTaskQueue: backgroundTaskQueue
|
||||
@@ -123,7 +176,6 @@ function createWebBootstrap(options) {
|
||||
const removeUnusedUploadFiles = uploadSyncService.removeUnusedUploadFiles;
|
||||
const collectUploadPathsFromDirectory = uploadSyncService.collectUploadPathsFromDirectory;
|
||||
const syncPlaylistUploadsOnChange = uploadSyncService.syncPlaylistUploadsOnChange;
|
||||
const syncExistingUploadsToPlayer = uploadSyncService.syncExistingUploadsToPlayer;
|
||||
const runMediaSyncTask = uploadSyncService.runMediaSyncTask;
|
||||
|
||||
async function sendDashboardStateToSocket(socket) {
|
||||
@@ -208,6 +260,7 @@ function createWebBootstrap(options) {
|
||||
|
||||
return {
|
||||
upload: upload,
|
||||
uploadSyncService: uploadSyncService,
|
||||
buildDashboardState: buildDashboardState,
|
||||
collectUploadReferencesFromSlide: collectUploadReferencesFromSlide,
|
||||
collectUploadReferencesFromTemplate: collectUploadReferencesFromTemplate,
|
||||
@@ -215,7 +268,6 @@ function createWebBootstrap(options) {
|
||||
removeUnusedUploadFiles: removeUnusedUploadFiles,
|
||||
collectUploadPathsFromDirectory: collectUploadPathsFromDirectory,
|
||||
syncPlaylistUploadsOnChange: syncPlaylistUploadsOnChange,
|
||||
syncExistingUploadsToPlayer: syncExistingUploadsToPlayer,
|
||||
runMediaSyncTask: runMediaSyncTask,
|
||||
broadcastDashboardState: broadcastDashboardState,
|
||||
installDashboardWebsocket: installDashboardWebsocket
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
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('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')
|
||||
};
|
||||
@@ -1,5 +1,7 @@
|
||||
const { PERMISSIONS, normalizePermissionKeys } = require('../../rbac');
|
||||
const { fetchPagedRows } = require('../../data/utils');
|
||||
// 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 || '')
|
||||
@@ -1,10 +1,13 @@
|
||||
const { normalizePermissionKeys } = require('../../rbac');
|
||||
// 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.');
|
||||
@@ -39,12 +42,37 @@ function createSessionService(options) {
|
||||
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) {
|
||||
res.setHeader('Set-Cookie', serializeCookie(sessionCookieName, '', { maxAge: 0 }));
|
||||
appendCookieHeader(res, serializeCookie(sessionCookieName, '', { maxAge: 0 }));
|
||||
}
|
||||
|
||||
function setSessionCookie(res, token) {
|
||||
res.setHeader('Set-Cookie', serializeCookie(sessionCookieName, token, { maxAge: sessionMaxAgeMs }));
|
||||
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) {
|
||||
@@ -113,7 +141,8 @@ function createSessionService(options) {
|
||||
if (req.currentUser) {
|
||||
return next();
|
||||
}
|
||||
res.redirect('/login?message=' + encodeURIComponent('Please sign in to continue.'));
|
||||
setAuthMessageCookie(res, 'Please sign in to continue.');
|
||||
res.redirect('/login');
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -121,6 +150,8 @@ function createSessionService(options) {
|
||||
serializeCookie: serializeCookie,
|
||||
clearSessionCookie: clearSessionCookie,
|
||||
setSessionCookie: setSessionCookie,
|
||||
setAuthMessageCookie: setAuthMessageCookie,
|
||||
consumeAuthMessageCookie: consumeAuthMessageCookie,
|
||||
loadCurrentUser: loadCurrentUser,
|
||||
createUserSession: createUserSession,
|
||||
requireAuth: requireAuth
|
||||
@@ -1,93 +0,0 @@
|
||||
function registerBackgroundTaskHandlers(options) {
|
||||
const pool = options && options.pool;
|
||||
const common = options && options.common;
|
||||
const backgroundTaskQueue = options && options.backgroundTaskQueue;
|
||||
const captureSlideThumbnail = options && options.captureSlideThumbnail;
|
||||
const refreshApiSource = options && options.refreshApiSource;
|
||||
const refreshRssFeed = options && options.refreshRssFeed;
|
||||
const runMediaSyncTask = options && options.runMediaSyncTask;
|
||||
const mediaDir = String(options && options.mediaDir || '').trim();
|
||||
const playerInternalBaseUrl = String(options && options.playerInternalBaseUrl || '').trim();
|
||||
|
||||
if (!pool || !common || !backgroundTaskQueue || !captureSlideThumbnail || !refreshApiSource || !refreshRssFeed || !runMediaSyncTask || !mediaDir || !playerInternalBaseUrl) {
|
||||
throw new Error('registerBackgroundTaskHandlers requires the background task dependencies.');
|
||||
}
|
||||
|
||||
backgroundTaskQueue.setTaskHandler('media-sync', function (task) {
|
||||
return runMediaSyncTask(task && task.payload ? task.payload : task);
|
||||
});
|
||||
|
||||
backgroundTaskQueue.setTaskHandler('data-source-refresh', async function (task) {
|
||||
const payload = task && task.payload ? task.payload : {};
|
||||
const sourceType = String(payload.sourceType || '').trim();
|
||||
const sourceId = Number(payload.sourceId || 0);
|
||||
|
||||
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.id, apiSource.api_url, 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.');
|
||||
});
|
||||
|
||||
backgroundTaskQueue.setTaskHandler('slide-thumbnail-refresh', 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.');
|
||||
}
|
||||
|
||||
return captureSlideThumbnail({
|
||||
pool: pool,
|
||||
common: common,
|
||||
mediaDir: mediaDir,
|
||||
baseUrl: playerInternalBaseUrl,
|
||||
slideId: slideId,
|
||||
previousThumbnailPath: payload.previousThumbnailPath || null
|
||||
});
|
||||
});
|
||||
|
||||
backgroundTaskQueue.setTaskHandler('template-slide-thumbnail-refresh', 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 [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 = { registerBackgroundTaskHandlers };
|
||||
@@ -1,774 +0,0 @@
|
||||
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 tasksById = new Map();
|
||||
const recurringJobsByKey = new Map();
|
||||
const taskIdToRecurringKey = new Map();
|
||||
const pendingIds = [];
|
||||
let nextTaskId = 1;
|
||||
let activeCount = 0;
|
||||
let drainScheduled = false;
|
||||
let initializationPromise = null;
|
||||
|
||||
function createTaskCompletionController() {
|
||||
let resolveCompletion = null;
|
||||
let rejectCompletion = null;
|
||||
const completionPromise = new Promise(function (resolve, reject) {
|
||||
resolveCompletion = resolve;
|
||||
rejectCompletion = reject;
|
||||
});
|
||||
|
||||
return {
|
||||
promise: completionPromise,
|
||||
resolve: resolveCompletion,
|
||||
reject: rejectCompletion
|
||||
};
|
||||
}
|
||||
|
||||
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 buildSnapshot(task) {
|
||||
return {
|
||||
id: task.id,
|
||||
key: task.key,
|
||||
taskType: task.taskType || '',
|
||||
title: task.title,
|
||||
category: task.category,
|
||||
status: task.status,
|
||||
createdAt: task.createdAt,
|
||||
startedAt: task.startedAt,
|
||||
finishedAt: task.finishedAt,
|
||||
errorMessage: task.errorMessage,
|
||||
attempts: task.attempts || 0,
|
||||
metadata: task.metadata,
|
||||
payload: task.payload || null
|
||||
};
|
||||
}
|
||||
|
||||
function getTaskById(taskId) {
|
||||
const numericTaskId = Number(taskId);
|
||||
if (!Number.isFinite(numericTaskId) || numericTaskId <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return tasksById.get(numericTaskId) || null;
|
||||
}
|
||||
|
||||
function buildTaskFromRow(row) {
|
||||
const task = {
|
||||
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),
|
||||
completionPromise: null,
|
||||
resolveCompletion: null,
|
||||
rejectCompletion: null,
|
||||
persisted: true,
|
||||
run: typeof row.run === 'function' ? row.run : function () {
|
||||
return Promise.resolve();
|
||||
}
|
||||
};
|
||||
|
||||
const completionController = createTaskCompletionController();
|
||||
task.completionPromise = completionController.promise;
|
||||
task.resolveCompletion = completionController.resolve;
|
||||
task.rejectCompletion = completionController.reject;
|
||||
return task;
|
||||
}
|
||||
|
||||
function buildTaskRecord(task) {
|
||||
return {
|
||||
task_key: task.key || null,
|
||||
task_type: task.taskType || 'general',
|
||||
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) {
|
||||
if (!pool || !task.taskType) {
|
||||
return task;
|
||||
}
|
||||
|
||||
const record = buildTaskRecord(task);
|
||||
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);
|
||||
task.persisted = true;
|
||||
return task;
|
||||
}
|
||||
|
||||
async function persistTaskUpdate(task) {
|
||||
if (!pool || !task.persisted) {
|
||||
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,
|
||||
task.id
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
async function persistTaskDelete(taskId) {
|
||||
if (!pool) {
|
||||
return;
|
||||
}
|
||||
|
||||
await pool.query('DELETE FROM o_background_tasks WHERE id = ?', [taskId]);
|
||||
}
|
||||
|
||||
async function initialize() {
|
||||
if (initializationPromise) {
|
||||
return initializationPromise;
|
||||
}
|
||||
|
||||
initializationPromise = (async function () {
|
||||
if (!pool) {
|
||||
return;
|
||||
}
|
||||
|
||||
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 id ASC'
|
||||
);
|
||||
|
||||
let highestTaskId = 0;
|
||||
for (const row of rows || []) {
|
||||
const task = buildTaskFromRow(row);
|
||||
if (!Number.isInteger(task.id) || task.id <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
highestTaskId = Math.max(highestTaskId, task.id);
|
||||
tasksById.set(task.id, task);
|
||||
|
||||
if (task.status === 'running') {
|
||||
task.status = 'queued';
|
||||
task.startedAt = '';
|
||||
task.finishedAt = '';
|
||||
task.errorMessage = '';
|
||||
await pool.query(
|
||||
'UPDATE o_background_tasks SET status = ?, started_at = NULL, finished_at = NULL, error_message = NULL WHERE id = ?',
|
||||
['queued', task.id]
|
||||
);
|
||||
}
|
||||
|
||||
if (task.status === 'queued' || task.status === 'running') {
|
||||
pendingIds.push(task.id);
|
||||
}
|
||||
}
|
||||
|
||||
nextTaskId = Math.max(nextTaskId, highestTaskId + 1);
|
||||
if (pendingIds.length) {
|
||||
scheduleDrain();
|
||||
}
|
||||
})();
|
||||
|
||||
return initializationPromise;
|
||||
}
|
||||
|
||||
function scheduleDrain() {
|
||||
if (drainScheduled) {
|
||||
return;
|
||||
}
|
||||
|
||||
drainScheduled = true;
|
||||
setTimeout(function () {
|
||||
drainScheduled = false;
|
||||
processQueue();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function clearRecurringTimer(job) {
|
||||
if (job && job.timerId) {
|
||||
clearTimeout(job.timerId);
|
||||
job.timerId = null;
|
||||
}
|
||||
}
|
||||
|
||||
function setTaskHandler(taskType, handler) {
|
||||
const normalizedTaskType = normalizeText(taskType);
|
||||
if (!normalizedTaskType || typeof handler !== 'function') {
|
||||
return false;
|
||||
}
|
||||
|
||||
taskHandlers.set(normalizedTaskType, handler);
|
||||
return true;
|
||||
}
|
||||
|
||||
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);
|
||||
}, safeDelay);
|
||||
}
|
||||
|
||||
function triggerRecurringJob(recurringKey) {
|
||||
const job = recurringJobsByKey.get(recurringKey);
|
||||
if (!job || job.enabled === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (job.activeTaskId && tasksById.has(job.activeTaskId)) {
|
||||
scheduleRecurringRun(job, job.intervalMs);
|
||||
return;
|
||||
}
|
||||
|
||||
job.activeTaskId = -1;
|
||||
enqueueTask({
|
||||
key: `${job.key}:${Date.now()}`,
|
||||
title: job.title,
|
||||
category: job.category,
|
||||
taskType: job.taskType || '',
|
||||
metadata: Object.assign({}, job.metadata || {}, {
|
||||
recurringKey: job.key,
|
||||
recurringTitle: job.title
|
||||
}),
|
||||
payload: Object.assign({}, job.payload || {}, {
|
||||
recurringKey: job.key
|
||||
}),
|
||||
run: job.run,
|
||||
persist: Boolean(job.taskType)
|
||||
}).then(function (task) {
|
||||
if (task && Number.isInteger(task.id)) {
|
||||
job.activeTaskId = task.id;
|
||||
taskIdToRecurringKey.set(task.id, job.key);
|
||||
} else {
|
||||
job.activeTaskId = null;
|
||||
}
|
||||
}).catch(function (error) {
|
||||
job.activeTaskId = null;
|
||||
console.error(error);
|
||||
});
|
||||
|
||||
scheduleRecurringRun(job, job.intervalMs);
|
||||
}
|
||||
|
||||
function runRecurringTask(recurringKey) {
|
||||
const normalizedKey = normalizeText(recurringKey);
|
||||
if (!normalizedKey || !recurringJobsByKey.has(normalizedKey)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
triggerRecurringJob(normalizedKey);
|
||||
return true;
|
||||
}
|
||||
|
||||
function syncRecurringTaskState(task, status, errorMessage) {
|
||||
const recurringKey = taskIdToRecurringKey.get(task.id) || (task && task.metadata && task.metadata.recurringKey);
|
||||
if (!recurringKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const job = recurringJobsByKey.get(recurringKey);
|
||||
if (!job) {
|
||||
taskIdToRecurringKey.delete(task.id);
|
||||
return;
|
||||
}
|
||||
|
||||
job.activeTaskId = null;
|
||||
job.lastRunAt = toIsoDate(new Date());
|
||||
job.lastStatus = status;
|
||||
job.lastError = errorMessage ? String(errorMessage) : '';
|
||||
taskIdToRecurringKey.delete(task.id);
|
||||
}
|
||||
|
||||
async function processQueue() {
|
||||
while (activeCount < maxConcurrent) {
|
||||
const nextTaskId = pendingIds.shift();
|
||||
if (!nextTaskId) {
|
||||
break;
|
||||
}
|
||||
|
||||
const task = tasksById.get(nextTaskId);
|
||||
if (!task || task.status !== 'queued') {
|
||||
continue;
|
||||
}
|
||||
|
||||
activeCount += 1;
|
||||
task.status = 'running';
|
||||
task.startedAt = toIsoDate(new Date());
|
||||
task.errorMessage = '';
|
||||
task.attempts = Math.max(0, Number(task.attempts) || 0) + 1;
|
||||
|
||||
try {
|
||||
await persistTaskUpdate(task);
|
||||
} catch (error) {
|
||||
task.status = 'failed';
|
||||
task.errorMessage = String(error && error.message ? error.message : 'Unable to update task state.');
|
||||
task.finishedAt = toIsoDate(new Date());
|
||||
if (typeof task.rejectCompletion === 'function') {
|
||||
const completionError = new Error(task.errorMessage);
|
||||
completionError.task = buildSnapshot(task);
|
||||
task.rejectCompletion(completionError);
|
||||
}
|
||||
activeCount = Math.max(0, activeCount - 1);
|
||||
scheduleDrain();
|
||||
continue;
|
||||
}
|
||||
|
||||
Promise.resolve()
|
||||
.then(function () {
|
||||
if (task.taskType) {
|
||||
const handler = taskHandlers.get(task.taskType);
|
||||
if (!handler) {
|
||||
throw new Error('No handler registered for task type ' + task.taskType + '.');
|
||||
}
|
||||
|
||||
return handler({
|
||||
id: task.id,
|
||||
key: task.key,
|
||||
title: task.title,
|
||||
category: task.category,
|
||||
taskType: task.taskType,
|
||||
payload: task.payload,
|
||||
metadata: task.metadata,
|
||||
attempts: task.attempts
|
||||
});
|
||||
}
|
||||
|
||||
return task.run({
|
||||
id: task.id,
|
||||
key: task.key,
|
||||
title: task.title,
|
||||
category: task.category,
|
||||
metadata: task.metadata
|
||||
});
|
||||
})
|
||||
.then(function () {
|
||||
task.status = 'completed';
|
||||
task.finishedAt = toIsoDate(new Date());
|
||||
if (typeof task.resolveCompletion === 'function') {
|
||||
task.resolveCompletion(buildSnapshot(task));
|
||||
}
|
||||
syncRecurringTaskState(task, task.status, '');
|
||||
return persistTaskUpdate(task);
|
||||
})
|
||||
.catch(function (error) {
|
||||
task.status = 'failed';
|
||||
task.errorMessage = String(error && error.message ? error.message : 'Background task failed.');
|
||||
task.finishedAt = toIsoDate(new Date());
|
||||
if (typeof task.rejectCompletion === 'function') {
|
||||
const completionError = new Error(task.errorMessage);
|
||||
completionError.task = buildSnapshot(task);
|
||||
task.rejectCompletion(completionError);
|
||||
}
|
||||
syncRecurringTaskState(task, task.status, task.errorMessage);
|
||||
return persistTaskUpdate(task);
|
||||
})
|
||||
.finally(function () {
|
||||
activeCount = Math.max(0, activeCount - 1);
|
||||
scheduleDrain();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function enqueueTask(definition) {
|
||||
const normalizedKey = normalizeText(definition && definition.key);
|
||||
const normalizedTitle = normalizeText(definition && definition.title) || 'Background task';
|
||||
const normalizedTaskType = normalizeText(definition && definition.taskType);
|
||||
const shouldPersist = Boolean((definition && definition.persist) || (pool && normalizedTaskType));
|
||||
const existingTask = normalizedKey
|
||||
? Array.from(tasksById.values()).find(function (task) {
|
||||
return task.key === normalizedKey && task.status === 'queued' && (!normalizedTaskType || task.taskType === normalizedTaskType);
|
||||
})
|
||||
: null;
|
||||
|
||||
if (existingTask) {
|
||||
existingTask.title = normalizedTitle;
|
||||
existingTask.category = normalizeText(definition && definition.category) || existingTask.category || 'general';
|
||||
existingTask.metadata = definition && definition.metadata ? definition.metadata : {};
|
||||
existingTask.taskType = normalizedTaskType || existingTask.taskType || '';
|
||||
existingTask.payload = definition && definition.payload !== undefined ? definition.payload : existingTask.payload;
|
||||
existingTask.run = typeof definition.run === 'function' ? definition.run : existingTask.run;
|
||||
existingTask.createdAt = toIsoDate(new Date());
|
||||
existingTask.errorMessage = '';
|
||||
existingTask.persisted = existingTask.persisted || shouldPersist;
|
||||
await persistTaskUpdate(existingTask);
|
||||
return buildSnapshot(existingTask);
|
||||
}
|
||||
|
||||
const task = {
|
||||
id: shouldPersist ? 0 : nextTaskId,
|
||||
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,
|
||||
persisted: shouldPersist,
|
||||
run: typeof definition.run === 'function' ? definition.run : function () {
|
||||
return Promise.resolve();
|
||||
},
|
||||
completionPromise: null,
|
||||
resolveCompletion: null,
|
||||
rejectCompletion: null
|
||||
};
|
||||
|
||||
const completionController = createTaskCompletionController();
|
||||
task.completionPromise = completionController.promise;
|
||||
task.resolveCompletion = completionController.resolve;
|
||||
task.rejectCompletion = completionController.reject;
|
||||
|
||||
if (shouldPersist) {
|
||||
await persistTaskInsert(task);
|
||||
} else {
|
||||
nextTaskId += 1;
|
||||
}
|
||||
|
||||
tasksById.set(task.id, task);
|
||||
pendingIds.push(task.id);
|
||||
scheduleDrain();
|
||||
return buildSnapshot(task);
|
||||
}
|
||||
|
||||
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,
|
||||
activeTaskId: null,
|
||||
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.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) {
|
||||
return {
|
||||
key: job.key,
|
||||
title: job.title,
|
||||
category: job.category,
|
||||
intervalMs: job.intervalMs,
|
||||
enabled: job.enabled !== false,
|
||||
activeTaskId: job.activeTaskId || null,
|
||||
createdAt: job.createdAt || '',
|
||||
nextRunAt: job.nextRunAt || '',
|
||||
lastRunAt: job.lastRunAt || '',
|
||||
lastStatus: job.lastStatus || '',
|
||||
lastError: job.lastError || '',
|
||||
metadata: job.metadata || {}
|
||||
};
|
||||
}
|
||||
|
||||
function getTaskSortTime(task) {
|
||||
const finishedTime = task && task.finishedAt ? Date.parse(task.finishedAt) : NaN;
|
||||
if (Number.isFinite(finishedTime)) {
|
||||
return finishedTime;
|
||||
}
|
||||
|
||||
const createdTime = task && task.createdAt ? Date.parse(task.createdAt) : NaN;
|
||||
if (Number.isFinite(createdTime)) {
|
||||
return createdTime;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
function listTasks() {
|
||||
return Array.from(tasksById.values())
|
||||
.slice()
|
||||
.sort(function (left, right) {
|
||||
const leftStartedTime = left && left.startedAt ? Date.parse(left.startedAt) : NaN;
|
||||
const rightStartedTime = right && right.startedAt ? Date.parse(right.startedAt) : NaN;
|
||||
if (Number.isFinite(leftStartedTime) && Number.isFinite(rightStartedTime) && leftStartedTime !== rightStartedTime) {
|
||||
return rightStartedTime - leftStartedTime;
|
||||
}
|
||||
|
||||
if (Number.isFinite(leftStartedTime) !== Number.isFinite(rightStartedTime)) {
|
||||
return Number.isFinite(leftStartedTime) ? -1 : 1;
|
||||
}
|
||||
|
||||
const leftCreatedTime = left && left.createdAt ? Date.parse(left.createdAt) : NaN;
|
||||
const rightCreatedTime = right && right.createdAt ? Date.parse(right.createdAt) : NaN;
|
||||
if (Number.isFinite(leftCreatedTime) && Number.isFinite(rightCreatedTime) && leftCreatedTime !== rightCreatedTime) {
|
||||
return rightCreatedTime - leftCreatedTime;
|
||||
}
|
||||
|
||||
if (Number.isFinite(leftCreatedTime) !== Number.isFinite(rightCreatedTime)) {
|
||||
return Number.isFinite(leftCreatedTime) ? -1 : 1;
|
||||
}
|
||||
|
||||
return right.id - left.id;
|
||||
})
|
||||
.map(buildSnapshot);
|
||||
}
|
||||
|
||||
function listRecurringTasks() {
|
||||
return Array.from(recurringJobsByKey.values())
|
||||
.slice()
|
||||
.sort(function (left, right) {
|
||||
return left.key.localeCompare(right.key);
|
||||
})
|
||||
.map(buildRecurringSnapshot);
|
||||
}
|
||||
|
||||
function clearFinishedTasks() {
|
||||
let removedCount = 0;
|
||||
Array.from(tasksById.values()).forEach(function (task) {
|
||||
if (task.status === 'running' || task.status === 'queued') {
|
||||
return;
|
||||
}
|
||||
tasksById.delete(task.id);
|
||||
removedCount += 1;
|
||||
persistTaskDelete(task.id).catch(function (error) {
|
||||
console.warn('Unable to delete finished task from persistence:', error);
|
||||
});
|
||||
});
|
||||
return removedCount;
|
||||
}
|
||||
|
||||
function cancelTask(taskId) {
|
||||
const task = getTaskById(taskId);
|
||||
if (!task || task.status !== 'queued') {
|
||||
return false;
|
||||
}
|
||||
|
||||
task.status = 'canceled';
|
||||
task.finishedAt = toIsoDate(new Date());
|
||||
const pendingIndex = pendingIds.indexOf(task.id);
|
||||
if (pendingIndex >= 0) {
|
||||
pendingIds.splice(pendingIndex, 1);
|
||||
}
|
||||
|
||||
if (typeof task.rejectCompletion === 'function') {
|
||||
const cancellationError = new Error('Task canceled.');
|
||||
cancellationError.task = buildSnapshot(task);
|
||||
task.rejectCompletion(cancellationError);
|
||||
}
|
||||
|
||||
persistTaskUpdate(task).catch(function (error) {
|
||||
console.warn('Unable to persist canceled task:', error);
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function retryTask(taskId) {
|
||||
const task = getTaskById(taskId);
|
||||
if (!task || task.status !== 'failed') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return enqueueTask({
|
||||
key: task.key,
|
||||
title: task.title,
|
||||
category: task.category,
|
||||
metadata: task.metadata,
|
||||
run: task.run
|
||||
});
|
||||
}
|
||||
|
||||
function getSummary() {
|
||||
const counts = {
|
||||
queued: 0,
|
||||
running: 0,
|
||||
completed: 0,
|
||||
failed: 0,
|
||||
canceled: 0
|
||||
};
|
||||
|
||||
listTasks().forEach(function (task) {
|
||||
if (Object.prototype.hasOwnProperty.call(counts, task.status)) {
|
||||
counts[task.status] += 1;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
activeCount: activeCount,
|
||||
counts: counts,
|
||||
scheduledCount: recurringJobsByKey.size,
|
||||
total: listTasks().length
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
enqueueTask: enqueueTask,
|
||||
enqueueTaskAndWait: function (definition) {
|
||||
return enqueueTask(definition).then(function (snapshot) {
|
||||
const task = snapshot && snapshot.id ? getTaskById(snapshot.id) : null;
|
||||
if (!task || !task.completionPromise) {
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
return task.completionPromise;
|
||||
});
|
||||
},
|
||||
initialize: initialize,
|
||||
setTaskHandler: setTaskHandler,
|
||||
registerRecurringTask: registerRecurringTask,
|
||||
removeRecurringTask: removeRecurringTask,
|
||||
runRecurringTask: runRecurringTask,
|
||||
listTasks: listTasks,
|
||||
listRecurringTasks: listRecurringTasks,
|
||||
getTaskById: getTaskById,
|
||||
getSummary: getSummary,
|
||||
cancelTask: cancelTask,
|
||||
retryTask: retryTask,
|
||||
clearFinishedTasks: clearFinishedTasks
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createBackgroundTaskQueue: createBackgroundTaskQueue,
|
||||
normalizeIntervalMs: normalizeIntervalMs
|
||||
};
|
||||
@@ -1,146 +0,0 @@
|
||||
const { normalizeIntervalMs } = require('./background-task-queue');
|
||||
|
||||
function registerBackgroundTaskScheduling(options) {
|
||||
const pool = options && options.pool;
|
||||
const common = options && options.common;
|
||||
const backgroundTaskQueue = options && options.backgroundTaskQueue;
|
||||
const collectUploadPathsFromDirectory = options && options.collectUploadPathsFromDirectory;
|
||||
const removeUnusedUploadFiles = options && options.removeUnusedUploadFiles;
|
||||
const refreshApiSource = options && options.refreshApiSource;
|
||||
const refreshRssFeed = options && options.refreshRssFeed;
|
||||
const mediaDir = String(options && options.mediaDir || '').trim();
|
||||
const dataSourceStartupRefreshStaggerMs = Math.max(100, Number(options && options.dataSourceStartupRefreshStaggerMs || 250));
|
||||
|
||||
if (!pool || !common || !backgroundTaskQueue || !collectUploadPathsFromDirectory || !removeUnusedUploadFiles || !refreshApiSource || !refreshRssFeed || !mediaDir) {
|
||||
throw new Error('registerBackgroundTaskScheduling requires the background task dependencies.');
|
||||
}
|
||||
|
||||
async function syncRecurringRefreshes() {
|
||||
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: 'data-source',
|
||||
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.id, apiSource.api_url, 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: 'data-source',
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function registerRecurringMaintenanceTasks() {
|
||||
async function runUnusedUploadSweep() {
|
||||
const uploadPaths = await collectUploadPathsFromDirectory(mediaDir);
|
||||
if (!uploadPaths.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
await removeUnusedUploadFiles(pool, mediaDir, uploadPaths);
|
||||
}
|
||||
|
||||
backgroundTaskQueue.registerRecurringTask({
|
||||
key: 'unused-upload-sweep',
|
||||
title: 'Unused upload sweep',
|
||||
category: 'media-sync',
|
||||
intervalMs: 24 * 60 * 60 * 1000,
|
||||
metadata: {
|
||||
mediaDir: mediaDir
|
||||
},
|
||||
run: runUnusedUploadSweep
|
||||
});
|
||||
}
|
||||
|
||||
async function scheduleInitialDataSourceRefreshes() {
|
||||
const apiSourcesData = await common.fetchApiSourcesData(pool);
|
||||
const rssFeedsData = await common.fetchRssFeedsData(pool);
|
||||
const startupSources = [];
|
||||
|
||||
(apiSourcesData.apiSources || []).forEach(function (apiSource) {
|
||||
startupSources.push({
|
||||
type: 'api-source',
|
||||
id: Number(apiSource.id),
|
||||
name: apiSource.name,
|
||||
run: function () {
|
||||
return refreshApiSource(pool, common, apiSource.id, apiSource.api_url, null);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
(rssFeedsData.rssFeeds || []).forEach(function (rssFeed) {
|
||||
startupSources.push({
|
||||
type: 'rss-feed',
|
||||
id: Number(rssFeed.id),
|
||||
name: rssFeed.name,
|
||||
run: function () {
|
||||
return refreshRssFeed(pool, common, rssFeed.id, rssFeed.feed_url, rssFeed.item_limit, null);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
startupSources.forEach(function (source, index) {
|
||||
const startupDelayMs = index * dataSourceStartupRefreshStaggerMs;
|
||||
|
||||
setTimeout(function () {
|
||||
backgroundTaskQueue.enqueueTask({
|
||||
key: 'startup-data-source-refresh:' + source.type + ':' + source.id + ':' + Date.now(),
|
||||
title: source.type === 'rss-feed' ? 'RSS feed refresh' : 'API source refresh',
|
||||
category: 'data-source',
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
async function initialize() {
|
||||
await syncRecurringRefreshes();
|
||||
await registerRecurringMaintenanceTasks();
|
||||
scheduleInitialDataSourceRefreshes().catch(function (error) {
|
||||
console.warn('Unable to schedule startup data source refreshes:', error);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
initialize: initialize
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { registerBackgroundTaskScheduling };
|
||||
@@ -1,32 +0,0 @@
|
||||
const { registerBackgroundTaskHandlers } = require('./background-task-handlers');
|
||||
const { registerBackgroundTaskScheduling } = require('./background-task-scheduling');
|
||||
|
||||
function createBackgroundTaskSetup(options) {
|
||||
const pool = options && options.pool;
|
||||
const common = options && options.common;
|
||||
const backgroundTaskQueue = options && options.backgroundTaskQueue;
|
||||
const collectUploadPathsFromDirectory = options && options.collectUploadPathsFromDirectory;
|
||||
const removeUnusedUploadFiles = options && options.removeUnusedUploadFiles;
|
||||
const refreshApiSource = options && options.refreshApiSource;
|
||||
const refreshRssFeed = options && options.refreshRssFeed;
|
||||
const uploadsDir = String(options && options.uploadsDir || '').trim();
|
||||
const dataSourceStartupRefreshStaggerMs = Math.max(100, Number(options && options.dataSourceStartupRefreshStaggerMs || 250));
|
||||
|
||||
if (!pool || !common || !backgroundTaskQueue || !collectUploadPathsFromDirectory || !removeUnusedUploadFiles || !refreshApiSource || !refreshRssFeed || !uploadsDir) {
|
||||
throw new Error('createBackgroundTaskSetup requires the background task dependencies.');
|
||||
}
|
||||
|
||||
async function initialize() {
|
||||
registerBackgroundTaskHandlers(options);
|
||||
const backgroundTaskScheduling = registerBackgroundTaskScheduling(options);
|
||||
if (backgroundTaskScheduling && typeof backgroundTaskScheduling.initialize === 'function') {
|
||||
await backgroundTaskScheduling.initialize();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
initialize: initialize
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createBackgroundTaskSetup };
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,30 @@
|
||||
const path = require('path');
|
||||
|
||||
function createWebConfig() {
|
||||
const mediaDir = path.join(__dirname, '..', '..', '..', 'media');
|
||||
const uploadsDir = path.join(mediaDir, 'uploads');
|
||||
const thumbnailsDir = path.join(mediaDir, 'thumbnails');
|
||||
const assetDir = path.join(__dirname, '..', 'public');
|
||||
const playerInternalBaseUrl = (process.env.PLAYER_INTERNAL_BASE_URL || process.env.PLAYER_BASE_URL || 'http://player:8081').replace(/\/$/, '');
|
||||
const playerPublicBaseUrl = (process.env.PLAYER_PUBLIC_BASE_URL || process.env.PLAYER_BASE_URL || 'http://localhost:8081').replace(/\/$/, '');
|
||||
const sessionCookieName = 'digital_signage_session';
|
||||
const sessionMaxAgeDays = Number(process.env.SESSION_MAX_AGE_DAYS || 14);
|
||||
const sessionMaxAgeMs = (Number.isFinite(sessionMaxAgeDays) && sessionMaxAgeDays > 0 ? sessionMaxAgeDays : 14) * 24 * 60 * 60 * 1000;
|
||||
const port = Number(process.env.WEB_PORT || 8080);
|
||||
const dataSourceStartupRefreshStaggerMs = Math.max(100, Number(process.env.DATA_SOURCE_STARTUP_REFRESH_STAGGER_MS || 250));
|
||||
|
||||
return {
|
||||
port: port,
|
||||
mediaDir: mediaDir,
|
||||
uploadsDir: uploadsDir,
|
||||
thumbnailsDir: thumbnailsDir,
|
||||
assetDir: assetDir,
|
||||
playerInternalBaseUrl: playerInternalBaseUrl,
|
||||
playerPublicBaseUrl: playerPublicBaseUrl,
|
||||
sessionCookieName: sessionCookieName,
|
||||
sessionMaxAgeMs: sessionMaxAgeMs,
|
||||
dataSourceStartupRefreshStaggerMs: dataSourceStartupRefreshStaggerMs
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createWebConfig };
|
||||
@@ -1,21 +1,24 @@
|
||||
// Helpers for normalizing dashboard state and date values.
|
||||
|
||||
const { WebSocket } = require('ws');
|
||||
|
||||
function normalizeClientName(value) {
|
||||
return String(value || '').trim();
|
||||
}
|
||||
|
||||
function enrichScreensWithConnections(screens, connectionsBySlug, onboardingNameBySlug) {
|
||||
function enrichScreensWithConnections(screens, connectionsBySlug, onboardingNameBySlug, playerUrlsBySlug) {
|
||||
return (screens || []).map(function (screen) {
|
||||
const connectionState = connectionsBySlug[screen.slug] || { count: 0, connections: [] };
|
||||
return Object.assign({}, screen, {
|
||||
client_name: onboardingNameBySlug[screen.slug] || null,
|
||||
player_connection_count: connectionState.count || 0,
|
||||
player_connections: Array.isArray(connectionState.connections) ? connectionState.connections : []
|
||||
player_connections: Array.isArray(connectionState.connections) ? connectionState.connections : [],
|
||||
player_url: String(playerUrlsBySlug && playerUrlsBySlug[screen.slug] || '').trim() || null
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function buildClientRows(screens, connectionsBySlug, onboardingNameBySlug, onboardingNameByDeviceId, playerPublicBaseUrl, formatDashboardDate) {
|
||||
function buildClientRows(screens, connectionsBySlug, onboardingNameBySlug, onboardingNameByDeviceId, formatDashboardDate) {
|
||||
return (screens || []).flatMap(function (screen) {
|
||||
const connectionState = connectionsBySlug[screen.slug] || { count: 0, connections: [] };
|
||||
return (connectionState.connections || []).map(function (connection) {
|
||||
@@ -27,7 +30,7 @@ function buildClientRows(screens, connectionsBySlug, onboardingNameBySlug, onboa
|
||||
playlist_name: screen.playlist_name || null,
|
||||
connectedAtLabel: formatDashboardDate(connection.connectedAt),
|
||||
lastSeenAtLabel: formatDashboardDate(connection.lastSeenAt),
|
||||
player_url: `${playerPublicBaseUrl}/screen/${encodeURIComponent(screen.slug)}`
|
||||
player_url: screen.player_url || String(connection.page || '').trim() || null
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -51,7 +54,6 @@ function createDashboardStateService(options) {
|
||||
const playerSnapshotCache = options && options.playerSnapshotCache;
|
||||
const playerSnapshotSockets = options && options.playerSnapshotSockets;
|
||||
const ensurePlayerSnapshotSubscription = options && options.ensurePlayerSnapshotSubscription;
|
||||
const playerPublicBaseUrl = String(options && options.playerPublicBaseUrl || '').replace(/\/$/, '');
|
||||
const formatDashboardDate = options && options.formatDashboardDate;
|
||||
|
||||
if (!pool || !common || !playerSnapshotCache || !playerSnapshotSockets || typeof ensurePlayerSnapshotSubscription !== 'function' || typeof formatDashboardDate !== 'function') {
|
||||
@@ -61,6 +63,9 @@ function createDashboardStateService(options) {
|
||||
async function buildDashboardState() {
|
||||
const data = await common.fetchAdminData(pool);
|
||||
const screensData = data.screens || [];
|
||||
const playerUrlsBySlug = typeof common.fetchScreenPlayerUrls === 'function'
|
||||
? await common.fetchScreenPlayerUrls(pool)
|
||||
: {};
|
||||
screensData.forEach(function (screen) {
|
||||
ensurePlayerSnapshotSubscription(screen.slug);
|
||||
});
|
||||
@@ -94,14 +99,14 @@ function createDashboardStateService(options) {
|
||||
}
|
||||
});
|
||||
|
||||
const screens = enrichScreensWithConnections(data.screens || [], connectionsBySlug, onboardingNameBySlug)
|
||||
const screens = enrichScreensWithConnections(data.screens || [], connectionsBySlug, onboardingNameBySlug, playerUrlsBySlug)
|
||||
.map(function (screen) {
|
||||
return Object.assign({}, screen, {
|
||||
player_url: `${playerPublicBaseUrl}/screen/${encodeURIComponent(screen.slug)}`
|
||||
player_url: screen.player_url || null
|
||||
});
|
||||
})
|
||||
.sort(compareScreenNames);
|
||||
const clients = buildClientRows(screens, connectionsBySlug, onboardingNameBySlug, onboardingNameByDeviceId, playerPublicBaseUrl, formatDashboardDate);
|
||||
const clients = buildClientRows(screens, connectionsBySlug, onboardingNameBySlug, onboardingNameByDeviceId, formatDashboardDate);
|
||||
const playerServiceConnected = Array.from(playerSnapshotSockets.values()).some(function (socket) {
|
||||
return socket && socket.readyState === WebSocket.OPEN;
|
||||
});
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
async function refreshApiSource(pool, common, apiSourceId, apiUrl, actorId) {
|
||||
async function refreshApiSource(pool, common, apiSourceOrId, actorId) {
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
const apiSource = apiSourceOrId && typeof apiSourceOrId === 'object'
|
||||
? apiSourceOrId
|
||||
: typeof common.fetchApiSourceById === 'function'
|
||||
? await common.fetchApiSourceById(pool, Number(apiSourceOrId))
|
||||
: null;
|
||||
if (!apiSource) {
|
||||
throw new Error('API source not found.');
|
||||
}
|
||||
|
||||
let responseDetails = null;
|
||||
let pullError = '';
|
||||
|
||||
try {
|
||||
responseDetails = await common.fetchApiSourceResponse(apiUrl);
|
||||
responseDetails = await common.fetchApiSourceResponse(apiSource);
|
||||
} catch (error) {
|
||||
pullError = String(error && error.message ? error.message : 'Unable to load API response.');
|
||||
}
|
||||
@@ -13,7 +22,7 @@ async function refreshApiSource(pool, common, apiSourceId, apiUrl, actorId) {
|
||||
await connection.beginTransaction();
|
||||
await connection.query(
|
||||
'UPDATE i_api_sources SET last_pulled_at = ?, last_pull_error = ?, last_response_status = ?, last_response_content_type = ?, last_response_json = ?, modified_by = ? WHERE id = ?',
|
||||
[new Date(), pullError || null, responseDetails ? responseDetails.responseStatus : null, responseDetails ? responseDetails.responseContentType : null, responseDetails ? responseDetails.responseJson : null, actorId, apiSourceId]
|
||||
[new Date(), pullError || null, responseDetails ? responseDetails.responseStatus : null, responseDetails ? responseDetails.responseContentType : null, responseDetails ? responseDetails.responseJson : null, actorId, apiSource.id]
|
||||
);
|
||||
await connection.commit();
|
||||
} catch (error) {
|
||||
|
||||
+35
-20
@@ -1,3 +1,5 @@
|
||||
// Shared helpers for dashboard date formatting, uploads, and query parsing.
|
||||
|
||||
const dashboardDateFormatter = new Intl.DateTimeFormat('en-US', {
|
||||
month: 'short',
|
||||
day: '2-digit',
|
||||
@@ -88,25 +90,31 @@ function getCanvasSignature(width, height) {
|
||||
return normalizedWidth + 'x' + normalizedHeight;
|
||||
}
|
||||
|
||||
async function fetchPlaylistCanvasSignature(pool, playlistId) {
|
||||
async function fetchPlaylistCanvasId(pool, playlistId) {
|
||||
const [playlistRows] = await pool.query('SELECT canvas_id FROM c_playlists WHERE id = ?', [playlistId]);
|
||||
const storedCanvasId = Number(playlistRows && playlistRows[0] && playlistRows[0].canvas_id);
|
||||
if (Number.isInteger(storedCanvasId) && storedCanvasId > 0) {
|
||||
return storedCanvasId;
|
||||
}
|
||||
|
||||
const [rows] = await pool.query(
|
||||
`SELECT DISTINCT cs.width AS canvas_width, cs.height AS canvas_height
|
||||
`SELECT DISTINCT st.canvas_size_id AS canvas_id
|
||||
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 = ?
|
||||
AND cs.width IS NOT NULL
|
||||
AND cs.height IS NOT NULL`,
|
||||
AND st.canvas_size_id IS NOT NULL`,
|
||||
[playlistId]
|
||||
);
|
||||
const signatures = Array.from(new Set(rows.map(function (row) {
|
||||
return getCanvasSignature(row.canvas_width, row.canvas_height);
|
||||
}).filter(Boolean)));
|
||||
if (!signatures.length) {
|
||||
const canvasIds = Array.from(new Set(rows.map(function (row) {
|
||||
return Number(row.canvas_id);
|
||||
}).filter(function (value) {
|
||||
return Number.isInteger(value) && value > 0;
|
||||
})));
|
||||
if (!canvasIds.length) {
|
||||
return null;
|
||||
}
|
||||
return signatures.length === 1 ? signatures[0] : 'mismatch';
|
||||
return canvasIds.length === 1 ? canvasIds[0] : 'mismatch';
|
||||
}
|
||||
|
||||
async function fetchScreensByPlaylistId(connection, playlistId) {
|
||||
@@ -158,19 +166,25 @@ async function fetchOrderedPlaylistSlides(connection, playlistId) {
|
||||
|
||||
function redirectAfterSave(req, res, defaultUrl, options) {
|
||||
const safeOptions = options || {};
|
||||
const action = String((req && req.body && req.body.action) || req.query.action || '').toLowerCase();
|
||||
const action = String((req && req.body && (req.body.action || req.body.save_action)) || req.query.action || '').toLowerCase();
|
||||
const message = safeOptions.message || '';
|
||||
|
||||
function withMessage(url) {
|
||||
if (!message) {
|
||||
return url;
|
||||
}
|
||||
|
||||
const joiner = String(url || '').indexOf('?') === -1 ? '?' : '&';
|
||||
return url + joiner + 'message=' + encodeURIComponent(message);
|
||||
}
|
||||
|
||||
if (action === 'close') {
|
||||
return res.redirect(safeOptions.closeUrl || defaultUrl);
|
||||
return res.redirect(withMessage(safeOptions.closeUrl || defaultUrl));
|
||||
}
|
||||
if (action === 'new') {
|
||||
return res.redirect(safeOptions.newUrl || defaultUrl);
|
||||
return res.redirect(withMessage(safeOptions.newUrl || defaultUrl));
|
||||
}
|
||||
const message = safeOptions.message || '';
|
||||
if (message) {
|
||||
const joiner = defaultUrl.indexOf('?') === -1 ? '?' : '&';
|
||||
return res.redirect(defaultUrl + joiner + 'message=' + encodeURIComponent(message));
|
||||
}
|
||||
return res.redirect(defaultUrl);
|
||||
return res.redirect(withMessage(defaultUrl));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
@@ -183,7 +197,8 @@ module.exports = {
|
||||
normalizeScheduleMode: normalizeScheduleMode,
|
||||
getAuditUserId: getAuditUserId,
|
||||
getCanvasSignature: getCanvasSignature,
|
||||
fetchPlaylistCanvasSignature: fetchPlaylistCanvasSignature,
|
||||
fetchPlaylistCanvasId: fetchPlaylistCanvasId,
|
||||
fetchPlaylistCanvasSignature: fetchPlaylistCanvasId,
|
||||
fetchScreensByPlaylistId: fetchScreensByPlaylistId,
|
||||
fetchScreensBySlideId: fetchScreensBySlideId,
|
||||
fetchScreensByTemplateId: fetchScreensByTemplateId,
|
||||
|
||||
@@ -1,8 +1,22 @@
|
||||
// Shared helpers for parsing and sorting web list query state.
|
||||
|
||||
function parsePageNumber(value) {
|
||||
const pageNumber = Math.floor(Number(value) || 1);
|
||||
return Math.max(1, pageNumber);
|
||||
}
|
||||
|
||||
function getSearchQuery(req) {
|
||||
return String(req && req.query && req.query.search || '').trim();
|
||||
}
|
||||
|
||||
function getSortQuery(req) {
|
||||
return String(req && req.query && req.query.sort || '').trim();
|
||||
}
|
||||
|
||||
function getSortDirectionQuery(req) {
|
||||
return String(req && req.query && req.query.direction || '').trim().toLowerCase() === 'desc' ? 'desc' : 'asc';
|
||||
}
|
||||
|
||||
function normalizeSortDirection(value) {
|
||||
return String(value || '').trim().toLowerCase() === 'desc' ? 'desc' : 'asc';
|
||||
}
|
||||
@@ -81,7 +95,10 @@ function createSearchMatcher(searchTerm, fields) {
|
||||
|
||||
module.exports = {
|
||||
parsePageNumber: parsePageNumber,
|
||||
getSearchQuery: getSearchQuery,
|
||||
getSortQuery: getSortQuery,
|
||||
normalizeSortDirection: normalizeSortDirection,
|
||||
getSortDirectionQuery: getSortDirectionQuery,
|
||||
getComparableSortValue: getComparableSortValue,
|
||||
compareSortValues: compareSortValues,
|
||||
sortRows: sortRows,
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
|
||||
const FONT_LIBRARY_DIR_NAME = 'fonts';
|
||||
const FONT_MANIFEST_FILE = 'fonts.json';
|
||||
const FONT_STYLESHEET_FILE = 'fonts.css';
|
||||
const DEFAULT_FONT_FAMILY_FORMATS = [
|
||||
'Arial=Arial,Helvetica,sans-serif',
|
||||
'Comic Sans MS=Comic Sans MS,cursive,sans-serif',
|
||||
'Courier New=Courier New,Courier,monospace',
|
||||
'Georgia=Georgia,serif',
|
||||
'Helvetica=Helvetica,Arial,sans-serif',
|
||||
'Impact=Impact,Charcoal,sans-serif',
|
||||
'Lucida Sans Unicode=Lucida Sans Unicode,Lucida Grande,sans-serif',
|
||||
'Palatino Linotype=Palatino Linotype,Book Antiqua,Palatino,serif',
|
||||
'Tahoma=Tahoma,Geneva,sans-serif',
|
||||
'Times New Roman=Times New Roman,Times,serif',
|
||||
'Trebuchet MS=Trebuchet MS,Helvetica,sans-serif',
|
||||
'Verdana=Verdana,Geneva,sans-serif'
|
||||
];
|
||||
const FONT_FILE_EXTENSIONS = new Map([
|
||||
['.woff2', 'woff2'],
|
||||
['.woff', 'woff'],
|
||||
['.ttf', 'truetype'],
|
||||
['.otf', 'opentype']
|
||||
]);
|
||||
|
||||
function normalizeText(value) {
|
||||
return String(value || '').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function resolveMediaRoot(mediaRootOrUploadDir) {
|
||||
const resolved = path.resolve(String(mediaRootOrUploadDir || '').trim());
|
||||
return path.basename(resolved) === 'uploads' ? path.dirname(resolved) : resolved;
|
||||
}
|
||||
|
||||
function getFontLibraryDir(mediaRootOrUploadDir) {
|
||||
return path.join(resolveMediaRoot(mediaRootOrUploadDir), FONT_LIBRARY_DIR_NAME);
|
||||
}
|
||||
|
||||
function getFontManifestPath(mediaRootOrUploadDir) {
|
||||
return path.join(getFontLibraryDir(mediaRootOrUploadDir), FONT_MANIFEST_FILE);
|
||||
}
|
||||
|
||||
function getFontStylesheetPath(mediaRootOrUploadDir) {
|
||||
return path.join(getFontLibraryDir(mediaRootOrUploadDir), FONT_STYLESHEET_FILE);
|
||||
}
|
||||
|
||||
function getFontStylesheetHref(mediaRootOrUploadDir) {
|
||||
const stylesheetPath = getFontStylesheetPath(mediaRootOrUploadDir);
|
||||
if (!fs.existsSync(stylesheetPath)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const stat = fs.statSync(stylesheetPath);
|
||||
return `/media/${FONT_LIBRARY_DIR_NAME}/${FONT_STYLESHEET_FILE}?v=${Number(stat.mtimeMs) || Date.now()}`;
|
||||
}
|
||||
|
||||
function normalizeFontEntry(entry) {
|
||||
const fileName = normalizeText(entry && entry.fileName);
|
||||
const family = normalizeText(entry && (entry.family || entry.name || entry.displayName));
|
||||
const extension = path.extname(fileName).toLowerCase();
|
||||
|
||||
return {
|
||||
id: normalizeText(entry && entry.id) || fileName,
|
||||
name: family || fileName.replace(/\.[^.]+$/, ''),
|
||||
family: family || fileName.replace(/\.[^.]+$/, ''),
|
||||
fileName: fileName,
|
||||
format: normalizeText(entry && entry.format) || FONT_FILE_EXTENSIONS.get(extension) || '',
|
||||
enabled: entry && entry.enabled === false ? false : true,
|
||||
createdAt: normalizeText(entry && entry.createdAt),
|
||||
modifiedAt: normalizeText(entry && entry.modifiedAt)
|
||||
};
|
||||
}
|
||||
|
||||
function sortFonts(left, right) {
|
||||
return String(left && left.name || '').localeCompare(String(right && right.name || ''), undefined, { sensitivity: 'base' })
|
||||
|| String(left && left.fileName || '').localeCompare(String(right && right.fileName || ''), undefined, { sensitivity: 'base' });
|
||||
}
|
||||
|
||||
function readManifest(mediaRootOrUploadDir) {
|
||||
const manifestPath = getFontManifestPath(mediaRootOrUploadDir);
|
||||
if (!fs.existsSync(manifestPath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(fs.readFileSync(manifestPath, 'utf8') || '[]');
|
||||
const list = Array.isArray(parsed) ? parsed : Array.isArray(parsed && parsed.fonts) ? parsed.fonts : [];
|
||||
return list.map(normalizeFontEntry).filter(function (entry) {
|
||||
return Boolean(entry.fileName) && Boolean(normalizeText(entry.family || entry.name));
|
||||
}).sort(sortFonts);
|
||||
} catch (_error) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function buildFontFaceRule(entry) {
|
||||
const family = normalizeText(entry.family || entry.name);
|
||||
const fileName = normalizeText(entry.fileName);
|
||||
if (!family || !fileName) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const extension = path.extname(fileName).toLowerCase();
|
||||
const format = FONT_FILE_EXTENSIONS.get(extension) || normalizeText(entry.format) || 'woff2';
|
||||
const escapedFamily = family.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
|
||||
return [
|
||||
'@font-face {',
|
||||
` font-family: '${escapedFamily}';`,
|
||||
` src: url('/media/${FONT_LIBRARY_DIR_NAME}/${encodeURIComponent(fileName)}') format('${format}');`,
|
||||
' font-style: normal;',
|
||||
' font-weight: 400;',
|
||||
' font-display: swap;',
|
||||
'}'
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function buildFontStylesheet(fonts) {
|
||||
const rules = (Array.isArray(fonts) ? fonts : [])
|
||||
.filter(function (font) {
|
||||
return font && font.enabled !== false;
|
||||
})
|
||||
.map(buildFontFaceRule)
|
||||
.filter(Boolean);
|
||||
return rules.length ? `/* Managed fonts */\n\n${rules.join('\n\n')}\n` : '/* Managed fonts */\n';
|
||||
}
|
||||
|
||||
function buildFontFamilyFormats(fonts) {
|
||||
const formatEntries = Array.from(new Set(DEFAULT_FONT_FAMILY_FORMATS.concat((Array.isArray(fonts) ? fonts : [])
|
||||
.filter(function (font) {
|
||||
return font && font.enabled !== false && normalizeText(font.family || font.name);
|
||||
})
|
||||
.map(function (font) {
|
||||
const family = normalizeText(font.family || font.name);
|
||||
return `${family}=${family}`;
|
||||
}))))
|
||||
.sort(function (left, right) {
|
||||
const leftLabel = String(left || '').split('=')[0];
|
||||
const rightLabel = String(right || '').split('=')[0];
|
||||
return leftLabel.localeCompare(rightLabel, undefined, { sensitivity: 'base' });
|
||||
});
|
||||
|
||||
return formatEntries.join(';');
|
||||
}
|
||||
|
||||
async function ensureFontLibrary(mediaRootOrUploadDir) {
|
||||
const fontDir = getFontLibraryDir(mediaRootOrUploadDir);
|
||||
const manifestPath = getFontManifestPath(mediaRootOrUploadDir);
|
||||
const stylesheetPath = getFontStylesheetPath(mediaRootOrUploadDir);
|
||||
|
||||
await fs.promises.mkdir(fontDir, { recursive: true });
|
||||
|
||||
if (!fs.existsSync(manifestPath)) {
|
||||
await fs.promises.writeFile(manifestPath, '[]\n', 'utf8');
|
||||
}
|
||||
|
||||
if (!fs.existsSync(stylesheetPath)) {
|
||||
await fs.promises.writeFile(stylesheetPath, buildFontStylesheet([]), 'utf8');
|
||||
}
|
||||
|
||||
return {
|
||||
fonts: [],
|
||||
fontDir: fontDir,
|
||||
manifestPath: manifestPath,
|
||||
stylesheetPath: stylesheetPath,
|
||||
stylesheetHref: getFontStylesheetHref(mediaRootOrUploadDir),
|
||||
fontFamilyFormats: buildFontFamilyFormats([])
|
||||
};
|
||||
}
|
||||
|
||||
function loadFontLibrary(mediaRootOrUploadDir) {
|
||||
const fonts = readManifest(mediaRootOrUploadDir);
|
||||
return {
|
||||
fonts: fonts,
|
||||
fontDir: getFontLibraryDir(mediaRootOrUploadDir),
|
||||
manifestPath: getFontManifestPath(mediaRootOrUploadDir),
|
||||
stylesheetPath: getFontStylesheetPath(mediaRootOrUploadDir),
|
||||
stylesheetHref: getFontStylesheetHref(mediaRootOrUploadDir),
|
||||
fontFamilyFormats: buildFontFamilyFormats(fonts)
|
||||
};
|
||||
}
|
||||
|
||||
async function saveFontLibrary(mediaRootOrUploadDir, fonts) {
|
||||
const fontDir = getFontLibraryDir(mediaRootOrUploadDir);
|
||||
const manifestPath = getFontManifestPath(mediaRootOrUploadDir);
|
||||
const stylesheetPath = getFontStylesheetPath(mediaRootOrUploadDir);
|
||||
const normalizedFonts = (Array.isArray(fonts) ? fonts : []).map(normalizeFontEntry).filter(function (entry) {
|
||||
return Boolean(entry.fileName) && Boolean(normalizeText(entry.family || entry.name));
|
||||
}).sort(sortFonts);
|
||||
|
||||
await fs.promises.mkdir(fontDir, { recursive: true });
|
||||
await fs.promises.writeFile(manifestPath, JSON.stringify(normalizedFonts, null, 2) + '\n', 'utf8');
|
||||
await fs.promises.writeFile(stylesheetPath, buildFontStylesheet(normalizedFonts), 'utf8');
|
||||
return normalizedFonts;
|
||||
}
|
||||
|
||||
function isSupportedFontUpload(file) {
|
||||
const extension = path.extname(String(file && file.originalname || file && file.filename || '')).toLowerCase();
|
||||
return FONT_FILE_EXTENSIONS.has(extension);
|
||||
}
|
||||
|
||||
async function addFontFile(mediaRootOrUploadDir, uploadedFile, requestedFamily) {
|
||||
if (!uploadedFile || !uploadedFile.path || !isSupportedFontUpload(uploadedFile)) {
|
||||
throw new Error('Unsupported font upload.');
|
||||
}
|
||||
|
||||
const fontDir = getFontLibraryDir(mediaRootOrUploadDir);
|
||||
const fonts = readManifest(mediaRootOrUploadDir);
|
||||
const family = normalizeText(requestedFamily) || normalizeText(path.basename(uploadedFile.originalname || uploadedFile.filename || '', path.extname(uploadedFile.originalname || uploadedFile.filename || '')));
|
||||
if (!family) {
|
||||
throw new Error('Font family is required.');
|
||||
}
|
||||
|
||||
if (fonts.some(function (font) {
|
||||
return String(font.family || font.name || '').trim().toLowerCase() === family.toLowerCase();
|
||||
})) {
|
||||
throw new Error('A font with that family name already exists.');
|
||||
}
|
||||
|
||||
const extension = path.extname(uploadedFile.originalname || uploadedFile.filename || '').toLowerCase();
|
||||
const slug = family.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'font';
|
||||
const fileName = `${slug}-${Date.now()}-${crypto.randomUUID().slice(0, 8)}${extension}`;
|
||||
const destinationPath = path.join(fontDir, fileName);
|
||||
|
||||
await fs.promises.mkdir(fontDir, { recursive: true });
|
||||
await fs.promises.rename(uploadedFile.path, destinationPath);
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const nextFonts = fonts.concat({
|
||||
id: fileName,
|
||||
name: family,
|
||||
family: family,
|
||||
fileName: fileName,
|
||||
format: FONT_FILE_EXTENSIONS.get(extension) || 'woff2',
|
||||
enabled: true,
|
||||
createdAt: now,
|
||||
modifiedAt: now
|
||||
});
|
||||
|
||||
await saveFontLibrary(mediaRootOrUploadDir, nextFonts);
|
||||
return {
|
||||
font: normalizeFontEntry(nextFonts[nextFonts.length - 1]),
|
||||
fonts: loadFontLibrary(mediaRootOrUploadDir).fonts
|
||||
};
|
||||
}
|
||||
|
||||
async function deleteFontFile(mediaRootOrUploadDir, fontId) {
|
||||
const fontDir = getFontLibraryDir(mediaRootOrUploadDir);
|
||||
const fonts = readManifest(mediaRootOrUploadDir);
|
||||
const normalizedId = normalizeText(fontId);
|
||||
const index = fonts.findIndex(function (font) {
|
||||
return String(font.id || font.fileName || '').trim() === normalizedId || String(font.fileName || '').trim() === normalizedId;
|
||||
});
|
||||
|
||||
if (index === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [removed] = fonts.splice(index, 1);
|
||||
if (removed && removed.fileName) {
|
||||
try {
|
||||
await fs.promises.unlink(path.join(fontDir, removed.fileName));
|
||||
} catch (_error) {
|
||||
// ignore missing file cleanup issues
|
||||
}
|
||||
}
|
||||
|
||||
await saveFontLibrary(mediaRootOrUploadDir, fonts);
|
||||
return normalizeFontEntry(removed);
|
||||
}
|
||||
|
||||
async function setFontEnabled(mediaRootOrUploadDir, fontId, enabled) {
|
||||
const fonts = readManifest(mediaRootOrUploadDir);
|
||||
const normalizedId = normalizeText(fontId);
|
||||
const index = fonts.findIndex(function (font) {
|
||||
return String(font.id || font.fileName || '').trim() === normalizedId || String(font.fileName || '').trim() === normalizedId;
|
||||
});
|
||||
|
||||
if (index === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nextFonts = fonts.slice();
|
||||
nextFonts[index] = Object.assign({}, nextFonts[index], {
|
||||
enabled: Boolean(enabled),
|
||||
modifiedAt: new Date().toISOString()
|
||||
});
|
||||
|
||||
await saveFontLibrary(mediaRootOrUploadDir, nextFonts);
|
||||
return normalizeFontEntry(nextFonts[index]);
|
||||
}
|
||||
|
||||
function collectFontLibraryUploadPaths(mediaRootOrUploadDir) {
|
||||
const fonts = readManifest(mediaRootOrUploadDir);
|
||||
const uploadPaths = new Set();
|
||||
const manifestPath = getFontManifestPath(mediaRootOrUploadDir);
|
||||
const stylesheetPath = getFontStylesheetPath(mediaRootOrUploadDir);
|
||||
const mediaRoot = resolveMediaRoot(mediaRootOrUploadDir);
|
||||
|
||||
if (manifestPath) {
|
||||
uploadPaths.add('/media/' + path.relative(mediaRoot, manifestPath).replace(/\\/g, '/'));
|
||||
}
|
||||
if (stylesheetPath) {
|
||||
uploadPaths.add('/media/' + path.relative(mediaRoot, stylesheetPath).replace(/\\/g, '/'));
|
||||
}
|
||||
|
||||
fonts.forEach(function (font) {
|
||||
if (font && font.fileName) {
|
||||
uploadPaths.add(`/media/${FONT_LIBRARY_DIR_NAME}/${font.fileName}`);
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(uploadPaths);
|
||||
}
|
||||
|
||||
async function collectFontLibraryDirectoryUploadPaths(mediaRootOrUploadDir) {
|
||||
const fontDir = getFontLibraryDir(mediaRootOrUploadDir);
|
||||
try {
|
||||
const entries = await fs.promises.readdir(fontDir, { withFileTypes: true });
|
||||
return entries
|
||||
.filter(function (entry) {
|
||||
return entry && entry.isFile();
|
||||
})
|
||||
.map(function (entry) {
|
||||
return `/media/${FONT_LIBRARY_DIR_NAME}/${entry.name}`;
|
||||
});
|
||||
} catch (_error) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function collectFontLibrarySyncOperations(mediaRootOrUploadDir) {
|
||||
const fonts = readManifest(mediaRootOrUploadDir);
|
||||
const operations = [
|
||||
{ type: 'put', uploadPath: '/media/' + FONT_LIBRARY_DIR_NAME + '/' + FONT_MANIFEST_FILE },
|
||||
{ type: 'put', uploadPath: '/media/' + FONT_LIBRARY_DIR_NAME + '/' + FONT_STYLESHEET_FILE }
|
||||
];
|
||||
|
||||
fonts.forEach(function (font) {
|
||||
if (!font || !font.fileName) {
|
||||
return;
|
||||
}
|
||||
|
||||
operations.push({
|
||||
type: font.enabled === false ? 'delete' : 'put',
|
||||
uploadPath: `/media/${FONT_LIBRARY_DIR_NAME}/${font.fileName}`
|
||||
});
|
||||
});
|
||||
|
||||
return operations;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ensureFontLibrary: ensureFontLibrary,
|
||||
loadFontLibrary: loadFontLibrary,
|
||||
saveFontLibrary: saveFontLibrary,
|
||||
addFontFile: addFontFile,
|
||||
deleteFontFile: deleteFontFile,
|
||||
setFontEnabled: setFontEnabled,
|
||||
collectFontLibraryUploadPaths: collectFontLibraryUploadPaths,
|
||||
collectFontLibraryDirectoryUploadPaths: collectFontLibraryDirectoryUploadPaths,
|
||||
collectFontLibrarySyncOperations: collectFontLibrarySyncOperations,
|
||||
getFontStylesheetHref: getFontStylesheetHref,
|
||||
buildFontFamilyFormats: buildFontFamilyFormats,
|
||||
isSupportedFontUpload: isSupportedFontUpload,
|
||||
getFontLibraryDir: getFontLibraryDir,
|
||||
getFontManifestPath: getFontManifestPath,
|
||||
getFontStylesheetPath: getFontStylesheetPath
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
// Media helper entry point for upload sync and thumbnail capture.
|
||||
|
||||
module.exports = {
|
||||
createUploadSyncService: require('./upload-sync').createUploadSyncService,
|
||||
captureSlideThumbnail: require('./slide-thumbnails').captureSlideThumbnail,
|
||||
fontLibrary: require('./font-library')
|
||||
};
|
||||
@@ -1,3 +1,5 @@
|
||||
// Slide thumbnail capture helpers for player-sourced screenshots.
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const puppeteer = require('puppeteer-core');
|
||||
@@ -15,8 +17,8 @@ const {
|
||||
sanitizeFontFamily,
|
||||
sanitizeFontSize,
|
||||
sanitizeTextColor
|
||||
} = require('../../player/render-helpers');
|
||||
const { createRequestAuthHeaders } = require('../../request-auth');
|
||||
} = require('#src/player/render-helpers');
|
||||
const { createRequestAuthHeaders } = require('#src/request-auth');
|
||||
|
||||
const SYSTEM_CHROMIUM_PATHS = [
|
||||
process.env.PUPPETEER_EXECUTABLE_PATH,
|
||||
@@ -1,8 +1,11 @@
|
||||
// Upload sync helpers for mirroring web media to player storage.
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const multer = require('multer');
|
||||
const { createRequestAuthHeaders } = require('../../request-auth');
|
||||
const { createRequestAuthHeaders } = require('#src/request-auth');
|
||||
const { collectFontLibrarySyncOperations } = require('./font-library');
|
||||
|
||||
function normalizeUploadRoot(uploadDir) {
|
||||
return path.resolve(String(uploadDir || '').trim());
|
||||
@@ -11,25 +14,62 @@ function normalizeUploadRoot(uploadDir) {
|
||||
function createUploadSyncService(options) {
|
||||
const pool = options && options.pool;
|
||||
const common = options && options.common;
|
||||
const playerInternalBaseUrl = String(options && options.playerInternalBaseUrl || '').replace(/\/$/, '');
|
||||
const configuredPlayerInternalBaseUrl = String(options && options.playerInternalBaseUrl || '').replace(/\/$/, '');
|
||||
const playerSnapshotCache = options && options.playerSnapshotCache;
|
||||
const notifyPlayerScreens = options && options.notifyPlayerScreens;
|
||||
const backgroundTaskQueue = options && options.backgroundTaskQueue;
|
||||
const MAX_UPLOAD_BYTES = 1024 * 1024 * 1024;
|
||||
|
||||
let playerUploadSyncMode = null;
|
||||
let playerUploadSyncModePromise = null;
|
||||
const pendingPlayerUploadSyncs = new Map();
|
||||
let pendingPlayerUploadSyncFlushTimer = null;
|
||||
let pendingPlayerUploadSyncFlushInFlight = null;
|
||||
const pendingPlaylistUploadSyncs = new Map();
|
||||
let pendingPlaylistUploadSyncFlushTimer = null;
|
||||
let pendingPlaylistUploadSyncFlushInFlight = null;
|
||||
let playerInternalBaseUrl = null;
|
||||
let playerInternalBaseUrlPromise = null;
|
||||
|
||||
if (!common || !playerSnapshotCache || typeof notifyPlayerScreens !== 'function') {
|
||||
throw new Error('createUploadSyncService requires the upload dependencies.');
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function createUploadMiddleware(uploadDir) {
|
||||
const storage = multer.diskStorage({
|
||||
destination: function (_req, _file, cb) {
|
||||
@@ -232,53 +272,7 @@ function createUploadSyncService(options) {
|
||||
return uploadPaths;
|
||||
}
|
||||
|
||||
async function getPlayerUploadSyncMode(localUploadDir) {
|
||||
if (playerUploadSyncMode) {
|
||||
return playerUploadSyncMode;
|
||||
}
|
||||
if (playerUploadSyncModePromise) {
|
||||
return playerUploadSyncModePromise;
|
||||
}
|
||||
|
||||
playerUploadSyncModePromise = (async function () {
|
||||
try {
|
||||
const authHeaders = createRequestAuthHeaders({
|
||||
method: 'GET',
|
||||
pathname: '/api/media/config'
|
||||
});
|
||||
const response = await fetch(`${playerInternalBaseUrl}/api/media/config`, {
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
...authHeaders
|
||||
}
|
||||
});
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
const data = await response.json();
|
||||
const playerUploadDir = data && (data.uploadDir || data.mediaDir) ? normalizeUploadRoot(data.uploadDir || data.mediaDir) : null;
|
||||
if (!playerUploadDir) {
|
||||
return null;
|
||||
}
|
||||
return playerUploadDir === normalizeUploadRoot(localUploadDir) ? 'shared' : 'different';
|
||||
} catch (_error) {
|
||||
return null;
|
||||
}
|
||||
})().then(function (mode) {
|
||||
if (mode) {
|
||||
playerUploadSyncMode = mode;
|
||||
}
|
||||
playerUploadSyncModePromise = null;
|
||||
return mode;
|
||||
}, function () {
|
||||
playerUploadSyncModePromise = null;
|
||||
return null;
|
||||
});
|
||||
|
||||
return playerUploadSyncModePromise;
|
||||
}
|
||||
|
||||
async function shouldMirrorUploads(localUploadDir) {
|
||||
function shouldMirrorUploads(localUploadDir) {
|
||||
return Boolean(localUploadDir);
|
||||
}
|
||||
|
||||
@@ -310,7 +304,12 @@ function createUploadSyncService(options) {
|
||||
}
|
||||
|
||||
async function pushUploadFileToPlayer(uploadPath, localUploadDir) {
|
||||
if (!uploadPath || !(await shouldMirrorUploads(localUploadDir))) {
|
||||
if (!uploadPath || !shouldMirrorUploads(localUploadDir)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const resolvedPlayerInternalBaseUrl = await getPlayerInternalBaseUrl();
|
||||
if (!resolvedPlayerInternalBaseUrl) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -335,7 +334,7 @@ function createUploadSyncService(options) {
|
||||
pathname: `/api/media/${encodeURIComponent(relativePath)}`,
|
||||
body: fileBuffer
|
||||
});
|
||||
const response = await fetch(`${playerInternalBaseUrl}/api/media/${encodeURIComponent(relativePath)}`, {
|
||||
const response = await fetch(`${resolvedPlayerInternalBaseUrl}/api/media/${encodeURIComponent(relativePath)}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
@@ -355,7 +354,12 @@ function createUploadSyncService(options) {
|
||||
}
|
||||
|
||||
async function removeUploadFileFromPlayer(uploadPath, localUploadDir) {
|
||||
if (!uploadPath || !(await shouldMirrorUploads(localUploadDir))) {
|
||||
if (!uploadPath || !shouldMirrorUploads(localUploadDir)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const resolvedPlayerInternalBaseUrl = await getPlayerInternalBaseUrl();
|
||||
if (!resolvedPlayerInternalBaseUrl) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -368,7 +372,7 @@ function createUploadSyncService(options) {
|
||||
method: 'DELETE',
|
||||
pathname: `/api/media/${encodeURIComponent(relativePath)}`
|
||||
});
|
||||
const response = await fetch(`${playerInternalBaseUrl}/api/media/${encodeURIComponent(relativePath)}`, {
|
||||
const response = await fetch(`${resolvedPlayerInternalBaseUrl}/api/media/${encodeURIComponent(relativePath)}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
@@ -387,7 +391,7 @@ function createUploadSyncService(options) {
|
||||
}
|
||||
|
||||
async function syncUploadRefsToPlayer(uploadRefs, localUploadDir) {
|
||||
if (!(await shouldMirrorUploads(localUploadDir))) {
|
||||
if (!shouldMirrorUploads(localUploadDir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -404,13 +408,6 @@ function createUploadSyncService(options) {
|
||||
}
|
||||
}
|
||||
|
||||
async function syncExistingUploadsToPlayer(pool, localUploadDir) {
|
||||
return queueMediaSyncTask('media-sync:initial', 'Initial media sync', {
|
||||
mode: 'initial',
|
||||
uploadDir: localUploadDir
|
||||
});
|
||||
}
|
||||
|
||||
function getVisibleCurrentSlideIds() {
|
||||
const visibleSlideIds = new Set();
|
||||
playerSnapshotCache.forEach(function (snapshot) {
|
||||
@@ -618,6 +615,7 @@ function createUploadSyncService(options) {
|
||||
uploadRefs.add(reference);
|
||||
});
|
||||
});
|
||||
const fontLibraryOperations = collectFontLibrarySyncOperations(uploadDir);
|
||||
Array.from(uploadRefs).forEach(function (uploadPath) {
|
||||
queuePlayerUploadSync({
|
||||
type: 'put',
|
||||
@@ -625,6 +623,17 @@ function createUploadSyncService(options) {
|
||||
uploadDir: uploadDir
|
||||
});
|
||||
});
|
||||
fontLibraryOperations.forEach(function (operation) {
|
||||
if (!operation || !operation.uploadPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
queuePlayerUploadSync({
|
||||
type: String(operation.type || 'put').trim().toLowerCase() === 'delete' ? 'delete' : 'put',
|
||||
uploadPath: operation.uploadPath,
|
||||
uploadDir: uploadDir
|
||||
});
|
||||
});
|
||||
await flushPendingPlayerUploadSyncs();
|
||||
return;
|
||||
}
|
||||
@@ -696,14 +705,12 @@ function createUploadSyncService(options) {
|
||||
countUploadReferences: countUploadReferences,
|
||||
removeUnusedUploadFiles: removeUnusedUploadFiles,
|
||||
collectUploadPathsFromDirectory: collectUploadPathsFromDirectory,
|
||||
getPlayerUploadSyncMode: getPlayerUploadSyncMode,
|
||||
shouldMirrorUploads: shouldMirrorUploads,
|
||||
queuePlayerUploadSync: queuePlayerUploadSync,
|
||||
schedulePendingPlayerUploadSyncFlush: schedulePendingPlayerUploadSyncFlush,
|
||||
pushUploadFileToPlayer: pushUploadFileToPlayer,
|
||||
removeUploadFileFromPlayer: removeUploadFileFromPlayer,
|
||||
syncUploadRefsToPlayer: syncUploadRefsToPlayer,
|
||||
syncExistingUploadsToPlayer: syncExistingUploadsToPlayer,
|
||||
getVisibleCurrentSlideIds: getVisibleCurrentSlideIds,
|
||||
isScreenRefreshBlocked: isScreenRefreshBlocked,
|
||||
splitRefreshScreenSlugsByVisibility: splitRefreshScreenSlugsByVisibility,
|
||||
@@ -0,0 +1,25 @@
|
||||
function createNotifyPlayerScreens(forwardPlayerCommand) {
|
||||
if (typeof forwardPlayerCommand !== 'function') {
|
||||
throw new Error('createNotifyPlayerScreens requires a player command sender.');
|
||||
}
|
||||
|
||||
return function notifyPlayerScreens(slugs, commandOrPayload) {
|
||||
const uniqueSlugs = Array.from(new Set((slugs || []).map(function (slug) {
|
||||
return String(slug || '').trim();
|
||||
}).filter(Boolean)));
|
||||
|
||||
if (!uniqueSlugs.length) {
|
||||
return Promise.resolve(0);
|
||||
}
|
||||
|
||||
return Promise.allSettled(uniqueSlugs.map(function (slug) {
|
||||
return forwardPlayerCommand(slug, commandOrPayload || 'refresh');
|
||||
})).then(function (results) {
|
||||
return results.filter(function (result) {
|
||||
return result.status === 'fulfilled';
|
||||
}).length;
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createNotifyPlayerScreens };
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user