diff --git a/CHANGELOG.md b/CHANGELOG.md index 61dd7c8..ecc07ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,26 @@ All notable changes to this project will be documented in this file. - No unreleased changes recorded yet. +## 1.5.3 - 2026-07-25 + +### Added + +- A modal-based slide picker for playlist editing with search, multi-select, and slide thumbnails. +- Reusable modal and table-pagination helpers for the admin UI. +- Slide thumbnail previews in playlist schedules, with image fallbacks when a thumbnail is missing. + +### Changed + +- RTMP playback now probes session readiness more aggressively and retries startup before surfacing failures. +- Playlist editing now includes richer scheduling controls, live slide counts, and support for skipping unavailable RTMP slides. +- Admin lists and background task pages now preserve filter and pagination state during AJAX navigation. +- Shared admin shell styling and helpers were refreshed to support the newer list, modal, and pagination UI. + +### Fixed + +- Player upload sync and route handling now degrade more gracefully when backend services are unavailable. +- RTMP and playlist playback now recover more cleanly when a stream fails to initialize or respond in time. + ## 1.5.2 - 2026-07-25 ### Fixed diff --git a/Dockerfile b/Dockerfile index c48b0f4..3ae367a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,7 +2,7 @@ FROM node:24-alpine WORKDIR /app -RUN apk add --no-cache ffmpeg +RUN apk add --no-cache ffmpeg chromium nss freetype harfbuzz ttf-freefont COPY package*.json ./ RUN npm install --omit=dev --no-audit --no-fund diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..48137a3 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,3516 @@ +{ + "name": "pulse-signage", + "version": "1.5.3", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "pulse-signage", + "version": "1.5.3", + "dependencies": { + "@sparticuz/chromium": "^137.0.0", + "bootstrap-icons": "1.11.3", + "dotenv": "^17.4.2", + "express": "^4.21.2", + "handlebars": "^4.7.8", + "hls.js": "^1.5.15", + "multer": "^1.4.5-lts.1", + "mysql2": "^3.14.3", + "puppeteer-core": "^24.16.0", + "qrcode": "^1.5.4", + "sharp": "^0.35.3", + "ws": "^8.21.0" + }, + "devDependencies": { + "nodemon": "^3.1.10" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz", + "integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.2" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz", + "integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.2" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz", + "integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==", + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz", + "integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz", + "integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz", + "integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz", + "integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz", + "integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz", + "integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz", + "integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz", + "integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz", + "integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz", + "integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz", + "integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz", + "integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz", + "integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz", + "integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz", + "integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz", + "integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.2" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz", + "integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz", + "integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.2" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz", + "integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==", + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.1" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz", + "integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz", + "integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz", + "integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz", + "integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@puppeteer/browsers": { + "version": "2.13.2", + "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.13.2.tgz", + "integrity": "sha512-5EUZSUIc37H6aIXyWO0Z4y8NlF8NnjgmqeQgOGiswAU7pY0HOo16ho4+alIWmSfdZnjqBRawMsP3I5YqLSn6kw==", + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.4.3", + "extract-zip": "^2.0.1", + "progress": "^2.0.3", + "proxy-agent": "^6.5.0", + "semver": "^7.7.4", + "tar-fs": "^3.1.1", + "yargs": "^17.7.2" + }, + "bin": { + "browsers": "lib/cjs/main-cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@puppeteer/browsers/node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@puppeteer/browsers/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@puppeteer/browsers/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/@puppeteer/browsers/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@puppeteer/browsers/node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/@puppeteer/browsers/node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@puppeteer/browsers/node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/@sparticuz/chromium": { + "version": "137.0.1", + "resolved": "https://registry.npmjs.org/@sparticuz/chromium/-/chromium-137.0.1.tgz", + "integrity": "sha512-9ixW23xjzIgSwBPLhWFOnrJNsnEw4dQWZqGzBCFGwGVuBPkNiJHO5VAGKezXj81J0wMYwP04tLlJFjsN5z4ROw==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.9", + "tar-fs": "^3.0.9" + }, + "engines": { + "node": ">=20.11.0" + } + }, + "node_modules/@tootallnate/quickjs-emscripten": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", + "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.1.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz", + "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==", + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@types/yauzl": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", + "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/ast-types": { + "version": "0.13.4", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", + "integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/aws-ssl-profiles": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", + "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/b4a": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.1.tgz", + "integrity": "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==", + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/bare-events": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.9.1.tgz", + "integrity": "sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg==", + "license": "Apache-2.0", + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } + }, + "node_modules/bare-fs": { + "version": "4.7.4", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.7.4.tgz", + "integrity": "sha512-y1kC+ffIx/tPLdTE693uNjHfzTfr+ravR5tvWlMXe25nELbkqV400S71qHDwbkAQ1FVEZobB1NFRzFbCCcyBCQ==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.5.4", + "bare-path": "^3.0.0", + "bare-stream": "^2.6.4", + "bare-url": "^2.2.2", + "fast-fifo": "^1.3.2" + }, + "engines": { + "bare": ">=1.16.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } + } + }, + "node_modules/bare-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.1.1.tgz", + "integrity": "sha512-JprUlveX3QjApC1cTpsUOiscADftCGVWkzitbHsRqv84hzYwYHw2mbluddsq5TvI8mH/8Ov1f4BiMAdcB0oYnQ==", + "license": "Apache-2.0" + }, + "node_modules/bare-stream": { + "version": "2.13.3", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.3.tgz", + "integrity": "sha512-Kc+brLqvEqGkjyfiwJmImAOqLZL7OsoLKuavx+hJjgVV3nLTOjloJyPMFxjUPerGGHrNH0fLU06jjykMLWrERQ==", + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.8.1", + "streamx": "^2.25.0", + "teex": "^1.0.1" + }, + "peerDependencies": { + "bare-abort-controller": "*", + "bare-buffer": "*", + "bare-events": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + }, + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } + } + }, + "node_modules/bare-url": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.4.6.tgz", + "integrity": "sha512-iQxPClE07hETVpbRoX7JXX3v/ZQViCxe/SYCxylRLzdEx1xJAufPptfiOqR8tqiCtmbtMDANKWszzjLu1PMAZQ==", + "license": "Apache-2.0", + "dependencies": { + "bare-path": "^3.0.0" + } + }, + "node_modules/basic-ftp": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.3.1.tgz", + "integrity": "sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/body-parser": { + "version": "1.20.6", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz", + "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/bootstrap-icons": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/bootstrap-icons/-/bootstrap-icons-1.11.3.tgz", + "integrity": "sha512-+3lpHrCw/it2/7lBL15VR0HEumaBss0+f/Lb6ZvHISn1mlK83jjFpooTLsMWbIjJMDjDjOExMsTxnXSIT4k4ww==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/twbs" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/bootstrap" + } + ], + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chromium-bidi": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-14.0.0.tgz", + "integrity": "sha512-9gYlLtS6tStdRWzrtXaTMnqcM4dudNegMXJxkR0I/CXObHalYeYcAMPrL19eroNZHtJ8DQmu1E+ZNOYu/IXMXw==", + "license": "Apache-2.0", + "dependencies": { + "mitt": "^3.0.1", + "zod": "^3.24.1" + }, + "peerDependencies": { + "devtools-protocol": "*" + } + }, + "node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/concat-stream": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", + "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", + "engines": [ + "node >= 0.8" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^2.2.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, + "node_modules/data-uri-to-buffer": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz", + "integrity": "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/degenerator": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz", + "integrity": "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==", + "license": "MIT", + "dependencies": { + "ast-types": "^0.13.4", + "escodegen": "^2.1.0", + "esprima": "^4.0.1" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/devtools-protocol": { + "version": "0.0.1608973", + "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1608973.tgz", + "integrity": "sha512-Tpm17fxYzt+J7VrGdc1k8YdRqS3YV7se/M6KeemEqvUbq/n7At1rWVuXMxQgpWkdwSdIEKYbU//Bve+Shm4YNQ==", + "license": "BSD-3-Clause" + }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, + "node_modules/dotenv": { + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/escodegen": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", + "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", + "license": "BSD-2-Clause", + "dependencies": { + "esprima": "^4.0.1", + "estraverse": "^5.2.0", + "esutils": "^2.0.2" + }, + "bin": { + "escodegen": "bin/escodegen.js", + "esgenerate": "bin/esgenerate.js" + }, + "engines": { + "node": ">=6.0" + }, + "optionalDependencies": { + "source-map": "~0.6.1" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } + }, + "node_modules/express": { + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.15.1", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/extract-zip": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "license": "BSD-2-Clause", + "dependencies": { + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + }, + "bin": { + "extract-zip": "cli.js" + }, + "engines": { + "node": ">= 10.17.0" + }, + "optionalDependencies": { + "@types/yauzl": "^2.9.1" + } + }, + "node_modules/extract-zip/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/extract-zip/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "license": "MIT" + }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generate-function": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", + "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", + "license": "MIT", + "dependencies": { + "is-property": "^1.0.2" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-uri": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.5.tgz", + "integrity": "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==", + "license": "MIT", + "dependencies": { + "basic-ftp": "^5.0.2", + "data-uri-to-buffer": "^6.0.2", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/get-uri/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/get-uri/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/handlebars": { + "version": "4.7.9", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", + "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.5", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" + }, + "engines": { + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" + } + }, + "node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hls.js": { + "version": "1.6.16", + "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.16.tgz", + "integrity": "sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA==", + "license": "Apache-2.0" + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/http-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/http-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/https-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ignore-by-default": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", + "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", + "dev": true, + "license": "ISC" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.3.1.tgz", + "integrity": "sha512-1e9d3kb97NHJTIJDZW9rKqW2h6+dFa50Dy0fpPSMQp2ADje5gvKsXmdiK6dwY5t76TaTt5+P5N1Y/LoToIxP6g==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-property": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", + "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", + "license": "MIT" + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/lru.min": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz", + "integrity": "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==", + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=1.30.0", + "node": ">=8.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wellwelwel" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "license": "MIT" + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/multer": { + "version": "1.4.5-lts.2", + "resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.2.tgz", + "integrity": "sha512-VzGiVigcG9zUAoCNU+xShztrlr1auZOlurXynNvO9GiWD1/mTBbUljOKY+qMeazBqXgRnjzeEgJI/wyjJUHg9A==", + "deprecated": "Multer 1.x is impacted by a number of vulnerabilities, which have been patched in 2.x. You should upgrade to the latest 2.x version.", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.0.0", + "concat-stream": "^1.5.2", + "mkdirp": "^0.5.4", + "object-assign": "^4.1.1", + "type-is": "^1.6.4", + "xtend": "^4.0.0" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/mysql2": { + "version": "3.22.6", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.22.6.tgz", + "integrity": "sha512-fPKmeDGUzvFP7bMD5SASlJ5zIgvCC4hbanTmhbUlEmhyrY1hR4Hi3xLOWgTd3luYjLVifx6uGvMNJ2m/LlqEpg==", + "license": "MIT", + "dependencies": { + "aws-ssl-profiles": "^1.1.2", + "denque": "^2.1.0", + "generate-function": "^2.3.1", + "iconv-lite": "^0.7.2", + "long": "^5.3.2", + "lru.min": "^1.1.4", + "named-placeholders": "^1.1.6", + "sql-escaper": "^1.4.0" + }, + "engines": { + "node": ">= 8.0" + }, + "peerDependencies": { + "@types/node": ">= 8" + } + }, + "node_modules/mysql2/node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/named-placeholders": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz", + "integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==", + "license": "MIT", + "dependencies": { + "lru.min": "^1.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "license": "MIT" + }, + "node_modules/netmask": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.1.1.tgz", + "integrity": "sha512-eonl3sLUha+S1GzTPxychyhnUzKyeQkZ7jLjKrBagJgPla13F+uQ71HgpFefyHgqrjEbCPkDArxYsjY8/+gLKA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/nodemon": { + "version": "3.1.14", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz", + "integrity": "sha512-jakjZi93UtB3jHMWsXL68FXSAosbLfY0In5gtKq3niLSkrWznrVBzXFNOEMJUfc9+Ke7SHWoAZsiMkNP3vq6Jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.2", + "debug": "^4", + "ignore-by-default": "^1.0.1", + "minimatch": "^10.2.1", + "pstree.remy": "^1.1.8", + "semver": "^7.5.3", + "simple-update-notifier": "^2.0.0", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "bin": { + "nodemon": "bin/nodemon.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nodemon" + } + }, + "node_modules/nodemon/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/nodemon/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/pac-proxy-agent": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz", + "integrity": "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==", + "license": "MIT", + "dependencies": { + "@tootallnate/quickjs-emscripten": "^0.23.0", + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "get-uri": "^6.0.1", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.6", + "pac-resolver": "^7.0.1", + "socks-proxy-agent": "^8.0.5" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/pac-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/pac-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/pac-resolver": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz", + "integrity": "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==", + "license": "MIT", + "dependencies": { + "degenerator": "^5.0.0", + "netmask": "^2.0.2" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "license": "MIT" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/proxy-agent": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.5.0.tgz", + "integrity": "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "http-proxy-agent": "^7.0.1", + "https-proxy-agent": "^7.0.6", + "lru-cache": "^7.14.1", + "pac-proxy-agent": "^7.1.0", + "proxy-from-env": "^1.1.0", + "socks-proxy-agent": "^8.0.5" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/proxy-agent/node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/pstree.remy": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", + "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/puppeteer-core": { + "version": "24.43.1", + "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.43.1.tgz", + "integrity": "sha512-T5ScUMAsmhdNbgDR41AGESYeS6V9MSgetkSnVhhW+gXvzC42VesKCn5ld87gAZDJ6vLHL9GkRvY9WtQWSnwFbw==", + "license": "Apache-2.0", + "dependencies": { + "@puppeteer/browsers": "2.13.2", + "chromium-bidi": "14.0.0", + "debug": "^4.4.3", + "devtools-protocol": "0.0.1608973", + "typed-query-selector": "^2.12.2", + "webdriver-bidi-protocol": "0.4.1", + "ws": "^8.20.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/puppeteer-core/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/puppeteer-core/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/readable-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/sharp": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz", + "integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==", + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.3", + "@img/sharp-darwin-x64": "0.35.3", + "@img/sharp-freebsd-wasm32": "0.35.3", + "@img/sharp-libvips-darwin-arm64": "1.3.2", + "@img/sharp-libvips-darwin-x64": "1.3.2", + "@img/sharp-libvips-linux-arm": "1.3.2", + "@img/sharp-libvips-linux-arm64": "1.3.2", + "@img/sharp-libvips-linux-ppc64": "1.3.2", + "@img/sharp-libvips-linux-riscv64": "1.3.2", + "@img/sharp-libvips-linux-s390x": "1.3.2", + "@img/sharp-libvips-linux-x64": "1.3.2", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", + "@img/sharp-libvips-linuxmusl-x64": "1.3.2", + "@img/sharp-linux-arm": "0.35.3", + "@img/sharp-linux-arm64": "0.35.3", + "@img/sharp-linux-ppc64": "0.35.3", + "@img/sharp-linux-riscv64": "0.35.3", + "@img/sharp-linux-s390x": "0.35.3", + "@img/sharp-linux-x64": "0.35.3", + "@img/sharp-linuxmusl-arm64": "0.35.3", + "@img/sharp-linuxmusl-x64": "0.35.3", + "@img/sharp-webcontainers-wasm32": "0.35.3", + "@img/sharp-win32-arm64": "0.35.3", + "@img/sharp-win32-ia32": "0.35.3", + "@img/sharp-win32-x64": "0.35.3" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz", + "integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.1.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", + "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/socks-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socks-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sql-escaper": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.5.0.tgz", + "integrity": "sha512-JCY91/D1lVq7p6RzuHg9XFQH9vT9L22bljsk+uTdMWke4ZqPCCE+iIMNPmJPUUTywn6eW2nncaWc+MizKZgF4A==", + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=2.0.0", + "node": ">=12.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/mysqljs/sql-escaper?sponsor=1" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/streamx": { + "version": "2.28.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.28.0.tgz", + "integrity": "sha512-1Yowhzjf0ivGMrTIkY9hav5TxobO9qIVqUE41fiCGMGgc3CLlf4MY+9AHmZqBWgDTue0fY9zWjYFVyf6Diuobw==", + "license": "MIT", + "dependencies": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/tar-fs": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.3.tgz", + "integrity": "sha512-/hU4AXnIdZu+Gvl1pk0oI5f5HxWsCJRtY2aFaJdk9VvyL48DWU6iU5WAIPG+wIi1YvWA6eTJvIviP/tMAZZNwQ==", + "license": "MIT", + "dependencies": { + "pump": "^3.0.0", + "tar-stream": "^3.1.5" + }, + "optionalDependencies": { + "bare-fs": "^4.0.1", + "bare-path": "^3.0.0" + } + }, + "node_modules/tar-stream": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.2.0.tgz", + "integrity": "sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==", + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "bare-fs": "^4.5.5", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, + "node_modules/teex": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", + "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==", + "license": "MIT", + "dependencies": { + "streamx": "^2.12.5" + } + }, + "node_modules/text-decoder": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", + "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==", + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/touch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", + "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", + "dev": true, + "license": "ISC", + "bin": { + "nodetouch": "bin/nodetouch.js" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typed-query-selector": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.2.tgz", + "integrity": "sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ==", + "license": "MIT" + }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, + "node_modules/uglify-js": { + "version": "3.19.3", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", + "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", + "license": "BSD-2-Clause", + "optional": true, + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/undefsafe": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", + "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/webdriver-bidi-protocol": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.1.tgz", + "integrity": "sha512-ARrjNjtWRRs2w4Tk7nqrf2gBI0QXWuOmMCx2hU+1jUt6d00MjMxURrhxhGbrsoiZKJrhTSTzbIrc554iKI10qw==", + "license": "Apache-2.0" + }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, + "node_modules/wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", + "license": "MIT" + }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/package.json b/package.json index bd6d099..3f9d96b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pulse-signage", - "version": "1.5.2", + "version": "1.5.3", "private": false, "description": "Pulse Signage application with MySQL and media storage", "repository": { @@ -16,14 +16,17 @@ "dev:player": "nodemon -r dotenv/config src/player.js" }, "dependencies": { + "@sparticuz/chromium": "^137.0.0", "bootstrap-icons": "1.11.3", "dotenv": "^17.4.2", "express": "^4.21.2", - "hls.js": "^1.5.15", "handlebars": "^4.7.8", + "hls.js": "^1.5.15", "multer": "^1.4.5-lts.1", "mysql2": "^3.14.3", + "puppeteer-core": "^24.16.0", "qrcode": "^1.5.4", + "sharp": "^0.35.3", "ws": "^8.21.0" }, "devDependencies": { diff --git a/src/common.js b/src/common.js index 9449057..8b4fbf7 100644 --- a/src/common.js +++ b/src/common.js @@ -9,12 +9,19 @@ module.exports = { uniqueScreenSlug: data.uniqueScreenSlug, parseJsonSafe: data.parseJsonSafe, fetchAdminData: data.fetchAdminData, + fetchPlaylistsPage: data.fetchPlaylistsPage, + fetchSlidesPage: data.fetchSlidesPage, + fetchTemplatesPage: data.fetchTemplatesPage, + fetchCanvasSizesPage: data.fetchCanvasSizesPage, + fetchScreensPage: data.fetchScreensPage, fetchPlaylistById: data.fetchPlaylistById, fetchApiSourcesData: data.fetchApiSourcesData, + fetchApiSourcesPage: data.fetchApiSourcesPage, fetchApiSourceById: data.fetchApiSourceById, fetchApiSourceResponse: data.fetchApiSourceResponse, buildApiSourcePayload: data.buildApiSourcePayload, fetchRssFeedsData: data.fetchRssFeedsData, + fetchRssFeedsPage: data.fetchRssFeedsPage, fetchRssFeedById: data.fetchRssFeedById, fetchRssFeedItemsByFeedId: data.fetchRssFeedItemsByFeedId, normalizeRssFeedItem: data.normalizeRssFeedItem, diff --git a/src/data/admin.js b/src/data/admin.js index ea18674..069f9b4 100644 --- a/src/data/admin.js +++ b/src/data/admin.js @@ -1,5 +1,7 @@ +const { fetchPagedRows } = require('./utils'); + async function fetchAdminData(pool) { - const [playlists] = await pool.query('SELECT id, name, fade_between_slides, created_at, modified_at, created_by, modified_by FROM playlists ORDER BY id DESC'); + const [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 [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, @@ -10,7 +12,7 @@ async function fetchAdminData(pool) { `); 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 [slides] = await pool.query(` - SELECT s.id, s.title, s.body, s.template_id, s.content_json, s.media_path, s.media_type, s.created_at, s.modified_at, s.created_by, s.modified_by, st.name AS template_name, cs.width AS canvas_width, cs.height AS canvas_height + 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 @@ -23,7 +25,7 @@ async function fetchAdminData(pool) { ORDER BY s.id DESC `); const [playlistSlides] = await pool.query(` - SELECT ps.id, ps.playlist_id, ps.position, ps.duration_seconds, ps.schedule_mode, ps.schedule_start_datetime, ps.schedule_end_datetime, ps.schedule_start_time, ps.schedule_end_time, ps.schedule_days_json, sl.id AS slide_id, sl.title, sl.media_path, sl.media_type, cs.width AS canvas_width, cs.height AS canvas_height + SELECT ps.id, ps.playlist_id, ps.position, ps.duration_seconds, ps.schedule_mode, ps.schedule_start_datetime, ps.schedule_end_datetime, ps.schedule_start_time, ps.schedule_end_time, ps.schedule_days_json, sl.id AS slide_id, sl.title, sl.media_path, sl.media_type, 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 @@ -33,6 +35,84 @@ async function fetchAdminData(pool) { return { playlists, canvasSizes, templates, templateRegions, slides, screens, playlistSlides }; } +async function fetchPlaylistsPage(pool, page, pageSize) { + 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 + ORDER BY p.id DESC`, + countSql: 'SELECT COUNT(*) AS count FROM playlists', + page: page, + pageSize: pageSize + }); + + return Object.assign({ playlists: paged.rows }, paged); +} + +async function fetchSlidesPage(pool, page, pageSize) { + 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 + ORDER BY s.id DESC`, + countSql: 'SELECT COUNT(*) AS count FROM slides', + page: page, + pageSize: pageSize + }); + + return Object.assign({ slides: paged.rows }, paged); +} + +async function fetchTemplatesPage(pool, page, pageSize) { + 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 + FROM slide_templates st + LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id + ORDER BY st.id DESC`, + countSql: 'SELECT COUNT(*) AS count FROM slide_templates', + page: page, + pageSize: pageSize + }); + + return Object.assign({ templates: paged.rows }, paged); +} + +async function fetchCanvasSizesPage(pool, page, pageSize) { + 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 + ORDER BY width ASC, height ASC, name ASC`, + countSql: 'SELECT COUNT(*) AS count FROM canvas_sizes', + page: page, + pageSize: pageSize + }); + + return Object.assign({ canvasSizes: paged.rows }, paged); +} + +async function fetchScreensPage(pool, page, pageSize) { + 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 + ORDER BY s.id DESC`, + countSql: 'SELECT COUNT(*) AS count FROM screens', + page: page, + pageSize: pageSize + }); + + return Object.assign({ screens: paged.rows }, paged); +} + module.exports = { - fetchAdminData + fetchAdminData, + fetchPlaylistsPage, + fetchSlidesPage, + fetchTemplatesPage, + fetchCanvasSizesPage, + fetchScreensPage }; diff --git a/src/data/api-sources.js b/src/data/api-sources.js index 994d3e6..af6623e 100644 --- a/src/data/api-sources.js +++ b/src/data/api-sources.js @@ -1,5 +1,6 @@ const http = require('http'); const https = require('https'); +const { fetchPagedRows } = require('./utils'); function normalizeUpdateIntervalUnit(value) { const unit = String(value || '').trim().toLowerCase(); @@ -14,6 +15,17 @@ async function fetchApiSourcesData(pool) { return { apiSources: apiSources }; } +async function fetchApiSourcesPage(pool, page, pageSize) { + 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', + page: page, + pageSize: pageSize + }); + + return Object.assign({ apiSources: paged.rows }, paged); +} + async function fetchApiSourceById(pool, id) { const [rows] = await pool.query( 'SELECT id, name, api_url, 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 = ?', @@ -144,6 +156,7 @@ function buildApiSourcePayload(req, existingApiSource) { module.exports = { fetchApiSourcesData: fetchApiSourcesData, + fetchApiSourcesPage: fetchApiSourcesPage, fetchApiSourceById: fetchApiSourceById, fetchApiSourceResponse: fetchApiSourceResponse, buildApiSourcePayload: buildApiSourcePayload diff --git a/src/data/canvas-sizes.js b/src/data/canvas-sizes.js index 39f17fd..1173686 100644 --- a/src/data/canvas-sizes.js +++ b/src/data/canvas-sizes.js @@ -1,8 +1,21 @@ +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'); return { canvasSizes }; } +async function fetchCanvasSizesPage(pool, page, pageSize) { + 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', + page: page, + pageSize: pageSize + }); + + return Object.assign({ canvasSizes: paged.rows }, paged); +} + async function fetchCanvasSizeById(pool, id) { const [rows] = await pool.query('SELECT id, name, width, height, created_at, modified_at, created_by, modified_by FROM canvas_sizes WHERE id = ?', [id]); return rows[0] || null; @@ -28,6 +41,7 @@ function buildCanvasSizePayload(req, existingCanvasSize) { module.exports = { fetchCanvasSizesData, + fetchCanvasSizesPage, fetchCanvasSizeById, buildCanvasSizePayload }; diff --git a/src/data/index.js b/src/data/index.js index 90ada13..372d481 100644 --- a/src/data/index.js +++ b/src/data/index.js @@ -1,7 +1,7 @@ -const { fetchAdminData } = require('./admin'); +const { fetchAdminData, fetchPlaylistsPage, fetchSlidesPage, fetchTemplatesPage, fetchCanvasSizesPage, fetchScreensPage } = require('./admin'); const { fetchPlaylistById } = require('./playlists'); -const { fetchApiSourcesData, fetchApiSourceById, fetchApiSourceResponse, buildApiSourcePayload } = require('./api-sources'); -const { fetchRssFeedsData, fetchRssFeedById, fetchRssFeedItemsByFeedId, normalizeRssFeedItem, buildRssFeedPayload, fetchRssFeedItems, replaceRssFeedItems } = require('./rss-feeds'); +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 { fetchTemplateById, fetchTemplatesData, extractTemplateRegions, buildTemplatePayload } = require('./templates'); const { fetchCanvasSizesData, fetchCanvasSizeById, buildCanvasSizePayload } = require('./canvas-sizes'); @@ -13,12 +13,19 @@ module.exports = { uniqueScreenSlug, parseJsonSafe, fetchAdminData, + fetchPlaylistsPage, + fetchSlidesPage, + fetchTemplatesPage, + fetchCanvasSizesPage, + fetchScreensPage, fetchPlaylistById, fetchApiSourcesData, + fetchApiSourcesPage, fetchApiSourceById, fetchApiSourceResponse, buildApiSourcePayload, fetchRssFeedsData, + fetchRssFeedsPage, fetchRssFeedById, fetchRssFeedItemsByFeedId, normalizeRssFeedItem, diff --git a/src/data/playlists.js b/src/data/playlists.js index ac7b8a9..88a7e79 100644 --- a/src/data/playlists.js +++ b/src/data/playlists.js @@ -1,5 +1,5 @@ async function fetchPlaylistById(pool, id) { - const [rows] = await pool.query('SELECT id, name, fade_between_slides, created_at, modified_at, created_by, modified_by FROM playlists WHERE id = ?', [id]); + const [rows] = await pool.query('SELECT id, name, fade_between_slides, skip_unavailable_rtmp, created_at, modified_at, created_by, modified_by FROM playlists WHERE id = ?', [id]); return rows[0] || null; } diff --git a/src/data/rss-feeds.js b/src/data/rss-feeds.js index e16b974..123f50d 100644 --- a/src/data/rss-feeds.js +++ b/src/data/rss-feeds.js @@ -1,5 +1,6 @@ const http = require('http'); const https = require('https'); +const { fetchPagedRows } = require('./utils'); function normalizeUpdateIntervalUnit(value) { const unit = String(value || '').trim().toLowerCase(); @@ -14,6 +15,17 @@ async function fetchRssFeedsData(pool) { return { rssFeeds: rssFeeds }; } +async function fetchRssFeedsPage(pool, page, pageSize) { + 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', + page: page, + pageSize: pageSize + }); + + return Object.assign({ rssFeeds: paged.rows }, paged); +} + async function fetchRssFeedById(pool, id) { const [rows] = await pool.query( 'SELECT id, name, feed_url, update_interval_value, update_interval_unit, item_limit, created_at, modified_at, created_by, modified_by FROM rss_feeds WHERE id = ?', @@ -285,6 +297,7 @@ function buildRssFeedPayload(req, existingRssFeed) { module.exports = { fetchRssFeedsData, + fetchRssFeedsPage, fetchRssFeedById, fetchRssFeedItemsByFeedId, normalizeRssFeedItem, diff --git a/src/data/screens.js b/src/data/screens.js index a2a2e30..28f4ce1 100644 --- a/src/data/screens.js +++ b/src/data/screens.js @@ -38,7 +38,7 @@ async function fetchScreenById(pool, id) { } async function fetchScreenEditData(pool) { - const [playlists] = await pool.query('SELECT id, name, fade_between_slides, created_at, modified_at, created_by, modified_by FROM playlists ORDER BY id DESC'); + const [playlists] = await pool.query('SELECT id, name, fade_between_slides, skip_unavailable_rtmp, created_at, modified_at, created_by, modified_by FROM playlists ORDER BY id DESC'); return { playlists }; } diff --git a/src/data/slides.js b/src/data/slides.js index d8b1582..e3f8f33 100644 --- a/src/data/slides.js +++ b/src/data/slides.js @@ -27,7 +27,7 @@ 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.created_at, s.modified_at, + 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, 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 @@ -90,7 +90,7 @@ function buildTemplateContent(template, body, filesByField, existingContent) { const existing = body[`existing_region_image_${region.id}`]; content[region.region_key] = { type: 'image', - value: uploaded ? `/media/${uploaded.filename}` : String(existing || '').trim() || ((existingContent && existingContent[region.region_key]) ? existingContent[region.region_key].value : '') + value: uploaded ? `/media/uploads/${uploaded.filename}` : String(existing || '').trim() || ((existingContent && existingContent[region.region_key]) ? existingContent[region.region_key].value : '') }; } else if (region.region_type === 'webpage') { const submitted = body[`region_webpage_${region.id}`]; diff --git a/src/data/templates.js b/src/data/templates.js index e6cc3cb..a9ac865 100644 --- a/src/data/templates.js +++ b/src/data/templates.js @@ -154,7 +154,7 @@ async function buildTemplatePayload(pool, req, existingTemplate) { const removeBackgroundImage = Boolean(req.body.remove_background_image); const backgroundColor = sanitizeBackgroundColor(req.body.background_color || (existingTemplate && existingTemplate.background_color)); const backgroundImagePath = backgroundImage - ? `/media/${backgroundImage.filename}` + ? `/media/uploads/${backgroundImage.filename}` : removeBackgroundImage ? null : String(req.body.existing_background_image_path || (existingTemplate && existingTemplate.background_image_path) || '').trim() || null; diff --git a/src/data/utils.js b/src/data/utils.js index de19fa5..0fd341c 100644 --- a/src/data/utils.js +++ b/src/data/utils.js @@ -22,6 +22,38 @@ function readFormArray(body, key) { return [body[key]]; } +function normalizePageNumber(value) { + const pageNumber = Math.floor(Number(value) || 1); + return Math.max(1, pageNumber); +} + +async function fetchPagedRows(pool, options) { + const selectSql = String(options && options.selectSql || '').trim(); + const countSql = String(options && options.countSql || '').trim(); + const params = Array.isArray(options && options.params) ? options.params : []; + const pageSize = Math.max(1, Number(options && options.pageSize) || 10); + const currentPage = normalizePageNumber(options && options.page); + + if (!selectSql || !countSql) { + throw new Error('fetchPagedRows requires selectSql and countSql.'); + } + + const [countRows] = await pool.query(countSql, params); + const totalItems = Number(countRows && countRows[0] && countRows[0].count) || 0; + const totalPages = Math.max(1, Math.ceil(totalItems / pageSize)); + const safeCurrentPage = Math.min(currentPage, totalPages); + const offset = (safeCurrentPage - 1) * pageSize; + const [rows] = await pool.query(`${selectSql} LIMIT ? OFFSET ?`, params.concat([pageSize, offset])); + + return { + rows: rows || [], + totalItems: totalItems, + totalPages: totalPages, + currentPage: safeCurrentPage, + pageSize: pageSize + }; +} + async function fetchDuplicateName(pool, tableName, name, excludeId, columnName) { const normalizedName = String(name || '').trim(); if (!normalizedName) { @@ -44,5 +76,7 @@ async function fetchDuplicateName(pool, tableName, name, excludeId, columnName) module.exports = { parseJsonSafe, readFormArray, + normalizePageNumber, + fetchPagedRows, fetchDuplicateName }; diff --git a/src/db/index.js b/src/db/index.js index d9b58f8..4ca69eb 100644 --- a/src/db/index.js +++ b/src/db/index.js @@ -23,7 +23,7 @@ async function pruneStaleOnboardingDevices(pool) { ); } -async function ensureSchema(pool) { +async function ensureSchema(pool, options) { await pool.query(` CREATE TABLE IF NOT EXISTS canvas_sizes ( id INT AUTO_INCREMENT PRIMARY KEY, @@ -43,6 +43,7 @@ async function ensureSchema(pool) { id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(255) NOT NULL, fade_between_slides TINYINT(1) NOT NULL DEFAULT 0, + skip_unavailable_rtmp TINYINT(1) NOT NULL DEFAULT 0, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, created_by INT NULL, modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, @@ -102,6 +103,7 @@ async function ensureSchema(pool) { 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, @@ -341,7 +343,7 @@ async function ensureSchema(pool) { [DEFAULT_ROLE.key, DEFAULT_ROLE.name, DEFAULT_ROLE.description || null, null, null] ); - await migrations.runMigrations(pool); + 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]); diff --git a/src/db/migrations.js b/src/db/migrations.js index 5051bd7..734224a 100644 --- a/src/db/migrations.js +++ b/src/db/migrations.js @@ -1,3 +1,5 @@ +const fs = require('fs'); +const path = require('path'); const { version: appVersion } = require('../../package.json'); function parseVersion(value) { @@ -260,6 +262,27 @@ function replaceUploadPrefixInValue(value) { 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; @@ -327,6 +350,187 @@ async function backfillLegacyMediaPaths(pool) { } } +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 ( @@ -410,6 +614,7 @@ const migrations = [ // v1.4.6: playlists gained fade_between_slides plus 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'); @@ -433,6 +638,7 @@ const migrations = [ 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'); @@ -517,6 +723,15 @@ const migrations = [ 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: 'media-path-prefix-rename', version: appVersion, @@ -526,6 +741,24 @@ const migrations = [ 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, @@ -556,7 +789,7 @@ const migrations = [ } ]; -async function runMigrations(pool) { +async function runMigrations(pool, options) { const appliedRows = await getAppliedMigrationRows(pool); const appliedKeys = new Set((appliedRows || []).map(function (row) { return String(row.migration_key || '').trim(); @@ -576,7 +809,7 @@ async function runMigrations(pool) { }); for (const migration of pendingMigrations) { - await migration.up(pool); + await migration.up(pool, options || {}); await recordMigration(pool, migration); } } diff --git a/src/player.js b/src/player.js index 9989ea8..e181da5 100644 --- a/src/player.js +++ b/src/player.js @@ -67,7 +67,7 @@ async function start() { async function syncDatabaseState() { try { - await common.ensureSchema(pool); + await common.ensureSchema(pool, { mediaDir: MEDIA_DIR }); if (playerRuntime.snapshotAllConnections().length > 0) { await pruneStaleOnboardingDevices(pool); diff --git a/src/player/modules/rtmp-streams.js b/src/player/modules/rtmp-streams.js index 249a81d..4134f32 100644 --- a/src/player/modules/rtmp-streams.js +++ b/src/player/modules/rtmp-streams.js @@ -6,7 +6,13 @@ const { spawn } = require('child_process'); function createRtmpStreamService(options) { const mediaDir = options && options.mediaDir ? options.mediaDir : null; const ffmpegPath = options && options.ffmpegPath ? options.ffmpegPath : 'ffmpeg'; + const ffprobePath = options && options.ffprobePath ? options.ffprobePath : 'ffprobe'; + const probeIntervalMs = options && options.probeIntervalMs ? Number(options.probeIntervalMs) : 2000; const sessions = new Map(); + const watchedSources = new Set(); + const sourceStatusCache = new Map(); + const sourceProbePromises = new Map(); + let probeTimer = null; if (!mediaDir) { throw new Error('mediaDir is required'); @@ -48,6 +54,207 @@ function createRtmpStreamService(options) { return false; } + function runCommand(command, args, timeoutMs) { + return new Promise(function (resolve) { + var child = spawn(command, args, { + stdio: ['ignore', 'pipe', 'pipe'] + }); + var stdout = ''; + var stderr = ''; + var finished = false; + var timer = null; + + function done(result) { + if (finished) { + return; + } + finished = true; + if (timer) { + clearTimeout(timer); + timer = null; + } + resolve(result); + } + + child.stdout.on('data', function (chunk) { + stdout += String(chunk || ''); + }); + + child.stderr.on('data', function (chunk) { + stderr += String(chunk || ''); + }); + + child.on('error', function (error) { + done({ ok: false, error: error, stdout: stdout, stderr: stderr, timedOut: false }); + }); + + child.on('exit', function (code, signal) { + done({ ok: code === 0, code: code, signal: signal, stdout: stdout, stderr: stderr, timedOut: false }); + }); + + timer = setTimeout(function () { + try { + child.kill('SIGKILL'); + } catch (_error) { + // ignore timeout cleanup errors + } + done({ ok: false, code: null, signal: 'SIGKILL', stdout: stdout, stderr: stderr, timedOut: true }); + }, Math.max(1000, Number(timeoutMs || 0) || 4000)); + }); + } + + async function probeSourceUrl(sourceUrl) { + const normalizedSource = normalizeSourceUrl(sourceUrl); + const result = await runCommand(ffprobePath, [ + '-hide_banner', + '-loglevel', 'error', + '-rw_timeout', '3000000', + '-show_streams', + '-of', 'json', + normalizedSource + ], 4000); + + if (!result.ok) { + return { + live: false, + timedOut: Boolean(result.timedOut), + stderr: String(result.stderr || '').trim() + }; + } + + try { + const parsed = JSON.parse(String(result.stdout || '{}')); + const streams = Array.isArray(parsed && parsed.streams) ? parsed.streams : []; + return { + live: streams.length > 0, + timedOut: false, + stderr: String(result.stderr || '').trim() + }; + } catch (_error) { + return { + live: false, + timedOut: false, + stderr: String(result.stderr || '').trim() + }; + } + } + + function getSourceStatus(sourceUrl) { + return sourceStatusCache.get(String(sourceUrl || '').trim()) || null; + } + + function setSourceStatus(sourceUrl, nextStatus) { + const normalizedSource = String(sourceUrl || '').trim(); + if (!normalizedSource) { + return null; + } + const status = Object.assign({ + live: false, + probing: false, + checkedAt: Date.now(), + timedOut: false, + stderr: '' + }, nextStatus || {}); + status.live = Boolean(status.live); + status.probing = Boolean(status.probing); + status.checkedAt = Number(status.checkedAt || Date.now()); + sourceStatusCache.set(normalizedSource, status); + return status; + } + + function registerSourceWatch(sourceUrl) { + const normalizedSource = normalizeSourceUrl(sourceUrl); + watchedSources.add(normalizedSource); + return normalizedSource; + } + + async function refreshSourceStatus(sourceUrl) { + const normalizedSource = registerSourceWatch(sourceUrl); + const existingPromise = sourceProbePromises.get(normalizedSource) || null; + if (existingPromise) { + return existingPromise; + } + + setSourceStatus(normalizedSource, Object.assign({}, getSourceStatus(normalizedSource) || {}, { + probing: true + })); + + const probePromise = probeSourceUrl(normalizedSource).then(function (probe) { + return setSourceStatus(normalizedSource, { + live: Boolean(probe.live), + probing: false, + checkedAt: Date.now(), + timedOut: Boolean(probe.timedOut), + stderr: String(probe.stderr || '') + }); + }).catch(function (error) { + return setSourceStatus(normalizedSource, { + live: false, + probing: false, + checkedAt: Date.now(), + timedOut: false, + stderr: String(error && error.message ? error.message : '') + }); + }).finally(function () { + sourceProbePromises.delete(normalizedSource); + }); + + sourceProbePromises.set(normalizedSource, probePromise); + return probePromise; + } + + function scheduleSourceRefresh(sourceUrl) { + const normalizedSource = registerSourceWatch(sourceUrl); + const currentStatus = getSourceStatus(normalizedSource); + if (currentStatus && currentStatus.probing) { + return sourceProbePromises.get(normalizedSource) || Promise.resolve(currentStatus); + } + return refreshSourceStatus(normalizedSource); + } + + function isSourceStatusFresh(status) { + if (!status || !status.checkedAt) { + return false; + } + return Date.now() - Number(status.checkedAt || 0) < probeIntervalMs; + } + + function sweepWatchedSources() { + watchedSources.forEach(function (sourceUrl) { + const status = getSourceStatus(sourceUrl); + if (!status || !isSourceStatusFresh(status) || status.live === false) { + scheduleSourceRefresh(sourceUrl); + } + }); + } + + if (probeIntervalMs > 0) { + probeTimer = setInterval(function () { + sweepWatchedSources(); + }, probeIntervalMs); + if (probeTimer && typeof probeTimer.unref === 'function') { + probeTimer.unref(); + } + } + + async function isSessionLive(session) { + if (!session || !session.process) { + return false; + } + if (session.exitCode !== undefined && session.exitCode !== null) { + return false; + } + if (session.exitSignal !== undefined && session.exitSignal !== null) { + return false; + } + try { + const stat = await fs.promises.stat(session.manifestPath); + return stat.isFile() && stat.size > 0; + } catch (_error) { + return false; + } + } + function buildPlaylistUrl(key) { return '/api/rtmp/streams/' + encodeURIComponent(key) + '/index.m3u8'; } @@ -56,14 +263,30 @@ function createRtmpStreamService(options) { const normalizedSource = normalizeSourceUrl(sourceUrl); const normalizedDisableAudio = Boolean(disableAudio); const key = getSessionKey(normalizedSource, normalizedDisableAudio); - - if (sessions.has(key)) { - return sessions.get(key); - } - const directory = path.join(cacheRoot, key); const manifestPath = path.join(directory, 'index.m3u8'); + const existingSession = sessions.get(key) || null; + if (existingSession && existingSession.process && existingSession.exitCode === null && existingSession.exitSignal === null) { + return existingSession; + } + + if (existingSession) { + sessions.delete(key); + try { + if (existingSession.process && typeof existingSession.process.kill === 'function') { + existingSession.process.kill('SIGKILL'); + } + } catch (_error) { + // ignore cleanup errors + } + try { + await fs.promises.rm(directory, { recursive: true, force: true }); + } catch (_error2) { + // ignore cleanup errors + } + } + await ensureDirectory(directory); const args = [ @@ -117,6 +340,8 @@ function createRtmpStreamService(options) { manifestPath: manifestPath, playlistUrl: buildPlaylistUrl(key), process: child, + exitCode: null, + exitSignal: null, ready: waitForFile(manifestPath, 5000) }; @@ -132,6 +357,51 @@ function createRtmpStreamService(options) { return session.playlistUrl + '?t=' + Date.now(); } + async function getSessionStatus(sourceUrl, disableAudio) { + const normalizedSource = normalizeSourceUrl(sourceUrl); + const normalizedDisableAudio = Boolean(disableAudio); + registerSourceWatch(normalizedSource); + + let sourceStatus = getSourceStatus(normalizedSource); + if (!sourceStatus) { + scheduleSourceRefresh(normalizedSource).catch(function (_error) { + return null; + }); + sourceStatus = getSourceStatus(normalizedSource); + } else if (!isSourceStatusFresh(sourceStatus) && !sourceStatus.probing) { + scheduleSourceRefresh(normalizedSource).catch(function (_error) { + return null; + }); + } + + if (!sourceStatus || !sourceStatus.live) { + return { + session: null, + ready: false, + live: false, + probing: Boolean(sourceStatus && sourceStatus.probing), + checkedAt: sourceStatus ? sourceStatus.checkedAt : null, + timedOut: Boolean(sourceStatus && sourceStatus.timedOut), + stderr: String(sourceStatus && sourceStatus.stderr || '') + }; + } + + const session = await ensureSession(normalizedSource, normalizedDisableAudio); + const ready = await session.ready.catch(function () { + return false; + }); + const live = ready && Boolean(session && session.process && session.exitCode === null && session.exitSignal === null); + return { + session: session, + ready: ready, + live: live, + probing: Boolean(sourceStatus && sourceStatus.probing), + checkedAt: sourceStatus ? sourceStatus.checkedAt : null, + timedOut: false, + stderr: '' + }; + } + function getSessionByKey(key) { return sessions.get(String(key || '').trim()) || null; } @@ -141,9 +411,13 @@ function createRtmpStreamService(options) { if (!session) { return null; } - await session.ready.catch(function () { - return false; - }); + if (!session.process || session.exitCode !== null || session.exitSignal !== null) { + return null; + } + const live = await isSessionLive(session); + if (!live) { + return null; + } return session.manifestPath; } @@ -163,6 +437,11 @@ function createRtmpStreamService(options) { ensureSession: ensureSession, getPlaylistUrl: getPlaylistUrl, getSessionByKey: getSessionByKey, + isSessionLive: isSessionLive, + getSessionStatus: getSessionStatus, + refreshSourceStatus: refreshSourceStatus, + scheduleSourceRefresh: scheduleSourceRefresh, + getSourceStatus: getSourceStatus, getManifestFilePath: getManifestFilePath, getSegmentFilePath: getSegmentFilePath }; diff --git a/src/player/player-page.script.html b/src/player/player-page.script.html index 2596d97..30cbd44 100644 --- a/src/player/player-page.script.html +++ b/src/player/player-page.script.html @@ -6,6 +6,7 @@ let currentPlaylistSignature = ''; let currentPlaylistEtag = ''; let currentPlaylistFadeBetweenSlides = false; + let currentPlaylistSkipUnavailableRtmp = false; let pendingPlaylistUpdate = null; let activeSlidesCacheKey = ''; let activeSlidesCacheValue = []; @@ -16,6 +17,7 @@ let index = 0; let timer = null; let slideTransitionTimer = null; + let slideRenderRequestId = 0; let preloadContainer = null; let preloadSignature = ''; let commandSocket = null; @@ -154,16 +156,18 @@ } if (initialData && initialData.playlist) { currentPlaylistFadeBetweenSlides = Boolean(initialData.playlist.fade_between_slides); + currentPlaylistSkipUnavailableRtmp = Boolean(initialData.playlist.skip_unavailable_rtmp); } savePlaylistSnapshot({ slides: slides, signature: currentPlaylistSignature, fadeBetweenSlides: currentPlaylistFadeBetweenSlides, + skipUnavailableRtmp: currentPlaylistSkipUnavailableRtmp, etag: currentPlaylistEtag }); syncWebpagePreloads(getActiveSlidesFrom(slides), index); - if (typeof syncRtmpPreloads === 'function') { - syncRtmpPreloads(getActiveSlidesFrom(slides), index); + if (typeof syncRtmpWarmups === 'function') { + syncRtmpWarmups(getActiveSlidesFrom(slides), index); } syncBlackoutState(); showCurrent(); diff --git a/src/player/playlist.js b/src/player/playlist.js index 19be00e..0662167 100644 --- a/src/player/playlist.js +++ b/src/player/playlist.js @@ -73,7 +73,7 @@ function createPlayerPlaylistService(options) { return payloadWithoutPlaylist; } - const [playlistRows] = await pool.query('SELECT id, name, fade_between_slides, created_at, modified_at, created_by, modified_by FROM playlists WHERE id = ?', [screen.playlist_id]); + const [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 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, @@ -180,6 +180,7 @@ function createPlayerPlaylistService(options) { updatePlaylistRevisionHash(hash, playlist && playlist.id); updatePlaylistRevisionHash(hash, playlist && playlist.modified_at); updatePlaylistRevisionHash(hash, playlist && playlist.fade_between_slides); + updatePlaylistRevisionHash(hash, playlist && playlist.skip_unavailable_rtmp); (Array.isArray(slideRows) ? slideRows : []).forEach(function (slide) { updatePlaylistRevisionHash(hash, slide.id); diff --git a/src/player/public/css/player.css b/src/player/public/css/player.css index 6658587..6aa1141 100644 --- a/src/player/public/css/player.css +++ b/src/player/public/css/player.css @@ -397,6 +397,7 @@ body.screen-blackout #app { .template-region-rtmp-placeholder { position: absolute; inset: 0; + display: flex; } .template-region-placeholder { diff --git a/src/player/public/js/player-page-commands.js b/src/player/public/js/player-page-commands.js index 368e649..5b00f92 100644 --- a/src/player/public/js/player-page-commands.js +++ b/src/player/public/js/player-page-commands.js @@ -89,6 +89,12 @@ function scheduleSlideAdvance(delayMs) { }, holdDelayMs); } +// Add the fade time to a slide's hold duration so the configured duration remains visible. +function getSlideHoldDelay(delayMs) { + var holdDelayMs = Math.max(1, Number(delayMs || 0)); + return holdDelayMs + (currentPlaylistFadeBetweenSlides ? slideFadeDurationMs : 0); +} + // Cancel any pending fade-transition cleanup. function clearSlideTransitionTimer() { if (slideTransitionTimer) { diff --git a/src/player/public/js/player-page-offline.js b/src/player/public/js/player-page-offline.js index d7c97eb..439f114 100644 --- a/src/player/public/js/player-page-offline.js +++ b/src/player/public/js/player-page-offline.js @@ -1,5 +1,8 @@ // Show or hide the offline status banner. function setOfflineBannerVisible(visible, message) { + if (window.__pulseThumbnailPreview) { + return; + } var normalizedVisible = Boolean(visible); var bannerMessage = String(message || 'Offline mode: using cached playlist.').trim(); if (normalizedVisible) { @@ -38,6 +41,9 @@ function setOfflineBannerVisible(visible, message) { // Update the offline banner based on connectivity or playlist availability. function syncOfflineBanner() { + if (window.__pulseThumbnailPreview) { + return; + } if (!window.navigator.onLine) { setOfflineBannerVisible(true, 'Offline mode: using cached playlist.'); return; @@ -57,6 +63,9 @@ function clearRefreshRetry() { // Retry playlist refresh with a short backoff while the player is offline. function scheduleRefreshRetry() { + if (window.__pulseThumbnailPreview) { + return; + } if (refreshRetryTimer) { return; } diff --git a/src/player/public/js/player-page-playback.js b/src/player/public/js/player-page-playback.js index 0928d8a..b9f7e70 100644 --- a/src/player/public/js/player-page-playback.js +++ b/src/player/public/js/player-page-playback.js @@ -1,5 +1,5 @@ // Render the slide at the requested index within the active set. -function renderSlideAtIndex(sourceSlides, targetIndex) { +async function renderSlideAtIndex(sourceSlides, targetIndex) { const availableSlides = Array.isArray(sourceSlides) ? sourceSlides : []; if (!availableSlides.length) { renderEmpty(slides.length ? 'No slides are scheduled for this time.' : 'No slides assigned to this screen yet.'); @@ -11,18 +11,52 @@ function renderSlideAtIndex(sourceSlides, targetIndex) { if (!Number.isFinite(normalizedIndex) || normalizedIndex < 0 || normalizedIndex >= availableSlides.length) { } - const slide = availableSlides[normalizedIndex]; + var slideCount = availableSlides.length; + var currentIndex = normalizedIndex; + var slide = null; + var attempts = 0; + var renderRequestId = ++slideRenderRequestId; + + while (attempts < slideCount) { + slide = availableSlides[currentIndex]; + if (!slide) { + break; + } + if (currentPlaylistSkipUnavailableRtmp && typeof probeRtmpSlideAvailability === 'function') { + var isAvailable = await probeRtmpSlideAvailability(slide); + if (!isAvailable) { + if (renderRequestId !== slideRenderRequestId) { + return false; + } + currentIndex = (currentIndex + 1) % slideCount; + attempts += 1; + slide = null; + continue; + } + } + if (renderRequestId !== slideRenderRequestId) { + break; + } + break; + } + if (!slide) { + if (currentPlaylistSkipUnavailableRtmp) { + renderEmpty(slides.length ? 'No RTMP slides are currently available.' : 'No slides assigned to this screen yet.'); + lastRenderedViewKey = getCurrentRenderKey(availableSlides); + scheduleRefreshRetry(); + return false; + } renderEmpty(slides.length ? 'No slides are scheduled for this time.' : 'No slides assigned to this screen yet.'); return false; } - index = normalizedIndex; + index = currentIndex; var markup = buildSlideMarkup(slide); renderSlideMarkup(markup, currentPlaylistFadeBetweenSlides); sendCommandState(slide); if (!isPaused) { - scheduleSlideAdvance(Math.max(1, Number(slide.duration_seconds || 10)) * 1000); + scheduleSlideAdvance(getSlideHoldDelay(Math.max(1, Number(slide.duration_seconds || 10)) * 1000)); } lastRenderedViewKey = getCurrentRenderKey(availableSlides); return true; @@ -36,6 +70,7 @@ function applyPendingPlaylistUpdate() { slides = pendingPlaylistUpdate.slides; currentPlaylistSignature = pendingPlaylistUpdate.signature; currentPlaylistFadeBetweenSlides = pendingPlaylistUpdate.fadeBetweenSlides; + currentPlaylistSkipUnavailableRtmp = Boolean(pendingPlaylistUpdate.skipUnavailableRtmp); pendingPlaylistUpdate = null; clearActiveSlidesCache(); slideMarkupCache = Object.create(null); @@ -53,18 +88,45 @@ function showCurrent() { applyPendingPlaylistUpdate(); const activeSlides = getCurrentActiveSlides(); syncWebpagePreloads(activeSlides, index); - if (typeof syncRtmpPreloads === 'function') { - syncRtmpPreloads(activeSlides, index); + if (typeof syncRtmpWarmups === 'function') { + syncRtmpWarmups(activeSlides, index); } if (!activeSlides.length) { renderEmpty(slides.length ? 'No slides are scheduled for this time.' : 'No slides assigned to this screen yet.'); lastRenderedViewKey = getCurrentRenderKey(activeSlides); return; } - renderSlideAtIndex(activeSlides, index); + void renderSlideAtIndex(activeSlides, index); lastRenderedViewKey = getCurrentRenderKey(activeSlides); } +// Skip the current slide when an RTMP feed is unavailable and the playlist asks for it. +function handleRtmpPlaybackFailure(message, options) { + if (!currentPlaylistSkipUnavailableRtmp) { + return false; + } + + var silent = Boolean(options && options.silent); + const activeSlides = getCurrentActiveSlides(); + if (!activeSlides.length) { + return false; + } + + clearSlideTimer(); + if (!silent) { + logDebug('Skipping RTMP slide because the stream is unavailable.', String(message || ''), 'error'); + } + + const nextIndex = (Number(index || 0) + 1) % activeSlides.length; + window.setTimeout(function () { + if (!getCurrentActiveSlides().length) { + return; + } + void renderSlideAtIndex(getCurrentActiveSlides(), nextIndex); + }, 0); + return true; +} + // Fetch the latest playlist and queue any updates. function refresh() { var request = new XMLHttpRequest(); @@ -85,7 +147,7 @@ function refresh() { markRefreshHealthy(); setOfflineBannerVisible(false); if (lastRenderedSlide && getCurrentActiveSlides().length < 2) { - scheduleSlideAdvance(Math.max(1, Number(lastRenderedSlide.duration_seconds || 10)) * 1000); + scheduleSlideAdvance(getSlideHoldDelay(Math.max(1, Number(lastRenderedSlide.duration_seconds || 10)) * 1000)); } return; } @@ -105,10 +167,12 @@ function refresh() { const nextSignature = getPlaylistRevision(data); const nextSlides = Array.isArray(data.slides) ? data.slides.map(normalizeSlide) : []; const nextFadeBetweenSlides = Boolean(data && data.playlist && data.playlist.fade_between_slides); + const nextSkipUnavailableRtmp = Boolean(data && data.playlist && data.playlist.skip_unavailable_rtmp); savePlaylistSnapshot({ slides: nextSlides, signature: nextSignature, fadeBetweenSlides: nextFadeBetweenSlides, + skipUnavailableRtmp: nextSkipUnavailableRtmp, etag: responseEtag }); markRefreshHealthy(); @@ -121,6 +185,7 @@ function refresh() { slides = nextSlides; currentPlaylistSignature = nextSignature; currentPlaylistFadeBetweenSlides = nextFadeBetweenSlides; + currentPlaylistSkipUnavailableRtmp = nextSkipUnavailableRtmp; index = 0; showCurrent(); sendCommandState(lastRenderedSlide); @@ -128,18 +193,19 @@ function refresh() { } if (nextSignature === currentPlaylistSignature || (pendingPlaylistUpdate && nextSignature === pendingPlaylistUpdate.signature)) { if (currentActiveSlides.length < 2 && lastRenderedSlide) { - scheduleSlideAdvance(Math.max(1, Number(lastRenderedSlide.duration_seconds || 10)) * 1000); + scheduleSlideAdvance(getSlideHoldDelay(Math.max(1, Number(lastRenderedSlide.duration_seconds || 10)) * 1000)); } return; } syncWebpagePreloads(getActiveSlidesFrom(nextSlides), index); - if (typeof syncRtmpPreloads === 'function') { - syncRtmpPreloads(getActiveSlidesFrom(nextSlides), index); + if (typeof syncRtmpWarmups === 'function') { + syncRtmpWarmups(getActiveSlidesFrom(nextSlides), index); } if (currentActiveSlides.length < 2) { slides = nextSlides; currentPlaylistSignature = nextSignature; currentPlaylistFadeBetweenSlides = nextFadeBetweenSlides; + currentPlaylistSkipUnavailableRtmp = nextSkipUnavailableRtmp; pendingPlaylistUpdate = null; index = 0; showCurrent(); @@ -149,7 +215,8 @@ function refresh() { pendingPlaylistUpdate = { slides: nextSlides, signature: nextSignature, - fadeBetweenSlides: nextFadeBetweenSlides + fadeBetweenSlides: nextFadeBetweenSlides, + skipUnavailableRtmp: nextSkipUnavailableRtmp }; logDebug('Playlist update detected; applying on next slide transition.'); } catch (_error) { @@ -171,7 +238,7 @@ function refresh() { setOfflineBannerVisible(true); scheduleRefreshRetry(); if (lastRenderedSlide && getCurrentActiveSlides().length < 2) { - scheduleSlideAdvance(Math.max(1, Number(lastRenderedSlide.duration_seconds || 10)) * 1000); + scheduleSlideAdvance(getSlideHoldDelay(Math.max(1, Number(lastRenderedSlide.duration_seconds || 10)) * 1000)); } }; request.ontimeout = function () { @@ -183,7 +250,7 @@ function refresh() { setOfflineBannerVisible(true); scheduleRefreshRetry(); if (lastRenderedSlide && getCurrentActiveSlides().length < 2) { - scheduleSlideAdvance(Math.max(1, Number(lastRenderedSlide.duration_seconds || 10)) * 1000); + scheduleSlideAdvance(getSlideHoldDelay(Math.max(1, Number(lastRenderedSlide.duration_seconds || 10)) * 1000)); } }; request.send(); diff --git a/src/player/public/js/player-page-rendering.js b/src/player/public/js/player-page-rendering.js index 3fefcc7..3e5fc43 100644 --- a/src/player/public/js/player-page-rendering.js +++ b/src/player/public/js/player-page-rendering.js @@ -411,6 +411,22 @@ function getTemplateLayout(template) { return layout; } +// Build a dark backdrop style for template and media canvases. +function buildBackdropStyle(backgroundColor, backgroundImagePath) { + var color = String(backgroundColor || '#111111').trim() || '#111111'; + var style = 'background-color:' + escapeHtml(color) + ';'; + var gradient = 'linear-gradient(rgba(0, 0, 0, 0.42), rgba(0, 0, 0, 0.42))'; + + if (backgroundImagePath) { + style += 'background-image:' + gradient + ',url("' + escapeHtml(backgroundImagePath) + '");'; + style += 'background-position:center,center;background-size:100% 100%,cover;background-repeat:no-repeat,no-repeat;'; + return style; + } + + style += 'background-image:' + gradient + ';background-position:center;background-size:100% 100%;background-repeat:no-repeat;'; + return style; +} + // Build the cache key for a template render plan. function getTemplateRenderPlanCacheKey(template) { return getTemplateLayoutCacheKey(template); @@ -469,7 +485,7 @@ function renderTemplateSlideMarkup(slide) { const regionContent = content[region.regionKey] || {}; return plan.renderRegion(region, regionContent); }).join('') : ''; - const stageStyle = layout ? 'background-color:' + escapeHtml(layout.backgroundColor || '#111111') + ';' : ''; + const stageStyle = layout ? buildBackdropStyle(layout.backgroundColor || '#111111', template.background_image_path) : ''; return renderSlideShell(slide, '', (layout ? layout.canvasWidth : 0) + 'px', (layout ? layout.canvasHeight : 0) + 'px', '
' + (layout ? layout.background : '') + regions + '
'); } @@ -486,14 +502,16 @@ function renderMediaSlideContent(slide) { function renderMediaSlideMarkup(slide) { const canvasSize = fitCanvasSize(16, 9, window.innerWidth, window.innerHeight); const media = renderMediaSlideContent(slide); - return renderSlideShell(slide, 'slide-media', canvasSize.width + 'px', canvasSize.height + 'px', media); + const canvasStyle = slide.kind === 'image' ? buildBackdropStyle('#111111', slide.media_url) : ''; + return renderSlideShell(slide, 'slide-media', canvasSize.width + 'px', canvasSize.height + 'px', media, canvasStyle); } // Render the shared slide shell around slide-specific inner content. -function renderSlideShell(slide, canvasClass, canvasWidth, canvasHeight, innerHtml) { +function renderSlideShell(slide, canvasClass, canvasWidth, canvasHeight, innerHtml, canvasStyle) { const body = slide.body ? '
' + escapeHtml(slide.body) + '
' : ''; const className = canvasClass ? 'slide-canvas ' + canvasClass : 'slide-canvas'; - return '
' + innerHtml + body + '
'; + const style = 'width:' + canvasWidth + ';height:' + canvasHeight + ';' + (canvasStyle || ''); + return '
' + innerHtml + body + '
'; } // Build the cache key for rendered slide markup. diff --git a/src/player/regions/rtmp.js b/src/player/regions/rtmp.js index 9e59b93..f2c873d 100644 --- a/src/player/regions/rtmp.js +++ b/src/player/regions/rtmp.js @@ -1,20 +1,296 @@ function renderRtmpRegion(region, regionContent) { var url = String(regionContent.value || '').trim(); var disableAudio = regionContent.disable_audio === undefined ? true : Boolean(regionContent.disable_audio); + var skipUnavailable = Boolean(currentPlaylistSkipUnavailableRtmp); if (!url) { return '
RTMP stream
'; } - return '
Loading RTMP stream...
'; + return '
Loading RTMP stream...
'; } function getRtmpSessionUrl(sourceUrl, disableAudio) { return '/api/rtmp/session?source=' + encodeURIComponent(sourceUrl) + '&disableAudio=' + (disableAudio ? '1' : '0'); } -var rtmpPreloadSignature = ''; +var rtmpAvailabilityCache = Object.create(null); +var rtmpAvailabilityRetryMs = 5000; +var rtmpWarmupTimers = Object.create(null); +var rtmpBrowserReadyCache = Object.create(null); +var rtmpBrowserWarmupTimers = Object.create(null); +var rtmpBrowserWarmupContainer = null; +var rtmpBrowserWarmupStates = Object.create(null); -function getRtmpPreloadSlides(sourceSlides, targetIndex) { +function getRtmpAvailabilityKey(sourceUrl, disableAudio) { + return String(sourceUrl || '').trim() + '\n' + (disableAudio ? '1' : '0'); +} + +function setRtmpAvailability(sourceUrl, disableAudio, available) { + var key = getRtmpAvailabilityKey(sourceUrl, disableAudio); + rtmpAvailabilityCache[key] = { + available: available === null ? null : Boolean(available), + pending: available === null, + checkedAt: Date.now() + }; +} + +function getRtmpAvailability(sourceUrl, disableAudio) { + return rtmpAvailabilityCache[getRtmpAvailabilityKey(sourceUrl, disableAudio)] || null; +} + +function isRtmpAvailabilityStale(status) { + if (!status || !status.checkedAt) { + return true; + } + return Date.now() - Number(status.checkedAt || 0) >= rtmpAvailabilityRetryMs; +} + +function isRtmpSlideUnavailable(slide) { + if (!slide || !slide.template || !Array.isArray(slide.template.regions)) { + return false; + } + + var content = slide.content || {}; + return slide.template.regions.some(function (region) { + if (region.region_type !== 'rtmp') { + return false; + } + + var regionContent = content[region.region_key] || {}; + var sourceUrl = String(regionContent.value || '').trim(); + if (!sourceUrl) { + return false; + } + + var disableAudio = regionContent.disable_audio === undefined ? true : Boolean(regionContent.disable_audio); + var serverStatus = getRtmpAvailability(sourceUrl, disableAudio); + return Boolean(!serverStatus || isRtmpAvailabilityStale(serverStatus) || serverStatus.available !== true); + }); +} + +function clearRtmpAvailabilityForSlide(slide) { + if (!slide || !slide.template || !Array.isArray(slide.template.regions)) { + return; + } + + var content = slide.content || {}; + slide.template.regions.forEach(function (region) { + if (region.region_type !== 'rtmp') { + return; + } + + var regionContent = content[region.region_key] || {}; + var sourceUrl = String(regionContent.value || '').trim(); + if (!sourceUrl) { + return; + } + + var disableAudio = regionContent.disable_audio === undefined ? true : Boolean(regionContent.disable_audio); + setRtmpAvailability(sourceUrl, disableAudio, null); + }); +} + +function setRtmpBrowserReady(sourceUrl, disableAudio, ready) { + var key = getRtmpAvailabilityKey(sourceUrl, disableAudio); + rtmpBrowserReadyCache[key] = { + ready: ready === null ? null : Boolean(ready), + checkedAt: Date.now() + }; +} + +function getRtmpBrowserReady(sourceUrl, disableAudio) { + return rtmpBrowserReadyCache[getRtmpAvailabilityKey(sourceUrl, disableAudio)] || null; +} + +function isRtmpBrowserReadyStale(status) { + if (!status || !status.checkedAt) { + return true; + } + return Date.now() - Number(status.checkedAt || 0) >= rtmpAvailabilityRetryMs; +} + +function ensureRtmpBrowserWarmupContainer() { + if (rtmpBrowserWarmupContainer) { + return rtmpBrowserWarmupContainer; + } + + rtmpBrowserWarmupContainer = document.createElement('div'); + rtmpBrowserWarmupContainer.className = 'rtmp-browser-warmups'; + rtmpBrowserWarmupContainer.setAttribute('aria-hidden', 'true'); + document.body.appendChild(rtmpBrowserWarmupContainer); + return rtmpBrowserWarmupContainer; +} + +function getRtmpBrowserWarmupState(sourceUrl, disableAudio) { + return rtmpBrowserWarmupStates[getRtmpAvailabilityKey(sourceUrl, disableAudio)] || null; +} + +function clearRtmpBrowserWarmupState(sourceUrl, disableAudio) { + var key = getRtmpAvailabilityKey(sourceUrl, disableAudio); + var state = rtmpBrowserWarmupStates[key]; + if (!state) { + return; + } + + if (state.video && state.video.__rtmpBrowserWarmupTimer) { + window.clearTimeout(state.video.__rtmpBrowserWarmupTimer); + state.video.__rtmpBrowserWarmupTimer = null; + } + if (state.video && state.video.__rtmpHls) { + try { + state.video.__rtmpHls.destroy(); + } catch (_error) { + // ignore cleanup errors + } + state.video.__rtmpHls = null; + } + if (state.wrapper && state.wrapper.parentNode) { + state.wrapper.parentNode.removeChild(state.wrapper); + } + delete rtmpBrowserWarmupStates[key]; +} + +function warmupRtmpBrowser(sourceUrl, disableAudio) { + var key = getRtmpAvailabilityKey(sourceUrl, disableAudio); + var existing = rtmpBrowserWarmupStates[key]; + if (existing) { + return existing; + } + + var wrapper = document.createElement('div'); + wrapper.className = 'rtmp-browser-warmup-entry'; + wrapper.setAttribute('aria-hidden', 'true'); + + var video = document.createElement('video'); + video.className = 'template-region-rtmp-video'; + video.dataset.rtmpSource = sourceUrl; + video.dataset.rtmpDisableAudio = disableAudio ? '1' : '0'; + video.autoplay = true; + video.playsInline = true; + video.preload = 'auto'; + video.tabIndex = -1; + video.disablePictureInPicture = true; + video.muted = disableAudio; + + wrapper.appendChild(video); + ensureRtmpBrowserWarmupContainer().appendChild(wrapper); + + var state = { + wrapper: wrapper, + video: video, + sourceUrl: sourceUrl, + disableAudio: disableAudio + }; + rtmpBrowserWarmupStates[key] = state; + setRtmpBrowserReady(sourceUrl, disableAudio, null); + + var markReady = function () { + setRtmpBrowserReady(sourceUrl, disableAudio, true); + clearRtmpBrowserWarmupState(sourceUrl, disableAudio); + }; + + var markNotReady = function () { + setRtmpBrowserReady(sourceUrl, disableAudio, false); + }; + + video.addEventListener('canplay', markReady, { once: true }); + video.addEventListener('playing', markReady, { once: true }); + video.addEventListener('error', markNotReady, { once: true }); + + if (window.Hls && window.Hls.isSupported && window.Hls.isSupported()) { + var hls = new window.Hls({ + enableWorker: true, + lowLatencyMode: true, + liveSyncDurationCount: 4, + liveMaxLatencyDurationCount: 8, + maxBufferLength: 10, + maxLiveSyncPlaybackRate: 1, + backBufferLength: 10 + }); + video.__rtmpHls = hls; + hls.attachMedia(video); + hls.on(window.Hls.Events.MEDIA_ATTACHED, function () { + requestWarmupPlaylist(sourceUrl, disableAudio, video, markNotReady); + }); + hls.on(window.Hls.Events.ERROR, function (_event, data) { + if (data && data.fatal) { + markNotReady(); + clearRtmpBrowserWarmupState(sourceUrl, disableAudio); + } + }); + return state; + } + + if (video.canPlayType && video.canPlayType('application/vnd.apple.mpegurl')) { + requestWarmupPlaylist(sourceUrl, disableAudio, video, markNotReady); + return state; + } + + markNotReady(); + return state; +} + +function requestWarmupPlaylist(sourceUrl, disableAudio, video, onFailure) { + fetch(getRtmpSessionUrl(sourceUrl, disableAudio), { + credentials: 'same-origin' + }).then(function (response) { + return response.json().catch(function () { + return null; + }).then(function (payload) { + return { + response: response, + payload: payload + }; + }); + }).then(function (result) { + if (!result || !result.response) { + throw new Error('Unable to initialize RTMP stream.'); + } + + var response = result.response; + var payload = result.payload || null; + var playlistUrl = payload && payload.playlistUrl ? String(payload.playlistUrl).trim() : ''; + + if (!response.ok) { + if (response.status === 503 && payload && payload.probing) { + setRtmpBrowserReady(sourceUrl, disableAudio, null); + if (video && !video.__rtmpBrowserWarmupTimer) { + video.__rtmpBrowserWarmupTimer = window.setTimeout(function () { + video.__rtmpBrowserWarmupTimer = null; + requestWarmupPlaylist(sourceUrl, disableAudio, video, onFailure); + }, 500); + } + return; + } + throw new Error('Unable to initialize RTMP stream.'); + } + + if (!playlistUrl) { + throw new Error('RTMP playlist URL was not returned.'); + } + + setRtmpBrowserReady(sourceUrl, disableAudio, false); + + if (video.__rtmpHls && window.Hls && window.Hls.isSupported && window.Hls.isSupported()) { + video.__rtmpHls.loadSource(playlistUrl); + return; + } + + if (video) { + video.src = playlistUrl; + video.play().catch(function () { + return null; + }); + } + }).catch(function () { + setRtmpBrowserReady(sourceUrl, disableAudio, false); + if (typeof onFailure === 'function') { + onFailure(); + } + }); +} + +function getRtmpWarmupSlides(sourceSlides, targetIndex) { var availableSlides = Array.isArray(sourceSlides) ? sourceSlides : []; if (!availableSlides.length) { return []; @@ -25,21 +301,21 @@ function getRtmpPreloadSlides(sourceSlides, targetIndex) { normalizedIndex = 0; } - var preloadSlides = []; + var warmupSlides = []; var currentSlide = availableSlides[normalizedIndex]; var nextSlide = availableSlides[normalizedIndex + 1]; if (currentSlide) { - preloadSlides.push(currentSlide); + warmupSlides.push(currentSlide); } if (nextSlide && nextSlide !== currentSlide) { - preloadSlides.push(nextSlide); + warmupSlides.push(nextSlide); } - return preloadSlides; + return warmupSlides; } -function getRtmpPreloadEntries(sourceSlides) { +function getRtmpWarmupEntries(sourceSlides) { var entries = []; var seen = Object.create(null); @@ -50,20 +326,24 @@ function getRtmpPreloadEntries(sourceSlides) { if (region.region_type !== 'rtmp') { return; } + var regionContent = content[region.region_key] || {}; var url = String(regionContent.value || '').trim(); if (!url) { return; } + var disableAudio = regionContent.disable_audio === undefined ? true : Boolean(regionContent.disable_audio); - var key = url + '\n' + (disableAudio ? '1' : '0'); + var key = getRtmpAvailabilityKey(url, disableAudio); if (seen[key]) { return; } + seen[key] = true; entries.push({ url: url, - disableAudio: disableAudio + disableAudio: disableAudio, + key: key }); }); }); @@ -71,30 +351,397 @@ function getRtmpPreloadEntries(sourceSlides) { return entries; } -function syncRtmpPreloads(sourceSlides, targetIndex) { - var entries = getRtmpPreloadEntries(getRtmpPreloadSlides(sourceSlides, targetIndex)); - var signature = entries.map(function (entry) { - return entry.url + '\n' + (entry.disableAudio ? '1' : '0'); - }).join('\n'); - - if (signature === rtmpPreloadSignature) { - return; - } - - rtmpPreloadSignature = signature; - if (!entries.length) { - return; - } +function syncRtmpWarmups(sourceSlides, targetIndex) { + var entries = getRtmpWarmupEntries(getRtmpWarmupSlides(sourceSlides, targetIndex)); entries.forEach(function (entry) { - fetch(getRtmpSessionUrl(entry.url, entry.disableAudio), { - credentials: 'same-origin' - }).catch(function () { - return null; - }); + var status = getRtmpAvailability(entry.url, entry.disableAudio); + if (status && !isRtmpAvailabilityStale(status) && status.available === true) { + return; + } + + if (rtmpWarmupTimers[entry.key]) { + return; + } + + function requestWarmup() { + fetch(getRtmpSessionUrl(entry.url, entry.disableAudio), { + credentials: 'same-origin' + }).then(function (response) { + return response.json().catch(function () { + return null; + }).then(function (payload) { + return { + response: response, + payload: payload + }; + }); + }).then(function (result) { + rtmpWarmupTimers[entry.key] = null; + if (!result || !result.response) { + setRtmpAvailability(entry.url, entry.disableAudio, false); + return; + } + + if (!result.response.ok) { + if (result.response.status === 503 && result.payload && result.payload.probing) { + setRtmpAvailability(entry.url, entry.disableAudio, null); + rtmpWarmupTimers[entry.key] = window.setTimeout(function () { + rtmpWarmupTimers[entry.key] = null; + requestWarmup(); + }, 500); + return; + } + setRtmpAvailability(entry.url, entry.disableAudio, false); + return; + } + + setRtmpAvailability(entry.url, entry.disableAudio, true); + }).catch(function () { + rtmpWarmupTimers[entry.key] = null; + setRtmpAvailability(entry.url, entry.disableAudio, false); + }); + } + + setRtmpAvailability(entry.url, entry.disableAudio, null); + requestWarmup(); }); } +async function probeRtmpSlideAvailability(slide) { + var entries = getRtmpWarmupEntries([slide]); + if (!entries.length) { + return true; + } + + var results = await Promise.all(entries.map(function (entry) { + setRtmpAvailability(entry.url, entry.disableAudio, null); + return fetch(getRtmpSessionUrl(entry.url, entry.disableAudio), { + credentials: 'same-origin' + }).then(function (response) { + return response.json().catch(function () { + return null; + }).then(function (payload) { + return { + response: response, + payload: payload + }; + }); + }).then(function (result) { + if (!result || !result.response) { + setRtmpAvailability(entry.url, entry.disableAudio, false); + return false; + } + + if (!result.response.ok) { + if (result.response.status === 503 && result.payload && result.payload.probing) { + setRtmpAvailability(entry.url, entry.disableAudio, null); + return false; + } + setRtmpAvailability(entry.url, entry.disableAudio, false); + return false; + } + + setRtmpAvailability(entry.url, entry.disableAudio, true); + return true; + }).catch(function () { + setRtmpAvailability(entry.url, entry.disableAudio, false); + return false; + }); + })); + + return results.every(function (value) { + return Boolean(value); + }); +} + +function syncRtmpBrowserWarmups(sourceSlides, targetIndex) { + return; +} + +function isRtmpBrowserUnavailable(slide) { + if (!slide || !slide.template || !Array.isArray(slide.template.regions)) { + return false; + } + + var content = slide.content || {}; + return slide.template.regions.some(function (region) { + if (region.region_type !== 'rtmp') { + return false; + } + + var regionContent = content[region.region_key] || {}; + var sourceUrl = String(regionContent.value || '').trim(); + if (!sourceUrl) { + return false; + } + + var disableAudio = regionContent.disable_audio === undefined ? true : Boolean(regionContent.disable_audio); + var status = getRtmpBrowserReady(sourceUrl, disableAudio); + return Boolean(!status || isRtmpBrowserReadyStale(status) || status.ready !== true); + }); +} + +function bindRtmpVideoPlaceholder(video, placeholder) { + if (!video) { + return; + } + + var markReady = function () { + if (placeholder) { + placeholder.style.display = 'none'; + } + }; + + video.addEventListener('canplay', markReady, { once: true }); + video.addEventListener('playing', markReady, { once: true }); + if (video.readyState >= 2) { + markReady(); + } +} + +function startRtmpPlayback(video, sourceUrl, disableAudio, skipUnavailable, placeholder, isPreload, onFailure) { + var startupTimeoutMs = skipUnavailable ? 6000 : 0; + var startupTimer = null; + var probeRetryTimer = null; + var webReceiveTimer = null; + var webReceiveRetryUsed = false; + + function failPlayback(message, silent) { + if (video.dataset.rtmpFailureHandled === '1') { + return; + } + video.dataset.rtmpFailureHandled = '1'; + if (typeof onFailure === 'function') { + onFailure(message, silent); + return; + } + if (!isPreload && skipUnavailable && typeof handleRtmpPlaybackFailure === 'function' && handleRtmpPlaybackFailure(message, { silent: Boolean(silent) })) { + return; + } + if (placeholder) { + placeholder.style.display = 'flex'; + placeholder.textContent = message; + } + } + + function clearStartupTimer() { + if (startupTimer) { + window.clearTimeout(startupTimer); + startupTimer = null; + } + if (video.__rtmpStartupTimer) { + video.__rtmpStartupTimer = null; + } + if (probeRetryTimer) { + window.clearTimeout(probeRetryTimer); + probeRetryTimer = null; + } + if (video.__rtmpProbeRetryTimer) { + video.__rtmpProbeRetryTimer = null; + } + if (webReceiveTimer) { + window.clearTimeout(webReceiveTimer); + webReceiveTimer = null; + } + if (video.__rtmpWebReceiveTimer) { + video.__rtmpWebReceiveTimer = null; + } + } + + function tryPlay() { + if (video && typeof video.play === 'function') { + video.play().catch(function () { + return null; + }); + } + } + + function markReceiving() { + if (webReceiveTimer) { + window.clearTimeout(webReceiveTimer); + webReceiveTimer = null; + } + if (video.__rtmpWebReceiveTimer) { + video.__rtmpWebReceiveTimer = null; + } + } + + function scheduleReceiveRetry() { + if (isPreload || webReceiveRetryUsed || video.dataset.rtmpFailureHandled === '1') { + return; + } + if (webReceiveTimer) { + return; + } + webReceiveTimer = window.setTimeout(function () { + webReceiveTimer = null; + video.__rtmpWebReceiveTimer = null; + if (video.dataset.rtmpFailureHandled === '1' || webReceiveRetryUsed) { + return; + } + if (video.readyState >= 2) { + return; + } + webReceiveRetryUsed = true; + clearStartupTimer(); + try { + if (video.__rtmpHls) { + video.__rtmpHls.destroy(); + } + } catch (_error) { + // ignore cleanup errors + } + video.__rtmpHls = null; + requestSession(); + }, 1200); + video.__rtmpWebReceiveTimer = webReceiveTimer; + } + + function retryWebReceiveOrFail(message, silent) { + if (isPreload || video.dataset.rtmpFailureHandled === '1') { + return; + } + if (!webReceiveRetryUsed) { + webReceiveRetryUsed = true; + clearStartupTimer(); + if (webReceiveTimer) { + window.clearTimeout(webReceiveTimer); + webReceiveTimer = null; + } + if (video.__rtmpWebReceiveTimer) { + video.__rtmpWebReceiveTimer = null; + } + window.setTimeout(function () { + requestSession(); + }, 500); + return; + } + failPlayback(message, silent); + } + + video.dataset.rtmpInitialized = '1'; + video.muted = disableAudio; + video.controls = false; + video.playsInline = true; + video.autoplay = true; + + bindRtmpVideoPlaceholder(video, placeholder); + video.addEventListener('canplay', markReceiving, { once: true }); + video.addEventListener('playing', markReceiving, { once: true }); + + if (startupTimeoutMs > 0) { + startupTimer = window.setTimeout(function () { + startupTimer = null; + video.__rtmpStartupTimer = null; + failPlayback('Unable to load RTMP stream.', true); + }, startupTimeoutMs); + video.__rtmpStartupTimer = startupTimer; + } + + function retryWhileProbing() { + if (probeRetryTimer || video.dataset.rtmpFailureHandled === '1') { + return; + } + probeRetryTimer = window.setTimeout(function () { + probeRetryTimer = null; + video.__rtmpProbeRetryTimer = null; + requestSession(); + }, 500); + video.__rtmpProbeRetryTimer = probeRetryTimer; + } + + function requestSession() { + fetch(getRtmpSessionUrl(sourceUrl, disableAudio), { + credentials: 'same-origin' + }).then(function (response) { + return response.json().catch(function () { + return null; + }).then(function (payload) { + return { + response: response, + payload: payload + }; + }); + }).then(function (result) { + if (!result || !result.response) { + throw new Error('Unable to initialize RTMP stream.'); + } + + var response = result.response; + var payload = result.payload || null; + var playlistUrl = payload && payload.playlistUrl ? String(payload.playlistUrl).trim() : ''; + + if (!response.ok) { + if (response.status === 503 && payload && payload.probing) { + setRtmpAvailability(sourceUrl, disableAudio, null); + retryWhileProbing(); + return null; + } + setRtmpAvailability(sourceUrl, disableAudio, false); + retryWebReceiveOrFail('Unable to load RTMP stream.', true); + return null; + } + + setRtmpAvailability(sourceUrl, disableAudio, true); + if (!playlistUrl) { + throw new Error('RTMP playlist URL was not returned.'); + } + + if (window.Hls && window.Hls.isSupported && window.Hls.isSupported()) { + var hls = new window.Hls({ + enableWorker: true, + lowLatencyMode: true, + liveSyncDurationCount: 4, + liveMaxLatencyDurationCount: 8, + maxBufferLength: 20, + maxLiveSyncPlaybackRate: 1, + backBufferLength: 30 + }); + video.__rtmpHls = hls; + hls.attachMedia(video); + hls.on(window.Hls.Events.MEDIA_ATTACHED, function () { + hls.loadSource(playlistUrl); + scheduleReceiveRetry(); + }); + hls.on(window.Hls.Events.MANIFEST_PARSED, function () { + tryPlay(); + }); + hls.on(window.Hls.Events.ERROR, function (_event, data) { + if (data && data.fatal) { + clearStartupTimer(); + setRtmpAvailability(sourceUrl, disableAudio, false); + try { + hls.destroy(); + } catch (_error) { + // ignore cleanup errors + } + video.__rtmpHls = null; + retryWebReceiveOrFail('RTMP playback failed.', true); + } + }); + return; + } + + if (video.canPlayType && video.canPlayType('application/vnd.apple.mpegurl')) { + video.src = playlistUrl; + tryPlay(); + scheduleReceiveRetry(); + return; + } + + failPlayback('RTMP playback is not supported in this browser.', true); + return null; + }).catch(function () { + clearStartupTimer(); + setRtmpAvailability(sourceUrl, disableAudio, false); + retryWebReceiveOrFail('Unable to load RTMP stream.', true); + }); + } + + requestSession(); +} + function syncRtmpRegions(root) { if (!root) { return; @@ -118,93 +765,32 @@ function syncRtmpRegions(root) { return; } - video.dataset.rtmpInitialized = '1'; - video.muted = disableAudio; - video.controls = false; - video.playsInline = true; - video.autoplay = true; + var skipUnavailable = String(video.dataset.rtmpSkipUnavailable || '0') === '1'; + var startupTimer = null; - var markReady = function () { - if (placeholder) { - placeholder.style.display = 'none'; - } - }; - - var tryPlay = function () { - if (video && typeof video.play === 'function') { - video.play().catch(function () { - return null; - }); - } - }; - - video.addEventListener('canplay', markReady, { once: true }); - video.addEventListener('playing', markReady, { once: true }); - - fetch(getRtmpSessionUrl(sourceUrl, disableAudio), { - credentials: 'same-origin' - }).then(function (response) { - if (!response.ok) { - throw new Error('Unable to initialize RTMP stream.'); - } - return response.json(); - }).then(function (payload) { - var playlistUrl = payload && payload.playlistUrl ? String(payload.playlistUrl).trim() : ''; - if (!playlistUrl) { - throw new Error('RTMP playlist URL was not returned.'); - } - - if (window.Hls && window.Hls.isSupported && window.Hls.isSupported()) { - var hls = new window.Hls({ - enableWorker: true, - lowLatencyMode: true, - liveSyncDurationCount: 4, - liveMaxLatencyDurationCount: 8, - maxBufferLength: 20, - maxLiveSyncPlaybackRate: 1, - backBufferLength: 30 - }); - video.__rtmpHls = hls; - hls.attachMedia(video); - hls.on(window.Hls.Events.MEDIA_ATTACHED, function () { - hls.loadSource(playlistUrl); - }); - hls.on(window.Hls.Events.MANIFEST_PARSED, function () { - tryPlay(); - }); - hls.on(window.Hls.Events.ERROR, function (_event, data) { - if (data && data.fatal) { - try { - hls.destroy(); - } catch (_error) { - // ignore cleanup errors - } - video.__rtmpHls = null; - if (placeholder) { - placeholder.style.display = 'flex'; - placeholder.textContent = 'RTMP playback failed.'; - } - } - }); + if (skipUnavailable) { + var preflightStatus = getRtmpAvailability(sourceUrl, disableAudio); + if (preflightStatus && !isRtmpAvailabilityStale(preflightStatus) && preflightStatus.available === false) { + failPlayback('Unable to load RTMP stream.', true); return; } + } - if (video.canPlayType && video.canPlayType('application/vnd.apple.mpegurl')) { - video.src = playlistUrl; - tryPlay(); + function failPlayback(message, silent) { + if (video.dataset.rtmpFailureHandled === '1') { return; } + video.dataset.rtmpFailureHandled = '1'; + if (skipUnavailable && typeof handleRtmpPlaybackFailure === 'function' && handleRtmpPlaybackFailure(message, { silent: Boolean(silent) })) { + return; + } + if (placeholder) { + placeholder.style.display = 'flex'; + placeholder.textContent = message; + } + } - if (placeholder) { - placeholder.style.display = 'flex'; - placeholder.textContent = 'RTMP playback is not supported in this browser.'; - } - }).catch(function (_error) { - if (placeholder) { - placeholder.style.display = 'flex'; - placeholder.textContent = 'Unable to load RTMP stream.'; - } - }); + startRtmpPlayback(video, sourceUrl, disableAudio, skipUnavailable, placeholder, false); }); } @@ -215,6 +801,18 @@ function destroyRtmpRegions(root) { var videos = root.querySelectorAll('video[data-rtmp-source]'); Array.prototype.forEach.call(videos, function (video) { + if (video.__rtmpStartupTimer) { + window.clearTimeout(video.__rtmpStartupTimer); + video.__rtmpStartupTimer = null; + } + if (video.__rtmpProbeRetryTimer) { + window.clearTimeout(video.__rtmpProbeRetryTimer); + video.__rtmpProbeRetryTimer = null; + } + if (video.__rtmpWebReceiveTimer) { + window.clearTimeout(video.__rtmpWebReceiveTimer); + video.__rtmpWebReceiveTimer = null; + } if (video.__rtmpHls) { try { video.__rtmpHls.destroy(); diff --git a/src/player/render.js b/src/player/render.js index fb72d1b..91f20a7 100644 --- a/src/player/render.js +++ b/src/player/render.js @@ -1,5 +1,6 @@ const Handlebars = require('handlebars'); const { mediaKind, safeJsonForScript, getPlayerPageTemplate, getPlayerClientNameScript, getPlayerPageOfflineScript, getPlayerPagePlaylistScript, getPlayerPageCommandsScript, getPlayerPageRenderingScript, getPlayerPagePlaybackScript, getPlayerPageScript, getPlayerRegionScripts, getPlayerOnboardingLandingScript, getPlayerOnboardingFormScript } = require('./render-helpers'); +const { createThumbnailPreviewBootstrapScript } = require('./thumbnail-preview'); const { createPageAuthBundle, createPageFetchAuthScript } = require('../request-auth'); function renderPage(template, options) { @@ -117,7 +118,7 @@ function renderPlayerPage(slug, initialData) { return renderPage(template, { title: 'Screen ' + slug, body: '
Loading screen...
', - script: createPageFetchAuthScript(pageAuthToken) + hlsScriptTag + serviceWorkerScript + '' + '' + '' + '' + '' + onboardingScript + script + script: createPageFetchAuthScript(pageAuthToken) + hlsScriptTag + serviceWorkerScript + createThumbnailPreviewBootstrapScript(initialData) + '' + '' + '' + '' + '' + onboardingScript + script }); } diff --git a/src/player/routes.js b/src/player/routes.js index bdaadb0..3966a43 100644 --- a/src/player/routes.js +++ b/src/player/routes.js @@ -2,6 +2,7 @@ const fs = require('fs'); const express = require('express'); const path = require('path'); const { getSharedSecret, createPageAuthBundle, verifyPageAuthToken, verifyRequestAuth } = require('../request-auth'); +const { buildThumbnailPreviewData } = require('./thumbnail-preview'); const TRANSIENT_DB_ERROR_CODES = ['ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'EPIPE', 'ENOTFOUND', 'PROTOCOL_CONNECTION_LOST', 'POOL_CLOSED', 'ERR_POOL_CLOSED']; @@ -58,6 +59,21 @@ function registerPlayerRoutes(app, options) { next(); } + function resolveMediaFilePath(fileName) { + const relativePath = path.normalize(String(fileName || '').trim()).replace(/^([\\/])+/, ''); + if (!relativePath || relativePath === '.' || relativePath.startsWith('..') || path.isAbsolute(relativePath)) { + return null; + } + + const resolvedMediaDir = path.resolve(mediaDir); + const resolvedFilePath = path.resolve(mediaDir, relativePath); + if (resolvedFilePath !== resolvedMediaDir && !resolvedFilePath.startsWith(resolvedMediaDir + path.sep)) { + return null; + } + + return resolvedFilePath; + } + app.use('/assets', express.static(assetDir)); app.use('/media', express.static(mediaDir)); app.use('/assets/vendor', express.static(path.join(__dirname, '..', '..', 'node_modules', 'hls.js', 'dist'))); @@ -93,21 +109,21 @@ function registerPlayerRoutes(app, options) { app.get('/api/media/config', requireRequestAuth, function (_req, res) { res.json({ - mediaDir: mediaDir + mediaDir: mediaDir, + uploadDir: path.join(mediaDir, 'uploads') }); }); app.put('/api/media/:filename', express.raw({ type: '*/*', limit: '100mb' }), requireRequestAuth, async function (req, res, next) { try { - const filename = require('path').basename(String(req.params.filename || '').trim()); - if (!filename) { + const filePath = resolveMediaFilePath(req.params.filename); + if (!filePath) { return res.status(400).json({ error: 'Filename is required' }); } - const filePath = require('path').join(mediaDir, filename); const body = Buffer.isBuffer(req.body) ? req.body : Buffer.from(req.body || ''); - await fs.promises.mkdir(mediaDir, { recursive: true }); + await fs.promises.mkdir(path.dirname(filePath), { recursive: true }); await fs.promises.writeFile(filePath, body); - res.json({ ok: true, filename: filename }); + res.json({ ok: true, filename: req.params.filename }); } catch (error) { next(error); } @@ -115,11 +131,10 @@ function registerPlayerRoutes(app, options) { app.delete('/api/media/:filename', requireRequestAuth, async function (req, res, next) { try { - const filename = require('path').basename(String(req.params.filename || '').trim()); - if (!filename) { + const filePath = resolveMediaFilePath(req.params.filename); + if (!filePath) { return res.status(400).json({ error: 'Filename is required' }); } - const filePath = require('path').join(mediaDir, filename); try { await fs.promises.unlink(filePath); } catch (error) { @@ -127,7 +142,7 @@ function registerPlayerRoutes(app, options) { throw error; } } - res.json({ ok: true, filename: filename }); + res.json({ ok: true, filename: req.params.filename }); } catch (error) { next(error); } @@ -138,16 +153,25 @@ function registerPlayerRoutes(app, options) { const source = String(req.query.source || '').trim(); const disableAudio = String(req.query.disableAudio || '').trim().toLowerCase(); const useMutedOutput = disableAudio === '1' || disableAudio === 'true' || disableAudio === 'yes' || disableAudio === 'on'; - const session = await rtmpStreamService.ensureSession(source, useMutedOutput); - await session.ready.catch(function () { - return false; - }); + const status = await rtmpStreamService.getSessionStatus(source, useMutedOutput); + const session = status.session; + const ready = Boolean(status.ready); + const live = Boolean(status.live); res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate'); + if (!ready || !live) { + return res.status(503).json({ + ready: false, + live: false, + timedOut: Boolean(status.timedOut), + stderr: String(status.stderr || '') + }); + } res.json({ key: session.key, playlistUrl: session.playlistUrl, disableAudio: session.disableAudio, - ready: true + ready: true, + live: true }); } catch (error) { next(error); @@ -192,6 +216,43 @@ function registerPlayerRoutes(app, options) { }); }); + app.get('/api/internal/slide-thumbnails/:id/preview', requireRequestAuth, async function (req, res, next) { + try { + const slide = await common.fetchSlideById(pool, Number(req.params.id)); + if (!slide) { + return res.status(404).send('Slide not found'); + } + + const data = buildThumbnailPreviewData(slide); + + if (typeof common.fetchRssFeedsData === 'function' && typeof common.fetchRssFeedItemsByFeedId === 'function') { + const rssData = await common.fetchRssFeedsData(pool); + data.rssFeeds = await Promise.all((rssData.rssFeeds || []).map(async function (feed) { + const items = await common.fetchRssFeedItemsByFeedId(pool, feed.id); + return Object.assign({}, feed, { + items: items.map(function (item) { + return typeof common.normalizeRssFeedItem === 'function' ? common.normalizeRssFeedItem(item) : item; + }) + }); + })); + } + + if (typeof common.fetchApiSourcesData === 'function') { + const apiData = await common.fetchApiSourcesData(pool); + data.apiSources = (apiData.apiSources || []).map(function (source) { + return Object.assign({}, source, { + responseJson: typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(source.last_response_json) : null + }); + }); + } + + res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate'); + res.send(common.renderPlayerPage('slide-thumbnail-preview-' + slide.id, data)); + } catch (error) { + next(error); + } + }); + app.get('/api/screens/:slug/playlist', requirePageAuth(['player']), async function (req, res, next) { try { res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate'); diff --git a/src/player/thumbnail-preview.js b/src/player/thumbnail-preview.js new file mode 100644 index 0000000..732997c --- /dev/null +++ b/src/player/thumbnail-preview.js @@ -0,0 +1,27 @@ +function buildThumbnailPreviewData(slide) { + return { + thumbnailPreview: true, + screen: { + id: slide.id, + name: slide.title, + slug: 'slide-thumbnail-preview-' + slide.id, + playlist_id: null + }, + playlist: { + fade_between_slides: false + }, + slides: [slide], + rssFeeds: [], + apiSources: [], + revision: String(slide.modified_at || slide.id || Date.now()) + }; +} + +function createThumbnailPreviewBootstrapScript(initialData) { + return initialData && initialData.thumbnailPreview ? '' : ''; +} + +module.exports = { + buildThumbnailPreviewData: buildThumbnailPreviewData, + createThumbnailPreviewBootstrapScript: createThumbnailPreviewBootstrapScript +}; \ No newline at end of file diff --git a/src/web.js b/src/web.js index d38442c..abe6838 100644 --- a/src/web.js +++ b/src/web.js @@ -17,6 +17,7 @@ const registerAdminContentRoutes = require('./web/routes/admin/content'); const registerAdminDataSourceRoutes = require('./web/routes/data-sources'); const registerAdminSettingsRoutes = require('./web/routes/settings/background-tasks'); const { createBackgroundTaskQueue, normalizeIntervalMs } = require('./web/lib/background-task-queue'); +const { captureSlideThumbnail } = require('./web/lib/slide-thumbnails'); const { refreshApiSource, refreshRssFeed } = require('./web/lib/data-source-refresh'); const { createWebBootstrap } = require('./web/bootstrap'); const { requirePermission } = require('./rbac'); @@ -52,7 +53,11 @@ async function start() { const pool = common.createPool(); const PORT = Number(process.env.WEB_PORT || 3000); const MEDIA_DIR = path.join(__dirname, '..', 'media'); + const UPLOADS_DIR = path.join(MEDIA_DIR, 'uploads'); + const THUMBNAILS_DIR = path.join(MEDIA_DIR, 'thumbnails'); const ASSET_DIR = path.join(__dirname, 'web', 'public'); + const WEB_BASE_URL = (process.env.WEB_PUBLIC_BASE_URL || process.env.WEB_BASE_URL || ('http://127.0.0.1:' + PORT)).replace(/\/$/, ''); + const DATA_SOURCE_STARTUP_REFRESH_STAGGER_MS = Math.max(100, Number(process.env.DATA_SOURCE_STARTUP_REFRESH_STAGGER_MS || 250)); const backgroundTaskQueue = createBackgroundTaskQueue({ pool: pool, maxConcurrent: 1 @@ -83,7 +88,7 @@ async function start() { common: common, playerInternalBaseUrl: PLAYER_INTERNAL_BASE_URL, playerPublicBaseUrl: PLAYER_PUBLIC_BASE_URL, - uploadDir: MEDIA_DIR, + uploadDir: UPLOADS_DIR, dashboardRefreshIntervalMs: Number(process.env.DASHBOARD_REFRESH_INTERVAL_MS || 2000), formatDashboardDate: formatDashboardDate, notifyPlayerScreens: notifyPlayerScreens, @@ -138,10 +143,29 @@ async function start() { throw new Error('Unsupported data source refresh task.'); }); + backgroundTaskQueue.setTaskHandler('slide-thumbnail-refresh', async function (task) { + const payload = task && task.payload ? task.payload : {}; + const slideId = Number(payload.slideId || 0); + if (!Number.isFinite(slideId) || slideId <= 0) { + throw new Error('Slide id is required.'); + } + + return captureSlideThumbnail({ + pool: pool, + common: common, + mediaDir: MEDIA_DIR, + baseUrl: PLAYER_INTERNAL_BASE_URL, + slideId: slideId, + previousThumbnailPath: payload.previousThumbnailPath || null + }); + }); + app.use(express.urlencoded({ extended: true })); app.use(express.json()); app.use('/assets', express.static(ASSET_DIR)); app.use('/media', express.static(MEDIA_DIR)); + fs.mkdirSync(UPLOADS_DIR, { recursive: true }); + fs.mkdirSync(THUMBNAILS_DIR, { recursive: true }); app.use(async function (req, _res, next) { try { @@ -153,7 +177,7 @@ async function start() { }); app.use(function (req, res, next) { - if (req.path === '/' || req.path === '/login' || req.path === '/logout') { + if (req.path === '/' || req.path === '/login' || req.path === '/logout' || req.path.indexOf('/api/internal/slide-thumbnails/') === 0) { return next(); } @@ -255,7 +279,7 @@ async function start() { common: common, pages: pages, upload: upload, - uploadDir: MEDIA_DIR, + uploadDir: UPLOADS_DIR, fetchScreensBySlideId: fetchScreensBySlideId, fetchScreensByTemplateId: fetchScreensByTemplateId, collectUploadReferencesFromSlide: collectUploadReferencesFromSlide, @@ -266,6 +290,7 @@ async function start() { redirectAfterSave: redirectAfterSave, notifyPlayerScreens: notifyPlayerScreens, broadcastDashboardState: broadcastDashboardState, + backgroundTaskQueue: backgroundTaskQueue, getSlideDeleteBlockMessage: playerActionService.getSlideDeleteBlockMessage, getTemplateDeleteBlockMessage: playerActionService.getTemplateDeleteBlockMessage, getCanvasSizeDeleteBlockMessage: playerActionService.getCanvasSizeDeleteBlockMessage, @@ -340,7 +365,7 @@ async function start() { }); // Ensure schema and mirror media before the web service starts handling traffic. - await common.ensureSchema(pool); + await common.ensureSchema(pool, { mediaDir: MEDIA_DIR }); await backgroundTaskQueue.initialize(); async function syncRecurringRefreshes() { @@ -381,7 +406,65 @@ async function start() { }); } + async function scheduleInitialDataSourceRefreshes() { + const apiSourcesData = await common.fetchApiSourcesData(pool); + const rssFeedsData = await common.fetchRssFeedsData(pool); + const startupSources = []; + + (apiSourcesData.apiSources || []).forEach(function (apiSource) { + startupSources.push({ + type: 'api-source', + id: Number(apiSource.id), + name: apiSource.name, + run: function () { + return refreshApiSource(pool, common, apiSource.id, apiSource.api_url, null); + } + }); + }); + + (rssFeedsData.rssFeeds || []).forEach(function (rssFeed) { + startupSources.push({ + type: 'rss-feed', + id: Number(rssFeed.id), + name: rssFeed.name, + run: function () { + return refreshRssFeed(pool, common, rssFeed.id, rssFeed.feed_url, rssFeed.item_limit, null); + } + }); + }); + + startupSources.forEach(function (source, index) { + const startupDelayMs = index * DATA_SOURCE_STARTUP_REFRESH_STAGGER_MS; + + setTimeout(function () { + backgroundTaskQueue.enqueueTask({ + key: 'startup-data-source-refresh:' + source.type + ':' + source.id + ':' + Date.now(), + title: source.type === 'rss-feed' ? 'RSS feed refresh' : 'API source refresh', + category: 'data-source', + taskType: 'data-source-refresh', + metadata: { + sourceType: source.type, + sourceId: source.id, + sourceName: source.name, + startupRefresh: true + }, + payload: { + sourceType: source.type, + sourceId: source.id, + sourceName: source.name, + startupRefresh: true + } + }).catch(function (error) { + console.warn('Unable to queue startup refresh for ' + source.type + ' ' + source.id + ':', error); + }); + }, startupDelayMs); + }); + } + await syncRecurringRefreshes(); + scheduleInitialDataSourceRefreshes().catch(function (error) { + console.warn('Unable to schedule startup data source refreshes:', error); + }); fs.mkdirSync(MEDIA_DIR, { recursive: true }); await syncExistingUploadsToPlayer(pool, MEDIA_DIR).catch(function (error) { diff --git a/src/web/lib/background-task-queue.js b/src/web/lib/background-task-queue.js index 05bf523..f94fd87 100644 --- a/src/web/lib/background-task-queue.js +++ b/src/web/lib/background-task-queue.js @@ -600,22 +600,32 @@ function createBackgroundTaskQueue(options) { }; } + function getTaskSortTime(task) { + const finishedTime = task && task.finishedAt ? Date.parse(task.finishedAt) : NaN; + if (Number.isFinite(finishedTime)) { + return finishedTime; + } + + const createdTime = task && task.createdAt ? Date.parse(task.createdAt) : NaN; + if (Number.isFinite(createdTime)) { + return createdTime; + } + + return 0; + } + function listTasks() { return Array.from(tasksById.values()) .slice() .sort(function (left, right) { - const statusRank = { - running: 0, - queued: 1, - failed: 2, - completed: 3, - canceled: 4 - }; + const leftQueuedTime = left && left.createdAt ? Date.parse(left.createdAt) : NaN; + const rightQueuedTime = right && right.createdAt ? Date.parse(right.createdAt) : NaN; + if (Number.isFinite(leftQueuedTime) && Number.isFinite(rightQueuedTime) && leftQueuedTime !== rightQueuedTime) { + return rightQueuedTime - leftQueuedTime; + } - const leftRank = Object.prototype.hasOwnProperty.call(statusRank, left.status) ? statusRank[left.status] : 9; - const rightRank = Object.prototype.hasOwnProperty.call(statusRank, right.status) ? statusRank[right.status] : 9; - if (leftRank !== rightRank) { - return leftRank - rightRank; + if (Number.isFinite(leftQueuedTime) !== Number.isFinite(rightQueuedTime)) { + return Number.isFinite(leftQueuedTime) ? -1 : 1; } return right.id - left.id; diff --git a/src/web/lib/pagination.js b/src/web/lib/pagination.js new file mode 100644 index 0000000..03d6c75 --- /dev/null +++ b/src/web/lib/pagination.js @@ -0,0 +1,64 @@ +function normalizePageNumber(value) { + const pageNumber = Math.floor(Number(value) || 1); + return Math.max(1, pageNumber); +} + +function buildQueryString(query) { + const searchParams = new URLSearchParams(); + Object.keys(query || {}).forEach(function (key) { + const value = query[key]; + if (value === undefined || value === null || value === '') { + return; + } + searchParams.set(key, String(value)); + }); + + const queryString = searchParams.toString(); + return queryString ? `?${queryString}` : ''; +} + +function buildPagination(totalItems, currentPage, pageParam, queryState, pageSize, itemLabel, ariaLabel) { + const normalizedPageParam = String(pageParam || 'page').trim() || 'page'; + const normalizedPageSize = Math.max(1, Number(pageSize) || 10); + const totalPages = Math.max(1, Math.ceil(Number(totalItems) / normalizedPageSize)); + const safeCurrentPage = Math.min(normalizePageNumber(currentPage), totalPages); + const startIndex = totalItems <= 0 ? 0 : (safeCurrentPage - 1) * normalizedPageSize; + const endIndex = totalItems <= 0 ? 0 : Math.min(totalItems, startIndex + normalizedPageSize); + const pages = []; + + for (let pageNumber = 1; pageNumber <= totalPages; pageNumber += 1) { + const nextQuery = Object.assign({}, queryState, { [normalizedPageParam]: pageNumber }); + pages.push({ + number: pageNumber, + active: pageNumber === safeCurrentPage, + url: buildQueryString(nextQuery) + }); + } + + const previousQuery = Object.assign({}, queryState, { [normalizedPageParam]: safeCurrentPage - 1 }); + const nextQuery = Object.assign({}, queryState, { [normalizedPageParam]: safeCurrentPage + 1 }); + + return { + currentPage: safeCurrentPage, + totalPages: totalPages, + totalItems: Number(totalItems) || 0, + hasMultiplePages: totalPages > 1, + startItem: startIndex + 1, + endItem: endIndex, + hasPrevious: safeCurrentPage > 1, + hasNext: safeCurrentPage < totalPages, + previousUrl: buildQueryString(previousQuery), + nextUrl: buildQueryString(nextQuery), + pages: pages, + pageSize: normalizedPageSize, + pageParam: normalizedPageParam, + itemLabel: String(itemLabel || 'items'), + ariaLabel: String(ariaLabel || 'Pagination') + }; +} + +module.exports = { + normalizePageNumber, + buildQueryString, + buildPagination +}; \ No newline at end of file diff --git a/src/web/lib/rbac-data.js b/src/web/lib/rbac-data.js index 59de43c..e06c73a 100644 --- a/src/web/lib/rbac-data.js +++ b/src/web/lib/rbac-data.js @@ -1,4 +1,5 @@ const { PERMISSIONS, normalizePermissionKeys } = require('../../rbac'); +const { fetchPagedRows } = require('../../data/utils'); function parseCsvIds(value) { return String(value || '') @@ -27,6 +28,21 @@ async function fetchRoles(pool) { return rows || []; } +async function fetchRolesPage(pool, page, pageSize) { + 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 + ORDER BY r.name ASC`, + countSql: 'SELECT COUNT(*) AS count FROM roles', + page: page, + pageSize: pageSize + }); + + return Object.assign({ roles: paged.rows }, paged); +} + async function fetchRoleById(pool, roleId) { const [rows] = await pool.query( `SELECT r.id, r.role_key, r.name, r.description, r.created_at, r.modified_at, @@ -106,6 +122,40 @@ async function fetchUsersWithRoles(pool) { }); } +async function fetchUsersWithRolesPage(pool, page, pageSize) { + const paged = await fetchPagedRows(pool, { + selectSql: `SELECT u.id, u.name, u.username, u.created_at, u.modified_at, + COALESCE(role_data.role_names, '') AS role_names, + COALESCE(role_data.role_ids_csv, '') AS role_ids_csv + FROM 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 + GROUP BY ur.user_id + ) role_data ON role_data.user_id = u.id + ORDER BY u.id ASC`, + countSql: 'SELECT COUNT(*) AS count FROM users', + page: page, + pageSize: pageSize + }); + + return { + users: (paged.rows || []).map(function (row) { + return Object.assign({}, row, { + roleIds: parseCsvIds(row.role_ids_csv), + roleNames: String(row.role_names || '').trim() + }); + }), + totalItems: paged.totalItems, + totalPages: paged.totalPages, + currentPage: paged.currentPage, + pageSize: paged.pageSize + }; +} + async function fetchUserWithRoles(pool, userId) { const [rows] = await pool.query( `SELECT u.id, u.name, u.username, u.created_at, u.modified_at, @@ -184,11 +234,13 @@ module.exports = { PERMISSIONS, fetchPermissions, fetchRoles, + fetchRolesPage, fetchRoleById, fetchRolePermissionKeys, fetchRoleUserIds, fetchRolesForUser, fetchUsersWithRoles, + fetchUsersWithRolesPage, fetchUserWithRoles, syncUserRoles, syncRoleUsers, diff --git a/src/web/lib/slide-thumbnails.js b/src/web/lib/slide-thumbnails.js new file mode 100644 index 0000000..3252459 --- /dev/null +++ b/src/web/lib/slide-thumbnails.js @@ -0,0 +1,367 @@ +const fs = require('fs'); +const path = require('path'); +const puppeteer = require('puppeteer-core'); +const chromiumModule = require('@sparticuz/chromium'); +const sharp = require('sharp'); +const chromium = chromiumModule && typeof chromiumModule.executablePath === 'function' + ? chromiumModule + : chromiumModule && chromiumModule.default && typeof chromiumModule.default.executablePath === 'function' + ? chromiumModule.default + : chromiumModule; +const { + escapeHtml, + mediaKind, + renderEditorJsContent, + sanitizeFontFamily, + sanitizeFontSize, + sanitizeTextColor +} = require('../../player/render-helpers'); +const { createRequestAuthHeaders } = require('../../request-auth'); + +const SYSTEM_CHROMIUM_PATHS = [ + process.env.PUPPETEER_EXECUTABLE_PATH, + process.env.CHROMIUM_PATH, + '/usr/bin/chromium', + '/usr/bin/chromium-browser', + '/usr/local/bin/chromium', + '/snap/bin/chromium' +].filter(Boolean); +const PLAYER_VIEWPORT = { + width: 1920, + height: 1080, + deviceScaleFactor: 1 +}; +const THUMBNAIL_MAX_SIZE = { + width: 480, + height: 270 +}; + +function normalizeBaseUrl(baseUrl) { + return String(baseUrl || '').trim().replace(/\/$/, ''); +} + +function resolveAssetUrl(baseUrl, value) { + const raw = String(value || '').trim(); + if (!raw) { + return ''; + } + if (/^(?:https?:)?\/\//i.test(raw) || raw.startsWith('data:')) { + return raw; + } + const normalizedBaseUrl = normalizeBaseUrl(baseUrl); + if (!normalizedBaseUrl) { + return raw; + } + if (raw.startsWith('/')) { + return normalizedBaseUrl + raw; + } + return normalizedBaseUrl + '/' + raw.replace(/^\/+/, ''); +} + +function getCanvasSize(slide) { + const template = slide && slide.template ? slide.template : null; + return { + width: Math.max(1, Number(template && template.canvas_size_width ? template.canvas_size_width : 1920)), + height: Math.max(1, Number(template && template.canvas_size_height ? template.canvas_size_height : 1080)) + }; +} + +function getRegionContent(slide, region) { + const content = slide && slide.content && slide.content[region.region_key] ? slide.content[region.region_key] : {}; + return content && typeof content === 'object' ? content : { value: content }; +} + +function hasVisibleContent(html) { + return Boolean(String(html || '').replace(/<[^>]+>/g, '').trim()); +} + +function buildTextRegionMarkup(region, regionContent) { + const fontFamily = sanitizeFontFamily(regionContent.font_family || region.font_family); + const fontSize = sanitizeFontSize(regionContent.font_size || region.font_size); + const fontColor = sanitizeTextColor(regionContent.font_color || region.font_color); + const renderedBody = renderEditorJsContent(regionContent.value || ''); + if (!hasVisibleContent(renderedBody)) { + return ''; + } + + return '
' + renderedBody + '
'; +} + +function buildRegionInnerHtml(region, regionContent, baseUrl) { + const regionType = String(regionContent.type || region.region_type || 'text').trim().toLowerCase(); + const rawValue = regionContent && regionContent.value !== undefined ? regionContent.value : ''; + + if (regionType === 'image') { + const src = resolveAssetUrl(baseUrl, rawValue); + return src + ? '' + escapeHtml(region.label || region.region_key || 'image') + '' + : ''; + } + + if (regionType === 'webpage') { + const src = resolveAssetUrl(baseUrl, rawValue); + return src + ? '' + : ''; + } + + if (regionType === 'html') { + const html = String(rawValue || '').trim(); + return html + ? '' + : ''; + } + + if (regionType === 'rtmp') { + const label = String(rawValue || '').trim() || 'RTMP source'; + return '
' + escapeHtml(label) + '
'; + } + + return buildTextRegionMarkup(region, regionContent); +} + +function buildSlideHtml(slide, baseUrl) { + const canvasSize = getCanvasSize(slide); + const template = slide && slide.template ? slide.template : null; + const backgroundColor = sanitizeTextColor(template && template.background_color ? template.background_color : '#111111', '#111111'); + const backgroundImagePath = template && template.background_image_path ? resolveAssetUrl(baseUrl, template.background_image_path) : ''; + + if (!template) { + const mediaPath = String(slide && slide.media_path || '').trim(); + const mediaUrl = resolveAssetUrl(baseUrl, mediaPath); + const kind = mediaKind(mediaPath || slide.media_type || ''); + const mediaMarkup = kind === 'image' && mediaUrl + ? '' + escapeHtml(slide.title || 'slide') + '' + : ''; + + return [ + '', + '', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + '
', + mediaMarkup ? '
' + mediaMarkup + '
' : '', + '
', + ' ', + '' + ].join('\n'); + } + + const regions = Array.isArray(template && template.regions) ? template.regions.slice().sort(function (left, right) { + return Number(left.z_index || 0) - Number(right.z_index || 0) || Number(left.id || 0) - Number(right.id || 0); + }) : []; + + const regionsHtml = regions.map(function (region) { + const content = getRegionContent(slide, region); + const left = Math.max(0, Number(region.x || 0)); + const top = Math.max(0, Number(region.y || 0)); + const width = Math.max(1, Number(region.width || 1)); + const height = Math.max(1, Number(region.height || 1)); + const regionType = String(region.region_type || 'text').toLowerCase(); + const pixelWidth = Math.max(1, Math.round(Number(region.width || 0) || 1)); + const pixelHeight = Math.max(1, Math.round(Number(region.height || 0) || 1)); + const innerHtml = regionType === 'text' + ? buildTextRegionMarkup(Object.assign({}, region, { baseStyle: 'left:' + left + '%;top:' + top + '%;width:' + width + '%;height:' + height + '%;z-index:' + Number(region.z_index || 0) + ';', pixelWidth: pixelWidth, pixelHeight: pixelHeight }), content) + : buildRegionInnerHtml(Object.assign({}, region, { baseStyle: 'left:' + left + '%;top:' + top + '%;width:' + width + '%;height:' + height + '%;z-index:' + Number(region.z_index || 0) + ';', pixelWidth: pixelWidth, pixelHeight: pixelHeight }), content, baseUrl); + + if (!innerHtml) { + return ''; + } + + return [ + '
', + innerHtml, + '
' + ].join(''); + }).join(''); + + return [ + '', + '', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + '
', + '
', + backgroundImagePath ? ' ' : '', + regionsHtml, + '
', + '
', + ' ', + '' + ].join('\n'); +} + +async function launchBrowser() { + let executablePath = SYSTEM_CHROMIUM_PATHS.find(function (candidate) { + return fs.existsSync(candidate); + }) || ''; + const usingSystemChromium = Boolean(executablePath); + + if (!executablePath && chromium && typeof chromium.executablePath === 'function') { + executablePath = await chromium.executablePath(); + } + + if (!executablePath || !fs.existsSync(executablePath)) { + throw new Error('Chromium executable was not found.'); + } + + if (usingSystemChromium) { + return puppeteer.launch({ + args: [ + '--no-sandbox', + '--disable-setuid-sandbox', + '--disable-dev-shm-usage', + '--disable-gpu' + ], + defaultViewport: { width: 1920, height: 1080, deviceScaleFactor: 1 }, + executablePath: executablePath, + headless: true + }); + } + + return puppeteer.launch({ + args: puppeteer.defaultArgs({ + args: chromium && chromium.args ? chromium.args : [], + headless: 'shell' + }), + defaultViewport: chromium && chromium.defaultViewport ? chromium.defaultViewport : null, + executablePath: executablePath, + headless: 'shell' + }); +} + +async function captureSlideThumbnail(options) { + const pool = options && options.pool; + const common = options && options.common; + const mediaDir = String(options && options.mediaDir || '').trim(); + const baseUrl = normalizeBaseUrl(options && options.baseUrl); + const slideId = Number(options && options.slideId || 0); + const previousThumbnailPath = String(options && options.previousThumbnailPath || '').trim(); + + if (!pool || !common || !mediaDir || !Number.isFinite(slideId) || slideId <= 0) { + throw new Error('captureSlideThumbnail requires pool, common, mediaDir, and slideId.'); + } + + const slide = await common.fetchSlideById(pool, slideId); + if (!slide) { + throw new Error('Slide not found.'); + } + + const canvasSize = getCanvasSize(slide); + const thumbnailDir = path.join(mediaDir, 'thumbnails'); + const thumbnailRelativePath = String(slide.thumbnail_path || '').trim() || '/media/thumbnails/slides/slide-' + slide.id + '.png'; + const filePath = path.join(mediaDir, thumbnailRelativePath.replace(/^\/+media\//, '')); + const fullSizePath = filePath.replace(/\.png$/i, '.full.png'); + const thumbnailTempPath = filePath.replace(/\.png$/i, '.tmp.png'); + const thumbnailPath = thumbnailRelativePath; + + await fs.promises.mkdir(path.dirname(filePath), { recursive: true }); + + const browser = await launchBrowser(); + try { + const page = await browser.newPage(); + try { + const previewPath = '/api/internal/slide-thumbnails/' + slide.id + '/preview'; + const previewUrl = baseUrl + previewPath; + await page.setExtraHTTPHeaders(createRequestAuthHeaders({ + method: 'GET', + pathname: previewPath, + body: {} + })); + await page.setViewport(PLAYER_VIEWPORT); + await page.goto(previewUrl, { waitUntil: 'domcontentloaded', timeout: 30000 }); + await page.waitForSelector('.slide-canvas', { timeout: 30000, visible: true }); + const canvas = await page.$('.slide-canvas'); + if (!canvas) { + throw new Error('Player render did not produce a slide canvas.'); + } + await canvas.screenshot({ path: fullSizePath }); + } finally { + await page.close().catch(function () { + return null; + }); + } + } finally { + await browser.close().catch(function () { + return null; + }); + } + + await sharp(fullSizePath) + .resize({ + width: THUMBNAIL_MAX_SIZE.width, + height: THUMBNAIL_MAX_SIZE.height, + fit: 'inside', + withoutEnlargement: true + }) + .png() + .toFile(thumbnailTempPath); + + await fs.promises.rm(filePath, { force: true }); + await fs.promises.rename(thumbnailTempPath, filePath); + await fs.promises.unlink(fullSizePath).catch(function (error) { + if (!error || error.code !== 'ENOENT') { + throw error; + } + }); + + await pool.query('UPDATE slides SET thumbnail_path = ? WHERE id = ?', [thumbnailPath, slide.id]); + return { + slideId: slide.id, + thumbnailPath: thumbnailPath, + filePath: filePath, + fullSizePath: fullSizePath, + mediaKind: mediaKind(slide.media_path || '') + }; +} + +module.exports = { + captureSlideThumbnail: captureSlideThumbnail +}; \ No newline at end of file diff --git a/src/web/lib/upload-sync.js b/src/web/lib/upload-sync.js index d677c0c..cc57532 100644 --- a/src/web/lib/upload-sync.js +++ b/src/web/lib/upload-sync.js @@ -51,6 +51,32 @@ function createUploadSyncService(options) { return value; } + function getUploadRelativePath(uploadPath) { + const value = normalizeUploadReference(uploadPath); + if (!value) { + return null; + } + return value.replace(/^\/media\//, ''); + } + + function resolveUploadFilePath(uploadDir, uploadPath) { + const relativePath = getUploadRelativePath(uploadPath); + if (!relativePath) { + return null; + } + + const normalizedUploadDir = normalizeUploadRoot(uploadDir); + if (!normalizedUploadDir) { + return null; + } + + if (relativePath.startsWith('uploads/')) { + return path.join(normalizedUploadDir, relativePath.slice('uploads/'.length)); + } + + return path.join(path.dirname(normalizedUploadDir), relativePath); + } + function collectUploadReferencesFromValue(value, refs) { if (!value) { return refs; @@ -134,7 +160,7 @@ function createUploadSyncService(options) { continue; } - const filePath = path.join(uploadDir, path.basename(uploadPath)); + const filePath = resolveUploadFilePath(uploadDir, uploadPath); try { await fs.promises.unlink(filePath); } catch (error) { @@ -174,7 +200,7 @@ function createUploadSyncService(options) { return null; } const data = await response.json(); - const playerUploadDir = data && data.mediaDir ? normalizeUploadRoot(data.mediaDir) : null; + const playerUploadDir = data && (data.uploadDir || data.mediaDir) ? normalizeUploadRoot(data.uploadDir || data.mediaDir) : null; if (!playerUploadDir) { return null; } @@ -232,8 +258,11 @@ function createUploadSyncService(options) { return false; } - const filename = path.basename(uploadPath); - const sourcePath = path.join(localUploadDir, filename); + const relativePath = getUploadRelativePath(uploadPath); + const sourcePath = resolveUploadFilePath(localUploadDir, uploadPath); + if (!relativePath || !sourcePath) { + return false; + } let fileBuffer = null; try { fileBuffer = await fs.promises.readFile(sourcePath); @@ -247,10 +276,10 @@ function createUploadSyncService(options) { try { const authHeaders = createRequestAuthHeaders({ method: 'PUT', - pathname: `/api/media/${encodeURIComponent(filename)}`, + pathname: `/api/media/${encodeURIComponent(relativePath)}`, body: fileBuffer }); - const response = await fetch(`${playerInternalBaseUrl}/api/media/${encodeURIComponent(filename)}`, { + const response = await fetch(`${playerInternalBaseUrl}/api/media/${encodeURIComponent(relativePath)}`, { method: 'PUT', headers: { 'Content-Type': 'application/octet-stream', @@ -259,12 +288,12 @@ function createUploadSyncService(options) { body: fileBuffer }); if (!response.ok) { - console.warn('Unable to sync upload to player:', filename, response.status, response.statusText); + console.warn('Unable to sync upload to player:', relativePath, response.status, response.statusText); return false; } return true; } catch (error) { - console.warn('Unable to sync upload to player:', filename, error); + console.warn('Unable to sync upload to player:', relativePath, error); return false; } } @@ -274,13 +303,16 @@ function createUploadSyncService(options) { return false; } - const filename = path.basename(uploadPath); + const relativePath = getUploadRelativePath(uploadPath); + if (!relativePath) { + return false; + } try { const authHeaders = createRequestAuthHeaders({ method: 'DELETE', - pathname: `/api/media/${encodeURIComponent(filename)}` + pathname: `/api/media/${encodeURIComponent(relativePath)}` }); - const response = await fetch(`${playerInternalBaseUrl}/api/media/${encodeURIComponent(filename)}`, { + const response = await fetch(`${playerInternalBaseUrl}/api/media/${encodeURIComponent(relativePath)}`, { method: 'DELETE', headers: { Accept: 'application/json', @@ -288,12 +320,12 @@ function createUploadSyncService(options) { } }); if (!response.ok && response.status !== 404) { - console.warn('Unable to remove upload from player:', filename, response.status, response.statusText); + console.warn('Unable to remove upload from player:', relativePath, response.status, response.statusText); return false; } return true; } catch (error) { - console.warn('Unable to remove upload from player:', filename, error); + console.warn('Unable to remove upload from player:', relativePath, error); return false; } } diff --git a/src/web/public/css/theme-custom.css b/src/web/public/css/theme-custom.css index e883414..4845fd0 100644 --- a/src/web/public/css/theme-custom.css +++ b/src/web/public/css/theme-custom.css @@ -168,6 +168,32 @@ top: 12px; } +.card-header .card-tools > .btn, +.card-header .card-tools > a.btn { + flex: 0 0 auto; + white-space: nowrap; +} + +.card-header .card-tools .input-group { + flex: 1 1 11rem; + min-width: 8rem; + max-width: 14rem; +} + +.background-tasks-task-tools { + flex-wrap: nowrap; +} + +.background-tasks-task-search { + flex: 0 1 12rem; + min-width: 9rem; + max-width: 12rem; +} + +.background-tasks-task-status { + flex: 0 0 auto; +} + .admin-form-card { box-shadow: none; } @@ -1182,6 +1208,149 @@ html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile { display: none !important; } +.playlist-slide-picker-modal-body { + display: grid; + gap: 1rem; +} + +.playlist-slide-picker-toolbar { + position: sticky; + top: 0; + z-index: 1; + padding-bottom: 0.25rem; + background: var(--bs-modal-bg, var(--bs-body-bg)); +} + +.playlist-slide-picker-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 1.25rem; + max-height: min(64vh, 48rem); + overflow: auto; + padding: 0.25rem 0.5rem 0.5rem 0.25rem; +} + +.playlist-slide-picker-card { + appearance: none; + border: 1px solid var(--bs-border-color); + border-radius: 1.1rem; + background: var(--bs-body-bg); + color: var(--bs-body-color); + padding: 0; + display: grid; + gap: 0.3rem; + text-align: left; + cursor: pointer; + transition: border-color 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease; +} + +.playlist-slide-picker-card:hover, +.playlist-slide-picker-card:focus-visible { + border-color: var(--bs-primary); + box-shadow: 0 0 0 0.2rem rgba(var(--bs-primary-rgb), 0.15); + transform: translateY(-1px); + outline: 0; +} + +.playlist-slide-picker-card.is-selected { + border-color: var(--bs-primary); + box-shadow: 0 0 0 0.2rem rgba(var(--bs-primary-rgb), 0.2); +} + +.playlist-slide-picker-card.is-assigned, +.playlist-slide-picker-card:disabled { + cursor: not-allowed; + opacity: 0.55; + transform: none; + box-shadow: none; +} + +.playlist-slide-picker-card.is-hidden { + display: none !important; +} + +.playlist-slide-picker-media { + position: relative; + aspect-ratio: 16 / 9; + overflow: hidden; + border-radius: 1.05rem 1.05rem 0 0; + background: rgba(15, 23, 42, 0.08); + display: flex; + align-items: center; + justify-content: center; +} + +.playlist-slide-picker-thumb-image { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.playlist-slide-picker-thumb-placeholder { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.35rem; + color: var(--bs-secondary-color, #6c757d); + background: + linear-gradient(135deg, rgba(255, 255, 255, 0.35) 0%, rgba(255, 255, 255, 0.12) 100%), + repeating-linear-gradient(45deg, rgba(108, 117, 125, 0.08), rgba(108, 117, 125, 0.08) 12px, rgba(108, 117, 125, 0.04) 12px, rgba(108, 117, 125, 0.04) 24px); + font-size: 0.8rem; + text-align: center; + padding: 0.75rem; +} + +.playlist-slide-picker-thumb-placeholder-icon { + font-size: 1.5rem; + line-height: 1; +} + +.playlist-slide-picker-thumb-placeholder-label { + display: block; + font-weight: 500; +} + +.playlist-slide-picker-title { + font-weight: 600; + line-height: 1.25; + min-height: 0; + font-size: 0.98rem; + padding: 0 0.9rem 0.6rem; +} + +.playlist-slide-picker-meta { + display: none; +} + +.playlist-slide-picker-check { + position: absolute; + top: 0.5rem; + right: 0.5rem; + z-index: 1; + color: var(--bs-primary); + opacity: 0; + background: rgba(var(--bs-body-bg-rgb), 0.88); + border-radius: 999px; + padding: 0.2rem 0.3rem; + box-shadow: 0 0.2rem 0.5rem rgba(0, 0, 0, 0.12); +} + +.playlist-slide-picker-card.is-selected .playlist-slide-picker-check { + opacity: 1; +} + +.playlist-slide-picker-badge { + display: none; +} + +.playlist-slide-picker-empty.is-hidden { + display: none !important; +} + .schedule-day-grid .schedule-day-button { flex: 1 1 0; } @@ -1242,10 +1411,60 @@ td[data-label="Actions"] > div { flex: 0 0 auto; } +.playlist-duration-input { + width: 6rem; + min-width: 6rem; +} + table.table > :not(caption) > * > * { vertical-align: middle; } +.playlist-slide-cell { + display: flex; + align-items: center; + gap: 0.75rem; + min-width: 0; +} + +.playlist-slide-cell-content { + min-width: 0; +} + +.playlist-slide-title { + display: block; + line-height: 1.25; +} + +.playlist-slide-thumb { + flex: 0 0 auto; + width: 5.5rem; + height: 3.5rem; + border-radius: 0.5rem; + overflow: hidden; + background: rgba(15, 23, 42, 0.08); + display: flex; + align-items: center; + justify-content: center; +} + +.playlist-slide-thumb-image { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.playlist-slide-thumb-placeholder { + display: inline-flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + color: var(--bs-secondary-color, #6c757d); + font-size: 1.1rem; +} + .playlist-order-cell { width: 1%; white-space: nowrap; diff --git a/src/web/public/js/admin-page.js b/src/web/public/js/admin-page.js index ee29ec1..74969cc 100644 --- a/src/web/public/js/admin-page.js +++ b/src/web/public/js/admin-page.js @@ -434,6 +434,11 @@ window.location.replace(response.url); return; } + if (form.hasAttribute('data-async-save-reload-on-success')) { + clearFormDirty(form); + window.location.replace(response.url || window.location.href); + return; + } clearFormDirty(form); return response.text().then(function (text) { var savedMessage = ''; diff --git a/src/web/public/js/admin/admin-page.js b/src/web/public/js/admin/admin-page.js index ee29ec1..f9b845d 100644 --- a/src/web/public/js/admin/admin-page.js +++ b/src/web/public/js/admin/admin-page.js @@ -234,6 +234,12 @@ if (typeof window.initLocalDateTimes === 'function') { window.initLocalDateTimes(targetElement); } + if (typeof window.initTableSearches === 'function') { + window.initTableSearches(targetElement); + } + if (typeof window.initTablePaginations === 'function') { + window.initTablePaginations(targetElement); + } } function replaceRefreshTargetFromPage(refreshTargetSelector, sequenceId) { @@ -434,6 +440,11 @@ window.location.replace(response.url); return; } + if (form.hasAttribute('data-async-save-reload-on-success')) { + clearFormDirty(form); + window.location.replace(response.url || window.location.href); + return; + } clearFormDirty(form); return response.text().then(function (text) { var savedMessage = ''; @@ -577,6 +588,332 @@ }); } + function initTablePaginations(root) { + var scope = root && root.querySelectorAll ? root : document; + + Array.prototype.forEach.call(scope.querySelectorAll('[data-table-pagination="auto"]'), function (container) { + if (container.getAttribute('data-table-pagination-bound') === 'true') { + return; + } + + var table = container.querySelector('[data-table-searchable]'); + var cardHeader = container.querySelector('.card-header'); + var cardBody = container.querySelector('.card-body.table-responsive') || container.querySelector('.card-body'); + var footer = container.querySelector('[data-table-pagination-controls]'); + var footerSummary = null; + var footerList = null; + var syncFrame = 0; + var state = { + currentPage: 1, + pageSize: 1, + totalPages: 1 + }; + + if (!table || !cardBody || !table.tBodies.length) { + return; + } + + container.setAttribute('data-table-pagination-bound', 'true'); + + function getRows() { + return Array.prototype.slice.call(table.querySelectorAll('tbody tr[data-table-search-row]')); + } + + function isSearchMatched(row) { + return String(row.getAttribute('data-table-search-match') || 'true') !== 'false'; + } + + function ensureFooter() { + if (footer) { + footerSummary = footer.querySelector('[data-table-pagination-summary]'); + footerList = footer.querySelector('[data-table-pagination-list]'); + return footer; + } + + footer = document.createElement('div'); + footer.className = 'card-footer d-none'; + footer.setAttribute('data-table-pagination-controls', 'true'); + footer.innerHTML = '' + + '
' + + '
' + + '' + + '
'; + cardBody.parentNode.insertBefore(footer, cardBody.nextSibling); + footerSummary = footer.querySelector('[data-table-pagination-summary]'); + footerList = footer.querySelector('[data-table-pagination-list]'); + return footer; + } + + function setRowVisible(row, visible) { + row.hidden = !visible; + row.classList.toggle('d-none', !visible); + } + + function getAvailableHeight() { + var containerRect = container.getBoundingClientRect(); + var headerHeight = cardHeader ? cardHeader.getBoundingClientRect().height : 0; + var footerEstimate = footer && !footer.classList.contains('d-none') ? footer.getBoundingClientRect().height : 56; + return Math.max(0, window.innerHeight - Math.max(0, containerRect.top) - headerHeight - footerEstimate - 24); + } + + function renderFooter(totalItems) { + var pageButtons = []; + + ensureFooter(); + + if (state.totalPages <= 1 || totalItems <= 0) { + footer.classList.add('d-none'); + footerSummary.textContent = ''; + footerList.innerHTML = ''; + return; + } + + footer.classList.remove('d-none'); + footerSummary.textContent = 'Showing ' + (((state.currentPage - 1) * state.pageSize) + 1) + '-' + Math.min(totalItems, state.currentPage * state.pageSize) + ' of ' + totalItems; + + pageButtons.push('
  • Previous
  • '); + for (var pageNumber = 1; pageNumber <= state.totalPages; pageNumber += 1) { + pageButtons.push('
  • ' + pageNumber + '
  • '); + } + pageButtons.push('
  • Next
  • '); + footerList.innerHTML = pageButtons.join(''); + } + + function applyPagination(resetPage) { + var rows = getRows(); + var matchedRows = rows.filter(isSearchMatched); + var totalItems = matchedRows.length; + var rowHeight = 48; + var pageSize; + var startIndex; + var endIndex; + + if (resetPage) { + state.currentPage = 1; + } + + rows.forEach(function (row) { + if (!isSearchMatched(row)) { + setRowVisible(row, false); + } + }); + + if (!totalItems) { + state.pageSize = 1; + state.totalPages = 1; + state.currentPage = 1; + renderFooter(0); + return; + } + + matchedRows.forEach(function (row) { + setRowVisible(row, true); + }); + + if (matchedRows[0]) { + rowHeight = Math.max(24, matchedRows[0].getBoundingClientRect().height || matchedRows[0].offsetHeight || 48); + } + + pageSize = Math.max(1, Math.floor(getAvailableHeight() / rowHeight)); + + if (pageSize >= totalItems) { + state.pageSize = totalItems; + state.totalPages = 1; + state.currentPage = 1; + matchedRows.forEach(function (row) { + setRowVisible(row, true); + }); + renderFooter(totalItems); + return; + } + + state.pageSize = pageSize; + state.totalPages = Math.max(1, Math.ceil(totalItems / pageSize)); + state.currentPage = Math.min(Math.max(1, state.currentPage), state.totalPages); + startIndex = (state.currentPage - 1) * pageSize; + endIndex = startIndex + pageSize; + + matchedRows.forEach(function (row, index) { + setRowVisible(row, index >= startIndex && index < endIndex); + }); + + renderFooter(totalItems); + } + + function scheduleSync(options) { + if (syncFrame) { + window.cancelAnimationFrame(syncFrame); + } + + syncFrame = window.requestAnimationFrame(function () { + syncFrame = 0; + applyPagination(Boolean(options && options.resetPage)); + }); + } + + ensureFooter(); + + if (footer) { + footer.addEventListener('click', function (event) { + var target = event.target && event.target.closest ? event.target.closest('[data-table-pagination-page]') : null; + var pageValue; + + if (!target || target.closest('.disabled')) { + return; + } + + event.preventDefault(); + pageValue = Math.max(1, Math.floor(Number(target.getAttribute('data-table-pagination-page')) || 1)); + state.currentPage = pageValue; + scheduleSync(); + }); + } + + window.addEventListener('resize', function () { + scheduleSync(); + }); + + if (typeof ResizeObserver === 'function') { + try { + new ResizeObserver(function () { + scheduleSync(); + }).observe(container); + } catch (_error) { + // Ignore observer setup failures and rely on window resize. + } + } + + container.syncTablePagination = scheduleSync; + scheduleSync({ resetPage: true }); + }); + } + + function initTableSearches(root) { + var scope = root && root.querySelectorAll ? root : document; + + Array.prototype.forEach.call(scope.querySelectorAll('[data-table-search]'), function (input) { + if (input.getAttribute('data-table-search-bound') === 'true') { + return; + } + + var container = input.closest('[data-table-search-container]') || input.closest('.card') || document; + var table = container.querySelector('[data-table-searchable]'); + var emptyRow = null; + + if (!table) { + return; + } + + input.setAttribute('data-table-search-bound', 'true'); + + function getSearchableRows() { + return Array.prototype.slice.call(table.querySelectorAll('tbody tr[data-table-search-row]')); + } + + function getDefaultEmptyRow() { + return table.querySelector('tbody tr[data-table-search-empty-default]'); + } + + function removeGeneratedEmptyRow() { + if (emptyRow && emptyRow.parentNode) { + emptyRow.parentNode.removeChild(emptyRow); + } + emptyRow = null; + } + + function ensureGeneratedEmptyRow(message) { + var tbody = table.tBodies[0] || table.querySelector('tbody'); + var columnCount = 1; + + if (!tbody) { + return; + } + + if (!emptyRow) { + emptyRow = document.createElement('tr'); + emptyRow.setAttribute('data-table-search-empty-row', 'true'); + var cell = document.createElement('td'); + cell.className = 'empty'; + cell.setAttribute('data-table-search-empty-cell', 'true'); + emptyRow.appendChild(cell); + } + + columnCount = table.tHead && table.tHead.rows && table.tHead.rows[0] ? table.tHead.rows[0].cells.length : (getSearchableRows()[0] ? getSearchableRows()[0].cells.length : 1); + emptyRow.firstElementChild.colSpan = Math.max(1, columnCount); + emptyRow.firstElementChild.textContent = message; + + if (!emptyRow.parentNode) { + tbody.appendChild(emptyRow); + } + } + + function syncSearch() { + var query = String(input.value || '').trim().toLowerCase(); + var rows = getSearchableRows(); + var visibleCount = 0; + var defaultEmptyRow = getDefaultEmptyRow(); + + removeGeneratedEmptyRow(); + + rows.forEach(function (row) { + var haystack = String(row.getAttribute('data-search-text') || row.textContent || '').toLowerCase(); + var visible = !query || haystack.indexOf(query) !== -1; + row.setAttribute('data-table-search-match', visible ? 'true' : 'false'); + row.classList.toggle('d-none', !visible); + row.hidden = !visible; + if (visible) { + visibleCount += 1; + } + }); + + if (defaultEmptyRow) { + defaultEmptyRow.classList.toggle('d-none', Boolean(query)); + defaultEmptyRow.hidden = Boolean(query); + } + + if (query && visibleCount === 0) { + ensureGeneratedEmptyRow('No results match your search.'); + } + + if (typeof window.syncTablePagination === 'function') { + window.syncTablePagination(container, { resetPage: true }); + } + } + + input.addEventListener('input', syncSearch); + syncSearch(); + }); + } + + function createSlideThumbPlaceholder() { + var placeholder = document.createElement('span'); + var icon = document.createElement('i'); + + placeholder.className = 'playlist-slide-thumb-placeholder'; + icon.className = 'bi bi-image'; + icon.setAttribute('aria-hidden', 'true'); + placeholder.appendChild(icon); + return placeholder; + } + + function attachSlideThumbFallbacks(root) { + var scope = root && root.querySelectorAll ? root : document; + + Array.prototype.forEach.call(scope.querySelectorAll('.playlist-slide-thumb-image'), function (image) { + if (image.getAttribute('data-slide-thumb-fallback-bound') === 'true') { + return; + } + image.setAttribute('data-slide-thumb-fallback-bound', 'true'); + image.addEventListener('error', function () { + if (image.parentNode) { + image.parentNode.replaceChild(createSlideThumbPlaceholder(), image); + } + }); + }); + } + if (window.initSortableTables) { window.initSortableTables(); } @@ -589,6 +926,20 @@ initSubmitOnChange(); initJsonTogglePanels(); initLocalDateTimes(); + initTableSearches(); + initTablePaginations(); + attachSlideThumbFallbacks(document); window.initJsonTogglePanels = initJsonTogglePanels; window.initLocalDateTimes = initLocalDateTimes; + window.initTableSearches = initTableSearches; + window.initTablePaginations = initTablePaginations; + window.syncTablePagination = function (container, options) { + if (!container || !container.syncTablePagination) { + return; + } + + container.syncTablePagination(options); + }; + window.createSlideThumbPlaceholder = createSlideThumbPlaceholder; + window.attachSlideThumbFallbacks = attachSlideThumbFallbacks; }()); diff --git a/src/web/public/js/lib/modal.js b/src/web/public/js/lib/modal.js new file mode 100644 index 0000000..e3ffb0a --- /dev/null +++ b/src/web/public/js/lib/modal.js @@ -0,0 +1,32 @@ +(function () { + function getOrCreate(modalElement) { + if (!modalElement || !window.bootstrap || !window.bootstrap.Modal) { + return null; + } + return window.bootstrap.Modal.getOrCreateInstance(modalElement); + } + + function show(modalElement) { + var modal = getOrCreate(modalElement); + if (modal) { + modal.show(); + return true; + } + return false; + } + + function hide(modalElement) { + var modal = getOrCreate(modalElement); + if (modal) { + modal.hide(); + return true; + } + return false; + } + + window.pulseModal = { + getOrCreate: getOrCreate, + show: show, + hide: hide + }; +}()); \ No newline at end of file diff --git a/src/web/public/js/playlists/playlist-schedule.js b/src/web/public/js/playlists/playlist-schedule.js index 032e39c..8ba6cbe 100644 --- a/src/web/public/js/playlists/playlist-schedule.js +++ b/src/web/public/js/playlists/playlist-schedule.js @@ -305,16 +305,239 @@ function initPlaylistEditStaging() { var tbody = document.getElementById('playlist-items-body'); var form = document.getElementById('playlist-edit-form'); - var addSection = document.getElementById('playlist-add-section'); - var addSlideForm = document.getElementById('playlist-add-slide-form'); - var addSlideButton = document.getElementById('playlist-add-slide-button'); - var addSlideSelect = document.getElementById('playlist-add-slide-select'); - var addSlideEmpty = document.getElementById('playlist-add-slide-empty'); + var addSlideModal = document.getElementById('playlist-add-slide-modal'); + var addSlideGrid = document.getElementById('playlist-slide-picker-grid'); + var addSlideSearch = document.getElementById('playlist-slide-picker-search'); + var addSlideEmpty = document.getElementById('playlist-slide-picker-empty'); + var addSlideConfirm = document.getElementById('playlist-confirm-add-slides'); + var addSlideCount = document.getElementById('playlist-slide-picker-selected-count'); + var addSlideData = document.getElementById('playlist-add-slide-data'); + var addSlideOpenButton = document.getElementById('playlist-open-slide-modal'); + var slidePickerCards = []; + var slidePickerSelection = new Set(); + var slidePickerSlides = []; if (!tbody || !form) { return; } + if (addSlideData) { + try { + var parsedSlides = JSON.parse(addSlideData.textContent || '[]'); + slidePickerSlides = Array.isArray(parsedSlides) ? parsedSlides : []; + } catch (_error) { + slidePickerSlides = []; + } + } + + function getModalInstance() { + if (!window.pulseModal) { + return null; + } + return window.pulseModal.getOrCreate(addSlideModal); + } + + function setCardSelected(card, selected) { + if (!card) { + return; + } + card.classList.toggle('is-selected', Boolean(selected)); + card.setAttribute('aria-pressed', Boolean(selected) ? 'true' : 'false'); + } + + function setCardVisible(card, visible) { + if (!card) { + return; + } + card.classList.toggle('is-hidden', !visible); + } + + function clearModalSelection() { + slidePickerSelection.clear(); + slidePickerCards.forEach(function (card) { + setCardSelected(card, false); + }); + if (addSlideSearch) { + addSlideSearch.value = ''; + } + syncSlidePickerState(); + } + + function attachSlideThumbFallbacks(root) { + if (typeof window.attachSlideThumbFallbacks === 'function') { + window.attachSlideThumbFallbacks(root || tbody); + } + } + + function createPickerThumb(slide) { + function createPlaceholder() { + var placeholder = document.createElement('div'); + var icon = document.createElement('i'); + var label = document.createElement('span'); + + placeholder.className = 'playlist-slide-picker-thumb-placeholder'; + icon.className = 'bi bi-image playlist-slide-picker-thumb-placeholder-icon'; + icon.setAttribute('aria-hidden', 'true'); + label.className = 'playlist-slide-picker-thumb-placeholder-label'; + label.textContent = 'No thumbnail'; + + placeholder.appendChild(icon); + placeholder.appendChild(label); + return placeholder; + } + + if (slide && slide.thumbnail_path) { + var image = document.createElement('img'); + image.className = 'playlist-slide-picker-thumb-image'; + image.loading = 'lazy'; + image.alt = String(slide.title || 'Slide'); + image.src = String(slide.thumbnail_path || ''); + image.addEventListener('error', function () { + if (image.parentNode) { + image.parentNode.replaceChild(createPlaceholder(), image); + } + }); + return image; + } + + return createPlaceholder(); + } + + function createPickerCard(slide) { + var button = document.createElement('button'); + var media = document.createElement('div'); + var title = document.createElement('div'); + var check = document.createElement('span'); + var searchText = String(slide && slide.title ? slide.title : '').toLowerCase(); + + button.type = 'button'; + button.className = 'playlist-slide-picker-card'; + button.setAttribute('aria-pressed', 'false'); + button.setAttribute('data-slide-id', String(slide.id || '')); + button.setAttribute('data-search-text', searchText); + + if (slide && slide.isAssigned) { + button.disabled = true; + button.classList.add('is-assigned'); + } + + media.className = 'playlist-slide-picker-media'; + media.appendChild(createPickerThumb(slide)); + check.className = 'playlist-slide-picker-check bi bi-check2-circle'; + check.setAttribute('aria-hidden', 'true'); + media.appendChild(check); + + title.className = 'playlist-slide-picker-title'; + title.textContent = String(slide && slide.title ? slide.title : 'Slide'); + + button.appendChild(media); + button.appendChild(title); + + return button; + } + + function renderSlidePicker() { + if (!addSlideGrid) { + return; + } + addSlideGrid.innerHTML = ''; + slidePickerCards = slidePickerSlides.map(function (slide) { + var card = createPickerCard(slide); + addSlideGrid.appendChild(card); + return card; + }); + syncSlidePickerState(); + } + + function syncSlidePickerState() { + var query = String(addSlideSearch && addSlideSearch.value ? addSlideSearch.value : '').trim().toLowerCase(); + var visibleCount = 0; + + slidePickerCards.forEach(function (card) { + var searchText = String(card.getAttribute('data-search-text') || ''); + var matches = !query || searchText.indexOf(query) !== -1; + setCardVisible(card, matches); + if (matches) { + visibleCount += 1; + } + }); + + if (addSlideEmpty) { + addSlideEmpty.textContent = query ? 'No slides match your search.' : 'No slides are currently available for this playlist.'; + addSlideEmpty.classList.toggle('is-hidden', visibleCount !== 0); + } + + if (addSlideCount) { + addSlideCount.textContent = String(slidePickerSelection.size); + } + + if (addSlideConfirm) { + addSlideConfirm.disabled = slidePickerSelection.size === 0; + } + } + + function toggleCardSelection(card) { + var slideId = String(card && card.getAttribute('data-slide-id') ? card.getAttribute('data-slide-id') : ''); + var selected; + + if (!slideId || card.disabled) { + return; + } + + selected = !slidePickerSelection.has(slideId); + if (selected) { + slidePickerSelection.add(slideId); + } else { + slidePickerSelection.delete(slideId); + } + setCardSelected(card, selected); + syncSlidePickerState(); + } + + function addSelectedSlides() { + var selectedSlides = slidePickerSlides.filter(function (slide) { + return slidePickerSelection.has(String(slide.id || '')) && !slide.isAssigned; + }); + + if (!selectedSlides.length) { + return; + } + + if (tbody.querySelector('.playlist-empty-row')) { + tbody.querySelector('.playlist-empty-row').remove(); + } + + selectedSlides.forEach(function (slide) { + var row = createRow({ + row_key: 'new-' + Date.now() + '-' + slide.id, + slide_id: String(slide.id), + thumbnail_path: String(slide.thumbnail_path || ''), + canvas_signature: String(slide.canvasSignature || ''), + title: slide.title || 'Slide', + duration_seconds: 10, + schedule_mode: 'always', + schedule_start_datetime: '', + schedule_end_datetime: '', + schedule_start_time: '', + schedule_end_time: '', + schedule_days_json: '[]', + summary: 'Always visible' + }); + tbody.appendChild(row); + }); + + attachSlideThumbFallbacks(tbody); + + updateRowOrder(true); + + if (!window.pulseModal || !window.pulseModal.hide(addSlideModal)) { + addSlideModal.classList.remove('show'); + addSlideModal.setAttribute('aria-hidden', 'true'); + } + } + + attachSlideThumbFallbacks(tbody); + function getRows() { return Array.prototype.slice.call(tbody.querySelectorAll('tr[data-playlist-slide-row]')); } @@ -375,9 +598,6 @@ } function syncAddSlideOptions() { - if (!addSlideSelect) { - return; - } var activeSlideIds = {}; var activeCanvasSignatures = {}; getRows().forEach(function (row) { @@ -397,51 +617,37 @@ allowedCanvasSignature = canvasKeys[0]; } - Array.prototype.forEach.call(addSlideSelect.options, function (option) { - if (!option.value) { - return; + slidePickerSlides.forEach(function (slide) { + var slideId = String(slide.id || ''); + var card = slidePickerCards.find(function (item) { + return String(item.getAttribute('data-slide-id') || '') === slideId; + }); + var canvasSignature = String(slide.canvasSignature || ''); + var isAssigned = Boolean(activeSlideIds[slideId]); + var isCanvasMismatch = Boolean(allowedCanvasSignature && canvasSignature && canvasSignature !== allowedCanvasSignature); + + if (card) { + card.disabled = isAssigned || isCanvasMismatch; + card.classList.toggle('is-assigned', isAssigned); + card.classList.toggle('is-mismatch', isCanvasMismatch); + card.classList.toggle('is-hidden', false); + card.setAttribute('aria-disabled', card.disabled ? 'true' : 'false'); } - var optionCanvasSignature = String(option.getAttribute('data-canvas-signature') || ''); - var isActive = Boolean(activeSlideIds[String(option.value)]); - var isCanvasMismatch = Boolean(allowedCanvasSignature && optionCanvasSignature && optionCanvasSignature !== allowedCanvasSignature); - option.disabled = isActive || isCanvasMismatch; - option.hidden = isActive || isCanvasMismatch; - }); - if ( - addSlideSelect.options[addSlideSelect.selectedIndex] - && (addSlideSelect.options[addSlideSelect.selectedIndex].disabled || addSlideSelect.options[addSlideSelect.selectedIndex].hidden) - ) { - for (var i = 0; i < addSlideSelect.options.length; i += 1) { - if (!addSlideSelect.options[i].disabled && !addSlideSelect.options[i].hidden) { - addSlideSelect.selectedIndex = i; - break; + if (isAssigned) { + slidePickerSelection.delete(slideId); + if (card) { + setCardSelected(card, false); } } - } - - var availableCount = 0; - Array.prototype.forEach.call(addSlideSelect.options, function (option) { - if (!option.value) { - return; - } - if (!option.disabled && !option.hidden) { - availableCount += 1; - } }); - if (addSlideButton) { - addSlideButton.disabled = availableCount === 0; - } - if (addSlideSelect) { - addSlideSelect.disabled = availableCount === 0; - } - if (addSlideEmpty) { - addSlideEmpty.classList.toggle('is-hidden', availableCount !== 0); - } - if (addSection && addSlideForm && addSlideSelect && addSlideButton) { - var controlsVisible = availableCount > 0; - addSlideForm.classList.toggle('is-hidden', !controlsVisible); + if (addSlideOpenButton) { + addSlideOpenButton.disabled = slidePickerCards.filter(function (card) { + return !card.disabled; + }).length === 0; } + + syncSlidePickerState(); } function updateRowOrder(markDirty) { @@ -530,6 +736,9 @@ var row = document.createElement('tr'); var rowKey = values.row_key || ('new-' + Date.now() + '-' + Math.random().toString(36).slice(2)); var playlistId = tbody.getAttribute('data-playlist-id') || ''; + var thumbnailMarkup = values.thumbnail_path + ? '' + : ''; row.setAttribute('data-playlist-slide-row', ''); row.setAttribute('data-row-key', rowKey); row.setAttribute('data-slide-id', String(values.slide_id)); @@ -541,7 +750,12 @@ '' + '' + '' + - '' + values.title + '' + + '' + + '
    ' + + '' + + '
    ' + values.title + '
    ' + + '
    ' + + '' + '' + '
    ' + values.summary + '
    ' + '' + @@ -551,7 +765,7 @@ '' + '' + '' + - '' + + '' + '
    ' + '' + '' + @@ -609,32 +823,37 @@ }); } - if (addSlideButton && addSlideSelect) { - addSlideButton.addEventListener('click', function () { - var selectedOption = addSlideSelect.options[addSlideSelect.selectedIndex]; - if (!selectedOption || selectedOption.disabled) { + if (addSlideModal && addSlideGrid) { + renderSlidePicker(); + + addSlideGrid.addEventListener('click', function (event) { + var card = event.target.closest('.playlist-slide-picker-card'); + if (!card) { return; } - var placeholder = tbody.querySelector('.playlist-empty-row'); - if (placeholder) { - placeholder.remove(); - } - var row = createRow({ - row_key: 'new-' + Date.now(), - slide_id: String(selectedOption.value), - canvas_signature: String(selectedOption.getAttribute('data-canvas-signature') || ''), - title: selectedOption.textContent || 'Slide', - duration_seconds: 10, - schedule_mode: 'always', - schedule_start_datetime: '', - schedule_end_datetime: '', - schedule_start_time: '', - schedule_end_time: '', - schedule_days_json: '[]', - summary: 'Always visible' + event.preventDefault(); + toggleCardSelection(card); + }); + + if (addSlideSearch) { + addSlideSearch.addEventListener('input', syncSlidePickerState); + } + + if (addSlideConfirm) { + addSlideConfirm.addEventListener('click', function () { + addSelectedSlides(); }); - tbody.appendChild(row); - updateRowOrder(true); + } + + addSlideModal.addEventListener('shown.bs.modal', function () { + syncSlidePickerState(); + if (addSlideSearch) { + addSlideSearch.focus(); + } + }); + + addSlideModal.addEventListener('hidden.bs.modal', function () { + clearModalSelection(); }); } diff --git a/src/web/public/js/settings/background-tasks.js b/src/web/public/js/settings/background-tasks.js index e610562..80ee5b9 100644 --- a/src/web/public/js/settings/background-tasks.js +++ b/src/web/public/js/settings/background-tasks.js @@ -8,6 +8,133 @@ return; } + function getPageUrl(href) { + try { + return new URL(href, window.location.href); + } catch (_error) { + return null; + } + } + + function updateLocalDateTimes(root) { + if (typeof window.initLocalDateTimes === 'function') { + window.initLocalDateTimes(root || document); + } + } + + function replaceSectionFromDocument(sectionName, responseDocument) { + var currentCard = document.querySelector('[data-pagination-card="' + sectionName + '"]'); + var nextCard = responseDocument.querySelector('[data-pagination-card="' + sectionName + '"]'); + + if (!currentCard || !nextCard) { + return false; + } + + currentCard.outerHTML = nextCard.outerHTML; + updateLocalDateTimes(document.querySelector('[data-pagination-card="' + sectionName + '"]')); + return true; + } + + function replaceSummaryFromDocument(responseDocument) { + var currentSummary = document.getElementById('background-tasks-summary'); + var nextSummary = responseDocument.getElementById('background-tasks-summary'); + + if (!currentSummary || !nextSummary) { + return false; + } + + currentSummary.outerHTML = nextSummary.outerHTML; + return true; + } + + function replaceFiltersFromDocument(responseDocument) { + var currentFilters = document.getElementById('background-tasks-filters'); + var nextFilters = responseDocument.getElementById('background-tasks-filters'); + + if (!currentFilters || !nextFilters) { + return false; + } + + currentFilters.outerHTML = nextFilters.outerHTML; + return true; + } + + function replaceStateFromDocument(responseDocument) { + var currentState = document.getElementById('background-tasks-state'); + var nextState = responseDocument.getElementById('background-tasks-state'); + + if (!currentState || !nextState) { + return false; + } + + currentState.outerHTML = nextState.outerHTML; + stateNode = document.getElementById('background-tasks-state'); + currentVersion = stateNode ? String(stateNode.getAttribute('data-state-version') || '') : currentVersion; + return true; + } + + function applyPageResponse(responseDocument, sectionNames) { + var updated = false; + + if (replaceStateFromDocument(responseDocument)) { + updated = true; + } + + if (replaceSummaryFromDocument(responseDocument)) { + updated = true; + } + + if (replaceFiltersFromDocument(responseDocument)) { + updated = true; + } + + sectionNames.forEach(function (sectionName) { + if (replaceSectionFromDocument(sectionName, responseDocument)) { + updated = true; + } + }); + + return updated; + } + + function loadPage(nextUrl, options) { + var url = getPageUrl(nextUrl); + var sectionNames = options && options.sectionNames ? options.sectionNames : []; + var replaceHistory = Boolean(options && options.replaceHistory); + + if (!url) { + return; + } + + fetch(url.toString(), { + method: 'GET', + headers: { + 'Accept': 'text/html, application/xhtml+xml', + 'X-Requested-With': 'XMLHttpRequest' + }, + credentials: 'same-origin', + cache: 'no-store' + }).then(function (response) { + if (!response.ok) { + throw new Error('Unable to load background tasks page.'); + } + return response.text(); + }).then(function (text) { + var responseDocument = new DOMParser().parseFromString(text || '', 'text/html'); + if (!applyPageResponse(responseDocument, sectionNames.length ? sectionNames : ['tasks', 'recurring'])) { + return; + } + + if (replaceHistory) { + window.history.replaceState({}, document.title, url.pathname + url.search + url.hash); + } else { + window.history.pushState({}, document.title, url.pathname + url.search + url.hash); + } + }).catch(function (_error) { + window.location.assign(url.pathname + url.search + url.hash); + }); + } + function stripMessageParameter() { try { var url = new URL(window.location.href); @@ -21,6 +148,100 @@ } } + function findPaginationLink(target) { + if (!target || !target.closest) { + return null; + } + + var link = target.closest('.pagination .page-link'); + if (!link) { + return null; + } + + var href = String(link.getAttribute('href') || '').trim(); + if (!href || href === '#') { + return null; + } + + var card = link.closest('[data-pagination-card]'); + if (!card) { + return null; + } + + return { + href: href, + sectionName: String(card.getAttribute('data-pagination-card') || '').trim() + }; + } + + function findFilterLink(target) { + if (!target || !target.closest) { + return null; + } + + var link = target.closest('a[data-background-tasks-nav]'); + if (!link) { + return null; + } + + var href = String(link.getAttribute('href') || '').trim(); + if (!href || href === '#') { + return null; + } + + return { + href: href + }; + } + + function initPaginationNavigation() { + document.addEventListener('click', function (event) { + if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) { + return; + } + + var paginationLink = findPaginationLink(event.target); + if (paginationLink && paginationLink.sectionName) { + var currentUrl = getPageUrl(window.location.href); + var nextUrl = getPageUrl(paginationLink.href); + if (!currentUrl || !nextUrl) { + return; + } + + if (currentUrl.pathname === nextUrl.pathname && currentUrl.search === nextUrl.search && currentUrl.hash === nextUrl.hash) { + event.preventDefault(); + return; + } + + event.preventDefault(); + loadPage(nextUrl.toString(), { sectionNames: [paginationLink.sectionName] }); + return; + } + + var filterLink = findFilterLink(event.target); + if (!filterLink) { + return; + } + + var filterUrl = getPageUrl(filterLink.href); + if (!filterUrl) { + return; + } + + if (filterUrl.pathname === window.location.pathname && filterUrl.search === window.location.search && filterUrl.hash === window.location.hash) { + event.preventDefault(); + return; + } + + event.preventDefault(); + loadPage(filterUrl.toString(), { sectionNames: ['tasks'] }); + }, true); + + window.addEventListener('popstate', function () { + loadPage(window.location.href, { replaceHistory: true }); + }); + } + function refreshPage() { if (document.visibilityState !== 'visible') { return; @@ -59,5 +280,6 @@ } stripMessageParameter(); + initPaginationNavigation(); window.setInterval(refreshPage, REFRESH_INTERVAL_MS); }()); \ No newline at end of file diff --git a/src/web/public/js/slide-form.js b/src/web/public/js/slide-form.js index b895d7a..ac39b02 100644 --- a/src/web/public/js/slide-form.js +++ b/src/web/public/js/slide-form.js @@ -543,7 +543,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo if (!src) { return '
    Webpage
    '; } - return ''; + return ''; } function renderPreviewHtmlRegion(value) { @@ -551,7 +551,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo if (!html) { return '
    HTML
    '; } - return ''; + return ''; } function destroyEditors() { diff --git a/src/web/public/js/slides/slide-form.js b/src/web/public/js/slides/slide-form.js index 6301c94..c1986f3 100644 --- a/src/web/public/js/slides/slide-form.js +++ b/src/web/public/js/slides/slide-form.js @@ -67,6 +67,34 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo var sidebarTopOffset = 16; var templateSelectorLock = createTemplateSelectorLockController(templateSelect); + function applyBackdropStyle(element, backgroundColor, backgroundImagePath) { + if (!element) { + return; + } + + var color = String(backgroundColor || '#111111').trim() || '#111111'; + element.style.backgroundColor = color; + element.style.backgroundImage = backgroundImagePath + ? 'linear-gradient(rgba(0, 0, 0, 0.42), rgba(0, 0, 0, 0.42)), url("' + encodeURI(String(backgroundImagePath)) + '")' + : 'linear-gradient(rgba(0, 0, 0, 0.42), rgba(0, 0, 0, 0.42))'; + element.style.backgroundPosition = backgroundImagePath ? 'center, center' : 'center'; + element.style.backgroundSize = backgroundImagePath ? '100% 100%, cover' : '100% 100%'; + element.style.backgroundRepeat = backgroundImagePath ? 'no-repeat, no-repeat' : 'no-repeat'; + } + + function applyPopupBackdropStyle(element, backgroundColor) { + if (!element) { + return; + } + + var color = String(backgroundColor || '#111111').trim() || '#111111'; + element.style.backgroundColor = color; + element.style.backgroundImage = 'linear-gradient(rgba(0, 0, 0, 0.42), rgba(0, 0, 0, 0.42))'; + element.style.backgroundPosition = 'center'; + element.style.backgroundSize = '100% 100%'; + element.style.backgroundRepeat = 'no-repeat'; + } + class FontSizeInputUI extends Plugin { static get pluginName() { return 'FontSizeInputUI'; @@ -408,7 +436,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo if (!src) { return '
    Webpage
    '; } - return ''; + return ''; } function renderPreviewRtmpRegion(value, disableAudio) { @@ -425,7 +453,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo if (!html) { return '
    HTML
    '; } - return ''; + return ''; } function destroyEditors() { @@ -814,7 +842,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo } if (!template) { - popupStage.style.backgroundColor = '#111111'; + applyBackdropStyle(popupCanvas, '#111111'); popupCanvas.style.width = ''; popupCanvas.style.height = ''; popupCanvas.style.transform = ''; @@ -837,7 +865,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo var canvasWidth = currentPreviewCanvasWidth || Math.max(1, Number(template.canvas_size_width || 1920)); var canvasHeight = currentPreviewCanvasHeight || Math.max(1, Number(template.canvas_size_height || 1080)); - popupStage.style.backgroundColor = template.background_color || '#111111'; + applyBackdropStyle(popupCanvas, template.background_color || '#111111', template.background_image_path); popupCanvas.style.width = canvasWidth + 'px'; popupCanvas.style.height = canvasHeight + 'px'; popupCanvas.innerHTML = slidePreviewCanvas.innerHTML; @@ -949,7 +977,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo slidePreviewOverlay.style.height = ''; slidePreviewOverlay.style.transform = ''; slidePreviewStage.style.aspectRatio = '16 / 9'; - slidePreviewStage.style.backgroundColor = '#111111'; + applyBackdropStyle(slidePreviewStage, '#111111'); return; } @@ -959,7 +987,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo currentPreviewCanvasWidth = canvasWidth; currentPreviewCanvasHeight = canvasHeight; slidePreviewStage.style.aspectRatio = canvasWidth + ' / ' + canvasHeight; - slidePreviewStage.style.backgroundColor = template.background_color || '#111111'; + applyBackdropStyle(slidePreviewStage, template.background_color || '#111111', template.background_image_path); slidePreviewMeta.textContent = canvasWidth + 'x' + canvasHeight; if (!(template.regions || []).length) { slidePreviewEmpty.style.display = 'flex'; diff --git a/src/web/public/js/templates/template-designer.js b/src/web/public/js/templates/template-designer.js index 070a437..2dfc36e 100644 --- a/src/web/public/js/templates/template-designer.js +++ b/src/web/public/js/templates/template-designer.js @@ -528,10 +528,10 @@ } function openAddRegionModal() { - if (!regionAddModal || !window.bootstrap || !window.bootstrap.Modal) { + if (!window.pulseModal) { return; } - window.bootstrap.Modal.getOrCreateInstance(regionAddModal).show(); + window.pulseModal.show(regionAddModal); } function createDefaultRegion(type) { @@ -701,8 +701,8 @@ button.addEventListener('click', function () { var regionType = button.getAttribute('data-add-region-type'); addRegion(createDefaultRegion(regionType)); - if (window.bootstrap && window.bootstrap.Modal) { - window.bootstrap.Modal.getOrCreateInstance(regionAddModal).hide(); + if (window.pulseModal) { + window.pulseModal.hide(regionAddModal); } }); }); diff --git a/src/web/routes/admin/content.js b/src/web/routes/admin/content.js index fe415a5..0f83a62 100644 --- a/src/web/routes/admin/content.js +++ b/src/web/routes/admin/content.js @@ -13,15 +13,37 @@ module.exports = function registerAdminContentRoutes(app, deps) { const redirectAfterSave = deps.redirectAfterSave; const notifyPlayerScreens = deps.notifyPlayerScreens; const broadcastDashboardState = deps.broadcastDashboardState; + const backgroundTaskQueue = deps.backgroundTaskQueue; const getSlideDeleteBlockMessage = deps.getSlideDeleteBlockMessage; const getTemplateDeleteBlockMessage = deps.getTemplateDeleteBlockMessage; const getCanvasSizeDeleteBlockMessage = deps.getCanvasSizeDeleteBlockMessage; const requirePermission = deps.requirePermission; + const { buildPagination } = require('../../lib/pagination'); + + const LIST_PAGE_SIZE = 10; if (!pool || !common || !pages || !upload || typeof fetchScreensBySlideId !== 'function' || typeof fetchScreensByTemplateId !== 'function' || typeof collectUploadReferencesFromSlide !== 'function' || typeof collectUploadReferencesFromTemplate !== 'function' || typeof collectUploadReferencesFromPayload !== 'function' || typeof syncPlaylistUploadsOnChange !== 'function' || typeof getAuditUserId !== 'function' || typeof redirectAfterSave !== 'function' || typeof notifyPlayerScreens !== 'function' || typeof broadcastDashboardState !== 'function' || typeof getSlideDeleteBlockMessage !== 'function' || typeof getTemplateDeleteBlockMessage !== 'function' || typeof getCanvasSizeDeleteBlockMessage !== 'function') { throw new Error('registerAdminContentRoutes requires the content route dependencies.'); } + function queueSlideThumbnailRefresh(slideId, previousThumbnailPath) { + if (!backgroundTaskQueue || typeof backgroundTaskQueue.enqueueTask !== 'function') { + return Promise.resolve(null); + } + + return backgroundTaskQueue.enqueueTask({ + key: 'slide-thumbnail:' + Number(slideId), + title: 'Refresh slide thumbnail', + category: 'slides', + taskType: 'slide-thumbnail-refresh', + payload: { + slideId: Number(slideId), + previousThumbnailPath: String(previousThumbnailPath || '').trim() + }, + 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 = ?'; @@ -58,8 +80,12 @@ module.exports = function registerAdminContentRoutes(app, deps) { app.get('/slides', requirePermission('slides.read'), async function (req, res, next) { try { - const data = await common.fetchAdminData(pool); - res.send(pages.renderSlidesPage(data, req.query.message ? String(req.query.message) : '', req.currentUser)); + const page = Math.max(1, Math.floor(Number(req.query.page) || 1)); + const data = await common.fetchSlidesPage(pool, page, LIST_PAGE_SIZE); + res.send(pages.renderSlidesPage({ + slides: data.slides || [], + pagination: buildPagination(data.totalItems, data.currentPage, 'page', {}, LIST_PAGE_SIZE, 'slides', 'Slide pages') + }, req.query.message ? String(req.query.message) : '', req.currentUser)); } catch (error) { next(error); } @@ -136,6 +162,9 @@ module.exports = function registerAdminContentRoutes(app, deps) { localUploadDir: deps.uploadDir, nextUploadRefs: collectUploadReferencesFromPayload(payload) }); + queueSlideThumbnailRefresh(result.insertId, null).catch(function (error) { + console.warn('Unable to queue slide thumbnail refresh:', error); + }); redirectAfterSave(req, res, '/slides/' + result.insertId + '/edit', { closeUrl: '/slides', newUrl: '/slides/new', @@ -176,6 +205,9 @@ module.exports = function registerAdminContentRoutes(app, deps) { screenSlideCounts: screenSlideCounts }); await broadcastDashboardState(); + queueSlideThumbnailRefresh(slide.id, slide.thumbnail_path).catch(function (error) { + console.warn('Unable to queue slide thumbnail refresh:', error); + }); redirectAfterSave(req, res, '/slides/' + slide.id + '/edit', { closeUrl: '/slides', newUrl: '/slides/new', @@ -218,8 +250,12 @@ module.exports = function registerAdminContentRoutes(app, deps) { app.get('/templates', requirePermission('templates.read'), async function (req, res, next) { try { - const data = await common.fetchTemplatesData(pool); - res.send(pages.renderTemplatesPage(data, req.query.message ? String(req.query.message) : '', req.currentUser)); + const page = Math.max(1, Math.floor(Number(req.query.page) || 1)); + const data = await common.fetchTemplatesPage(pool, page, LIST_PAGE_SIZE); + res.send(pages.renderTemplatesPage({ + templates: data.templates || [], + pagination: buildPagination(data.totalItems, data.currentPage, 'page', {}, LIST_PAGE_SIZE, 'templates', 'Template pages') + }, req.query.message ? String(req.query.message) : '', req.currentUser)); } catch (error) { next(error); } @@ -355,8 +391,12 @@ module.exports = function registerAdminContentRoutes(app, deps) { app.get('/canvas-sizes', requirePermission('canvas-sizes.read'), async function (req, res, next) { try { - const data = await common.fetchAdminData(pool); - res.send(pages.renderCanvasSizesPage(data, req.query.message ? String(req.query.message) : '', req.currentUser)); + const page = Math.max(1, Math.floor(Number(req.query.page) || 1)); + const data = await common.fetchCanvasSizesPage(pool, page, LIST_PAGE_SIZE); + res.send(pages.renderCanvasSizesPage({ + canvasSizes: data.canvasSizes || [], + pagination: buildPagination(data.totalItems, data.currentPage, 'page', {}, LIST_PAGE_SIZE, 'canvas sizes', 'Canvas size pages') + }, req.query.message ? String(req.query.message) : '', req.currentUser)); } catch (error) { next(error); } diff --git a/src/web/routes/admin/data-sources.js b/src/web/routes/admin/data-sources.js index 85de715..f232ae5 100644 --- a/src/web/routes/admin/data-sources.js +++ b/src/web/routes/admin/data-sources.js @@ -13,6 +13,9 @@ module.exports = function registerAdminDataSourceRoutes(app, deps) { const fetchRssFeedItems = deps.fetchRssFeedItems || (common && common.fetchRssFeedItems) || null; const replaceRssFeedItems = deps.replaceRssFeedItems || (common && common.replaceRssFeedItems) || null; const fetchApiSourceResponse = common && common.fetchApiSourceResponse ? common.fetchApiSourceResponse : null; + const { buildPagination } = require('../../lib/pagination'); + + const LIST_PAGE_SIZE = 10; function toIsoTimestamp(value) { if (!value) { @@ -195,8 +198,21 @@ module.exports = function registerAdminDataSourceRoutes(app, deps) { app.get('/data-sources/api-sources', requirePermission('api-sources.read'), async function (req, res, next) { try { - const data = await common.fetchApiSourcesData(pool); - res.send(pages.renderApiSourcesPage(data, req.query.message ? String(req.query.message) : '', req.currentUser, formatDashboardDate)); + const page = Math.max(1, Math.floor(Number(req.query.page) || 1)); + const data = await common.fetchApiSourcesPage(pool, page, LIST_PAGE_SIZE); + const apiSources = (data.apiSources || []).map(function (apiSource) { + return Object.assign({}, apiSource, { + intervalLabel: apiSource.update_interval_unit === 'seconds' + ? (Math.max(1, Number(apiSource.update_interval_value) || 0) === 1 ? 'Every second' : `Every ${Math.max(1, Number(apiSource.update_interval_value) || 0)} seconds`) + : (Math.max(1, Number(apiSource.update_interval_value) || 0) === 1 ? 'Every minute' : `Every ${Math.max(1, Number(apiSource.update_interval_value) || 0)} minutes`), + lastPullLabel: apiSource.last_pulled_at ? formatDashboardDate(apiSource.last_pulled_at) : 'Never', + lastPulledAtValue: apiSource.last_pulled_at ? new Date(apiSource.last_pulled_at).toISOString() : '' + }); + }); + res.send(pages.renderApiSourcesPage({ + apiSources: apiSources, + pagination: buildPagination(data.totalItems, data.currentPage, 'page', {}, LIST_PAGE_SIZE, 'API sources', 'API source pages') + }, req.query.message ? String(req.query.message) : '', req.currentUser, formatDashboardDate)); } catch (error) { next(error); } @@ -364,8 +380,12 @@ module.exports = function registerAdminDataSourceRoutes(app, deps) { app.get('/data-sources/rss-feeds', requirePermission('rss-feeds.read'), async function (req, res, next) { try { - const data = await common.fetchRssFeedsData(pool); - res.send(pages.renderRssFeedsPage(data, req.query.message ? String(req.query.message) : '', req.currentUser)); + const page = Math.max(1, Math.floor(Number(req.query.page) || 1)); + const data = await common.fetchRssFeedsPage(pool, page, LIST_PAGE_SIZE); + res.send(pages.renderRssFeedsPage({ + rssFeeds: data.rssFeeds || [], + pagination: buildPagination(data.totalItems, data.currentPage, 'page', {}, LIST_PAGE_SIZE, 'RSS feeds', 'RSS feed pages') + }, req.query.message ? String(req.query.message) : '', req.currentUser)); } catch (error) { next(error); } diff --git a/src/web/routes/admin/manage.js b/src/web/routes/admin/manage.js index a90cf4a..9e15288 100644 --- a/src/web/routes/admin/manage.js +++ b/src/web/routes/admin/manage.js @@ -76,6 +76,7 @@ module.exports = function registerAdminManageRoutes(app, deps) { try { const name = String(req.body.name || '').trim(); const fadeBetweenSlides = req.body.fade_between_slides ? 1 : 0; + const skipUnavailableRtmp = req.body.skip_unavailable_rtmp ? 1 : 0; if (!name) { return res.status(400).send('Playlist name is required.'); } @@ -83,7 +84,7 @@ module.exports = function registerAdminManageRoutes(app, deps) { 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, created_by, modified_by) VALUES (?, ?, ?, ?)', [name, fadeBetweenSlides, actorId, actorId]); + 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]); redirectAfterSave(req, res, '/playlists?edit=' + result.insertId, { closeUrl: '/playlists', newUrl: '/playlists/new', @@ -99,6 +100,7 @@ module.exports = function registerAdminManageRoutes(app, deps) { try { const name = String(req.body.name || '').trim(); const fadeBetweenSlides = req.body.fade_between_slides ? 1 : 0; + const skipUnavailableRtmp = req.body.skip_unavailable_rtmp ? 1 : 0; if (!name) { return res.status(400).send('Playlist name is required.'); } @@ -220,7 +222,7 @@ module.exports = function registerAdminManageRoutes(app, deps) { const actorId = getAuditUserId(req); await connection.beginTransaction(); - await connection.query('UPDATE playlists SET name = ?, fade_between_slides = ?, modified_by = ? WHERE id = ?', [name, fadeBetweenSlides, actorId, playlist.id]); + 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]); for (let i = 0; i < normalizedSlides.length; i += 1) { const item = normalizedSlides[i]; diff --git a/src/web/routes/admin/rbac.js b/src/web/routes/admin/rbac.js index 14a6a02..fd236e3 100644 --- a/src/web/routes/admin/rbac.js +++ b/src/web/routes/admin/rbac.js @@ -7,8 +7,11 @@ module.exports = function registerAdminRbacRoutes(app, deps) { const permissions = Array.isArray(deps.permissions) ? deps.permissions : []; const readArrayField = deps.readArrayField; const normalizePermissionKeys = deps.normalizePermissionKeys; + const { buildPagination } = require('../../lib/pagination'); const requirePermission = deps.requirePermission; + const LIST_PAGE_SIZE = 10; + function slugifyRoleKey(name) { const value = String(name || '').trim().toLowerCase(); const slug = value.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''); @@ -158,8 +161,12 @@ module.exports = function registerAdminRbacRoutes(app, deps) { app.get('/rbac', requirePermission('rbac.read'), async function (req, res, next) { try { - const roles = await rbacData.fetchRoles(pool); - res.send(pages.renderRbacPage({ roles: roles }, req.query.message ? String(req.query.message) : '', req.currentUser)); + const page = Math.max(1, Math.floor(Number(req.query.page) || 1)); + const data = await rbacData.fetchRolesPage(pool, page, LIST_PAGE_SIZE); + res.send(pages.renderRbacPage({ + roles: data.roles || [], + pagination: buildPagination(data.totalItems, data.currentPage, 'page', {}, LIST_PAGE_SIZE, 'roles', 'Role pages') + }, req.query.message ? String(req.query.message) : '', req.currentUser)); } catch (error) { next(error); } diff --git a/src/web/routes/admin/users.js b/src/web/routes/admin/users.js index 6bc3e0a..9a4ed2e 100644 --- a/src/web/routes/admin/users.js +++ b/src/web/routes/admin/users.js @@ -7,8 +7,11 @@ module.exports = function registerAdminUsersRoutes(app, deps) { const hashPassword = deps.hashPassword; const readArrayField = deps.readArrayField; const rbacData = deps.rbacData; + const { buildPagination } = require('../../lib/pagination'); const requirePermission = deps.requirePermission; + const LIST_PAGE_SIZE = 10; + async function fetchRoleOptions() { return rbacData.fetchRoles(pool); } @@ -53,8 +56,9 @@ module.exports = function registerAdminUsersRoutes(app, deps) { app.get('/users', requirePermission('users.read'), async function (req, res, next) { try { - const users = await rbacData.fetchUsersWithRoles(pool); - const mappedUsers = users.map(function (user) { + const page = Math.max(1, Math.floor(Number(req.query.page) || 1)); + const data = await rbacData.fetchUsersWithRolesPage(pool, page, LIST_PAGE_SIZE); + const mappedUsers = (data.users || []).map(function (user) { return Object.assign({}, user, { isCurrentUser: Number(user.id) === Number(req.currentUser.id), createdAtLabel: formatDashboardDate(user.created_at), @@ -62,7 +66,10 @@ module.exports = function registerAdminUsersRoutes(app, deps) { roleNames: String(user.roleNames || '').trim() || 'No roles assigned' }); }); - res.send(pages.renderUsersPage({ users: mappedUsers }, req.query.message ? String(req.query.message) : '', req.currentUser)); + res.send(pages.renderUsersPage({ + users: mappedUsers, + pagination: buildPagination(data.totalItems, data.currentPage, 'page', {}, LIST_PAGE_SIZE, 'users', 'Users pages') + }, req.query.message ? String(req.query.message) : '', req.currentUser)); } catch (error) { next(error); } diff --git a/src/web/routes/data-sources/api-sources/list.js b/src/web/routes/data-sources/api-sources/list.js index 45ff6c1..f9011c9 100644 --- a/src/web/routes/data-sources/api-sources/list.js +++ b/src/web/routes/data-sources/api-sources/list.js @@ -43,6 +43,7 @@ module.exports = function renderApiSourcesPage(data, message, currentUser, forma active: 'api-sources', message: message, currentUser: currentUser || null, - apiSources: apiSources + apiSources: apiSources, + pagination: data.pagination || null }); }; \ No newline at end of file diff --git a/src/web/routes/data-sources/rss-feeds/list.js b/src/web/routes/data-sources/rss-feeds/list.js index 0bec437..e8fdcf2 100644 --- a/src/web/routes/data-sources/rss-feeds/list.js +++ b/src/web/routes/data-sources/rss-feeds/list.js @@ -27,6 +27,7 @@ module.exports = function renderRssFeedsPage(data, message, currentUser) { active: 'rss-feeds', message: message, currentUser: currentUser || null, - rssFeeds: rssFeeds + rssFeeds: rssFeeds, + pagination: data.pagination || null }); }; \ No newline at end of file diff --git a/src/web/routes/settings/background-tasks-page.js b/src/web/routes/settings/background-tasks-page.js index fc83780..57ab515 100644 --- a/src/web/routes/settings/background-tasks-page.js +++ b/src/web/routes/settings/background-tasks-page.js @@ -2,6 +2,7 @@ const { renderView } = require('../../view'); const { hasAnyPermission } = require('../../../rbac'); const TASKS_PER_PAGE = 10; +const TASK_STATUSES = new Set(['queued', 'running', 'failed', 'completed', 'canceled']); function formatIntervalLabel(intervalMs) { const value = Math.max(1, Number(intervalMs) || 0); @@ -21,7 +22,40 @@ function parsePageNumber(value) { return Math.max(1, pageNumber); } -function buildPagination(totalItems, currentPage, pageParam) { +function normalizeStatusFilter(value) { + const status = String(value || '').trim().toLowerCase(); + return TASK_STATUSES.has(status) ? status : ''; +} + +function normalizeSourceFilter(sourceType, sourceId) { + const normalizedSourceType = String(sourceType || '').trim(); + const normalizedSourceId = Math.floor(Number(sourceId) || 0); + + if (!normalizedSourceType || !Number.isFinite(normalizedSourceId) || normalizedSourceId <= 0) { + return { sourceType: '', sourceId: '' }; + } + + return { + sourceType: normalizedSourceType, + sourceId: normalizedSourceId + }; +} + +function buildQueryString(query) { + const searchParams = new URLSearchParams(); + ['page', 'recurringPage', 'status', 'sourceType', 'sourceId'].forEach(function (key) { + const value = query && query[key]; + if (value === undefined || value === null || value === '') { + return; + } + searchParams.set(key, String(value)); + }); + + const queryString = searchParams.toString(); + return queryString ? `?${queryString}` : ''; +} + +function buildPagination(totalItems, currentPage, pageParam, queryState) { const normalizedPageParam = String(pageParam || 'page').trim() || 'page'; const totalPages = Math.max(1, Math.ceil(totalItems / TASKS_PER_PAGE)); const safeCurrentPage = Math.min(Math.max(1, currentPage), totalPages); @@ -30,13 +64,17 @@ function buildPagination(totalItems, currentPage, pageParam) { const pages = []; for (let pageNumber = 1; pageNumber <= totalPages; pageNumber += 1) { + const nextQuery = Object.assign({}, queryState, { [normalizedPageParam]: pageNumber }); pages.push({ number: pageNumber, active: pageNumber === safeCurrentPage, - url: `?${normalizedPageParam}=${pageNumber}` + url: buildQueryString(nextQuery) }); } + const previousQuery = Object.assign({}, queryState, { [normalizedPageParam]: safeCurrentPage - 1 }); + const nextQuery = Object.assign({}, queryState, { [normalizedPageParam]: safeCurrentPage + 1 }); + return { currentPage: safeCurrentPage, totalPages: totalPages, @@ -46,23 +84,139 @@ function buildPagination(totalItems, currentPage, pageParam) { endItem: endIndex, hasPrevious: safeCurrentPage > 1, hasNext: safeCurrentPage < totalPages, - previousUrl: `?${normalizedPageParam}=${safeCurrentPage - 1}`, - nextUrl: `?${normalizedPageParam}=${safeCurrentPage + 1}`, + previousUrl: buildQueryString(previousQuery), + nextUrl: buildQueryString(nextQuery), pages: pages, pageSize: TASKS_PER_PAGE, pageParam: normalizedPageParam }; } +function taskMatchesFilters(task, statusFilter, sourceFilter) { + if (statusFilter && task.status !== statusFilter) { + return false; + } + + if (!sourceFilter || !sourceFilter.sourceType || !sourceFilter.sourceId) { + return true; + } + + const metadata = task.metadata || {}; + return String(metadata.sourceType || '').trim() === sourceFilter.sourceType && Number(metadata.sourceId || 0) === sourceFilter.sourceId; +} + +function formatStatusLabel(status) { + const normalizedStatus = String(status || '').trim().toLowerCase(); + if (!normalizedStatus) { + return 'All tasks'; + } + + return normalizedStatus.charAt(0).toUpperCase() + normalizedStatus.slice(1); +} + +function buildTaskSourceFilterUrl(queryState, task, sourceFilter) { + const metadata = task.metadata || {}; + const sourceType = String(metadata.sourceType || '').trim(); + const sourceId = Math.floor(Number(metadata.sourceId) || 0); + + if (!sourceType || !Number.isFinite(sourceId) || sourceId <= 0) { + return ''; + } + + const isActive = sourceFilter && sourceFilter.sourceType === sourceType && sourceFilter.sourceId === sourceId; + const nextQuery = Object.assign({}, queryState, { + page: 1, + sourceType: isActive ? '' : sourceType, + sourceId: isActive ? '' : sourceId + }); + + return buildQueryString(nextQuery); +} + +function buildStatusFilterUrl(queryState, statusFilter) { + const nextQuery = Object.assign({}, queryState, { + page: 1, + status: statusFilter && statusFilter.status === statusFilter.targetStatus ? '' : statusFilter.targetStatus, + sourceType: statusFilter && statusFilter.sourceType ? statusFilter.sourceType : '', + sourceId: statusFilter && statusFilter.sourceId ? statusFilter.sourceId : '' + }); + + return buildQueryString(nextQuery); +} + module.exports = function renderBackgroundTasksPage(data, message, currentUser) { const tasks = (data && data.tasks) || []; const recurringTasks = (data && data.recurringTasks) || []; const summary = (data && data.summary) || { counts: {}, total: 0, activeCount: 0 }; const canManage = hasAnyPermission(currentUser, ['background-tasks.allow']); - const pagination = buildPagination(tasks.length, parsePageNumber(data && data.page), 'page'); - const recurringPagination = buildPagination(recurringTasks.length, parsePageNumber(data && data.recurringPage), 'recurringPage'); - const visibleTasks = tasks.slice((pagination.currentPage - 1) * TASKS_PER_PAGE, pagination.currentPage * TASKS_PER_PAGE); + const queryState = { + page: parsePageNumber(data && data.page), + recurringPage: parsePageNumber(data && data.recurringPage), + status: normalizeStatusFilter(data && data.status), + sourceType: '', + sourceId: '' + }; + const sourceFilter = normalizeSourceFilter(data && data.sourceType, data && data.sourceId); + queryState.status = normalizeStatusFilter(data && data.status); + queryState.sourceType = sourceFilter.sourceType; + queryState.sourceId = sourceFilter.sourceId; + const filteredTasks = tasks.filter(function (task) { + return taskMatchesFilters(task, queryState.status, sourceFilter); + }); + const pagination = buildPagination(filteredTasks.length, queryState.page, 'page', queryState); + const recurringPagination = buildPagination(recurringTasks.length, queryState.recurringPage, 'recurringPage', queryState); + const visibleTasks = filteredTasks.slice((pagination.currentPage - 1) * TASKS_PER_PAGE, pagination.currentPage * TASKS_PER_PAGE); const visibleRecurringTasks = recurringTasks.slice((recurringPagination.currentPage - 1) * TASKS_PER_PAGE, recurringPagination.currentPage * TASKS_PER_PAGE); + const statusCards = [ + { + status: 'queued', + label: 'Queued', + count: summary.counts.queued, + variant: 'primary' + }, + { + status: 'running', + label: 'Running', + count: summary.counts.running, + variant: 'info' + }, + { + status: 'failed', + label: 'Failed', + count: summary.counts.failed, + variant: 'danger' + }, + { + status: 'completed', + label: 'Finished', + count: summary.counts.completed, + variant: 'success' + } + ].map(function (card) { + const isActive = queryState.status === card.status; + const statusFilter = { + status: queryState.status, + sourceType: queryState.sourceType, + sourceId: queryState.sourceId, + targetStatus: card.status + }; + + return Object.assign({}, card, { + active: isActive, + href: buildStatusFilterUrl(queryState, statusFilter), + ariaLabel: isActive ? `Clear ${card.label.toLowerCase()} filter` : `Filter tasks by ${card.label.toLowerCase()}` + }); + }); + + const visibleTasksWithSources = visibleTasks.map(function (task) { + return Object.assign({}, task, { + 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) : '' + }); + }); + const hasActiveFilters = Boolean(queryState.status || queryState.sourceType || queryState.sourceId); return renderView('settings/background-tasks/index', { title: 'Background tasks', @@ -71,13 +225,18 @@ module.exports = function renderBackgroundTasksPage(data, message, currentUser) currentUser: currentUser || null, scripts: ['js/settings/background-tasks.js'], stateVersion: data && data.stateVersion ? String(data.stateVersion) : '', - tasks: visibleTasks, + tasks: visibleTasksWithSources, recurringTasks: visibleRecurringTasks.map(function (task) { return Object.assign({}, task, { intervalLabel: formatIntervalLabel(task.intervalMs) }); }), summary: summary, + statusCards: statusCards, + activeStatusLabel: formatStatusLabel(queryState.status), + activeSourceLabel: queryState.sourceType && queryState.sourceId ? `${queryState.sourceType} #${queryState.sourceId}` : '', + clearFiltersUrl: buildQueryString({ recurringPage: queryState.recurringPage }), + hasActiveFilters: hasActiveFilters, pagination: pagination, recurringPagination: recurringPagination, canManage: canManage, diff --git a/src/web/routes/settings/rbac/list.js b/src/web/routes/settings/rbac/list.js index 6fd7186..f691e12 100644 --- a/src/web/routes/settings/rbac/list.js +++ b/src/web/routes/settings/rbac/list.js @@ -6,6 +6,7 @@ module.exports = function renderRbacPage(data, message, currentUser) { active: 'rbac', message: message, currentUser: currentUser || null, - roles: data.roles || [] + roles: data.roles || [], + pagination: data.pagination || null }); }; \ No newline at end of file diff --git a/src/web/routes/settings/users/list.js b/src/web/routes/settings/users/list.js index ca1ec7b..80a5979 100644 --- a/src/web/routes/settings/users/list.js +++ b/src/web/routes/settings/users/list.js @@ -6,6 +6,7 @@ module.exports = function renderUsersPage(data, message, currentUser) { active: 'users', message: message, currentUser: currentUser || null, - users: data.users || [] + users: data.users || [], + pagination: data.pagination || null }); }; \ No newline at end of file diff --git a/src/web/routes/signage/canvas-sizes/list.js b/src/web/routes/signage/canvas-sizes/list.js index 5c54e4b..bf67317 100644 --- a/src/web/routes/signage/canvas-sizes/list.js +++ b/src/web/routes/signage/canvas-sizes/list.js @@ -6,7 +6,9 @@ module.exports = function renderCanvasSizesPage(data, message, currentUser) { name: size.name, width: size.width, height: size.height, - templateCount: (data.templates || []).filter((template) => Number(template.canvas_size_id) === Number(size.id)).length + templateCount: size.template_count !== undefined + ? Number(size.template_count) || 0 + : (data.templates || []).filter((template) => Number(template.canvas_size_id) === Number(size.id)).length })); return renderView('canvas-sizes/list', { @@ -14,6 +16,7 @@ module.exports = function renderCanvasSizesPage(data, message, currentUser) { active: 'canvas-sizes', message: message, currentUser: currentUser || null, - canvasSizes: canvasSizes + canvasSizes: canvasSizes, + pagination: data.pagination || null }); }; diff --git a/src/web/routes/signage/canvas-sizes/routes.js b/src/web/routes/signage/canvas-sizes/routes.js index 5265483..9ef7f93 100644 --- a/src/web/routes/signage/canvas-sizes/routes.js +++ b/src/web/routes/signage/canvas-sizes/routes.js @@ -2,12 +2,19 @@ module.exports = function registerCanvasSizeRoutes(app, deps) { const pages = deps.pages; const common = deps.common; const pool = deps.pool; + const { buildPagination } = require('../../../lib/pagination'); const requirePermission = deps.requirePermission; + const LIST_PAGE_SIZE = 10; + app.get('/canvas-sizes', requirePermission('canvas-sizes.read'), async function (req, res, next) { try { - const data = await common.fetchCanvasSizesData(pool); - res.send(pages.renderCanvasSizesPage(data, req.query.message ? String(req.query.message) : '', req.currentUser)); + const page = Math.max(1, Math.floor(Number(req.query.page) || 1)); + const data = await common.fetchCanvasSizesPage(pool, page, LIST_PAGE_SIZE); + res.send(pages.renderCanvasSizesPage({ + canvasSizes: data.canvasSizes || [], + pagination: buildPagination(data.totalItems, data.currentPage, 'page', {}, LIST_PAGE_SIZE, 'canvas sizes', 'Canvas size pages') + }, req.query.message ? String(req.query.message) : '', req.currentUser)); } catch (error) { next(error); } diff --git a/src/web/routes/signage/clients/list.js b/src/web/routes/signage/clients/list.js index fb58b3a..24b4f7d 100644 --- a/src/web/routes/signage/clients/list.js +++ b/src/web/routes/signage/clients/list.js @@ -7,6 +7,7 @@ module.exports = function renderConnectedClientsPage(data, message, currentUser) message: message, currentUser: currentUser || null, scripts: ['js/dashboard/dashboard-page.js'], - clients: data.clients || [] + clients: data.clients || [], + pagination: data.pagination || null }); }; \ No newline at end of file diff --git a/src/web/routes/signage/clients/routes.js b/src/web/routes/signage/clients/routes.js index e3745f9..fb91739 100644 --- a/src/web/routes/signage/clients/routes.js +++ b/src/web/routes/signage/clients/routes.js @@ -2,12 +2,22 @@ module.exports = function registerClientsRoutes(app, deps) { const pool = deps.pool; const pages = deps.pages; const buildDashboardState = deps.buildDashboardState; + const { buildPagination } = require('../../../lib/pagination'); const requirePermission = deps.requirePermission; + const LIST_PAGE_SIZE = 10; + app.get('/clients', requirePermission('clients.read'), async function (req, res, next) { try { const data = await buildDashboardState(pool); - res.send(pages.renderConnectedClientsPage(data, req.query.message ? String(req.query.message) : '', req.currentUser)); + const page = Math.max(1, Math.floor(Number(req.query.page) || 1)); + const totalItems = Array.isArray(data.clients) ? data.clients.length : 0; + const startIndex = (page - 1) * LIST_PAGE_SIZE; + const clients = (data.clients || []).slice(startIndex, startIndex + LIST_PAGE_SIZE); + res.send(pages.renderConnectedClientsPage({ + clients: clients, + pagination: buildPagination(totalItems, page, 'page', {}, LIST_PAGE_SIZE, 'clients', 'Client pages') + }, req.query.message ? String(req.query.message) : '', req.currentUser)); } catch (error) { next(error); } diff --git a/src/web/routes/signage/playlists/add.js b/src/web/routes/signage/playlists/add.js index aa08540..06ad89a 100644 --- a/src/web/routes/signage/playlists/add.js +++ b/src/web/routes/signage/playlists/add.js @@ -5,7 +5,8 @@ module.exports = function renderPlaylistFormPage(message, currentUser) { title: 'Create playlist', active: 'playlists', playlist: { - fade_between_slides: false + fade_between_slides: false, + skip_unavailable_rtmp: false }, message: message, currentUser: currentUser || null diff --git a/src/web/routes/signage/playlists/edit.js b/src/web/routes/signage/playlists/edit.js index 07ebd8d..224ce57 100644 --- a/src/web/routes/signage/playlists/edit.js +++ b/src/web/routes/signage/playlists/edit.js @@ -102,6 +102,7 @@ module.exports = function renderPlaylistEditPage(playlist, data, message, curren currentUser: currentUser || null, playlist: playlist, playlistFadeBetweenSlides: Boolean(playlist.fade_between_slides), + playlistSkipUnavailableRtmp: Boolean(playlist.skip_unavailable_rtmp), playlistSlides: playlistSlides, slides: data.slides || [], hasSlides: (data.slides || []).length > 0, @@ -110,6 +111,6 @@ module.exports = function renderPlaylistEditPage(playlist, data, message, curren selectableSlides: selectableSlides, playlistCanvasSignature: playlistCanvasSignature, playlistCanvasMismatch: playlistCanvasMismatch, - scripts: ['js/vendor/sortable.min.js', 'js/playlists/playlist-schedule.js'] + scripts: ['js/lib/modal.js', 'js/vendor/sortable.min.js', 'js/playlists/playlist-schedule.js'] }); }; diff --git a/src/web/routes/signage/playlists/list.js b/src/web/routes/signage/playlists/list.js index 24082ea..2c2eacd 100644 --- a/src/web/routes/signage/playlists/list.js +++ b/src/web/routes/signage/playlists/list.js @@ -1,9 +1,10 @@ const { renderView } = require('../../../view'); module.exports = function renderPlaylistsPage(data, message, currentUser) { - const playlistSlides = data.playlistSlides || []; const playlists = (data.playlists || []).map((playlist) => { - const slideCount = playlistSlides.filter((item) => item.playlist_id === playlist.id).length; + const slideCount = playlist.slide_count !== undefined + ? Number(playlist.slide_count) || 0 + : (data.playlistSlides || []).filter((item) => item.playlist_id === playlist.id).length; return Object.assign({}, playlist, { slideCount: slideCount, slideCountIsOne: slideCount === 1 @@ -15,7 +16,8 @@ module.exports = function renderPlaylistsPage(data, message, currentUser) { active: 'playlists', message: message, currentUser: currentUser || null, - playlists: playlists + playlists: playlists, + pagination: data.pagination || null }); }; diff --git a/src/web/routes/signage/playlists/routes.js b/src/web/routes/signage/playlists/routes.js index 5c9705b..c69a140 100644 --- a/src/web/routes/signage/playlists/routes.js +++ b/src/web/routes/signage/playlists/routes.js @@ -2,8 +2,11 @@ module.exports = function registerPlaylistsRoutes(app, deps) { const pool = deps.pool; const common = deps.common; const pages = deps.pages; + const { buildPagination } = require('../../../lib/pagination'); const requirePermission = deps.requirePermission; + const LIST_PAGE_SIZE = 10; + function requireQueryPermission(readPermissionKey, editPermissionKey) { return function (req, res, next) { const permissionKey = req.query && req.query.edit ? editPermissionKey : readPermissionKey; @@ -13,15 +16,20 @@ module.exports = function registerPlaylistsRoutes(app, deps) { app.get('/playlists', requireQueryPermission('playlists.read', 'playlists.update'), async function (req, res, next) { try { - const data = await common.fetchAdminData(pool); + const page = Math.max(1, Math.floor(Number(req.query.page) || 1)); if (req.query.edit) { + const data = await common.fetchAdminData(pool); const playlist = await common.fetchPlaylistById(pool, Number(req.query.edit)); if (!playlist) { return res.status(404).send('Playlist not found'); } return res.send(pages.renderPlaylistEditPage(playlist, data, req.query.message ? String(req.query.message) : '', req.currentUser)); } - res.send(pages.renderPlaylistsPage(data, req.query.message ? String(req.query.message) : '', req.currentUser)); + const data = await common.fetchPlaylistsPage(pool, page, LIST_PAGE_SIZE); + res.send(pages.renderPlaylistsPage({ + playlists: data.playlists || [], + pagination: buildPagination(data.totalItems, data.currentPage, 'page', {}, LIST_PAGE_SIZE, 'playlists', 'Playlist pages') + }, req.query.message ? String(req.query.message) : '', req.currentUser)); } catch (error) { next(error); } diff --git a/src/web/routes/signage/playlists/slide-config.js b/src/web/routes/signage/playlists/slide-config.js index 06c10f3..bc2b9c8 100644 --- a/src/web/routes/signage/playlists/slide-config.js +++ b/src/web/routes/signage/playlists/slide-config.js @@ -74,6 +74,6 @@ module.exports = function renderPlaylistSlideConfigPage(playlist, playlistSlide, ...day, checked: selectedDays.has(day.value) })), - scripts: ['js/playlists/playlist-schedule.js'] + scripts: ['js/lib/modal.js', 'js/playlists/playlist-schedule.js'] }); }; diff --git a/src/web/routes/signage/screens/list.js b/src/web/routes/signage/screens/list.js index b90ffab..4a7acae 100644 --- a/src/web/routes/signage/screens/list.js +++ b/src/web/routes/signage/screens/list.js @@ -6,7 +6,8 @@ module.exports = function renderScreensPage(data, message, currentUser) { active: 'screens', message: message, currentUser: currentUser || null, - screens: data.screens || [] + screens: data.screens || [], + pagination: data.pagination || null }); }; diff --git a/src/web/routes/signage/screens/routes.js b/src/web/routes/signage/screens/routes.js index abe4545..56f20be 100644 --- a/src/web/routes/signage/screens/routes.js +++ b/src/web/routes/signage/screens/routes.js @@ -3,8 +3,11 @@ module.exports = function registerScreensRoutes(app, deps) { const common = deps.common; const pages = deps.pages; const buildDashboardState = deps.buildDashboardState; + const { buildPagination } = require('../../../lib/pagination'); const requirePermission = deps.requirePermission; + const LIST_PAGE_SIZE = 10; + function requireQueryPermission(readPermissionKey, editPermissionKey) { return function (req, res, next) { const permissionKey = req.query && req.query.edit ? editPermissionKey : readPermissionKey; @@ -14,8 +17,8 @@ module.exports = function registerScreensRoutes(app, deps) { app.get('/screens', requireQueryPermission('screens.read', 'screens.update'), async function (req, res, next) { try { - const data = await buildDashboardState(pool); if (req.query.edit) { + const data = await buildDashboardState(pool); const screen = await common.fetchScreenById(pool, Number(req.query.edit)); if (!screen) { return res.status(404).send('Screen not found'); @@ -23,7 +26,12 @@ module.exports = function registerScreensRoutes(app, deps) { const editData = await common.fetchScreenEditData(pool); return res.send(pages.renderScreenEditPage(screen, editData, req.query.message ? String(req.query.message) : '', req.currentUser)); } - res.send(pages.renderScreensPage(data, req.query.message ? String(req.query.message) : '', req.currentUser)); + const page = Math.max(1, Math.floor(Number(req.query.page) || 1)); + const data = await common.fetchScreensPage(pool, page, LIST_PAGE_SIZE); + res.send(pages.renderScreensPage({ + screens: data.screens || [], + pagination: buildPagination(data.totalItems, data.currentPage, 'page', {}, LIST_PAGE_SIZE, 'screens', 'Screen pages') + }, req.query.message ? String(req.query.message) : '', req.currentUser)); } catch (error) { next(error); } diff --git a/src/web/routes/signage/slides/list.js b/src/web/routes/signage/slides/list.js index 4441fc2..302f2a0 100644 --- a/src/web/routes/signage/slides/list.js +++ b/src/web/routes/signage/slides/list.js @@ -6,7 +6,8 @@ module.exports = function renderSlidesPage(data, message, currentUser) { active: 'slides', message: message, currentUser: currentUser || null, - slides: data.slides || [] + slides: data.slides || [], + pagination: data.pagination || null }); }; diff --git a/src/web/routes/signage/slides/routes.js b/src/web/routes/signage/slides/routes.js index 65962a9..aa52f43 100644 --- a/src/web/routes/signage/slides/routes.js +++ b/src/web/routes/signage/slides/routes.js @@ -2,12 +2,19 @@ module.exports = function registerSlidesRoutes(app, deps) { const pages = deps.pages; const common = deps.common; const pool = deps.pool; + const { buildPagination } = require('../../../lib/pagination'); const requirePermission = deps.requirePermission; + const LIST_PAGE_SIZE = 10; + app.get('/slides', requirePermission('slides.read'), async function (req, res, next) { try { - const data = await common.fetchAdminData(pool); - res.send(pages.renderSlidesPage(data, req.query.message ? String(req.query.message) : '', req.currentUser)); + const page = Math.max(1, Math.floor(Number(req.query.page) || 1)); + const data = await common.fetchSlidesPage(pool, page, LIST_PAGE_SIZE); + res.send(pages.renderSlidesPage({ + slides: data.slides || [], + pagination: buildPagination(data.totalItems, data.currentPage, 'page', {}, LIST_PAGE_SIZE, 'slides', 'Slide pages') + }, req.query.message ? String(req.query.message) : '', req.currentUser)); } catch (error) { next(error); } diff --git a/src/web/routes/signage/templates/add.js b/src/web/routes/signage/templates/add.js index 7980c34..69d816e 100644 --- a/src/web/routes/signage/templates/add.js +++ b/src/web/routes/signage/templates/add.js @@ -50,6 +50,6 @@ module.exports = function renderTemplateFormPage(template, mode, message, canvas currentUser: currentUser || null, template: current, canvasSizes: canvasSizes || [], - scripts: ['js/templates/template-designer-utils.js', 'js/templates/template-designer.js'] + scripts: ['js/lib/modal.js', 'js/templates/template-designer-utils.js', 'js/templates/template-designer.js'] }); }; diff --git a/src/web/routes/signage/templates/list.js b/src/web/routes/signage/templates/list.js index 48da7c1..e311cd8 100644 --- a/src/web/routes/signage/templates/list.js +++ b/src/web/routes/signage/templates/list.js @@ -6,7 +6,9 @@ module.exports = function renderTemplatesPage(data, message, currentUser) { name: template.name, canvas_size_width: template.canvas_size_width, canvas_size_height: template.canvas_size_height, - regionCount: (data.templateRegions || []).filter((region) => region.template_id === template.id).length + regionCount: template.region_count !== undefined + ? Number(template.region_count) || 0 + : (data.templateRegions || []).filter((region) => region.template_id === template.id).length })); return renderView('templates/list', { @@ -14,6 +16,7 @@ module.exports = function renderTemplatesPage(data, message, currentUser) { active: 'templates', message: message, currentUser: currentUser || null, - templates: templates + templates: templates, + pagination: data.pagination || null }); }; diff --git a/src/web/routes/signage/templates/routes.js b/src/web/routes/signage/templates/routes.js index d604e4c..df74118 100644 --- a/src/web/routes/signage/templates/routes.js +++ b/src/web/routes/signage/templates/routes.js @@ -2,12 +2,19 @@ module.exports = function registerTemplatesRoutes(app, deps) { const pages = deps.pages; const common = deps.common; const pool = deps.pool; + const { buildPagination } = require('../../../lib/pagination'); const requirePermission = deps.requirePermission; + const LIST_PAGE_SIZE = 10; + app.get('/templates', requirePermission('templates.read'), async function (req, res, next) { try { - const data = await common.fetchTemplatesData(pool); - res.send(pages.renderTemplatesPage(data, req.query.message ? String(req.query.message) : '', req.currentUser)); + const page = Math.max(1, Math.floor(Number(req.query.page) || 1)); + const data = await common.fetchTemplatesPage(pool, page, LIST_PAGE_SIZE); + res.send(pages.renderTemplatesPage({ + templates: data.templates || [], + pagination: buildPagination(data.totalItems, data.currentPage, 'page', {}, LIST_PAGE_SIZE, 'templates', 'Template pages') + }, req.query.message ? String(req.query.message) : '', req.currentUser)); } catch (error) { next(error); } diff --git a/src/web/view.js b/src/web/view.js index 619c744..991d292 100644 --- a/src/web/view.js +++ b/src/web/view.js @@ -109,6 +109,9 @@ Handlebars.registerHelper('saveActionButtons', function (options) { ].join('')); }); +Handlebars.registerPartial('modal-shell', fs.readFileSync(path.join(VIEWS_ROOT, 'shared', 'modal-shell.hbs'), 'utf8')); +Handlebars.registerPartial('table-pagination', fs.readFileSync(path.join(VIEWS_ROOT, 'shared', 'table-pagination.hbs'), 'utf8')); + function resolveTemplatePath(relativePath) { const firstSegment = String(relativePath || '').split(/[\\/]/)[0]; if (SIGNAGE_VIEW_PREFIXES.has(firstSegment)) { diff --git a/src/web/views/data-sources/api-sources/list.hbs b/src/web/views/data-sources/api-sources/list.hbs index 06a77f0..c0f3870 100644 --- a/src/web/views/data-sources/api-sources/list.hbs +++ b/src/web/views/data-sources/api-sources/list.hbs @@ -5,17 +5,21 @@
    -
    +

    Saved sources

    - {{#if (hasPermission currentUser 'api-sources.create')}} -
    - Add API source +
    +
    + +
    - {{/if}} + {{#if (hasPermission currentUser 'api-sources.create')}} + Add API source + {{/if}} +
    - +
    @@ -29,7 +33,7 @@ {{#if apiSources.length}} {{#each apiSources}} - + @@ -69,9 +73,10 @@ {{/each}} {{else}} - + {{/if}}
    Name
    {{name}} {{api_url}} {{intervalLabel}}
    No API sources yet.
    No API sources yet.
    + {{> table-pagination pagination=pagination basePath="/data-sources/api-sources"}}
    diff --git a/src/web/views/data-sources/rss-feeds/list.hbs b/src/web/views/data-sources/rss-feeds/list.hbs index a99d66a..83970c2 100644 --- a/src/web/views/data-sources/rss-feeds/list.hbs +++ b/src/web/views/data-sources/rss-feeds/list.hbs @@ -5,17 +5,21 @@
    -
    +

    Saved feeds

    - {{#if (hasPermission currentUser 'rss-feeds.create')}} -
    - Add RSS feed +
    +
    + +
    - {{/if}} + {{#if (hasPermission currentUser 'rss-feeds.create')}} + Add RSS feed + {{/if}} +
    - +
    @@ -28,7 +32,7 @@ {{#if rssFeeds.length}} {{#each rssFeeds}} - + @@ -52,9 +56,10 @@ {{/each}} {{else}} - + {{/if}}
    Name
    {{name}} {{feed_url}} {{intervalLabel}}
    No RSS feeds yet.
    No RSS feeds yet.
    + {{> table-pagination pagination=pagination basePath="/data-sources/rss-feeds"}}
    diff --git a/src/web/views/settings/background-tasks/index.hbs b/src/web/views/settings/background-tasks/index.hbs index d5ade90..690f3d0 100644 --- a/src/web/views/settings/background-tasks/index.hbs +++ b/src/web/views/settings/background-tasks/index.hbs @@ -3,49 +3,44 @@ -
    -
    -
    -
    -
    Queued
    -
    {{summary.counts.queued}}
    -
    -
    -
    -
    -
    -
    -
    Running
    -
    {{summary.counts.running}}
    -
    -
    -
    -
    -
    -
    -
    Failed
    -
    {{summary.counts.failed}}
    -
    -
    -
    -
    -
    -
    -
    Finished
    -
    {{summary.counts.completed}}
    -
    +
    + {{#if hasActiveFilters}} +
    +
    + Showing results for {{#if activeStatusLabel}}{{activeStatusLabel}}{{else}}all tasks{{/if}}{{#if activeSourceLabel}} from {{activeSourceLabel}}{{/if}}.
    + Clear filters
    + {{/if}}
    -
    -
    +
    + {{#each statusCards}} + + {{/each}} +
    + +
    +

    Task queue

    -
    +
    + {{#if canManage}} {{#if canClearFinished}}
    @@ -53,11 +48,10 @@
    {{/if}} {{/if}} - Active workers: {{summary.activeCount}}
    - +
    @@ -73,7 +67,7 @@ {{#if tasks.length}} {{#each tasks}} - + {{/each}} {{else}} - + {{/if}} @@ -178,12 +172,18 @@ {{/if}} -
    -
    +
    +

    Scheduled refreshes

    +
    + +
    -
    Task
    {{title}}
    {{#if key}} @@ -147,7 +141,7 @@
    No background tasks are queued right now.
    +
    @@ -197,7 +197,7 @@ {{#if recurringTasks.length}} {{#each recurringTasks}} - + {{/each}} {{else}} - + {{/if}} diff --git a/src/web/views/settings/rbac/list.hbs b/src/web/views/settings/rbac/list.hbs index 798fc97..36492c9 100644 --- a/src/web/views/settings/rbac/list.hbs +++ b/src/web/views/settings/rbac/list.hbs @@ -7,17 +7,21 @@
    -
    +

    Existing roles

    - {{#if (hasPermission currentUser 'rbac.create')}} -
    - Add role +
    +
    + +
    - {{/if}} + {{#if (hasPermission currentUser 'rbac.create')}} + Add role + {{/if}} +
    -
    Task
    {{title}}
    {{key}}
    @@ -232,7 +232,7 @@
    No scheduled refreshes are registered yet.
    +
    @@ -30,7 +34,7 @@ {{#if roles.length}} {{#each roles}} - + {{/each}} {{else}} - + {{/if}}
    Role
    @@ -68,11 +72,12 @@
    No roles found.
    No roles found.
    + {{> table-pagination pagination=pagination basePath="/rbac"}}
    diff --git a/src/web/views/settings/users/list.hbs b/src/web/views/settings/users/list.hbs index 0d94b05..0e7f3c5 100644 --- a/src/web/views/settings/users/list.hbs +++ b/src/web/views/settings/users/list.hbs @@ -5,17 +5,21 @@
    -
    +

    Existing users

    - {{#if (hasPermission currentUser 'users.create')}} -
    - Add user +
    +
    + +
    - {{/if}} + {{#if (hasPermission currentUser 'users.create')}} + Add user + {{/if}} +
    - +
    @@ -27,7 +31,7 @@ {{#if users.length}} {{#each users}} - + {{/each}} {{else}} - + {{/if}}
    User
    @@ -85,9 +89,10 @@
    No users found.
    No users found.
    + {{> table-pagination pagination=pagination basePath="/users"}}
    diff --git a/src/web/views/shared/modal-shell.hbs b/src/web/views/shared/modal-shell.hbs new file mode 100644 index 0000000..bb92bb8 --- /dev/null +++ b/src/web/views/shared/modal-shell.hbs @@ -0,0 +1,7 @@ + \ No newline at end of file diff --git a/src/web/views/shared/table-pagination.hbs b/src/web/views/shared/table-pagination.hbs new file mode 100644 index 0000000..f777767 --- /dev/null +++ b/src/web/views/shared/table-pagination.hbs @@ -0,0 +1,22 @@ +{{#if pagination.hasMultiplePages}} + +{{/if}} diff --git a/src/web/views/signage/canvas-sizes/list.hbs b/src/web/views/signage/canvas-sizes/list.hbs index fa87ca8..2a78ab5 100644 --- a/src/web/views/signage/canvas-sizes/list.hbs +++ b/src/web/views/signage/canvas-sizes/list.hbs @@ -5,17 +5,21 @@
    -
    +

    Saved sizes

    - {{#if (hasPermission currentUser 'canvas-sizes.create')}} -
    - Add canvas size +
    +
    + +
    - {{/if}} + {{#if (hasPermission currentUser 'canvas-sizes.create')}} + Add canvas size + {{/if}} +
    - +
    @@ -27,7 +31,7 @@ {{#if canvasSizes.length}} {{#each canvasSizes}} - + @@ -50,9 +54,10 @@ {{/each}} {{else}} - + {{/if}}
    Name
    {{name}} {{width}}x{{height}} {{templateCount}}
    No canvas sizes yet.
    No canvas sizes yet.
    + {{> table-pagination pagination=pagination basePath="/canvas-sizes"}}
    diff --git a/src/web/views/signage/clients/list.hbs b/src/web/views/signage/clients/list.hbs index f40bfc3..b03840b 100644 --- a/src/web/views/signage/clients/list.hbs +++ b/src/web/views/signage/clients/list.hbs @@ -5,12 +5,18 @@
    -
    +

    Active connections

    +
    +
    + + +
    +
    - +
    @@ -25,7 +31,7 @@ {{#if clients.length}} {{#each clients}} - + @@ -95,9 +101,10 @@ {{/each}} {{else}} - + {{/if}}
    Client
    {{#if client_name}}{{client_name}}{{else}}Unknown{{/if}}
    No connected clients yet.
    No connected clients yet.
    + {{> table-pagination pagination=pagination basePath="/clients"}}
    diff --git a/src/web/views/signage/playlists/edit.hbs b/src/web/views/signage/playlists/edit.hbs index f566bd6..b340e92 100644 --- a/src/web/views/signage/playlists/edit.hbs +++ b/src/web/views/signage/playlists/edit.hbs @@ -12,51 +12,36 @@

    Playlist details

    -
    -
    + +
    +
    +
    + + {{#if hasSlides}} + {{#if hasSelectableSlides}} + + {{else}} +
    + {{#if playlistCanvasMismatch}} + Remove mismatched slides before adding more. + {{else if playlistCanvasSignature}} + No slides match this playlist canvas size. + {{else}} + No slides are currently available for this playlist. + {{/if}} +
    + {{/if}} + {{else}} +
    Create slides first.
    + {{/if}} +
    +
    - -
    -
    -

    Add slide to playlist

    - {{#if hasSlides}} - {{#if hasSelectableSlides}} -
    -
    -
    - -
    -
    - -
    -
    -
    - - {{else}} -
    - {{#if playlistCanvasMismatch}} - This playlist contains slides with different canvas sizes. Remove the mismatched items before adding more. - {{else if playlistCanvasSignature}} - No slides match this playlist canvas size. - {{else}} - No slides are currently available for this playlist. - {{/if}} -
    - {{/if}} - {{else}} -
    Create slides first.
    - {{/if}} -
    -
    @@ -110,7 +99,18 @@
    - {{title}} +
    + +
    + {{title}} +
    +
    @@ -123,7 +123,7 @@ - +
    @@ -143,5 +143,34 @@
    + + +{{#> modal-shell modalId="playlist-add-slide-modal" modalLabelId="playlist-add-slide-modal-label" modalDialogClass="modal-dialog-centered modal-dialog-scrollable modal-xl" modalBackdropStatic=true modalKeyboardDisabled=true}} + + + +{{/modal-shell}} +
    diff --git a/src/web/views/signage/playlists/list.hbs b/src/web/views/signage/playlists/list.hbs index dabc377..c7c3a5a 100644 --- a/src/web/views/signage/playlists/list.hbs +++ b/src/web/views/signage/playlists/list.hbs @@ -5,17 +5,21 @@
    -
    +

    Existing playlists

    - {{#if (hasPermission currentUser 'playlists.create')}} -
    - Create playlist +
    +
    + +
    - {{/if}} + {{#if (hasPermission currentUser 'playlists.create')}} + Create playlist + {{/if}} +
    - +
    @@ -26,7 +30,7 @@ {{#if playlists.length}} {{#each playlists}} - + {{/each}} {{else}} - + {{/if}}
    Name
    {{name}} {{slideCount}} slide{{#unless slideCountIsOne}}s{{/unless}} @@ -48,10 +52,11 @@
    No playlists yet.
    No playlists yet.
    + {{> table-pagination pagination=pagination basePath="/playlists"}}
    diff --git a/src/web/views/signage/screens/list.hbs b/src/web/views/signage/screens/list.hbs index 2cc4f11..fc68184 100644 --- a/src/web/views/signage/screens/list.hbs +++ b/src/web/views/signage/screens/list.hbs @@ -5,17 +5,21 @@
    -
    +

    Existing screens

    - {{#if (hasPermission currentUser 'screens.create')}} -
    - Add screen +
    +
    + +
    - {{/if}} + {{#if (hasPermission currentUser 'screens.create')}} + Add screen + {{/if}} +
    - +
    @@ -28,7 +32,7 @@ {{#if screens.length}} {{#each screens}} - + @@ -58,10 +62,11 @@ {{/each}} {{else}} - + {{/if}}
    Name
    {{name}} {{playerUrl slug}} {{playlist_name}}
    No screens yet.
    No screens yet.
    + {{> table-pagination pagination=pagination basePath="/screens"}}
    diff --git a/src/web/views/signage/slides/form.hbs b/src/web/views/signage/slides/form.hbs index d0e79bd..4a3610f 100644 --- a/src/web/views/signage/slides/form.hbs +++ b/src/web/views/signage/slides/form.hbs @@ -72,5 +72,5 @@ {{/if}} - + diff --git a/src/web/views/signage/slides/list.hbs b/src/web/views/signage/slides/list.hbs index 3f774be..d3b42fd 100644 --- a/src/web/views/signage/slides/list.hbs +++ b/src/web/views/signage/slides/list.hbs @@ -5,19 +5,24 @@
    -
    +

    Existing slides

    - {{#if (hasPermission currentUser 'slides.create')}} -
    - Create slide +
    +
    + +
    - {{/if}} + {{#if (hasPermission currentUser 'slides.create')}} + Create slide + {{/if}} +
    - +
    + @@ -26,7 +31,16 @@ {{#if slides.length}} {{#each slides}} - + + {{/each}} {{else}} - + {{/if}}
    Thumbnail Title Template Actions
    + + {{title}} {{template_name}} @@ -48,10 +62,17 @@
    No slides yet.
    No slides yet.
    + {{> table-pagination pagination=pagination basePath="/slides"}}
    + + diff --git a/src/web/views/signage/templates/add.hbs b/src/web/views/signage/templates/add.hbs index a9c7070..e6b5bfc 100644 --- a/src/web/views/signage/templates/add.hbs +++ b/src/web/views/signage/templates/add.hbs @@ -106,28 +106,24 @@
    -