This commit is contained in:
+3
-7
@@ -1,7 +1,3 @@
|
||||
NODE_ENV=production
|
||||
WEB_PORT=3000
|
||||
PLAYER_PORT=3001
|
||||
|
||||
DB_HOST=mysql
|
||||
DB_PORT=3306
|
||||
DB_NAME=pulse-signage
|
||||
@@ -9,12 +5,12 @@ DB_USER=pulse-signage
|
||||
DB_PASSWORD=signage_password
|
||||
MYSQL_ROOT_PASSWORD=
|
||||
|
||||
PLAYER_INTERNAL_BASE_URL=http://player:3001
|
||||
PLAYER_PUBLIC_BASE_URL=http://localhost:3001
|
||||
PLAYER_IDENTIFIER=
|
||||
PLAYER_INTERNAL_BASE_URL=http://player:8081
|
||||
PLAYER_PUBLIC_BASE_URL=http://localhost:8081
|
||||
PULSE_SIGNAGE_SHARED_SECRET=
|
||||
|
||||
SESSION_MAX_AGE_DAYS=14
|
||||
DASHBOARD_REFRESH_INTERVAL_MS=2000
|
||||
DEFAULT_ADMIN_USERNAME=admin
|
||||
DEFAULT_ADMIN_NAME=Admin
|
||||
DEFAULT_ADMIN_PASSWORD=admin
|
||||
|
||||
+24
-2
@@ -2,9 +2,31 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## Unreleased
|
||||
## 2.0.0 - 2026-07-28
|
||||
|
||||
- No unreleased changes recorded yet.
|
||||
### Breaking Changes
|
||||
|
||||
- Player and web startup now assume the centralized DB + distributed player model.
|
||||
- Dashboard player-feed status now reflects recent player heartbeats instead of screen presence alone.
|
||||
|
||||
### Added
|
||||
|
||||
- A schema reference document under `docs/schema.md` covering the current tables and relationships.
|
||||
- A clearer player registry flow that keeps player identity, base URLs, and screen assignment data in the database.
|
||||
|
||||
### Changed
|
||||
|
||||
- Major player/web architecture refactor with centralized web management and distributed player services.
|
||||
- Compose and package startup now run the app entrypoints directly with `node`, instead of preloading `dotenv` through npm scripts.
|
||||
- The dashboard refresh interval is fixed at 5 seconds.
|
||||
- Dashboard player-feed status now follows recent player heartbeats, so it stays connected even when no screens are assigned.
|
||||
- Common table-search helpers were moved next to the table/list utilities, and the DB module was split into schema, runtime, and bootstrap concerns.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Player identifier collisions now fail cleanly without a stack trace.
|
||||
- Onboarding now scopes the available screens to the current player and blocks onboarding when no screens are assigned.
|
||||
- The dashboard no longer reports the player feed as disconnected just because there are no configured screens.
|
||||
|
||||
## 1.5.16 - 2026-07-26
|
||||
|
||||
|
||||
+1
-1
@@ -11,6 +11,6 @@ COPY src ./src
|
||||
|
||||
RUN mkdir -p /app/media
|
||||
|
||||
EXPOSE 3000
|
||||
EXPOSE 8080 8081
|
||||
|
||||
CMD ["npm", "run", "start:web"]
|
||||
|
||||
@@ -18,7 +18,7 @@ The easiest way to run the stack is with Docker Compose.
|
||||
|
||||
1. Set any environment variables you want to override.
|
||||
2. Run `docker compose up -d`.
|
||||
3. Open the web admin app on port `3000` and the player on port `3001`.
|
||||
3. Open the web admin app on port `8080` and the player on port `8081`.
|
||||
|
||||
By default, the compose file uses `git.lzstealth.com/lzstealth/pulse-signage:latest` for both app services.
|
||||
|
||||
@@ -28,8 +28,8 @@ Before starting, copy `.env.example` to `.env` and adjust the values for your se
|
||||
|
||||
The included `docker-compose.yml` starts three services:
|
||||
|
||||
- `web` - the admin app on port `3000`
|
||||
- `player` - the signage player on port `3001`
|
||||
- `web` - the admin app on port `8080`
|
||||
- `player` - the signage player on port `8081`
|
||||
- `mysql` - the database on port `3306`
|
||||
|
||||
The compose file also defines:
|
||||
@@ -68,27 +68,29 @@ The default database container uses MySQL 8.4, but the app can also run against
|
||||
|
||||
#### Web Admin App
|
||||
|
||||
- `WEB_PORT` - admin app port, default `3000`
|
||||
- `PLAYER_INTERNAL_BASE_URL` - player address used by the server, default `http://player:3001`
|
||||
- `PLAYER_PUBLIC_BASE_URL` - player address shown in browser links, default `http://localhost:3001`
|
||||
- `PULSE_SIGNAGE_SHARED_SECRET` - shared secret used to sign player page API fetches and server-to-player requests; leave unset to disable the auth checks for compatibility
|
||||
- `PULSE_SIGNAGE_SHARED_SECRET` - shared secret used to sign player page API fetches and server-to-player requests; set this to a long random value and keep it the same across restarts. A good default is the output of `openssl rand -hex 32` or another 64-character hex string.
|
||||
- `SESSION_MAX_AGE_DAYS` - admin session lifetime, default `14`
|
||||
- `DASHBOARD_REFRESH_INTERVAL_MS` - dashboard refresh interval, default `2000`
|
||||
|
||||
#### Player App
|
||||
|
||||
- `PLAYER_PORT` - player port, default `3001`
|
||||
- `PLAYER_IDENTIFIER` - stable player identity used for the registry row and duplicate-player protection. Keep this the same across restarts for the same physical player.
|
||||
- `PLAYER_INTERNAL_BASE_URL` - player address stored in `d_players.internal_base_url` for web-to-player requests, default `http://player:8081` in Docker
|
||||
- `PLAYER_PUBLIC_BASE_URL` - player address shown in browser links and onboarding status responses, default `http://localhost:8081`
|
||||
- `WEB_PORT` - admin app port when you run the service outside Docker, default `8080`
|
||||
- `PLAYER_PORT` - player port when you run the service outside Docker, default `8081`
|
||||
|
||||
### Documentation
|
||||
|
||||
- [Player API](docs/api.md)
|
||||
- [Database schema](docs/schema.md)
|
||||
- [Player websocket behavior](docs/websocket.md)
|
||||
|
||||
### Notes
|
||||
|
||||
- The web app must be able to reach the player through `PLAYER_INTERNAL_BASE_URL`.
|
||||
- When running in Docker, that value should point to the Docker service name, not `localhost`.
|
||||
- The web app reaches players through the internal base URL stored in `d_players.internal_base_url`.
|
||||
- In Docker, the player service should publish its own internal and public base URLs so the registry row stays accurate.
|
||||
- The web and player services can run separately, and they do not need to share the same upload mount as long as each service can access its own configured media path.
|
||||
- If `PULSE_SIGNAGE_SHARED_SECRET` is set, the web and player services must use the same value, and any reverse proxy in front of the player must forward `/api/media/...` without rewriting the path.
|
||||
- Player onboarding only lists screens assigned to that player. If a player has no screens assigned, onboarding shows a message and cannot continue until an admin assigns a screen.
|
||||
- Uploaded media is stored separately from the application source, so back it up if you are not using Docker volumes.
|
||||
- The player page uses the browser Screen Wake Lock API when available, but kiosk mode and OS sleep settings still matter because the browser can deny or release the wake lock.
|
||||
|
||||
+7
-12
@@ -4,27 +4,22 @@ services:
|
||||
container_name: signage-web
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3000:3000"
|
||||
- "8080:8080"
|
||||
environment:
|
||||
NODE_ENV: ${NODE_ENV:-production}
|
||||
WEB_PORT: ${WEB_PORT:-3000}
|
||||
DB_HOST: ${DB_HOST:-mysql}
|
||||
DB_PORT: ${DB_PORT:-3306}
|
||||
DB_NAME: ${DB_NAME:-signage}
|
||||
DB_USER: ${DB_USER:-signage_user}
|
||||
DB_PASSWORD: ${DB_PASSWORD:-signage_password}
|
||||
PLAYER_INTERNAL_BASE_URL: ${PLAYER_INTERNAL_BASE_URL:-http://player:3001}
|
||||
PLAYER_PUBLIC_BASE_URL: ${PLAYER_PUBLIC_BASE_URL:-http://localhost:3001}
|
||||
PULSE_SIGNAGE_SHARED_SECRET: ${PULSE_SIGNAGE_SHARED_SECRET:-}
|
||||
SESSION_MAX_AGE_DAYS: ${SESSION_MAX_AGE_DAYS:-14}
|
||||
DASHBOARD_REFRESH_INTERVAL_MS: ${DASHBOARD_REFRESH_INTERVAL_MS:-2000}
|
||||
DEFAULT_ADMIN_USERNAME: ${DEFAULT_ADMIN_USERNAME:-admin}
|
||||
DEFAULT_ADMIN_NAME: ${DEFAULT_ADMIN_NAME:-Admin}
|
||||
DEFAULT_ADMIN_PASSWORD: ${DEFAULT_ADMIN_PASSWORD:-admin}
|
||||
PASSWORD_HASH_ITERATIONS: ${PASSWORD_HASH_ITERATIONS:-310000}
|
||||
volumes:
|
||||
- media:/app/media
|
||||
command: ["npm", "run", "start:web"]
|
||||
command: ["node", "src/web.js"]
|
||||
depends_on:
|
||||
mysql:
|
||||
condition: service_healthy
|
||||
@@ -36,11 +31,11 @@ services:
|
||||
container_name: signage-player
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3001:3001"
|
||||
- "8081:8081"
|
||||
environment:
|
||||
NODE_ENV: ${NODE_ENV:-production}
|
||||
PLAYER_PORT: ${PLAYER_PORT:-3001}
|
||||
PLAYER_PUBLIC_BASE_URL: ${PLAYER_PUBLIC_BASE_URL:-http://localhost:3001}
|
||||
PLAYER_IDENTIFIER: ${PLAYER_IDENTIFIER:-}
|
||||
PLAYER_INTERNAL_BASE_URL: ${PLAYER_INTERNAL_BASE_URL:-http://player:8081}
|
||||
PLAYER_PUBLIC_BASE_URL: ${PLAYER_PUBLIC_BASE_URL:-http://localhost:8081}
|
||||
PULSE_SIGNAGE_SHARED_SECRET: ${PULSE_SIGNAGE_SHARED_SECRET:-}
|
||||
DB_HOST: ${DB_HOST:-mysql}
|
||||
DB_PORT: ${DB_PORT:-3306}
|
||||
@@ -49,7 +44,7 @@ services:
|
||||
DB_PASSWORD: ${DB_PASSWORD:-signage_password}
|
||||
volumes:
|
||||
- media:/app/media
|
||||
command: ["npm", "run", "start:player"]
|
||||
command: ["node", "src/player.js"]
|
||||
depends_on:
|
||||
mysql:
|
||||
condition: service_healthy
|
||||
|
||||
+186
@@ -0,0 +1,186 @@
|
||||
# Database Schema
|
||||
|
||||
This app creates and maintains its schema at startup through `src/db/index.js`.
|
||||
The sections below summarize the current tables and their purpose.
|
||||
|
||||
Primary keys are `id` unless noted otherwise. Timestamps are stored as `created_at` and `modified_at` when a table supports auditing.
|
||||
|
||||
## Admin
|
||||
|
||||
- `a_users` - admin user accounts and password hashes.
|
||||
- `a_roles` - named role definitions.
|
||||
- `a_permissions` - permission catalog seeded from the application constants.
|
||||
- `a_role_permissions` - many-to-many mapping between roles and permissions.
|
||||
- `a_user_roles` - many-to-many mapping between users and roles.
|
||||
- `a_sessions` - persisted admin session tokens.
|
||||
|
||||
### `a_users`
|
||||
|
||||
- `id`, `name`, `username`, `password_hash`, `password_salt`, `password_iterations`, `created_at`, `created_by`, `modified_at`, `modified_by`
|
||||
- `username` is unique.
|
||||
|
||||
### `a_roles`
|
||||
|
||||
- `id`, `name`, `description`, `created_at`, `created_by`, `modified_at`, `modified_by`
|
||||
- `name` is unique.
|
||||
|
||||
### `a_permissions`
|
||||
|
||||
- `id`, `permission_key`, `name`, `section_name`, `description`, `created_at`, `modified_at`
|
||||
- `permission_key` is unique.
|
||||
|
||||
### `a_role_permissions`
|
||||
|
||||
- `role_id`, `permission_id`, `created_at`, `modified_at`
|
||||
- Foreign keys:
|
||||
- `role_id` -> `a_roles.id`
|
||||
- `permission_id` -> `a_permissions.id`
|
||||
- Composite primary key: `(role_id, permission_id)`
|
||||
|
||||
### `a_user_roles`
|
||||
|
||||
- `user_id`, `role_id`, `created_at`, `modified_at`
|
||||
- Foreign keys:
|
||||
- `user_id` -> `a_users.id`
|
||||
- `role_id` -> `a_roles.id`
|
||||
- Composite primary key: `(user_id, role_id)`
|
||||
|
||||
### `a_sessions`
|
||||
|
||||
- `session_hash`, `user_id`, `expires_at`, `created_at`, `created_by`, `modified_at`, `modified_by`
|
||||
- `session_hash` is the primary key.
|
||||
- Foreign key:
|
||||
- `user_id` -> `a_users.id`
|
||||
|
||||
## Content
|
||||
|
||||
- `c_canvas_sizes` - reusable canvas presets for templates.
|
||||
- `c_playlists` - playlist definitions and playback options.
|
||||
- `c_templates` - slide templates with canvas and background settings.
|
||||
- `c_template_regions` - template region layout and metadata.
|
||||
- `c_slides` - slide records with template binding, JSON content, and thumbnail path.
|
||||
- `c_playlist_slides` - ordered playlist items, timing, and schedule rules.
|
||||
|
||||
### `c_canvas_sizes`
|
||||
|
||||
- `id`, `name`, `width`, `height`, `created_at`, `created_by`, `modified_at`, `modified_by`
|
||||
- `(width, height)` is unique.
|
||||
|
||||
### `c_playlists`
|
||||
|
||||
- `id`, `name`, `fade_between_slides`, `skip_unavailable_rtmp`, `created_at`, `created_by`, `modified_at`, `modified_by`
|
||||
|
||||
### `c_templates`
|
||||
|
||||
- `id`, `name`, `canvas_size_id`, `background_image_path`, `background_color`, `created_at`, `created_by`, `modified_at`, `modified_by`
|
||||
- Foreign key:
|
||||
- `canvas_size_id` -> `c_canvas_sizes.id` with `ON DELETE SET NULL`
|
||||
|
||||
### `c_template_regions`
|
||||
|
||||
- `id`, `template_id`, `region_key`, `region_type`, `label`, `lock_ratio`, `x`, `y`, `width`, `height`, `z_index`, `created_at`, `created_by`, `modified_at`, `modified_by`
|
||||
- Foreign key:
|
||||
- `template_id` -> `c_templates.id` with `ON DELETE CASCADE`
|
||||
|
||||
### `c_slides`
|
||||
|
||||
- `id`, `title`, `template_id`, `content_json`, `thumbnail_path`, `created_at`, `created_by`, `modified_at`, `modified_by`
|
||||
- Foreign key:
|
||||
- `template_id` -> `c_templates.id` with `ON DELETE SET NULL`
|
||||
|
||||
### `c_playlist_slides`
|
||||
|
||||
- `id`, `playlist_id`, `slide_id`, `position`, `duration_seconds`, `use_video_duration`, `schedule_mode`, `schedule_start_datetime`, `schedule_end_datetime`, `schedule_start_time`, `schedule_end_time`, `schedule_days_json`, `created_at`, `created_by`, `modified_at`, `modified_by`
|
||||
- Foreign keys:
|
||||
- `playlist_id` -> `c_playlists.id` with `ON DELETE CASCADE`
|
||||
- `slide_id` -> `c_slides.id` with `ON DELETE CASCADE`
|
||||
|
||||
## Player Registry
|
||||
|
||||
- `d_players` - registered player containers with public/internal base URLs, hostname, metadata, and last seen time.
|
||||
- `d_screens` - screens assigned to playlists and, optionally, to a player registry row.
|
||||
|
||||
### `d_players`
|
||||
|
||||
- `id`, `identifier`, `public_base_url`, `internal_base_url`, `hostname`, `metadata_json`, `last_seen_at`, `created_at`, `modified_at`
|
||||
- `identifier` is unique.
|
||||
|
||||
### `d_screens`
|
||||
|
||||
- `id`, `name`, `slug`, `playlist_id`, `player_id`, `created_at`, `created_by`, `modified_at`, `modified_by`
|
||||
- `slug` is unique.
|
||||
- Foreign keys:
|
||||
- `playlist_id` -> `c_playlists.id` with `ON DELETE SET NULL`
|
||||
- `player_id` -> `d_players.id` with `ON DELETE SET NULL`
|
||||
|
||||
## Onboarding
|
||||
|
||||
- `d_onboarding_devices` - device-to-screen bindings and onboarded client names.
|
||||
|
||||
### `d_onboarding_devices`
|
||||
|
||||
- `device_id`, `client_name`, `screen_id`, `created_at`, `created_by`, `modified_at`, `modified_by`
|
||||
- `device_id` is the primary key.
|
||||
- Foreign key:
|
||||
- `screen_id` -> `d_screens.id` with `ON DELETE SET NULL`
|
||||
|
||||
## Integrations
|
||||
|
||||
- `i_rss_feeds` - RSS feed definitions and refresh cadence.
|
||||
- `i_rss_feed_items` - cached RSS feed items.
|
||||
- `i_api_sources` - API source definitions and last response snapshot.
|
||||
|
||||
### `i_rss_feeds`
|
||||
|
||||
- `id`, `name`, `feed_url`, `update_interval_value`, `update_interval_unit`, `item_limit`, `created_at`, `created_by`, `modified_at`, `modified_by`
|
||||
|
||||
### `i_rss_feed_items`
|
||||
|
||||
- `id`, `rss_feed_id`, `position`, `item_json`, `created_at`, `created_by`, `modified_at`, `modified_by`
|
||||
- Foreign key:
|
||||
- `rss_feed_id` -> `i_rss_feeds.id` with `ON DELETE CASCADE`
|
||||
- Unique key:
|
||||
- `(rss_feed_id, position)`
|
||||
|
||||
### `i_api_sources`
|
||||
|
||||
- `id`, `name`, `api_url`, `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
|
||||
|
||||
- `o_background_tasks` - queue and history for background jobs.
|
||||
|
||||
### `o_background_tasks`
|
||||
|
||||
- `id`, `task_key`, `task_type`, `title`, `category`, `status`, `payload_json`, `metadata_json`, `attempts`, `created_at`, `created_by`, `started_at`, `finished_at`, `error_message`
|
||||
- Indexed by `status`, `task_key`, and `task_type`.
|
||||
|
||||
## Notes
|
||||
|
||||
- The schema is initialized with `CREATE TABLE IF NOT EXISTS`, so new installs can start from an empty database.
|
||||
- `src/db/index.js` also seeds default permissions and the default administrator role.
|
||||
- Some legacy migrations still run after schema creation, so the startup path can backfill older installations.
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
A_USERS ||--o{ A_USER_ROLES : has
|
||||
A_ROLES ||--o{ A_USER_ROLES : assigned_to
|
||||
A_ROLES ||--o{ A_ROLE_PERMISSIONS : has
|
||||
A_PERMISSIONS ||--o{ A_ROLE_PERMISSIONS : granted_to
|
||||
A_USERS ||--o{ A_SESSIONS : owns
|
||||
|
||||
C_CANVAS_SIZES ||--o{ C_TEMPLATES : used_by
|
||||
C_TEMPLATES ||--o{ C_TEMPLATE_REGIONS : contains
|
||||
C_TEMPLATES ||--o{ C_SLIDES : used_by
|
||||
C_PLAYLISTS ||--o{ C_PLAYLIST_SLIDES : contains
|
||||
C_SLIDES ||--o{ C_PLAYLIST_SLIDES : included_in
|
||||
|
||||
D_PLAYERS ||--o{ D_SCREENS : assigned_to
|
||||
C_PLAYLISTS ||--o{ D_SCREENS : uses
|
||||
D_SCREENS ||--o{ D_ONBOARDING_DEVICES : binds
|
||||
|
||||
I_RSS_FEEDS ||--o{ I_RSS_FEED_ITEMS : caches
|
||||
O_BACKGROUND_TASKS {
|
||||
BIGINT id
|
||||
}
|
||||
```
|
||||
+6
-7
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "pulse-signage",
|
||||
"version": "1.5.16",
|
||||
"version": "2.0.0",
|
||||
"private": false,
|
||||
"description": "Pulse Signage application with MySQL and media storage",
|
||||
"repository": {
|
||||
@@ -9,17 +9,16 @@
|
||||
},
|
||||
"main": "src/common.js",
|
||||
"scripts": {
|
||||
"start": "node -r dotenv/config src/web.js",
|
||||
"start:web": "node -r dotenv/config src/web.js",
|
||||
"start:player": "node -r dotenv/config src/player.js",
|
||||
"dev:web": "nodemon -r dotenv/config src/web.js",
|
||||
"dev:player": "nodemon -r dotenv/config src/player.js"
|
||||
"start": "node src/web.js",
|
||||
"start:web": "node src/web.js",
|
||||
"start:player": "node src/player.js",
|
||||
"dev:web": "nodemon src/web.js",
|
||||
"dev:player": "nodemon src/player.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sparticuz/chromium": "^137.0.0",
|
||||
"bootstrap-icons": "1.11.3",
|
||||
"cropperjs": "^1.6.2",
|
||||
"dotenv": "^17.4.2",
|
||||
"express": "^4.21.2",
|
||||
"handlebars": "^4.7.8",
|
||||
"hls.js": "^1.5.15",
|
||||
|
||||
+10
-18
@@ -1,22 +1,15 @@
|
||||
const db = require('./db');
|
||||
const dbRuntime = require('./db/runtime');
|
||||
const dbBootstrap = require('./db/bootstrap');
|
||||
const data = require('./data');
|
||||
const player = require('./player/render');
|
||||
|
||||
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';
|
||||
}
|
||||
const listQuery = require('./web/lib/list-query');
|
||||
|
||||
module.exports = {
|
||||
createPool: db.createPool,
|
||||
ensureSchema: db.ensureSchema,
|
||||
createPool: dbRuntime.createPool,
|
||||
bootstrapDatabase: dbBootstrap.bootstrapDatabase,
|
||||
getSearchQuery: listQuery.getSearchQuery,
|
||||
getSortQuery: listQuery.getSortQuery,
|
||||
getSortDirectionQuery: listQuery.getSortDirectionQuery,
|
||||
slugify: data.slugify,
|
||||
uniqueScreenSlug: data.uniqueScreenSlug,
|
||||
parseJsonSafe: data.parseJsonSafe,
|
||||
@@ -26,9 +19,6 @@ module.exports = {
|
||||
fetchTemplatesPage: data.fetchTemplatesPage,
|
||||
fetchCanvasSizesPage: data.fetchCanvasSizesPage,
|
||||
fetchScreensPage: data.fetchScreensPage,
|
||||
getSearchQuery: getSearchQuery,
|
||||
getSortQuery: getSortQuery,
|
||||
getSortDirectionQuery: getSortDirectionQuery,
|
||||
fetchPlaylistById: data.fetchPlaylistById,
|
||||
fetchApiSourcesData: data.fetchApiSourcesData,
|
||||
fetchApiSourcesPage: data.fetchApiSourcesPage,
|
||||
@@ -45,6 +35,8 @@ module.exports = {
|
||||
replaceRssFeedItems: data.replaceRssFeedItems,
|
||||
fetchScreenById: data.fetchScreenById,
|
||||
fetchScreenEditData: data.fetchScreenEditData,
|
||||
fetchPlayersSelectionData: data.fetchPlayersSelectionData,
|
||||
fetchPlayerById: data.fetchPlayerById,
|
||||
fetchTemplateById: data.fetchTemplateById,
|
||||
fetchSlideById: data.fetchSlideById,
|
||||
fetchTemplatesData: data.fetchTemplatesData,
|
||||
|
||||
+50
-38
@@ -1,35 +1,41 @@
|
||||
const { fetchPagedRows } = require('./utils');
|
||||
|
||||
async function fetchAdminData(pool) {
|
||||
const [playlists] = await pool.query('SELECT id, name, fade_between_slides, skip_unavailable_rtmp, created_at, modified_at, created_by, modified_by FROM playlists ORDER BY id DESC');
|
||||
const [canvasSizes] = await pool.query('SELECT id, name, width, height, created_at, modified_at, created_by, modified_by FROM canvas_sizes ORDER BY width ASC, height ASC, name ASC');
|
||||
const [playlists] = await pool.query('SELECT id, name, fade_between_slides, skip_unavailable_rtmp, created_at, modified_at, created_by, modified_by FROM c_playlists ORDER BY id DESC');
|
||||
const [canvasSizes] = await pool.query('SELECT id, name, width, height, created_at, modified_at, created_by, modified_by FROM c_canvas_sizes ORDER BY width ASC, height ASC, name ASC');
|
||||
const [templates] = await pool.query(`
|
||||
SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, st.background_color, st.created_at, st.modified_at,
|
||||
cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height
|
||||
FROM slide_templates st
|
||||
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id
|
||||
FROM c_templates st
|
||||
LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id
|
||||
ORDER BY st.id DESC
|
||||
`);
|
||||
const [templateRegions] = await pool.query('SELECT id, template_id, region_key, region_type, label, font_family, lock_ratio, x, y, width, height, z_index, created_at, modified_at, created_by, modified_by FROM slide_template_regions ORDER BY template_id ASC, z_index ASC, id ASC');
|
||||
const [templateRegions] = await pool.query('SELECT id, template_id, region_key, region_type, label, lock_ratio, x, y, width, height, z_index, created_at, modified_at, created_by, modified_by FROM c_template_regions ORDER BY template_id ASC, z_index ASC, id ASC');
|
||||
const [slides] = await pool.query(`
|
||||
SELECT s.id, s.title, s.body, s.template_id, s.content_json, s.media_path, s.media_type, s.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
|
||||
FROM slides s
|
||||
LEFT JOIN slide_templates st ON st.id = s.template_id
|
||||
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id
|
||||
SELECT s.id, s.title, s.template_id, s.content_json, s.thumbnail_path, s.created_at, s.modified_at, s.created_by, s.modified_by, st.name AS template_name, cs.width AS canvas_width, cs.height AS canvas_height
|
||||
FROM c_slides s
|
||||
LEFT JOIN c_templates st ON st.id = s.template_id
|
||||
LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id
|
||||
ORDER BY s.id DESC
|
||||
`);
|
||||
const [screens] = await pool.query(`
|
||||
SELECT s.id, s.name, s.slug, s.playlist_id, s.created_at, s.modified_at, s.created_by, s.modified_by, p.name AS playlist_name
|
||||
FROM screens s
|
||||
LEFT JOIN playlists p ON p.id = s.playlist_id
|
||||
SELECT s.id, s.name, s.slug, s.playlist_id, s.player_id, pl.identifier AS player_label, pl.public_base_url AS player_public_base_url, pl.internal_base_url AS player_internal_base_url,
|
||||
CASE
|
||||
WHEN pl.last_seen_at IS NOT NULL AND pl.last_seen_at >= (CURRENT_TIMESTAMP - INTERVAL 1 MINUTE) THEN 1
|
||||
ELSE 0
|
||||
END AS player_online,
|
||||
s.created_at, s.modified_at, s.created_by, s.modified_by, p.name AS playlist_name
|
||||
FROM d_screens s
|
||||
LEFT JOIN c_playlists p ON p.id = s.playlist_id
|
||||
LEFT JOIN d_players pl ON pl.id = s.player_id
|
||||
ORDER BY s.id DESC
|
||||
`);
|
||||
const [playlistSlides] = await pool.query(`
|
||||
SELECT ps.id, ps.playlist_id, ps.position, ps.duration_seconds, ps.use_video_duration, ps.schedule_mode, ps.schedule_start_datetime, ps.schedule_end_datetime, ps.schedule_start_time, ps.schedule_end_time, ps.schedule_days_json, sl.id AS slide_id, sl.title, sl.media_path, sl.media_type, sl.content_json, sl.thumbnail_path, cs.width AS canvas_width, cs.height AS canvas_height
|
||||
FROM playlist_slides ps
|
||||
JOIN slides sl ON sl.id = ps.slide_id
|
||||
LEFT JOIN slide_templates st ON st.id = sl.template_id
|
||||
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id
|
||||
SELECT ps.id, ps.playlist_id, ps.position, ps.duration_seconds, ps.use_video_duration, ps.schedule_mode, ps.schedule_start_datetime, ps.schedule_end_datetime, ps.schedule_start_time, ps.schedule_end_time, ps.schedule_days_json, sl.id AS slide_id, sl.title, sl.content_json, sl.thumbnail_path, cs.width AS canvas_width, cs.height AS canvas_height
|
||||
FROM c_playlist_slides ps
|
||||
JOIN c_slides sl ON sl.id = ps.slide_id
|
||||
LEFT JOIN c_templates st ON st.id = sl.template_id
|
||||
LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id
|
||||
ORDER BY ps.playlist_id ASC, ps.position ASC, ps.id ASC
|
||||
`);
|
||||
return { playlists, canvasSizes, templates, templateRegions, slides, screens, playlistSlides };
|
||||
@@ -38,10 +44,10 @@ 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 playlist_slides ps WHERE ps.playlist_id = p.id) AS slide_count
|
||||
FROM playlists p
|
||||
(SELECT COUNT(*) FROM c_playlist_slides ps WHERE ps.playlist_id = p.id) AS slide_count
|
||||
FROM c_playlists p
|
||||
ORDER BY p.id DESC`,
|
||||
countSql: 'SELECT COUNT(*) AS count FROM playlists',
|
||||
countSql: 'SELECT COUNT(*) AS count FROM c_playlists',
|
||||
searchColumns: ['p.name'],
|
||||
searchTerm: searchTerm,
|
||||
sortColumns: {
|
||||
@@ -61,13 +67,13 @@ async function fetchPlaylistsPage(pool, page, pageSize, searchTerm, sortKey, sor
|
||||
|
||||
async function fetchSlidesPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
|
||||
const paged = await fetchPagedRows(pool, {
|
||||
selectSql: `SELECT s.id, s.title, s.body, s.template_id, s.content_json, s.media_path, s.media_type, 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
|
||||
FROM slides s
|
||||
LEFT JOIN slide_templates st ON st.id = s.template_id
|
||||
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id
|
||||
selectSql: `SELECT s.id, s.title, s.template_id, s.content_json, s.thumbnail_path, s.created_at, s.modified_at, s.created_by, s.modified_by, st.name AS template_name, cs.width AS canvas_width, cs.height AS canvas_height
|
||||
FROM c_slides s
|
||||
LEFT JOIN c_templates st ON st.id = s.template_id
|
||||
LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id
|
||||
ORDER BY s.id DESC`,
|
||||
countSql: 'SELECT COUNT(*) AS count FROM slides',
|
||||
searchColumns: ['s.title', 's.body', 's.media_path', 'st.name', 's.content_json'],
|
||||
countSql: 'SELECT COUNT(*) AS count FROM c_slides',
|
||||
searchColumns: ['s.title', 'st.name', 's.content_json'],
|
||||
searchTerm: searchTerm,
|
||||
sortColumns: {
|
||||
title: 's.title',
|
||||
@@ -88,12 +94,12 @@ async function fetchTemplatesPage(pool, page, pageSize, searchTerm, sortKey, sor
|
||||
const paged = await fetchPagedRows(pool, {
|
||||
selectSql: `SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, st.background_color, st.created_at, st.modified_at,
|
||||
cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height,
|
||||
(SELECT COUNT(*) FROM slide_template_regions str WHERE str.template_id = st.id) AS region_count,
|
||||
(SELECT COUNT(*) FROM slides s WHERE s.template_id = st.id) AS slide_count
|
||||
FROM slide_templates st
|
||||
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id
|
||||
(SELECT COUNT(*) FROM c_template_regions str WHERE str.template_id = st.id) AS region_count,
|
||||
(SELECT COUNT(*) FROM c_slides s WHERE s.template_id = st.id) AS slide_count
|
||||
FROM c_templates st
|
||||
LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id
|
||||
ORDER BY st.id DESC`,
|
||||
countSql: 'SELECT COUNT(*) AS count FROM slide_templates',
|
||||
countSql: 'SELECT COUNT(*) AS count FROM c_templates',
|
||||
searchColumns: ['st.name', 'st.background_image_path', 'st.background_color', 'cs.name'],
|
||||
searchTerm: searchTerm,
|
||||
sortColumns: {
|
||||
@@ -116,10 +122,10 @@ async function fetchTemplatesPage(pool, page, pageSize, searchTerm, sortKey, sor
|
||||
async function fetchCanvasSizesPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
|
||||
const paged = await fetchPagedRows(pool, {
|
||||
selectSql: `SELECT id, name, width, height, created_at, modified_at, created_by, modified_by,
|
||||
(SELECT COUNT(*) FROM slide_templates st WHERE st.canvas_size_id = canvas_sizes.id) AS template_count
|
||||
FROM canvas_sizes
|
||||
(SELECT COUNT(*) FROM c_templates st WHERE st.canvas_size_id = c_canvas_sizes.id) AS template_count
|
||||
FROM c_canvas_sizes
|
||||
ORDER BY width ASC, height ASC, name ASC`,
|
||||
countSql: 'SELECT COUNT(*) AS count FROM canvas_sizes',
|
||||
countSql: 'SELECT COUNT(*) AS count FROM c_canvas_sizes',
|
||||
searchColumns: ['name', 'width', 'height'],
|
||||
searchTerm: searchTerm,
|
||||
sortColumns: {
|
||||
@@ -140,11 +146,17 @@ async function fetchCanvasSizesPage(pool, page, pageSize, searchTerm, sortKey, s
|
||||
|
||||
async function fetchScreensPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
|
||||
const paged = await fetchPagedRows(pool, {
|
||||
selectSql: `SELECT s.id, s.name, s.slug, s.playlist_id, s.created_at, s.modified_at, s.created_by, s.modified_by, p.name AS playlist_name
|
||||
FROM screens s
|
||||
LEFT JOIN playlists p ON p.id = s.playlist_id
|
||||
selectSql: `SELECT s.id, s.name, s.slug, s.playlist_id, s.player_id, pl.identifier AS player_label, pl.public_base_url AS player_public_base_url, pl.internal_base_url AS player_internal_base_url,
|
||||
CASE
|
||||
WHEN pl.last_seen_at IS NOT NULL AND pl.last_seen_at >= (CURRENT_TIMESTAMP - INTERVAL 1 MINUTE) THEN 1
|
||||
ELSE 0
|
||||
END AS player_online,
|
||||
s.created_at, s.modified_at, s.created_by, s.modified_by, p.name AS playlist_name
|
||||
FROM d_screens s
|
||||
LEFT JOIN c_playlists p ON p.id = s.playlist_id
|
||||
LEFT JOIN d_players pl ON pl.id = s.player_id
|
||||
ORDER BY s.id DESC`,
|
||||
countSql: 'SELECT COUNT(*) AS count FROM screens',
|
||||
countSql: 'SELECT COUNT(*) AS count FROM d_screens',
|
||||
searchColumns: ['s.name', 's.slug', 'p.name'],
|
||||
searchTerm: searchTerm,
|
||||
sortColumns: {
|
||||
|
||||
@@ -9,7 +9,7 @@ function normalizeUpdateIntervalUnit(value) {
|
||||
|
||||
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 api_sources ORDER BY modified_at DESC, id DESC'
|
||||
'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'
|
||||
);
|
||||
|
||||
return { apiSources: apiSources };
|
||||
@@ -17,8 +17,8 @@ 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 api_sources ORDER BY modified_at DESC, id DESC',
|
||||
countSql: 'SELECT COUNT(*) AS count FROM api_sources',
|
||||
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',
|
||||
countSql: 'SELECT COUNT(*) AS count FROM i_api_sources',
|
||||
searchColumns: ['name', 'api_url', 'last_pull_error'],
|
||||
searchTerm: searchTerm,
|
||||
sortColumns: {
|
||||
@@ -41,7 +41,7 @@ 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 api_sources WHERE id = ?',
|
||||
'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 = ?',
|
||||
[id]
|
||||
);
|
||||
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
const { fetchPagedRows } = require('./utils');
|
||||
|
||||
async function fetchCanvasSizesData(pool) {
|
||||
const [canvasSizes] = await pool.query('SELECT id, name, width, height, created_at, modified_at, created_by, modified_by FROM canvas_sizes ORDER BY width ASC, height ASC, name ASC');
|
||||
const [canvasSizes] = await pool.query('SELECT id, name, width, height, created_at, modified_at, created_by, modified_by FROM c_canvas_sizes ORDER BY width ASC, height ASC, name ASC');
|
||||
return { canvasSizes };
|
||||
}
|
||||
|
||||
async function fetchCanvasSizesPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
|
||||
const paged = await fetchPagedRows(pool, {
|
||||
selectSql: 'SELECT id, name, width, height, created_at, modified_at, created_by, modified_by FROM canvas_sizes ORDER BY width ASC, height ASC, name ASC',
|
||||
countSql: 'SELECT COUNT(*) AS count FROM canvas_sizes',
|
||||
selectSql: 'SELECT id, name, width, height, created_at, modified_at, created_by, modified_by FROM c_canvas_sizes ORDER BY width ASC, height ASC, name ASC',
|
||||
countSql: 'SELECT COUNT(*) AS count FROM c_canvas_sizes',
|
||||
searchColumns: ['name', 'width', 'height'],
|
||||
searchTerm: searchTerm,
|
||||
sortColumns: {
|
||||
@@ -27,7 +27,7 @@ async function fetchCanvasSizesPage(pool, page, pageSize, searchTerm, sortKey, s
|
||||
}
|
||||
|
||||
async function fetchCanvasSizeById(pool, id) {
|
||||
const [rows] = await pool.query('SELECT id, name, width, height, created_at, modified_at, created_by, modified_by FROM canvas_sizes WHERE id = ?', [id]);
|
||||
const [rows] = await pool.query('SELECT id, name, width, height, created_at, modified_at, created_by, modified_by FROM c_canvas_sizes WHERE id = ?', [id]);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ async function isClientNameAvailable(pool, clientName, excludeDeviceId, liveConn
|
||||
if (pool) {
|
||||
const [deviceRows] = await pool.query(
|
||||
`SELECT device_id
|
||||
FROM player_onboarding_devices
|
||||
FROM d_onboarding_devices
|
||||
WHERE client_name IS NOT NULL
|
||||
AND TRIM(client_name) <> ''
|
||||
AND LOWER(TRIM(client_name)) = LOWER(TRIM(?))
|
||||
|
||||
@@ -3,6 +3,7 @@ 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 { fetchPlayersSelectionData, fetchPlayerById } = require('./players');
|
||||
const { fetchTemplateById, fetchTemplatesData, extractTemplateRegions, buildTemplatePayload } = require('./templates');
|
||||
const { fetchCanvasSizesData, fetchCanvasSizeById, buildCanvasSizePayload } = require('./canvas-sizes');
|
||||
const { fetchSlideById, buildSlidePayload } = require('./slides');
|
||||
@@ -34,6 +35,8 @@ module.exports = {
|
||||
replaceRssFeedItems,
|
||||
fetchScreenById,
|
||||
fetchScreenEditData,
|
||||
fetchPlayersSelectionData,
|
||||
fetchPlayerById,
|
||||
fetchTemplateById,
|
||||
fetchSlideById,
|
||||
fetchTemplatesData,
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
async function fetchPlayersSelectionData(pool) {
|
||||
const [rows] = await pool.query(`
|
||||
SELECT p.id, p.identifier, p.identifier AS label, p.public_base_url, p.internal_base_url, p.hostname, p.metadata_json, p.last_seen_at,
|
||||
p.created_at, p.modified_at,
|
||||
CASE
|
||||
WHEN p.last_seen_at IS NOT NULL AND p.last_seen_at >= (CURRENT_TIMESTAMP - INTERVAL 1 MINUTE) THEN 1
|
||||
ELSE 0
|
||||
END AS is_online
|
||||
FROM d_players p
|
||||
ORDER BY is_online DESC, p.identifier ASC, p.id ASC
|
||||
`);
|
||||
return { players: rows };
|
||||
}
|
||||
|
||||
async function fetchPlayerById(pool, id) {
|
||||
const [rows] = await pool.query(`
|
||||
SELECT p.id, p.identifier, p.identifier AS label, p.public_base_url, p.internal_base_url, p.hostname, p.metadata_json, p.last_seen_at,
|
||||
p.created_at, p.modified_at,
|
||||
CASE
|
||||
WHEN p.last_seen_at IS NOT NULL AND p.last_seen_at >= (CURRENT_TIMESTAMP - INTERVAL 1 MINUTE) THEN 1
|
||||
ELSE 0
|
||||
END AS is_online
|
||||
FROM d_players p
|
||||
WHERE p.id = ?
|
||||
`, [id]);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
fetchPlayersSelectionData,
|
||||
fetchPlayerById
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
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 playlists WHERE id = ?', [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;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ function normalizeUpdateIntervalUnit(value) {
|
||||
|
||||
async function fetchRssFeedsData(pool) {
|
||||
const [rssFeeds] = await pool.query(
|
||||
'SELECT id, name, feed_url, update_interval_value, update_interval_unit, item_limit, created_at, modified_at, created_by, modified_by FROM rss_feeds ORDER BY modified_at DESC, id DESC'
|
||||
'SELECT id, name, feed_url, update_interval_value, update_interval_unit, item_limit, created_at, modified_at, created_by, modified_by FROM i_rss_feeds ORDER BY modified_at DESC, id DESC'
|
||||
);
|
||||
|
||||
return { rssFeeds: rssFeeds };
|
||||
@@ -17,8 +17,8 @@ async function fetchRssFeedsData(pool) {
|
||||
|
||||
async function fetchRssFeedsPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
|
||||
const paged = await fetchPagedRows(pool, {
|
||||
selectSql: 'SELECT id, name, feed_url, update_interval_value, update_interval_unit, item_limit, created_at, modified_at, created_by, modified_by FROM rss_feeds ORDER BY modified_at DESC, id DESC',
|
||||
countSql: 'SELECT COUNT(*) AS count FROM rss_feeds',
|
||||
selectSql: 'SELECT id, name, feed_url, update_interval_value, update_interval_unit, item_limit, created_at, modified_at, created_by, modified_by FROM i_rss_feeds ORDER BY modified_at DESC, id DESC',
|
||||
countSql: 'SELECT COUNT(*) AS count FROM i_rss_feeds',
|
||||
searchColumns: ['name', 'feed_url'],
|
||||
searchTerm: searchTerm,
|
||||
sortColumns: {
|
||||
@@ -40,7 +40,7 @@ async function fetchRssFeedsPage(pool, page, pageSize, searchTerm, sortKey, sort
|
||||
|
||||
async function fetchRssFeedById(pool, id) {
|
||||
const [rows] = await pool.query(
|
||||
'SELECT id, name, feed_url, update_interval_value, update_interval_unit, item_limit, created_at, modified_at, created_by, modified_by FROM rss_feeds WHERE id = ?',
|
||||
'SELECT id, name, feed_url, update_interval_value, update_interval_unit, item_limit, created_at, modified_at, created_by, modified_by FROM i_rss_feeds WHERE id = ?',
|
||||
[id]
|
||||
);
|
||||
|
||||
@@ -50,7 +50,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 +229,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 +244,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]
|
||||
);
|
||||
|
||||
|
||||
+23
-6
@@ -13,7 +13,7 @@ async function uniqueScreenSlug(pool, baseSlug, excludeId) {
|
||||
let counter = 2;
|
||||
while (true) {
|
||||
const params = [candidate];
|
||||
let sql = 'SELECT id FROM screens WHERE slug = ?';
|
||||
let sql = 'SELECT id FROM d_screens WHERE slug = ?';
|
||||
if (excludeId !== undefined && excludeId !== null) {
|
||||
sql += ' AND id <> ?';
|
||||
params.push(excludeId);
|
||||
@@ -29,17 +29,34 @@ async function uniqueScreenSlug(pool, baseSlug, excludeId) {
|
||||
|
||||
async function fetchScreenById(pool, id) {
|
||||
const [rows] = await pool.query(`
|
||||
SELECT s.id, s.name, s.slug, s.playlist_id, s.created_at, s.modified_at, s.created_by, s.modified_by, p.name AS playlist_name
|
||||
FROM screens s
|
||||
LEFT JOIN playlists p ON p.id = s.playlist_id
|
||||
SELECT s.id, s.name, s.slug, s.playlist_id, s.player_id, pl.identifier AS player_label, pl.public_base_url AS player_public_base_url, pl.internal_base_url AS player_internal_base_url,
|
||||
CASE
|
||||
WHEN pl.last_seen_at IS NOT NULL AND pl.last_seen_at >= (CURRENT_TIMESTAMP - INTERVAL 1 MINUTE) THEN 1
|
||||
ELSE 0
|
||||
END AS player_online,
|
||||
s.created_at, s.modified_at, s.created_by, s.modified_by, p.name AS playlist_name
|
||||
FROM d_screens s
|
||||
LEFT JOIN c_playlists p ON p.id = s.playlist_id
|
||||
LEFT JOIN d_players pl ON pl.id = s.player_id
|
||||
WHERE s.id = ?
|
||||
`, [id]);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
async function fetchScreenEditData(pool) {
|
||||
const [playlists] = await pool.query('SELECT id, name, fade_between_slides, skip_unavailable_rtmp, created_at, modified_at, created_by, modified_by FROM playlists ORDER BY id DESC');
|
||||
return { playlists };
|
||||
const [playlists] = await pool.query('SELECT id, name, fade_between_slides, skip_unavailable_rtmp, created_at, modified_at, created_by, modified_by FROM c_playlists ORDER BY id DESC');
|
||||
const [players] = await pool.query(`
|
||||
SELECT p.id, p.identifier, p.identifier AS label, p.public_base_url, p.internal_base_url, p.hostname, p.metadata_json, p.last_seen_at,
|
||||
CASE
|
||||
WHEN p.last_seen_at IS NOT NULL AND p.last_seen_at >= (CURRENT_TIMESTAMP - INTERVAL 1 MINUTE) THEN 1
|
||||
ELSE 0
|
||||
END AS is_online
|
||||
FROM d_players p
|
||||
WHERE p.last_seen_at IS NOT NULL
|
||||
AND p.last_seen_at >= (CURRENT_TIMESTAMP - INTERVAL 1 MINUTE)
|
||||
ORDER BY is_online DESC, p.identifier ASC, p.id ASC
|
||||
`);
|
||||
return { playlists, players };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
|
||||
+4
-15
@@ -27,11 +27,11 @@ function sanitizeRichText(html) {
|
||||
|
||||
async function fetchSlideById(pool, id) {
|
||||
const [slides] = await pool.query(`
|
||||
SELECT s.id, s.title, s.body, s.template_id, s.content_json, s.media_path, s.media_type, s.thumbnail_path, s.created_at, s.modified_at,
|
||||
SELECT s.id, s.title, s.template_id, s.content_json, s.thumbnail_path, s.created_at, s.modified_at,
|
||||
st.name AS template_name, cs.name AS canvas_size_name, cs.width AS canvas_width, cs.height AS canvas_height
|
||||
FROM slides s
|
||||
LEFT JOIN slide_templates st ON st.id = s.template_id
|
||||
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id
|
||||
FROM c_slides s
|
||||
LEFT JOIN c_templates st ON st.id = s.template_id
|
||||
LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id
|
||||
WHERE s.id = ?
|
||||
`, [id]);
|
||||
if (!slides.length) {
|
||||
@@ -72,11 +72,9 @@ function sanitizeFontSize(value, fallback) {
|
||||
|
||||
function getTextRegionStyle(body, region, existingContent) {
|
||||
const existing = existingContent && existingContent[region.region_key] ? existingContent[region.region_key] : {};
|
||||
const fontFamily = String(body[`region_font_family_${region.id}`] || existing.font_family || region.font_family || 'Arial').trim() || 'Arial';
|
||||
const fontSize = sanitizeFontSize(body[`region_font_size_${region.id}`], existing.font_size || DEFAULT_FONT_SIZE);
|
||||
const fontColor = sanitizeTextColor(body[`region_font_color_${region.id}`] || existing.font_color || region.font_color || '#000000');
|
||||
return {
|
||||
font_family: fontFamily,
|
||||
font_size: fontSize,
|
||||
font_color: fontColor
|
||||
};
|
||||
@@ -139,7 +137,6 @@ function buildTemplateContent(template, body, filesByField, existingContent) {
|
||||
feed_id: feedId === undefined || feedId === null || feedId === '' ? (current.feed_id || null) : Number(feedId),
|
||||
item_number: Number.isFinite(parsedItemNumber) && parsedItemNumber > 0 ? parsedItemNumber : 1,
|
||||
variable_name: 'item',
|
||||
font_family: style.font_family,
|
||||
font_size: style.font_size,
|
||||
font_color: style.font_color
|
||||
};
|
||||
@@ -156,7 +153,6 @@ function buildTemplateContent(template, body, filesByField, existingContent) {
|
||||
source_id: sourceId === undefined || sourceId === null || sourceId === '' ? (current.source_id || null) : Number(sourceId),
|
||||
item_number: Number.isFinite(parsedItemNumber) && parsedItemNumber > 0 ? parsedItemNumber : 1,
|
||||
variable_name: 'item',
|
||||
font_family: style.font_family,
|
||||
font_size: style.font_size,
|
||||
font_color: style.font_color
|
||||
};
|
||||
@@ -167,7 +163,6 @@ function buildTemplateContent(template, body, filesByField, existingContent) {
|
||||
content[region.region_key] = {
|
||||
type: 'text',
|
||||
value: submitted === undefined ? current : String(submitted),
|
||||
font_family: style.font_family,
|
||||
font_size: style.font_size,
|
||||
font_color: style.font_color
|
||||
};
|
||||
@@ -198,21 +193,15 @@ async function buildSlidePayload(pool, req, existingSlide) {
|
||||
if (template) {
|
||||
return {
|
||||
title,
|
||||
body: existingSlide ? existingSlide.body : null,
|
||||
templateId: template.id,
|
||||
contentJson: JSON.stringify(buildTemplateContent(template, req.body, filesByField, existingContent)),
|
||||
mediaPath: null,
|
||||
mediaType: null
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title,
|
||||
body: existingSlide ? existingSlide.body : null,
|
||||
templateId: null,
|
||||
contentJson: existingSlide ? existingSlide.content_json : null,
|
||||
mediaPath: existingSlide ? existingSlide.media_path : null,
|
||||
mediaType: existingSlide ? existingSlide.media_type : null
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+7
-12
@@ -1,8 +1,6 @@
|
||||
const { parseJsonSafe, readFormArray } = require('./utils');
|
||||
|
||||
const ALLOWED_TEMPLATE_REGION_TYPES = ['text', 'image', 'video', 'webpage', 'html', 'rtmp', 'rss', 'api'];
|
||||
const FONT_FAMILY_REGION_TYPES = ['text', 'html', 'rss', 'api'];
|
||||
|
||||
function sanitizeBackgroundColor(value) {
|
||||
const raw = String(value || '').trim();
|
||||
if (/^#[0-9a-fA-F]{6}$/.test(raw) || /^#[0-9a-fA-F]{3}$/.test(raw)) {
|
||||
@@ -50,15 +48,15 @@ async function fetchTemplateById(pool, id) {
|
||||
const [templates] = await pool.query(`
|
||||
SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, st.background_color, st.created_at, st.modified_at,
|
||||
cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height
|
||||
FROM slide_templates st
|
||||
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id
|
||||
FROM c_templates st
|
||||
LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id
|
||||
WHERE st.id = ?
|
||||
`, [id]);
|
||||
if (!templates.length) {
|
||||
return null;
|
||||
}
|
||||
const template = templates[0];
|
||||
const [regions] = await pool.query('SELECT id, template_id, region_key, region_type, label, font_family, lock_ratio, x, y, width, height, z_index, created_at, modified_at, created_by, modified_by FROM slide_template_regions WHERE template_id = ? ORDER BY z_index ASC, id ASC', [id]);
|
||||
const [regions] = await pool.query('SELECT id, template_id, region_key, region_type, label, lock_ratio, 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;
|
||||
}
|
||||
@@ -67,11 +65,11 @@ async function fetchTemplatesData(pool) {
|
||||
const [templates] = await pool.query(`
|
||||
SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, st.background_color, st.created_at, st.modified_at,
|
||||
cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height
|
||||
FROM slide_templates st
|
||||
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id
|
||||
FROM c_templates st
|
||||
LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id
|
||||
ORDER BY st.id DESC
|
||||
`);
|
||||
const [templateRegions] = await pool.query('SELECT id, template_id, region_key, region_type, label, font_family, lock_ratio, x, y, width, height, z_index, created_at, modified_at, created_by, modified_by FROM slide_template_regions ORDER BY template_id ASC, z_index ASC, id ASC');
|
||||
const [templateRegions] = await pool.query('SELECT id, template_id, region_key, region_type, label, lock_ratio, x, y, width, height, z_index, created_at, modified_at, created_by, modified_by FROM c_template_regions ORDER BY template_id ASC, z_index ASC, id ASC');
|
||||
return { templates, templateRegions };
|
||||
}
|
||||
|
||||
@@ -86,7 +84,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 +105,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 +118,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),
|
||||
@@ -167,7 +162,7 @@ async function buildTemplatePayload(pool, req, existingTemplate) {
|
||||
|
||||
let resolvedCanvasSizeId = canvasSizeId;
|
||||
if (resolvedCanvasSizeId) {
|
||||
const [canvasSizes] = await pool.query('SELECT id, name, width, height, created_at, modified_at, created_by, modified_by FROM canvas_sizes WHERE id = ?', [resolvedCanvasSizeId]);
|
||||
const [canvasSizes] = await pool.query('SELECT id, name, width, height, created_at, modified_at, created_by, modified_by FROM c_canvas_sizes WHERE id = ?', [resolvedCanvasSizeId]);
|
||||
const canvasSize = canvasSizes[0];
|
||||
if (!canvasSize) {
|
||||
const error = new Error('Canvas size not found.');
|
||||
|
||||
Vendored
+71
@@ -0,0 +1,71 @@
|
||||
const { hashPassword } = require('../auth');
|
||||
const { DEFAULT_ROLE } = require('../rbac');
|
||||
const migrations = require('./migrations');
|
||||
const { ensureSchema } = require('./index');
|
||||
|
||||
async function seedDatabase(pool) {
|
||||
const [userCountRows] = await pool.query('SELECT COUNT(*) AS user_count FROM a_users');
|
||||
const username = String(process.env.DEFAULT_ADMIN_USERNAME || 'admin').trim() || 'admin';
|
||||
if (!userCountRows.length || Number(userCountRows[0].user_count) === 0) {
|
||||
const name = String(process.env.DEFAULT_ADMIN_NAME || 'Admin').trim() || 'Admin';
|
||||
const password = String(process.env.DEFAULT_ADMIN_PASSWORD || 'admin').trim() || 'admin';
|
||||
const passwordRecord = hashPassword(password);
|
||||
await pool.query(
|
||||
'INSERT IGNORE INTO a_users (name, username, password_hash, password_salt, password_iterations, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
[name, username, passwordRecord.hash, passwordRecord.salt, passwordRecord.iterations, null, null]
|
||||
);
|
||||
}
|
||||
|
||||
await pool.query('UPDATE a_users SET name = username WHERE name IS NULL OR name = ""');
|
||||
|
||||
await pool.query(
|
||||
`INSERT INTO a_roles (name, description, created_by, modified_by)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE description = VALUES(description), modified_by = VALUES(modified_by)`,
|
||||
[DEFAULT_ROLE.name, DEFAULT_ROLE.description || null, null, null]
|
||||
);
|
||||
|
||||
const [defaultRoleRows] = await pool.query('SELECT id FROM a_roles WHERE name = ? LIMIT 1', [DEFAULT_ROLE.name]);
|
||||
const defaultRoleId = defaultRoleRows.length ? Number(defaultRoleRows[0].id) : null;
|
||||
if (defaultRoleId) {
|
||||
await pool.query(
|
||||
`INSERT IGNORE INTO a_role_permissions (role_id, permission_id)
|
||||
SELECT ?, id FROM a_permissions`,
|
||||
[defaultRoleId]
|
||||
);
|
||||
}
|
||||
|
||||
if (!userCountRows.length || Number(userCountRows[0].user_count) === 0) {
|
||||
if (defaultRoleId) {
|
||||
await pool.query(
|
||||
`INSERT IGNORE INTO a_user_roles (user_id, role_id)
|
||||
SELECT id, ? FROM a_users`,
|
||||
[defaultRoleId]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const [defaultAdminRows] = await pool.query('SELECT id FROM a_users WHERE username = ? LIMIT 1', [username]);
|
||||
if (defaultAdminRows.length) {
|
||||
const defaultAdminId = Number(defaultAdminRows[0].id);
|
||||
const [defaultAdminRoleRows] = await pool.query('SELECT COUNT(*) AS role_count FROM a_user_roles WHERE user_id = ?', [defaultAdminId]);
|
||||
if (!defaultAdminRoleRows.length || Number(defaultAdminRoleRows[0].role_count) === 0) {
|
||||
if (defaultRoleId) {
|
||||
await pool.query(
|
||||
'INSERT IGNORE INTO a_user_roles (user_id, role_id) VALUES (?, ?)',
|
||||
[defaultAdminId, defaultRoleId]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function bootstrapDatabase(pool, options) {
|
||||
await ensureSchema(pool, options || {});
|
||||
await migrations.runMigrations(pool, options || {});
|
||||
await seedDatabase(pool);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
bootstrapDatabase
|
||||
};
|
||||
+67
-130
@@ -1,31 +1,8 @@
|
||||
const mysql = require('mysql2/promise');
|
||||
const { hashPassword } = require('../auth');
|
||||
const { PERMISSIONS, DEFAULT_ROLE } = require('../rbac');
|
||||
const migrations = require('./migrations');
|
||||
|
||||
function createPool() {
|
||||
return mysql.createPool({
|
||||
host: process.env.DB_HOST || '127.0.0.1',
|
||||
port: Number(process.env.DB_PORT || 3306),
|
||||
user: process.env.DB_USER || 'signage_user',
|
||||
password: process.env.DB_PASSWORD || 'signage_password',
|
||||
database: process.env.DB_NAME || 'signage',
|
||||
waitForConnections: true,
|
||||
connectionLimit: 10,
|
||||
namedPlaceholders: true
|
||||
});
|
||||
}
|
||||
|
||||
async function pruneStaleOnboardingDevices(pool) {
|
||||
await pool.query(
|
||||
`DELETE FROM player_onboarding_devices
|
||||
WHERE modified_at < (CURRENT_TIMESTAMP - INTERVAL 1 MINUTE)`
|
||||
);
|
||||
}
|
||||
const { PERMISSIONS } = require('../rbac');
|
||||
|
||||
async function ensureSchema(pool, options) {
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS canvas_sizes (
|
||||
CREATE TABLE IF NOT EXISTS c_canvas_sizes (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
width INT NOT NULL,
|
||||
@@ -39,7 +16,7 @@ async function ensureSchema(pool, options) {
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS playlists (
|
||||
CREATE TABLE IF NOT EXISTS c_playlists (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
fade_between_slides TINYINT(1) NOT NULL DEFAULT 0,
|
||||
@@ -52,7 +29,7 @@ async function ensureSchema(pool, options) {
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS slide_templates (
|
||||
CREATE TABLE IF NOT EXISTS c_templates (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
canvas_size_id INT NULL,
|
||||
@@ -61,12 +38,13 @@ async function ensureSchema(pool, options) {
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by INT NULL,
|
||||
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
modified_by INT NULL
|
||||
modified_by INT NULL,
|
||||
CONSTRAINT fk_c_templates_canvas_size FOREIGN KEY (canvas_size_id) REFERENCES c_canvas_sizes(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
INSERT IGNORE INTO canvas_sizes (name, width, height) VALUES
|
||||
INSERT IGNORE INTO c_canvas_sizes (name, width, height) VALUES
|
||||
('Full HD', 1920, 1080),
|
||||
('HD', 1280, 720),
|
||||
('4K UHD', 3840, 2160),
|
||||
@@ -75,7 +53,7 @@ async function ensureSchema(pool, options) {
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS slide_template_regions (
|
||||
CREATE TABLE IF NOT EXISTS c_template_regions (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
template_id INT NOT NULL,
|
||||
region_key VARCHAR(100) NOT NULL,
|
||||
@@ -90,29 +68,28 @@ async function ensureSchema(pool, options) {
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by INT NULL,
|
||||
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
modified_by INT NULL
|
||||
modified_by INT NULL,
|
||||
CONSTRAINT fk_c_template_regions_template FOREIGN KEY (template_id) REFERENCES c_templates(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS slides (
|
||||
CREATE TABLE IF NOT EXISTS c_slides (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
body TEXT NULL,
|
||||
template_id INT NULL,
|
||||
content_json JSON NULL,
|
||||
media_path VARCHAR(512) NULL,
|
||||
media_type VARCHAR(100) NULL,
|
||||
thumbnail_path VARCHAR(512) NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by INT NULL,
|
||||
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
modified_by INT NULL
|
||||
modified_by INT NULL,
|
||||
CONSTRAINT fk_c_slides_template FOREIGN KEY (template_id) REFERENCES c_templates(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS playlist_slides (
|
||||
CREATE TABLE IF NOT EXISTS c_playlist_slides (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
playlist_id INT NOT NULL,
|
||||
slide_id INT NOT NULL,
|
||||
@@ -129,27 +106,44 @@ async function ensureSchema(pool, options) {
|
||||
created_by INT NULL,
|
||||
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
modified_by INT NULL,
|
||||
CONSTRAINT fk_playlist_slides_playlist FOREIGN KEY (playlist_id) REFERENCES playlists(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_playlist_slides_slide FOREIGN KEY (slide_id) REFERENCES slides(id) ON DELETE CASCADE
|
||||
CONSTRAINT fk_c_playlist_slides_playlist FOREIGN KEY (playlist_id) REFERENCES c_playlists(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_c_playlist_slides_slide FOREIGN KEY (slide_id) REFERENCES c_slides(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS screens (
|
||||
CREATE TABLE IF NOT EXISTS d_players (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
identifier VARCHAR(255) NOT NULL,
|
||||
public_base_url VARCHAR(1024) NULL,
|
||||
internal_base_url VARCHAR(1024) NULL,
|
||||
hostname VARCHAR(255) NULL,
|
||||
metadata_json MEDIUMTEXT 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,
|
||||
UNIQUE KEY uq_d_players_identifier (identifier)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS d_screens (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
slug VARCHAR(255) NOT NULL UNIQUE,
|
||||
playlist_id INT NULL,
|
||||
player_id INT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by INT NULL,
|
||||
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
modified_by INT NULL,
|
||||
CONSTRAINT fk_screens_playlist FOREIGN KEY (playlist_id) REFERENCES playlists(id) ON DELETE SET NULL
|
||||
CONSTRAINT fk_d_screens_playlist FOREIGN KEY (playlist_id) REFERENCES c_playlists(id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_d_screens_player FOREIGN KEY (player_id) REFERENCES d_players(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS rss_feeds (
|
||||
CREATE TABLE IF NOT EXISTS i_rss_feeds (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
feed_url VARCHAR(1024) NOT NULL,
|
||||
@@ -164,7 +158,7 @@ async function ensureSchema(pool, options) {
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS rss_feed_items (
|
||||
CREATE TABLE IF NOT EXISTS i_rss_feed_items (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
rss_feed_id INT NOT NULL,
|
||||
position INT NOT NULL,
|
||||
@@ -173,13 +167,13 @@ async function ensureSchema(pool, options) {
|
||||
created_by INT NULL,
|
||||
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
modified_by INT NULL,
|
||||
CONSTRAINT fk_rss_feed_items_rss_feed FOREIGN KEY (rss_feed_id) REFERENCES rss_feeds(id) ON DELETE CASCADE,
|
||||
UNIQUE KEY uq_rss_feed_items_feed_position (rss_feed_id, position)
|
||||
CONSTRAINT fk_i_rss_feed_items_rss_feed FOREIGN KEY (rss_feed_id) REFERENCES i_rss_feeds(id) ON DELETE CASCADE,
|
||||
UNIQUE KEY uq_integrations_rss_feed_items_feed_position (rss_feed_id, position)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS api_sources (
|
||||
CREATE TABLE IF NOT EXISTS i_api_sources (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
api_url VARCHAR(1024) NOT NULL,
|
||||
@@ -198,7 +192,7 @@ async function ensureSchema(pool, options) {
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS player_onboarding_devices (
|
||||
CREATE TABLE IF NOT EXISTS d_onboarding_devices (
|
||||
device_id VARCHAR(128) PRIMARY KEY,
|
||||
client_name VARCHAR(255) NULL,
|
||||
screen_id INT NULL,
|
||||
@@ -206,12 +200,12 @@ async function ensureSchema(pool, options) {
|
||||
created_by INT NULL,
|
||||
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
modified_by INT NULL,
|
||||
CONSTRAINT fk_player_onboarding_devices_screen FOREIGN KEY (screen_id) REFERENCES screens(id) ON DELETE SET NULL
|
||||
CONSTRAINT fk_d_onboarding_devices_screen FOREIGN KEY (screen_id) REFERENCES d_screens(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
CREATE TABLE IF NOT EXISTS a_users (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(255) NULL,
|
||||
username VARCHAR(255) NOT NULL UNIQUE,
|
||||
@@ -226,84 +220,78 @@ async function ensureSchema(pool, options) {
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS roles (
|
||||
CREATE TABLE IF NOT EXISTS a_roles (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
role_key VARCHAR(100) NOT NULL UNIQUE,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by INT NULL,
|
||||
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
modified_by INT NULL
|
||||
modified_by INT NULL,
|
||||
UNIQUE KEY uq_a_roles_name (name)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS permissions (
|
||||
CREATE TABLE IF NOT EXISTS a_permissions (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
permission_key VARCHAR(100) NOT NULL UNIQUE,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
section_name VARCHAR(255) NOT NULL,
|
||||
description TEXT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by INT NULL,
|
||||
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
modified_by INT NULL
|
||||
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
|
||||
for (const permission of PERMISSIONS) {
|
||||
await pool.query(
|
||||
`INSERT INTO permissions (permission_key, name, section_name, description, created_by, modified_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE name = VALUES(name), section_name = VALUES(section_name), description = VALUES(description), modified_by = VALUES(modified_by)` ,
|
||||
[permission.key, permission.name, permission.sectionName, permission.description || null, null, null]
|
||||
`INSERT INTO a_permissions (permission_key, name, section_name, description)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE name = VALUES(name), section_name = VALUES(section_name), description = VALUES(description)` ,
|
||||
[permission.key, permission.name, permission.sectionName, permission.description || null]
|
||||
);
|
||||
}
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS role_permissions (
|
||||
CREATE TABLE IF NOT EXISTS a_role_permissions (
|
||||
role_id INT NOT NULL,
|
||||
permission_id INT NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by INT NULL,
|
||||
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
modified_by INT NULL,
|
||||
PRIMARY KEY (role_id, permission_id),
|
||||
CONSTRAINT fk_role_permissions_role FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_role_permissions_permission FOREIGN KEY (permission_id) REFERENCES permissions(id) ON DELETE CASCADE
|
||||
CONSTRAINT fk_a_role_permissions_role FOREIGN KEY (role_id) REFERENCES a_roles(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_a_role_permissions_permission FOREIGN KEY (permission_id) REFERENCES a_permissions(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS user_roles (
|
||||
CREATE TABLE IF NOT EXISTS a_user_roles (
|
||||
user_id INT NOT NULL,
|
||||
role_id INT NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by INT NULL,
|
||||
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
modified_by INT NULL,
|
||||
PRIMARY KEY (user_id, role_id),
|
||||
CONSTRAINT fk_user_roles_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_user_roles_role FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE
|
||||
CONSTRAINT fk_a_user_roles_user FOREIGN KEY (user_id) REFERENCES a_users(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_a_user_roles_role FOREIGN KEY (role_id) REFERENCES a_roles(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS auth_sessions (
|
||||
CREATE TABLE IF NOT EXISTS a_sessions (
|
||||
session_hash CHAR(64) PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
expires_at DATETIME NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by INT NULL,
|
||||
last_used_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
modified_by INT NULL,
|
||||
CONSTRAINT fk_auth_sessions_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
CONSTRAINT fk_a_sessions_user FOREIGN KEY (user_id) REFERENCES a_users(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS background_tasks (
|
||||
CREATE TABLE IF NOT EXISTS o_background_tasks (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
task_key VARCHAR(191) NULL,
|
||||
task_type VARCHAR(100) NOT NULL,
|
||||
@@ -314,69 +302,18 @@ async function ensureSchema(pool, options) {
|
||||
metadata_json MEDIUMTEXT NULL,
|
||||
attempts INT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by INT NULL,
|
||||
started_at TIMESTAMP NULL,
|
||||
finished_at TIMESTAMP NULL,
|
||||
error_message MEDIUMTEXT NULL,
|
||||
INDEX idx_background_tasks_status (status),
|
||||
INDEX idx_background_tasks_key (task_key),
|
||||
INDEX idx_background_tasks_type (task_type)
|
||||
INDEX idx_ops_background_tasks_status (status),
|
||||
INDEX idx_ops_background_tasks_key (task_key),
|
||||
INDEX idx_ops_background_tasks_type (task_type)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
|
||||
const [userCountRows] = await pool.query('SELECT COUNT(*) AS user_count FROM users');
|
||||
const username = String(process.env.DEFAULT_ADMIN_USERNAME || 'admin').trim() || 'admin';
|
||||
if (!userCountRows.length || Number(userCountRows[0].user_count) === 0) {
|
||||
const name = String(process.env.DEFAULT_ADMIN_NAME || 'Admin').trim() || 'Admin';
|
||||
const password = String(process.env.DEFAULT_ADMIN_PASSWORD || 'admin').trim() || 'admin';
|
||||
const passwordRecord = hashPassword(password);
|
||||
await pool.query(
|
||||
'INSERT INTO users (name, username, password_hash, password_salt, password_iterations, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
[name, username, passwordRecord.hash, passwordRecord.salt, passwordRecord.iterations, null, null]
|
||||
);
|
||||
}
|
||||
|
||||
await pool.query('UPDATE users SET name = username WHERE name IS NULL OR name = ""');
|
||||
|
||||
await pool.query(
|
||||
`INSERT INTO roles (role_key, name, description, created_by, modified_by)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE name = VALUES(name), description = VALUES(description), modified_by = VALUES(modified_by)`,
|
||||
[DEFAULT_ROLE.key, DEFAULT_ROLE.name, DEFAULT_ROLE.description || null, null, null]
|
||||
);
|
||||
|
||||
await migrations.runMigrations(pool, options || {});
|
||||
|
||||
if (!userCountRows.length || Number(userCountRows[0].user_count) === 0) {
|
||||
const [roleRows] = await pool.query('SELECT id FROM roles WHERE role_key = ? LIMIT 1', [DEFAULT_ROLE.key]);
|
||||
const defaultRoleId = roleRows.length ? Number(roleRows[0].id) : null;
|
||||
if (defaultRoleId) {
|
||||
await pool.query(
|
||||
`INSERT IGNORE INTO user_roles (user_id, role_id, created_by, modified_by)
|
||||
SELECT id, ?, NULL, NULL FROM users`,
|
||||
[defaultRoleId]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const [defaultAdminRows] = await pool.query('SELECT id FROM users WHERE username = ? LIMIT 1', [username]);
|
||||
if (defaultAdminRows.length) {
|
||||
const defaultAdminId = Number(defaultAdminRows[0].id);
|
||||
const [defaultAdminRoleRows] = await pool.query('SELECT COUNT(*) AS role_count FROM user_roles WHERE user_id = ?', [defaultAdminId]);
|
||||
if (!defaultAdminRoleRows.length || Number(defaultAdminRoleRows[0].role_count) === 0) {
|
||||
const [roleRows] = await pool.query('SELECT id FROM roles WHERE role_key = ? LIMIT 1', [DEFAULT_ROLE.key]);
|
||||
const defaultRoleId = roleRows.length ? Number(roleRows[0].id) : null;
|
||||
if (defaultRoleId) {
|
||||
await pool.query(
|
||||
'INSERT IGNORE INTO user_roles (user_id, role_id, created_by, modified_by) VALUES (?, ?, ?, ?)',
|
||||
[defaultAdminId, defaultRoleId, null, null]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createPool,
|
||||
ensureSchema,
|
||||
pruneStaleOnboardingDevices
|
||||
ensureSchema
|
||||
};
|
||||
|
||||
+4
-845
@@ -1,848 +1,7 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { version: appVersion } = require('../../package.json');
|
||||
|
||||
function parseVersion(value) {
|
||||
const parts = String(value || '0.0.0').split('.').map(function (part) {
|
||||
return Math.max(0, Number(part) || 0);
|
||||
});
|
||||
|
||||
return {
|
||||
major: parts[0] || 0,
|
||||
minor: parts[1] || 0,
|
||||
patch: parts[2] || 0
|
||||
};
|
||||
}
|
||||
|
||||
function compareVersions(left, right) {
|
||||
const leftVersion = parseVersion(left);
|
||||
const rightVersion = parseVersion(right);
|
||||
|
||||
if (leftVersion.major !== rightVersion.major) {
|
||||
return leftVersion.major - rightVersion.major;
|
||||
}
|
||||
if (leftVersion.minor !== rightVersion.minor) {
|
||||
return leftVersion.minor - rightVersion.minor;
|
||||
}
|
||||
if (leftVersion.patch !== rightVersion.patch) {
|
||||
return leftVersion.patch - rightVersion.patch;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function addColumnIfMissing(pool, tableName, columnName, columnDefinition) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT COUNT(*) AS column_count
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = ?
|
||||
AND column_name = ?`,
|
||||
[tableName, columnName]
|
||||
);
|
||||
|
||||
if (rows.length && Number(rows[0].column_count) > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await pool.query(`ALTER TABLE \`${tableName}\` ADD COLUMN \`${columnName}\` ${columnDefinition}`);
|
||||
}
|
||||
|
||||
async function dropColumnIfPresent(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]
|
||||
);
|
||||
|
||||
if (!rows.length || Number(rows[0].column_count) === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await pool.query(`ALTER TABLE \`${tableName}\` DROP COLUMN \`${columnName}\``);
|
||||
}
|
||||
|
||||
async function addForeignKeyIfMissing(pool, tableName, columnName, constraintName, referencedTable, referencedColumn, onDeleteAction) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT COUNT(*) AS constraint_count
|
||||
FROM information_schema.table_constraints
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = ?
|
||||
AND constraint_name = ?`,
|
||||
[tableName, constraintName]
|
||||
);
|
||||
|
||||
if (rows.length && Number(rows[0].constraint_count) > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await pool.query(
|
||||
`ALTER TABLE \`${tableName}\`
|
||||
ADD CONSTRAINT \`${constraintName}\`
|
||||
FOREIGN KEY (\`${columnName}\`) REFERENCES \`${referencedTable}\`(\`${referencedColumn}\`)
|
||||
ON DELETE ${onDeleteAction}
|
||||
ON UPDATE CASCADE`
|
||||
);
|
||||
}
|
||||
|
||||
async function hasSingleColumnUniqueIndex(pool, tableName, columnName) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT INDEX_NAME, COUNT(*) AS column_count
|
||||
FROM information_schema.statistics
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = ?
|
||||
AND non_unique = 0
|
||||
AND column_name = ?
|
||||
GROUP BY INDEX_NAME`,
|
||||
[tableName, columnName]
|
||||
);
|
||||
|
||||
return (rows || []).some(function (row) {
|
||||
return Number(row.column_count) === 1;
|
||||
});
|
||||
}
|
||||
|
||||
async function addUniqueIndexIfMissing(pool, tableName, columnName, indexName) {
|
||||
const hasUniqueIndex = await hasSingleColumnUniqueIndex(pool, tableName, columnName);
|
||||
if (hasUniqueIndex) {
|
||||
return;
|
||||
}
|
||||
|
||||
await pool.query(`ALTER TABLE \`${tableName}\` ADD UNIQUE KEY \`${indexName}\` (\`${columnName}\`)`);
|
||||
}
|
||||
|
||||
async function dedupePermissionRows(pool) {
|
||||
const [rows] = await pool.query('SELECT id, permission_key FROM permissions ORDER BY id ASC');
|
||||
const canonicalIdByKey = new Map();
|
||||
const duplicateRowsByKey = new Map();
|
||||
|
||||
for (const row of rows || []) {
|
||||
const permissionKey = String((row && row.permission_key) || '').trim().toLowerCase();
|
||||
const permissionId = Number(row.id);
|
||||
if (!permissionKey || !Number.isInteger(permissionId) || permissionId <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!canonicalIdByKey.has(permissionKey)) {
|
||||
canonicalIdByKey.set(permissionKey, permissionId);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!duplicateRowsByKey.has(permissionKey)) {
|
||||
duplicateRowsByKey.set(permissionKey, []);
|
||||
}
|
||||
duplicateRowsByKey.get(permissionKey).push(permissionId);
|
||||
}
|
||||
|
||||
if (!duplicateRowsByKey.size) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [permissionKey, duplicateIds] of duplicateRowsByKey.entries()) {
|
||||
const canonicalId = canonicalIdByKey.get(permissionKey);
|
||||
for (const duplicateId of duplicateIds) {
|
||||
await pool.query(
|
||||
'UPDATE IGNORE role_permissions SET permission_id = ? WHERE permission_id = ?',
|
||||
[canonicalId, duplicateId]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const duplicateIds = [];
|
||||
for (const duplicateList of duplicateRowsByKey.values()) {
|
||||
duplicateIds.push.apply(duplicateIds, duplicateList);
|
||||
}
|
||||
|
||||
if (duplicateIds.length) {
|
||||
await pool.query('DELETE FROM permissions WHERE id IN (?)', [duplicateIds]);
|
||||
}
|
||||
}
|
||||
|
||||
async function addAuditColumns(pool, tableName) {
|
||||
await addColumnIfMissing(pool, tableName, 'created_by', 'INT NULL');
|
||||
await addColumnIfMissing(pool, tableName, 'modified_by', 'INT NULL');
|
||||
|
||||
await pool.query(
|
||||
`UPDATE \`${tableName}\` t
|
||||
LEFT JOIN users created_user ON created_user.id = t.created_by
|
||||
SET t.created_by = NULL
|
||||
WHERE t.created_by IS NOT NULL`
|
||||
);
|
||||
await pool.query(
|
||||
`UPDATE \`${tableName}\` t
|
||||
LEFT JOIN users modified_user ON modified_user.id = t.modified_by
|
||||
SET t.modified_by = NULL
|
||||
WHERE t.modified_by IS NOT NULL`
|
||||
);
|
||||
|
||||
await addForeignKeyIfMissing(pool, tableName, 'created_by', `fk_${tableName}_created_by`, 'users', 'id', 'SET NULL');
|
||||
await addForeignKeyIfMissing(pool, tableName, 'modified_by', `fk_${tableName}_modified_by`, 'users', 'id', 'SET NULL');
|
||||
}
|
||||
|
||||
async function hasColumn(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 rows.length && Number(rows[0].column_count) > 0;
|
||||
}
|
||||
|
||||
async function backfillLegacyRssFeedItemJson(pool) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'rss_feed_items'`
|
||||
);
|
||||
const columnNames = new Set((rows || []).map(function (row) {
|
||||
return String(row.COLUMN_NAME || row.column_name || '').trim().toLowerCase();
|
||||
}).filter(Boolean));
|
||||
|
||||
if (!['title', 'link', 'pub_date', 'description'].every(function (columnName) {
|
||||
return columnNames.has(columnName);
|
||||
})) {
|
||||
return;
|
||||
}
|
||||
|
||||
await pool.query(
|
||||
`UPDATE rss_feed_items
|
||||
SET item_json = JSON_OBJECT(
|
||||
'title', title,
|
||||
'link', link,
|
||||
'pubDate', pub_date,
|
||||
'description', description
|
||||
)
|
||||
WHERE item_json IS NULL`
|
||||
);
|
||||
}
|
||||
|
||||
async function backfillLegacySlideTemplateCanvasSize(pool) {
|
||||
const [legacyTemplateColumns] = await pool.query(`
|
||||
SELECT COUNT(*) AS column_count
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'slide_templates'
|
||||
AND column_name IN ('canvas_width', 'canvas_height')
|
||||
`);
|
||||
if (!legacyTemplateColumns[0] || Number(legacyTemplateColumns[0].column_count) !== 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
await pool.query(`
|
||||
UPDATE slide_templates st
|
||||
JOIN canvas_sizes cs ON cs.width = st.canvas_width AND cs.height = st.canvas_height
|
||||
SET st.canvas_size_id = cs.id
|
||||
WHERE st.canvas_size_id IS NULL
|
||||
`);
|
||||
}
|
||||
|
||||
function replaceUploadPrefixInValue(value) {
|
||||
if (typeof value === 'string') {
|
||||
return value.replace(/\/uploads\//g, '/media/');
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(function (item) {
|
||||
return replaceUploadPrefixInValue(item);
|
||||
});
|
||||
}
|
||||
if (value && typeof value === 'object') {
|
||||
return Object.keys(value).reduce(function (result, key) {
|
||||
result[key] = replaceUploadPrefixInValue(value[key]);
|
||||
return result;
|
||||
}, {});
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function replaceLegacyMediaUploadPathInValue(value) {
|
||||
if (typeof value === 'string') {
|
||||
if (!value.startsWith('/media/') || value.startsWith('/media/uploads/')) {
|
||||
return value;
|
||||
}
|
||||
return '/media/uploads/' + path.basename(value);
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(function (item) {
|
||||
return replaceLegacyMediaUploadPathInValue(item);
|
||||
});
|
||||
}
|
||||
if (value && typeof value === 'object') {
|
||||
return Object.keys(value).reduce(function (result, key) {
|
||||
result[key] = replaceLegacyMediaUploadPathInValue(value[key]);
|
||||
return result;
|
||||
}, {});
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseJsonValue(value) {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return null;
|
||||
}
|
||||
if (typeof value !== 'string') {
|
||||
return value;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch (_error) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
async function backfillLegacyMediaPaths(pool) {
|
||||
const [slides] = await pool.query(`
|
||||
SELECT id, media_path, content_json
|
||||
FROM slides
|
||||
WHERE media_path LIKE '/uploads/%'
|
||||
OR content_json LIKE '%/uploads/%'
|
||||
`);
|
||||
|
||||
for (const slide of slides || []) {
|
||||
let mediaPath = String(slide.media_path || '').trim() || null;
|
||||
let contentJson = slide.content_json;
|
||||
let changed = false;
|
||||
|
||||
if (mediaPath && mediaPath.startsWith('/uploads/')) {
|
||||
mediaPath = mediaPath.replace(/^\/uploads\//, '/media/');
|
||||
changed = true;
|
||||
}
|
||||
|
||||
const parsedContent = parseJsonValue(contentJson);
|
||||
if (parsedContent && typeof parsedContent === 'object') {
|
||||
const updatedContent = replaceUploadPrefixInValue(parsedContent);
|
||||
if (JSON.stringify(updatedContent) !== JSON.stringify(parsedContent)) {
|
||||
contentJson = JSON.stringify(updatedContent);
|
||||
changed = true;
|
||||
}
|
||||
} else if (typeof parsedContent === 'string' && parsedContent.indexOf('/uploads/') !== -1) {
|
||||
contentJson = parsedContent.replace(/\/uploads\//g, '/media/');
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
await pool.query('UPDATE slides SET media_path = ?, content_json = ? WHERE id = ?', [mediaPath, contentJson, slide.id]);
|
||||
}
|
||||
}
|
||||
|
||||
const [templates] = await pool.query(`
|
||||
SELECT id, background_image_path
|
||||
FROM slide_templates
|
||||
WHERE background_image_path LIKE '/uploads/%'
|
||||
`);
|
||||
|
||||
for (const template of templates || []) {
|
||||
const backgroundImagePath = String(template.background_image_path || '').trim();
|
||||
if (!backgroundImagePath.startsWith('/uploads/')) {
|
||||
continue;
|
||||
}
|
||||
await pool.query(
|
||||
'UPDATE slide_templates SET background_image_path = ? WHERE id = ?',
|
||||
[backgroundImagePath.replace(/^\/uploads\//, '/media/'), template.id]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function moveFileIfMissing(sourcePath, targetPath) {
|
||||
try {
|
||||
await fs.promises.access(targetPath, fs.constants.F_OK);
|
||||
return false;
|
||||
} catch (_error) {
|
||||
// target does not exist
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.promises.access(sourcePath, fs.constants.F_OK);
|
||||
} catch (_error) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await fs.promises.mkdir(path.dirname(targetPath), { recursive: true });
|
||||
|
||||
try {
|
||||
await fs.promises.rename(sourcePath, targetPath);
|
||||
} catch (error) {
|
||||
if (error && error.code === 'EXDEV') {
|
||||
await fs.promises.copyFile(sourcePath, targetPath);
|
||||
await fs.promises.unlink(sourcePath);
|
||||
return true;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async function backfillLegacyMediaUploadsToSubfolder(pool, mediaDir) {
|
||||
const normalizedMediaDir = String(mediaDir || '').trim() ? path.resolve(String(mediaDir).trim()) : null;
|
||||
if (!normalizedMediaDir) {
|
||||
return;
|
||||
}
|
||||
|
||||
const uploadsDir = path.join(normalizedMediaDir, 'uploads');
|
||||
await fs.promises.mkdir(uploadsDir, { recursive: true });
|
||||
|
||||
const [slides] = await pool.query(`
|
||||
SELECT id, media_path, content_json
|
||||
FROM slides
|
||||
WHERE media_path LIKE '/media/%'
|
||||
OR content_json LIKE '%/media/%'
|
||||
`);
|
||||
|
||||
for (const slide of slides || []) {
|
||||
let mediaPath = String(slide.media_path || '').trim() || null;
|
||||
let contentJson = slide.content_json;
|
||||
let changed = false;
|
||||
|
||||
if (mediaPath && mediaPath.startsWith('/media/') && !mediaPath.startsWith('/media/uploads/')) {
|
||||
const fileName = path.basename(mediaPath);
|
||||
await moveFileIfMissing(path.join(normalizedMediaDir, fileName), path.join(uploadsDir, fileName));
|
||||
mediaPath = '/media/uploads/' + fileName;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
const parsedContent = parseJsonValue(contentJson);
|
||||
if (parsedContent && typeof parsedContent === 'object') {
|
||||
const updatedContent = replaceLegacyMediaUploadPathInValue(parsedContent);
|
||||
if (JSON.stringify(updatedContent) !== JSON.stringify(parsedContent)) {
|
||||
contentJson = JSON.stringify(updatedContent);
|
||||
changed = true;
|
||||
}
|
||||
} else if (typeof parsedContent === 'string' && parsedContent.indexOf('/media/') !== -1) {
|
||||
const updatedContent = replaceLegacyMediaUploadPathInValue(parsedContent);
|
||||
if (updatedContent !== parsedContent) {
|
||||
contentJson = updatedContent;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
await pool.query('UPDATE slides SET media_path = ?, content_json = ? WHERE id = ?', [mediaPath, contentJson, slide.id]);
|
||||
}
|
||||
}
|
||||
|
||||
const [templates] = await pool.query(`
|
||||
SELECT id, background_image_path
|
||||
FROM slide_templates
|
||||
WHERE background_image_path LIKE '/media/%'
|
||||
`);
|
||||
|
||||
for (const template of templates || []) {
|
||||
const backgroundImagePath = String(template.background_image_path || '').trim();
|
||||
if (!backgroundImagePath.startsWith('/media/') || backgroundImagePath.startsWith('/media/uploads/')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const fileName = path.basename(backgroundImagePath);
|
||||
await moveFileIfMissing(path.join(normalizedMediaDir, fileName), path.join(uploadsDir, fileName));
|
||||
await pool.query(
|
||||
'UPDATE slide_templates SET background_image_path = ? WHERE id = ?',
|
||||
['/media/uploads/' + fileName, template.id]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function backfillLooseMediaFilesToUploadsSubfolder(pool, mediaDir) {
|
||||
const normalizedMediaDir = String(mediaDir || '').trim() ? path.resolve(String(mediaDir).trim()) : null;
|
||||
if (!normalizedMediaDir) {
|
||||
return;
|
||||
}
|
||||
|
||||
const uploadsDir = path.join(normalizedMediaDir, 'uploads');
|
||||
await fs.promises.mkdir(uploadsDir, { recursive: true });
|
||||
|
||||
const directoryEntries = await fs.promises.readdir(normalizedMediaDir, { withFileTypes: true });
|
||||
for (const entry of directoryEntries || []) {
|
||||
if (!entry || !entry.isFile()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const fileName = String(entry.name || '').trim();
|
||||
if (!fileName) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const sourcePath = path.join(normalizedMediaDir, fileName);
|
||||
const targetPath = path.join(uploadsDir, fileName);
|
||||
await moveFileIfMissing(sourcePath, targetPath);
|
||||
}
|
||||
|
||||
const [slides] = await pool.query(`
|
||||
SELECT id, media_path, content_json
|
||||
FROM slides
|
||||
WHERE media_path LIKE '/media/%'
|
||||
OR content_json LIKE '%/media/%'
|
||||
`);
|
||||
|
||||
for (const slide of slides || []) {
|
||||
let mediaPath = String(slide.media_path || '').trim() || null;
|
||||
let contentJson = slide.content_json;
|
||||
let changed = false;
|
||||
|
||||
if (mediaPath && mediaPath.startsWith('/media/') && !mediaPath.startsWith('/media/uploads/')) {
|
||||
mediaPath = '/media/uploads/' + path.basename(mediaPath);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
const parsedContent = parseJsonValue(contentJson);
|
||||
if (parsedContent && typeof parsedContent === 'object') {
|
||||
const updatedContent = replaceLegacyMediaUploadPathInValue(parsedContent);
|
||||
if (JSON.stringify(updatedContent) !== JSON.stringify(parsedContent)) {
|
||||
contentJson = JSON.stringify(updatedContent);
|
||||
changed = true;
|
||||
}
|
||||
} else if (typeof parsedContent === 'string' && parsedContent.indexOf('/media/') !== -1) {
|
||||
const updatedContent = replaceLegacyMediaUploadPathInValue(parsedContent);
|
||||
if (updatedContent !== parsedContent) {
|
||||
contentJson = updatedContent;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
await pool.query('UPDATE slides SET media_path = ?, content_json = ? WHERE id = ?', [mediaPath, contentJson, slide.id]);
|
||||
}
|
||||
}
|
||||
|
||||
const [templates] = await pool.query(`
|
||||
SELECT id, background_image_path
|
||||
FROM slide_templates
|
||||
WHERE background_image_path LIKE '/media/%'
|
||||
`);
|
||||
|
||||
for (const template of templates || []) {
|
||||
const backgroundImagePath = String(template.background_image_path || '').trim();
|
||||
if (!backgroundImagePath.startsWith('/media/') || backgroundImagePath.startsWith('/media/uploads/')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const fileName = path.basename(backgroundImagePath);
|
||||
await pool.query(
|
||||
'UPDATE slide_templates SET background_image_path = ? WHERE id = ?',
|
||||
['/media/uploads/' + fileName, template.id]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureMigrationTable(pool) {
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
migration_key VARCHAR(100) NOT NULL UNIQUE,
|
||||
app_version VARCHAR(32) NOT NULL,
|
||||
comment TEXT NOT NULL,
|
||||
applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
}
|
||||
|
||||
async function getAppliedMigrationRows(pool) {
|
||||
await ensureMigrationTable(pool);
|
||||
|
||||
const [rows] = await pool.query(
|
||||
'SELECT migration_key, app_version, comment, applied_at FROM schema_migrations ORDER BY id ASC'
|
||||
);
|
||||
|
||||
return rows || [];
|
||||
}
|
||||
|
||||
function getLatestAppliedVersion(rows) {
|
||||
let latestVersion = '0.0.0';
|
||||
|
||||
for (const row of rows || []) {
|
||||
const candidateVersion = String(row.app_version || '0.0.0');
|
||||
if (compareVersions(candidateVersion, latestVersion) > 0) {
|
||||
latestVersion = candidateVersion;
|
||||
}
|
||||
}
|
||||
|
||||
return latestVersion;
|
||||
}
|
||||
|
||||
async function recordMigration(pool, migration) {
|
||||
await pool.query(
|
||||
'INSERT IGNORE INTO schema_migrations (migration_key, app_version, comment) VALUES (?, ?, ?)',
|
||||
[migration.key, migration.version, migration.comment]
|
||||
);
|
||||
}
|
||||
|
||||
const migrations = [
|
||||
{
|
||||
key: 'interval-value-rename',
|
||||
version: appVersion,
|
||||
comment: 'Rename RSS and API refresh interval columns to update_interval_value so seconds and minutes share one neutral numeric field.',
|
||||
order: 10,
|
||||
up: async function (pool) {
|
||||
await addColumnIfMissing(pool, 'rss_feeds', 'update_interval_value', 'INT NOT NULL DEFAULT 60');
|
||||
await addColumnIfMissing(pool, 'rss_feeds', 'update_interval_unit', "VARCHAR(10) NOT NULL DEFAULT 'minutes'");
|
||||
await addColumnIfMissing(pool, 'api_sources', 'update_interval_value', 'INT NOT NULL DEFAULT 60');
|
||||
await addColumnIfMissing(pool, 'api_sources', 'update_interval_unit', "VARCHAR(10) NOT NULL DEFAULT 'minutes'");
|
||||
|
||||
if (await hasColumn(pool, 'rss_feeds', 'update_interval_minutes')) {
|
||||
await pool.query(`
|
||||
UPDATE rss_feeds
|
||||
SET update_interval_value = update_interval_minutes
|
||||
`);
|
||||
await dropColumnIfPresent(pool, 'rss_feeds', 'update_interval_minutes');
|
||||
}
|
||||
|
||||
if (await hasColumn(pool, 'api_sources', 'update_interval_minutes')) {
|
||||
await pool.query(`
|
||||
UPDATE api_sources
|
||||
SET update_interval_value = update_interval_minutes
|
||||
`);
|
||||
await dropColumnIfPresent(pool, 'api_sources', 'update_interval_minutes');
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'schema-columns-current',
|
||||
version: appVersion,
|
||||
comment: 'Backfill the released schema columns for older databases.',
|
||||
order: 20,
|
||||
up: async function (pool) {
|
||||
// Current released schema: keep canvas_sizes audit fields available in older databases.
|
||||
await addColumnIfMissing(pool, 'canvas_sizes', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
|
||||
await addAuditColumns(pool, 'canvas_sizes');
|
||||
|
||||
// Current released schema: ensure playlists carry the playback and audit fields.
|
||||
await addColumnIfMissing(pool, 'playlists', 'fade_between_slides', 'TINYINT(1) NOT NULL DEFAULT 0');
|
||||
await addColumnIfMissing(pool, 'playlists', 'skip_unavailable_rtmp', 'TINYINT(1) NOT NULL DEFAULT 0');
|
||||
await addColumnIfMissing(pool, 'playlists', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
|
||||
await addAuditColumns(pool, 'playlists');
|
||||
|
||||
// Current released schema: keep slide template canvas and background fields available.
|
||||
await addColumnIfMissing(pool, 'slide_templates', 'canvas_size_id', 'INT NULL');
|
||||
await addColumnIfMissing(pool, 'slide_templates', 'background_image_path', 'VARCHAR(512) NULL');
|
||||
await addColumnIfMissing(pool, 'slide_templates', 'background_color', 'VARCHAR(32) NULL');
|
||||
await addColumnIfMissing(pool, 'slide_templates', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
|
||||
await addAuditColumns(pool, 'slide_templates');
|
||||
await backfillLegacySlideTemplateCanvasSize(pool);
|
||||
|
||||
// Current released schema: keep slide template region typography and audit fields available.
|
||||
await addColumnIfMissing(pool, 'slide_template_regions', 'font_family', 'VARCHAR(100) NULL');
|
||||
await addColumnIfMissing(pool, 'slide_template_regions', 'lock_ratio', 'VARCHAR(20) NULL');
|
||||
await addColumnIfMissing(pool, 'slide_template_regions', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
|
||||
await addAuditColumns(pool, 'slide_template_regions');
|
||||
|
||||
// Current released schema: keep structured slide content and media fields available.
|
||||
await addColumnIfMissing(pool, 'slides', 'body', 'TEXT NULL');
|
||||
await addColumnIfMissing(pool, 'slides', 'template_id', 'INT NULL');
|
||||
await addColumnIfMissing(pool, 'slides', 'content_json', 'JSON NULL');
|
||||
await addColumnIfMissing(pool, 'slides', 'media_path', 'VARCHAR(512) NULL');
|
||||
await addColumnIfMissing(pool, 'slides', 'media_type', 'VARCHAR(100) NULL');
|
||||
await addColumnIfMissing(pool, 'slides', 'thumbnail_path', 'VARCHAR(512) NULL');
|
||||
await addColumnIfMissing(pool, 'slides', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
|
||||
await addAuditColumns(pool, 'slides');
|
||||
|
||||
// Current released schema: keep playlist slide duration, schedule, and audit fields available.
|
||||
await addColumnIfMissing(pool, 'playlist_slides', 'duration_seconds', 'DECIMAL(10,3) NOT NULL DEFAULT 10.000');
|
||||
await addColumnIfMissing(pool, 'playlist_slides', 'schedule_mode', "VARCHAR(20) NOT NULL DEFAULT 'always'");
|
||||
await addColumnIfMissing(pool, 'playlist_slides', 'schedule_start_datetime', 'DATETIME NULL');
|
||||
await addColumnIfMissing(pool, 'playlist_slides', 'schedule_end_datetime', 'DATETIME NULL');
|
||||
await addColumnIfMissing(pool, 'playlist_slides', 'schedule_start_time', 'TIME NULL');
|
||||
await addColumnIfMissing(pool, 'playlist_slides', 'schedule_end_time', 'TIME NULL');
|
||||
await addColumnIfMissing(pool, 'playlist_slides', 'schedule_days_json', 'JSON NULL');
|
||||
await addColumnIfMissing(pool, 'playlist_slides', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
|
||||
await addAuditColumns(pool, 'playlist_slides');
|
||||
|
||||
// Current released schema: keep screen playlist bindings and audit fields available.
|
||||
await addColumnIfMissing(pool, 'screens', 'playlist_id', 'INT NULL');
|
||||
await addColumnIfMissing(pool, 'screens', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
|
||||
await addAuditColumns(pool, 'screens');
|
||||
|
||||
// Current released schema: keep RSS feed interval and audit fields available.
|
||||
await addColumnIfMissing(pool, 'rss_feeds', 'name', 'VARCHAR(255) NOT NULL');
|
||||
await addColumnIfMissing(pool, 'rss_feeds', 'feed_url', 'VARCHAR(1024) NOT NULL');
|
||||
await addColumnIfMissing(pool, 'rss_feeds', 'update_interval_value', 'INT NOT NULL DEFAULT 60');
|
||||
await addColumnIfMissing(pool, 'rss_feeds', 'update_interval_unit', "VARCHAR(10) NOT NULL DEFAULT 'minutes'");
|
||||
await addColumnIfMissing(pool, 'rss_feeds', 'item_limit', 'INT NOT NULL DEFAULT 1');
|
||||
await addColumnIfMissing(pool, 'rss_feeds', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
|
||||
await addAuditColumns(pool, 'rss_feeds');
|
||||
|
||||
// Current released schema: keep normalized RSS feed item snapshots available.
|
||||
await addColumnIfMissing(pool, 'rss_feed_items', 'rss_feed_id', 'INT NOT NULL');
|
||||
await addColumnIfMissing(pool, 'rss_feed_items', 'position', 'INT NOT NULL');
|
||||
await addColumnIfMissing(pool, 'rss_feed_items', 'item_json', 'MEDIUMTEXT NULL');
|
||||
await addColumnIfMissing(pool, 'rss_feed_items', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
|
||||
await backfillLegacyRssFeedItemJson(pool);
|
||||
|
||||
// Current released schema: keep API source polling and snapshot fields available.
|
||||
await addColumnIfMissing(pool, 'api_sources', 'name', 'VARCHAR(255) NOT NULL');
|
||||
await addColumnIfMissing(pool, 'api_sources', 'api_url', 'VARCHAR(1024) NOT NULL');
|
||||
await addColumnIfMissing(pool, 'api_sources', 'update_interval_value', 'INT NOT NULL DEFAULT 60');
|
||||
await addColumnIfMissing(pool, 'api_sources', 'update_interval_unit', "VARCHAR(10) NOT NULL DEFAULT 'minutes'");
|
||||
await addColumnIfMissing(pool, 'api_sources', 'last_pulled_at', 'TIMESTAMP NULL');
|
||||
await addColumnIfMissing(pool, 'api_sources', 'last_pull_error', 'MEDIUMTEXT NULL');
|
||||
await addColumnIfMissing(pool, 'api_sources', 'last_response_status', 'INT NULL');
|
||||
await addColumnIfMissing(pool, 'api_sources', 'last_response_content_type', 'VARCHAR(255) NULL');
|
||||
await addColumnIfMissing(pool, 'api_sources', 'last_response_json', 'MEDIUMTEXT NULL');
|
||||
await addColumnIfMissing(pool, 'api_sources', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
|
||||
await addAuditColumns(pool, 'api_sources');
|
||||
|
||||
// Current released schema: keep onboarding device and RBAC audit fields available.
|
||||
await addColumnIfMissing(pool, 'player_onboarding_devices', 'client_name', 'VARCHAR(255) NULL');
|
||||
await addColumnIfMissing(pool, 'player_onboarding_devices', 'screen_id', 'INT NULL');
|
||||
await addColumnIfMissing(pool, 'player_onboarding_devices', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
|
||||
await addAuditColumns(pool, 'player_onboarding_devices');
|
||||
|
||||
await addColumnIfMissing(pool, 'users', 'name', 'VARCHAR(255) NULL');
|
||||
await addColumnIfMissing(pool, 'users', 'password_hash', 'CHAR(64) NOT NULL');
|
||||
await addColumnIfMissing(pool, 'users', 'password_salt', 'VARCHAR(64) NOT NULL');
|
||||
await addColumnIfMissing(pool, 'users', 'password_iterations', 'INT NOT NULL');
|
||||
await addColumnIfMissing(pool, 'users', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
|
||||
await addAuditColumns(pool, 'users');
|
||||
|
||||
await addColumnIfMissing(pool, 'roles', 'role_key', 'VARCHAR(100) NULL');
|
||||
await addColumnIfMissing(pool, 'roles', 'description', 'TEXT NULL');
|
||||
await addColumnIfMissing(pool, 'roles', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
|
||||
await addAuditColumns(pool, 'roles');
|
||||
|
||||
await addColumnIfMissing(pool, 'permissions', 'permission_key', 'VARCHAR(100) NULL');
|
||||
await addColumnIfMissing(pool, 'permissions', 'name', 'VARCHAR(255) NULL');
|
||||
await addColumnIfMissing(pool, 'permissions', 'section_name', 'VARCHAR(255) NULL');
|
||||
await addColumnIfMissing(pool, 'permissions', 'description', 'TEXT NULL');
|
||||
await addColumnIfMissing(pool, 'permissions', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
|
||||
await addAuditColumns(pool, 'permissions');
|
||||
await dedupePermissionRows(pool);
|
||||
await addUniqueIndexIfMissing(pool, 'permissions', 'permission_key', 'uq_permissions_permission_key');
|
||||
|
||||
await addColumnIfMissing(pool, 'role_permissions', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
|
||||
await addAuditColumns(pool, 'role_permissions');
|
||||
|
||||
await addColumnIfMissing(pool, 'user_roles', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
|
||||
await addAuditColumns(pool, 'user_roles');
|
||||
|
||||
await addAuditColumns(pool, 'auth_sessions');
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'playlist-skip-unavailable-rtmp',
|
||||
version: appVersion,
|
||||
comment: 'Add the playlist flag that skips RTMP slides when streams are unavailable.',
|
||||
order: 21,
|
||||
up: async function (pool) {
|
||||
await addColumnIfMissing(pool, 'playlists', 'skip_unavailable_rtmp', 'TINYINT(1) NOT NULL DEFAULT 0');
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'slides-thumbnail-path-column',
|
||||
version: appVersion,
|
||||
comment: 'Backfill the slides.thumbnail_path column for databases that already recorded the broader schema migration.',
|
||||
order: 22,
|
||||
up: async function (pool) {
|
||||
await addColumnIfMissing(pool, 'slides', 'thumbnail_path', 'VARCHAR(512) NULL');
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'playlist-slide-use-video-duration-flag',
|
||||
version: appVersion,
|
||||
comment: 'Add the playlist slide flag that follows the current video duration.',
|
||||
order: 23,
|
||||
up: async function (pool) {
|
||||
await addColumnIfMissing(pool, 'playlist_slides', 'use_video_duration', 'TINYINT(1) NOT NULL DEFAULT 0');
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'playlist-slide-duration-decimals',
|
||||
version: appVersion,
|
||||
comment: 'Widen playlist slide durations to preserve fractional seconds.',
|
||||
order: 24,
|
||||
up: async function (pool) {
|
||||
await pool.query('ALTER TABLE playlist_slides MODIFY duration_seconds DECIMAL(10,3) NOT NULL DEFAULT 10.000');
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'media-path-prefix-rename',
|
||||
version: appVersion,
|
||||
comment: 'Rename stored media URLs from /uploads to /media so existing slides and templates keep working after the storage root move.',
|
||||
order: 25,
|
||||
up: async function (pool) {
|
||||
await backfillLegacyMediaPaths(pool);
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'media-upload-subfolder-move',
|
||||
version: appVersion,
|
||||
comment: 'Move existing upload files and references from the media root into media/uploads.',
|
||||
order: 26,
|
||||
up: async function (pool, options) {
|
||||
await backfillLegacyMediaUploadsToSubfolder(pool, options && options.mediaDir);
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'media-upload-subfolder-rescue',
|
||||
version: appVersion,
|
||||
comment: 'Rescan loose media files and promote them into media/uploads.',
|
||||
order: 27,
|
||||
up: async function (pool, options) {
|
||||
await backfillLooseMediaFilesToUploadsSubfolder(pool, options && options.mediaDir);
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'background-tasks-table',
|
||||
version: appVersion,
|
||||
comment: 'Persist background tasks so queued work survives a web restart.',
|
||||
order: 30,
|
||||
up: async function (pool) {
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS background_tasks (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
task_key VARCHAR(191) NULL,
|
||||
task_type VARCHAR(100) NOT NULL,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
category VARCHAR(100) NOT NULL DEFAULT 'general',
|
||||
status VARCHAR(20) NOT NULL,
|
||||
payload_json MEDIUMTEXT NULL,
|
||||
metadata_json MEDIUMTEXT NULL,
|
||||
attempts INT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
started_at TIMESTAMP NULL,
|
||||
finished_at TIMESTAMP NULL,
|
||||
error_message MEDIUMTEXT NULL,
|
||||
INDEX idx_background_tasks_status (status),
|
||||
INDEX idx_background_tasks_key (task_key),
|
||||
INDEX idx_background_tasks_type (task_type)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
async function runMigrations(pool, options) {
|
||||
const appliedRows = await getAppliedMigrationRows(pool);
|
||||
const appliedKeys = new Set((appliedRows || []).map(function (row) {
|
||||
return String(row.migration_key || '').trim();
|
||||
}).filter(Boolean));
|
||||
|
||||
const pendingMigrations = migrations
|
||||
.filter(function (migration) {
|
||||
return compareVersions(migration.version, appVersion) <= 0
|
||||
&& !appliedKeys.has(migration.key);
|
||||
})
|
||||
.sort(function (left, right) {
|
||||
const versionOrder = compareVersions(left.version, right.version);
|
||||
if (versionOrder !== 0) {
|
||||
return versionOrder;
|
||||
}
|
||||
return Number(left.order || 0) - Number(right.order || 0);
|
||||
});
|
||||
|
||||
for (const migration of pendingMigrations) {
|
||||
await migration.up(pool, options || {});
|
||||
await recordMigration(pool, migration);
|
||||
}
|
||||
async function runMigrations(_pool, _options) {
|
||||
return null;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
appVersion: appVersion,
|
||||
compareVersions: compareVersions,
|
||||
runMigrations: runMigrations
|
||||
};
|
||||
runMigrations
|
||||
};
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
const mysql = require('mysql2/promise');
|
||||
|
||||
function createPool() {
|
||||
return mysql.createPool({
|
||||
host: process.env.DB_HOST || '127.0.0.1',
|
||||
port: Number(process.env.DB_PORT || 3306),
|
||||
user: process.env.DB_USER || 'signage_user',
|
||||
password: process.env.DB_PASSWORD || 'signage_password',
|
||||
database: process.env.DB_NAME || 'signage',
|
||||
waitForConnections: true,
|
||||
connectionLimit: 10,
|
||||
namedPlaceholders: true
|
||||
});
|
||||
}
|
||||
|
||||
async function pruneStaleOnboardingDevices(pool) {
|
||||
await pool.query(
|
||||
`DELETE FROM d_onboarding_devices
|
||||
WHERE modified_at < (CURRENT_TIMESTAMP - INTERVAL 1 MINUTE)`
|
||||
);
|
||||
}
|
||||
|
||||
async function upsertPlayerRegistryEntry(pool, player) {
|
||||
const normalizedPlayer = player && typeof player === 'object' ? player : {};
|
||||
const identifier = String(normalizedPlayer.identifier || '').trim();
|
||||
if (!identifier) {
|
||||
throw new Error('identifier is required.');
|
||||
}
|
||||
|
||||
const publicBaseUrl = String(normalizedPlayer.publicBaseUrl || '').trim().replace(/\/+$/, '') || null;
|
||||
const internalBaseUrl = String(normalizedPlayer.internalBaseUrl || '').trim().replace(/\/+$/, '') || null;
|
||||
const hostname = String(normalizedPlayer.hostname || '').trim() || null;
|
||||
const metadataJson = normalizedPlayer.metadataJson == null
|
||||
? null
|
||||
: (typeof normalizedPlayer.metadataJson === 'string' ? normalizedPlayer.metadataJson : JSON.stringify(normalizedPlayer.metadataJson));
|
||||
|
||||
await pool.query(
|
||||
`INSERT INTO d_players (identifier, public_base_url, internal_base_url, hostname, metadata_json, last_seen_at)
|
||||
VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
public_base_url = VALUES(public_base_url),
|
||||
internal_base_url = VALUES(internal_base_url),
|
||||
hostname = VALUES(hostname),
|
||||
metadata_json = VALUES(metadata_json),
|
||||
last_seen_at = CURRENT_TIMESTAMP,
|
||||
identifier = VALUES(identifier)` ,
|
||||
[identifier, publicBaseUrl, internalBaseUrl, hostname, metadataJson]
|
||||
);
|
||||
|
||||
const [rows] = await pool.query('SELECT * FROM d_players WHERE identifier = ? LIMIT 1', [identifier]);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createPool,
|
||||
pruneStaleOnboardingDevices,
|
||||
upsertPlayerRegistryEntry
|
||||
};
|
||||
+204
-8
@@ -1,6 +1,8 @@
|
||||
const express = require('express');
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const http = require('http');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const common = require('./common');
|
||||
const { createPlayerRuntime } = require('./player/runtime');
|
||||
@@ -9,18 +11,145 @@ 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 { pruneStaleOnboardingDevices } = require('./db');
|
||||
const { pruneStaleOnboardingDevices, upsertPlayerRegistryEntry } = require('./db/runtime');
|
||||
|
||||
const REQUIRED_TABLES = [
|
||||
'c_canvas_sizes',
|
||||
'c_playlists',
|
||||
'c_slides',
|
||||
'c_playlist_slides',
|
||||
'c_templates',
|
||||
'c_template_regions',
|
||||
'd_screens',
|
||||
'd_players',
|
||||
'd_onboarding_devices',
|
||||
'o_background_tasks'
|
||||
];
|
||||
|
||||
function delay(ms) {
|
||||
return new Promise(function (resolve) {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
|
||||
function loadOrCreatePlayerIdentifier(filePath) {
|
||||
const normalizedFilePath = String(filePath || '').trim();
|
||||
if (!normalizedFilePath) {
|
||||
return `player-${crypto.randomUUID()}`;
|
||||
}
|
||||
|
||||
try {
|
||||
const existing = String(fs.readFileSync(normalizedFilePath, 'utf8') || '').trim();
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
} catch (error) {
|
||||
if (!error || error.code !== 'ENOENT') {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const generated = `player-${crypto.randomUUID()}`;
|
||||
fs.mkdirSync(path.dirname(normalizedFilePath), { recursive: true });
|
||||
fs.writeFileSync(normalizedFilePath, generated, 'utf8');
|
||||
return generated;
|
||||
}
|
||||
|
||||
function getPlayerIdentifierLockName(identifier) {
|
||||
const normalizedIdentifier = String(identifier || '').trim();
|
||||
const hash = crypto.createHash('sha256').update(normalizedIdentifier).digest('hex').slice(0, 32);
|
||||
return `pulse-signage:player:${hash}`;
|
||||
}
|
||||
|
||||
async function acquirePlayerIdentifierLock(pool, identifier) {
|
||||
const connection = await pool.getConnection();
|
||||
const lockName = getPlayerIdentifierLockName(identifier);
|
||||
|
||||
try {
|
||||
const [rows] = await connection.query('SELECT GET_LOCK(?, 0) AS lock_acquired', [lockName]);
|
||||
const lockAcquired = rows.length ? Number(rows[0].lock_acquired) : 0;
|
||||
|
||||
if (lockAcquired !== 1) {
|
||||
connection.release();
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
connection: connection,
|
||||
lockName: lockName
|
||||
};
|
||||
} catch (error) {
|
||||
connection.release();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function releasePlayerIdentifierLock(lockHandle) {
|
||||
if (!lockHandle || !lockHandle.connection) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await lockHandle.connection.query('SELECT RELEASE_LOCK(?)', [lockHandle.lockName]);
|
||||
} catch (_error) {
|
||||
// Ignore release failures; the connection close will drop the lock.
|
||||
}
|
||||
|
||||
try {
|
||||
lockHandle.connection.release();
|
||||
} catch (_error) {
|
||||
// ignore release errors
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForDatabaseBootstrap(pool) {
|
||||
const expectedTableCount = REQUIRED_TABLES.length;
|
||||
let attempt = 0;
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT COUNT(*) AS table_count
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name IN (${REQUIRED_TABLES.map(function () {
|
||||
return '?';
|
||||
}).join(', ')})`,
|
||||
REQUIRED_TABLES
|
||||
);
|
||||
|
||||
if (rows.length && Number(rows[0].table_count) === expectedTableCount) {
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
if (attempt === 0 || attempt % 10 === 0) {
|
||||
console.log('Waiting for database bootstrap...');
|
||||
}
|
||||
}
|
||||
|
||||
attempt += 1;
|
||||
if (attempt % 10 === 0) {
|
||||
console.log(`Still waiting for database bootstrap after ${attempt} attempts.`);
|
||||
}
|
||||
|
||||
await delay(1000);
|
||||
}
|
||||
}
|
||||
|
||||
// Player runtime, media API, and websocket wiring.
|
||||
async function start() {
|
||||
const app = express();
|
||||
const pool = common.createPool();
|
||||
const PORT = Number(process.env.PLAYER_PORT || 3001);
|
||||
const PORT = Number(process.env.PLAYER_PORT || 8081);
|
||||
const 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');
|
||||
const PLAYER_IDENTIFIER_FILE = path.join(MEDIA_DIR, 'player-identity.txt');
|
||||
const DB_SYNC_INTERVAL_MS = Number(process.env.PLAYER_DB_SYNC_INTERVAL_MS || 15000);
|
||||
const explicitPlayerIdentifier = String(process.env.PLAYER_IDENTIFIER || '').trim();
|
||||
const playerIdentifier = explicitPlayerIdentifier || String(loadOrCreatePlayerIdentifier(PLAYER_IDENTIFIER_FILE) || os.hostname() || '').trim();
|
||||
const playerPublicBaseUrl = String(process.env.PLAYER_PUBLIC_BASE_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/+$/, '');
|
||||
const playerInternalBaseUrl = String(process.env.PLAYER_INTERNAL_BASE_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/+$/, '');
|
||||
const onboardingStore = createOnboardingStore(ONBOARDING_QUEUE_FILE);
|
||||
const playerRuntime = createPlayerRuntime({
|
||||
pool: pool,
|
||||
@@ -35,6 +164,8 @@ async function start() {
|
||||
mediaDir: MEDIA_DIR
|
||||
});
|
||||
const server = http.createServer(app);
|
||||
let playerIdentifierLock = null;
|
||||
let syncIntervalId = null;
|
||||
playerRuntime.installWebsocket(server);
|
||||
app.use(express.json());
|
||||
registerPlayerOnboardingRoutes(app, {
|
||||
@@ -42,6 +173,7 @@ async function start() {
|
||||
common: common,
|
||||
playerRuntime: playerRuntime,
|
||||
onboardingStore: onboardingStore,
|
||||
playerIdentifier: playerIdentifier,
|
||||
QRCode: require('qrcode')
|
||||
});
|
||||
registerPlayerRoutes(app, {
|
||||
@@ -60,14 +192,24 @@ async function start() {
|
||||
});
|
||||
|
||||
fs.mkdirSync(MEDIA_DIR, { recursive: true });
|
||||
|
||||
server.listen(PORT, function () {
|
||||
console.log(`Pulse Signage app listening on port ${PORT}`);
|
||||
});
|
||||
await common.bootstrapDatabase(pool, { mediaDir: MEDIA_DIR });
|
||||
|
||||
async function syncDatabaseState() {
|
||||
try {
|
||||
await common.ensureSchema(pool, { mediaDir: MEDIA_DIR });
|
||||
if (playerIdentifier) {
|
||||
await upsertPlayerRegistryEntry(pool, {
|
||||
identifier: playerIdentifier,
|
||||
publicBaseUrl: playerPublicBaseUrl || null,
|
||||
internalBaseUrl: playerInternalBaseUrl || null,
|
||||
hostname: os.hostname(),
|
||||
metadataJson: {
|
||||
instanceId: playerIdentifierLock ? getPlayerIdentifierLockName(playerIdentifier) : null,
|
||||
pid: process.pid,
|
||||
platform: process.platform,
|
||||
nodeVersion: process.version
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (playerRuntime.snapshotAllConnections().length > 0) {
|
||||
await pruneStaleOnboardingDevices(pool);
|
||||
@@ -88,12 +230,66 @@ async function start() {
|
||||
}
|
||||
}
|
||||
|
||||
await waitForDatabaseBootstrap(pool);
|
||||
playerIdentifierLock = await acquirePlayerIdentifierLock(pool, playerIdentifier);
|
||||
if (!playerIdentifierLock) {
|
||||
console.error(`Player identifier "${playerIdentifier}" is already in use by another active player.`);
|
||||
try {
|
||||
await pool.end();
|
||||
} catch (_endError) {
|
||||
// ignore pool shutdown errors
|
||||
}
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
await syncDatabaseState();
|
||||
setInterval(function () {
|
||||
server.listen(PORT, function () {
|
||||
console.log(`Pulse Signage app listening on port ${PORT}`);
|
||||
});
|
||||
syncIntervalId = setInterval(function () {
|
||||
syncDatabaseState().catch(function (error) {
|
||||
console.error(error);
|
||||
});
|
||||
}, DB_SYNC_INTERVAL_MS);
|
||||
|
||||
async function shutdownOnError(error) {
|
||||
console.error(error);
|
||||
if (syncIntervalId) {
|
||||
clearInterval(syncIntervalId);
|
||||
syncIntervalId = null;
|
||||
}
|
||||
|
||||
try {
|
||||
await releasePlayerIdentifierLock(playerIdentifierLock);
|
||||
} catch (_releaseError) {
|
||||
// ignore release errors
|
||||
}
|
||||
|
||||
try {
|
||||
server.close();
|
||||
} catch (_closeError) {
|
||||
// ignore close errors
|
||||
}
|
||||
|
||||
try {
|
||||
await pool.end();
|
||||
} catch (_endError) {
|
||||
// ignore pool shutdown errors
|
||||
}
|
||||
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
process.on('SIGINT', function () {
|
||||
shutdownOnError(new Error('Player stopped.')).catch(function () {
|
||||
process.exit(1);
|
||||
});
|
||||
});
|
||||
process.on('SIGTERM', function () {
|
||||
shutdownOnError(new Error('Player stopped.')).catch(function () {
|
||||
process.exit(1);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { start };
|
||||
|
||||
@@ -11,7 +11,7 @@ function normalizeDeviceId(value) {
|
||||
}
|
||||
|
||||
function getPublicBaseUrl(req) {
|
||||
const configured = String(process.env.PLAYER_PUBLIC_BASE_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, '');
|
||||
const configured = String(process.env.PLAYER_PUBLIC_BASE_URL || process.env.PLAYER_INTERNAL_BASE_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, '');
|
||||
if (configured) {
|
||||
return configured;
|
||||
}
|
||||
@@ -74,8 +74,8 @@ async function getOnboardingStatus(pool, deviceId) {
|
||||
|
||||
const [rows] = await pool.query(
|
||||
`SELECT d.device_id, d.client_name, d.screen_id, s.name AS screen_name, s.slug AS screen_slug, s.playlist_id
|
||||
FROM player_onboarding_devices d
|
||||
LEFT JOIN screens s ON s.id = d.screen_id
|
||||
FROM d_onboarding_devices d
|
||||
LEFT JOIN d_screens s ON s.id = d.screen_id
|
||||
WHERE d.device_id = ?`,
|
||||
[normalizedDeviceId]
|
||||
);
|
||||
@@ -99,7 +99,7 @@ async function commitDeviceBinding(pool, deviceId, clientName, screenSlug, isNam
|
||||
}
|
||||
|
||||
return withClientNameReservation(pool, normalizedClientName, async function () {
|
||||
const [screenRows] = await pool.query('SELECT id, name, slug FROM screens WHERE slug = ?', [normalizedScreenSlug]);
|
||||
const [screenRows] = await pool.query('SELECT id, name, slug FROM d_screens WHERE slug = ?', [normalizedScreenSlug]);
|
||||
if (!screenRows.length) {
|
||||
throw new Error('Screen not found.');
|
||||
}
|
||||
@@ -113,7 +113,7 @@ async function commitDeviceBinding(pool, deviceId, clientName, screenSlug, isNam
|
||||
}
|
||||
|
||||
await pool.query(
|
||||
'INSERT INTO player_onboarding_devices (device_id, client_name, screen_id) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE client_name = VALUES(client_name), screen_id = VALUES(screen_id), modified_at = CURRENT_TIMESTAMP',
|
||||
'INSERT INTO d_onboarding_devices (device_id, client_name, screen_id) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE client_name = VALUES(client_name), screen_id = VALUES(screen_id), modified_at = CURRENT_TIMESTAMP',
|
||||
[normalizedDeviceId, normalizedClientName, screen.id]
|
||||
);
|
||||
|
||||
@@ -157,6 +157,7 @@ function registerPlayerOnboardingRoutes(app, options) {
|
||||
const playerRuntime = options && options.playerRuntime ? options.playerRuntime : null;
|
||||
const QRCode = options && options.QRCode ? options.QRCode : null;
|
||||
const onboardingStore = options && options.onboardingStore ? options.onboardingStore : null;
|
||||
const playerIdentifier = String(options && options.playerIdentifier || '').trim();
|
||||
|
||||
if (!app || !pool || !common || !playerRuntime || !QRCode) {
|
||||
throw new Error('registerPlayerOnboardingRoutes requires app, pool, common, playerRuntime, and QRCode.');
|
||||
@@ -218,7 +219,18 @@ function registerPlayerOnboardingRoutes(app, options) {
|
||||
|
||||
app.get('/api/onboarding/screens', requireOnboardingPageAuth, async function (_req, res, next) {
|
||||
try {
|
||||
const [rows] = await pool.query('SELECT id, name, slug FROM screens ORDER BY name ASC, id ASC');
|
||||
if (!playerIdentifier) {
|
||||
return res.json({ screens: [] });
|
||||
}
|
||||
|
||||
const [rows] = await pool.query(
|
||||
`SELECT s.id, s.name, s.slug
|
||||
FROM d_screens s
|
||||
JOIN d_players p ON p.id = s.player_id
|
||||
WHERE p.identifier = ?
|
||||
ORDER BY s.name ASC, s.id ASC`,
|
||||
[playerIdentifier]
|
||||
);
|
||||
res.json({ screens: rows });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
|
||||
@@ -36,12 +36,21 @@
|
||||
option.textContent = String(screen && (screen.name || screen.slug) ? (screen.name || screen.slug) : "Screen");
|
||||
screenSelect.appendChild(option);
|
||||
});
|
||||
if (!screens.length) {
|
||||
setMessage("This player has no screens assigned yet. Ask an admin to assign one before onboarding.");
|
||||
Array.prototype.slice.call(form.querySelectorAll("input, select, button")).forEach(function (control) {
|
||||
control.disabled = true;
|
||||
});
|
||||
}
|
||||
return screens;
|
||||
});
|
||||
}
|
||||
if (!deviceId) { setMessage("Missing device id. Scan the QR code from the player screen again."); return; }
|
||||
try { window.localStorage.setItem(deviceKey, deviceId); } catch (_error) {}
|
||||
loadScreens().then(function () {
|
||||
if (screenSelect && screenSelect.options && screenSelect.options.length <= 1) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
var storedClientName = window.localStorage.getItem(clientNameKey) || "";
|
||||
var storedScreenSlug = window.localStorage.getItem(screenKey) || "";
|
||||
|
||||
@@ -55,6 +55,14 @@
|
||||
.then(function (payload) {
|
||||
var screens = payload && Array.isArray(payload.screens) ? payload.screens : [];
|
||||
setSelectOptions(localScreenSelect, screens, selectedSlug);
|
||||
if (!screens.length) {
|
||||
setLocalMessage("This player has no screens assigned yet. Ask an admin to assign one before onboarding.");
|
||||
if (localForm) {
|
||||
Array.prototype.slice.call(localForm.querySelectorAll("input, select, button")).forEach(function (control) {
|
||||
control.disabled = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
return screens;
|
||||
})
|
||||
.catch(function () { setSelectOptions(localScreenSelect, [], selectedSlug); return []; });
|
||||
@@ -120,6 +128,9 @@
|
||||
});
|
||||
}
|
||||
loadScreens().then(function () {
|
||||
if (localScreenSelect && localScreenSelect.options && localScreenSelect.options.length <= 1) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
var storedScreenSlug = window.localStorage.getItem(screenKey) || "";
|
||||
var storedClientName = window.localStorage.getItem(clientNameKey) || "";
|
||||
|
||||
+10
-17
@@ -56,7 +56,7 @@ function createPlayerPlaylistService(options) {
|
||||
|
||||
async function buildScreenPlaylist(slug) {
|
||||
try {
|
||||
const [screenRows] = await pool.query('SELECT id, name, slug, playlist_id, created_at, modified_at, created_by, modified_by FROM screens WHERE slug = ?', [slug]);
|
||||
const [screenRows] = await pool.query('SELECT id, name, slug, playlist_id, created_at, modified_at, created_by, modified_by FROM d_screens WHERE slug = ?', [slug]);
|
||||
if (!screenRows.length) {
|
||||
return { screen: null, playlist: null, slides: [], rssFeeds: [], apiSources: [] };
|
||||
}
|
||||
@@ -73,16 +73,16 @@ function createPlayerPlaylistService(options) {
|
||||
return payloadWithoutPlaylist;
|
||||
}
|
||||
|
||||
const [playlistRows] = await pool.query('SELECT id, name, fade_between_slides, skip_unavailable_rtmp, created_at, modified_at, created_by, modified_by FROM playlists WHERE id = ?', [screen.playlist_id]);
|
||||
const [playlistRows] = await pool.query('SELECT id, name, fade_between_slides, skip_unavailable_rtmp, created_at, modified_at, created_by, modified_by FROM c_playlists WHERE id = ?', [screen.playlist_id]);
|
||||
const playlist = playlistRows[0] || null;
|
||||
const [slideRows] = await pool.query(`
|
||||
SELECT sl.id, sl.title, sl.body, sl.template_id, sl.content_json, sl.media_path, sl.media_type, sl.created_at, sl.modified_at,
|
||||
SELECT sl.id, sl.title, sl.template_id, sl.content_json, sl.created_at, sl.modified_at,
|
||||
ps.position, ps.duration_seconds AS duration_seconds, ps.use_video_duration, ps.schedule_mode, ps.schedule_start_datetime, ps.schedule_end_datetime, ps.schedule_start_time, ps.schedule_end_time, ps.schedule_days_json,
|
||||
st.name AS template_name, cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height, cs.width AS canvas_width, cs.height AS canvas_height
|
||||
FROM playlist_slides ps
|
||||
JOIN slides sl ON sl.id = ps.slide_id
|
||||
LEFT JOIN slide_templates st ON st.id = sl.template_id
|
||||
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id
|
||||
FROM c_playlist_slides ps
|
||||
JOIN c_slides sl ON sl.id = ps.slide_id
|
||||
LEFT JOIN c_templates st ON st.id = sl.template_id
|
||||
LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id
|
||||
WHERE ps.playlist_id = ?
|
||||
ORDER BY ps.position ASC, ps.id ASC
|
||||
`, [screen.playlist_id]);
|
||||
@@ -97,11 +97,11 @@ function createPlayerPlaylistService(options) {
|
||||
[templateRows] = await pool.query(`
|
||||
SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, st.background_color, st.created_at, st.modified_at,
|
||||
cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height, cs.width AS canvas_width, cs.height AS canvas_height
|
||||
FROM slide_templates st
|
||||
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id
|
||||
FROM c_templates st
|
||||
LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id
|
||||
WHERE st.id 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 slide_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;
|
||||
@@ -144,7 +144,6 @@ function createPlayerPlaylistService(options) {
|
||||
return {
|
||||
id: slide.id,
|
||||
title: slide.title,
|
||||
body: slide.body,
|
||||
modified_at: slide.modified_at,
|
||||
duration_seconds: videoDuration || storedDuration,
|
||||
use_video_duration: Boolean(slide.use_video_duration),
|
||||
@@ -154,9 +153,6 @@ function createPlayerPlaylistService(options) {
|
||||
schedule_start_time: slide.schedule_start_time,
|
||||
schedule_end_time: slide.schedule_end_time,
|
||||
schedule_days_json: slide.schedule_days_json,
|
||||
media_url: slide.media_path,
|
||||
media_type: slide.media_type,
|
||||
kind: common.mediaKind(slide.media_path),
|
||||
template_id: slide.template_id,
|
||||
template: slide.template_id ? templatesById[slide.template_id] || null : null,
|
||||
content: content
|
||||
@@ -219,11 +215,8 @@ function createPlayerPlaylistService(options) {
|
||||
(Array.isArray(slideRows) ? slideRows : []).forEach(function (slide) {
|
||||
updatePlaylistRevisionHash(hash, slide.id);
|
||||
updatePlaylistRevisionHash(hash, slide.title);
|
||||
updatePlaylistRevisionHash(hash, slide.body);
|
||||
updatePlaylistRevisionHash(hash, slide.template_id);
|
||||
updatePlaylistRevisionHash(hash, slide.content_json);
|
||||
updatePlaylistRevisionHash(hash, slide.media_path);
|
||||
updatePlaylistRevisionHash(hash, slide.media_type);
|
||||
updatePlaylistRevisionHash(hash, slide.modified_at);
|
||||
updatePlaylistRevisionHash(hash, slide.position);
|
||||
updatePlaylistRevisionHash(hash, slide.duration_seconds);
|
||||
|
||||
@@ -511,10 +511,9 @@ function renderMediaSlideMarkup(slide) {
|
||||
|
||||
// Render the shared slide shell around slide-specific inner content.
|
||||
function renderSlideShell(slide, canvasClass, canvasWidth, canvasHeight, innerHtml, canvasStyle) {
|
||||
const body = slide.body ? '<div class="body">' + escapeHtml(slide.body) + '</div>' : '';
|
||||
const className = canvasClass ? 'slide-canvas ' + canvasClass : 'slide-canvas';
|
||||
const style = 'width:' + canvasWidth + ';height:' + canvasHeight + ';' + (canvasStyle || '');
|
||||
return '<div class="slide"><div class="' + className + '" style="' + style + '">' + innerHtml + body + '</div></div>';
|
||||
return '<div class="slide"><div class="' + className + '" style="' + style + '">' + innerHtml + '</div></div>';
|
||||
}
|
||||
|
||||
// Build the cache key for rendered slide markup.
|
||||
|
||||
@@ -279,7 +279,7 @@ function registerPlayerRoutes(app, options) {
|
||||
let screen = null;
|
||||
let screenLookupFailed = false;
|
||||
try {
|
||||
const [screenRows] = await pool.query('SELECT id, name, slug FROM screens WHERE slug = ?', [req.params.slug]);
|
||||
const [screenRows] = await pool.query('SELECT id, name, slug FROM d_screens WHERE slug = ?', [req.params.slug]);
|
||||
screen = screenRows[0] || null;
|
||||
} catch (error) {
|
||||
screenLookupFailed = isTransientDbError(error);
|
||||
@@ -319,7 +319,7 @@ function registerPlayerRoutes(app, options) {
|
||||
let screenLookupFailed = false;
|
||||
if (!isRedirectCommand) {
|
||||
try {
|
||||
const [screenRows] = await pool.query('SELECT id, name, slug FROM screens WHERE slug = ?', [req.params.slug]);
|
||||
const [screenRows] = await pool.query('SELECT id, name, slug FROM d_screens WHERE slug = ?', [req.params.slug]);
|
||||
screen = screenRows[0] || null;
|
||||
} catch (error) {
|
||||
screenLookupFailed = isTransientDbError(error);
|
||||
|
||||
@@ -165,7 +165,6 @@ const PERMISSIONS = PERMISSION_SECTIONS.flatMap(function (section) {
|
||||
});
|
||||
|
||||
const DEFAULT_ROLE = {
|
||||
key: 'administrators',
|
||||
name: 'Administrators',
|
||||
description: 'Full access to the admin interface.'
|
||||
};
|
||||
|
||||
+9
-15
@@ -42,9 +42,6 @@ const {
|
||||
fetchOrderedPlaylistSlides,
|
||||
redirectAfterSave
|
||||
} = require('./web/lib/helpers');
|
||||
const PLAYER_INTERNAL_BASE_URL = (process.env.PLAYER_INTERNAL_BASE_URL || process.env.PLAYER_BASE_URL || 'http://player:3001').replace(/\/$/, '');
|
||||
const PLAYER_PUBLIC_BASE_URL = (process.env.PLAYER_PUBLIC_BASE_URL || process.env.PLAYER_BASE_URL || 'http://localhost:3001').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;
|
||||
@@ -53,7 +50,8 @@ async function start() {
|
||||
const app = express();
|
||||
const server = http.createServer(app);
|
||||
const pool = common.createPool();
|
||||
const PORT = Number(process.env.WEB_PORT || 3000);
|
||||
const PORT = Number(process.env.WEB_PORT || 8080);
|
||||
const DASHBOARD_REFRESH_INTERVAL_MS = 5000;
|
||||
const MEDIA_DIR = path.join(__dirname, '..', 'media');
|
||||
const UPLOADS_DIR = path.join(MEDIA_DIR, 'uploads');
|
||||
const THUMBNAILS_DIR = path.join(MEDIA_DIR, 'thumbnails');
|
||||
@@ -66,7 +64,7 @@ async function start() {
|
||||
});
|
||||
const playerActionService = createPlayerActionService({
|
||||
common: common,
|
||||
playerInternalBaseUrl: PLAYER_INTERNAL_BASE_URL
|
||||
pool: pool
|
||||
});
|
||||
const notifyPlayerScreens = function (slugs, commandOrPayload) {
|
||||
const uniqueSlugs = Array.from(new Set((slugs || []).map(function (slug) {
|
||||
@@ -88,10 +86,8 @@ async function start() {
|
||||
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),
|
||||
dashboardRefreshIntervalMs: DASHBOARD_REFRESH_INTERVAL_MS,
|
||||
formatDashboardDate: formatDashboardDate,
|
||||
notifyPlayerScreens: notifyPlayerScreens,
|
||||
backgroundTaskQueue: backgroundTaskQueue,
|
||||
@@ -118,7 +114,6 @@ async function start() {
|
||||
runMediaSyncTask: runMediaSyncTask,
|
||||
mediaDir: MEDIA_DIR,
|
||||
uploadsDir: UPLOADS_DIR,
|
||||
playerInternalBaseUrl: PLAYER_INTERNAL_BASE_URL,
|
||||
dataSourceStartupRefreshStaggerMs: DATA_SOURCE_STARTUP_REFRESH_STAGGER_MS
|
||||
});
|
||||
const broadcastDashboardState = webBootstrap.broadcastDashboardState;
|
||||
@@ -135,6 +130,11 @@ async function start() {
|
||||
const createUserSession = sessionService.createUserSession;
|
||||
const requireAuth = sessionService.requireAuth;
|
||||
|
||||
// Ensure the database schema exists before any middleware queries it.
|
||||
await common.bootstrapDatabase(pool, { mediaDir: MEDIA_DIR });
|
||||
await backgroundTaskQueue.initialize();
|
||||
await backgroundTaskSetup.initialize();
|
||||
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
app.use(express.json());
|
||||
app.use('/assets', express.static(ASSET_DIR));
|
||||
@@ -224,7 +224,6 @@ async function start() {
|
||||
getScreenConnections: playerActionService.getScreenConnections,
|
||||
getPlaylistDeleteBlockMessage: playerActionService.getPlaylistDeleteBlockMessage,
|
||||
forwardPlayerCommand: playerActionService.forwardPlayerCommand,
|
||||
playerPublicBaseUrl: PLAYER_PUBLIC_BASE_URL,
|
||||
requirePermission: requirePermission
|
||||
});
|
||||
|
||||
@@ -342,11 +341,6 @@ 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 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);
|
||||
|
||||
Vendored
+21
-10
@@ -3,11 +3,13 @@ const { createDashboardStateService } = require('./lib/dashboard-state');
|
||||
const { createUploadSyncService } = require('./lib/upload-sync');
|
||||
const { createRequestAuthHeaders } = require('../request-auth');
|
||||
|
||||
function normalizeBaseUrl(value) {
|
||||
return String(value || '').trim().replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function createWebBootstrap(options) {
|
||||
const pool = options && options.pool;
|
||||
const common = options && options.common;
|
||||
const playerInternalBaseUrl = 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 formatDashboardDate = options && options.formatDashboardDate;
|
||||
@@ -22,11 +24,16 @@ function createWebBootstrap(options) {
|
||||
const dashboardClients = new Set();
|
||||
const playerSnapshotCache = new Map();
|
||||
const playerSnapshotSockets = new Map();
|
||||
const playerSnapshotBaseUrls = new Map();
|
||||
let dashboardRefreshInFlight = null;
|
||||
let broadcastDashboardState = null;
|
||||
|
||||
function getPlayerSnapshotSocketUrl(slug) {
|
||||
const url = new URL(playerInternalBaseUrl.replace(/^http/, 'ws'));
|
||||
function getPlayerSnapshotSocketUrl(slug, baseUrl) {
|
||||
const resolvedBaseUrl = normalizeBaseUrl(baseUrl);
|
||||
if (!resolvedBaseUrl) {
|
||||
return '';
|
||||
}
|
||||
const url = new URL(resolvedBaseUrl.replace(/^http/, 'ws'));
|
||||
url.pathname = `/ws/screens/${encodeURIComponent(slug)}/events`;
|
||||
url.search = '';
|
||||
return url.toString();
|
||||
@@ -47,13 +54,19 @@ function createWebBootstrap(options) {
|
||||
playerSnapshotSockets.delete(key);
|
||||
}
|
||||
|
||||
function ensurePlayerSnapshotSubscription(slug) {
|
||||
function ensurePlayerSnapshotSubscription(slug, baseUrl) {
|
||||
const key = String(slug || '').trim();
|
||||
if (!key || playerSnapshotSockets.has(key)) {
|
||||
const resolvedBaseUrl = normalizeBaseUrl(baseUrl) || normalizeBaseUrl(playerSnapshotBaseUrls.get(key));
|
||||
if (!key || !resolvedBaseUrl || playerSnapshotSockets.has(key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const socketUrl = getPlayerSnapshotSocketUrl(key);
|
||||
playerSnapshotBaseUrls.set(key, resolvedBaseUrl);
|
||||
|
||||
const socketUrl = getPlayerSnapshotSocketUrl(key, resolvedBaseUrl);
|
||||
if (!socketUrl) {
|
||||
return;
|
||||
}
|
||||
const authHeaders = createRequestAuthHeaders({
|
||||
method: 'GET',
|
||||
pathname: `/ws/screens/${encodeURIComponent(key)}/events`
|
||||
@@ -83,7 +96,7 @@ function createWebBootstrap(options) {
|
||||
socket.onclose = function () {
|
||||
clearPlayerSnapshotSocket(key);
|
||||
setTimeout(function () {
|
||||
ensurePlayerSnapshotSubscription(key);
|
||||
ensurePlayerSnapshotSubscription(key, playerSnapshotBaseUrls.get(key));
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
@@ -102,7 +115,6 @@ function createWebBootstrap(options) {
|
||||
playerSnapshotCache: playerSnapshotCache,
|
||||
playerSnapshotSockets: playerSnapshotSockets,
|
||||
ensurePlayerSnapshotSubscription: ensurePlayerSnapshotSubscription,
|
||||
playerPublicBaseUrl: playerPublicBaseUrl,
|
||||
formatDashboardDate: formatDashboardDate
|
||||
});
|
||||
const buildDashboardState = dashboardStateService.buildDashboardState;
|
||||
@@ -110,7 +122,6 @@ function createWebBootstrap(options) {
|
||||
const uploadSyncService = createUploadSyncService({
|
||||
pool: pool,
|
||||
common: common,
|
||||
playerInternalBaseUrl: playerInternalBaseUrl,
|
||||
playerSnapshotCache: playerSnapshotCache,
|
||||
notifyPlayerScreens: notifyPlayerScreens,
|
||||
backgroundTaskQueue: backgroundTaskQueue
|
||||
|
||||
@@ -7,12 +7,25 @@ function registerBackgroundTaskHandlers(options) {
|
||||
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) {
|
||||
if (!pool || !common || !backgroundTaskQueue || !captureSlideThumbnail || !refreshApiSource || !refreshRssFeed || !runMediaSyncTask || !mediaDir) {
|
||||
throw new Error('registerBackgroundTaskHandlers requires the background task dependencies.');
|
||||
}
|
||||
|
||||
async function resolveThumbnailBaseUrl() {
|
||||
const playersData = await common.fetchPlayersSelectionData(pool);
|
||||
const players = Array.isArray(playersData && playersData.players) ? playersData.players : [];
|
||||
const selectedPlayer = players.find(function (player) {
|
||||
return String(player && player.internal_base_url || '').trim() || String(player && player.public_base_url || '').trim();
|
||||
});
|
||||
|
||||
if (!selectedPlayer) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return String(selectedPlayer.internal_base_url || selectedPlayer.public_base_url || '').trim().replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
backgroundTaskQueue.setTaskHandler('media-sync', function (task) {
|
||||
return runMediaSyncTask(task && task.payload ? task.payload : task);
|
||||
});
|
||||
@@ -48,11 +61,16 @@ function registerBackgroundTaskHandlers(options) {
|
||||
throw new Error('Slide id is required.');
|
||||
}
|
||||
|
||||
const baseUrl = await resolveThumbnailBaseUrl();
|
||||
if (!baseUrl) {
|
||||
throw new Error('No player is available to render slide thumbnails.');
|
||||
}
|
||||
|
||||
return captureSlideThumbnail({
|
||||
pool: pool,
|
||||
common: common,
|
||||
mediaDir: mediaDir,
|
||||
baseUrl: playerInternalBaseUrl,
|
||||
baseUrl: baseUrl,
|
||||
slideId: slideId,
|
||||
previousThumbnailPath: payload.previousThumbnailPath || null
|
||||
});
|
||||
@@ -66,7 +84,7 @@ function registerBackgroundTaskHandlers(options) {
|
||||
}
|
||||
|
||||
const [slides] = await pool.query(
|
||||
'SELECT id, thumbnail_path FROM slides WHERE template_id = ? ORDER BY id ASC',
|
||||
'SELECT id, thumbnail_path FROM c_slides WHERE template_id = ? ORDER BY id ASC',
|
||||
[templateId]
|
||||
);
|
||||
|
||||
@@ -76,11 +94,16 @@ function registerBackgroundTaskHandlers(options) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const baseUrl = await resolveThumbnailBaseUrl();
|
||||
if (!baseUrl) {
|
||||
throw new Error('No player is available to render template thumbnails.');
|
||||
}
|
||||
|
||||
await captureSlideThumbnail({
|
||||
pool: pool,
|
||||
common: common,
|
||||
mediaDir: mediaDir,
|
||||
baseUrl: playerInternalBaseUrl,
|
||||
baseUrl: baseUrl,
|
||||
slideId: slideId,
|
||||
previousThumbnailPath: slide && slide.thumbnail_path ? slide.thumbnail_path : null
|
||||
});
|
||||
|
||||
@@ -11,6 +11,21 @@ function toIsoDate(value) {
|
||||
return Number.isNaN(date.getTime()) ? '' : date.toISOString();
|
||||
}
|
||||
|
||||
function getTaskPlayerLabel(metadata) {
|
||||
const taskMetadata = metadata && typeof metadata === 'object' ? metadata : {};
|
||||
const explicitLabel = String(taskMetadata.affectsPlayerLabel || taskMetadata.playerLabel || taskMetadata.playerName || taskMetadata.playerIdentifier || '').trim();
|
||||
if (explicitLabel) {
|
||||
return explicitLabel;
|
||||
}
|
||||
|
||||
const playerId = Number(taskMetadata.playerId || 0);
|
||||
if (Number.isFinite(playerId) && playerId > 0) {
|
||||
return 'Player #' + playerId;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function normalizeIntervalMs(value, unit) {
|
||||
const numericValue = Math.max(1, Number(value) || 0);
|
||||
const normalizedUnit = String(unit || 'minutes').trim().toLowerCase();
|
||||
@@ -81,12 +96,14 @@ function createBackgroundTaskQueue(options) {
|
||||
category: task.category,
|
||||
status: task.status,
|
||||
createdAt: task.createdAt,
|
||||
createdBy: task.createdBy,
|
||||
startedAt: task.startedAt,
|
||||
finishedAt: task.finishedAt,
|
||||
errorMessage: task.errorMessage,
|
||||
attempts: task.attempts || 0,
|
||||
metadata: task.metadata,
|
||||
payload: task.payload || null
|
||||
payload: task.payload || null,
|
||||
affectsPlayerLabel: getTaskPlayerLabel(task.metadata)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -108,6 +125,7 @@ function createBackgroundTaskQueue(options) {
|
||||
category: String(row.category || 'general').trim() || 'general',
|
||||
status: String(row.status || 'queued').trim() || 'queued',
|
||||
createdAt: row.created_at ? new Date(row.created_at).toISOString() : '',
|
||||
createdBy: row.created_by === null || row.created_by === undefined ? null : Number(row.created_by),
|
||||
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 || ''),
|
||||
@@ -141,6 +159,7 @@ function createBackgroundTaskQueue(options) {
|
||||
metadata_json: stringifyJsonValue(task.metadata),
|
||||
attempts: Math.max(0, Number(task.attempts) || 0),
|
||||
created_at: task.createdAt ? new Date(task.createdAt) : new Date(),
|
||||
created_by: task.createdBy === null || task.createdBy === undefined ? null : Number(task.createdBy),
|
||||
started_at: task.startedAt ? new Date(task.startedAt) : null,
|
||||
finished_at: task.finishedAt ? new Date(task.finishedAt) : null,
|
||||
error_message: task.errorMessage || null
|
||||
@@ -154,7 +173,7 @@ function createBackgroundTaskQueue(options) {
|
||||
|
||||
const record = buildTaskRecord(task);
|
||||
const [result] = await pool.query(
|
||||
`INSERT INTO background_tasks (
|
||||
`INSERT INTO o_background_tasks (
|
||||
task_key,
|
||||
task_type,
|
||||
title,
|
||||
@@ -164,10 +183,11 @@ function createBackgroundTaskQueue(options) {
|
||||
metadata_json,
|
||||
attempts,
|
||||
created_at,
|
||||
created_by,
|
||||
started_at,
|
||||
finished_at,
|
||||
error_message
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` ,
|
||||
[
|
||||
record.task_key,
|
||||
record.task_type,
|
||||
@@ -178,6 +198,7 @@ function createBackgroundTaskQueue(options) {
|
||||
record.metadata_json,
|
||||
record.attempts,
|
||||
record.created_at,
|
||||
record.created_by,
|
||||
record.started_at,
|
||||
record.finished_at,
|
||||
record.error_message
|
||||
@@ -196,7 +217,7 @@ function createBackgroundTaskQueue(options) {
|
||||
|
||||
const record = buildTaskRecord(task);
|
||||
await pool.query(
|
||||
`UPDATE background_tasks
|
||||
`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 = ?`,
|
||||
[
|
||||
@@ -221,7 +242,7 @@ function createBackgroundTaskQueue(options) {
|
||||
return;
|
||||
}
|
||||
|
||||
await pool.query('DELETE FROM background_tasks WHERE id = ?', [taskId]);
|
||||
await pool.query('DELETE FROM o_background_tasks WHERE id = ?', [taskId]);
|
||||
}
|
||||
|
||||
async function initialize() {
|
||||
@@ -235,7 +256,7 @@ function createBackgroundTaskQueue(options) {
|
||||
}
|
||||
|
||||
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 background_tasks ORDER BY id ASC'
|
||||
'SELECT id, task_key, task_type, title, category, status, payload_json, metadata_json, attempts, created_at, created_by, started_at, finished_at, error_message FROM o_background_tasks ORDER BY id ASC'
|
||||
);
|
||||
|
||||
let highestTaskId = 0;
|
||||
@@ -254,7 +275,7 @@ function createBackgroundTaskQueue(options) {
|
||||
task.finishedAt = '';
|
||||
task.errorMessage = '';
|
||||
await pool.query(
|
||||
'UPDATE background_tasks SET status = ?, started_at = NULL, finished_at = NULL, error_message = NULL WHERE id = ?',
|
||||
'UPDATE o_background_tasks SET status = ?, started_at = NULL, finished_at = NULL, error_message = NULL WHERE id = ?',
|
||||
['queued', task.id]
|
||||
);
|
||||
}
|
||||
@@ -606,7 +627,8 @@ function createBackgroundTaskQueue(options) {
|
||||
lastRunAt: job.lastRunAt || '',
|
||||
lastStatus: job.lastStatus || '',
|
||||
lastError: job.lastError || '',
|
||||
metadata: job.metadata || {}
|
||||
metadata: job.metadata || {},
|
||||
affectsPlayerLabel: getTaskPlayerLabel(job.metadata)
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
const { WebSocket } = require('ws');
|
||||
|
||||
function normalizeClientName(value) {
|
||||
return String(value || '').trim();
|
||||
}
|
||||
|
||||
function resolveScreenUrl(screen) {
|
||||
const baseUrl = String(screen && screen.player_public_base_url || '').trim().replace(/\/+$/, '');
|
||||
const slug = String(screen && screen.slug || '').trim();
|
||||
if (!baseUrl || !slug) {
|
||||
return baseUrl;
|
||||
}
|
||||
|
||||
return `${baseUrl}/screen/${encodeURIComponent(slug)}`;
|
||||
}
|
||||
|
||||
function enrichScreensWithConnections(screens, connectionsBySlug, onboardingNameBySlug) {
|
||||
return (screens || []).map(function (screen) {
|
||||
const connectionState = connectionsBySlug[screen.slug] || { count: 0, connections: [] };
|
||||
@@ -15,7 +23,7 @@ function enrichScreensWithConnections(screens, connectionsBySlug, onboardingName
|
||||
});
|
||||
}
|
||||
|
||||
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 +35,9 @@ 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: resolveScreenUrl(screen),
|
||||
player_label: screen.player_label || null,
|
||||
player_online: Boolean(screen.player_online)
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -51,7 +61,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') {
|
||||
@@ -60,15 +69,20 @@ function createDashboardStateService(options) {
|
||||
|
||||
async function buildDashboardState() {
|
||||
const data = await common.fetchAdminData(pool);
|
||||
const screensData = data.screens || [];
|
||||
screensData.forEach(function (screen) {
|
||||
ensurePlayerSnapshotSubscription(screen.slug);
|
||||
const [playerCountRows] = await pool.query(`
|
||||
SELECT COUNT(*) AS connected_players_count
|
||||
FROM d_players p
|
||||
WHERE p.last_seen_at IS NOT NULL
|
||||
AND p.last_seen_at >= (CURRENT_TIMESTAMP - INTERVAL 1 MINUTE)
|
||||
`);
|
||||
const connectedPlayersCount = Number(playerCountRows && playerCountRows[0] && playerCountRows[0].connected_players_count || 0);
|
||||
(data.screens || []).forEach(function (screen) {
|
||||
ensurePlayerSnapshotSubscription(screen.slug, screen.player_internal_base_url);
|
||||
});
|
||||
|
||||
const [onboardingRows] = await pool.query(
|
||||
`SELECT s.slug, pod.device_id, pod.client_name
|
||||
FROM player_onboarding_devices pod
|
||||
JOIN screens s ON s.id = pod.screen_id
|
||||
FROM d_onboarding_devices pod
|
||||
JOIN d_screens s ON s.id = pod.screen_id
|
||||
WHERE pod.client_name IS NOT NULL
|
||||
AND TRIM(pod.client_name) <> ''`
|
||||
);
|
||||
@@ -87,7 +101,7 @@ function createDashboardStateService(options) {
|
||||
});
|
||||
|
||||
const connectionsBySlug = {};
|
||||
screensData.forEach(function (screen) {
|
||||
(data.screens || []).forEach(function (screen) {
|
||||
const cached = playerSnapshotCache.get(String(screen.slug || '').trim());
|
||||
if (cached && Array.isArray(cached.connections)) {
|
||||
connectionsBySlug[screen.slug] = cached;
|
||||
@@ -97,24 +111,24 @@ function createDashboardStateService(options) {
|
||||
const screens = enrichScreensWithConnections(data.screens || [], connectionsBySlug, onboardingNameBySlug)
|
||||
.map(function (screen) {
|
||||
return Object.assign({}, screen, {
|
||||
player_url: `${playerPublicBaseUrl}/screen/${encodeURIComponent(screen.slug)}`
|
||||
player_url: resolveScreenUrl(screen),
|
||||
player_public_base_url: screen.player_public_base_url || null,
|
||||
player_internal_base_url: screen.player_internal_base_url || null
|
||||
});
|
||||
})
|
||||
.sort(compareScreenNames);
|
||||
const clients = buildClientRows(screens, connectionsBySlug, onboardingNameBySlug, onboardingNameByDeviceId, playerPublicBaseUrl, formatDashboardDate);
|
||||
const playerServiceConnected = Array.from(playerSnapshotSockets.values()).some(function (socket) {
|
||||
return socket && socket.readyState === WebSocket.OPEN;
|
||||
});
|
||||
const clients = buildClientRows(screens, connectionsBySlug, onboardingNameBySlug, onboardingNameByDeviceId, formatDashboardDate);
|
||||
|
||||
return {
|
||||
playlists: data.playlists || [],
|
||||
screens: screens,
|
||||
clients: clients,
|
||||
slides: data.slides || [],
|
||||
playerServiceConnected: playerServiceConnected,
|
||||
connectedClientsCount: screens.reduce(function (total, screen) {
|
||||
return total + Number(screen.player_connection_count || 0);
|
||||
}, 0)
|
||||
playerServiceConnected: connectedPlayersCount > 0 || Array.from(playerSnapshotSockets.values()).some(function (socket) {
|
||||
return socket && socket.readyState === 1;
|
||||
}),
|
||||
connectedClientsCount: clients.length,
|
||||
connectedPlayersCount: connectedPlayersCount
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ async function refreshApiSource(pool, common, apiSourceId, apiUrl, actorId) {
|
||||
|
||||
await connection.beginTransaction();
|
||||
await connection.query(
|
||||
'UPDATE api_sources SET last_pulled_at = ?, last_pull_error = ?, last_response_status = ?, last_response_content_type = ?, last_response_json = ?, modified_by = ? WHERE id = ?',
|
||||
'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]
|
||||
);
|
||||
await connection.commit();
|
||||
|
||||
+11
-11
@@ -91,10 +91,10 @@ function getCanvasSignature(width, height) {
|
||||
async function fetchPlaylistCanvasSignature(pool, playlistId) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT DISTINCT cs.width AS canvas_width, cs.height AS canvas_height
|
||||
FROM playlist_slides ps
|
||||
JOIN slides sl ON sl.id = ps.slide_id
|
||||
LEFT JOIN slide_templates st ON st.id = sl.template_id
|
||||
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id
|
||||
FROM c_playlist_slides ps
|
||||
JOIN c_slides sl ON sl.id = ps.slide_id
|
||||
LEFT JOIN c_templates st ON st.id = sl.template_id
|
||||
LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id
|
||||
WHERE ps.playlist_id = ?
|
||||
AND cs.width IS NOT NULL
|
||||
AND cs.height IS NOT NULL`,
|
||||
@@ -111,7 +111,7 @@ async function fetchPlaylistCanvasSignature(pool, playlistId) {
|
||||
|
||||
async function fetchScreensByPlaylistId(connection, playlistId) {
|
||||
const [rows] = await connection.query(
|
||||
'SELECT slug FROM screens WHERE playlist_id = ? AND slug IS NOT NULL',
|
||||
'SELECT slug FROM d_screens WHERE playlist_id = ? AND slug IS NOT NULL',
|
||||
[playlistId]
|
||||
);
|
||||
return rows.map(function (row) {
|
||||
@@ -122,8 +122,8 @@ async function fetchScreensByPlaylistId(connection, playlistId) {
|
||||
async function fetchScreensBySlideId(connection, slideId) {
|
||||
const [rows] = await connection.query(
|
||||
`SELECT DISTINCT s.slug
|
||||
FROM screens s
|
||||
JOIN playlist_slides ps ON ps.playlist_id = s.playlist_id
|
||||
FROM d_screens s
|
||||
JOIN c_playlist_slides ps ON ps.playlist_id = s.playlist_id
|
||||
WHERE ps.slide_id = ?
|
||||
AND s.slug IS NOT NULL`,
|
||||
[slideId]
|
||||
@@ -136,9 +136,9 @@ async function fetchScreensBySlideId(connection, slideId) {
|
||||
async function fetchScreensByTemplateId(connection, templateId) {
|
||||
const [rows] = await connection.query(
|
||||
`SELECT DISTINCT s.slug
|
||||
FROM screens s
|
||||
JOIN playlist_slides ps ON ps.playlist_id = s.playlist_id
|
||||
JOIN slides sl ON sl.id = ps.slide_id
|
||||
FROM d_screens s
|
||||
JOIN c_playlist_slides ps ON ps.playlist_id = s.playlist_id
|
||||
JOIN c_slides sl ON sl.id = ps.slide_id
|
||||
WHERE sl.template_id = ?
|
||||
AND s.slug IS NOT NULL`,
|
||||
[templateId]
|
||||
@@ -150,7 +150,7 @@ async function fetchScreensByTemplateId(connection, templateId) {
|
||||
|
||||
async function fetchOrderedPlaylistSlides(connection, playlistId) {
|
||||
const [rows] = await connection.query(
|
||||
'SELECT id, position FROM playlist_slides WHERE playlist_id = ? ORDER BY position ASC, id ASC',
|
||||
'SELECT id, position FROM c_playlist_slides WHERE playlist_id = ? ORDER BY position ASC, id ASC',
|
||||
[playlistId]
|
||||
);
|
||||
return rows;
|
||||
|
||||
@@ -3,6 +3,18 @@ function parsePageNumber(value) {
|
||||
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,6 +93,9 @@ function createSearchMatcher(searchTerm, fields) {
|
||||
|
||||
module.exports = {
|
||||
parsePageNumber: parsePageNumber,
|
||||
getSearchQuery: getSearchQuery,
|
||||
getSortQuery: getSortQuery,
|
||||
getSortDirectionQuery: getSortDirectionQuery,
|
||||
normalizeSortDirection: normalizeSortDirection,
|
||||
getComparableSortValue: getComparableSortValue,
|
||||
compareSortValues: compareSortValues,
|
||||
|
||||
@@ -1,14 +1,40 @@
|
||||
const { createRequestAuthHeaders } = require('../../request-auth');
|
||||
|
||||
function createPlayerActionService(options) {
|
||||
const playerInternalBaseUrl = String(options && options.playerInternalBaseUrl || '').replace(/\/$/, '');
|
||||
const pool = options && options.pool ? options.pool : null;
|
||||
const common = options && options.common;
|
||||
|
||||
if (!playerInternalBaseUrl || !common) {
|
||||
if (!common) {
|
||||
throw new Error('createPlayerActionService requires the player action dependencies.');
|
||||
}
|
||||
|
||||
async function resolvePlayerInternalBaseUrl(slug) {
|
||||
const normalizedSlug = String(slug || '').trim();
|
||||
if (!pool || !normalizedSlug) {
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT pl.internal_base_url
|
||||
FROM d_screens s
|
||||
LEFT JOIN d_players pl ON pl.id = s.player_id
|
||||
WHERE s.slug = ?
|
||||
LIMIT 1`,
|
||||
[normalizedSlug]
|
||||
);
|
||||
const resolved = String(rows[0] && rows[0].internal_base_url || '').trim().replace(/\/+$/, '');
|
||||
return resolved;
|
||||
} catch (_error) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
async function forwardPlayerCommand(slug, commandOrPayload, connectionId) {
|
||||
const resolvedPlayerBaseUrl = await resolvePlayerInternalBaseUrl(slug);
|
||||
if (!resolvedPlayerBaseUrl) {
|
||||
throw new Error(`No player is assigned to screen ${slug}.`);
|
||||
}
|
||||
const payload = typeof commandOrPayload === 'object' && commandOrPayload !== null
|
||||
? Object.assign({}, commandOrPayload)
|
||||
: { command: commandOrPayload };
|
||||
@@ -20,7 +46,7 @@ function createPlayerActionService(options) {
|
||||
pathname: `/api/screens/${encodeURIComponent(slug)}/commands`,
|
||||
body: payload
|
||||
});
|
||||
const response = await fetch(`${playerInternalBaseUrl}/api/screens/${encodeURIComponent(slug)}/commands`, {
|
||||
const response = await fetch(`${resolvedPlayerBaseUrl}/api/screens/${encodeURIComponent(slug)}/commands`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -43,11 +69,15 @@ function createPlayerActionService(options) {
|
||||
}
|
||||
|
||||
async function getScreenConnections(slug) {
|
||||
const resolvedPlayerBaseUrl = await resolvePlayerInternalBaseUrl(slug);
|
||||
if (!resolvedPlayerBaseUrl) {
|
||||
return { connections: [] };
|
||||
}
|
||||
const authHeaders = createRequestAuthHeaders({
|
||||
method: 'GET',
|
||||
pathname: `/api/screens/${encodeURIComponent(slug)}/connections`
|
||||
});
|
||||
const response = await fetch(`${playerInternalBaseUrl}/api/screens/${encodeURIComponent(slug)}/connections`, {
|
||||
const response = await fetch(`${resolvedPlayerBaseUrl}/api/screens/${encodeURIComponent(slug)}/connections`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
@@ -68,7 +98,7 @@ function createPlayerActionService(options) {
|
||||
}
|
||||
|
||||
async function getScreenDeleteBlockMessage(pool, screen, getScreenConnections) {
|
||||
const [rows] = await pool.query('SELECT COUNT(*) AS ref_count FROM player_onboarding_devices WHERE screen_id = ?', [screen.id]);
|
||||
const [rows] = await pool.query('SELECT COUNT(*) AS ref_count FROM d_onboarding_devices WHERE screen_id = ?', [screen.id]);
|
||||
if (Number(rows[0] && rows[0].ref_count) > 0) {
|
||||
return 'This screen is still linked to onboarding devices.';
|
||||
}
|
||||
@@ -89,22 +119,22 @@ function createPlayerActionService(options) {
|
||||
}
|
||||
|
||||
async function getSlideDeleteBlockMessage(pool, slide) {
|
||||
const [rows] = await pool.query('SELECT COUNT(*) AS ref_count FROM playlist_slides WHERE slide_id = ?', [slide.id]);
|
||||
const [rows] = await pool.query('SELECT COUNT(*) AS ref_count FROM c_playlist_slides WHERE slide_id = ?', [slide.id]);
|
||||
return Number(rows[0] && rows[0].ref_count) > 0 ? 'This slide is still used by one or more playlists.' : '';
|
||||
}
|
||||
|
||||
async function getTemplateDeleteBlockMessage(pool, template) {
|
||||
const [rows] = await pool.query('SELECT COUNT(*) AS ref_count FROM slides WHERE template_id = ?', [template.id]);
|
||||
const [rows] = await pool.query('SELECT COUNT(*) AS ref_count FROM c_slides WHERE template_id = ?', [template.id]);
|
||||
return Number(rows[0] && rows[0].ref_count) > 0 ? 'This template is still used by one or more slides.' : '';
|
||||
}
|
||||
|
||||
async function getCanvasSizeDeleteBlockMessage(pool, canvasSize) {
|
||||
const [rows] = await pool.query('SELECT COUNT(*) AS ref_count FROM slide_templates WHERE canvas_size_id = ?', [canvasSize.id]);
|
||||
const [rows] = await pool.query('SELECT COUNT(*) AS ref_count FROM c_templates WHERE canvas_size_id = ?', [canvasSize.id]);
|
||||
return Number(rows[0] && rows[0].ref_count) > 0 ? 'This canvas size is still used by one or more templates.' : '';
|
||||
}
|
||||
|
||||
async function getPlaylistDeleteBlockMessage(pool, playlist) {
|
||||
const [rows] = await pool.query('SELECT COUNT(*) AS ref_count FROM screens WHERE playlist_id = ?', [playlist.id]);
|
||||
const [rows] = await pool.query('SELECT COUNT(*) AS ref_count FROM d_screens WHERE playlist_id = ?', [playlist.id]);
|
||||
return Number(rows[0] && rows[0].ref_count) > 0 ? 'This playlist is still assigned to one or more screens.' : '';
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
function normalizeBaseUrl(value) {
|
||||
return String(value || '').trim().replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function resolveScreenBaseUrl(screen, fieldName, fallbackBaseUrl) {
|
||||
const candidate = screen && typeof screen === 'object' ? screen[fieldName] : null;
|
||||
const normalizedCandidate = normalizeBaseUrl(candidate);
|
||||
if (normalizedCandidate) {
|
||||
return normalizedCandidate;
|
||||
}
|
||||
return normalizeBaseUrl(fallbackBaseUrl);
|
||||
}
|
||||
|
||||
function resolveScreenPlayerUrl(screenOrSlug, fallbackBaseUrl) {
|
||||
const screen = screenOrSlug && typeof screenOrSlug === 'object'
|
||||
? screenOrSlug
|
||||
: { slug: screenOrSlug };
|
||||
const baseUrl = resolveScreenBaseUrl(screen, 'player_public_base_url', fallbackBaseUrl);
|
||||
const slug = String(screen && screen.slug || '').trim();
|
||||
if (!baseUrl || !slug) {
|
||||
return '';
|
||||
}
|
||||
return `${baseUrl}/screen/${encodeURIComponent(slug)}`;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
normalizeBaseUrl: normalizeBaseUrl,
|
||||
resolveScreenBaseUrl: resolveScreenBaseUrl,
|
||||
resolveScreenPlayerUrl: resolveScreenPlayerUrl
|
||||
};
|
||||
+53
-39
@@ -13,16 +13,29 @@ function parseCsvIds(value) {
|
||||
}
|
||||
|
||||
async function fetchPermissions(pool) {
|
||||
const [rows] = await pool.query('SELECT id, permission_key, name, section_name, description FROM permissions ORDER BY section_name ASC, name ASC');
|
||||
const [rows] = await pool.query('SELECT id, permission_key, name, section_name, description FROM a_permissions ORDER BY section_name ASC, name ASC');
|
||||
return rows || [];
|
||||
}
|
||||
|
||||
async function fetchRoleByName(pool, roleName) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT r.id, r.name, r.description, r.created_at, r.modified_at,
|
||||
(SELECT COUNT(*) FROM a_user_roles ur WHERE ur.role_id = r.id) AS user_count,
|
||||
(SELECT COUNT(*) FROM a_role_permissions rp WHERE rp.role_id = r.id) AS permission_count
|
||||
FROM a_roles r
|
||||
WHERE r.name = ?
|
||||
LIMIT 1`,
|
||||
[roleName]
|
||||
);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
async function fetchRoles(pool) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT r.id, r.role_key, r.name, r.description, r.created_at, r.modified_at,
|
||||
(SELECT COUNT(*) FROM user_roles ur WHERE ur.role_id = r.id) AS user_count,
|
||||
(SELECT COUNT(*) FROM role_permissions rp WHERE rp.role_id = r.id) AS permission_count
|
||||
FROM roles r
|
||||
`SELECT r.id, r.name, r.description, r.created_at, r.modified_at,
|
||||
(SELECT COUNT(*) FROM a_user_roles ur WHERE ur.role_id = r.id) AS user_count,
|
||||
(SELECT COUNT(*) FROM a_role_permissions rp WHERE rp.role_id = r.id) AS permission_count
|
||||
FROM a_roles r
|
||||
ORDER BY r.name ASC`
|
||||
);
|
||||
return rows || [];
|
||||
@@ -30,13 +43,13 @@ async function fetchRoles(pool) {
|
||||
|
||||
async function fetchRolesPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
|
||||
const paged = await fetchPagedRows(pool, {
|
||||
selectSql: `SELECT r.id, r.role_key, r.name, r.description, r.created_at, r.modified_at,
|
||||
(SELECT COUNT(*) FROM user_roles ur WHERE ur.role_id = r.id) AS user_count,
|
||||
(SELECT COUNT(*) FROM role_permissions rp WHERE rp.role_id = r.id) AS permission_count
|
||||
FROM roles r
|
||||
selectSql: `SELECT r.id, r.name, r.description, r.created_at, r.modified_at,
|
||||
(SELECT COUNT(*) FROM a_user_roles ur WHERE ur.role_id = r.id) AS user_count,
|
||||
(SELECT COUNT(*) FROM a_role_permissions rp WHERE rp.role_id = r.id) AS permission_count
|
||||
FROM a_roles r
|
||||
ORDER BY r.name ASC`,
|
||||
countSql: 'SELECT COUNT(*) AS count FROM roles',
|
||||
searchColumns: ['r.role_key', 'r.name', 'r.description'],
|
||||
countSql: 'SELECT COUNT(*) AS count FROM a_roles',
|
||||
searchColumns: ['r.name', 'r.description'],
|
||||
searchTerm: searchTerm,
|
||||
sortColumns: {
|
||||
role: 'r.name',
|
||||
@@ -57,10 +70,10 @@ async function fetchRolesPage(pool, page, pageSize, searchTerm, sortKey, sortDir
|
||||
|
||||
async function fetchRoleById(pool, roleId) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT r.id, r.role_key, r.name, r.description, r.created_at, r.modified_at,
|
||||
(SELECT COUNT(*) FROM user_roles ur WHERE ur.role_id = r.id) AS user_count,
|
||||
(SELECT COUNT(*) FROM role_permissions rp WHERE rp.role_id = r.id) AS permission_count
|
||||
FROM roles r
|
||||
`SELECT r.id, r.name, r.description, r.created_at, r.modified_at,
|
||||
(SELECT COUNT(*) FROM a_user_roles ur WHERE ur.role_id = r.id) AS user_count,
|
||||
(SELECT COUNT(*) FROM a_role_permissions rp WHERE rp.role_id = r.id) AS permission_count
|
||||
FROM a_roles r
|
||||
WHERE r.id = ?
|
||||
LIMIT 1`,
|
||||
[roleId]
|
||||
@@ -71,8 +84,8 @@ async function fetchRoleById(pool, roleId) {
|
||||
async function fetchRolePermissionKeys(pool, roleId) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT p.permission_key
|
||||
FROM role_permissions rp
|
||||
JOIN permissions p ON p.id = rp.permission_id
|
||||
FROM a_role_permissions rp
|
||||
JOIN a_permissions p ON p.id = rp.permission_id
|
||||
WHERE rp.role_id = ?
|
||||
ORDER BY p.section_name ASC, p.name ASC`,
|
||||
[roleId]
|
||||
@@ -85,7 +98,7 @@ async function fetchRolePermissionKeys(pool, roleId) {
|
||||
async function fetchRoleUserIds(pool, roleId) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT ur.user_id
|
||||
FROM user_roles ur
|
||||
FROM a_user_roles ur
|
||||
WHERE ur.role_id = ?
|
||||
ORDER BY ur.user_id ASC`,
|
||||
[roleId]
|
||||
@@ -99,9 +112,9 @@ async function fetchRoleUserIds(pool, roleId) {
|
||||
|
||||
async function fetchRolesForUser(pool, userId) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT r.id, r.role_key, r.name, r.description
|
||||
FROM user_roles ur
|
||||
JOIN roles r ON r.id = ur.role_id
|
||||
`SELECT r.id, r.name, r.description
|
||||
FROM a_user_roles ur
|
||||
JOIN a_roles r ON r.id = ur.role_id
|
||||
WHERE ur.user_id = ?
|
||||
ORDER BY r.name ASC`,
|
||||
[userId]
|
||||
@@ -114,13 +127,13 @@ async function fetchUsersWithRoles(pool) {
|
||||
`SELECT u.id, u.name, u.username, u.created_at, u.modified_at,
|
||||
COALESCE(role_data.role_names, '') AS role_names,
|
||||
COALESCE(role_data.role_ids_csv, '') AS role_ids_csv
|
||||
FROM users u
|
||||
FROM a_users u
|
||||
LEFT JOIN (
|
||||
SELECT ur.user_id,
|
||||
GROUP_CONCAT(DISTINCT r.name ORDER BY r.name SEPARATOR ', ') AS role_names,
|
||||
GROUP_CONCAT(DISTINCT r.id ORDER BY r.name SEPARATOR ',') AS role_ids_csv
|
||||
FROM user_roles ur
|
||||
JOIN roles r ON r.id = ur.role_id
|
||||
FROM a_user_roles ur
|
||||
JOIN a_roles r ON r.id = ur.role_id
|
||||
GROUP BY ur.user_id
|
||||
) role_data ON role_data.user_id = u.id
|
||||
ORDER BY u.id ASC`
|
||||
@@ -143,18 +156,18 @@ async function fetchUsersWithRolesPage(pool, page, pageSize, searchTerm, sortKey
|
||||
selectSql: `SELECT u.id, u.name, u.username, u.created_at, u.modified_at,
|
||||
COALESCE(role_data.role_names, '') AS role_names,
|
||||
COALESCE(role_data.role_ids_csv, '') AS role_ids_csv
|
||||
FROM users u
|
||||
FROM a_users u
|
||||
LEFT JOIN (
|
||||
SELECT ur.user_id,
|
||||
GROUP_CONCAT(DISTINCT r.name ORDER BY r.name SEPARATOR ', ') AS role_names,
|
||||
GROUP_CONCAT(DISTINCT r.id ORDER BY r.name SEPARATOR ',') AS role_ids_csv
|
||||
FROM user_roles ur
|
||||
JOIN roles r ON r.id = ur.role_id
|
||||
FROM a_user_roles ur
|
||||
JOIN a_roles r ON r.id = ur.role_id
|
||||
GROUP BY ur.user_id
|
||||
) role_data ON role_data.user_id = u.id
|
||||
${whereSql}
|
||||
ORDER BY u.id ASC`,
|
||||
countSql: `SELECT COUNT(*) AS count FROM users u ${whereSql}`,
|
||||
countSql: `SELECT COUNT(*) AS count FROM a_users u ${whereSql}`,
|
||||
params: queryArgs,
|
||||
searchColumns: ['u.name', 'u.username', 'role_data.role_names'],
|
||||
searchTerm: searchTerm,
|
||||
@@ -190,13 +203,13 @@ async function fetchUserWithRoles(pool, userId) {
|
||||
`SELECT u.id, u.name, u.username, u.created_at, u.modified_at,
|
||||
COALESCE(role_data.role_names, '') AS role_names,
|
||||
COALESCE(role_data.role_ids_csv, '') AS role_ids_csv
|
||||
FROM users u
|
||||
FROM a_users u
|
||||
LEFT JOIN (
|
||||
SELECT ur.user_id,
|
||||
GROUP_CONCAT(DISTINCT r.name ORDER BY r.name SEPARATOR ', ') AS role_names,
|
||||
GROUP_CONCAT(DISTINCT r.id ORDER BY r.name SEPARATOR ',') AS role_ids_csv
|
||||
FROM user_roles ur
|
||||
JOIN roles r ON r.id = ur.role_id
|
||||
FROM a_user_roles ur
|
||||
JOIN a_roles r ON r.id = ur.role_id
|
||||
GROUP BY ur.user_id
|
||||
) role_data ON role_data.user_id = u.id
|
||||
WHERE u.id = ?
|
||||
@@ -221,9 +234,9 @@ async function syncUserRoles(pool, userId, roleIds) {
|
||||
return Number.isInteger(roleId) && roleId > 0;
|
||||
})));
|
||||
|
||||
await pool.query('DELETE FROM user_roles WHERE user_id = ?', [userId]);
|
||||
await pool.query('DELETE FROM a_user_roles WHERE user_id = ?', [userId]);
|
||||
for (const roleId of uniqueRoleIds) {
|
||||
await pool.query('INSERT IGNORE INTO user_roles (user_id, role_id, created_by, modified_by) VALUES (?, ?, ?, ?)', [userId, roleId, null, null]);
|
||||
await pool.query('INSERT IGNORE INTO a_user_roles (user_id, role_id) VALUES (?, ?)', [userId, roleId]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,9 +247,9 @@ async function syncRoleUsers(pool, roleId, userIds) {
|
||||
return Number.isInteger(userId) && userId > 0;
|
||||
})));
|
||||
|
||||
await pool.query('DELETE FROM user_roles WHERE role_id = ?', [roleId]);
|
||||
await pool.query('DELETE FROM a_user_roles WHERE role_id = ?', [roleId]);
|
||||
for (const userId of uniqueUserIds) {
|
||||
await pool.query('INSERT IGNORE INTO user_roles (user_id, role_id, created_by, modified_by) VALUES (?, ?, ?, ?)', [userId, roleId, null, null]);
|
||||
await pool.query('INSERT IGNORE INTO a_user_roles (user_id, role_id) VALUES (?, ?)', [userId, roleId]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -244,24 +257,25 @@ async function syncRolePermissions(pool, roleId, permissionKeys) {
|
||||
const uniquePermissionKeys = normalizePermissionKeys(permissionKeys);
|
||||
|
||||
if (!uniquePermissionKeys.length) {
|
||||
await pool.query('DELETE FROM role_permissions WHERE role_id = ?', [roleId]);
|
||||
await pool.query('DELETE FROM a_role_permissions WHERE role_id = ?', [roleId]);
|
||||
return;
|
||||
}
|
||||
|
||||
const [permissionRows] = await pool.query('SELECT id, permission_key FROM permissions WHERE permission_key IN (?)', [uniquePermissionKeys]);
|
||||
const [permissionRows] = await pool.query('SELECT id, permission_key FROM a_permissions WHERE permission_key IN (?)', [uniquePermissionKeys]);
|
||||
if (permissionRows.length !== uniquePermissionKeys.length) {
|
||||
throw new Error('One or more selected permissions are invalid.');
|
||||
}
|
||||
|
||||
await pool.query('DELETE FROM role_permissions WHERE role_id = ?', [roleId]);
|
||||
await pool.query('DELETE FROM a_role_permissions WHERE role_id = ?', [roleId]);
|
||||
for (const permissionRow of permissionRows) {
|
||||
await pool.query('INSERT IGNORE INTO role_permissions (role_id, permission_id, created_by, modified_by) VALUES (?, ?, ?, ?)', [roleId, Number(permissionRow.id), null, null]);
|
||||
await pool.query('INSERT IGNORE INTO a_role_permissions (role_id, permission_id) VALUES (?, ?)', [roleId, Number(permissionRow.id)]);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
PERMISSIONS,
|
||||
fetchPermissions,
|
||||
fetchRoleByName,
|
||||
fetchRoles,
|
||||
fetchRolesPage,
|
||||
fetchRoleById,
|
||||
|
||||
+15
-13
@@ -57,8 +57,8 @@ function createSessionService(options) {
|
||||
const tokenHash = hashSessionToken(token);
|
||||
const [rows] = await pool.query(
|
||||
`SELECT s.user_id, u.id, u.name, u.username
|
||||
FROM auth_sessions s
|
||||
JOIN users u ON u.id = s.user_id
|
||||
FROM a_sessions s
|
||||
JOIN a_users u ON u.id = s.user_id
|
||||
WHERE s.session_hash = ?
|
||||
AND s.expires_at > NOW()
|
||||
LIMIT 1`,
|
||||
@@ -70,28 +70,30 @@ function createSessionService(options) {
|
||||
|
||||
const userId = Number(rows[0].id);
|
||||
const [roleRows] = await pool.query(
|
||||
`SELECT r.role_key
|
||||
FROM user_roles ur
|
||||
JOIN roles r ON r.id = ur.role_id
|
||||
`SELECT r.id
|
||||
FROM a_user_roles ur
|
||||
JOIN a_roles r ON r.id = ur.role_id
|
||||
WHERE ur.user_id = ?
|
||||
ORDER BY r.name ASC`,
|
||||
[userId]
|
||||
);
|
||||
const [permissionRows] = await pool.query(
|
||||
`SELECT p.permission_key
|
||||
FROM user_roles ur
|
||||
JOIN role_permissions rp ON rp.role_id = ur.role_id
|
||||
JOIN permissions p ON p.id = rp.permission_id
|
||||
FROM a_user_roles ur
|
||||
JOIN a_role_permissions rp ON rp.role_id = ur.role_id
|
||||
JOIN a_permissions p ON p.id = rp.permission_id
|
||||
WHERE ur.user_id = ?
|
||||
ORDER BY p.section_name ASC, p.name ASC`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
await pool.query('UPDATE auth_sessions SET last_used_at = CURRENT_TIMESTAMP, modified_by = ? WHERE session_hash = ?', [rows[0].user_id, tokenHash]);
|
||||
await pool.query('UPDATE a_sessions SET modified_at = CURRENT_TIMESTAMP, modified_by = ? WHERE session_hash = ?', [rows[0].user_id, tokenHash]);
|
||||
return Object.assign({}, rows[0], {
|
||||
roleKeys: roleRows.map(function (row) {
|
||||
return String(row.role_key || '').trim();
|
||||
}).filter(Boolean),
|
||||
roleIds: roleRows.map(function (row) {
|
||||
return Number(row.id);
|
||||
}).filter(function (roleId) {
|
||||
return Number.isInteger(roleId) && roleId > 0;
|
||||
}),
|
||||
permissionKeys: normalizePermissionKeys(permissionRows.map(function (row) {
|
||||
return String(row.permission_key || '').trim();
|
||||
}))
|
||||
@@ -103,7 +105,7 @@ function createSessionService(options) {
|
||||
const tokenHash = hashSessionToken(token);
|
||||
const expiresAt = new Date(Date.now() + sessionMaxAgeMs);
|
||||
await pool.query(
|
||||
'INSERT INTO auth_sessions (session_hash, user_id, expires_at, created_by, modified_by) VALUES (?, ?, ?, ?, ?)',
|
||||
'INSERT INTO a_sessions (session_hash, user_id, expires_at, created_by, modified_by) VALUES (?, ?, ?, ?, ?)',
|
||||
[tokenHash, userId, expiresAt, userId, userId]
|
||||
);
|
||||
return token;
|
||||
|
||||
@@ -10,7 +10,6 @@ const chromium = chromiumModule && typeof chromiumModule.executablePath === 'fun
|
||||
: chromiumModule;
|
||||
const {
|
||||
escapeHtml,
|
||||
mediaKind,
|
||||
renderEditorJsContent,
|
||||
sanitizeFontFamily,
|
||||
sanitizeFontSize,
|
||||
@@ -276,13 +275,12 @@ async function captureSlideThumbnail(options) {
|
||||
}
|
||||
});
|
||||
|
||||
await pool.query('UPDATE slides SET thumbnail_path = ? WHERE id = ?', [thumbnailPath, slide.id]);
|
||||
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(slide.media_path || '')
|
||||
fullSizePath: fullSizePath
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+60
-19
@@ -11,7 +11,6 @@ function normalizeUploadRoot(uploadDir) {
|
||||
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;
|
||||
@@ -30,6 +29,29 @@ function createUploadSyncService(options) {
|
||||
throw new Error('createUploadSyncService requires the upload dependencies.');
|
||||
}
|
||||
|
||||
async function resolveKnownPlayerBaseUrls() {
|
||||
const baseUrls = [];
|
||||
if (pool) {
|
||||
try {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT COALESCE(NULLIF(TRIM(pl.internal_base_url), ''), '') AS resolved_player_internal_base_url
|
||||
FROM d_screens s
|
||||
LEFT JOIN d_players pl ON pl.id = s.player_id`
|
||||
);
|
||||
rows.forEach(function (row) {
|
||||
const resolved = String(row && row.resolved_player_internal_base_url || '').trim().replace(/\/+$/, '');
|
||||
if (resolved) {
|
||||
baseUrls.push(resolved);
|
||||
}
|
||||
});
|
||||
} catch (_error) {
|
||||
// Fall back to the configured default below.
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(new Set(baseUrls));
|
||||
}
|
||||
|
||||
function createUploadMiddleware(uploadDir) {
|
||||
const storage = multer.diskStorage({
|
||||
destination: function (_req, _file, cb) {
|
||||
@@ -117,7 +139,6 @@ function createUploadSyncService(options) {
|
||||
if (!slide) {
|
||||
return refs;
|
||||
}
|
||||
collectUploadReferencesFromValue(slide.media_path, refs);
|
||||
collectUploadReferencesFromValue(common.parseJsonSafe(slide.content_json), refs);
|
||||
return refs;
|
||||
}
|
||||
@@ -136,7 +157,6 @@ function createUploadSyncService(options) {
|
||||
if (!payload) {
|
||||
return refs;
|
||||
}
|
||||
collectUploadReferencesFromValue(payload.mediaPath, refs);
|
||||
collectUploadReferencesFromValue(common.parseJsonSafe(payload.contentJson), refs);
|
||||
collectUploadReferencesFromValue(payload.backgroundImagePath, refs);
|
||||
return refs;
|
||||
@@ -145,13 +165,12 @@ function createUploadSyncService(options) {
|
||||
async function countUploadReferences(pool, uploadPath) {
|
||||
const [slideRows] = await pool.query(
|
||||
`SELECT COUNT(*) AS ref_count
|
||||
FROM slides
|
||||
WHERE media_path = ?
|
||||
OR JSON_SEARCH(COALESCE(content_json, JSON_OBJECT()), 'one', ?) IS NOT NULL`,
|
||||
[uploadPath, uploadPath]
|
||||
FROM c_slides
|
||||
WHERE JSON_SEARCH(COALESCE(content_json, JSON_OBJECT()), 'one', ?) IS NOT NULL`,
|
||||
[uploadPath]
|
||||
);
|
||||
const [templateRows] = await pool.query(
|
||||
'SELECT COUNT(*) AS ref_count FROM slide_templates WHERE background_image_path = ?',
|
||||
'SELECT COUNT(*) AS ref_count FROM c_templates WHERE background_image_path = ?',
|
||||
[uploadPath]
|
||||
);
|
||||
return Number(slideRows[0].ref_count || 0) + Number(templateRows[0].ref_count || 0);
|
||||
@@ -237,11 +256,16 @@ function createUploadSyncService(options) {
|
||||
|
||||
playerUploadSyncModePromise = (async function () {
|
||||
try {
|
||||
const playerBaseUrls = await resolveKnownPlayerBaseUrls();
|
||||
const playerBaseUrl = playerBaseUrls[0];
|
||||
if (!playerBaseUrl) {
|
||||
return null;
|
||||
}
|
||||
const authHeaders = createRequestAuthHeaders({
|
||||
method: 'GET',
|
||||
pathname: '/api/media/config'
|
||||
});
|
||||
const response = await fetch(`${playerInternalBaseUrl}/api/media/config`, {
|
||||
const response = await fetch(`${playerBaseUrl}/api/media/config`, {
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
...authHeaders
|
||||
@@ -304,11 +328,16 @@ function createUploadSyncService(options) {
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
async function pushUploadFileToPlayer(uploadPath, localUploadDir) {
|
||||
async function pushUploadFileToPlayer(uploadPath, localUploadDir, playerBaseUrl) {
|
||||
if (!uploadPath || !(await shouldMirrorUploads(localUploadDir))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const resolvedPlayerBaseUrl = String(playerBaseUrl || '').trim().replace(/\/+$/, '');
|
||||
if (!resolvedPlayerBaseUrl) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const relativePath = getUploadRelativePath(uploadPath);
|
||||
const sourcePath = resolveUploadFilePath(localUploadDir, uploadPath);
|
||||
if (!relativePath || !sourcePath) {
|
||||
@@ -330,7 +359,7 @@ function createUploadSyncService(options) {
|
||||
pathname: `/api/media/${encodeURIComponent(relativePath)}`,
|
||||
body: fileBuffer
|
||||
});
|
||||
const response = await fetch(`${playerInternalBaseUrl}/api/media/${encodeURIComponent(relativePath)}`, {
|
||||
const response = await fetch(`${resolvedPlayerBaseUrl}/api/media/${encodeURIComponent(relativePath)}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
@@ -349,11 +378,16 @@ function createUploadSyncService(options) {
|
||||
}
|
||||
}
|
||||
|
||||
async function removeUploadFileFromPlayer(uploadPath, localUploadDir) {
|
||||
async function removeUploadFileFromPlayer(uploadPath, localUploadDir, playerBaseUrl) {
|
||||
if (!uploadPath || !(await shouldMirrorUploads(localUploadDir))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const resolvedPlayerBaseUrl = String(playerBaseUrl || '').trim().replace(/\/+$/, '');
|
||||
if (!resolvedPlayerBaseUrl) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const relativePath = getUploadRelativePath(uploadPath);
|
||||
if (!relativePath) {
|
||||
return false;
|
||||
@@ -363,7 +397,7 @@ function createUploadSyncService(options) {
|
||||
method: 'DELETE',
|
||||
pathname: `/api/media/${encodeURIComponent(relativePath)}`
|
||||
});
|
||||
const response = await fetch(`${playerInternalBaseUrl}/api/media/${encodeURIComponent(relativePath)}`, {
|
||||
const response = await fetch(`${resolvedPlayerBaseUrl}/api/media/${encodeURIComponent(relativePath)}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
@@ -386,9 +420,14 @@ function createUploadSyncService(options) {
|
||||
return;
|
||||
}
|
||||
|
||||
const playerBaseUrls = await resolveKnownPlayerBaseUrls();
|
||||
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);
|
||||
let success = true;
|
||||
for (let j = 0; j < playerBaseUrls.length; j += 1) {
|
||||
const targetSuccess = await pushUploadFileToPlayer(uniqueRefs[i], localUploadDir, playerBaseUrls[j]);
|
||||
success = success && targetSuccess;
|
||||
}
|
||||
if (!success) {
|
||||
queuePlayerUploadSync({
|
||||
type: 'put',
|
||||
@@ -568,14 +607,16 @@ function createUploadSyncService(options) {
|
||||
}
|
||||
|
||||
pendingPlayerUploadSyncFlushInFlight = (async function () {
|
||||
const playerBaseUrls = await resolveKnownPlayerBaseUrls();
|
||||
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);
|
||||
let success = true;
|
||||
for (let j = 0; j < playerBaseUrls.length; j += 1) {
|
||||
const targetSuccess = operation.type === 'delete'
|
||||
? await removeUploadFileFromPlayer(operation.uploadPath, operation.uploadDir, playerBaseUrls[j])
|
||||
: await pushUploadFileToPlayer(operation.uploadPath, operation.uploadDir, playerBaseUrls[j]);
|
||||
success = success && targetSuccess;
|
||||
}
|
||||
if (success) {
|
||||
pendingPlayerUploadSyncs.delete(operation.uploadPath);
|
||||
|
||||
@@ -183,10 +183,10 @@
|
||||
function renderScreenTile(screen) {
|
||||
var clientCount = Number(screen.player_connection_count || 0);
|
||||
var playerUrl = String(screen.player_url || '').trim();
|
||||
var connectionLabel = clientCount ? clientCount + ' live' : 'No clients';
|
||||
var connectionLabel = clientCount ? clientCount + ' connected' : 'No players connected';
|
||||
var connectionStateClass = clientCount ? 'is-live' : 'is-idle';
|
||||
var playlistLabel = screen.playlist_name ? escapeHtml(screen.playlist_name) : 'Unassigned';
|
||||
var connectionsLabel = clientCount ? clientCount + ' connected' : 'No clients connected';
|
||||
var connectionsLabel = clientCount ? clientCount + ' connected' : 'No players connected';
|
||||
|
||||
return [
|
||||
'<article class="dashboard-screen-tile" data-screen-key="' + escapeHtml(screen.id || '') + '">',
|
||||
@@ -225,7 +225,7 @@
|
||||
}
|
||||
|
||||
function updateStats(state) {
|
||||
var clientCount = document.getElementById('dashboard-client-count');
|
||||
var playerCount = document.getElementById('dashboard-client-count');
|
||||
var screenCount = document.getElementById('dashboard-screen-count');
|
||||
var slideCount = document.getElementById('dashboard-slide-count');
|
||||
var playlistCount = document.getElementById('dashboard-playlist-count');
|
||||
@@ -239,8 +239,8 @@
|
||||
if (screenCount && Array.isArray(state.screens)) {
|
||||
screenCount.textContent = String(state.screens.length);
|
||||
}
|
||||
if (clientCount) {
|
||||
clientCount.textContent = String(Number(state.connectedClientsCount || 0));
|
||||
if (playerCount) {
|
||||
playerCount.textContent = String(Number(state.connectedClientsCount || 0));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -472,3 +472,4 @@
|
||||
|
||||
initClientRenameHandler();
|
||||
}());
|
||||
|
||||
|
||||
@@ -727,8 +727,6 @@
|
||||
showVideoDurationButton: slide.showVideoDurationButton,
|
||||
videoSourcePath: slide.videoSourcePath,
|
||||
videoDurationSeconds: slide.videoDurationSeconds,
|
||||
media_type: slide.media_type,
|
||||
media_path: slide.media_path,
|
||||
title: slide.title || 'Slide',
|
||||
duration_seconds: 10,
|
||||
schedule_mode: 'always',
|
||||
@@ -951,14 +949,12 @@
|
||||
row.setAttribute('data-row-key', rowKey);
|
||||
row.setAttribute('data-slide-id', String(values.slide_id));
|
||||
row.setAttribute('data-canvas-signature', String(values.canvas_signature || ''));
|
||||
row.setAttribute('data-media-type', String(values.media_type || ''));
|
||||
row.setAttribute('data-media-path', String(values.media_path || ''));
|
||||
row.setAttribute('data-video-source-path', String(values.videoSourcePath || values.media_path || ''));
|
||||
row.setAttribute('data-video-source-path', String(values.videoSourcePath || ''));
|
||||
row.setAttribute('data-video-duration-seconds', String(values.videoDurationSeconds || ''));
|
||||
row.setAttribute('data-use-video-duration', String(Boolean(values.useVideoDuration)));
|
||||
var durationActionMarkup = buildVideoDurationButtonMarkup(
|
||||
Boolean(values.useVideoDuration),
|
||||
Boolean(values.showVideoDurationButton) || String(values.media_type || '').trim() === 'video'
|
||||
Boolean(values.showVideoDurationButton)
|
||||
);
|
||||
row.innerHTML = '' +
|
||||
buildPlaylistOrderCellMarkup() +
|
||||
@@ -1096,10 +1092,9 @@
|
||||
}
|
||||
|
||||
async function applyVideoDurationToRow(row, button) {
|
||||
var mediaType = String(row && row.getAttribute('data-media-type') || '').trim().toLowerCase();
|
||||
var mediaPath = String(row && (row.getAttribute('data-video-source-path') || row.getAttribute('data-media-path')) || '').trim();
|
||||
var mediaPath = String(row && row.getAttribute('data-video-source-path') || '').trim();
|
||||
var input = row ? row.querySelector('[name="duration_seconds[]"]') : null;
|
||||
var isVideoRow = mediaType === 'video' || Boolean(mediaPath);
|
||||
var isVideoRow = Boolean(mediaPath);
|
||||
var storedDuration = Number(row && row.getAttribute('data-video-duration-seconds') || 0);
|
||||
|
||||
if (!isVideoRow || !input) {
|
||||
|
||||
@@ -433,12 +433,11 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
||||
}
|
||||
|
||||
function renderPreviewTextRegion(region, value, style, scale) {
|
||||
var fontFamily = style.font_family ? 'font-family:' + escapeHtml(style.font_family) + ';' : '';
|
||||
var color = style.font_color ? 'color:' + escapeHtml(style.font_color) + ';' : '';
|
||||
var fontSize = style.font_size ? 'font-size:' + Math.max(1, Math.round(Number(style.font_size))) + 'px;' : '';
|
||||
var width = Math.max(1, Math.round(Number(region && region.width ? region.width : 0) || 1));
|
||||
var height = Math.max(1, Math.round(Number(region && region.height ? region.height : 0) || 1));
|
||||
var wrapperStyle = 'width:' + width + 'px;height:' + height + 'px;overflow:hidden;' + fontFamily + fontSize + color;
|
||||
var wrapperStyle = 'width:' + width + 'px;height:' + height + 'px;overflow:hidden;' + fontSize + color;
|
||||
return '<div class="slide-preview-text-content" style="' + wrapperStyle + '">' + renderPreviewText(value) + '</div>';
|
||||
}
|
||||
|
||||
@@ -551,7 +550,6 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
||||
function getCurrentTextStyle(region) {
|
||||
var current = existingContent[region.region_key] || {};
|
||||
return {
|
||||
font_family: String(current.font_family || region.font_family || 'Arial').trim() || 'Arial',
|
||||
font_size: Math.max(8, Number(current.font_size || DEFAULT_FONT_SIZE)),
|
||||
font_color: String(current.font_color || region.font_color || '#000000').trim() || '#000000'
|
||||
};
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
(function () {
|
||||
function formatConnectedPlayersLabel(count) {
|
||||
var total = Number(count || 0);
|
||||
if (total === 1) {
|
||||
return '1 player connected';
|
||||
}
|
||||
return total + ' players connected';
|
||||
}
|
||||
|
||||
function updateSidebarStatus(status, label) {
|
||||
var dot = document.getElementById('sidebar-status-dot');
|
||||
var text = document.getElementById('sidebar-status-text');
|
||||
@@ -68,7 +76,10 @@
|
||||
if (typeof window.webHandleDashboardState === 'function') {
|
||||
window.webHandleDashboardState(payload.state);
|
||||
}
|
||||
updateSidebarStatus(Boolean(payload.state && payload.state.playerServiceConnected), 'Live player feed');
|
||||
updateSidebarStatus(
|
||||
Boolean(payload.state && payload.state.playerServiceConnected),
|
||||
formatConnectedPlayersLabel(payload.state && payload.state.connectedPlayersCount)
|
||||
);
|
||||
}
|
||||
} catch (_error) {
|
||||
// Ignore malformed dashboard payloads.
|
||||
|
||||
@@ -89,7 +89,6 @@
|
||||
region_key: name,
|
||||
label: name,
|
||||
region_type: card.querySelector('[name="region_type[]"]').value,
|
||||
font_family: card.querySelector('[name="font_family[]"]').value,
|
||||
lock_ratio: normalizeLockRatio(card.querySelector('[name="region_lock_ratio[]"]').value),
|
||||
x: Number(card.querySelector('[name="region_x[]"]').value || 0),
|
||||
y: Number(card.querySelector('[name="region_y[]"]').value || 0),
|
||||
@@ -108,7 +107,6 @@
|
||||
syncRegionIdentity(card, values.label);
|
||||
}
|
||||
if (values.region_type !== undefined) { card.querySelector('[name="region_type[]"]').value = values.region_type; }
|
||||
if (values.font_family !== undefined) { card.querySelector('[name="font_family[]"]').value = values.font_family; }
|
||||
if (values.lock_ratio !== undefined) { card.querySelector('[name="region_lock_ratio[]"]').value = normalizeLockRatio(values.lock_ratio); }
|
||||
if (values.x !== undefined) { card.querySelector('[name="region_x[]"]').value = Math.round(values.x); }
|
||||
if (values.y !== undefined) { card.querySelector('[name="region_y[]"]').value = Math.round(values.y); }
|
||||
|
||||
@@ -179,7 +179,6 @@
|
||||
region_key: getRegionName(card),
|
||||
label: getRegionName(card),
|
||||
region_type: card.querySelector('[name="region_type[]"]').value,
|
||||
font_family: card.querySelector('[name="font_family[]"]').value,
|
||||
lock_ratio: normalizeLockRatio(card.querySelector('[name="region_lock_ratio[]"]').value),
|
||||
x: Number(card.querySelector('[name="region_x[]"]').value || 0),
|
||||
y: Number(card.querySelector('[name="region_y[]"]').value || 0),
|
||||
@@ -202,7 +201,6 @@
|
||||
syncRegionIdentity(card, values.label);
|
||||
}
|
||||
if (values.region_type !== undefined) { card.querySelector('[name="region_type[]"]').value = values.region_type; }
|
||||
if (values.font_family !== undefined) { card.querySelector('[name="font_family[]"]').value = values.font_family; }
|
||||
if (values.lock_ratio !== undefined) { card.querySelector('[name="region_lock_ratio[]"]').value = normalizeLockRatio(values.lock_ratio); }
|
||||
if (values.x !== undefined) { card.querySelector('[name="region_x[]"]').value = Math.round(values.x); }
|
||||
if (values.y !== undefined) { card.querySelector('[name="region_y[]"]').value = Math.round(values.y); }
|
||||
@@ -335,7 +333,6 @@
|
||||
var chip = card.querySelector('[data-region-chip]');
|
||||
var title = card.querySelector('[data-region-title]');
|
||||
var nameInput = card.querySelector('[name="region_name[]"]');
|
||||
var fontFamilyInput = card.querySelector('[name="font_family[]"]');
|
||||
var regionTypeInput = card.querySelector('[name="region_type[]"]');
|
||||
var regionKeyInput = card.querySelector('[name="region_key[]"]');
|
||||
var regionLabelInput = card.querySelector('[name="region_label[]"]');
|
||||
@@ -349,9 +346,6 @@
|
||||
if (nameInput) {
|
||||
nameInput.value = region.region_key || region.label || '';
|
||||
}
|
||||
if (fontFamilyInput) {
|
||||
fontFamilyInput.value = region.region_type === 'image' || region.region_type === 'video' || region.region_type === 'rtmp' ? '' : (region.font_family || 'Arial');
|
||||
}
|
||||
if (regionTypeInput) {
|
||||
regionTypeInput.value = region.region_type || 'text';
|
||||
}
|
||||
@@ -577,7 +571,6 @@
|
||||
region_key: name,
|
||||
label: name,
|
||||
region_type: type,
|
||||
font_family: type === 'text' || type === 'html' || type === 'rss' || type === 'api' ? 'Arial' : '',
|
||||
x: 80,
|
||||
y: 80,
|
||||
width: size.width,
|
||||
|
||||
@@ -20,12 +20,12 @@ module.exports = function registerAdminAccountRoutes(app, deps) {
|
||||
return res.status(400).send('Name is required.');
|
||||
}
|
||||
|
||||
if (await common.fetchDuplicateName(pool, 'users', name, req.currentUser.id)) {
|
||||
if (await common.fetchDuplicateName(pool, 'a_users', name, req.currentUser.id)) {
|
||||
return res.redirect('/account?message=' + encodeURIComponent('That name already exists.'));
|
||||
}
|
||||
|
||||
const actorId = getAuditUserId(req);
|
||||
const [result] = await pool.query('UPDATE users SET name = ?, modified_by = ? WHERE id = ?', [name, actorId, req.currentUser.id]);
|
||||
const [result] = await pool.query('UPDATE a_users SET name = ?, modified_by = ? WHERE id = ?', [name, actorId, req.currentUser.id]);
|
||||
if (!result.affectedRows) {
|
||||
return res.status(404).send('User not found.');
|
||||
}
|
||||
@@ -42,7 +42,7 @@ module.exports = function registerAdminAccountRoutes(app, deps) {
|
||||
const newPassword = String(req.body.new_password || '');
|
||||
const confirmPassword = String(req.body.confirm_password || '');
|
||||
|
||||
const [rows] = await pool.query('SELECT id, name, username, password_hash, password_salt, password_iterations FROM users WHERE id = ? LIMIT 1', [req.currentUser.id]);
|
||||
const [rows] = await pool.query('SELECT id, name, username, password_hash, password_salt, password_iterations FROM a_users WHERE id = ? LIMIT 1', [req.currentUser.id]);
|
||||
const user = rows[0] || null;
|
||||
if (!user) {
|
||||
return res.status(404).send('User not found.');
|
||||
@@ -59,10 +59,10 @@ module.exports = function registerAdminAccountRoutes(app, deps) {
|
||||
|
||||
const passwordRecord = hashPassword(newPassword);
|
||||
await pool.query(
|
||||
'UPDATE users SET password_hash = ?, password_salt = ?, password_iterations = ?, modified_by = ? WHERE id = ?',
|
||||
'UPDATE a_users SET password_hash = ?, password_salt = ?, password_iterations = ?, modified_by = ? WHERE id = ?',
|
||||
[passwordRecord.hash, passwordRecord.salt, passwordRecord.iterations, getAuditUserId(req), user.id]
|
||||
);
|
||||
await pool.query('DELETE FROM auth_sessions WHERE user_id = ?', [user.id]);
|
||||
await pool.query('DELETE FROM a_sessions WHERE user_id = ?', [user.id]);
|
||||
|
||||
const token = await createUserSession(pool, user.id);
|
||||
setSessionCookie(res, token);
|
||||
|
||||
@@ -22,7 +22,7 @@ module.exports = function registerAdminScreenCommandRoutes(app, deps) {
|
||||
return res.status(400).json({ error: 'Command is required' });
|
||||
}
|
||||
|
||||
const [screenRows] = await pool.query('SELECT id, name, slug FROM screens WHERE slug = ?', [slug]);
|
||||
const [screenRows] = await pool.query('SELECT id, name, slug FROM d_screens WHERE slug = ?', [slug]);
|
||||
if (!screenRows.length) {
|
||||
return res.status(404).json({ error: 'Screen not found' });
|
||||
}
|
||||
@@ -39,7 +39,7 @@ module.exports = function registerAdminScreenCommandRoutes(app, deps) {
|
||||
|
||||
const [currentRows] = await pool.query(
|
||||
`SELECT client_name
|
||||
FROM player_onboarding_devices
|
||||
FROM d_onboarding_devices
|
||||
WHERE device_id = ?
|
||||
LIMIT 1`,
|
||||
[deviceId]
|
||||
@@ -65,7 +65,7 @@ module.exports = function registerAdminScreenCommandRoutes(app, deps) {
|
||||
return withClientNameReservation(pool, clientName, async function () {
|
||||
let liveConnections = [];
|
||||
try {
|
||||
const [screenSlugs] = await pool.query('SELECT slug FROM screens ORDER BY slug ASC');
|
||||
const [screenSlugs] = await pool.query('SELECT slug FROM d_screens ORDER BY slug ASC');
|
||||
const liveResults = await Promise.all((screenSlugs || []).map(async function (row) {
|
||||
const screenSlug = String(row && row.slug ? row.slug : '').trim();
|
||||
if (!screenSlug || typeof getScreenConnections !== 'function') {
|
||||
@@ -109,8 +109,8 @@ module.exports = function registerAdminScreenCommandRoutes(app, deps) {
|
||||
}
|
||||
|
||||
const [updateResult] = await pool.query(
|
||||
`UPDATE player_onboarding_devices pod
|
||||
JOIN screens s ON s.id = pod.screen_id
|
||||
`UPDATE d_onboarding_devices pod
|
||||
JOIN d_screens s ON s.id = pod.screen_id
|
||||
SET pod.client_name = ?, pod.modified_at = CURRENT_TIMESTAMP
|
||||
WHERE s.slug = ? AND pod.device_id = ?`,
|
||||
[clientName, slug, deviceId]
|
||||
|
||||
@@ -105,12 +105,12 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
||||
}
|
||||
|
||||
async function fetchTemplateSlideCount(templateId) {
|
||||
const [rows] = await pool.query('SELECT COUNT(*) AS slide_count FROM slides WHERE template_id = ?', [templateId]);
|
||||
const [rows] = await pool.query('SELECT COUNT(*) AS slide_count FROM c_slides WHERE template_id = ?', [templateId]);
|
||||
return Number(rows[0] && rows[0].slide_count) || 0;
|
||||
}
|
||||
|
||||
async function fetchTemplateRegionUsage(template) {
|
||||
const [slides] = await pool.query('SELECT content_json FROM slides WHERE template_id = ?', [template.id]);
|
||||
const [slides] = await pool.query('SELECT content_json FROM c_slides WHERE template_id = ?', [template.id]);
|
||||
const usage = new Set();
|
||||
const regionKeys = new Set((Array.isArray(template && template.regions) ? template.regions : [])
|
||||
.map((region) => String(region && region.region_key || '').trim())
|
||||
@@ -136,7 +136,7 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
||||
}
|
||||
|
||||
async function fetchSlidesByTemplateId(templateId) {
|
||||
const [slides] = await pool.query('SELECT id, thumbnail_path FROM slides WHERE template_id = ? ORDER BY id ASC', [templateId]);
|
||||
const [slides] = await pool.query('SELECT id, thumbnail_path FROM c_slides WHERE template_id = ? ORDER BY id ASC', [templateId]);
|
||||
return slides;
|
||||
}
|
||||
|
||||
@@ -158,9 +158,42 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
||||
return blockedKeys.length > 0 ? 'This region is still used by one or more slides.' : '';
|
||||
}
|
||||
|
||||
function queueSlideThumbnailRefresh(slideId, previousThumbnailPath) {
|
||||
async function resolveThumbnailPlayerMetadata() {
|
||||
if (!common || typeof common.fetchPlayersSelectionData !== 'function') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const playersData = await common.fetchPlayersSelectionData(pool);
|
||||
const players = Array.isArray(playersData && playersData.players) ? playersData.players : [];
|
||||
const selectedPlayer = players.find(function (player) {
|
||||
return String(player && player.internal_base_url || '').trim() || String(player && player.public_base_url || '').trim();
|
||||
});
|
||||
|
||||
if (!selectedPlayer) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
playerId: Number(selectedPlayer.id) || null,
|
||||
playerLabel: String(selectedPlayer.label || selectedPlayer.identifier || '').trim()
|
||||
};
|
||||
} catch (_error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function queueSlideThumbnailRefresh(slideId, previousThumbnailPath) {
|
||||
if (!backgroundTaskQueue || typeof backgroundTaskQueue.enqueueTask !== 'function') {
|
||||
return Promise.resolve(null);
|
||||
return null;
|
||||
}
|
||||
|
||||
const playerMetadata = await resolveThumbnailPlayerMetadata();
|
||||
const metadata = {};
|
||||
if (playerMetadata && playerMetadata.playerLabel) {
|
||||
metadata.playerId = playerMetadata.playerId;
|
||||
metadata.playerLabel = playerMetadata.playerLabel;
|
||||
metadata.affectsPlayerLabel = playerMetadata.playerLabel;
|
||||
}
|
||||
|
||||
return backgroundTaskQueue.enqueueTask({
|
||||
@@ -172,13 +205,22 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
||||
slideId: Number(slideId),
|
||||
previousThumbnailPath: String(previousThumbnailPath || '').trim()
|
||||
},
|
||||
metadata: metadata,
|
||||
persist: true
|
||||
});
|
||||
}
|
||||
|
||||
function queueTemplateSlideThumbnailRefresh(templateId) {
|
||||
async function queueTemplateSlideThumbnailRefresh(templateId) {
|
||||
if (!backgroundTaskQueue || typeof backgroundTaskQueue.enqueueTask !== 'function') {
|
||||
return Promise.resolve(null);
|
||||
return null;
|
||||
}
|
||||
|
||||
const playerMetadata = await resolveThumbnailPlayerMetadata();
|
||||
const metadata = {};
|
||||
if (playerMetadata && playerMetadata.playerLabel) {
|
||||
metadata.playerId = playerMetadata.playerId;
|
||||
metadata.playerLabel = playerMetadata.playerLabel;
|
||||
metadata.affectsPlayerLabel = playerMetadata.playerLabel;
|
||||
}
|
||||
|
||||
return backgroundTaskQueue.enqueueTask({
|
||||
@@ -189,13 +231,14 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
||||
payload: {
|
||||
templateId: Number(templateId)
|
||||
},
|
||||
metadata: metadata,
|
||||
persist: true
|
||||
});
|
||||
}
|
||||
|
||||
async function canvasSizeExists(width, height, ignoreId) {
|
||||
const params = [width, height];
|
||||
let query = 'SELECT COUNT(*) AS count FROM canvas_sizes WHERE width = ? AND height = ?';
|
||||
let query = 'SELECT COUNT(*) AS count FROM c_canvas_sizes WHERE width = ? AND height = ?';
|
||||
if (ignoreId) {
|
||||
query += ' AND id <> ?';
|
||||
params.push(ignoreId);
|
||||
@@ -214,8 +257,8 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
||||
|
||||
const [rows] = await pool.query(
|
||||
`SELECT s.slug, COUNT(ps.id) AS slide_count
|
||||
FROM screens s
|
||||
LEFT JOIN playlist_slides ps ON ps.playlist_id = s.playlist_id
|
||||
FROM d_screens s
|
||||
LEFT JOIN c_playlist_slides ps ON ps.playlist_id = s.playlist_id
|
||||
WHERE s.slug IN (?)
|
||||
GROUP BY s.id, s.slug`,
|
||||
[uniqueSlugs]
|
||||
@@ -334,13 +377,13 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
||||
try {
|
||||
await validateUploadedFiles(req.files || []);
|
||||
const payload = await common.buildSlidePayload(pool, req, null);
|
||||
if (await common.fetchDuplicateName(pool, 'slides', payload.title, null, 'title')) {
|
||||
if (await common.fetchDuplicateName(pool, 'c_slides', payload.title, null, 'title')) {
|
||||
return res.status(400).send('A slide with that title already exists.');
|
||||
}
|
||||
const actorId = getAuditUserId(req);
|
||||
const [result] = await pool.query(
|
||||
'INSERT INTO slides (title, body, template_id, content_json, media_path, media_type, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[payload.title, payload.body, payload.templateId, payload.contentJson, payload.mediaPath, payload.mediaType, actorId, actorId]
|
||||
'INSERT INTO c_slides (title, template_id, content_json, created_by, modified_by) VALUES (?, ?, ?, ?, ?)',
|
||||
[payload.title, payload.templateId, payload.contentJson, actorId, actorId]
|
||||
);
|
||||
await syncPlaylistUploadsOnChange({
|
||||
key: 'slide:create:' + result.insertId,
|
||||
@@ -372,14 +415,14 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
||||
const screenSlideCounts = await fetchScreenSlideCountsBySlug(affectedScreens);
|
||||
const existingUploadRefs = collectUploadReferencesFromSlide(slide);
|
||||
const payload = await common.buildSlidePayload(pool, req, slide);
|
||||
if (await common.fetchDuplicateName(pool, 'slides', payload.title, slide.id, 'title')) {
|
||||
if (await common.fetchDuplicateName(pool, 'c_slides', payload.title, slide.id, 'title')) {
|
||||
return res.status(400).send('A slide with that title already exists.');
|
||||
}
|
||||
const nextUploadRefs = collectUploadReferencesFromPayload(payload);
|
||||
const actorId = getAuditUserId(req);
|
||||
await pool.query(
|
||||
'UPDATE slides SET title = ?, body = ?, template_id = ?, content_json = ?, media_path = ?, media_type = ?, modified_by = ? WHERE id = ?',
|
||||
[payload.title, payload.body, payload.templateId, payload.contentJson, payload.mediaPath, payload.mediaType, actorId, slide.id]
|
||||
'UPDATE c_slides SET title = ?, template_id = ?, content_json = ?, modified_by = ? WHERE id = ?',
|
||||
[payload.title, payload.templateId, payload.contentJson, actorId, slide.id]
|
||||
);
|
||||
await syncPlaylistUploadsOnChange({
|
||||
key: 'slide:update:' + slide.id,
|
||||
@@ -418,7 +461,7 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
||||
const affectedScreens = await fetchScreensBySlideId(pool, slide.id);
|
||||
const screenSlideCounts = await fetchScreenSlideCountsBySlug(affectedScreens);
|
||||
const uploadRefs = collectUploadReferencesFromSlide(slide);
|
||||
await pool.query('DELETE FROM slides WHERE id = ?', [slide.id]);
|
||||
await pool.query('DELETE FROM c_slides WHERE id = ?', [slide.id]);
|
||||
await syncPlaylistUploadsOnChange({
|
||||
key: 'slide:delete:' + slide.id,
|
||||
pool: pool,
|
||||
@@ -463,19 +506,19 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
||||
app.post('/templates', requirePermission('templates.create'), upload.any(), async function (req, res, next) {
|
||||
try {
|
||||
const payload = await common.buildTemplatePayload(pool, req, null);
|
||||
if (await common.fetchDuplicateName(pool, 'slide_templates', payload.name)) {
|
||||
if (await common.fetchDuplicateName(pool, 'c_templates', payload.name)) {
|
||||
return res.redirect('/templates/new?message=' + encodeURIComponent('A template with that name already exists.'));
|
||||
}
|
||||
const actorId = getAuditUserId(req);
|
||||
const [result] = await pool.query(
|
||||
'INSERT INTO slide_templates (name, canvas_size_id, background_image_path, background_color, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
'INSERT INTO c_templates (name, canvas_size_id, background_image_path, background_color, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
[payload.name, payload.canvasSizeId, payload.backgroundImagePath, payload.backgroundColor, actorId, actorId]
|
||||
);
|
||||
for (let i = 0; i < payload.regions.length; i += 1) {
|
||||
const region = payload.regions[i];
|
||||
await pool.query(
|
||||
'INSERT INTO slide_template_regions (template_id, region_key, region_type, label, font_family, lock_ratio, x, y, width, height, z_index, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[result.insertId, region.region_key, region.region_type, region.label, region.font_family, region.lock_ratio, region.x, region.y, region.width, region.height, region.z_index, actorId, actorId]
|
||||
'INSERT INTO c_template_regions (template_id, region_key, region_type, label, lock_ratio, x, y, width, height, z_index, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[result.insertId, region.region_key, region.region_type, region.label, region.lock_ratio, region.x, region.y, region.width, region.height, region.z_index, actorId, actorId]
|
||||
);
|
||||
}
|
||||
await syncPlaylistUploadsOnChange({
|
||||
@@ -517,7 +560,7 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
||||
template.region_usage = await fetchTemplateRegionUsage(template);
|
||||
const existingUploadRefs = collectUploadReferencesFromTemplate(template);
|
||||
const payload = await common.buildTemplatePayload(pool, req, template);
|
||||
if (await common.fetchDuplicateName(pool, 'slide_templates', payload.name, template.id)) {
|
||||
if (await common.fetchDuplicateName(pool, 'c_templates', payload.name, template.id)) {
|
||||
return res.redirect('/templates/' + template.id + '/edit?message=' + encodeURIComponent('A template with that name already exists.'));
|
||||
}
|
||||
const regionDeleteBlockMessage = await getTemplateRegionDeleteBlockMessage(template, payload.regions);
|
||||
@@ -528,15 +571,15 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
||||
const affectedScreens = await fetchScreensByTemplateId(pool, template.id);
|
||||
const actorId = getAuditUserId(req);
|
||||
await pool.query(
|
||||
'UPDATE slide_templates SET name = ?, canvas_size_id = ?, background_image_path = ?, background_color = ?, modified_by = ? WHERE id = ?',
|
||||
'UPDATE c_templates SET name = ?, canvas_size_id = ?, background_image_path = ?, background_color = ?, modified_by = ? WHERE id = ?',
|
||||
[payload.name, payload.canvasSizeId, payload.backgroundImagePath, payload.backgroundColor, actorId, template.id]
|
||||
);
|
||||
await pool.query('DELETE FROM slide_template_regions WHERE template_id = ?', [template.id]);
|
||||
await pool.query('DELETE FROM c_template_regions WHERE template_id = ?', [template.id]);
|
||||
for (let i = 0; i < payload.regions.length; i += 1) {
|
||||
const region = payload.regions[i];
|
||||
await pool.query(
|
||||
'INSERT INTO slide_template_regions (template_id, region_key, region_type, label, font_family, lock_ratio, x, y, width, height, z_index, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[template.id, region.region_key, region.region_type, region.label, region.font_family, region.lock_ratio, region.x, region.y, region.width, region.height, region.z_index, actorId, actorId]
|
||||
'INSERT INTO c_template_regions (template_id, region_key, region_type, label, lock_ratio, x, y, width, height, z_index, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[template.id, region.region_key, region.region_type, region.label, region.lock_ratio, region.x, region.y, region.width, region.height, region.z_index, actorId, actorId]
|
||||
);
|
||||
}
|
||||
await syncPlaylistUploadsOnChange({
|
||||
@@ -575,9 +618,9 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
||||
}
|
||||
const uploadRefs = collectUploadReferencesFromTemplate(template);
|
||||
const affectedScreens = await fetchScreensByTemplateId(pool, template.id);
|
||||
await pool.query('UPDATE slides SET template_id = NULL, modified_by = ? WHERE template_id = ?', [getAuditUserId(req), template.id]);
|
||||
await pool.query('DELETE FROM slide_template_regions WHERE template_id = ?', [template.id]);
|
||||
await pool.query('DELETE FROM slide_templates WHERE id = ?', [template.id]);
|
||||
await pool.query('UPDATE c_slides SET template_id = NULL, modified_by = ? WHERE template_id = ?', [getAuditUserId(req), template.id]);
|
||||
await pool.query('DELETE FROM c_template_regions WHERE template_id = ?', [template.id]);
|
||||
await pool.query('DELETE FROM c_templates WHERE id = ?', [template.id]);
|
||||
await syncPlaylistUploadsOnChange({
|
||||
key: 'template:delete:' + template.id,
|
||||
pool: pool,
|
||||
@@ -614,14 +657,14 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
||||
app.post('/canvas-sizes', requirePermission('canvas-sizes.create'), async function (req, res, next) {
|
||||
try {
|
||||
const payload = common.buildCanvasSizePayload(req, null);
|
||||
if (await common.fetchDuplicateName(pool, 'canvas_sizes', payload.name)) {
|
||||
if (await common.fetchDuplicateName(pool, 'c_canvas_sizes', payload.name)) {
|
||||
return res.redirect('/canvas-sizes/new?message=' + encodeURIComponent('A canvas size with that name already exists.'));
|
||||
}
|
||||
if (await canvasSizeExists(payload.width, payload.height)) {
|
||||
return res.redirect('/canvas-sizes/new?message=' + encodeURIComponent('That canvas size already exists.'));
|
||||
}
|
||||
const actorId = getAuditUserId(req);
|
||||
const [result] = await pool.query('INSERT INTO canvas_sizes (name, width, height, created_by, modified_by) VALUES (?, ?, ?, ?, ?)', [payload.name, payload.width, payload.height, actorId, actorId]);
|
||||
const [result] = await pool.query('INSERT INTO c_canvas_sizes (name, width, height, created_by, modified_by) VALUES (?, ?, ?, ?, ?)', [payload.name, payload.width, payload.height, actorId, actorId]);
|
||||
redirectAfterSave(req, res, '/canvas-sizes/' + result.insertId + '/edit', {
|
||||
closeUrl: '/canvas-sizes',
|
||||
newUrl: '/canvas-sizes/new',
|
||||
@@ -651,13 +694,13 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
||||
return res.status(404).send('Canvas size not found');
|
||||
}
|
||||
const payload = common.buildCanvasSizePayload(req, canvasSize);
|
||||
if (await common.fetchDuplicateName(pool, 'canvas_sizes', payload.name, canvasSize.id)) {
|
||||
if (await common.fetchDuplicateName(pool, 'c_canvas_sizes', payload.name, canvasSize.id)) {
|
||||
return res.redirect('/canvas-sizes/' + canvasSize.id + '/edit?message=' + encodeURIComponent('A canvas size with that name already exists.'));
|
||||
}
|
||||
if (await canvasSizeExists(payload.width, payload.height, canvasSize.id)) {
|
||||
return res.redirect('/canvas-sizes/' + canvasSize.id + '/edit?message=' + encodeURIComponent('That canvas size already exists.'));
|
||||
}
|
||||
await pool.query('UPDATE canvas_sizes SET name = ?, width = ?, height = ?, modified_by = ? WHERE id = ?', [payload.name, payload.width, payload.height, getAuditUserId(req), canvasSize.id]);
|
||||
await pool.query('UPDATE c_canvas_sizes SET name = ?, width = ?, height = ?, modified_by = ? WHERE id = ?', [payload.name, payload.width, payload.height, getAuditUserId(req), canvasSize.id]);
|
||||
redirectAfterSave(req, res, '/canvas-sizes', {
|
||||
closeUrl: '/canvas-sizes',
|
||||
newUrl: '/canvas-sizes/new',
|
||||
@@ -678,8 +721,8 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
||||
if (blockMessage) {
|
||||
return res.redirect('/canvas-sizes?message=' + encodeURIComponent(blockMessage));
|
||||
}
|
||||
await pool.query('UPDATE slide_templates SET canvas_size_id = NULL, modified_by = ? WHERE canvas_size_id = ?', [getAuditUserId(req), canvasSize.id]);
|
||||
await pool.query('DELETE FROM canvas_sizes WHERE id = ?', [canvasSize.id]);
|
||||
await pool.query('UPDATE c_templates SET canvas_size_id = NULL, modified_by = ? WHERE canvas_size_id = ?', [getAuditUserId(req), canvasSize.id]);
|
||||
await pool.query('DELETE FROM c_canvas_sizes WHERE id = ?', [canvasSize.id]);
|
||||
res.redirect('/canvas-sizes?message=' + encodeURIComponent('Canvas size deleted.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
|
||||
@@ -40,7 +40,7 @@ module.exports = function registerAdminDataSourceRoutes(app, deps) {
|
||||
|
||||
await connection.beginTransaction();
|
||||
await connection.query(
|
||||
'UPDATE api_sources SET last_pulled_at = ?, last_pull_error = ?, last_response_status = ?, last_response_content_type = ?, last_response_json = ?, modified_by = ? WHERE id = ?',
|
||||
'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]
|
||||
);
|
||||
await connection.commit();
|
||||
@@ -70,7 +70,7 @@ module.exports = function registerAdminDataSourceRoutes(app, deps) {
|
||||
|
||||
await connection.beginTransaction();
|
||||
await connection.query(
|
||||
'UPDATE rss_feeds SET modified_by = ? WHERE id = ?',
|
||||
'UPDATE i_rss_feeds SET modified_by = ? WHERE id = ?',
|
||||
[actorId, rssFeedId]
|
||||
);
|
||||
if (replaceRssFeedItems) {
|
||||
@@ -229,13 +229,13 @@ module.exports = function registerAdminDataSourceRoutes(app, deps) {
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
const payload = common.buildApiSourcePayload(req, null);
|
||||
if (await common.fetchDuplicateName(pool, 'api_sources', payload.name)) {
|
||||
if (await common.fetchDuplicateName(pool, 'i_api_sources', payload.name)) {
|
||||
return res.redirect('/data-sources/api-sources/new?message=' + encodeURIComponent('An API source with that name already exists.'));
|
||||
}
|
||||
const actorId = getAuditUserId(req);
|
||||
await connection.beginTransaction();
|
||||
const [result] = await connection.query(
|
||||
'INSERT INTO api_sources (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_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
'INSERT INTO i_api_sources (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_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[payload.name, payload.apiUrl, payload.updateIntervalValue, payload.updateIntervalUnit, null, null, null, null, null, actorId, actorId]
|
||||
);
|
||||
await connection.commit();
|
||||
@@ -312,13 +312,13 @@ module.exports = function registerAdminDataSourceRoutes(app, deps) {
|
||||
}
|
||||
|
||||
const payload = common.buildApiSourcePayload(req, apiSource);
|
||||
if (await common.fetchDuplicateName(pool, 'api_sources', payload.name, apiSource.id)) {
|
||||
if (await common.fetchDuplicateName(pool, 'i_api_sources', payload.name, apiSource.id)) {
|
||||
return res.redirect('/data-sources/api-sources/' + apiSource.id + '/edit?message=' + encodeURIComponent('An API source with that name already exists.'));
|
||||
}
|
||||
const actorId = getAuditUserId(req);
|
||||
await connection.beginTransaction();
|
||||
await connection.query(
|
||||
'UPDATE api_sources SET 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 = ?, modified_by = ? WHERE id = ?',
|
||||
'UPDATE i_api_sources SET 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 = ?, modified_by = ? WHERE id = ?',
|
||||
[payload.name, payload.apiUrl, payload.updateIntervalValue, payload.updateIntervalUnit, null, null, null, null, null, actorId, apiSource.id]
|
||||
);
|
||||
await connection.commit();
|
||||
@@ -366,7 +366,7 @@ module.exports = function registerAdminDataSourceRoutes(app, deps) {
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
await connection.beginTransaction();
|
||||
await connection.query('DELETE FROM api_sources WHERE id = ?', [apiSource.id]);
|
||||
await connection.query('DELETE FROM i_api_sources WHERE id = ?', [apiSource.id]);
|
||||
await connection.commit();
|
||||
removeRecurringRefresh('api-source', apiSource.id);
|
||||
} catch (error) {
|
||||
@@ -405,13 +405,13 @@ module.exports = function registerAdminDataSourceRoutes(app, deps) {
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
const payload = common.buildRssFeedPayload(req, null);
|
||||
if (await common.fetchDuplicateName(pool, 'rss_feeds', payload.name)) {
|
||||
if (await common.fetchDuplicateName(pool, 'i_rss_feeds', payload.name)) {
|
||||
return res.redirect('/data-sources/rss-feeds/new?message=' + encodeURIComponent('An RSS feed with that name already exists.'));
|
||||
}
|
||||
const actorId = getAuditUserId(req);
|
||||
await connection.beginTransaction();
|
||||
const [result] = await connection.query(
|
||||
'INSERT INTO rss_feeds (name, feed_url, update_interval_value, update_interval_unit, item_limit, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
'INSERT INTO i_rss_feeds (name, feed_url, update_interval_value, update_interval_unit, item_limit, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
[payload.name, payload.feedUrl, payload.updateIntervalValue, payload.updateIntervalUnit, payload.itemLimit, actorId, actorId]
|
||||
);
|
||||
await connection.commit();
|
||||
@@ -487,13 +487,13 @@ module.exports = function registerAdminDataSourceRoutes(app, deps) {
|
||||
}
|
||||
|
||||
const payload = common.buildRssFeedPayload(req, rssFeed);
|
||||
if (await common.fetchDuplicateName(pool, 'rss_feeds', payload.name, rssFeed.id)) {
|
||||
if (await common.fetchDuplicateName(pool, 'i_rss_feeds', payload.name, rssFeed.id)) {
|
||||
return res.redirect('/data-sources/rss-feeds/' + rssFeed.id + '/edit?message=' + encodeURIComponent('An RSS feed with that name already exists.'));
|
||||
}
|
||||
const actorId = getAuditUserId(req);
|
||||
await connection.beginTransaction();
|
||||
await connection.query(
|
||||
'UPDATE rss_feeds SET name = ?, feed_url = ?, update_interval_value = ?, update_interval_unit = ?, item_limit = ?, modified_by = ? WHERE id = ?',
|
||||
'UPDATE i_rss_feeds SET name = ?, feed_url = ?, update_interval_value = ?, update_interval_unit = ?, item_limit = ?, modified_by = ? WHERE id = ?',
|
||||
[payload.name, payload.feedUrl, payload.updateIntervalValue, payload.updateIntervalUnit, payload.itemLimit, actorId, rssFeed.id]
|
||||
);
|
||||
await connection.commit();
|
||||
@@ -541,7 +541,7 @@ module.exports = function registerAdminDataSourceRoutes(app, deps) {
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
await connection.beginTransaction();
|
||||
await connection.query('DELETE FROM rss_feeds WHERE id = ?', [rssFeed.id]);
|
||||
await connection.query('DELETE FROM i_rss_feeds WHERE id = ?', [rssFeed.id]);
|
||||
await connection.commit();
|
||||
removeRecurringRefresh('rss-feed', rssFeed.id);
|
||||
} catch (error) {
|
||||
|
||||
@@ -18,11 +18,15 @@ module.exports = function registerAdminManageRoutes(app, deps) {
|
||||
const getScreenConnections = deps.getScreenConnections;
|
||||
const getPlaylistDeleteBlockMessage = deps.getPlaylistDeleteBlockMessage;
|
||||
const forwardPlayerCommand = deps.forwardPlayerCommand;
|
||||
const PLAYER_PUBLIC_BASE_URL = deps.playerPublicBaseUrl;
|
||||
const requirePermission = deps.requirePermission;
|
||||
|
||||
function normalizeOptionalPlayerId(value) {
|
||||
const normalized = Number(value);
|
||||
return Number.isFinite(normalized) && normalized > 0 ? normalized : null;
|
||||
}
|
||||
|
||||
async function fetchAllScreenSlugs() {
|
||||
const [rows] = await pool.query('SELECT slug FROM screens ORDER BY slug ASC');
|
||||
const [rows] = await pool.query('SELECT slug FROM d_screens ORDER BY slug ASC');
|
||||
return (rows || [])
|
||||
.map(function (row) {
|
||||
return String(row && row.slug ? row.slug : '').trim();
|
||||
@@ -89,11 +93,11 @@ module.exports = function registerAdminManageRoutes(app, deps) {
|
||||
if (!name) {
|
||||
return res.status(400).send('Playlist name is required.');
|
||||
}
|
||||
if (await common.fetchDuplicateName(pool, 'playlists', name)) {
|
||||
if (await common.fetchDuplicateName(pool, 'c_playlists', name)) {
|
||||
return res.status(400).send('A playlist with that name already exists.');
|
||||
}
|
||||
const actorId = getAuditUserId(req);
|
||||
const [result] = await pool.query('INSERT INTO playlists (name, fade_between_slides, skip_unavailable_rtmp, created_by, modified_by) VALUES (?, ?, ?, ?, ?)', [name, fadeBetweenSlides, skipUnavailableRtmp, actorId, actorId]);
|
||||
const [result] = await pool.query('INSERT INTO c_playlists (name, fade_between_slides, skip_unavailable_rtmp, created_by, modified_by) VALUES (?, ?, ?, ?, ?)', [name, fadeBetweenSlides, skipUnavailableRtmp, actorId, actorId]);
|
||||
redirectAfterSave(req, res, '/playlists?edit=' + result.insertId, {
|
||||
closeUrl: '/playlists',
|
||||
newUrl: '/playlists/new',
|
||||
@@ -117,7 +121,7 @@ module.exports = function registerAdminManageRoutes(app, deps) {
|
||||
if (!playlist) {
|
||||
return res.status(404).send('Playlist not found');
|
||||
}
|
||||
if (await common.fetchDuplicateName(pool, 'playlists', name, playlist.id)) {
|
||||
if (await common.fetchDuplicateName(pool, 'c_playlists', name, playlist.id)) {
|
||||
return res.status(400).send('A playlist with that name already exists.');
|
||||
}
|
||||
|
||||
@@ -215,9 +219,9 @@ module.exports = function registerAdminManageRoutes(app, deps) {
|
||||
if (normalizedSlides.length) {
|
||||
const [slides] = await connection.query(
|
||||
`SELECT sl.id, cs.width AS canvas_width, cs.height AS canvas_height
|
||||
FROM slides sl
|
||||
LEFT JOIN slide_templates st ON st.id = sl.template_id
|
||||
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id
|
||||
FROM c_slides sl
|
||||
LEFT JOIN c_templates st ON st.id = sl.template_id
|
||||
LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id
|
||||
WHERE sl.id IN (?)`,
|
||||
[normalizedSlides.map(function (item) { return item.slideId; })]
|
||||
);
|
||||
@@ -237,12 +241,12 @@ module.exports = function registerAdminManageRoutes(app, deps) {
|
||||
const actorId = getAuditUserId(req);
|
||||
|
||||
await connection.beginTransaction();
|
||||
await connection.query('UPDATE playlists SET name = ?, fade_between_slides = ?, skip_unavailable_rtmp = ?, modified_by = ? WHERE id = ?', [name, fadeBetweenSlides, skipUnavailableRtmp, actorId, playlist.id]);
|
||||
await connection.query('DELETE FROM playlist_slides WHERE playlist_id = ?', [playlist.id]);
|
||||
await connection.query('UPDATE c_playlists SET name = ?, fade_between_slides = ?, skip_unavailable_rtmp = ?, modified_by = ? WHERE id = ?', [name, fadeBetweenSlides, skipUnavailableRtmp, actorId, playlist.id]);
|
||||
await connection.query('DELETE FROM c_playlist_slides WHERE playlist_id = ?', [playlist.id]);
|
||||
for (let i = 0; i < normalizedSlides.length; i += 1) {
|
||||
const item = normalizedSlides[i];
|
||||
await connection.query(
|
||||
'INSERT INTO playlist_slides (playlist_id, slide_id, position, duration_seconds, use_video_duration, schedule_mode, schedule_start_datetime, schedule_end_datetime, schedule_start_time, schedule_end_time, schedule_days_json, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
'INSERT INTO c_playlist_slides (playlist_id, slide_id, position, duration_seconds, use_video_duration, schedule_mode, schedule_start_datetime, schedule_end_datetime, schedule_start_time, schedule_end_time, schedule_days_json, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[
|
||||
playlist.id,
|
||||
item.slideId,
|
||||
@@ -290,7 +294,7 @@ module.exports = function registerAdminManageRoutes(app, deps) {
|
||||
if (blockMessage) {
|
||||
return res.redirect('/playlists?message=' + encodeURIComponent(blockMessage));
|
||||
}
|
||||
await pool.query('DELETE FROM playlists WHERE id = ?', [playlist.id]);
|
||||
await pool.query('DELETE FROM c_playlists WHERE id = ?', [playlist.id]);
|
||||
res.redirect('/playlists?message=' + encodeURIComponent('Playlist deleted.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
@@ -321,10 +325,10 @@ module.exports = function registerAdminManageRoutes(app, deps) {
|
||||
}
|
||||
const durationValue = Number(req.body.duration_seconds || 10);
|
||||
const durationSeconds = Number.isFinite(durationValue) ? Math.max(0.001, Math.round(durationValue * 1000) / 1000) : 10;
|
||||
const [positionRows] = await pool.query('SELECT COALESCE(MAX(position), -1) AS max_position FROM playlist_slides WHERE playlist_id = ?', [playlist.id]);
|
||||
const [positionRows] = await pool.query('SELECT COALESCE(MAX(position), -1) AS max_position FROM c_playlist_slides WHERE playlist_id = ?', [playlist.id]);
|
||||
const nextPosition = Number(positionRows[0].max_position) + 1;
|
||||
const actorId = getAuditUserId(req);
|
||||
await pool.query('INSERT INTO playlist_slides (playlist_id, slide_id, position, duration_seconds, use_video_duration, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?)', [playlist.id, slideId, nextPosition, durationSeconds, 0, actorId, actorId]);
|
||||
await pool.query('INSERT INTO c_playlist_slides (playlist_id, slide_id, position, duration_seconds, use_video_duration, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?)', [playlist.id, slideId, nextPosition, durationSeconds, 0, actorId, actorId]);
|
||||
await notifyPlayerScreens(await fetchScreensByPlaylistId(pool, playlist.id), 'refresh');
|
||||
await broadcastDashboardState();
|
||||
res.redirect('/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide added to playlist.'));
|
||||
@@ -344,7 +348,7 @@ module.exports = function registerAdminManageRoutes(app, deps) {
|
||||
const durationSeconds = Number.isFinite(durationValue) ? Math.max(0.001, Math.round(durationValue * 1000) / 1000) : 10;
|
||||
const actorId = getAuditUserId(req);
|
||||
const [result] = await pool.query(
|
||||
'UPDATE playlist_slides SET duration_seconds = ?, modified_by = ? WHERE id = ? AND playlist_id = ?',
|
||||
'UPDATE c_playlist_slides SET duration_seconds = ?, modified_by = ? WHERE id = ? AND playlist_id = ?',
|
||||
[durationSeconds, actorId, playlistSlideId, playlist.id]
|
||||
);
|
||||
if (!result.affectedRows) {
|
||||
@@ -391,8 +395,8 @@ module.exports = function registerAdminManageRoutes(app, deps) {
|
||||
const swapSlide = orderedSlides[swapIndex];
|
||||
const actorId = getAuditUserId(req);
|
||||
|
||||
await connection.query('UPDATE playlist_slides SET position = ?, modified_by = ? WHERE id = ? AND playlist_id = ?', [swapSlide.position, actorId, currentSlide.id, playlist.id]);
|
||||
await connection.query('UPDATE playlist_slides SET position = ?, modified_by = ? WHERE id = ? AND playlist_id = ?', [currentSlide.position, actorId, swapSlide.id, playlist.id]);
|
||||
await connection.query('UPDATE c_playlist_slides SET position = ?, modified_by = ? WHERE id = ? AND playlist_id = ?', [swapSlide.position, actorId, currentSlide.id, playlist.id]);
|
||||
await connection.query('UPDATE c_playlist_slides SET position = ?, modified_by = ? WHERE id = ? AND playlist_id = ?', [currentSlide.position, actorId, swapSlide.id, playlist.id]);
|
||||
await connection.commit();
|
||||
await notifyPlayerScreens(await fetchScreensByPlaylistId(connection, playlist.id), 'refresh');
|
||||
await broadcastDashboardState();
|
||||
@@ -508,7 +512,7 @@ module.exports = function registerAdminManageRoutes(app, deps) {
|
||||
|
||||
const actorId = getAuditUserId(req);
|
||||
const [result] = await pool.query(
|
||||
'UPDATE playlist_slides SET schedule_mode = ?, schedule_start_datetime = ?, schedule_end_datetime = ?, schedule_start_time = ?, schedule_end_time = ?, schedule_days_json = ?, modified_by = ? WHERE id = ? AND playlist_id = ?',
|
||||
'UPDATE c_playlist_slides SET schedule_mode = ?, schedule_start_datetime = ?, schedule_end_datetime = ?, schedule_start_time = ?, schedule_end_time = ?, schedule_days_json = ?, modified_by = ? WHERE id = ? AND playlist_id = ?',
|
||||
[scheduleMode, scheduleStartDatetime, scheduleEndDatetime, scheduleStartTime, scheduleEndTime, scheduleDaysJson, actorId, playlistSlideId, playlist.id]
|
||||
);
|
||||
if (!result.affectedRows) {
|
||||
@@ -528,7 +532,7 @@ module.exports = function registerAdminManageRoutes(app, deps) {
|
||||
if (!playlist) {
|
||||
return res.status(404).send('Playlist not found');
|
||||
}
|
||||
await pool.query('DELETE FROM playlist_slides WHERE id = ? AND playlist_id = ?', [Number(req.params.playlistSlideId), playlist.id]);
|
||||
await pool.query('DELETE FROM c_playlist_slides WHERE id = ? AND playlist_id = ?', [Number(req.params.playlistSlideId), playlist.id]);
|
||||
await notifyPlayerScreens(await fetchScreensByPlaylistId(pool, playlist.id), 'refresh');
|
||||
await broadcastDashboardState();
|
||||
res.redirect('/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide removed.'));
|
||||
@@ -552,14 +556,15 @@ module.exports = function registerAdminManageRoutes(app, deps) {
|
||||
if (!name) {
|
||||
return res.status(400).send('Screen name is required.');
|
||||
}
|
||||
if (await common.fetchDuplicateName(pool, 'screens', name)) {
|
||||
if (await common.fetchDuplicateName(pool, 'd_screens', name)) {
|
||||
return res.status(400).send('A screen with that name already exists.');
|
||||
}
|
||||
const slugInput = String(req.body.slug || '').trim();
|
||||
const playlistId = req.body.playlist_id ? Number(req.body.playlist_id) : null;
|
||||
const slug = await common.uniqueScreenSlug(pool, common.slugify(slugInput || name));
|
||||
const actorId = getAuditUserId(req);
|
||||
const [result] = await pool.query('INSERT INTO screens (name, slug, playlist_id, created_by, modified_by) VALUES (?, ?, ?, ?, ?)', [name, slug, playlistId, actorId, actorId]);
|
||||
const playerId = normalizeOptionalPlayerId(req.body.player_id);
|
||||
const [result] = await pool.query('INSERT INTO d_screens (name, slug, playlist_id, player_id, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?)', [name, slug, playlistId, playerId, actorId, actorId]);
|
||||
redirectAfterSave(req, res, '/screens?edit=' + result.insertId, {
|
||||
closeUrl: '/screens',
|
||||
newUrl: '/screens/new',
|
||||
@@ -580,7 +585,7 @@ module.exports = function registerAdminManageRoutes(app, deps) {
|
||||
if (!screen) {
|
||||
return res.status(404).send('Screen not found');
|
||||
}
|
||||
if (await common.fetchDuplicateName(pool, 'screens', name, screen.id)) {
|
||||
if (await common.fetchDuplicateName(pool, 'd_screens', name, screen.id)) {
|
||||
return res.status(400).send('A screen with that name already exists.');
|
||||
}
|
||||
const slugInput = String(req.body.slug || '').trim();
|
||||
@@ -588,14 +593,16 @@ module.exports = function registerAdminManageRoutes(app, deps) {
|
||||
const previousPlaylistId = screen.playlist_id;
|
||||
const slug = await common.uniqueScreenSlug(pool, common.slugify(slugInput || name), screen.id);
|
||||
const previousSlug = String(screen.slug || '').trim();
|
||||
await pool.query('UPDATE screens SET name = ?, slug = ?, playlist_id = ?, modified_by = ? WHERE id = ?', [name, slug, playlistId, getAuditUserId(req), screen.id]);
|
||||
const selectedPlayerId = normalizeOptionalPlayerId(req.body.player_id);
|
||||
const playerId = selectedPlayerId || (screen.player_id && !screen.player_online ? Number(screen.player_id) : null);
|
||||
await pool.query('UPDATE d_screens SET name = ?, slug = ?, playlist_id = ?, player_id = ?, modified_by = ? WHERE id = ?', [name, slug, playlistId, playerId, getAuditUserId(req), screen.id]);
|
||||
if (previousPlaylistId !== playlistId && previousSlug) {
|
||||
await notifyPlayerScreens([previousSlug], 'refresh');
|
||||
}
|
||||
if (previousSlug && previousSlug !== slug) {
|
||||
await forwardPlayerCommand(previousSlug, {
|
||||
command: 'redirect',
|
||||
url: `${PLAYER_PUBLIC_BASE_URL}/screen/${encodeURIComponent(slug)}`
|
||||
url: `${String(screen.player_public_base_url || '').replace(/\/$/, '')}/screen/${encodeURIComponent(slug)}`
|
||||
});
|
||||
}
|
||||
redirectAfterSave(req, res, '/screens?edit=' + screen.id, {
|
||||
@@ -618,7 +625,7 @@ module.exports = function registerAdminManageRoutes(app, deps) {
|
||||
if (blockMessage) {
|
||||
return res.redirect('/screens?message=' + encodeURIComponent(blockMessage));
|
||||
}
|
||||
await pool.query('DELETE FROM screens WHERE id = ?', [screen.id]);
|
||||
await pool.query('DELETE FROM d_screens WHERE id = ?', [screen.id]);
|
||||
res.redirect('/screens?message=' + encodeURIComponent('Screen deleted.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
|
||||
@@ -12,12 +12,6 @@ module.exports = function registerAdminRbacRoutes(app, deps) {
|
||||
|
||||
const LIST_PAGE_SIZE = 10;
|
||||
|
||||
function slugifyRoleKey(name) {
|
||||
const value = String(name || '').trim().toLowerCase();
|
||||
const slug = value.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
||||
return slug || 'role';
|
||||
}
|
||||
|
||||
function normalizeSelectedIds(values) {
|
||||
return Array.from(new Set((Array.isArray(values) ? values : []).map(function (value) {
|
||||
return Number(value);
|
||||
@@ -40,21 +34,6 @@ module.exports = function registerAdminRbacRoutes(app, deps) {
|
||||
return normalizedActionKey.charAt(0).toUpperCase() + normalizedActionKey.slice(1);
|
||||
}
|
||||
|
||||
async function createUniqueRoleKey(baseName) {
|
||||
const baseKey = slugifyRoleKey(baseName);
|
||||
let candidate = baseKey;
|
||||
let suffix = 2;
|
||||
|
||||
while (true) {
|
||||
const [rows] = await pool.query('SELECT id FROM roles WHERE role_key = ? LIMIT 1', [candidate]);
|
||||
if (!rows.length) {
|
||||
return candidate;
|
||||
}
|
||||
candidate = `${baseKey}-${suffix}`;
|
||||
suffix += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function mapPermissionsForView(permissionRows, selectedPermissionKeys) {
|
||||
const selectedKeys = new Set(normalizePermissionKeys(selectedPermissionKeys));
|
||||
const permissionDefinitions = new Map(permissions.map(function (permission) {
|
||||
@@ -201,7 +180,7 @@ module.exports = function registerAdminRbacRoutes(app, deps) {
|
||||
if (!name) {
|
||||
return res.status(400).send(pages.renderRbacAddPage('Role name is required.', req.currentUser, createViewModel.formValues, createViewModel.permissionGroups, 'warning'));
|
||||
}
|
||||
if (await common.fetchDuplicateName(pool, 'roles', name)) {
|
||||
if (await common.fetchDuplicateName(pool, 'a_roles', name)) {
|
||||
return res.status(400).send(pages.renderRbacAddPage('A role with that name already exists.', req.currentUser, createViewModel.formValues, createViewModel.permissionGroups, 'warning'));
|
||||
}
|
||||
|
||||
@@ -211,15 +190,14 @@ module.exports = function registerAdminRbacRoutes(app, deps) {
|
||||
return res.status(400).send(pages.renderRbacAddPage('One or more selected permissions are invalid.', req.currentUser, createViewModel.formValues, createViewModel.permissionGroups, 'warning'));
|
||||
}
|
||||
|
||||
const roleKey = await createUniqueRoleKey(name);
|
||||
const actorId = getAuditUserId(req);
|
||||
const connection = await pool.getConnection();
|
||||
let insertedRoleId = null;
|
||||
try {
|
||||
await connection.beginTransaction();
|
||||
const [result] = await connection.query(
|
||||
'INSERT INTO roles (role_key, name, description, created_by, modified_by) VALUES (?, ?, ?, ?, ?)',
|
||||
[roleKey, name, description || null, actorId, actorId]
|
||||
'INSERT INTO a_roles (name, description, created_by, modified_by) VALUES (?, ?, ?, ?)',
|
||||
[name, description || null, actorId, actorId]
|
||||
);
|
||||
insertedRoleId = Number(result.insertId);
|
||||
if (selectedPermissionKeys.length) {
|
||||
@@ -295,7 +273,7 @@ module.exports = function registerAdminRbacRoutes(app, deps) {
|
||||
return res.redirect('/rbac/' + roleId + '/edit?message=' + encodeURIComponent('Role name is required.'));
|
||||
}
|
||||
|
||||
if (await common.fetchDuplicateName(pool, 'roles', name, roleId)) {
|
||||
if (await common.fetchDuplicateName(pool, 'a_roles', name, roleId)) {
|
||||
return res.redirect('/rbac/' + roleId + '/edit?message=' + encodeURIComponent('A role with that name already exists.'));
|
||||
}
|
||||
|
||||
@@ -325,7 +303,7 @@ module.exports = function registerAdminRbacRoutes(app, deps) {
|
||||
try {
|
||||
await connection.beginTransaction();
|
||||
await connection.query(
|
||||
'UPDATE roles SET name = ?, description = ?, modified_by = ? WHERE id = ?',
|
||||
'UPDATE a_roles SET name = ?, description = ?, modified_by = ? WHERE id = ?',
|
||||
[name, description || null, getAuditUserId(req), roleId]
|
||||
);
|
||||
if (shouldSyncPermissions) {
|
||||
@@ -438,14 +416,15 @@ module.exports = function registerAdminRbacRoutes(app, deps) {
|
||||
if (!role) {
|
||||
return res.status(404).send('Role not found.');
|
||||
}
|
||||
if (String(role.role_key || '') === 'administrators') {
|
||||
const defaultRole = await rbacData.fetchRoleByName(pool, 'Administrators');
|
||||
if (defaultRole && Number(role.id) === Number(defaultRole.id)) {
|
||||
return res.redirect('/rbac?message=' + encodeURIComponent('The built-in Administrators role cannot be deleted.'));
|
||||
}
|
||||
if (Number(role.user_count) > 0) {
|
||||
return res.redirect('/rbac?message=' + encodeURIComponent('Remove all users from this role before deleting it.'));
|
||||
}
|
||||
|
||||
await pool.query('DELETE FROM roles WHERE id = ?', [roleId]);
|
||||
await pool.query('DELETE FROM a_roles WHERE id = ?', [roleId]);
|
||||
res.redirect('/rbac?message=' + encodeURIComponent('Role deleted.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
|
||||
@@ -147,11 +147,11 @@ module.exports = function registerAdminUsersRoutes(app, deps) {
|
||||
if (!roleCheck.ok) {
|
||||
return renderValidationError(roleCheck.message);
|
||||
}
|
||||
if (await common.fetchDuplicateName(pool, 'users', name)) {
|
||||
if (await common.fetchDuplicateName(pool, 'a_users', name)) {
|
||||
return renderValidationError('That name already exists.');
|
||||
}
|
||||
|
||||
const [existingRows] = await connection.query('SELECT id FROM users WHERE username = ? LIMIT 1', [username]);
|
||||
const [existingRows] = await connection.query('SELECT id FROM a_users WHERE username = ? LIMIT 1', [username]);
|
||||
if (existingRows.length) {
|
||||
return renderValidationError('That username already exists.');
|
||||
}
|
||||
@@ -160,7 +160,7 @@ module.exports = function registerAdminUsersRoutes(app, deps) {
|
||||
const actorId = getAuditUserId(req);
|
||||
await connection.beginTransaction();
|
||||
const [result] = await connection.query(
|
||||
'INSERT INTO users (name, username, password_hash, password_salt, password_iterations, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
'INSERT INTO a_users (name, username, password_hash, password_salt, password_iterations, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
[name, username, passwordRecord.hash, passwordRecord.salt, passwordRecord.iterations, actorId, actorId]
|
||||
);
|
||||
await rbacData.syncUserRoles(connection, result.insertId, roleCheck.roleIds);
|
||||
@@ -188,7 +188,7 @@ module.exports = function registerAdminUsersRoutes(app, deps) {
|
||||
return res.redirect('/account?message=' + encodeURIComponent('Use My Account to update your own login details.'));
|
||||
}
|
||||
|
||||
const [rows] = await pool.query('SELECT id FROM users WHERE id = ? LIMIT 1', [userId]);
|
||||
const [rows] = await pool.query('SELECT id FROM a_users WHERE id = ? LIMIT 1', [userId]);
|
||||
if (!rows.length) {
|
||||
return res.status(404).send('User not found.');
|
||||
}
|
||||
@@ -221,7 +221,7 @@ module.exports = function registerAdminUsersRoutes(app, deps) {
|
||||
return res.redirect('/account?message=' + encodeURIComponent('Use My Account to update your own username or password.'));
|
||||
}
|
||||
|
||||
const [userRows] = await pool.query('SELECT id, username FROM users WHERE id = ? LIMIT 1', [userId]);
|
||||
const [userRows] = await pool.query('SELECT id, username FROM a_users WHERE id = ? LIMIT 1', [userId]);
|
||||
if (!userRows.length) {
|
||||
return res.status(404).send('User not found.');
|
||||
}
|
||||
@@ -231,16 +231,16 @@ module.exports = function registerAdminUsersRoutes(app, deps) {
|
||||
return res.redirect('/users/' + userId + '/edit?message=' + encodeURIComponent('Username is required.'));
|
||||
}
|
||||
|
||||
if (await common.fetchDuplicateName(pool, 'users', name, userId)) {
|
||||
if (await common.fetchDuplicateName(pool, 'a_users', name, userId)) {
|
||||
return res.redirect('/users/' + userId + '/edit?message=' + encodeURIComponent('That name already exists.'));
|
||||
}
|
||||
|
||||
const [existingRows] = await pool.query('SELECT id FROM users WHERE username = ? AND id <> ? LIMIT 1', [username, userId]);
|
||||
const [existingRows] = await pool.query('SELECT id FROM a_users WHERE username = ? AND id <> ? LIMIT 1', [username, userId]);
|
||||
if (existingRows.length) {
|
||||
return res.redirect('/users/' + userId + '/edit?message=' + encodeURIComponent('That username already exists.'));
|
||||
}
|
||||
|
||||
const [result] = await pool.query('UPDATE users SET name = ?, username = ?, modified_by = ? WHERE id = ?', [name, username, getAuditUserId(req), userId]);
|
||||
const [result] = await pool.query('UPDATE a_users SET name = ?, username = ?, modified_by = ? WHERE id = ?', [name, username, getAuditUserId(req), userId]);
|
||||
if (!result.affectedRows) {
|
||||
return res.status(404).send('User not found.');
|
||||
}
|
||||
@@ -269,17 +269,17 @@ module.exports = function registerAdminUsersRoutes(app, deps) {
|
||||
return res.redirect('/users?message=' + encodeURIComponent('Passwords do not match.'));
|
||||
}
|
||||
|
||||
const [rows] = await pool.query('SELECT id FROM users WHERE id = ? LIMIT 1', [userId]);
|
||||
const [rows] = await pool.query('SELECT id FROM a_users WHERE id = ? LIMIT 1', [userId]);
|
||||
if (!rows.length) {
|
||||
return res.status(404).send('User not found.');
|
||||
}
|
||||
|
||||
const passwordRecord = hashPassword(password);
|
||||
await pool.query(
|
||||
'UPDATE users SET password_hash = ?, password_salt = ?, password_iterations = ?, modified_by = ? WHERE id = ?',
|
||||
'UPDATE a_users SET password_hash = ?, password_salt = ?, password_iterations = ?, modified_by = ? WHERE id = ?',
|
||||
[passwordRecord.hash, passwordRecord.salt, passwordRecord.iterations, getAuditUserId(req), userId]
|
||||
);
|
||||
await pool.query('DELETE FROM auth_sessions WHERE user_id = ?', [userId]);
|
||||
await pool.query('DELETE FROM a_sessions WHERE user_id = ?', [userId]);
|
||||
res.redirect('/users?message=' + encodeURIComponent('Password updated.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
@@ -296,12 +296,12 @@ module.exports = function registerAdminUsersRoutes(app, deps) {
|
||||
return res.redirect('/users?message=' + encodeURIComponent('You cannot delete your own account from the users page.'));
|
||||
}
|
||||
|
||||
const [countRows] = await pool.query('SELECT COUNT(*) AS user_count FROM users');
|
||||
const [countRows] = await pool.query('SELECT COUNT(*) AS user_count FROM a_users');
|
||||
if (!countRows.length || Number(countRows[0].user_count) <= 1) {
|
||||
return res.redirect('/users?message=' + encodeURIComponent('At least one user must remain.'));
|
||||
}
|
||||
|
||||
const [result] = await pool.query('DELETE FROM users WHERE id = ?', [userId]);
|
||||
const [result] = await pool.query('DELETE FROM a_users WHERE id = ?', [userId]);
|
||||
if (!result.affectedRows) {
|
||||
return res.status(404).send('User not found.');
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ module.exports = function registerAuthRoutes(app, deps) {
|
||||
return res.status(400).send('Username and password are required.');
|
||||
}
|
||||
|
||||
const [rows] = await pool.query('SELECT id, name, username, password_hash, password_salt, password_iterations FROM users WHERE username = ? LIMIT 1', [username]);
|
||||
const [rows] = await pool.query('SELECT id, name, username, password_hash, password_salt, password_iterations FROM a_users WHERE username = ? LIMIT 1', [username]);
|
||||
const user = rows[0] || null;
|
||||
if (!user || !verifyPassword(password, user)) {
|
||||
return res.redirect('/login?message=' + encodeURIComponent('Invalid username or password.'));
|
||||
@@ -47,7 +47,7 @@ module.exports = function registerAuthRoutes(app, deps) {
|
||||
const cookies = parseCookies(req.headers.cookie || '');
|
||||
const token = cookies[sessionCookieName];
|
||||
if (token) {
|
||||
await pool.query('DELETE FROM auth_sessions WHERE session_hash = ?', [hashSessionToken(token)]);
|
||||
await pool.query('DELETE FROM a_sessions WHERE session_hash = ?', [hashSessionToken(token)]);
|
||||
}
|
||||
clearSessionCookie(res);
|
||||
res.redirect('/login?message=' + encodeURIComponent('You have been signed out.'));
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
const PLAYER_PUBLIC_BASE_URL = (process.env.PLAYER_PUBLIC_BASE_URL || process.env.PLAYER_BASE_URL || 'http://localhost:3001').replace(/\/$/, '');
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? '')
|
||||
.replace(/&/g, '&')
|
||||
@@ -9,8 +7,18 @@ function escapeHtml(value) {
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function screenPlayerUrl(slug) {
|
||||
return `${PLAYER_PUBLIC_BASE_URL}/screen/${encodeURIComponent(slug)}`;
|
||||
function screenPlayerUrl(screenOrSlug) {
|
||||
if (!screenOrSlug || typeof screenOrSlug !== 'object') {
|
||||
return '';
|
||||
}
|
||||
|
||||
const baseUrl = String(screenOrSlug.player_public_base_url || '').trim().replace(/\/+$/, '');
|
||||
const screenSlug = String(screenOrSlug.slug || '').trim();
|
||||
if (!baseUrl || !screenSlug) {
|
||||
return baseUrl;
|
||||
}
|
||||
|
||||
return `${baseUrl}/screen/${encodeURIComponent(screenSlug)}`;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
|
||||
@@ -111,9 +111,13 @@ function buildQueuePageViewModel(data, message, currentUser) {
|
||||
'category',
|
||||
'status',
|
||||
'errorMessage',
|
||||
'affectsPlayerLabel',
|
||||
function (item) { return item && item.metadata && item.metadata.sourceName; },
|
||||
function (item) { return item && item.metadata && item.metadata.sourceType; },
|
||||
function (item) { return item && item.metadata && item.metadata.sourceId; }
|
||||
function (item) { return item && item.metadata && item.metadata.sourceId; },
|
||||
function (item) { return item && item.metadata && item.metadata.playerLabel; },
|
||||
function (item) { return item && item.metadata && item.metadata.playerName; },
|
||||
function (item) { return item && item.metadata && item.metadata.playerId; }
|
||||
])(task);
|
||||
});
|
||||
const sortedTasks = sortRows(filteredTasks, function (task) {
|
||||
@@ -180,7 +184,8 @@ function buildQueuePageViewModel(data, message, currentUser) {
|
||||
sourceUrl: buildTaskSourceFilterUrl(queryState, task, sourceFilter),
|
||||
sourceLabel: task.metadata && task.metadata.sourceName ? String(task.metadata.sourceName).trim() : '',
|
||||
sourceTypeLabel: task.metadata && task.metadata.sourceType ? String(task.metadata.sourceType).trim() : '',
|
||||
sourceIdLabel: task.metadata && task.metadata.sourceId ? Number(task.metadata.sourceId) : ''
|
||||
sourceIdLabel: task.metadata && task.metadata.sourceId ? Number(task.metadata.sourceId) : '',
|
||||
affectsPlayerLabel: task.affectsPlayerLabel || String(task.metadata && (task.metadata.affectsPlayerLabel || task.metadata.playerLabel || task.metadata.playerName || '')).trim() || (task.metadata && task.metadata.playerId ? 'Player #' + Number(task.metadata.playerId) : '')
|
||||
});
|
||||
});
|
||||
const hasActiveFilters = Boolean(queryState.sourceType || queryState.sourceId);
|
||||
@@ -221,9 +226,13 @@ function buildScheduledPageViewModel(data, message, currentUser) {
|
||||
'lastRunAt',
|
||||
'lastStatus',
|
||||
'lastError',
|
||||
'affectsPlayerLabel',
|
||||
function (item) { return item && item.metadata && item.metadata.sourceName; },
|
||||
function (item) { return item && item.metadata && item.metadata.sourceType; },
|
||||
function (item) { return item && item.metadata && item.metadata.sourceId; }
|
||||
function (item) { return item && item.metadata && item.metadata.sourceId; },
|
||||
function (item) { return item && item.metadata && item.metadata.playerLabel; },
|
||||
function (item) { return item && item.metadata && item.metadata.playerName; },
|
||||
function (item) { return item && item.metadata && item.metadata.playerId; }
|
||||
])(task);
|
||||
});
|
||||
const sortedRecurringTasks = sortRows(filteredRecurringTasks, function (task) {
|
||||
|
||||
@@ -10,7 +10,7 @@ module.exports = function renderDashboardPage(data, message, currentUser) {
|
||||
screens: data.screens || [],
|
||||
clients: data.clients || [],
|
||||
slides: data.slides || [],
|
||||
connectedClientsCount: Number(data.connectedClientsCount || 0),
|
||||
connectedPlayersCount: Number(data.connectedPlayersCount || data.connectedClientsCount || 0),
|
||||
scripts: ['js/dashboard/dashboard-page.js']
|
||||
});
|
||||
};
|
||||
|
||||
@@ -99,33 +99,23 @@ function hasVideoRegion(contentJson) {
|
||||
}
|
||||
|
||||
function getVideoSourcePath(slide) {
|
||||
if (!slide) {
|
||||
if (!slide || !slide.content_json) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const directMediaPath = String(slide.media_path || '').trim();
|
||||
if (String(slide.media_type || '').trim().toLowerCase() === 'video' && directMediaPath) {
|
||||
return directMediaPath;
|
||||
}
|
||||
|
||||
const contentJson = slide.content_json;
|
||||
if (!contentJson) {
|
||||
return directMediaPath;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = typeof contentJson === 'string' ? JSON.parse(contentJson) : contentJson;
|
||||
const parsed = typeof slide.content_json === 'string' ? JSON.parse(slide.content_json) : slide.content_json;
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
return directMediaPath;
|
||||
return '';
|
||||
}
|
||||
|
||||
const videoRegion = Object.keys(parsed).map((key) => parsed[key]).find((region) => {
|
||||
return region && typeof region === 'object' && String(region.type || '').trim().toLowerCase() === 'video' && String(region.value || '').trim();
|
||||
});
|
||||
|
||||
return videoRegion ? String(videoRegion.value || '').trim() : directMediaPath;
|
||||
return videoRegion ? String(videoRegion.value || '').trim() : '';
|
||||
} catch (_error) {
|
||||
return directMediaPath;
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,7 +166,7 @@ module.exports = function renderPlaylistEditPage(playlist, data, message, curren
|
||||
isFirst: index === 0,
|
||||
isLast: index === items.length - 1,
|
||||
canvasSignature: getCanvasSignature(item.canvas_width, item.canvas_height) || '',
|
||||
showVideoDurationButton: String(item.media_type || '').trim().toLowerCase() === 'video' || hasVideoRegion(item.content_json),
|
||||
showVideoDurationButton: hasVideoRegion(item.content_json),
|
||||
videoSourcePath: getVideoSourcePath(item),
|
||||
videoDurationSeconds: getVideoDurationSeconds(item),
|
||||
useVideoDuration: Boolean(item.use_video_duration),
|
||||
@@ -199,7 +189,7 @@ module.exports = function renderPlaylistEditPage(playlist, data, message, curren
|
||||
}).map((slide) => Object.assign({}, slide, {
|
||||
canvasSignature: getCanvasSignature(slide.canvas_width, slide.canvas_height) || '',
|
||||
isAssigned: assignedSlideIds.has(slide.id),
|
||||
showVideoDurationButton: String(slide.media_type || '').trim().toLowerCase() === 'video' || hasVideoRegion(slide.content_json),
|
||||
showVideoDurationButton: hasVideoRegion(slide.content_json),
|
||||
videoSourcePath: getVideoSourcePath(slide),
|
||||
videoDurationSeconds: getVideoDurationSeconds(slide),
|
||||
useVideoDuration: Boolean(slide.use_video_duration),
|
||||
|
||||
@@ -6,7 +6,8 @@ module.exports = function renderScreenFormPage(data, message, currentUser) {
|
||||
active: 'screens',
|
||||
message: message,
|
||||
currentUser: currentUser || null,
|
||||
playlists: data.playlists || []
|
||||
playlists: data.playlists || [],
|
||||
players: data.players || []
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -7,7 +7,8 @@ module.exports = function renderScreenEditPage(screen, data, message, currentUse
|
||||
message: message,
|
||||
currentUser: currentUser || null,
|
||||
screen: screen,
|
||||
playlists: data.playlists || []
|
||||
playlists: data.playlists || [],
|
||||
players: data.players || []
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
+11
-2
@@ -30,8 +30,17 @@ Handlebars.registerHelper('isExternalUrl', function (value) {
|
||||
});
|
||||
|
||||
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)}`;
|
||||
if (!slug || typeof slug !== 'object') {
|
||||
return '';
|
||||
}
|
||||
|
||||
const baseUrl = String(slug.player_public_base_url || '').trim().replace(/\/+$/, '');
|
||||
const screenSlug = String(slug.slug || '').trim();
|
||||
if (!baseUrl || !screenSlug) {
|
||||
return baseUrl;
|
||||
}
|
||||
|
||||
return `${baseUrl}/screen/${encodeURIComponent(screenSlug)}`;
|
||||
});
|
||||
|
||||
Handlebars.registerHelper('json', function (value) {
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
<th data-table-sort-key="started">Started</th>
|
||||
<th data-table-sort-key="finished">Finished</th>
|
||||
<th data-table-sort-key="source">Source</th>
|
||||
<th>Affects player</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -117,11 +118,18 @@
|
||||
<span class="empty">-</span>
|
||||
{{/if}}
|
||||
</td>
|
||||
<td data-label="Affects player">
|
||||
{{#if affectsPlayerLabel}}
|
||||
<div>{{affectsPlayerLabel}}</div>
|
||||
{{else}}
|
||||
<span class="empty">-</span>
|
||||
{{/if}}
|
||||
</td>
|
||||
</tr>
|
||||
{{/each}}
|
||||
{{else}}
|
||||
<tr data-table-search-empty-default>
|
||||
<td colspan="7" class="empty">No background tasks are queued right now.</td>
|
||||
<td colspan="8" class="empty">No background tasks are queued right now.</td>
|
||||
</tr>
|
||||
{{/if}}
|
||||
</tbody>
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
<th data-table-sort-key="last_run">Last run</th>
|
||||
<th data-table-sort-key="last_result">Last result</th>
|
||||
<th data-table-sort-key="source">Source</th>
|
||||
<th>Affects player</th>
|
||||
{{#if canManage}}<th>Actions</th>{{/if}}
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -63,6 +64,13 @@
|
||||
<span class="empty">-</span>
|
||||
{{/if}}
|
||||
</td>
|
||||
<td data-label="Affects player">
|
||||
{{#if affectsPlayerLabel}}
|
||||
<div>{{affectsPlayerLabel}}</div>
|
||||
{{else}}
|
||||
<span class="empty">-</span>
|
||||
{{/if}}
|
||||
</td>
|
||||
{{#if ../canManage}}
|
||||
<td data-label="Actions">
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
@@ -76,7 +84,7 @@
|
||||
{{/each}}
|
||||
{{else}}
|
||||
<tr data-table-search-empty-default>
|
||||
<td colspan="{{#if canManage}}7{{else}}6{{/if}}" class="empty">No scheduled refreshes are registered yet.</td>
|
||||
<td colspan="{{#if canManage}}8{{else}}7{{/if}}" class="empty">No scheduled refreshes are registered yet.</td>
|
||||
</tr>
|
||||
{{/if}}
|
||||
</tbody>
|
||||
|
||||
@@ -27,8 +27,8 @@
|
||||
{{/if}}
|
||||
{{#if (hasPermission currentUser "clients.read")}}
|
||||
<div class="dashboard-hero-stat">
|
||||
<span class="dashboard-hero-stat-value">{{connectedClientsCount}}</span>
|
||||
<span class="dashboard-hero-stat-label">clients</span>
|
||||
<span class="dashboard-hero-stat-value" id="dashboard-client-count">{{connectedClientsCount}}</span>
|
||||
<span class="dashboard-hero-stat-label">clients connected</span>
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
@@ -124,13 +124,13 @@
|
||||
<div class="dashboard-screen-tile-top">
|
||||
<div class="dashboard-screen-tile-text">
|
||||
<h4 class="dashboard-screen-name">{{name}}</h4>
|
||||
<a class="dashboard-screen-link" href="{{playerUrl slug}}" target="_blank">{{playerUrl slug}}</a>
|
||||
<a class="dashboard-screen-link" href="{{playerUrl this}}" target="_blank">{{playerUrl this}}</a>
|
||||
</div>
|
||||
<span class="dashboard-screen-pill {{#if player_connection_count}}is-live{{else}}is-idle{{/if}}">
|
||||
{{#if player_connection_count}}
|
||||
{{player_connection_count}} live
|
||||
{{player_connection_count}} connected
|
||||
{{else}}
|
||||
No clients
|
||||
No clients connected
|
||||
{{/if}}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -83,7 +83,7 @@
|
||||
<tbody id="playlist-items-body" data-playlist-id="{{playlist.id}}">
|
||||
{{#if playlistSlides.length}}
|
||||
{{#each playlistSlides}}
|
||||
<tr data-playlist-slide-row data-row-key="existing-{{id}}" data-slide-id="{{slide_id}}" data-canvas-signature="{{canvasSignature}}" data-media-type="{{media_type}}" data-media-path="{{media_path}}" data-video-source-path="{{videoSourcePath}}" data-video-duration-seconds="{{videoDurationSeconds}}" data-use-video-duration="{{#if useVideoDuration}}true{{else}}false{{/if}}">
|
||||
<tr data-playlist-slide-row data-row-key="existing-{{id}}" data-slide-id="{{slide_id}}" data-canvas-signature="{{canvasSignature}}" data-video-source-path="{{videoSourcePath}}" data-video-duration-seconds="{{videoDurationSeconds}}" data-use-video-duration="{{#if useVideoDuration}}true{{else}}false{{/if}}">
|
||||
<td class="playlist-order-cell" data-label="Order">
|
||||
<div class="playlist-order-cell-inner">
|
||||
<button type="button" class="playlist-drag-handle btn btn-link p-0 text-body-secondary" data-playlist-drag-handle aria-label="Drag to reorder" title="Drag to reorder">
|
||||
|
||||
@@ -29,6 +29,16 @@
|
||||
{{/each}}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label for="screen-player" class="form-label">Player</label>
|
||||
<select id="screen-player" name="player_id" class="form-select">
|
||||
<option value="">-- not assigned --</option>
|
||||
{{#each players}}
|
||||
<option value="{{id}}">{{label}} (online)</option>
|
||||
{{/each}}
|
||||
</select>
|
||||
<div class="form-text">Only online players are shown here.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer d-flex justify-content-end">
|
||||
|
||||
@@ -31,6 +31,16 @@
|
||||
{{/each}}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label for="screen-player" class="form-label">Player</label>
|
||||
<select id="screen-player" name="player_id" class="form-select">
|
||||
<option value="">-- not assigned --</option>
|
||||
{{#each players}}
|
||||
<option value="{{id}}" {{#if (eq ../screen.player_id id)}}selected{{/if}}>{{label}} (online)</option>
|
||||
{{/each}}
|
||||
</select>
|
||||
<div class="form-text">Only online players are shown here.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer d-flex justify-content-end">
|
||||
@@ -47,10 +57,17 @@
|
||||
<div class="col-12 col-xl-4">
|
||||
<div class="card card-outline card-secondary admin-form-card h-100">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Player URL</h3>
|
||||
<h3 class="card-title">Assigned Player</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="mb-0"><a href="{{playerUrl screen.slug}}" target="_blank">{{playerUrl screen.slug}}</a></p>
|
||||
{{#if screen.player_label}}
|
||||
<p class="mb-1">{{screen.player_label}}</p>
|
||||
{{#if screen.player_public_base_url}}
|
||||
<p class="mb-0"><a href="{{screen.player_public_base_url}}" target="_blank">{{screen.player_public_base_url}}</a></p>
|
||||
{{/if}}
|
||||
{{else}}
|
||||
<p class="mb-0 text-muted">No player assigned.</p>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-table-sort-key="name">Name</th>
|
||||
<th data-table-sort-key="player">Player</th>
|
||||
<th data-table-sort-key="url">Player URL</th>
|
||||
<th data-table-sort-key="playlist">Playlist</th>
|
||||
<th>Connected clients</th>
|
||||
@@ -34,7 +35,8 @@
|
||||
{{#each screens}}
|
||||
<tr data-table-search-row>
|
||||
<td data-label="Name">{{name}}</td>
|
||||
<td data-label="Player URL"><a href="{{playerUrl slug}}" target="_blank">{{playerUrl slug}}</a></td>
|
||||
<td data-label="Player">{{#if player_label}}{{player_label}}{{else}}Unassigned{{/if}}</td>
|
||||
<td data-label="Player URL"><a href="{{playerUrl this}}" target="_blank">{{playerUrl this}}</a></td>
|
||||
<td data-label="Playlist">{{playlist_name}}</td>
|
||||
<td data-label="Connected clients">
|
||||
{{#if player_connection_count}}
|
||||
@@ -62,7 +64,7 @@
|
||||
</tr>
|
||||
{{/each}}
|
||||
{{else}}
|
||||
<tr data-table-search-empty-default><td colspan="5" class="empty">No screens yet.</td></tr>
|
||||
<tr data-table-search-empty-default><td colspan="6" class="empty">No screens yet.</td></tr>
|
||||
{{/if}}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -134,7 +134,6 @@
|
||||
<div class="region-field-grid region-field-grid--identity">
|
||||
<label>Region name<input class="form-control" name="region_name[]" value="" placeholder="region_1" required /></label>
|
||||
</div>
|
||||
<input type="hidden" name="font_family[]" value="Arial" />
|
||||
<input type="hidden" name="region_type[]" value="text" />
|
||||
<input type="hidden" name="region_key[]" value="" />
|
||||
<input type="hidden" name="region_label[]" value="" />
|
||||
|
||||
@@ -141,7 +141,6 @@
|
||||
<div class="region-field-grid region-field-grid--identity">
|
||||
<label>Region name<input class="form-control" name="region_name[]" value="" placeholder="region_1" required /></label>
|
||||
</div>
|
||||
<input type="hidden" name="font_family[]" value="Arial" />
|
||||
<input type="hidden" name="region_type[]" value="text" />
|
||||
<input type="hidden" name="region_key[]" value="" />
|
||||
<input type="hidden" name="region_label[]" value="" />
|
||||
|
||||
Reference in New Issue
Block a user