Release v2.2.0
This commit is contained in:
+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.
|
||||
@@ -2,6 +2,66 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## 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
|
||||
|
||||
@@ -65,7 +65,6 @@ The app reads its settings from environment variables. The list below matches th
|
||||
### 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`
|
||||
|
||||
@@ -11,8 +11,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}
|
||||
|
||||
+19
-6
@@ -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`
|
||||
@@ -94,6 +94,17 @@ Primary keys are `id` unless noted otherwise. Timestamps are stored as `created_
|
||||
- Foreign keys:
|
||||
- `playlist_id` -> `c_playlists.id` with `ON DELETE CASCADE`
|
||||
- `slide_id` -> `c_slides.id` with `ON DELETE CASCADE`
|
||||
|
||||
## 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.
|
||||
|
||||
### `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 +112,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,6 +123,10 @@ Primary keys are `id` unless noted otherwise. Timestamps are stored as `created_
|
||||
- Foreign key:
|
||||
- `screen_id` -> `d_screens.id` with `ON DELETE SET NULL`
|
||||
|
||||
## 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.
|
||||
@@ -134,7 +147,7 @@ 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`
|
||||
|
||||
## Operations
|
||||
|
||||
|
||||
@@ -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.2.0",
|
||||
"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);
|
||||
|
||||
+44
-17
@@ -1,20 +1,32 @@
|
||||
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');
|
||||
|
||||
module.exports = {
|
||||
createPool: dbCommon.createPool,
|
||||
@@ -30,9 +42,22 @@ 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,
|
||||
fetchApiSourcesData: data.fetchApiSourcesData,
|
||||
fetchApiSourcesPage: data.fetchApiSourcesPage,
|
||||
@@ -49,6 +74,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,
|
||||
|
||||
+5
-2
@@ -1,3 +1,5 @@
|
||||
// Admin data aggregation helpers for dashboards, playlists, slides, templates, and screens.
|
||||
|
||||
const { fetchPagedRows } = require('./utils');
|
||||
|
||||
async function fetchAdminData(pool) {
|
||||
@@ -10,7 +12,7 @@ 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 COUNT(DISTINCT ps.playlist_id) FROM c_playlist_slides ps WHERE ps.slide_id = s.id) AS playlist_count
|
||||
@@ -39,7 +41,8 @@ async function fetchAdminData(pool) {
|
||||
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) {
|
||||
|
||||
+20
-1
@@ -1,8 +1,12 @@
|
||||
// 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 { 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,6 +22,19 @@ 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,
|
||||
fetchApiSourcesData,
|
||||
fetchApiSourcesPage,
|
||||
@@ -34,6 +51,8 @@ module.exports = {
|
||||
replaceRssFeedItems,
|
||||
fetchScreenById,
|
||||
fetchScreenEditData,
|
||||
fetchScreenPlayerUrls,
|
||||
fetchScreenPlayerRecord,
|
||||
fetchTemplateById,
|
||||
fetchSlideById,
|
||||
fetchTemplatesData,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// 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]);
|
||||
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]
|
||||
);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+82
-5
@@ -1,3 +1,5 @@
|
||||
// Slide data access helpers, including rich-text normalization and payload building.
|
||||
|
||||
const { fetchTemplateById } = require('./templates');
|
||||
const { parseJsonSafe } = require('./utils');
|
||||
|
||||
@@ -25,6 +27,25 @@ 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,
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
@@ -122,6 +123,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,
|
||||
@@ -287,6 +322,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 = {
|
||||
|
||||
+180
-3
@@ -1,7 +1,184 @@
|
||||
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
|
||||
`);
|
||||
|
||||
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
|
||||
`);
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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}}}
|
||||
|
||||
@@ -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');
|
||||
@@ -101,7 +103,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;
|
||||
|
||||
@@ -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,26 +463,26 @@ body.screen-blackout #app {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.template-region.text table,
|
||||
.template-region.text th,
|
||||
.template-region.text td {
|
||||
.template-region table,
|
||||
.template-region th,
|
||||
.template-region td {
|
||||
border: 1px solid rgba(255, 255, 255, 0.35);
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.template-region.text table {
|
||||
.template-region table {
|
||||
width: 100%;
|
||||
border-spacing: 0;
|
||||
}
|
||||
|
||||
.template-region.text th,
|
||||
.template-region.text td {
|
||||
.template-region th,
|
||||
.template-region td {
|
||||
padding: 0.35em 0.5em;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.template-region.text th {
|
||||
.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);
|
||||
}
|
||||
@@ -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.
|
||||
@@ -415,15 +437,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 +467,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 +518,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 +538,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);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
const CACHE_VERSION = 'v2';
|
||||
// Service worker cache strategy for player pages, assets, media, and playlists.
|
||||
|
||||
const CACHE_VERSION = 'v36';
|
||||
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}`;
|
||||
|
||||
+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);
|
||||
@@ -822,4 +824,8 @@ function destroyRtmpRegions(root) {
|
||||
video.__rtmpHls = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
registry.register('rtmp', {
|
||||
renderRegion: renderRtmpRegion
|
||||
});
|
||||
@@ -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);
|
||||
@@ -142,4 +144,10 @@ function renderVideoRegion(region, regionContent) {
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
+18
-2
@@ -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 { 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 || '')
|
||||
});
|
||||
}
|
||||
@@ -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)
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// Player route registration and filesystem-backed media helpers.
|
||||
|
||||
const fs = require('fs');
|
||||
const express = require('express');
|
||||
const path = require('path');
|
||||
@@ -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) {
|
||||
@@ -273,6 +286,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);
|
||||
|
||||
+108
-3
@@ -1,8 +1,23 @@
|
||||
// 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');
|
||||
|
||||
function normalizePlayerPublicBaseUrl(pageUrl) {
|
||||
const value = String(pageUrl || '').trim();
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return new URL(value).origin.replace(/\/$/, '');
|
||||
} catch (_error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function createPlayerRuntime(options) {
|
||||
const pool = options && options.pool ? options.pool : null;
|
||||
const normalizeDeviceId = typeof options.normalizeDeviceId === 'function'
|
||||
@@ -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,
|
||||
|
||||
+34
-2
@@ -1,3 +1,5 @@
|
||||
// Permission section definitions and normalization helpers for RBAC.
|
||||
|
||||
const PERMISSION_SECTIONS = [
|
||||
{
|
||||
key: 'dashboard',
|
||||
@@ -31,6 +33,18 @@ const PERMISSION_SECTIONS = [
|
||||
{ key: 'delete', name: 'Delete', description: 'Delete screens.' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'announcements',
|
||||
order: 35,
|
||||
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: 'playlists',
|
||||
order: 40,
|
||||
@@ -103,6 +117,17 @@ const PERMISSION_SECTIONS = [
|
||||
{ key: 'delete', name: 'Delete', description: 'Delete API sources.' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'fonts',
|
||||
order: 95,
|
||||
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: 100,
|
||||
@@ -236,15 +261,22 @@ 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)) {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// Page and request authentication signing helpers.
|
||||
|
||||
const crypto = require('crypto');
|
||||
|
||||
const PAGE_TOKEN_HEADER = 'x-pulse-page-auth';
|
||||
|
||||
+81
-237
@@ -1,32 +1,29 @@
|
||||
// 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 { 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 } = 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,
|
||||
@@ -41,57 +38,34 @@ const {
|
||||
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({
|
||||
pool: pool,
|
||||
common: common,
|
||||
playerInternalBaseUrl: PLAYER_INTERNAL_BASE_URL
|
||||
playerInternalBaseUrl: webConfig.playerInternalBaseUrl
|
||||
});
|
||||
const notifyPlayerScreens = function (slugs, commandOrPayload) {
|
||||
const uniqueSlugs = Array.from(new Set((slugs || []).map(function (slug) {
|
||||
return String(slug || '').trim();
|
||||
}).filter(Boolean)));
|
||||
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 +76,80 @@ 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,
|
||||
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,
|
||||
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: createRequirePermission,
|
||||
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) {
|
||||
@@ -343,20 +183,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
+93
-41
@@ -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 { createUploadSyncService } = require('./lib/media');
|
||||
const { createRequestAuthHeaders } = require('../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,144 @@
|
||||
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 saveLabel = String(hash.saveLabel || 'Save');
|
||||
const saveButtonClass = String(hash.saveButtonClass || 'btn btn-success');
|
||||
const saveActionName = String(hash.saveActionName || 'save_action');
|
||||
const saveActionValue = String(hash.saveActionValue || 'save');
|
||||
const closeLabel = String(hash.saveAndCloseLabel || 'Save and Close');
|
||||
const closeValue = String(hash.saveAndCloseValue || 'close');
|
||||
const newLabel = String(hash.saveAndNewLabel || 'Save and New');
|
||||
const 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 ariaLabel = String(hash.ariaLabel || 'Save actions');
|
||||
const hasSecondaryActions = showSaveAndClose || showSaveAndNew;
|
||||
|
||||
if (!formId) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const escape = Handlebars.escapeExpression;
|
||||
const formAttr = ` form="${escape(formId)}"`;
|
||||
|
||||
return new Handlebars.SafeString([
|
||||
'<div class="btn-group save-action-group" role="group">',
|
||||
`<button type="submit" class="${escape(saveButtonClass)}"${formAttr} name="${escape(saveActionName)}" value="${escape(saveActionValue)}">${escape(saveLabel)}</button>`,
|
||||
hasSecondaryActions ? `<button type="button" class="btn btn-success dropdown-toggle dropdown-toggle-split" 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(saveActionName)}" value="${escape(closeValue)}">${escape(closeLabel)}</button>` : '',
|
||||
hasSecondaryActions && showSaveAndNew ? `<button type="submit" class="dropdown-item"${formAttr} name="${escape(saveActionName)}" value="${escape(newValue)}">${escape(newLabel)}</button>` : '',
|
||||
hasSecondaryActions ? '</div>' : '',
|
||||
'</div>',
|
||||
].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'));
|
||||
}
|
||||
|
||||
// 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) {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,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 };
|
||||
@@ -1,3 +1,5 @@
|
||||
// Shared helpers for building paginated list response state.
|
||||
|
||||
function normalizePageNumber(value) {
|
||||
const pageNumber = Math.floor(Number(value) || 1);
|
||||
return Math.max(1, pageNumber);
|
||||
@@ -89,6 +91,15 @@ function buildPagination(totalItems, currentPage, pageParam, queryState, pageSiz
|
||||
const firstQuery = Object.assign({}, queryState, { [normalizedPageParam]: 1 });
|
||||
const lastQuery = Object.assign({}, queryState, { [normalizedPageParam]: totalPages });
|
||||
|
||||
let normalizedItemLabel = String(itemLabel || 'items');
|
||||
const total = Number(totalItems) || 0;
|
||||
|
||||
if (total === 1 && normalizedItemLabel.endsWith('s')) {
|
||||
normalizedItemLabel = normalizedItemLabel.slice(0, -1);
|
||||
} else if (total > 1 && !normalizedItemLabel.endsWith('s')) {
|
||||
normalizedItemLabel += 's';
|
||||
}
|
||||
|
||||
return {
|
||||
currentPage: safeCurrentPage,
|
||||
totalPages: totalPages,
|
||||
@@ -107,7 +118,7 @@ function buildPagination(totalItems, currentPage, pageParam, queryState, pageSiz
|
||||
pages: pages,
|
||||
pageSize: normalizedPageSize,
|
||||
pageParam: normalizedPageParam,
|
||||
itemLabel: String(itemLabel || 'items'),
|
||||
itemLabel: String(normalizedItemLabel || 'item'),
|
||||
ariaLabel: String(ariaLabel || 'Pagination')
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,13 +1,55 @@
|
||||
const { createRequestAuthHeaders } = require('../../request-auth');
|
||||
const { createRequestAuthHeaders } = require('#src/request-auth');
|
||||
|
||||
function createPlayerActionService(options) {
|
||||
const playerInternalBaseUrl = String(options && options.playerInternalBaseUrl || '').replace(/\/$/, '');
|
||||
const pool = options && options.pool;
|
||||
const configuredPlayerInternalBaseUrl = String(options && options.playerInternalBaseUrl || '').replace(/\/$/, '');
|
||||
const common = options && options.common;
|
||||
|
||||
if (!playerInternalBaseUrl || !common) {
|
||||
if (!common) {
|
||||
throw new Error('createPlayerActionService requires the player action dependencies.');
|
||||
}
|
||||
|
||||
let playerInternalBaseUrl = null;
|
||||
let playerInternalBaseUrlPromise = null;
|
||||
|
||||
async function getPlayerInternalBaseUrl() {
|
||||
if (playerInternalBaseUrl) {
|
||||
return playerInternalBaseUrl;
|
||||
}
|
||||
|
||||
if (playerInternalBaseUrlPromise) {
|
||||
return playerInternalBaseUrlPromise;
|
||||
}
|
||||
|
||||
playerInternalBaseUrlPromise = (async function () {
|
||||
if (!pool) {
|
||||
return configuredPlayerInternalBaseUrl || null;
|
||||
}
|
||||
|
||||
try {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT internal_base_url
|
||||
FROM d_players
|
||||
WHERE device_id = '1'
|
||||
LIMIT 1`
|
||||
);
|
||||
const resolvedBaseUrl = String(rows && rows[0] && rows[0].internal_base_url || '').trim().replace(/\/$/, '');
|
||||
return resolvedBaseUrl || configuredPlayerInternalBaseUrl || null;
|
||||
} catch (_error) {
|
||||
return configuredPlayerInternalBaseUrl || null;
|
||||
}
|
||||
})().then(function (baseUrl) {
|
||||
playerInternalBaseUrl = baseUrl || null;
|
||||
playerInternalBaseUrlPromise = null;
|
||||
return playerInternalBaseUrl;
|
||||
}, function () {
|
||||
playerInternalBaseUrlPromise = null;
|
||||
return configuredPlayerInternalBaseUrl || null;
|
||||
});
|
||||
|
||||
return playerInternalBaseUrlPromise;
|
||||
}
|
||||
|
||||
async function forwardPlayerCommand(slug, commandOrPayload, connectionId) {
|
||||
const payload = typeof commandOrPayload === 'object' && commandOrPayload !== null
|
||||
? Object.assign({}, commandOrPayload)
|
||||
@@ -20,7 +62,12 @@ function createPlayerActionService(options) {
|
||||
pathname: `/api/screens/${encodeURIComponent(slug)}/commands`,
|
||||
body: payload
|
||||
});
|
||||
const response = await fetch(`${playerInternalBaseUrl}/api/screens/${encodeURIComponent(slug)}/commands`, {
|
||||
const resolvedPlayerInternalBaseUrl = await getPlayerInternalBaseUrl();
|
||||
if (!resolvedPlayerInternalBaseUrl) {
|
||||
throw new Error('Unable to resolve the player internal base URL.');
|
||||
}
|
||||
|
||||
const response = await fetch(`${resolvedPlayerInternalBaseUrl}/api/screens/${encodeURIComponent(slug)}/commands`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -42,12 +89,50 @@ function createPlayerActionService(options) {
|
||||
});
|
||||
}
|
||||
|
||||
async function forwardAnnouncementRefresh(slug) {
|
||||
const authHeaders = createRequestAuthHeaders({
|
||||
method: 'POST',
|
||||
pathname: `/api/screens/${encodeURIComponent(slug)}/announcements/refresh`,
|
||||
body: { command: 'announcement-refresh' }
|
||||
});
|
||||
const resolvedPlayerInternalBaseUrl = await getPlayerInternalBaseUrl();
|
||||
if (!resolvedPlayerInternalBaseUrl) {
|
||||
throw new Error('Unable to resolve the player internal base URL.');
|
||||
}
|
||||
|
||||
const response = await fetch(`${resolvedPlayerInternalBaseUrl}/api/screens/${encodeURIComponent(slug)}/announcements/refresh`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
...authHeaders
|
||||
},
|
||||
body: JSON.stringify({ command: 'announcement-refresh' })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text().catch(function () { return ''; });
|
||||
const error = new Error(errorText || `Unable to refresh announcements for player ${slug}.`);
|
||||
error.statusCode = response.status;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return response.json().catch(function () {
|
||||
return { ok: true };
|
||||
});
|
||||
}
|
||||
|
||||
async function getScreenConnections(slug) {
|
||||
const authHeaders = createRequestAuthHeaders({
|
||||
method: 'GET',
|
||||
pathname: `/api/screens/${encodeURIComponent(slug)}/connections`
|
||||
});
|
||||
const response = await fetch(`${playerInternalBaseUrl}/api/screens/${encodeURIComponent(slug)}/connections`, {
|
||||
const resolvedPlayerInternalBaseUrl = await getPlayerInternalBaseUrl();
|
||||
if (!resolvedPlayerInternalBaseUrl) {
|
||||
throw new Error('Unable to resolve the player internal base URL.');
|
||||
}
|
||||
|
||||
const response = await fetch(`${resolvedPlayerInternalBaseUrl}/api/screens/${encodeURIComponent(slug)}/connections`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
@@ -110,6 +195,7 @@ function createPlayerActionService(options) {
|
||||
|
||||
return {
|
||||
forwardPlayerCommand: forwardPlayerCommand,
|
||||
forwardAnnouncementRefresh: forwardAnnouncementRefresh,
|
||||
getScreenConnections: getScreenConnections,
|
||||
getScreenDeleteBlockMessage: getScreenDeleteBlockMessage,
|
||||
getSlideDeleteBlockMessage: getSlideDeleteBlockMessage,
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const PUBLIC_JS_ROOT = path.join(__dirname, '..', 'public', 'js');
|
||||
const REGION_ROOT_DIR = path.join(PUBLIC_JS_ROOT, 'regions');
|
||||
const REGION_TYPE_DIR = path.join(REGION_ROOT_DIR, 'type');
|
||||
const REGION_CORE_SCRIPTS = ['js/shared/placeholder-utils.js', 'js/shared/placeholder-chips.js', 'js/shared/time-date-placeholders.js', 'js/regions/region-utils.js', 'js/regions/region-types.js'];
|
||||
|
||||
function withAssetVersion(scriptPath, assetVersion) {
|
||||
if (!assetVersion) {
|
||||
return scriptPath;
|
||||
}
|
||||
|
||||
return scriptPath + (String(scriptPath).indexOf('?') === -1 ? '?v=' : '&v=') + encodeURIComponent(String(assetVersion));
|
||||
}
|
||||
|
||||
function getRegionModuleScripts(assetVersion) {
|
||||
if (!fs.existsSync(REGION_TYPE_DIR)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return fs.readdirSync(REGION_TYPE_DIR, { withFileTypes: true })
|
||||
.filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith('.js'))
|
||||
.map((entry) => withAssetVersion(`js/regions/type/${entry.name}`, assetVersion))
|
||||
.sort((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
function getRegionEditorScripts(assetVersion) {
|
||||
return REGION_CORE_SCRIPTS.map((script) => withAssetVersion(script, assetVersion)).concat(getRegionModuleScripts(assetVersion));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getRegionModuleScripts,
|
||||
getRegionEditorScripts,
|
||||
withAssetVersion
|
||||
};
|
||||
@@ -1,291 +0,0 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const puppeteer = require('puppeteer-core');
|
||||
const chromiumModule = require('@sparticuz/chromium');
|
||||
const sharp = require('sharp');
|
||||
const chromium = chromiumModule && typeof chromiumModule.executablePath === 'function'
|
||||
? chromiumModule
|
||||
: chromiumModule && chromiumModule.default && typeof chromiumModule.default.executablePath === 'function'
|
||||
? chromiumModule.default
|
||||
: chromiumModule;
|
||||
const {
|
||||
escapeHtml,
|
||||
mediaKind,
|
||||
renderEditorJsContent,
|
||||
sanitizeFontFamily,
|
||||
sanitizeFontSize,
|
||||
sanitizeTextColor
|
||||
} = require('../../player/render-helpers');
|
||||
const { createRequestAuthHeaders } = require('../../request-auth');
|
||||
|
||||
const SYSTEM_CHROMIUM_PATHS = [
|
||||
process.env.PUPPETEER_EXECUTABLE_PATH,
|
||||
process.env.CHROMIUM_PATH,
|
||||
'/usr/bin/chromium',
|
||||
'/usr/bin/chromium-browser',
|
||||
'/usr/local/bin/chromium',
|
||||
'/snap/bin/chromium'
|
||||
].filter(Boolean);
|
||||
const PLAYER_VIEWPORT = {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
deviceScaleFactor: 1
|
||||
};
|
||||
const THUMBNAIL_MAX_SIZE = {
|
||||
width: 480,
|
||||
height: 270
|
||||
};
|
||||
|
||||
function normalizeBaseUrl(baseUrl) {
|
||||
return String(baseUrl || '').trim().replace(/\/$/, '');
|
||||
}
|
||||
|
||||
function resolveAssetUrl(baseUrl, value) {
|
||||
const raw = String(value || '').trim();
|
||||
if (!raw) {
|
||||
return '';
|
||||
}
|
||||
if (/^(?:https?:)?\/\//i.test(raw) || raw.startsWith('data:')) {
|
||||
return raw;
|
||||
}
|
||||
const normalizedBaseUrl = normalizeBaseUrl(baseUrl);
|
||||
if (!normalizedBaseUrl) {
|
||||
return raw;
|
||||
}
|
||||
if (raw.startsWith('/')) {
|
||||
return normalizedBaseUrl + raw;
|
||||
}
|
||||
return normalizedBaseUrl + '/' + raw.replace(/^\/+/, '');
|
||||
}
|
||||
|
||||
function getCanvasSize(slide) {
|
||||
const template = slide && slide.template ? slide.template : null;
|
||||
return {
|
||||
width: Math.max(1, Number(template && template.canvas_size_width ? template.canvas_size_width : 1920)),
|
||||
height: Math.max(1, Number(template && template.canvas_size_height ? template.canvas_size_height : 1080))
|
||||
};
|
||||
}
|
||||
|
||||
function getRegionContent(slide, region) {
|
||||
const content = slide && slide.content && slide.content[region.region_key] ? slide.content[region.region_key] : {};
|
||||
return content && typeof content === 'object' ? content : { value: content };
|
||||
}
|
||||
|
||||
function hasVisibleContent(html) {
|
||||
return Boolean(String(html || '').replace(/<[^>]+>/g, '').trim());
|
||||
}
|
||||
|
||||
function buildTextRegionMarkup(region, regionContent) {
|
||||
const fontFamily = sanitizeFontFamily(regionContent.font_family || region.font_family);
|
||||
const fontSize = sanitizeFontSize(regionContent.font_size || region.font_size);
|
||||
const fontColor = sanitizeTextColor(regionContent.font_color || region.font_color);
|
||||
const renderedBody = renderEditorJsContent(regionContent.value || '');
|
||||
if (!hasVisibleContent(renderedBody)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return '<div class="template-region text" style="' + region.baseStyle + '"><div class="template-region-text-scale" style="width:' + region.pixelWidth + 'px;height:' + region.pixelHeight + 'px;' + (fontFamily ? 'font-family:' + escapeHtml(fontFamily) + ';' : '') + 'font-size:' + fontSize + 'px;color:' + escapeHtml(fontColor || '#000000') + ';">' + renderedBody + '</div></div>';
|
||||
}
|
||||
|
||||
function buildRegionInnerHtml(region, regionContent, baseUrl) {
|
||||
const regionType = String(regionContent.type || region.region_type || 'text').trim().toLowerCase();
|
||||
const rawValue = regionContent && regionContent.value !== undefined ? regionContent.value : '';
|
||||
|
||||
if (regionType === 'image') {
|
||||
const src = resolveAssetUrl(baseUrl, rawValue);
|
||||
return src
|
||||
? '<img src="' + escapeHtml(src) + '" alt="' + escapeHtml(region.label || region.region_key || 'image') + '" />'
|
||||
: '';
|
||||
}
|
||||
|
||||
if (regionType === 'video') {
|
||||
const src = resolveAssetUrl(baseUrl, rawValue);
|
||||
return src
|
||||
? '<video src="' + escapeHtml(src) + '" title="' + escapeHtml(region.label || region.region_key || 'video') + '" muted playsinline preload="metadata"></video>'
|
||||
: '';
|
||||
}
|
||||
|
||||
if (regionType === 'webpage') {
|
||||
const src = resolveAssetUrl(baseUrl, rawValue);
|
||||
return src
|
||||
? '<iframe src="' + escapeHtml(src) + '" title="' + escapeHtml(region.label || region.region_key || 'webpage') + '" loading="eager" referrerpolicy="no-referrer" scrolling="no"></iframe>'
|
||||
: '';
|
||||
}
|
||||
|
||||
if (regionType === 'html') {
|
||||
const html = String(rawValue || '').trim();
|
||||
return html
|
||||
? '<iframe sandbox="" srcdoc="<!doctype html><html><head><style>html,body{margin:0;width:100%;height:100%;overflow:hidden;background:transparent;}</style></head><body>' + escapeHtml(html) + '</body></html>" title="' + escapeHtml(region.label || region.region_key || 'html') + '" loading="eager" scrolling="no"></iframe>'
|
||||
: '';
|
||||
}
|
||||
|
||||
if (regionType === 'rtmp') {
|
||||
const label = String(rawValue || '').trim() || 'RTMP source';
|
||||
return '<div class="template-region-rtmp-placeholder">' + escapeHtml(label) + '</div>';
|
||||
}
|
||||
|
||||
return buildTextRegionMarkup(region, regionContent);
|
||||
}
|
||||
|
||||
async function launchBrowser() {
|
||||
let executablePath = SYSTEM_CHROMIUM_PATHS.find(function (candidate) {
|
||||
return fs.existsSync(candidate);
|
||||
}) || '';
|
||||
const usingSystemChromium = Boolean(executablePath);
|
||||
|
||||
if (!executablePath && chromium && typeof chromium.executablePath === 'function') {
|
||||
executablePath = await chromium.executablePath();
|
||||
}
|
||||
|
||||
if (!executablePath || !fs.existsSync(executablePath)) {
|
||||
throw new Error('Chromium executable was not found.');
|
||||
}
|
||||
|
||||
if (usingSystemChromium) {
|
||||
return puppeteer.launch({
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-gpu'
|
||||
],
|
||||
defaultViewport: { width: 1920, height: 1080, deviceScaleFactor: 1 },
|
||||
executablePath: executablePath,
|
||||
headless: true
|
||||
});
|
||||
}
|
||||
|
||||
return puppeteer.launch({
|
||||
args: puppeteer.defaultArgs({
|
||||
args: chromium && chromium.args ? chromium.args : [],
|
||||
headless: 'shell'
|
||||
}),
|
||||
defaultViewport: chromium && chromium.defaultViewport ? chromium.defaultViewport : null,
|
||||
executablePath: executablePath,
|
||||
headless: 'shell'
|
||||
});
|
||||
}
|
||||
|
||||
async function captureSlideThumbnail(options) {
|
||||
const pool = options && options.pool;
|
||||
const common = options && options.common;
|
||||
const mediaDir = String(options && options.mediaDir || '').trim();
|
||||
const baseUrl = normalizeBaseUrl(options && options.baseUrl);
|
||||
const slideId = Number(options && options.slideId || 0);
|
||||
const previousThumbnailPath = String(options && options.previousThumbnailPath || '').trim();
|
||||
|
||||
if (!pool || !common || !mediaDir || !Number.isFinite(slideId) || slideId <= 0) {
|
||||
throw new Error('captureSlideThumbnail requires pool, common, mediaDir, and slideId.');
|
||||
}
|
||||
|
||||
const slide = await common.fetchSlideById(pool, slideId);
|
||||
if (!slide) {
|
||||
throw new Error('Slide not found.');
|
||||
}
|
||||
|
||||
const canvasSize = getCanvasSize(slide);
|
||||
const thumbnailDir = path.join(mediaDir, 'thumbnails');
|
||||
const thumbnailRelativePath = String(slide.thumbnail_path || '').trim() || '/media/thumbnails/slides/slide-' + slide.id + '.png';
|
||||
const filePath = path.join(mediaDir, thumbnailRelativePath.replace(/^\/+media\//, ''));
|
||||
const fullSizePath = filePath.replace(/\.png$/i, '.full.png');
|
||||
const thumbnailTempPath = filePath.replace(/\.png$/i, '.tmp.png');
|
||||
const thumbnailPath = thumbnailRelativePath;
|
||||
|
||||
await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
|
||||
|
||||
async function waitForThumbnailRender(page) {
|
||||
await page.waitForFunction(function () {
|
||||
return document.readyState === 'complete' && Boolean(document.querySelector('.slide-canvas'));
|
||||
}, { timeout: 30000 });
|
||||
|
||||
await page.waitForFunction(function () {
|
||||
var canvas = document.querySelector('.slide-canvas');
|
||||
if (!canvas) {
|
||||
return false;
|
||||
}
|
||||
|
||||
var images = Array.prototype.slice.call(canvas.querySelectorAll('img'));
|
||||
return images.every(function (image) {
|
||||
return image.complete && typeof image.naturalWidth === 'number';
|
||||
});
|
||||
}, { timeout: 30000 });
|
||||
|
||||
await page.evaluate(async function () {
|
||||
if (document.fonts && document.fonts.ready) {
|
||||
try {
|
||||
await document.fonts.ready;
|
||||
} catch (_error) {
|
||||
// Ignore font readiness failures and fall back to the rendered frame.
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await page.evaluate(function () {
|
||||
return new Promise(function (resolve) {
|
||||
window.requestAnimationFrame(function () {
|
||||
window.requestAnimationFrame(resolve);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const browser = await launchBrowser();
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
try {
|
||||
const previewPath = '/api/internal/slide-thumbnails/' + slide.id + '/preview';
|
||||
const previewUrl = baseUrl + previewPath;
|
||||
await page.setExtraHTTPHeaders(createRequestAuthHeaders({
|
||||
method: 'GET',
|
||||
pathname: previewPath
|
||||
}));
|
||||
await page.setViewport(PLAYER_VIEWPORT);
|
||||
await page.goto(previewUrl, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||
await waitForThumbnailRender(page);
|
||||
const canvas = await page.$('.slide-canvas');
|
||||
if (!canvas) {
|
||||
throw new Error('Player render did not produce a slide canvas.');
|
||||
}
|
||||
await canvas.screenshot({ path: fullSizePath });
|
||||
} finally {
|
||||
await page.close().catch(function () {
|
||||
return null;
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
await browser.close().catch(function () {
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
await sharp(fullSizePath)
|
||||
.resize({
|
||||
width: THUMBNAIL_MAX_SIZE.width,
|
||||
height: THUMBNAIL_MAX_SIZE.height,
|
||||
fit: 'inside',
|
||||
withoutEnlargement: true
|
||||
})
|
||||
.png()
|
||||
.toFile(thumbnailTempPath);
|
||||
|
||||
await fs.promises.rm(filePath, { force: true });
|
||||
await fs.promises.rename(thumbnailTempPath, filePath);
|
||||
await fs.promises.unlink(fullSizePath).catch(function (error) {
|
||||
if (!error || error.code !== 'ENOENT') {
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
await pool.query('UPDATE c_slides SET thumbnail_path = ? WHERE id = ?', [thumbnailPath, slide.id]);
|
||||
return {
|
||||
slideId: slide.id,
|
||||
thumbnailPath: thumbnailPath,
|
||||
filePath: filePath,
|
||||
fullSizePath: fullSizePath,
|
||||
mediaKind: mediaKind('')
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
captureSlideThumbnail: captureSlideThumbnail
|
||||
};
|
||||
@@ -1,721 +0,0 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const multer = require('multer');
|
||||
const { createRequestAuthHeaders } = require('../../request-auth');
|
||||
|
||||
function normalizeUploadRoot(uploadDir) {
|
||||
return path.resolve(String(uploadDir || '').trim());
|
||||
}
|
||||
|
||||
function createUploadSyncService(options) {
|
||||
const pool = options && options.pool;
|
||||
const common = options && options.common;
|
||||
const playerInternalBaseUrl = 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;
|
||||
|
||||
if (!common || !playerSnapshotCache || typeof notifyPlayerScreens !== 'function') {
|
||||
throw new Error('createUploadSyncService requires the upload dependencies.');
|
||||
}
|
||||
|
||||
function createUploadMiddleware(uploadDir) {
|
||||
const storage = multer.diskStorage({
|
||||
destination: function (_req, _file, cb) {
|
||||
cb(null, uploadDir);
|
||||
},
|
||||
filename: function (_req, file, cb) {
|
||||
const safeExt = path.extname(file.originalname || '').toLowerCase();
|
||||
const stamp = `${Date.now()}-${crypto.randomUUID()}`;
|
||||
cb(null, `${stamp}${safeExt}`);
|
||||
}
|
||||
});
|
||||
return multer({
|
||||
storage: storage,
|
||||
limits: {
|
||||
fileSize: MAX_UPLOAD_BYTES
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeUploadReference(uploadPath) {
|
||||
const value = String(uploadPath || '').trim();
|
||||
if (!value || !value.startsWith('/media/')) {
|
||||
return null;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function getUploadRelativePath(uploadPath) {
|
||||
const value = normalizeUploadReference(uploadPath);
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
return value.replace(/^\/media\//, '');
|
||||
}
|
||||
|
||||
function resolveUploadFilePath(uploadDir, uploadPath) {
|
||||
const relativePath = getUploadRelativePath(uploadPath);
|
||||
if (!relativePath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedUploadDir = normalizeUploadRoot(uploadDir);
|
||||
if (!normalizedUploadDir) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const mediaRoot = path.basename(normalizedUploadDir) === 'uploads'
|
||||
? path.dirname(normalizedUploadDir)
|
||||
: normalizedUploadDir;
|
||||
|
||||
if (relativePath.startsWith('uploads/')) {
|
||||
return path.join(mediaRoot, relativePath);
|
||||
}
|
||||
|
||||
return path.join(mediaRoot, relativePath);
|
||||
}
|
||||
|
||||
function collectUploadReferencesFromValue(value, refs) {
|
||||
if (!value) {
|
||||
return refs;
|
||||
}
|
||||
const stack = [value];
|
||||
while (stack.length) {
|
||||
const current = stack.pop();
|
||||
if (Array.isArray(current)) {
|
||||
current.forEach(function (item) {
|
||||
stack.push(item);
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (current && typeof current === 'object') {
|
||||
Object.keys(current).forEach(function (key) {
|
||||
stack.push(current[key]);
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (typeof current === 'string') {
|
||||
const reference = normalizeUploadReference(current);
|
||||
if (reference) {
|
||||
refs.add(reference);
|
||||
}
|
||||
}
|
||||
}
|
||||
return refs;
|
||||
}
|
||||
|
||||
function collectUploadReferencesFromSlide(slide) {
|
||||
const refs = new Set();
|
||||
if (!slide) {
|
||||
return refs;
|
||||
}
|
||||
collectUploadReferencesFromValue(common.parseJsonSafe(slide.content_json), refs);
|
||||
return refs;
|
||||
}
|
||||
|
||||
function collectUploadReferencesFromTemplate(template) {
|
||||
const refs = new Set();
|
||||
if (!template) {
|
||||
return refs;
|
||||
}
|
||||
collectUploadReferencesFromValue(template.background_image_path, refs);
|
||||
return refs;
|
||||
}
|
||||
|
||||
function collectUploadReferencesFromPayload(payload) {
|
||||
const refs = new Set();
|
||||
if (!payload) {
|
||||
return refs;
|
||||
}
|
||||
collectUploadReferencesFromValue(common.parseJsonSafe(payload.contentJson), refs);
|
||||
collectUploadReferencesFromValue(payload.backgroundImagePath, refs);
|
||||
return refs;
|
||||
}
|
||||
|
||||
async function countUploadReferences(pool, uploadPath) {
|
||||
const [slideRows] = await pool.query(
|
||||
`SELECT COUNT(*) AS ref_count
|
||||
FROM c_slides
|
||||
WHERE JSON_SEARCH(COALESCE(content_json, JSON_OBJECT()), 'one', ?) IS NOT NULL`,
|
||||
[uploadPath]
|
||||
);
|
||||
const [thumbnailRows] = await pool.query(
|
||||
'SELECT COUNT(*) AS ref_count FROM c_slides WHERE thumbnail_path = ?',
|
||||
[uploadPath]
|
||||
);
|
||||
const [templateRows] = await pool.query(
|
||||
'SELECT COUNT(*) AS ref_count FROM c_templates WHERE background_image_path = ?',
|
||||
[uploadPath]
|
||||
);
|
||||
return Number(slideRows[0].ref_count || 0) + Number(thumbnailRows[0].ref_count || 0) + Number(templateRows[0].ref_count || 0);
|
||||
}
|
||||
|
||||
async function removeUnusedUploadFiles(pool, uploadDir, uploadPaths) {
|
||||
const uniquePaths = Array.from(new Set((uploadPaths || []).map(normalizeUploadReference).filter(Boolean)));
|
||||
for (let i = 0; i < uniquePaths.length; i += 1) {
|
||||
const uploadPath = uniquePaths[i];
|
||||
const referenceCount = await countUploadReferences(pool, uploadPath);
|
||||
if (referenceCount > 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const filePath = resolveUploadFilePath(uploadDir, uploadPath);
|
||||
try {
|
||||
await fs.promises.unlink(filePath);
|
||||
} catch (error) {
|
||||
if (error && error.code !== 'ENOENT') {
|
||||
console.warn('Unable to remove unused upload file:', filePath, error);
|
||||
}
|
||||
}
|
||||
queuePlayerUploadSync({
|
||||
type: 'delete',
|
||||
uploadPath: uploadPath,
|
||||
uploadDir: uploadDir
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function collectUploadPathsFromDirectory(uploadDir) {
|
||||
const normalizedUploadDir = normalizeUploadRoot(uploadDir);
|
||||
if (!normalizedUploadDir) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const uploadPaths = [];
|
||||
|
||||
async function walkDirectory(currentDir, relativeDir) {
|
||||
let entries = [];
|
||||
try {
|
||||
entries = await fs.promises.readdir(currentDir, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
if (error && error.code !== 'ENOENT') {
|
||||
console.warn('Unable to read upload directory:', currentDir, error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
const entryName = String(entry && entry.name || '').trim();
|
||||
if (!entryName || entryName === '.' || entryName === '..') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const nextRelativePath = relativeDir ? path.posix.join(relativeDir, entryName) : entryName;
|
||||
const nextAbsolutePath = path.join(currentDir, entryName);
|
||||
|
||||
if (entry.isDirectory && entry.isDirectory()) {
|
||||
await walkDirectory(nextAbsolutePath, nextRelativePath);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.isFile && !entry.isFile()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
uploadPaths.push('/media/uploads/' + nextRelativePath.replace(/\\/g, '/'));
|
||||
}
|
||||
}
|
||||
|
||||
await walkDirectory(normalizedUploadDir, '');
|
||||
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) {
|
||||
return Boolean(localUploadDir);
|
||||
}
|
||||
|
||||
function queuePlayerUploadSync(operation) {
|
||||
if (!operation || !operation.uploadPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
pendingPlayerUploadSyncs.set(normalizeUploadReference(operation.uploadPath), {
|
||||
type: operation.type === 'delete' ? 'delete' : 'put',
|
||||
uploadPath: normalizeUploadReference(operation.uploadPath),
|
||||
uploadDir: operation.uploadDir || null
|
||||
});
|
||||
|
||||
schedulePendingPlayerUploadSyncFlush();
|
||||
}
|
||||
|
||||
function schedulePendingPlayerUploadSyncFlush() {
|
||||
if (pendingPlayerUploadSyncFlushTimer) {
|
||||
return;
|
||||
}
|
||||
|
||||
pendingPlayerUploadSyncFlushTimer = setTimeout(function () {
|
||||
pendingPlayerUploadSyncFlushTimer = null;
|
||||
flushPendingPlayerUploadSyncs().catch(function (error) {
|
||||
console.warn('Unable to flush pending upload syncs:', error);
|
||||
});
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
async function pushUploadFileToPlayer(uploadPath, localUploadDir) {
|
||||
if (!uploadPath || !(await shouldMirrorUploads(localUploadDir))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const relativePath = getUploadRelativePath(uploadPath);
|
||||
const sourcePath = resolveUploadFilePath(localUploadDir, uploadPath);
|
||||
if (!relativePath || !sourcePath) {
|
||||
return false;
|
||||
}
|
||||
let fileBuffer = null;
|
||||
try {
|
||||
fileBuffer = await fs.promises.readFile(sourcePath);
|
||||
} catch (error) {
|
||||
if (!error || error.code !== 'ENOENT') {
|
||||
console.warn('Unable to read upload for player sync:', sourcePath, error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const authHeaders = createRequestAuthHeaders({
|
||||
method: 'PUT',
|
||||
pathname: `/api/media/${encodeURIComponent(relativePath)}`,
|
||||
body: fileBuffer
|
||||
});
|
||||
const response = await fetch(`${playerInternalBaseUrl}/api/media/${encodeURIComponent(relativePath)}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
...authHeaders
|
||||
},
|
||||
body: fileBuffer
|
||||
});
|
||||
if (!response.ok) {
|
||||
console.warn('Unable to sync upload to player:', relativePath, response.status, response.statusText);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn('Unable to sync upload to player:', relativePath, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeUploadFileFromPlayer(uploadPath, localUploadDir) {
|
||||
if (!uploadPath || !(await shouldMirrorUploads(localUploadDir))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const relativePath = getUploadRelativePath(uploadPath);
|
||||
if (!relativePath) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const authHeaders = createRequestAuthHeaders({
|
||||
method: 'DELETE',
|
||||
pathname: `/api/media/${encodeURIComponent(relativePath)}`
|
||||
});
|
||||
const response = await fetch(`${playerInternalBaseUrl}/api/media/${encodeURIComponent(relativePath)}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
...authHeaders
|
||||
}
|
||||
});
|
||||
if (!response.ok && response.status !== 404) {
|
||||
console.warn('Unable to remove upload from player:', relativePath, response.status, response.statusText);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn('Unable to remove upload from player:', relativePath, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function syncUploadRefsToPlayer(uploadRefs, localUploadDir) {
|
||||
if (!(await shouldMirrorUploads(localUploadDir))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const uniqueRefs = Array.from(new Set((uploadRefs || []).map(normalizeUploadReference).filter(Boolean)));
|
||||
for (let i = 0; i < uniqueRefs.length; i += 1) {
|
||||
const success = await pushUploadFileToPlayer(uniqueRefs[i], localUploadDir);
|
||||
if (!success) {
|
||||
queuePlayerUploadSync({
|
||||
type: 'put',
|
||||
uploadPath: uniqueRefs[i],
|
||||
uploadDir: localUploadDir
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
const connections = snapshot && Array.isArray(snapshot.connections) ? snapshot.connections : [];
|
||||
connections.forEach(function (connection) {
|
||||
const currentSlide = connection && connection.currentSlide && typeof connection.currentSlide === 'object'
|
||||
? connection.currentSlide
|
||||
: null;
|
||||
const slideId = currentSlide && currentSlide.id !== undefined && currentSlide.id !== null
|
||||
? String(currentSlide.id).trim()
|
||||
: '';
|
||||
if (slideId) {
|
||||
visibleSlideIds.add(slideId);
|
||||
}
|
||||
});
|
||||
});
|
||||
return visibleSlideIds;
|
||||
}
|
||||
|
||||
function isScreenRefreshBlocked(screenSlug, blockedSlideIds, screenSlideCounts) {
|
||||
const slideIds = Array.isArray(blockedSlideIds)
|
||||
? blockedSlideIds.map(function (value) {
|
||||
return String(value || '').trim();
|
||||
}).filter(Boolean)
|
||||
: [];
|
||||
if (!slideIds.length) {
|
||||
return false;
|
||||
}
|
||||
const normalizedScreenSlug = String(screenSlug || '').trim();
|
||||
const slideCount = screenSlideCounts && Object.prototype.hasOwnProperty.call(screenSlideCounts, normalizedScreenSlug)
|
||||
? Number(screenSlideCounts[normalizedScreenSlug])
|
||||
: null;
|
||||
if (Number.isFinite(slideCount) && slideCount <= 1) {
|
||||
return false;
|
||||
}
|
||||
const snapshot = playerSnapshotCache.get(String(screenSlug || '').trim());
|
||||
const connections = snapshot && Array.isArray(snapshot.connections) ? snapshot.connections : [];
|
||||
return connections.some(function (connection) {
|
||||
const currentSlide = connection && connection.currentSlide && typeof connection.currentSlide === 'object'
|
||||
? connection.currentSlide
|
||||
: null;
|
||||
const currentSlideId = currentSlide && currentSlide.id !== undefined && currentSlide.id !== null
|
||||
? String(currentSlide.id).trim()
|
||||
: '';
|
||||
return currentSlideId && slideIds.includes(currentSlideId);
|
||||
});
|
||||
}
|
||||
|
||||
function splitRefreshScreenSlugsByVisibility(screenSlugs, blockedSlideIds, screenSlideCounts) {
|
||||
const ready = [];
|
||||
const blocked = [];
|
||||
Array.from(new Set(Array.isArray(screenSlugs) ? screenSlugs : [])).forEach(function (screenSlug) {
|
||||
const normalizedScreenSlug = String(screenSlug || '').trim();
|
||||
if (!normalizedScreenSlug) {
|
||||
return;
|
||||
}
|
||||
if (isScreenRefreshBlocked(normalizedScreenSlug, blockedSlideIds, screenSlideCounts)) {
|
||||
blocked.push(normalizedScreenSlug);
|
||||
} else {
|
||||
ready.push(normalizedScreenSlug);
|
||||
}
|
||||
});
|
||||
return { ready: ready, blocked: blocked };
|
||||
}
|
||||
|
||||
function normalizePlaylistUploadSyncOperation(options) {
|
||||
return {
|
||||
key: String(options && options.key ? options.key : '').trim(),
|
||||
pool: options && options.pool ? options.pool : null,
|
||||
localUploadDir: options && options.localUploadDir ? options.localUploadDir : null,
|
||||
previousUploadRefs: Array.from(new Set(options && options.previousUploadRefs ? options.previousUploadRefs : [])),
|
||||
nextUploadRefs: Array.from(new Set(options && options.nextUploadRefs ? options.nextUploadRefs : [])),
|
||||
blockedSlideIds: Array.from(new Set(options && options.blockedSlideIds ? options.blockedSlideIds : [])).map(function (value) {
|
||||
return String(value || '').trim();
|
||||
}).filter(Boolean),
|
||||
refreshScreenSlugs: Array.from(new Set(options && options.refreshScreenSlugs ? options.refreshScreenSlugs : [])).map(function (value) {
|
||||
return String(value || '').trim();
|
||||
}).filter(Boolean)
|
||||
,
|
||||
screenSlideCounts: options && options.screenSlideCounts && typeof options.screenSlideCounts === 'object'
|
||||
? options.screenSlideCounts
|
||||
: {}
|
||||
};
|
||||
}
|
||||
|
||||
function queuePlaylistUploadSync(operation) {
|
||||
if (!operation || !operation.key) {
|
||||
return;
|
||||
}
|
||||
pendingPlaylistUploadSyncs.set(operation.key, normalizePlaylistUploadSyncOperation(operation));
|
||||
schedulePendingPlaylistUploadSyncFlush();
|
||||
}
|
||||
|
||||
function schedulePendingPlaylistUploadSyncFlush() {
|
||||
if (pendingPlaylistUploadSyncFlushTimer) {
|
||||
return;
|
||||
}
|
||||
|
||||
pendingPlaylistUploadSyncFlushTimer = setTimeout(function () {
|
||||
pendingPlaylistUploadSyncFlushTimer = null;
|
||||
flushPendingPlaylistUploadSyncs().catch(function (error) {
|
||||
console.warn('Unable to flush pending playlist upload syncs:', error);
|
||||
});
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
async function syncPlaylistUploadsOnChange(options) {
|
||||
const operation = normalizePlaylistUploadSyncOperation(options);
|
||||
if (!operation.key) {
|
||||
return;
|
||||
}
|
||||
|
||||
return queueMediaSyncTask('media-sync:' + operation.key, 'Media sync', {
|
||||
mode: 'playlist',
|
||||
operation: operation
|
||||
});
|
||||
}
|
||||
|
||||
async function flushPendingPlaylistUploadSyncs() {
|
||||
if (pendingPlaylistUploadSyncFlushInFlight) {
|
||||
return pendingPlaylistUploadSyncFlushInFlight;
|
||||
}
|
||||
|
||||
if (!pendingPlaylistUploadSyncs.size) {
|
||||
return null;
|
||||
}
|
||||
|
||||
pendingPlaylistUploadSyncFlushInFlight = (async function () {
|
||||
const pendingEntries = Array.from(pendingPlaylistUploadSyncs.values());
|
||||
for (let i = 0; i < pendingEntries.length; i += 1) {
|
||||
const operation = pendingEntries[i];
|
||||
const refreshTargets = splitRefreshScreenSlugsByVisibility(operation.refreshScreenSlugs, operation.blockedSlideIds, operation.screenSlideCounts);
|
||||
if (!refreshTargets.ready.length) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (refreshTargets.ready.length) {
|
||||
await notifyPlayerScreens(refreshTargets.ready, 'refresh');
|
||||
}
|
||||
|
||||
pendingPlaylistUploadSyncs.delete(operation.key);
|
||||
}
|
||||
})().finally(function () {
|
||||
pendingPlaylistUploadSyncFlushInFlight = null;
|
||||
if (pendingPlaylistUploadSyncs.size) {
|
||||
schedulePendingPlaylistUploadSyncFlush();
|
||||
}
|
||||
});
|
||||
|
||||
return pendingPlaylistUploadSyncFlushInFlight;
|
||||
}
|
||||
|
||||
async function flushPendingPlayerUploadSyncs() {
|
||||
if (pendingPlayerUploadSyncFlushInFlight) {
|
||||
return pendingPlayerUploadSyncFlushInFlight;
|
||||
}
|
||||
|
||||
if (!pendingPlayerUploadSyncs.size) {
|
||||
return null;
|
||||
}
|
||||
|
||||
pendingPlayerUploadSyncFlushInFlight = (async function () {
|
||||
const pendingEntries = Array.from(pendingPlayerUploadSyncs.values());
|
||||
for (let i = 0; i < pendingEntries.length; i += 1) {
|
||||
const operation = pendingEntries[i];
|
||||
let success = false;
|
||||
if (operation.type === 'delete') {
|
||||
success = await removeUploadFileFromPlayer(operation.uploadPath, operation.uploadDir);
|
||||
} else {
|
||||
success = await pushUploadFileToPlayer(operation.uploadPath, operation.uploadDir);
|
||||
}
|
||||
if (success) {
|
||||
pendingPlayerUploadSyncs.delete(operation.uploadPath);
|
||||
}
|
||||
}
|
||||
})().finally(function () {
|
||||
pendingPlayerUploadSyncFlushInFlight = null;
|
||||
if (pendingPlayerUploadSyncs.size) {
|
||||
schedulePendingPlayerUploadSyncFlush();
|
||||
}
|
||||
});
|
||||
|
||||
return pendingPlayerUploadSyncFlushInFlight;
|
||||
}
|
||||
|
||||
async function runMediaSyncTask(payload) {
|
||||
const taskPayload = payload || {};
|
||||
const mode = String(taskPayload.mode || '').trim();
|
||||
|
||||
if (mode === 'initial') {
|
||||
const uploadDir = String(taskPayload.uploadDir || '').trim();
|
||||
if (!(await shouldMirrorUploads(uploadDir))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await common.fetchAdminData(pool);
|
||||
const uploadRefs = new Set();
|
||||
(data.slides || []).forEach(function (slide) {
|
||||
collectUploadReferencesFromSlide(slide).forEach(function (reference) {
|
||||
uploadRefs.add(reference);
|
||||
});
|
||||
});
|
||||
(data.templates || []).forEach(function (template) {
|
||||
collectUploadReferencesFromTemplate(template).forEach(function (reference) {
|
||||
uploadRefs.add(reference);
|
||||
});
|
||||
});
|
||||
Array.from(uploadRefs).forEach(function (uploadPath) {
|
||||
queuePlayerUploadSync({
|
||||
type: 'put',
|
||||
uploadPath: uploadPath,
|
||||
uploadDir: uploadDir
|
||||
});
|
||||
});
|
||||
await flushPendingPlayerUploadSyncs();
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode === 'playlist') {
|
||||
const operation = normalizePlaylistUploadSyncOperation(taskPayload.operation || taskPayload);
|
||||
|
||||
if (operation.nextUploadRefs.length) {
|
||||
await syncUploadRefsToPlayer(operation.nextUploadRefs, operation.localUploadDir);
|
||||
}
|
||||
|
||||
if (operation.previousUploadRefs.length) {
|
||||
const nextUploadRefSet = new Set(operation.nextUploadRefs);
|
||||
await removeUnusedUploadFiles(pool, operation.localUploadDir, operation.previousUploadRefs.filter(function (reference) {
|
||||
return !nextUploadRefSet.has(reference);
|
||||
}));
|
||||
}
|
||||
|
||||
if (operation.refreshScreenSlugs.length) {
|
||||
const refreshTargets = splitRefreshScreenSlugsByVisibility(operation.refreshScreenSlugs, operation.blockedSlideIds, operation.screenSlideCounts);
|
||||
if (refreshTargets.ready.length) {
|
||||
await notifyPlayerScreens(refreshTargets.ready, 'refresh');
|
||||
}
|
||||
refreshTargets.blocked.forEach(function (screenSlug) {
|
||||
queuePlaylistUploadSync({
|
||||
key: operation.key + ':refresh:' + screenSlug,
|
||||
blockedSlideIds: operation.blockedSlideIds,
|
||||
refreshScreenSlugs: [screenSlug]
|
||||
});
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error('Unknown media sync task mode.');
|
||||
}
|
||||
|
||||
async function queueMediaSyncTask(taskKey, title, payload) {
|
||||
const safePayload = Object.assign({}, payload || {});
|
||||
delete safePayload.pool;
|
||||
if (safePayload.operation && typeof safePayload.operation === 'object') {
|
||||
safePayload.operation = Object.assign({}, safePayload.operation);
|
||||
delete safePayload.operation.pool;
|
||||
}
|
||||
|
||||
const definition = {
|
||||
key: taskKey,
|
||||
title: title,
|
||||
category: 'media-sync',
|
||||
taskType: 'media-sync',
|
||||
payload: safePayload,
|
||||
persist: true
|
||||
};
|
||||
|
||||
if (backgroundTaskQueue && typeof backgroundTaskQueue.enqueueTaskAndWait === 'function') {
|
||||
return backgroundTaskQueue.enqueueTaskAndWait(definition);
|
||||
}
|
||||
|
||||
return runMediaSyncTask(safePayload);
|
||||
}
|
||||
|
||||
return {
|
||||
createUploadMiddleware: createUploadMiddleware,
|
||||
normalizeUploadReference: normalizeUploadReference,
|
||||
collectUploadReferencesFromValue: collectUploadReferencesFromValue,
|
||||
collectUploadReferencesFromSlide: collectUploadReferencesFromSlide,
|
||||
collectUploadReferencesFromTemplate: collectUploadReferencesFromTemplate,
|
||||
collectUploadReferencesFromPayload: collectUploadReferencesFromPayload,
|
||||
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,
|
||||
normalizePlaylistUploadSyncOperation: normalizePlaylistUploadSyncOperation,
|
||||
queuePlaylistUploadSync: queuePlaylistUploadSync,
|
||||
schedulePendingPlaylistUploadSyncFlush: schedulePendingPlaylistUploadSyncFlush,
|
||||
syncPlaylistUploadsOnChange: syncPlaylistUploadsOnChange,
|
||||
flushPendingPlaylistUploadSyncs: flushPendingPlaylistUploadSyncs,
|
||||
flushPendingPlayerUploadSyncs: flushPendingPlayerUploadSyncs,
|
||||
runMediaSyncTask: runMediaSyncTask,
|
||||
queueMediaSyncTask: queueMediaSyncTask
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createUploadSyncService };
|
||||
@@ -0,0 +1,34 @@
|
||||
async function initializeWebServer(options) {
|
||||
const common = options && options.common;
|
||||
const pool = options && options.pool;
|
||||
const mediaDir = String(options && options.mediaDir || '').trim();
|
||||
const backgroundTaskQueue = options && options.backgroundTaskQueue;
|
||||
const webBootstrap = options && options.webBootstrap;
|
||||
const loadCurrentUser = options && options.loadCurrentUser;
|
||||
const initializeBackgroundTasks = options && options.initializeBackgroundTasks;
|
||||
const captureSlideThumbnail = options && options.captureSlideThumbnail;
|
||||
const dataSourceStartupRefreshStaggerMs = Math.max(100, Number(options && options.dataSourceStartupRefreshStaggerMs || 250));
|
||||
const server = options && options.server;
|
||||
|
||||
if (!common || !pool || !mediaDir || !backgroundTaskQueue || !webBootstrap || typeof loadCurrentUser !== 'function' || typeof initializeBackgroundTasks !== 'function' || typeof captureSlideThumbnail !== 'function' || !server) {
|
||||
throw new Error('initializeWebServer requires the web startup dependencies.');
|
||||
}
|
||||
|
||||
await common.ensureSchema(pool, { mediaDir: mediaDir });
|
||||
await common.bootstrapDatabase(pool);
|
||||
await backgroundTaskQueue.initialize();
|
||||
|
||||
await initializeBackgroundTasks({
|
||||
pool: pool,
|
||||
common: common,
|
||||
backgroundTaskQueue: backgroundTaskQueue,
|
||||
uploadSyncService: webBootstrap.uploadSyncService,
|
||||
captureSlideThumbnail: captureSlideThumbnail,
|
||||
mediaDir: mediaDir,
|
||||
dataSourceStartupRefreshStaggerMs: dataSourceStartupRefreshStaggerMs
|
||||
});
|
||||
|
||||
webBootstrap.installDashboardWebsocket(server, loadCurrentUser);
|
||||
}
|
||||
|
||||
module.exports = { initializeWebServer };
|
||||
@@ -0,0 +1,38 @@
|
||||
// Shared web middleware for auth, media, and upload handling.
|
||||
|
||||
const express = require('express');
|
||||
const fs = require('fs');
|
||||
|
||||
module.exports = function registerMiddleware(app, deps) {
|
||||
const pool = deps.pool;
|
||||
const loadCurrentUser = deps.loadCurrentUser;
|
||||
const requireAuth = deps.requireAuth;
|
||||
const mediaDir = deps.MEDIA_DIR;
|
||||
const uploadsDir = deps.UPLOADS_DIR;
|
||||
const thumbnailsDir = deps.THUMBNAILS_DIR;
|
||||
const assetDir = deps.ASSET_DIR;
|
||||
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
app.use(express.json());
|
||||
app.use('/assets', express.static(assetDir));
|
||||
app.use('/media', express.static(mediaDir));
|
||||
fs.mkdirSync(uploadsDir, { recursive: true });
|
||||
fs.mkdirSync(thumbnailsDir, { 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);
|
||||
});
|
||||
};
|
||||
@@ -1,3 +1,5 @@
|
||||
// Error page renderer for the web UI.
|
||||
|
||||
const { renderView } = require('../view');
|
||||
|
||||
function getErrorCopy(statusCode, message) {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// Page route-to-view mapping for the web UI.
|
||||
|
||||
const path = require('path');
|
||||
|
||||
function routePath(...segments) {
|
||||
@@ -25,6 +27,9 @@ module.exports = {
|
||||
renderScreensPage: require(routePath('signage', 'screens', 'list')),
|
||||
renderScreenFormPage: require(routePath('signage', 'screens', 'add')),
|
||||
renderScreenEditPage: require(routePath('signage', 'screens', 'edit')),
|
||||
renderAnnouncementsPage: require(routePath('signage', 'announcements', 'list')),
|
||||
renderAnnouncementFormPage: require(routePath('signage', 'announcements', 'add')),
|
||||
renderAnnouncementEditPage: require(routePath('signage', 'announcements', 'edit')),
|
||||
renderSlidesPage: require(routePath('signage', 'slides', 'list')),
|
||||
renderSlideFormPage: require(routePath('signage', 'slides', 'form')),
|
||||
renderTemplatesPage: require(routePath('signage', 'templates', 'list')),
|
||||
@@ -33,6 +38,7 @@ module.exports = {
|
||||
renderCanvasSizesPage: require(routePath('signage', 'canvas-sizes', 'list')),
|
||||
renderCanvasSizeFormPage: require(routePath('signage', 'canvas-sizes', 'add')),
|
||||
renderCanvasSizeEditPage: require(routePath('signage', 'canvas-sizes', 'edit')),
|
||||
renderFontsPage: require(routePath('settings', 'fonts', 'list')),
|
||||
renderBackgroundTasksPage: require(routePath('settings', 'background-tasks-page')).renderBackgroundTasksPage,
|
||||
renderBackgroundTasksScheduledPage: require(routePath('settings', 'background-tasks-page')).renderBackgroundTasksScheduledPage,
|
||||
renderErrorPage: require('./error'),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user