Initial Upload

This commit is contained in:
Matt Batchelder
2025-12-02 10:32:59 -05:00
commit 05ce0da296
2240 changed files with 467811 additions and 0 deletions

View File

@@ -0,0 +1,326 @@
<?php
/*
* Copyright (C) 2025 Xibo Signage Ltd
*
* Xibo - Digital Signage - https://xibosignage.com
*
* This file is part of Xibo.
*
* Xibo is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Xibo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Xibo. If not, see <http://www.gnu.org/licenses/>.
*/
namespace Xibo\XTR;
use Carbon\Carbon;
use GuzzleHttp\Client;
use Xibo\Helper\Environment;
use Xibo\Storage\StorageServiceInterface;
/**
* Collects anonymous usage stats
*/
class AnonymousUsageTask implements TaskInterface
{
use TaskTrait;
private readonly string $url;
private StorageServiceInterface $db;
public function __construct()
{
$this->url = 'https://api.xibosignage.com/api/stats/usage';
}
/** @inheritdoc */
public function setFactories($container)
{
$this->db = $container->get('store');
return $this;
}
/** @inheritdoc */
public function run()
{
$isCollectUsage = $this->getConfig()->getSetting('PHONE_HOME') == 1;
if (!$isCollectUsage) {
$this->appendRunMessage('Anonymous usage disabled');
return;
}
// Make sure we have a key
$key = $this->getConfig()->getSetting('PHONE_HOME_KEY');
if (empty($key)) {
$key = bin2hex(random_bytes(16));
// Save it.
$this->getConfig()->changeSetting('PHONE_HOME_KEY', $key);
}
// Set PHONE_HOME_TIME to NOW.
$this->getConfig()->changeSetting('PHONE_HOME_DATE', Carbon::now()->format('U'));
// Collect the data and report it.
$data = [
'id' => $key,
'version' => Environment::$WEBSITE_VERSION_NAME,
'accountId' => defined('ACCOUNT_ID') ? constant('ACCOUNT_ID') : null,
];
// What type of install are we?
$data['installType'] = 'custom';
if (isset($_SERVER['INSTALL_TYPE'])) {
$data['installType'] = $_SERVER['INSTALL_TYPE'];
} else if ($this->getConfig()->getSetting('cloud_demo') !== null) {
$data['installType'] = 'cloud';
}
// General settings
$data['calendarType'] = strtolower($this->getConfig()->getSetting('CALENDAR_TYPE'));
$data['defaultLanguage'] = $this->getConfig()->getSetting('DEFAULT_LANGUAGE');
$data['isDetectLanguage'] = $this->getConfig()->getSetting('DETECT_LANGUAGE') == 1 ? 1 : 0;
// Connectors
$data['isSspConnector'] = $this->runQuery('SELECT `isEnabled` FROM `connectors` WHERE `className` = :name', [
'name' => '\\Xibo\\Connector\\XiboSspConnector'
]) ?? 0;
$data['isDashboardConnector'] =
$this->runQuery('SELECT `isEnabled` FROM `connectors` WHERE `className` = :name', [
'name' => '\\Xibo\\Connector\\XiboDashboardConnector'
]) ?? 0;
// Most recent date any user log in happened
$data['dateSinceLastUserLogin'] = $this->getDateSinceLastUserLogin();
// Displays
$data = array_merge($data, $this->displayStats());
$data['countOfDisplays'] = $this->runQuery(
'SELECT COUNT(*) AS countOf FROM `display` WHERE `lastaccessed` > :recently',
[
'recently' => Carbon::now()->subDays(7)->format('U'),
]
);
$data['countOfDisplaysTotal'] = $this->runQuery('SELECT COUNT(*) AS countOf FROM `display`');
$data['countOfDisplaysUnAuthorised'] =
$this->runQuery('SELECT COUNT(*) AS countOf FROM `display` WHERE licensed = 0');
$data['countOfDisplayGroups'] =
$this->runQuery('SELECT COUNT(*) AS countOf FROM `displaygroup` WHERE isDisplaySpecific = 0');
// Users
$data['countOfUsers'] = $this->runQuery('SELECT COUNT(*) AS countOf FROM `user`');
$data['countOfUsersActiveInLastTwentyFour'] =
$this->runQuery('SELECT COUNT(*) AS countOf FROM `user` WHERE `lastAccessed` > :recently', [
'recently' => Carbon::now()->subHours(24)->format('Y-m-d H:i:s'),
]);
$data['countOfUserGroups'] =
$this->runQuery('SELECT COUNT(*) AS countOf FROM `group` WHERE isUserSpecific = 0');
$data['countOfUsersWithStatusDashboard'] =
$this->runQuery('SELECT COUNT(*) AS countOf FROM `user` WHERE homePageId = \'statusdashboard.view\'');
$data['countOfUsersWithIconDashboard'] =
$this->runQuery('SELECT COUNT(*) AS countOf FROM `user` WHERE homePageId = \'icondashboard.view\'');
$data['countOfUsersWithMediaDashboard'] =
$this->runQuery('SELECT COUNT(*) AS countOf FROM `user` WHERE homePageId = \'mediamanager.view\'');
$data['countOfUsersWithPlaylistDashboard'] =
$this->runQuery('SELECT COUNT(*) AS countOf FROM `user` WHERE homePageId = \'playlistdashboard.view\'');
// Other objects
$data['countOfFolders'] = $this->runQuery('SELECT COUNT(*) AS countOf FROM `folder`');
$data['countOfLayouts'] = $this->runQuery('
SELECT COUNT(*) AS countOf
FROM `campaign`
WHERE `isLayoutSpecific` = 1
AND `campaignId` NOT IN (
SELECT `lkcampaignlayout`.`campaignId`
FROM `lkcampaignlayout`
INNER JOIN `lktaglayout`
ON `lktaglayout`.`layoutId` = `lkcampaignlayout`.`layoutId`
INNER JOIN `tag`
ON `lktaglayout`.tagId = `tag`.tagId
WHERE `tag`.`tag` = \'template\'
)
');
$data['countOfLayoutsWithPlaylists'] = $this->runQuery('
SELECT COUNT(DISTINCT `region`.`layoutId`) AS countOf
FROM `widget`
INNER JOIN `playlist` ON `playlist`.`playlistId` = `widget`.`playlistId`
INNER JOIN `region` ON `playlist`.`regionId` = `region`.`regionId`
WHERE `widget`.`type` = \'subplaylist\'
');
$data['countOfAdCampaigns'] =
$this->runQuery('
SELECT COUNT(*) AS countOf
FROM `campaign`
WHERE `type` = \'ad\'
AND `isLayoutSpecific` = 0
');
$data['countOfListCampaigns'] =
$this->runQuery('
SELECT COUNT(*) AS countOf
FROM `campaign`
WHERE `type` = \'list\'
AND `isLayoutSpecific` = 0
');
$data['countOfMedia'] =
$this->runQuery('SELECT COUNT(*) AS countOf FROM `media`');
$data['countOfPlaylists'] =
$this->runQuery('SELECT COUNT(*) AS countOf FROM `playlist` WHERE `regionId` IS NULL');
$data['countOfDataSets'] =
$this->runQuery('SELECT COUNT(*) AS countOf FROM `dataset`');
$data['countOfRemoteDataSets'] =
$this->runQuery('SELECT COUNT(*) AS countOf FROM `dataset` WHERE `isRemote` = 1');
$data['countOfDataConnectorDataSets'] =
$this->runQuery('SELECT COUNT(*) AS countOf FROM `dataset` WHERE `isRealTime` = 1');
$data['countOfApplications'] =
$this->runQuery('SELECT COUNT(*) AS countOf FROM `oauth_clients`');
$data['countOfApplicationsUsingClientCredentials'] =
$this->runQuery('SELECT COUNT(*) AS countOf FROM `oauth_clients` WHERE `clientCredentials` = 1');
$data['countOfApplicationsUsingUserCode'] =
$this->runQuery('SELECT COUNT(*) AS countOf FROM `oauth_clients` WHERE `authCode` = 1');
$data['countOfScheduledReports'] =
$this->runQuery('SELECT COUNT(*) AS countOf FROM `reportschedule`');
$data['countOfSavedReports'] =
$this->runQuery('SELECT COUNT(*) AS countOf FROM `saved_report`');
// Widgets
$data['countOfImageWidgets'] =
$this->runQuery('SELECT COUNT(*) AS countOf FROM `widget` WHERE `type` = \'image\'');
$data['countOfVideoWidgets'] =
$this->runQuery('SELECT COUNT(*) AS countOf FROM `widget` WHERE `type` = \'video\'');
$data['countOfPdfWidgets'] =
$this->runQuery('SELECT COUNT(*) AS countOf FROM `widget` WHERE `type` = \'pdf\'');
$data['countOfEmbeddedWidgets'] =
$this->runQuery('SELECT COUNT(*) AS countOf FROM `widget` WHERE `type` = \'embedded\'');
$data['countOfCanvasWidgets'] =
$this->runQuery('SELECT COUNT(*) AS countOf FROM `widget` WHERE `type` = \'global\'');
// Schedules
$data['countOfSchedulesThisMonth'] = $this->runQuery('
SELECT COUNT(*) AS countOf
FROM `schedule`
WHERE `fromDt` <= :toDt AND `toDt` > :fromDt
', [
'fromDt' => Carbon::now()->startOfMonth()->unix(),
'toDt' => Carbon::now()->endOfMonth()->unix(),
]);
$data['countOfSyncSchedulesThisMonth'] = $this->runQuery('
SELECT COUNT(*) AS countOf
FROM `schedule`
WHERE `fromDt` <= :toDt AND `toDt` > :fromDt
AND `eventTypeId` = 9
', [
'fromDt' => Carbon::now()->startOfMonth()->unix(),
'toDt' => Carbon::now()->endOfMonth()->unix(),
]);
$data['countOfAlwaysSchedulesThisMonth'] = $this->runQuery('
SELECT COUNT(*) AS countOf
FROM `schedule`
INNER JOIN `daypart` ON `daypart`.dayPartId = `schedule`.`dayPartId`
WHERE `daypart`.`isAlways` = 1
');
$data['countOfRecurringSchedules'] =
$this->runQuery('SELECT COUNT(*) AS countOf FROM `schedule` WHERE IFNULL(recurrence_type, \'\') <> \'\'');
$data['countOfSchedulesWithCriteria'] =
$this->runQuery('SELECT COUNT(DISTINCT `eventId`) AS countOf FROM `schedule_criteria`');
$data['countOfDayParts'] =
$this->runQuery('SELECT COUNT(*) AS countOf FROM `daypart`');
// Finished collecting, send.
$this->getLogger()->debug('run: sending stats ' . json_encode($data));
try {
(new Client())->post(
$this->url,
$this->getConfig()->getGuzzleProxy([
'json' => $data,
])
);
} catch (\Exception $e) {
$this->appendRunMessage('Unable to send stats.');
$this->log->error('run: stats send failed, e=' . $e->getMessage());
}
$this->appendRunMessage('Completed');
}
private function displayStats(): array
{
// Retrieve number of displays
$stats = $this->db->select('
SELECT client_type, COUNT(*) AS cnt
FROM `display`
WHERE licensed = 1
GROUP BY client_type
', []);
$counts = [
'total' => 0,
'android' => 0,
'windows' => 0,
'linux' => 0,
'lg' => 0,
'sssp' => 0,
'chromeOS' => 0,
];
foreach ($stats as $stat) {
$counts['total'] += intval($stat['cnt']);
$counts[$stat['client_type']] += intval($stat['cnt']);
}
return [
'countOfDisplaysAuthorised' => $counts['total'],
'countOfAndroid' => $counts['android'],
'countOfLinux' => $counts['linux'],
'countOfWebos' => $counts['lg'],
'countOfWindows' => $counts['windows'],
'countOfTizen' => $counts['sssp'],
'countOfChromeOS' => $counts['chromeOS'],
];
}
/**
* Run a query and return the value of a property
* @param string $sql
* @param string $property
* @param array $params
* @return string|null
*/
private function runQuery(string $sql, array $params = [], string $property = 'countOf'): ?string
{
try {
$record = $this->db->select($sql, $params);
return $record[0][$property] ?? null;
} catch (\PDOException $PDOException) {
$this->getLogger()->debug('runQuery: error returning specific stat, e: ' . $PDOException->getMessage());
return null;
}
}
/**
* Get the most recent user login timestamp converted to UTC
* @return string|null
*/
private function getDateSinceLastUserLogin(): string|null
{
$cmsTimezone = $this->getConfig()->getSetting('defaultTimezone');
$latestUserLoginDate = $this->runQuery(
'SELECT MAX(`lastAccessed`) AS lastAccessed FROM `user`',
[],
'lastAccessed'
);
return $latestUserLoginDate
? Carbon::parse($latestUserLoginDate, $cmsTimezone)->setTimezone('UTC')->timestamp
: null;
}
}

View File

@@ -0,0 +1,274 @@
<?php
/*
* Copyright (C) 2025 Xibo Signage Ltd
*
* Xibo - Digital Signage - https://xibosignage.com
*
* This file is part of Xibo.
*
* Xibo is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Xibo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Xibo. If not, see <http://www.gnu.org/licenses/>.
*/
namespace Xibo\XTR;
use Carbon\Carbon;
use Xibo\Entity\User;
use Xibo\Factory\MediaFactory;
use Xibo\Factory\UserFactory;
use Xibo\Helper\DateFormatHelper;
use Xibo\Support\Exception\GeneralException;
use Xibo\Support\Exception\InvalidArgumentException;
use Xibo\Support\Exception\NotFoundException;
use Xibo\Support\Exception\TaskRunException;
/**
* Class StatsArchiveTask
* @package Xibo\XTR
*/
class AuditLogArchiveTask implements TaskInterface
{
use TaskTrait;
/** @var User */
private $archiveOwner;
/** @var UserFactory */
private $userFactory;
/** @var MediaFactory */
private $mediaFactory;
/** @var \Xibo\Helper\SanitizerService */
private $sanitizerService;
/** @inheritdoc */
public function setFactories($container)
{
$this->mediaFactory = $container->get('mediaFactory');
$this->userFactory = $container->get('userFactory');
$this->sanitizerService = $container->get('sanitizerService');
return $this;
}
/**
* @inheritDoc
* @throws \Xibo\Support\Exception\GeneralException
*/
public function run()
{
$maxPeriods = intval($this->getOption('maxPeriods', 1));
$maxAge = Carbon::now()
->subMonths(intval($this->getOption('maxAgeMonths', 1)))
->startOfDay();
$this->setArchiveOwner();
// Delete or Archive?
if ($this->getOption('deleteInstead', 1) == 1) {
$this->appendRunMessage('# ' . __('AuditLog Delete'));
$this->appendRunMessage('maxAge: ' . $maxAge->format(DateFormatHelper::getSystemFormat()));
// Delete all audit log messages older than the configured number of months
// use a prepared statement for this, and delete the records in a loop
$deleteStatement = $this->store->getConnection()->prepare('
DELETE FROM `auditlog`
WHERE `logDate` < :logDate
LIMIT :limit
');
// Convert to a simple type so that we can pass by reference to bindParam.
// Load in other options for deleting
$maxage = $maxAge->format('U');
$limit = intval($this->getOption('deleteLimit', 1000));
$maxAttempts = intval($this->getOption('maxDeleteAttempts', -1));
$deleteSleep = intval($this->getOption('deleteSleep', 5));
// Bind params
$deleteStatement->bindParam(':logDate', $maxage, \PDO::PARAM_STR);
$deleteStatement->bindParam(':limit', $limit, \PDO::PARAM_INT);
try {
$i = 0;
$rows = 1;
while ($rows > 0) {
$i++;
// Run delete statement
$deleteStatement->execute();
// Find out how many rows we've deleted
$rows = $deleteStatement->rowCount();
// We shouldn't be in a transaction, but commit anyway just in case
$this->store->commitIfNecessary();
// Give SQL time to recover
if ($rows > 0) {
$this->log->debug('Archive delete effected ' . $rows . ' rows, sleeping.');
sleep($deleteSleep);
}
// Break if we've exceeded the maximum attempts, assuming that has been provided
if ($maxAttempts > -1 && $i >= $maxAttempts) {
break;
}
}
} catch (\PDOException $e) {
$this->log->error($e->getMessage());
throw new GeneralException('Archive rows cannot be deleted.');
}
} else {
$this->appendRunMessage('# ' . __('AuditLog Archive'));
$this->appendRunMessage('maxAge: ' . $maxAge->format(DateFormatHelper::getSystemFormat()));
// Get the earliest
$earliestDate = $this->store->select('
SELECT MIN(logDate) AS minDate FROM `auditlog` WHERE logDate < :logDate
', [
'logDate' => $maxAge->format('U')
]);
if (count($earliestDate) <= 0 || $earliestDate[0]['minDate'] === null) {
$this->appendRunMessage(__('Nothing to archive'));
return;
}
// Take the earliest date and roll forward until the max age
$earliestDate = Carbon::createFromTimestamp($earliestDate[0]['minDate'])->startOfDay();
$i = 0;
// We only archive up until the max age, leaving newer records alone.
while ($earliestDate < $maxAge && $i <= $maxPeriods) {
$i++;
$this->log->debug('Running archive number ' . $i);
// Push forward
$fromDt = $earliestDate->copy();
$earliestDate->addMonth();
$this->exportAuditLogToLibrary($fromDt, $earliestDate);
$this->store->commitIfNecessary();
}
}
$this->runMessage .= __('Done') . PHP_EOL . PHP_EOL;
}
/**
* Export stats to the library
* @param Carbon $fromDt
* @param Carbon $toDt
* @throws \Xibo\Support\Exception\GeneralException
*/
private function exportAuditLogToLibrary($fromDt, $toDt)
{
$this->runMessage .= ' - ' . $fromDt . ' / ' . $toDt . PHP_EOL;
$sql = '
SELECT *
FROM `auditlog`
WHERE logDate >= :fromDt
AND logDate < :toDt
';
$params = [
'fromDt' => $fromDt->format('U'),
'toDt' => $toDt->format('U')
];
$sql .= ' ORDER BY 1 ';
$records = $this->store->select($sql, $params);
if (count($records) <= 0) {
$this->runMessage .= __('No audit log found for these dates') . PHP_EOL;
return;
}
// Create a temporary file for this
$fileName = $this->config->getSetting('LIBRARY_LOCATION') . 'temp/auditlog.csv';
$out = fopen($fileName, 'w');
fputcsv($out, ['logId', 'logDate', 'userId', 'message', 'entity', 'entityId', 'objectAfter']);
// Do some post-processing
foreach ($records as $row) {
$sanitizedRow = $this->getSanitizer($row);
// Read the columns
fputcsv($out, [
$sanitizedRow->getInt('logId'),
$sanitizedRow->getInt('logDate'),
$sanitizedRow->getInt('userId'),
$sanitizedRow->getString('message'),
$sanitizedRow->getString('entity'),
$sanitizedRow->getInt('entityId'),
$sanitizedRow->getString('objectAfter')
]);
}
fclose($out);
$zip = new \ZipArchive();
$result = $zip->open($fileName . '.zip', \ZipArchive::CREATE | \ZipArchive::OVERWRITE);
if ($result !== true) {
throw new InvalidArgumentException(__('Can\'t create ZIP. Error Code: %s', $result));
}
$zip->addFile($fileName, 'auditlog.csv');
$zip->close();
// Remove the CSV file
unlink($fileName);
// Upload to the library
$media = $this->mediaFactory->create(
__('AuditLog Export %s to %s', $fromDt->format('Y-m-d'), $toDt->format('Y-m-d')),
'auditlog.csv.zip',
'genericfile',
$this->archiveOwner->getId()
);
$media->save();
// Delete the logs
$this->store->update('DELETE FROM `auditlog` WHERE logDate >= :fromDt AND logDate < :toDt', $params);
}
/**
* Set the archive owner
* @throws TaskRunException
*/
private function setArchiveOwner()
{
$archiveOwner = $this->getOption('archiveOwner', null);
if ($archiveOwner == null) {
$admins = $this->userFactory->getSuperAdmins();
if (count($admins) <= 0)
throw new TaskRunException(__('No super admins to use as the archive owner, please set one in the configuration.'));
$this->archiveOwner = $admins[0];
} else {
try {
$this->archiveOwner = $this->userFactory->getByName($archiveOwner);
} catch (NotFoundException $e) {
throw new TaskRunException(__('Archive Owner not found'));
}
}
}
}

View File

@@ -0,0 +1,330 @@
<?php
/*
* Copyright (C) 2023 Xibo Signage Ltd
*
* Xibo - Digital Signage - https://xibosignage.com
*
* This file is part of Xibo.
*
* Xibo is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Xibo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Xibo. If not, see <http://www.gnu.org/licenses/>.
*/
namespace Xibo\XTR;
use Carbon\Carbon;
use GeoJson\Feature\FeatureCollection;
use GeoJson\GeoJson;
use Xibo\Entity\DayPart;
use Xibo\Entity\Schedule;
/**
* Campaign Scheduler task
* This should be run once per hour to create interrupt schedules for applicable advertising campaigns.
* The schedules will be created for the following hour.
*/
class CampaignSchedulerTask implements TaskInterface
{
use TaskTrait;
/** @var \Xibo\Factory\CampaignFactory */
private $campaignFactory;
/** @var \Xibo\Factory\ScheduleFactory */
private $scheduleFactory;
/** @var \Xibo\Factory\DayPartFactory */
private $dayPartFactory;
/** @var \Xibo\Factory\DisplayGroupFactory */
private $displayGroupFactory;
/** @var \Xibo\Factory\DisplayFactory */
private $displayFactory;
/** @var \Xibo\Service\DisplayNotifyServiceInterface */
private $displayNotifyService;
/** @var \Xibo\Entity\DayPart */
private $customDayPart = null;
/** @inheritDoc */
public function setFactories($container)
{
$this->campaignFactory = $container->get('campaignFactory');
$this->scheduleFactory = $container->get('scheduleFactory');
$this->dayPartFactory = $container->get('dayPartFactory');
$this->displayGroupFactory = $container->get('displayGroupFactory');
$this->displayFactory = $container->get('displayFactory');
$this->displayNotifyService = $container->get('displayNotifyService');
return $this;
}
/** @inheritDoc */
public function run()
{
$nextHour = Carbon::now()->startOfHour()->addHour();
$nextHourEnd = $nextHour->copy()->addHour();
$activeCampaigns = $this->campaignFactory->query(null, [
'disableUserCheck' => 1,
'type' => 'ad',
'startDt' => $nextHour->unix(),
'endDt' => $nextHour->unix(),
]);
// We will need to notify some displays at the end.
$notifyDisplayGroupIds = [];
$campaignsProcessed = 0;
$campaignsScheduled = 0;
// See what we can schedule for each one.
foreach ($activeCampaigns as $campaign) {
$campaignsProcessed++;
try {
$this->log->debug('campaignSchedulerTask: active campaign found, id: ' . $campaign->campaignId);
// What schedules should I create?
$activeLayouts = [];
foreach ($campaign->loadLayouts() as $layout) {
$this->log->debug('campaignSchedulerTask: layout assignment: ' . $layout->layoutId);
// Is the layout value
if ($layout->duration <= 0) {
$this->log->error('campaignSchedulerTask: layout without duration');
continue;
}
// Are we on an active day of the week?
if (!in_array($nextHour->dayOfWeekIso, explode(',', $layout->daysOfWeek))) {
$this->log->debug('campaignSchedulerTask: day of week not active');
continue;
}
// Is this on an active day part?
if ($layout->dayPartId != 0) {
$this->log->debug('campaignSchedulerTask: dayPartId set, testing');
// Check the day part
try {
$dayPart = $this->dayPartFactory->getById($layout->dayPartId);
$dayPart->adjustForDate($nextHour);
} catch (\Exception $exception) {
$this->log->debug('campaignSchedulerTask: invalid dayPart, e = '
. $exception->getMessage());
continue;
}
// Is this day part active
if (!$nextHour->betweenIncluded($dayPart->adjustedStart, $dayPart->adjustedEnd)) {
$this->log->debug('campaignSchedulerTask: dayPart not active');
continue;
}
}
$this->log->debug('campaignSchedulerTask: layout is valid and needs a schedule');
$activeLayouts[] = $layout;
}
$countActiveLayouts = count($activeLayouts);
$this->log->debug('campaignSchedulerTask: there are ' . $countActiveLayouts . ' active layouts');
if ($countActiveLayouts <= 0) {
$this->log->debug('campaignSchedulerTask: no active layouts for campaign');
continue;
}
// The campaign is active
// Display groups
$displayGroups = [];
$allDisplays = [];
$countDisplays = 0;
$costPerPlay = 0;
$impressionsPerPlay = 0;
// First pass uses only logged in displays from the display group
foreach ($campaign->loadDisplayGroupIds() as $displayGroupId) {
$displayGroups[] = $this->displayGroupFactory->getById($displayGroupId);
// Record ids to notify
if (!in_array($displayGroupId, $notifyDisplayGroupIds)) {
$notifyDisplayGroupIds[] = $displayGroupId;
}
foreach ($this->displayFactory->getByDisplayGroupId($displayGroupId) as $display) {
// Keep track of these in case we resolve 0 logged in displays
$allDisplays[] = $display;
if ($display->licensed === 1 && $display->loggedIn === 1) {
$countDisplays++;
$costPerPlay += $display->costPerPlay;
$impressionsPerPlay += $display->impressionsPerPlay;
}
}
}
$this->log->debug('campaignSchedulerTask: campaign has ' . $countDisplays
. ' logged in and authorised displays');
// If there are 0 displays, then process again ignoring the logged in status.
if ($countDisplays <= 0) {
$this->log->debug('campaignSchedulerTask: processing displays again ignoring logged in status');
foreach ($allDisplays as $display) {
if ($display->licensed === 1) {
$countDisplays++;
$costPerPlay += $display->costPerPlay;
$impressionsPerPlay += $display->impressionsPerPlay;
}
}
}
$this->log->debug('campaignSchedulerTask: campaign has ' . $countDisplays
. ' authorised displays');
if ($countDisplays <= 0) {
$this->log->debug('campaignSchedulerTask: skipping campaign due to no authorised displays');
continue;
}
// What is the total amount of time we want this campaign to play in this hour period?
// We work out how much we should have played vs how much we have played
$progress = $campaign->getProgress($nextHour->copy());
// A simple assessment of how much of the target we need in this hour period (we assume the campaign
// will play for 24 hours a day and that adjustments to later scheduling will solve any underplay)
$targetNeededPerDay = $progress->targetPerDay / 24;
// If we are more than 5% ahead of where we should be, or we are at 100% already, then don't
// schedule anything else
if ($progress->progressTarget >= 100) {
$this->log->debug('campaignSchedulerTask: campaign has completed, skipping');
continue;
} else if ($progress->progressTime > 0
&& ($progress->progressTime - $progress->progressTarget + 5) <= 0
) {
$this->log->debug('campaignSchedulerTask: campaign is 5% or more ahead of schedule, skipping');
continue;
}
if ($progress->progressTime > 0 && $progress->progressTarget > 0) {
// If we're behind, then increase our play rate accordingly
$ratio = $progress->progressTime / $progress->progressTarget;
$targetNeededPerDay = $targetNeededPerDay * $ratio;
$this->log->debug('campaignSchedulerTask: targetNeededPerDay is ' . $targetNeededPerDay
. ', adjusted by ' . $ratio);
}
// Spread across the layouts
$targetNeededPerLayout = $targetNeededPerDay / $countActiveLayouts;
// Modify the target depending on what units it is expressed in
// This also caters for spreading the target across the active displays because the
// cost/impressions/displays are sums.
if ($campaign->targetType === 'budget') {
$playsNeededPerLayout = $targetNeededPerLayout / $costPerPlay;
} else if ($campaign->targetType === 'imp') {
$playsNeededPerLayout = $targetNeededPerLayout / $impressionsPerPlay;
} else {
$playsNeededPerLayout = $targetNeededPerLayout / $countDisplays;
}
// Take the ceiling because we can't do part plays
$playsNeededPerLayout = intval(ceil($playsNeededPerLayout));
$this->log->debug('campaignSchedulerTask: targetNeededPerLayout is ' . $targetNeededPerLayout
. ', targetType: ' . $campaign->targetType
. ', playsNeededPerLayout: ' . $playsNeededPerLayout
. ', there are ' . $countDisplays . ' displays.');
foreach ($activeLayouts as $layout) {
// We are on an active day of the week and within an active day part
// create a scheduled event for all displays assigned.
// and for each geo fence
// Create our schedule
$schedule = $this->scheduleFactory->createEmpty();
$schedule->setCampaignFactory($this->campaignFactory);
// Date
$schedule->fromDt = $nextHour->unix();
$schedule->toDt = $nextHourEnd->unix();
// Displays
foreach ($displayGroups as $displayGroup) {
$schedule->assignDisplayGroup($displayGroup);
}
// Interrupt Layout
$schedule->eventTypeId = Schedule::$INTERRUPT_EVENT;
$schedule->userId = $campaign->ownerId;
$schedule->parentCampaignId = $campaign->campaignId;
$schedule->campaignId = $layout->layoutCampaignId;
$schedule->displayOrder = 0;
$schedule->isPriority = 0;
$schedule->dayPartId = $this->getCustomDayPart()->dayPartId;
$schedule->isGeoAware = 0;
$schedule->syncTimezone = 0;
$schedule->syncEvent = 0;
// We cap SOV at 3600
$schedule->shareOfVoice = min($playsNeededPerLayout * $layout->duration, 3600);
$schedule->maxPlaysPerHour = $playsNeededPerLayout;
// Do we have a geofence? (geo schedules do not count against totalSovAvailable)
if (!empty($layout->geoFence)) {
$this->log->debug('campaignSchedulerTask: layout has a geo fence');
$schedule->isGeoAware = 1;
// Get some GeoJSON and pull out each Feature (create a schedule for each one)
$geoJson = GeoJson::jsonUnserialize(json_decode($layout->geoFence, true));
if ($geoJson instanceof FeatureCollection) {
$this->log->debug('campaignSchedulerTask: layout has multiple features');
foreach ($geoJson->getFeatures() as $feature) {
$schedule->geoLocation = json_encode($feature->jsonSerialize());
$schedule->save(['notify' => false]);
// Clone a new one
$schedule = clone $schedule;
}
} else {
$schedule->geoLocation = $layout->geoFence;
$schedule->save(['notify' => false]);
}
} else {
$schedule->save(['notify' => false]);
}
}
// Handle notify
foreach ($notifyDisplayGroupIds as $displayGroupId) {
$this->displayNotifyService->notifyByDisplayGroupId($displayGroupId);
}
$campaignsScheduled++;
} catch (\Exception $exception) {
$this->log->error('campaignSchedulerTask: ' . $exception->getMessage());
$this->appendRunMessage($campaign->campaign . ' failed');
}
}
$this->appendRunMessage($campaignsProcessed . ' campaigns processed, of which ' . $campaignsScheduled
. ' were scheduled. Skipped ' . ($campaignsProcessed - $campaignsScheduled) . ' for various reasons');
}
private function getCustomDayPart(): DayPart
{
if ($this->customDayPart === null) {
$this->customDayPart = $this->dayPartFactory->getCustomDayPart();
}
return $this->customDayPart;
}
}

View File

@@ -0,0 +1,85 @@
<?php
/**
* Copyright (C) 2019 Xibo Signage Ltd
*
* Xibo - Digital Signage - http://www.xibo.org.uk
*
* This file is part of Xibo.
*
* Xibo is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Xibo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Xibo. If not, see <http://www.gnu.org/licenses/>.
*/
namespace Xibo\XTR;
use Carbon\Carbon;
use Xibo\Factory\MediaFactory;
use Xibo\Helper\DateFormatHelper;
/**
* Class ClearCachedMediaDataTask
* @package Xibo\XTR
*/
class ClearCachedMediaDataTask implements TaskInterface
{
use TaskTrait;
/** @var MediaFactory */
private $mediaFactory;
/** @inheritdoc */
public function setFactories($container)
{
$this->mediaFactory = $container->get('mediaFactory');
return $this;
}
/** @inheritdoc */
public function run()
{
$this->runMessage = '# ' . __('Clear Cached Media Data') . PHP_EOL . PHP_EOL;
// Long running task
set_time_limit(0);
$this->runClearCache();
}
/**
* Updates all md5/filesizes to empty for any image/module file created since 2.2.0 release date
*/
private function runClearCache()
{
$cutOffDate = Carbon::createFromFormat('Y-m-d', '2019-11-26')->startOfDay()->format(DateFormatHelper::getSystemFormat());
// Update the MD5 and fileSize to null
$this->store->update('UPDATE `media` SET md5 = :md5, fileSize = :fileSize, modifiedDt = :modifiedDt
WHERE (`media`.type = \'image\' OR (`media`.type = \'module\' AND `media`.moduleSystemFile = 0)) AND createdDt >= :createdDt ', [
'fileSize' => null,
'md5' => null,
'createdDt' => $cutOffDate,
'modifiedDt' => date(DateFormatHelper::getSystemFormat())
]);
// Disable the task
$this->appendRunMessage('# Disabling task.');
$this->getTask()->isActive = 0;
$this->getTask()->save();
$this->appendRunMessage(__('Done.'. PHP_EOL));
}
}

View File

@@ -0,0 +1,129 @@
<?php
/**
* Copyright (C) 2020 Xibo Signage Ltd
*
* Xibo - Digital Signage - http://www.xibo.org.uk
*
* This file is part of Xibo.
*
* Xibo is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Xibo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Xibo. If not, see <http://www.gnu.org/licenses/>.
*/
namespace Xibo\XTR;
use Xibo\Entity\DataSet;
use Xibo\Factory\DataSetFactory;
use Xibo\Support\Exception\GeneralException;
/**
* Class DataSetConvertTask
* @package Xibo\XTR
*/
class DataSetConvertTask implements TaskInterface
{
use TaskTrait;
/** @var DataSetFactory */
private $dataSetFactory;
/** @inheritdoc */
public function setFactories($container)
{
$this->dataSetFactory = $container->get('dataSetFactory');
return $this;
}
/** @inheritdoc */
public function run()
{
// Protect against us having run before
if ($this->store->exists('SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = :schema AND TABLE_NAME = :name', [
'schema' => $_SERVER['MYSQL_DATABASE'],
'name' => 'datasetdata'
])) {
// Get all DataSets
foreach ($this->dataSetFactory->query() as $dataSet) {
/* @var \Xibo\Entity\DataSet $dataSet */
// Rebuild the data table
$dataSet->rebuild();
// Load the existing data from datasetdata
foreach (self::getExistingData($dataSet) as $row) {
$dataSet->addRow($row);
}
}
// Drop data set data
$this->store->update('DROP TABLE `datasetdata`;', []);
}
// Disable the task
$this->getTask()->isActive = 0;
$this->appendRunMessage('Conversion Completed');
}
/**
* Data Set Results
* @param DataSet $dataSet
* @return array
* @throws GeneralException
*/
public function getExistingData($dataSet)
{
$dbh = $this->store->getConnection();
$params = array('dataSetId' => $dataSet->dataSetId);
$selectSQL = '';
$outerSelect = '';
foreach ($dataSet->getColumn() as $col) {
/* @var \Xibo\Entity\DataSetColumn $col */
if ($col->dataSetColumnTypeId != 1)
continue;
$selectSQL .= sprintf("MAX(CASE WHEN DataSetColumnID = %d THEN `Value` ELSE null END) AS '%s', ", $col->dataSetColumnId, $col->heading);
$outerSelect .= sprintf(' `%s`,', $col->heading);
}
$outerSelect = rtrim($outerSelect, ',');
// We are ready to build the select and from part of the SQL
$SQL = "SELECT $outerSelect ";
$SQL .= " FROM ( ";
$SQL .= " SELECT $outerSelect ,";
$SQL .= " RowNumber ";
$SQL .= " FROM ( ";
$SQL .= " SELECT $selectSQL ";
$SQL .= " RowNumber ";
$SQL .= " FROM (";
$SQL .= " SELECT datasetcolumn.DataSetColumnID, datasetdata.RowNumber, datasetdata.`Value` ";
$SQL .= " FROM datasetdata ";
$SQL .= " INNER JOIN datasetcolumn ";
$SQL .= " ON datasetcolumn.DataSetColumnID = datasetdata.DataSetColumnID ";
$SQL .= " WHERE datasetcolumn.DataSetID = :dataSetId ";
$SQL .= " ) datasetdatainner ";
$SQL .= " GROUP BY RowNumber ";
$SQL .= " ) datasetdata ";
$SQL .= ' ) finalselect ';
$SQL .= " ORDER BY RowNumber ";
$sth = $dbh->prepare($SQL);
$sth->execute($params);
return $sth->fetchAll(\PDO::FETCH_ASSOC);
}
}

View File

@@ -0,0 +1,31 @@
<?php
/*
* Spring Signage Ltd - http://www.springsignage.com
* Copyright (C) 2018 Spring Signage Ltd
* (DropPlayerCacheTask.php)
*/
namespace Xibo\XTR;
/**
* Class DropPlayerCacheTask
* @package Xibo\XTR
*/
class DropPlayerCacheTask implements TaskInterface
{
use TaskTrait;
/** @inheritdoc */
public function setFactories($container)
{
// Nothing needed here
return $this;
}
/** @inheritdoc */
public function run()
{
$this->pool->deleteItem('display');
}
}

View File

@@ -0,0 +1,330 @@
<?php
/*
* Copyright (C) 2023 Xibo Signage Ltd
*
* Xibo - Digital Signage - https://xibosignage.com
*
* This file is part of Xibo.
*
* Xibo is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Xibo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Xibo. If not, see <http://www.gnu.org/licenses/>.
*/
namespace Xibo\XTR;
use Carbon\Carbon;
use Xibo\Entity\Media;
use Xibo\Entity\Playlist;
use Xibo\Entity\Task;
use Xibo\Factory\MediaFactory;
use Xibo\Factory\ModuleFactory;
use Xibo\Factory\PlaylistFactory;
use Xibo\Factory\WidgetFactory;
use Xibo\Helper\DateFormatHelper;
use Xibo\Storage\StorageServiceInterface;
use Xibo\Support\Exception\GeneralException;
use Xibo\Support\Exception\NotFoundException;
/**
* Class DynamicPlaylistSyncTask
* @package Xibo\XTR
*
* Keep dynamic Playlists in sync with changes to the Media table.
*/
class DynamicPlaylistSyncTask implements TaskInterface
{
use TaskTrait;
/** @var StorageServiceInterface */
private $store;
/** @var PlaylistFactory */
private $playlistFactory;
/** @var MediaFactory */
private $mediaFactory;
/** @var ModuleFactory */
private $moduleFactory;
/** @var WidgetFactory */
private $widgetFactory;
/** @inheritdoc */
public function setFactories($container)
{
$this->store = $container->get('store');
$this->playlistFactory = $container->get('playlistFactory');
$this->mediaFactory = $container->get('mediaFactory');
$this->moduleFactory = $container->get('moduleFactory');
$this->widgetFactory = $container->get('widgetFactory');
return $this;
}
/** @inheritdoc */
public function run()
{
// If we're in the error state, then always run, otherwise check the dates we modified various triggers
if ($this->getTask()->lastRunStatus !== Task::$STATUS_ERROR) {
// Run a little query to get the last modified date from the media table
$lastMediaUpdate = $this->store->select('
SELECT MAX(modifiedDt) AS modifiedDt
FROM `media`
WHERE `type` <> \'module\' AND `type` <> \'genericfile\'
', [])[0]['modifiedDt'];
$lastPlaylistUpdate = $this->store->select('
SELECT MAX(modifiedDt) AS modifiedDt
FROM `playlist`
', [])[0]['modifiedDt'];
if (empty($lastMediaUpdate) || empty($lastPlaylistUpdate)) {
$this->appendRunMessage('No library media or Playlists to assess');
return;
}
$this->log->debug('Last media updated date is ' . $lastMediaUpdate);
$this->log->debug('Last playlist updated date is ' . $lastPlaylistUpdate);
$lastMediaUpdate = Carbon::createFromFormat(DateFormatHelper::getSystemFormat(), $lastMediaUpdate);
$lastPlaylistUpdate = Carbon::createFromFormat(DateFormatHelper::getSystemFormat(), $lastPlaylistUpdate);
$lastTaskRun = Carbon::createFromTimestamp($this->getTask()->lastRunDt);
if ($lastMediaUpdate->lessThanOrEqualTo($lastTaskRun)
&& $lastPlaylistUpdate->lessThanOrEqualTo($lastTaskRun))
{
$this->appendRunMessage('No library media/playlist updates since we last ran');
return;
}
}
$count = 0;
// Get all Dynamic Playlists
foreach ($this->playlistFactory->query(null, ['isDynamic' => 1]) as $playlist) {
try {
// We want to detect any differences in what should be assigned to this Playlist.
$playlist->load(['checkDisplayOrder' => true]);
$this->log->debug('Assessing Playlist: ' . $playlist->name);
if (empty($playlist->filterMediaName) && empty($playlist->filterMediaTags) && empty($playlist->filterFolderId)) {
// if this Dynamic Playlist was populated will all Media in the system
// before we introduced measures against it, we need to go through and unassign all Widgets from it.
// if it is fresh Playlist added recently, it will not have any Widgets on it with empty filters.
if (!empty($playlist->widgets)) {
foreach ($playlist->widgets as $widget) {
$playlist->deleteWidget($widget);
}
}
$this->log->debug(sprintf(
'Dynamic Playlist ID %d , with no filters set, skipping.',
$playlist->playlistId
));
continue;
}
// Query for media which would be assigned to this Playlist and see if there are any differences
$media = [];
$mediaIds = [];
$displayOrder = [];
foreach ($this->mediaFactory->query(null, [
'name' => $playlist->filterMediaName,
'logicalOperatorName' => $playlist->filterMediaNameLogicalOperator,
'tags' => $playlist->filterMediaTags,
'exactTags' => $playlist->filterExactTags,
'logicalOperator' => $playlist->filterMediaTagsLogicalOperator,
'folderId' => !empty($playlist->filterFolderId) ? $playlist->filterFolderId : null,
'userCheckUserId' => $playlist->getOwnerId(),
'start' => 0,
'length' => $playlist->maxNumberOfItems
]) as $index => $item) {
$media[$item->mediaId] = $item;
$mediaIds[] = $item->mediaId;
// store the expected display order
$displayOrder[$item->mediaId] = $index + 1;
}
// Work out if the set of widgets is different or not.
// This is only the first loose check
$different = (count($playlist->widgets) !== count($media));
$this->log->debug('There are ' . count($media) . ' that should be assigned and '
. count($playlist->widgets) . ' currently assigned with max number of items set to '
. $playlist->maxNumberOfItems . ' First check difference is '
. var_export($different, true));
if (!$different) {
// Try a more complete check, using mediaIds
$compareMediaIds = $mediaIds;
// ordering should be the same, so the first time we get one out of order, we can stop
foreach ($playlist->widgets as $widget) {
try {
$widgetMediaId = $widget->getPrimaryMediaId();
if ($widgetMediaId !== $compareMediaIds[0]
|| $widget->duration !== $media[$widgetMediaId]->duration
) {
$different = true;
break;
}
} catch (NotFoundException $notFoundException) {
$this->log->error('Playlist ' . $playlist->getId()
. ' has a Widget without any associated media. widgetId = ' . $widget->getId());
// We ought to recalculate
$different = true;
break;
}
array_shift($compareMediaIds);
}
}
$this->log->debug('Second check difference is ' . var_export($different, true));
if ($different) {
// We will update this Playlist
$assignmentMade = false;
$count++;
// Remove the ones no-longer present, add the ones we're missing
// we don't delete and re-add the lot to avoid regenerating the widgetIds (makes stats harder to
// interpret)
foreach ($playlist->widgets as $widget) {
try {
$widgetMediaId = $widget->getPrimaryMediaId();
if (!in_array($widgetMediaId, $mediaIds)) {
$playlist->deleteWidget($widget);
} else {
// It's present in the array
// Check to see if the duration is different
if ($widget->duration !== $media[$widgetMediaId]->duration) {
// The media duration has changed, so update the widget
$widget->useDuration = 1;
$widget->duration = $media[$widgetMediaId]->duration;
$widget->calculatedDuration = $widget->duration;
$widget->save([
'saveWidgetOptions' => false,
'saveWidgetAudio' => false,
'saveWidgetMedia' => false,
'notify' => false,
'notifyPlaylists' => false,
'notifyDisplays' => false,
'audit' => true,
'alwaysUpdate' => true
]);
}
// Pop it off the list of ones to assign.
$mediaIds = array_diff($mediaIds, [$widgetMediaId]);
// We do want to save the Playlist here.
$assignmentMade = true;
}
} catch (NotFoundException) {
// Delete it
$playlist->deleteWidget($widget);
}
}
// Do we have any mediaId's left which should be assigned and aren't?
// Add the ones we have left
foreach ($media as $item) {
if (in_array($item->mediaId, $mediaIds)) {
if (count($playlist->widgets) >= $playlist->maxNumberOfItems) {
$this->log->debug(
sprintf(
'Dynamic Playlist ID %d, has reached the maximum number of items %d, finishing assignments',//phpcs:ignore
$playlist->playlistId,
$playlist->maxNumberOfItems
)
);
break;
}
$assignmentMade = true;
// make sure we pass the expected displayOrder for the new item we are about to add.
$this->createAndAssign($playlist, $item, $displayOrder[$item->mediaId]);
}
}
if ($assignmentMade) {
// We've made an assignment change, so audit this change
// don't audit any downstream save operations
$playlist->save([
'auditPlaylist' => true,
'audit' => false
]);
}
} else {
$this->log->debug('No differences detected');
}
} catch (GeneralException $exception) {
$this->log->debug($exception->getTraceAsString());
$this->log->error('Problem with PlaylistId: ' . $playlist->getId()
. ', e = ' . $exception->getMessage());
$this->appendRunMessage('Error with Playlist: ' . $playlist->name);
}
}
$this->appendRunMessage('Updated ' . $count . ' Playlists');
}
/**
* @param Playlist $playlist
* @param Media $media
* @param int $displayOrder
* @throws NotFoundException
*/
private function createAndAssign(Playlist $playlist, Media $media, int $displayOrder): void
{
$this->log->debug('Media Item needs to be assigned ' . $media->name . ' in sequence ' . $displayOrder);
// Create a module
try {
$module = $this->moduleFactory->getByType($media->mediaType);
} catch (NotFoundException) {
$this->log->error('createAndAssign: dynamic playlist matched missing module: ' . $media->mediaType);
return;
}
if ($module->assignable == 0) {
$this->log->error('createAndAssign: dynamic playlist matched unassignable media: ' . $media->mediaId);
return;
}
// Determine the duration
$mediaDuration = $media->duration;
if ($mediaDuration <= 0) {
$mediaDuration = $module->defaultDuration;
}
// Create a widget
$widget = $this->widgetFactory->create(
$playlist->getOwnerId(),
$playlist->playlistId,
$media->mediaType,
$mediaDuration,
$module->schemaVersion
);
$widget->useDuration = 1;
$widget->displayOrder = $displayOrder;
$widget->calculateDuration($module);
$widget->assignMedia($media->mediaId);
// Assign the widget to the playlist
// making sure we pass the displayOrder here, otherwise it would be added to the end of the array.
$playlist->assignWidget($widget, $displayOrder);
}
}

View File

@@ -0,0 +1,207 @@
<?php
/*
* Copyright (C) 2025 Xibo Signage Ltd
*
* Xibo - Digital Signage - https://xibosignage.com
*
* This file is part of Xibo.
*
* Xibo is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Xibo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Xibo. If not, see <http://www.gnu.org/licenses/>.
*/
namespace Xibo\XTR;
use Carbon\Carbon;
use Slim\Views\Twig;
use Xibo\Entity\UserNotification;
use Xibo\Factory\UserGroupFactory;
use Xibo\Factory\UserNotificationFactory;
/**
* Class EmailNotificationsTask
* @package Xibo\XTR
*/
class EmailNotificationsTask implements TaskInterface
{
use TaskTrait;
/** @var Twig */
private $view;
/** @var UserNotificationFactory */
private $userNotificationFactory;
/** @var UserGroupFactory */
private $userGroupFactory;
/** @inheritdoc */
public function setFactories($container)
{
$this->view = $container->get('view');
$this->userNotificationFactory = $container->get('userNotificationFactory');
$this->userGroupFactory = $container->get('userGroupFactory');
return $this;
}
/** @inheritdoc */
public function run()
{
$this->runMessage = '# ' . __('Email Notifications') . PHP_EOL . PHP_EOL;
$this->processQueue();
}
/** Process Queue of emails
* @throws \PHPMailer\PHPMailer\Exception
*/
private function processQueue()
{
// Handle queue of notifications to email.
$this->runMessage .= '## ' . __('Email Notifications') . PHP_EOL;
$msgFrom = $this->config->getSetting('mail_from');
$msgFromName = $this->config->getSetting('mail_from_name');
$processedNotifications = [];
$this->log->debug('Notification Queue sending from ' . $msgFrom);
foreach ($this->userNotificationFactory->getEmailQueue() as $notification) {
$this->log->debug('Notification found: ' . $notification->notificationId);
if (!empty($notification->email) || $notification->isSystem == 1) {
$mail = new \PHPMailer\PHPMailer\PHPMailer();
$this->log->debug('Sending Notification email to ' . $notification->email);
if ($this->checkEmailPreferences($notification)) {
$mail->addAddress($notification->email);
}
// System notifications, add mail_to to addresses if set.
if ($notification->isSystem == 1) {
// We should send the system notification to:
// - all assigned users
// - the mail_to (if set)
$mailTo = $this->config->getSetting('mail_to');
// Make sure we've been able to resolve an address.
if (empty($notification->email) && empty($mailTo)) {
$this->log->error('Discarding NotificationId ' . $notification->notificationId
. ' as no email address could be resolved.');
continue;
}
// if mail_to is set and is different from user email, and we did not
// process this notification yet (the same notificationId will be returned for each assigned user)
// add it to addresses
if ($mailTo !== $notification->email
&& !empty($mailTo)
&& !in_array($notification->notificationId, $processedNotifications)
) {
$this->log->debug('Sending Notification email to mailTo ' . $mailTo);
$mail->addAddress($mailTo);
}
}
// Email them
$mail->CharSet = 'UTF-8';
$mail->Encoding = 'base64';
$mail->From = $msgFrom;
// Add attachment
if ($notification->filename != null) {
$mail->addAttachment(
$this->config->getSetting('LIBRARY_LOCATION') . 'attachment/' . $notification->filename,
$notification->originalFileName
);
}
if (!empty($msgFromName)) {
$mail->FromName = $msgFromName;
}
$mail->Subject = $notification->subject;
$addresses = explode(',', $notification->nonusers);
foreach ($addresses as $address) {
$mail->AddAddress($address);
}
// Body
$mail->isHTML(true);
$mail->AltBody = $notification->body;
$mail->Body = $this->generateEmailBody($notification->subject, $notification->body);
if (!$mail->send()) {
$this->log->error('Unable to send email notification mail to ' . $notification->email);
$this->runMessage .= ' - E' . PHP_EOL;
$this->log->error('Unable to send email notification Error: ' . $mail->ErrorInfo);
} else {
$this->runMessage .= ' - A' . PHP_EOL;
}
$this->log->debug('Marking notification as sent');
} else {
$this->log->error('Discarding NotificationId ' . $notification->notificationId
. ' as no email address could be resolved.');
}
$processedNotifications[] = $notification->notificationId;
// Mark as sent
$notification->setEmailed(Carbon::now()->format('U'));
$notification->save();
}
$this->runMessage .= ' - Done' . PHP_EOL;
}
/**
* Generate an email body
* @param $subject
* @param $body
* @return string
*/
private function generateEmailBody($subject, $body)
{
// Generate Body
// Start an object buffer
ob_start();
// Render the template
echo $this->view->fetch(
'email-template.twig',
['config' => $this->config, 'subject' => $subject, 'body' => $body]
);
$body = ob_get_contents();
ob_end_clean();
return $body;
}
/**
* Should we send email to this user?
* check relevant flag for the notification type on the user group.
* @param UserNotification $notification
* @return bool
*/
private function checkEmailPreferences(UserNotification $notification): bool
{
return $this->userGroupFactory->checkNotificationEmailPreferences(
$notification->userId,
$notification->getTypeForGroup()
);
}
}

View File

@@ -0,0 +1,164 @@
<?php
/*
* Copyright (C) 2024 Xibo Signage Ltd
*
* Xibo - Digital Signage - https://xibosignage.com
*
* This file is part of Xibo.
*
* Xibo is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Xibo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Xibo. If not, see <http://www.gnu.org/licenses/>.
*/
namespace Xibo\XTR;
use Carbon\Carbon;
use Xibo\Factory\DisplayFactory;
use Xibo\Factory\MediaFactory;
use Xibo\Helper\DateFormatHelper;
use Xibo\Service\ImageProcessingServiceInterface;
/**
* Class ImageProcessingTask
* @package Xibo\XTR
*/
class ImageProcessingTask implements TaskInterface
{
use TaskTrait;
/** @var ImageProcessingServiceInterface */
private $imageProcessingService;
/** @var MediaFactory */
private $mediaFactory;
/** @var DisplayFactory */
private $displayFactory;
/** @inheritdoc */
public function setFactories($container)
{
$this->mediaFactory = $container->get('mediaFactory');
$this->displayFactory = $container->get('displayFactory');
$this->imageProcessingService = $container->get('imageProcessingService');
return $this;
}
/** @inheritdoc */
public function run()
{
$this->runMessage = '# ' . __('Image Processing') . PHP_EOL . PHP_EOL;
// Long running task
set_time_limit(0);
$this->runImageProcessing();
}
/**
*
*/
private function runImageProcessing()
{
$images = $this->mediaFactory->query(null, ['released' => 0, 'allModules' => 1, 'imageProcessing' => 1]);
$libraryLocation = $this->config->getSetting('LIBRARY_LOCATION');
$resizeThreshold = $this->config->getSetting('DEFAULT_RESIZE_THRESHOLD');
$count = 0;
// All displayIds
$displayIds = [];
// Get list of Images
foreach ($images as $media) {
$filePath = $libraryLocation . $media->storedAs;
list($imgWidth, $imgHeight) = @getimagesize($filePath);
// Orientation of the image
if ($imgWidth > $imgHeight) { // 'landscape';
$updatedImg = $this->imageProcessingService->resizeImage($filePath, $resizeThreshold, null);
} else { // 'portrait';
$updatedImg = $this->imageProcessingService->resizeImage($filePath, null, $resizeThreshold);
}
// Clears file status cache
clearstatcache(true, $updatedImg['filePath']);
$count++;
// Release image and save
$media->release(
md5_file($updatedImg['filePath']),
filesize($updatedImg['filePath']),
$updatedImg['height'],
$updatedImg['width']
);
$this->store->commitIfNecessary();
$mediaDisplays= [];
$sql = 'SELECT displayId FROM `requiredfile` WHERE itemId = :itemId';
foreach ($this->store->select($sql, ['itemId' => $media->mediaId]) as $row) {
$displayIds[] = $row['displayId'];
$mediaDisplays[] = $row['displayId'];
}
// Update Required Files
foreach ($mediaDisplays as $displayId) {
$this->store->update('UPDATE `requiredfile` SET released = :released, size = :size
WHERE `requiredfile`.displayId = :displayId AND `requiredfile`.itemId = :itemId ', [
'released' => 1,
'size' => $media->fileSize,
'displayId' => $displayId,
'itemId' => $media->mediaId
]);
}
// Mark any effected Layouts to be rebuilt.
$this->store->update('
UPDATE `layout`
SET status = :status, `modifiedDT` = :modifiedDt
WHERE layoutId IN (
SELECT DISTINCT region.layoutId
FROM lkwidgetmedia
INNER JOIN widget
ON widget.widgetId = lkwidgetmedia.widgetId
INNER JOIN lkplaylistplaylist
ON lkplaylistplaylist.childId = widget.playlistId
INNER JOIN playlist
ON lkplaylistplaylist.parentId = playlist.playlistId
INNER JOIN region
ON playlist.regionId = region.regionId
WHERE lkwidgetmedia.mediaId = :mediaId
)
', [
'status' => 3,
'modifiedDt' => Carbon::now()->format(DateFormatHelper::getSystemFormat()),
'mediaId' => $media->mediaId
]);
}
// Notify display
if ($count > 0) {
foreach (array_unique($displayIds) as $displayId) {
// Get display
$display = $this->displayFactory->getById($displayId);
$display->notify();
}
}
$this->appendRunMessage('Released and modified image count. ' . $count);
}
}

View File

@@ -0,0 +1,209 @@
<?php
/*
* Spring Signage Ltd - http://www.springsignage.com
* Copyright (C) 2018 Spring Signage Ltd
* (LayoutConvertTask.php)
*/
namespace Xibo\XTR;
use Xibo\Entity\Region;
use Xibo\Entity\Widget;
use Xibo\Factory\LayoutFactory;
use Xibo\Factory\PermissionFactory;
use Xibo\Helper\Environment;
/**
* Class LayoutConvertTask
* @package Xibo\XTR
*/
class LayoutConvertTask implements TaskInterface
{
use TaskTrait;
/** @var PermissionFactory */
private $permissionFactory;
/** @var LayoutFactory */
private $layoutFactory;
/** @var \Xibo\Factory\ModuleFactory */
private $moduleFactory;
/** @inheritdoc */
public function setFactories($container)
{
$this->permissionFactory = $container->get('permissionFactory');
$this->layoutFactory = $container->get('layoutFactory');
$this->moduleFactory = $container->get('moduleFactory');
return $this;
}
/** @inheritdoc */
public function run()
{
// lklayoutmedia is removed at the end of this task
if (!$this->store->exists('SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = :name', [
'name' => 'lklayoutmedia'
])) {
$this->appendRunMessage('Already converted');
// Disable the task
$this->disableTask();
// Don't do anything further
return;
}
// Permissions handling
// -------------------
// Layout permissions should remain the same
// the lklayoutmediagroup table and lklayoutregiongroup table will be removed
// We do not have simple switch for the lklayoutmediagroup table as these are supposed to represent "Widgets"
// which did not exist at this point.
// Build a keyed array of existing widget permissions
$mediaPermissions = [];
foreach ($this->store->select('
SELECT `lklayoutmediagroup`.groupId, `lkwidgetmedia`.widgetId, `view`, `edit`, `del`
FROM `lklayoutmediagroup`
INNER JOIN `lkwidgetmedia`
ON `lklayoutmediagroup`.`mediaId` = `lkwidgetmedia`.widgetId
WHERE `lkwidgetmedia`.widgetId IN (
SELECT widget.widgetId
FROM `widget`
INNER JOIN `playlist`
ON `playlist`.playlistId = `widget`.playlistId
WHERE `playlist`.regionId = `lklayoutmediagroup`.regionId
)
', []) as $row) {
$permission = $this->permissionFactory->create(
$row['groupId'],
Widget::class,
$row['widgetId'],
$row['view'],
$row['edit'],
$row['del']
);
$mediaPermissions[$row['mediaId']] = $permission;
}
// Build a keyed array of existing region permissions
$regionPermissions = [];
foreach ($this->store->select('SELECT groupId, layoutId, regionId, `view`, `edit`, `del` FROM `lklayoutregiongroup`', []) as $row) {
$permission = $this->permissionFactory->create(
$row['groupId'],
Region::class,
$row['regionId'],
$row['view'],
$row['edit'],
$row['del']
);
$regionPermissions[$row['regionId']] = $permission;
}
// Get the library location to store backups of existing XLF
$libraryLocation = $this->config->getSetting('LIBRARY_LOCATION');
// We need to go through each layout, save the XLF as a backup in the library and then upgrade it.
// This task applies to Layouts which are schemaVersion 2 or lower. xibosignage/xibo#2056
foreach ($this->store->select('SELECT layoutId, xml FROM `layout` WHERE schemaVersion <= :schemaVersion', [
'schemaVersion' => 2
]) as $oldLayout) {
$oldLayoutId = intval($oldLayout['layoutId']);
try {
// Does this layout have any XML associated with it? If not, then it is an empty layout.
if (empty($oldLayout['xml'])) {
// This is frankly, odd, so we better log it
$this->log->critical('Layout upgrade without any existing XLF, i.e. empty. ID = ' . $oldLayoutId);
// Pull out the layout record, and set some best guess defaults
$layout = $this->layoutFactory->getById($oldLayoutId);
// We have to guess something here as we do not have any XML to go by. Default to landscape 1080p.
$layout->width = 1920;
$layout->height = 1080;
} else {
// Save off a copy of the XML in the library
file_put_contents($libraryLocation . 'archive_' . $oldLayoutId . '.xlf', $oldLayout['xml']);
// Create a new layout from the XML
$layout = $this->layoutFactory->loadByXlf($oldLayout['xml'], $this->layoutFactory->getById($oldLayoutId));
}
// We need one final pass through all widgets on the layout so that we can set the durations properly.
foreach ($layout->getRegionWidgets() as $widget) {
$widget->calculateDuration($this->moduleFactory->getByType($widget->type), true);
// Get global stat setting of widget to set to on/off/inherit
$widget->setOptionValue('enableStat', 'attrib', $this->config->getSetting('WIDGET_STATS_ENABLED_DEFAULT'));
}
// Save the layout
$layout->schemaVersion = Environment::$XLF_VERSION;
$layout->save(['notify' => false, 'audit' => false]);
// Now that we have new ID's we need to cross reference them with the old IDs and recreate the permissions
foreach ($layout->regions as $region) {
/* @var \Xibo\Entity\Region $region */
if (array_key_exists($region->tempId, $regionPermissions)) {
$permission = $regionPermissions[$region->tempId];
/* @var \Xibo\Entity\Permission $permission */
// Double check we are for the same layout
if ($permission->objectId == $layout->layoutId) {
$permission = clone $permission;
$permission->objectId = $region->regionId;
$permission->save();
}
}
/* @var \Xibo\Entity\Playlist $playlist */
foreach ($region->getPlaylist()->widgets as $widget) {
/* @var \Xibo\Entity\Widget $widget */
if (array_key_exists($widget->tempId, $mediaPermissions)) {
$permission = $mediaPermissions[$widget->tempId];
/* @var \Xibo\Entity\Permission $permission */
if ($permission->objectId == $layout->layoutId && $region->tempId == $permission->objectIdString) {
$permission = clone $permission;
$permission->objectId = $widget->widgetId;
$permission->save();
}
}
}
}
} catch (\Exception $e) {
$this->appendRunMessage('Error upgrading Layout, this should be checked post-upgrade. ID: ' . $oldLayoutId);
$this->log->critical('Error upgrading Layout, this should be checked post-upgrade. ID: ' . $oldLayoutId);
$this->log->error($e->getMessage() . ' - ' . $e->getTraceAsString());
}
}
$this->appendRunMessage('Finished converting, dropping unnecessary tables.');
// Drop the permissions
$this->store->update('DROP TABLE `lklayoutmediagroup`;', []);
$this->store->update('DROP TABLE `lklayoutregiongroup`;', []);
$this->store->update('DROP TABLE lklayoutmedia', []);
$this->store->update('ALTER TABLE `layout` DROP `xml`;', []);
// Disable the task
$this->disableTask();
$this->appendRunMessage('Conversion Completed');
}
/**
* Disables and saves this task immediately
* @throws \Xibo\Support\Exception\InvalidArgumentException
*/
private function disableTask()
{
$this->getTask()->isActive = 0;
$this->getTask()->save();
$this->store->commitIfNecessary();
}
}

View File

@@ -0,0 +1,344 @@
<?php
/*
* Copyright (C) 2024 Xibo Signage Ltd
*
* Xibo - Digital Signage - https://xibosignage.com
*
* This file is part of Xibo.
*
* Xibo is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Xibo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Xibo. If not, see <http://www.gnu.org/licenses/>.
*/
namespace Xibo\XTR;
use Carbon\Carbon;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;
use Xibo\Controller\Module;
use Xibo\Event\MaintenanceDailyEvent;
use Xibo\Factory\DataSetFactory;
use Xibo\Factory\FontFactory;
use Xibo\Factory\LayoutFactory;
use Xibo\Factory\ModuleFactory;
use Xibo\Factory\ModuleTemplateFactory;
use Xibo\Factory\UserFactory;
use Xibo\Helper\DatabaseLogHandler;
use Xibo\Helper\DateFormatHelper;
use Xibo\Helper\Random;
use Xibo\Service\MediaService;
use Xibo\Service\MediaServiceInterface;
use Xibo\Support\Exception\GeneralException;
use Xibo\Support\Exception\NotFoundException;
/**
* Class MaintenanceDailyTask
* @package Xibo\XTR
*/
class MaintenanceDailyTask implements TaskInterface
{
use TaskTrait;
/** @var LayoutFactory */
private $layoutFactory;
/** @var UserFactory */
private $userFactory;
/** @var Module */
private $moduleController;
/** @var MediaServiceInterface */
private $mediaService;
/** @var DataSetFactory */
private $dataSetFactory;
/** @var FontFactory */
private $fontFactory;
/** @var ModuleFactory */
private $moduleFactory;
/** @var ModuleTemplateFactory */
private $moduleTemplateFactory;
/** @var string */
private $libraryLocation;
/** @inheritdoc */
public function setFactories($container)
{
$this->moduleController = $container->get('\Xibo\Controller\Module');
$this->layoutFactory = $container->get('layoutFactory');
$this->userFactory = $container->get('userFactory');
$this->dataSetFactory = $container->get('dataSetFactory');
$this->mediaService = $container->get('mediaService');
$this->fontFactory = $container->get('fontFactory');
$this->moduleFactory = $container->get('moduleFactory');
$this->moduleTemplateFactory = $container->get('moduleTemplateFactory');
return $this;
}
/** @inheritdoc */
public function run()
{
$this->runMessage = '# ' . __('Daily Maintenance') . PHP_EOL . PHP_EOL;
// Long-running task
set_time_limit(0);
// Make sure our library structure is as it should be
try {
$this->libraryLocation = $this->getConfig()->getSetting('LIBRARY_LOCATION');
MediaService::ensureLibraryExists($this->libraryLocation);
} catch (\Exception $exception) {
$this->getLogger()->error('Library structure invalid, e = ' . $exception->getMessage());
$this->appendRunMessage(__('Library structure invalid'));
}
// Import layouts
$this->importLayouts();
// Cycle the XMR Key
$this->cycleXmrKey();
try {
$this->appendRunMessage(__('## Build caches'));
// TODO: should we remove all bundle/asset cache before we start?
// Player bundle
$this->cachePlayerBundle();
// Cache Assets
$this->cacheAssets();
// Fonts
$this->mediaService->setUser($this->userFactory->getSystemUser())->updateFontsCss();
} catch (\Exception $exception) {
$this->getLogger()->error('Failure to build caches, e = ' . $exception->getMessage());
$this->appendRunMessage(__('Failure to build caches'));
}
// Tidy logs
$this->tidyLogs();
// Tidy Cache
$this->tidyCache();
// Dispatch an event so that consumers can hook into daily maintenance.
$event = new MaintenanceDailyEvent();
$this->getDispatcher()->dispatch($event, MaintenanceDailyEvent::$NAME);
foreach ($event->getMessages() as $message) {
$this->appendRunMessage($message);
}
}
/**
* Tidy the DB logs
*/
private function tidyLogs()
{
$this->runMessage .= '## ' . __('Tidy Logs') . PHP_EOL;
$maxage = $this->config->getSetting('MAINTENANCE_LOG_MAXAGE');
if ($maxage != 0) {
// Run this in the log handler so that we share the same connection and don't deadlock.
DatabaseLogHandler::tidyLogs(
Carbon::now()
->subDays(intval($maxage))
->format(DateFormatHelper::getSystemFormat())
);
$this->runMessage .= ' - ' . __('Done') . PHP_EOL . PHP_EOL;
} else {
$this->runMessage .= ' - ' . __('Disabled') . PHP_EOL . PHP_EOL;
}
}
/**
* Tidy Cache
*/
private function tidyCache()
{
$this->runMessage .= '## ' . __('Tidy Cache') . PHP_EOL;
$this->pool->purge();
$this->runMessage .= ' - ' . __('Done.') . PHP_EOL . PHP_EOL;
}
/**
* Import Layouts
* @throws GeneralException|\FontLib\Exception\FontNotFoundException
*/
private function importLayouts()
{
$this->runMessage .= '## ' . __('Import Layouts and Fonts') . PHP_EOL;
if ($this->config->getSetting('DEFAULTS_IMPORTED') == 0) {
// Make sure the library exists
$this->mediaService->initLibrary();
// Import any layouts
$folder = $this->config->uri('layouts', true);
foreach (array_diff(scandir($folder), array('..', '.')) as $file) {
if (stripos($file, '.zip')) {
try {
$layout = $this->layoutFactory->createFromZip(
$folder . '/' . $file,
null,
$this->userFactory->getSystemUser()->getId(),
false,
false,
true,
false,
true,
$this->dataSetFactory,
null,
$this->mediaService,
1
);
$layout->save([
'audit' => false,
'import' => true
]);
if (!empty($layout->getUnmatchedProperty('thumbnail'))) {
rename($layout->getUnmatchedProperty('thumbnail'), $layout->getThumbnailUri());
}
try {
$this->layoutFactory->getById($this->config->getSetting('DEFAULT_LAYOUT'));
} catch (NotFoundException $exception) {
$this->config->changeSetting('DEFAULT_LAYOUT', $layout->layoutId);
}
} catch (\Exception $exception) {
$this->log->error('Unable to import layout: ' . $file . '. E = ' . $exception->getMessage());
$this->log->debug($exception->getTraceAsString());
}
}
}
// Fonts
// -----
// install fonts from the theme folder
$libraryLocation = $this->config->getSetting('LIBRARY_LOCATION');
$fontFolder = $this->config->uri('fonts', true);
foreach (array_diff(scandir($fontFolder), array('..', '.')) as $file) {
// check if we already have this font file
if (count($this->fontFactory->getByFileName($file)) <= 0) {
// if we don't add it
$filePath = $fontFolder . DIRECTORY_SEPARATOR . $file;
$fontLib = \FontLib\Font::load($filePath);
// check embed flag, just in case
$embed = intval($fontLib->getData('OS/2', 'fsType'));
// if it's not embeddable, log error and skip it
if ($embed != 0 && $embed != 8) {
$this->log->error('Unable to install default Font: ' . $file
. ' . Font file is not embeddable due to its permissions');
continue;
}
$font = $this->fontFactory->createEmpty();
$font->modifiedBy = $this->userFactory->getSystemUser()->userName;
$font->name = $fontLib->getFontName() . ' ' . $fontLib->getFontSubfamily();
$font->familyName = strtolower(preg_replace('/\s+/', ' ', preg_replace('/\d+/u', '', $font->name)));
$font->fileName = $file;
$font->size = filesize($filePath);
$font->md5 = md5_file($filePath);
$font->save();
$copied = copy($filePath, $libraryLocation . 'fonts/' . $file);
if (!$copied) {
$this->getLogger()->error('importLayouts: Unable to copy fonts to ' . $libraryLocation);
}
}
}
$this->config->changeSetting('DEFAULTS_IMPORTED', 1);
$this->runMessage .= ' - ' . __('Done.') . PHP_EOL . PHP_EOL;
} else {
$this->runMessage .= ' - ' . __('Not Required.') . PHP_EOL . PHP_EOL;
}
}
/**
* Refresh the cache of assets
* @return void
* @throws GeneralException
*/
private function cacheAssets(): void
{
// Assets
$failedCount = 0;
$assets = array_merge($this->moduleFactory->getAllAssets(), $this->moduleTemplateFactory->getAllAssets());
foreach ($assets as $asset) {
try {
$asset->updateAssetCache($this->libraryLocation, true);
} catch (GeneralException $exception) {
$failedCount++;
$this->log->error('Unable to copy asset: ' . $asset->id . ', e: ' . $exception->getMessage());
}
}
$this->appendRunMessage(sprintf(__('Assets cached, %d failed.'), $failedCount));
}
/**
* Cache the player bundle.
* @return void
*/
private function cachePlayerBundle(): void
{
// Output the player bundle
$bundlePath = $this->getConfig()->getSetting('LIBRARY_LOCATION') . 'assets/bundle.min.js';
$bundleMd5CachePath = $bundlePath . '.md5';
copy(PROJECT_ROOT . '/modules/bundle.min.js', $bundlePath);
file_put_contents($bundleMd5CachePath, md5_file($bundlePath));
$this->appendRunMessage(__('Player bundle cached'));
}
/**
* Once per day we cycle the XMR CMS key
* the old key should remain valid in XMR for up to 1 hour further to allow for cross over
* @return void
*/
private function cycleXmrKey(): void
{
$this->log->debug('cycleXmrKey: adding new key');
try {
$key = Random::generateString(20, 'xmr_');
$this->getConfig()->changeSetting('XMR_CMS_KEY', $key);
$client = new Client($this->config->getGuzzleProxy([
'base_uri' => $this->getConfig()->getSetting('XMR_ADDRESS'),
]));
$client->post('/', [
'json' => [
'id' => constant('SECRET_KEY'),
'type' => 'keys',
'key' => $key,
],
]);
$this->log->debug('cycleXmrKey: added new key');
} catch (GuzzleException | \Exception $e) {
$this->log->error('cycleXmrKey: failed. E = ' . $e->getMessage());
}
}
}

View File

@@ -0,0 +1,685 @@
<?php
/*
* Copyright (C) 2025 Xibo Signage Ltd
*
* Xibo - Digital Signage - https://xibosignage.com
*
* This file is part of Xibo.
*
* Xibo is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Xibo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Xibo. If not, see <http://www.gnu.org/licenses/>.
*/
namespace Xibo\XTR;
use Carbon\Carbon;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;
use Xibo\Controller\Display;
use Xibo\Event\DisplayGroupLoadEvent;
use Xibo\Event\MaintenanceRegularEvent;
use Xibo\Factory\DisplayFactory;
use Xibo\Factory\DisplayGroupFactory;
use Xibo\Factory\LayoutFactory;
use Xibo\Factory\ModuleFactory;
use Xibo\Factory\NotificationFactory;
use Xibo\Factory\PlaylistFactory;
use Xibo\Factory\ScheduleFactory;
use Xibo\Factory\UserGroupFactory;
use Xibo\Helper\ByteFormatter;
use Xibo\Helper\DateFormatHelper;
use Xibo\Helper\Profiler;
use Xibo\Helper\Status;
use Xibo\Helper\WakeOnLan;
use Xibo\Service\MediaServiceInterface;
use Xibo\Support\Exception\GeneralException;
use Xibo\Support\Exception\NotFoundException;
/**
* Class MaintenanceRegularTask
* @package Xibo\XTR
*/
class MaintenanceRegularTask implements TaskInterface
{
use TaskTrait;
/** @var Display */
private $displayController;
/** @var MediaServiceInterface */
private $mediaService;
/** @var DisplayFactory */
private $displayFactory;
/** @var DisplayGroupFactory */
private $displayGroupFactory;
/** @var NotificationFactory */
private $notificationFactory;
/** @var UserGroupFactory */
private $userGroupFactory;
/** @var LayoutFactory */
private $layoutFactory;
/** @var PlaylistFactory */
private $playlistFactory;
/** @var ModuleFactory */
private $moduleFactory;
/** @var \Xibo\Helper\SanitizerService */
private $sanitizerService;
/**
* @var ScheduleFactory
*/
private $scheduleFactory;
/** @inheritdoc */
public function setFactories($container)
{
$this->displayController = $container->get('\Xibo\Controller\Display');
$this->mediaService = $container->get('mediaService');
$this->displayFactory = $container->get('displayFactory');
$this->displayGroupFactory = $container->get('displayGroupFactory');
$this->notificationFactory = $container->get('notificationFactory');
$this->userGroupFactory = $container->get('userGroupFactory');
$this->layoutFactory = $container->get('layoutFactory');
$this->playlistFactory = $container->get('playlistFactory');
$this->moduleFactory = $container->get('moduleFactory');
$this->sanitizerService = $container->get('sanitizerService');
$this->scheduleFactory = $container->get('scheduleFactory');
return $this;
}
/** @inheritdoc */
public function run()
{
$this->runMessage = '# ' . __('Regular Maintenance') . PHP_EOL . PHP_EOL;
$this->assertXmrKey();
$this->displayDownEmailAlerts();
$this->licenceSlotValidation();
$this->wakeOnLan();
$this->updatePlaylistDurations();
$this->buildLayouts();
$this->tidyLibrary();
$this->checkLibraryUsage();
$this->checkOverRequestedFiles();
$this->publishLayouts();
$this->assessDynamicDisplayGroups();
$this->tidyAdCampaignSchedules();
$this->tidyUnusedFullScreenLayout();
// Dispatch an event so that consumers can hook into regular maintenance.
$event = new MaintenanceRegularEvent();
$this->getDispatcher()->dispatch($event, MaintenanceRegularEvent::$NAME);
foreach ($event->getMessages() as $message) {
$this->appendRunMessage($message);
}
}
/**
* Display Down email alerts
* - just runs validate displays
*/
private function displayDownEmailAlerts()
{
$this->runMessage .= '## ' . __('Email Alerts') . PHP_EOL;
$this->displayController->validateDisplays($this->displayFactory->query());
$this->appendRunMessage(__('Done'));
}
/**
* Licence Slot Validation
*/
private function licenceSlotValidation()
{
$maxDisplays = $this->config->getSetting('MAX_LICENSED_DISPLAYS');
if ($maxDisplays > 0) {
$this->runMessage .= '## ' . __('Licence Slot Validation') . PHP_EOL;
// Get a list of all displays
try {
$dbh = $this->store->getConnection();
$sth = $dbh->prepare('SELECT displayId, display FROM `display` WHERE licensed = 1 ORDER BY lastAccessed');
$sth->execute();
$displays = $sth->fetchAll(\PDO::FETCH_ASSOC);
if (count($displays) > $maxDisplays) {
// :(
// We need to un-licence some displays
$difference = count($displays) - $maxDisplays;
$this->log->alert(sprintf('Max %d authorised displays exceeded, we need to un-authorise %d of %d displays', $maxDisplays, $difference, count($displays)));
$update = $dbh->prepare('UPDATE `display` SET licensed = 0 WHERE displayId = :displayId');
foreach ($displays as $display) {
$sanitizedDisplay = $this->getSanitizer($display);
// If we are down to 0 difference, then stop
if ($difference == 0) {
break;
}
$this->appendRunMessage(sprintf(__('Disabling %s'), $sanitizedDisplay->getString('display')));
$update->execute(['displayId' => $display['displayId']]);
$this->log->audit('Display', $display['displayId'], 'Regular Maintenance unauthorised display due to max number of slots exceeded.', ['display' => $display['display']]);
$difference--;
}
}
$this->runMessage .= ' - Done' . PHP_EOL . PHP_EOL;
}
catch (\Exception $e) {
$this->log->error($e);
}
}
}
/**
* Wake on LAN
*/
private function wakeOnLan()
{
$this->runMessage = '# ' . __('Wake On LAN') . PHP_EOL;
try {
// Get a list of all displays which have WOL enabled
foreach($this->displayFactory->query(null, ['wakeOnLan' => 1]) as $display) {
/** @var \Xibo\Entity\Display $display */
// Time to WOL (with respect to today)
$timeToWake = strtotime(date('Y-m-d') . ' ' . $display->wakeOnLanTime);
$timeNow = Carbon::now()->format('U');
// Should the display be awake?
if ($timeNow >= $timeToWake) {
// Client should be awake, so has this displays WOL time been passed
if ($display->lastWakeOnLanCommandSent < $timeToWake) {
// Call the Wake On Lan method of the display object
if ($display->macAddress == '' || $display->broadCastAddress == '') {
$this->log->error('This display has no mac address recorded against it yet. Make sure the display is running.');
$this->runMessage .= ' - ' . $display->display . ' Did not send MAC address yet' . PHP_EOL;
continue;
}
$this->log->notice('About to send WOL packet to ' . $display->broadCastAddress . ' with Mac Address ' . $display->macAddress);
try {
WakeOnLan::TransmitWakeOnLan($display->macAddress, $display->secureOn, $display->broadCastAddress, $display->cidr, '9', $this->log);
$this->runMessage .= ' - ' . $display->display . ' Sent WOL Message. Previous WOL send time: ' . Carbon::createFromTimestamp($display->lastWakeOnLanCommandSent)->format(DateFormatHelper::getSystemFormat()) . PHP_EOL;
$display->lastWakeOnLanCommandSent = Carbon::now()->format('U');
$display->save(['validate' => false, 'audit' => true]);
}
catch (\Exception $e) {
$this->runMessage .= ' - ' . $display->display . ' Error=' . $e->getMessage() . PHP_EOL;
}
}
else {
$this->runMessage .= ' - ' . $display->display . ' Display already awake. Previous WOL send time: ' . Carbon::createFromTimestamp($display->lastWakeOnLanCommandSent)->format(DateFormatHelper::getSystemFormat()) . PHP_EOL;
}
}
else {
$this->runMessage .= ' - ' . $display->display . ' Sleeping' . PHP_EOL;
}
}
$this->runMessage .= ' - Done' . PHP_EOL . PHP_EOL;
}
catch (\PDOException $e) {
$this->log->error($e->getMessage());
$this->runMessage .= ' - Error' . PHP_EOL . PHP_EOL;
}
}
/**
* Build layouts
*/
private function buildLayouts()
{
$this->runMessage .= '## ' . __('Build Layouts') . PHP_EOL;
// Build Layouts
// We do not want to build any draft Layouts - they are built in the Layout Designer or on Publish
foreach ($this->layoutFactory->query(null, ['status' => 3, 'showDrafts' => 0, 'disableUserCheck' => 1]) as $layout) {
/* @var \Xibo\Entity\Layout $layout */
try {
$layout = $this->layoutFactory->concurrentRequestLock($layout);
try {
$layout->xlfToDisk(['notify' => true]);
// Commit after each build
// https://github.com/xibosignage/xibo/issues/1593
$this->store->commitIfNecessary();
} finally {
$this->layoutFactory->concurrentRequestRelease($layout);
}
} catch (\Exception $e) {
$this->log->error(sprintf('Maintenance cannot build Layout %d, %s.', $layout->layoutId, $e->getMessage()));
}
}
$this->runMessage .= ' - Done' . PHP_EOL . PHP_EOL;
}
/**
* Tidy library
*/
private function tidyLibrary()
{
$this->runMessage .= '## ' . __('Tidy Library') . PHP_EOL;
// Keep tidy
$this->mediaService->removeExpiredFiles();
$this->mediaService->removeTempFiles();
$this->runMessage .= ' - Done' . PHP_EOL . PHP_EOL;
}
/**
* Check library usage
* @throws \Xibo\Support\Exception\NotFoundException
* @throws \Xibo\Support\Exception\InvalidArgumentException
*/
private function checkLibraryUsage()
{
$libraryLimit = $this->config->getSetting('LIBRARY_SIZE_LIMIT_KB') * 1024;
if ($libraryLimit <= 0) {
return;
}
$results = $this->store->select('SELECT IFNULL(SUM(FileSize), 0) AS SumSize FROM media', []);
$sanitizedResults = $this->getSanitizer($results);
$size = $sanitizedResults->getInt('SumSize');
if ($size >= $libraryLimit) {
// Create a notification if we don't already have one today for this display.
$subject = __('Library allowance exceeded');
$date = Carbon::now();
$notifications = $this->notificationFactory->getBySubjectAndDate(
$subject,
$date->startOfDay()->format('U'),
$date->addDay()->startOfDay()->format('U')
);
if (count($notifications) <= 0) {
$body = __(
sprintf(
'Library allowance of %s exceeded. Used %s',
ByteFormatter::format($libraryLimit),
ByteFormatter::format($size)
)
);
$notification = $this->notificationFactory->createSystemNotification(
$subject,
$body,
Carbon::now(),
'library'
);
$notification->save();
$this->log->critical($subject);
}
}
}
/**
* Checks to see if there are any overrequested files.
* @throws \Xibo\Support\Exception\NotFoundException
* @throws \Xibo\Support\Exception\InvalidArgumentException
*/
private function checkOverRequestedFiles()
{
$items = $this->store->select('
SELECT display.displayId,
display.display,
COUNT(*) AS countFiles
FROM `requiredfile`
INNER JOIN `display`
ON display.displayId = requiredfile.displayId
WHERE `bytesRequested` > 0
AND `requiredfile`.bytesRequested >= `requiredfile`.`size` * :factor
AND `requiredfile`.type NOT IN (\'W\', \'D\')
AND display.lastAccessed > :lastAccessed
AND `requiredfile`.complete = 0
GROUP BY display.displayId, display.display
', [
'factor' => 3,
'lastAccessed' => Carbon::now()->subDay()->format('U'),
]);
foreach ($items as $item) {
$sanitizedItem = $this->getSanitizer($item);
// Create a notification if we don't already have one today for this display.
$subject = sprintf(
__('%s is downloading %d files too many times'),
$sanitizedItem->getString('display'),
$sanitizedItem->getInt('countFiles')
);
$date = Carbon::now();
$notifications = $this->notificationFactory->getBySubjectAndDate(
$subject,
$date->startOfDay()->format('U'),
$date->addDay()->startOfDay()->format('U')
);
if (count($notifications) <= 0) {
$body = sprintf(
__('Please check the bandwidth graphs and display status for %s to investigate the issue.'),
$sanitizedItem->getString('display')
);
$notification = $this->notificationFactory->createSystemNotification(
$subject,
$body,
Carbon::now(),
'display'
);
$display = $this->displayFactory->getById($item['displayId']);
// Add in any displayNotificationGroups, with permissions
foreach ($this->userGroupFactory->getDisplayNotificationGroups($display->displayGroupId) as $group) {
$notification->assignUserGroup($group);
}
$notification->save();
$this->log->critical($subject);
}
}
}
/**
* Update Playlist Durations
*/
private function updatePlaylistDurations()
{
$this->runMessage .= '## ' . __('Playlist Duration Updates') . PHP_EOL;
// Build Layouts
foreach ($this->playlistFactory->query(null, ['requiresDurationUpdate' => 1]) as $playlist) {
try {
$playlist->setModuleFactory($this->moduleFactory);
$playlist->updateDuration();
} catch (GeneralException $xiboException) {
$this->log->error(
'Maintenance cannot update Playlist ' . $playlist->playlistId .
', ' . $xiboException->getMessage()
);
}
}
$this->runMessage .= ' - Done' . PHP_EOL . PHP_EOL;
}
/**
* Publish layouts with set publishedDate
* @throws GeneralException
*/
private function publishLayouts()
{
$this->runMessage .= '## ' . __('Publishing layouts with set publish dates') . PHP_EOL;
$layouts = $this->layoutFactory->query(
null,
['havePublishDate' => 1, 'disableUserCheck' => 1, 'excludeTemplates' => -1]
);
// check if we have any layouts with set publish date
if (count($layouts) > 0) {
foreach ($layouts as $layout) {
// check if the layout should be published now according to the date
if (Carbon::createFromFormat(DateFormatHelper::getSystemFormat(), $layout->publishedDate)
->isBefore(Carbon::now()->format(DateFormatHelper::getSystemFormat()))
) {
try {
// publish the layout
$layout = $this->layoutFactory->concurrentRequestLock($layout, true);
try {
$draft = $this->layoutFactory->getByParentId($layout->layoutId);
if ($draft->status === Status::$STATUS_INVALID
&& isset($draft->statusMessage)
&& (
count($draft->getStatusMessage()) > 1 ||
count($draft->getStatusMessage()) === 1 &&
!$draft->checkForEmptyRegion()
)
) {
throw new GeneralException(json_encode($draft->statusMessage));
}
$draft->publishDraft();
$draft->load();
$draft->xlfToDisk([
'notify' => true,
'exceptionOnError' => true,
'exceptionOnEmptyRegion' => false
]);
} finally {
$this->layoutFactory->concurrentRequestRelease($layout, true);
}
$this->log->info(
'Published layout ID ' . $layout->layoutId . ' new layout id is ' . $draft->layoutId
);
} catch (GeneralException $e) {
$this->log->error(
'Error publishing layout ID ' . $layout->layoutId .
' with name ' . $layout->layout . ' Failed with message: ' . $e->getMessage()
);
// create a notification
$subject = __(sprintf('Error publishing layout ID %d', $layout->layoutId));
$date = Carbon::now();
$notifications = $this->notificationFactory->getBySubjectAndDate(
$subject,
$date->startOfDay()->format('U'),
$date->addDay()->startOfDay()->format('U')
);
if (count($notifications) <= 0) {
$body = __(
sprintf(
'Publishing layout ID %d with name %s failed. With message %s',
$layout->layoutId,
$layout->layout,
$e->getMessage()
)
);
$notification = $this->notificationFactory->createSystemNotification(
$subject,
$body,
Carbon::now(),
'layout'
);
$notification->save();
$this->log->critical($subject);
}
}
} else {
$this->log->debug(
'Layouts with published date were found, they are set to publish later than current time'
);
}
}
} else {
$this->log->debug('No layouts to publish.');
}
$this->runMessage .= ' - Done' . PHP_EOL . PHP_EOL;
}
/**
* Assess any eligible dynamic display groups if necessary
* @return void
* @throws \Xibo\Support\Exception\NotFoundException
*/
private function assessDynamicDisplayGroups(): void
{
$this->runMessage .= '## ' . __('Assess Dynamic Display Groups') . PHP_EOL;
// Do we have a cache key set to say that dynamic display group assessment has been completed?
$cache = $this->pool->getItem('DYNAMIC_DISPLAY_GROUP_ASSESSED');
if ($cache->isMiss()) {
Profiler::start('RegularMaintenance::assessDynamicDisplayGroups', $this->log);
// Set the cache key with a long expiry and save.
$cache->set(true);
$cache->expiresAt(Carbon::now()->addYear());
$this->pool->save($cache);
// Process each dynamic display group
$count = 0;
foreach ($this->displayGroupFactory->getByIsDynamic(1) as $group) {
$count++;
try {
// Loads displays.
$this->getDispatcher()->dispatch(
new DisplayGroupLoadEvent($group),
DisplayGroupLoadEvent::$NAME
);
$group->save([
'validate' => false,
'saveGroup' => false,
'saveTags' => false,
'manageLinks' => false,
'manageDisplayLinks' => false,
'manageDynamicDisplayLinks' => true,
'allowNotify' => true
]);
} catch (GeneralException $exception) {
$this->log->error('assessDynamicDisplayGroups: Unable to manage group: '
. $group->displayGroup);
}
}
Profiler::end('RegularMaintenance::assessDynamicDisplayGroups', $this->log);
$this->runMessage .= ' - Done ' . $count . PHP_EOL . PHP_EOL;
} else {
$this->runMessage .= ' - Done (not required)' . PHP_EOL . PHP_EOL;
}
}
private function tidyAdCampaignSchedules()
{
$this->runMessage .= '## ' . __('Tidy Ad Campaign Schedules') . PHP_EOL;
Profiler::start('RegularMaintenance::tidyAdCampaignSchedules', $this->log);
$count = 0;
foreach ($this->scheduleFactory->query(null, [
'adCampaignsOnly' => 1,
'toDt' => Carbon::now()->subDays(90)->unix()
]) as $event) {
if (!empty($event->parentCampaignId)) {
$count++;
$this->log->debug('tidyAdCampaignSchedules : Found old Ad Campaign interrupt event ID '
. $event->eventId . ' deleting');
$event->delete(['notify' => false]);
}
}
$this->log->debug('tidyAdCampaignSchedules : Deleted ' . $count . ' events');
Profiler::end('RegularMaintenance::tidyAdCampaignSchedules', $this->log);
$this->runMessage .= ' - Done ' . $count . PHP_EOL . PHP_EOL;
}
/**
* Once per hour assert the current XMR to push its expiry time with XMR
* this also reseeds the key if XMR restarts
* @return void
*/
private function assertXmrKey(): void
{
$this->log->debug('assertXmrKey: asserting key');
try {
$key = $this->getConfig()->getSetting('XMR_CMS_KEY');
if (!empty($key)) {
$client = new Client($this->config->getGuzzleProxy([
'base_uri' => $this->getConfig()->getSetting('XMR_ADDRESS'),
]));
$client->post('/', [
'json' => [
'id' => constant('SECRET_KEY'),
'type' => 'keys',
'key' => $key,
],
]);
$this->log->debug('assertXmrKey: asserted key');
} else {
$this->log->error('assertXmrKey: key empty');
}
} catch (GuzzleException | \Exception $e) {
$this->log->error('assertXmrKey: failed. E = ' . $e->getMessage());
}
}
/**
* Deletes unused full screen layouts
* @throws NotFoundException
* @throws GeneralException
*/
private function tidyUnusedFullScreenLayout(): void
{
$this->runMessage .= '## ' . __('Tidy Unused FullScreen Layout') . PHP_EOL;
Profiler::start('RegularMaintenance::tidyUnusedFullScreenLayout', $this->log);
$count = 0;
foreach ($this->layoutFactory->query(null, [
'filterLayoutStatusId' => 3,
'isFullScreenCampaign' => 1
]) as $layout) {
$count++;
$this->log->debug('tidyUnusedFullScreenLayout : Found unused fullscreen layout ID '
. $layout->layoutId . ' deleting');
$layout->delete();
}
$this->log->debug('tidyUnusedFullScreenLayout : Deleted ' . $count . ' layouts');
Profiler::end('RegularMaintenance::tidyUnusedFullScreenLayout', $this->log);
$this->runMessage .= ' - Done ' . $count . PHP_EOL . PHP_EOL;
}
}

View File

@@ -0,0 +1,95 @@
<?php
/*
* Copyright (c) 2022 Xibo Signage Ltd
*
* Xibo - Digital Signage - http://www.xibo.org.uk
*
* This file is part of Xibo.
*
* Xibo is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Xibo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Xibo. If not, see <http://www.gnu.org/licenses/>.
*/
namespace Xibo\XTR;
use Xibo\Factory\MediaFactory;
class MediaOrientationTask implements TaskInterface
{
use TaskTrait;
/**
* @var MediaFactory
*/
private $mediaFactory;
/** @inheritdoc */
public function setFactories($container)
{
$this->mediaFactory = $container->get('mediaFactory');
return $this;
}
/** @inheritdoc */
public function run()
{
$this->runMessage = '# ' . __('Media Orientation') . PHP_EOL . PHP_EOL;
// Long running task
set_time_limit(0);
$this->setMediaOrientation();
}
private function setMediaOrientation()
{
$this->appendRunMessage('# Setting Media Orientation on Library Media files.');
// onlyMenuBoardAllowed filter means images and videos
$filesToCheck = $this->mediaFactory->query(null, ['requiresMetaUpdate' => 1, 'onlyMenuBoardAllowed' => 1]);
$count = 0;
foreach ($filesToCheck as $media) {
$count++;
$filePath = '';
$libraryFolder = $this->config->getSetting('LIBRARY_LOCATION');
if ($media->mediaType === 'image') {
$filePath = $libraryFolder . $media->storedAs;
} elseif ($media->mediaType === 'video' && file_exists($libraryFolder . $media->mediaId . '_videocover.png')) {
$filePath = $libraryFolder . $media->mediaId . '_videocover.png';
}
if (!empty($filePath)) {
list($imgWidth, $imgHeight) = @getimagesize($filePath);
$media->width = $imgWidth;
$media->height = $imgHeight;
$media->orientation = ($imgWidth >= $imgHeight) ? 'landscape' : 'portrait';
$media->save(['saveTags' => false, 'validate' => false]);
}
}
$this->appendRunMessage('Updated ' . $count . ' items');
$this->disableTask();
}
private function disableTask()
{
$this->appendRunMessage('# Disabling task.');
$this->log->debug('Disabling task.');
$this->getTask()->isActive = 0;
$this->getTask()->save();
$this->appendRunMessage(__('Done.'. PHP_EOL));
}
}

View File

@@ -0,0 +1,117 @@
<?php
/*
* Spring Signage Ltd - http://www.springsignage.com
* Copyright (C) 2016 Spring Signage Ltd
* (NotificationTidyTask.php)
*/
namespace Xibo\XTR;
use Carbon\Carbon;
/**
* Class NotificationTidyTask
* @package Xibo\XTR
*/
class NotificationTidyTask implements TaskInterface
{
use TaskTrait;
/** @inheritdoc */
public function setFactories($container)
{
// No factories required
return $this;
}
/** @inheritdoc */
public function run()
{
// Delete notifications older than X days
$maxAgeDays = intval($this->getOption('maxAgeDays', 7));
$systemOnly = intval($this->getOption('systemOnly', 1));
$readOnly = intval($this->getOption('readOnly', 0));
$this->runMessage = '# ' . __('Notification Tidy') . PHP_EOL . PHP_EOL;
$this->log->info('Deleting notifications older than ' . $maxAgeDays
. ' days. System Only: ' . $systemOnly
. '. Read Only' . $readOnly
);
// Where clause
$where = ' WHERE `releaseDt` < :releaseDt ';
if ($systemOnly == 1) {
$where .= ' AND `isSystem` = 1 ';
}
// Params for all deletes
$params = [
'releaseDt' => Carbon::now()->subDays($maxAgeDays)->format('U')
];
// Delete all notifications older than now minus X days
$sql = '
DELETE FROM `lknotificationdg`
WHERE `notificationId` IN (SELECT DISTINCT `notificationId` FROM `notification` ' . $where . ')
';
if ($readOnly == 1) {
$sql .= ' AND `notificationId` IN (SELECT `notificationId` FROM `lknotificationuser` WHERE read <> 0) ';
}
$this->store->update($sql, $params);
// Delete notification groups
$sql = '
DELETE FROM `lknotificationgroup`
WHERE `notificationId` IN (SELECT DISTINCT `notificationId` FROM `notification` ' . $where . ')
';
if ($readOnly == 1) {
$sql .= ' AND `notificationId` IN (SELECT `notificationId` FROM `lknotificationuser` WHERE read <> 0) ';
}
$this->store->update($sql, $params);
// Delete from notification user
$sql = '
DELETE FROM `lknotificationuser`
WHERE `notificationId` IN (SELECT DISTINCT `notificationId` FROM `notification` ' . $where . ')
';
if ($readOnly == 1) {
$sql .= ' AND `read` <> 0 ';
}
$this->store->update($sql, $params);
// Remove the attached file
$sql = 'SELECT filename FROM `notification` ' . $where;
foreach ($this->store->select($sql, $params) as $row) {
$filename = $row['filename'];
/*Delete the attachment*/
if (!empty($filename)) {
// Library location
$attachmentLocation = $this->config->getSetting('LIBRARY_LOCATION'). 'attachment/';
if (file_exists($attachmentLocation . $filename)) {
unlink($attachmentLocation . $filename);
}
}
}
// Delete from notification
$sql = 'DELETE FROM `notification` ' . $where;
if ($readOnly == 1) {
$sql .= ' AND `notificationId` NOT IN (SELECT `notificationId` FROM `lknotificationuser`) ';
}
$this->store->update($sql, $params);
$this->runMessage .= __('Done') . PHP_EOL . PHP_EOL;
}
}

View File

@@ -0,0 +1,61 @@
<?php
/**
* Copyright (C) 2021 Xibo Signage Ltd
*
* Xibo - Digital Signage - http://www.xibo.org.uk
*
* This file is part of Xibo.
*
* Xibo is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Xibo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Xibo. If not, see <http://www.gnu.org/licenses/>.
*/
namespace Xibo\XTR;
use Carbon\Carbon;
use Xibo\Helper\DateFormatHelper;
class PurgeListCleanupTask implements TaskInterface
{
use TaskTrait;
/** @inheritdoc */
public function setFactories($container)
{
$this->sanitizerService = $container->get('sanitizerService');
$this->store = $container->get('store');
$this->config = $container->get('configService');
return $this;
}
/** @inheritdoc */
public function run()
{
$this->tidyPurgeList();
}
public function tidyPurgeList()
{
$this->runMessage = '# ' . __('Purge List Cleanup Start') . PHP_EOL . PHP_EOL;
$count = $this->store->update('DELETE FROM `purge_list` WHERE expiryDate < :expiryDate', [
'expiryDate' => Carbon::now()->format(DateFormatHelper::getSystemFormat())
]);
if ($count <= 0) {
$this->appendRunMessage('# ' . __('Nothing to remove') . PHP_EOL . PHP_EOL);
} else {
$this->appendRunMessage('# ' . sprintf(__('Removed %d rows'), $count) . PHP_EOL . PHP_EOL);
}
}
}

View File

@@ -0,0 +1,280 @@
<?php
/*
* Copyright (C) 2023 Xibo Signage Ltd
*
* Xibo - Digital Signage - https://xibosignage.com
*
* This file is part of Xibo.
*
* Xibo is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Xibo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Xibo. If not, see <http://www.gnu.org/licenses/>.
*/
namespace Xibo\XTR;
use Carbon\Carbon;
use Xibo\Entity\DataSet;
use Xibo\Factory\DataSetFactory;
use Xibo\Factory\NotificationFactory;
use Xibo\Factory\UserFactory;
use Xibo\Factory\UserGroupFactory;
use Xibo\Support\Exception\GeneralException;
/**
* Class RemoteDataSetFetchTask
* @package Xibo\XTR
*/
class RemoteDataSetFetchTask implements TaskInterface
{
use TaskTrait;
/** @var DataSetFactory */
private $dataSetFactory;
/** @var NotificationFactory */
private $notificationFactory;
/** @var UserFactory */
private $userFactory;
/** @var UserGroupFactory */
private $userGroupFactory;
/** @inheritdoc */
public function setFactories($container)
{
$this->dataSetFactory = $container->get('dataSetFactory');
$this->notificationFactory = $container->get('notificationFactory');
$this->userFactory = $container->get('userFactory');
$this->userGroupFactory = $container->get('userGroupFactory');
return $this;
}
/**
* @inheritdoc
*/
public function run()
{
$this->runMessage = '# ' . __('Fetching Remote-DataSets') . PHP_EOL . PHP_EOL;
$runTime = Carbon::now()->format('U');
/** @var DataSet $dataSet */
$dataSet = null;
// Process all Remote DataSets (and their dependants)
$dataSets = $this->orderDataSetsByDependency($this->dataSetFactory->query(null, ['isRemote' => 1]));
// Log the order.
$this->log->debug(
'Order of processing: ' . json_encode(array_map(function ($element) {
return $element->dataSetId . ' - ' . $element->runsAfter;
}, $dataSets))
);
// Reorder this list according to which order we want to run in
foreach ($dataSets as $dataSet) {
$this->log->debug('Processing ' . $dataSet->dataSet . '. ID:' . $dataSet->dataSetId);
$hardRowLimit = $this->config->getSetting('DATASET_HARD_ROW_LIMIT');
$softRowLimit = $dataSet->rowLimit;
$limitPolicy = $dataSet->limitPolicy;
$currentNumberOfRows = intval($this->store->select('SELECT COUNT(*) AS total FROM `dataset_' . $dataSet->dataSetId . '`', [])[0]['total']);
try {
// Has this dataSet been accessed recently?
if (!$dataSet->isActive()) {
// Skipping dataSet due to it not being accessed recently
$this->log->info('Skipping dataSet ' . $dataSet->dataSetId . ' due to it not being accessed recently');
continue;
}
// Get all columns
$columns = $dataSet->getColumn();
// Filter columns where dataSetColumnType is "Remote"
$filteredColumns = array_filter($columns, function ($column) {
return $column->dataSetColumnTypeId == '3';
});
// Check if there are any remote columns defined in the dataset
if (count($filteredColumns) === 0) {
$this->log->info('Skipping dataSet ' . $dataSet->dataSetId . ': No remote columns defined in the dataset.');
continue;
}
$this->log->debug('Comparing run time ' . $runTime . ' to next sync time ' . $dataSet->getNextSyncTime());
if ($runTime >= $dataSet->getNextSyncTime()) {
// Getting the dependant DataSet to process the current DataSet on
$dependant = null;
if ($dataSet->runsAfter != null && $dataSet->runsAfter != $dataSet->dataSetId) {
$dependant = $this->dataSetFactory->getById($dataSet->runsAfter);
}
$this->log->debug('Fetch and process ' . $dataSet->dataSet);
$results = $this->dataSetFactory->callRemoteService($dataSet, $dependant);
if ($results->number > 0) {
// Truncate only if we also fetch new Data
if ($dataSet->isTruncateEnabled()
&& $results->isEligibleToTruncate
&& $runTime >= $dataSet->getNextClearTime()
) {
$this->log->debug('Truncate ' . $dataSet->dataSet);
$dataSet->deleteData();
// Update the last clear time.
$dataSet->saveLastClear($runTime);
}
$rowsToAdd = $results->number;
$this->log->debug('Current number of rows in DataSet ID ' . $dataSet->dataSetId . ' is: ' . $currentNumberOfRows . ' number of records to add ' . $rowsToAdd);
// row limit reached
if ($currentNumberOfRows + $rowsToAdd >= $hardRowLimit || $softRowLimit != null && $currentNumberOfRows + $rowsToAdd >= $softRowLimit) {
// handle remote DataSets created before introduction of limit policy
if ($limitPolicy == null) {
$this->log->debug('No limit policy set, default to stop syncing.');
$limitPolicy = 'stop';
}
// which limit policy was set?
if ($limitPolicy === 'stop') {
$this->log->info('DataSet ID ' . $dataSet->dataSetId . ' reached the row limit, due to selected limit policy, it will stop syncing');
continue;
} elseif ($limitPolicy === 'fifo') {
// FiFo
$this->log->info('DataSet ID ' . $dataSet->dataSetId . ' reached the row limit, due to selected limit policy, oldest rows will be removed');
$this->store->update('DELETE FROM `dataset_' . $dataSet->dataSetId . '` ORDER BY id ASC LIMIT ' . $rowsToAdd, []);
} elseif ($limitPolicy === 'truncate') {
// truncate
$this->log->info('DataSet ID ' . $dataSet->dataSetId . ' reached the row limit, due to selected limit policy, we will truncate the DataSet data');
$dataSet->deleteData();
// Update the last clear time.
$dataSet->saveLastClear($runTime);
}
}
if ($dataSet->sourceId === 1) {
$this->dataSetFactory->processResults($dataSet, $results);
} else {
$this->dataSetFactory->processCsvEntries($dataSet, $results);
}
// notify here
$dataSet->notify();
} else if ($dataSet->truncateOnEmpty
&& $results->isEligibleToTruncate
&& $dataSet->isTruncateEnabled()
&& $runTime >= $dataSet->getNextClearTime()
) {
$this->log->debug('Truncate ' . $dataSet->dataSet);
$dataSet->deleteData();
// Update the last clear time.
$dataSet->saveLastClear($runTime);
$this->appendRunMessage(__('No results for %s, truncate with no new data enabled', $dataSet->dataSet));
} else {
$this->appendRunMessage(__('No results for %s', $dataSet->dataSet));
}
$dataSet->saveLastSync($runTime);
} else {
$this->log->debug('Sync not required for ' . $dataSet->dataSetId);
}
} catch (GeneralException $e) {
$this->appendRunMessage(__('Error syncing DataSet %s', $dataSet->dataSet));
$this->log->error('Error syncing DataSet ' . $dataSet->dataSetId . '. E = ' . $e->getMessage());
$this->log->debug($e->getTraceAsString());
// Send a notification to the dataSet owner, informing them of the failure.
$notification = $this->notificationFactory->createEmpty();
$notification->subject = __('Remote DataSet %s failed to synchronise', $dataSet->dataSet);
$notification->body = 'The error is: ' . $e->getMessage();
$notification->createDt = Carbon::now()->format('U');
$notification->releaseDt = $notification->createDt;
$notification->isInterrupt = 0;
$notification->userId = $this->user->userId;
$notification->type = 'dataset';
// Assign me
$dataSetUser = $this->userFactory->getById($dataSet->userId);
$notification->assignUserGroup($this->userGroupFactory->getById($dataSetUser->groupId));
// Send
$notification->save();
// You might say at this point that if there are other data sets further down the list, we shouldn't
// continue because they might depend directly on this one
// however, it is my opinion that they should be processed anyway with the current cache of data.
// hence continue
}
}
$this->appendRunMessage(__('Done'));
}
/**
* Order the list of DataSets to be processed so that it is dependent aware.
*
* @param DataSet[] $dataSets Reference to an Array which holds all not yet processed DataSets
* @return DataSet[] Ordered list of DataSets to process
*
*
* What is going on here: RemoteDataSets can depend on others, so we have to be sure to fetch
* the data from the dependant first.
* For Example (id, dependant): (1,4), (2,3), (3,4), (4,1), (5,2), (6,6)
* Should be processed like: 4, 1, 3, 2, 5, 6
*
*/
private function orderDataSetsByDependency(array $dataSets)
{
// DataSets are in no particular order
// sort them according to their dependencies
usort($dataSets, function ($a, $b) {
/** @var DataSet $a */
/** @var DataSet $b */
// if a doesn't have a dependent, then a must be lower in the list (move b up)
if ($a->runsAfter === null) {
return -1;
}
// if b doesn't have a dependent, then a must be higher in the list (move b down)
if ($b->runsAfter === null) {
return 1;
}
// either a or b have a dependent
// if they are the same, keep them where they are
if ($a->runsAfter === $b->runsAfter) {
return 0;
}
// the dependents are different.
// if a depends on b, then move b up
if ($a->runsAfter === $b->dataSetId) {
return -1;
}
// if b depends on a, then move b down
if ($b->runsAfter === $a->dataSetId) {
return 1;
}
// Unsorted
return 0;
});
// Process in reverse order (LastIn-FirstOut)
return array_reverse($dataSets);
}
}

View File

@@ -0,0 +1,73 @@
<?php
/**
* Copyright (C) 2020 Xibo Signage Ltd
*
* Xibo - Digital Signage - http://www.xibo.org.uk
*
* This file is part of Xibo.
*
* Xibo is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Xibo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Xibo. If not, see <http://www.gnu.org/licenses/>.
*/
namespace Xibo\XTR;
use Carbon\Carbon;
use Xibo\Factory\MediaFactory;
class RemoveOldScreenshotsTask implements TaskInterface
{
use TaskTrait;
/** @var MediaFactory */
private $mediaFactory;
/** @inheritdoc */
public function setFactories($container)
{
$this->mediaFactory = $container->get('mediaFactory');
return $this;
}
/** @inheritdoc */
public function run()
{
$this->runMessage = '# ' . __('Remove Old Screenshots') . PHP_EOL . PHP_EOL;
$screenshotLocation = $this->config->getSetting('LIBRARY_LOCATION') . 'screenshots/';
$screenshotTTL = $this->config->getSetting('DISPLAY_SCREENSHOT_TTL');
$count = 0;
if ($screenshotTTL > 0) {
foreach (array_diff(scandir($screenshotLocation), ['..', '.']) as $file) {
$fileLocation = $screenshotLocation . $file;
$lastModified = Carbon::createFromTimestamp(filemtime($fileLocation));
$now = Carbon::now();
$diff = $now->diffInDays($lastModified);
if ($diff > $screenshotTTL) {
unlink($fileLocation);
$count++;
$this->log->debug('Removed old Display screenshot:' . $file);
}
}
$this->appendRunMessage(sprintf(__('Removed %d old Display screenshots'), $count));
} else {
$this->appendRunMessage(__('Display Screenshot Time to keep set to 0, nothing to remove.'));
}
}
}

View File

@@ -0,0 +1,371 @@
<?php
/*
* Copyright (C) 2024 Xibo Signage Ltd
*
* Xibo - Digital Signage - https://xibosignage.com
*
* This file is part of Xibo.
*
* Xibo is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Xibo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Xibo. If not, see <http://www.gnu.org/licenses/>.
*/
namespace Xibo\XTR;
use Carbon\Carbon;
use Mpdf\Mpdf;
use Mpdf\Output\Destination;
use Slim\Views\Twig;
use Xibo\Entity\ReportResult;
use Xibo\Factory\MediaFactory;
use Xibo\Factory\NotificationFactory;
use Xibo\Factory\ReportScheduleFactory;
use Xibo\Factory\SavedReportFactory;
use Xibo\Factory\UserFactory;
use Xibo\Factory\UserGroupFactory;
use Xibo\Service\MediaService;
use Xibo\Service\ReportServiceInterface;
use Xibo\Support\Exception\InvalidArgumentException;
/**
* Class ReportScheduleTask
* @package Xibo\XTR
*/
class ReportScheduleTask implements TaskInterface
{
use TaskTrait;
/** @var Twig */
private $view;
/** @var MediaFactory */
private $mediaFactory;
/** @var SavedReportFactory */
private $savedReportFactory;
/** @var UserGroupFactory */
private $userGroupFactory;
/** @var UserFactory */
private $userFactory;
/** @var ReportScheduleFactory */
private $reportScheduleFactory;
/** @var ReportServiceInterface */
private $reportService;
/** @var NotificationFactory */
private $notificationFactory;
/** @inheritdoc */
public function setFactories($container)
{
$this->view = $container->get('view');
$this->userFactory = $container->get('userFactory');
$this->mediaFactory = $container->get('mediaFactory');
$this->savedReportFactory = $container->get('savedReportFactory');
$this->userGroupFactory = $container->get('userGroupFactory');
$this->reportScheduleFactory = $container->get('reportScheduleFactory');
$this->reportService = $container->get('reportService');
$this->notificationFactory = $container->get('notificationFactory');
return $this;
}
/** @inheritdoc */
public function run()
{
$this->runMessage = '# ' . __('Report schedule') . PHP_EOL . PHP_EOL;
// Long running task
set_time_limit(0);
$this->runReportSchedule();
}
/**
* Run report schedule
* @throws InvalidArgumentException
* @throws \Xibo\Support\Exception\ConfigurationException
* @throws \Xibo\Support\Exception\NotFoundException
*/
private function runReportSchedule()
{
$libraryFolder = $this->getConfig()->getSetting('LIBRARY_LOCATION');
// Make sure the library exists
MediaService::ensureLibraryExists($libraryFolder);
$reportSchedules = $this->reportScheduleFactory->query(null, ['isActive' => 1, 'disableUserCheck' => 1]);
// Get list of ReportSchedule
foreach ($reportSchedules as $reportSchedule) {
$cron = \Cron\CronExpression::factory($reportSchedule->schedule);
$nextRunDt = $cron->getNextRunDate(\DateTime::createFromFormat('U', $reportSchedule->lastRunDt))->format('U');
$now = Carbon::now()->format('U');
// if report start date is greater than now
// then dont run the report schedule
if ($reportSchedule->fromDt > $now) {
$this->log->debug('Report schedule start date is in future '. $reportSchedule->fromDt);
continue;
}
// if report end date is less than or equal to now
// then disable report schedule
if ($reportSchedule->toDt != 0 && $reportSchedule->toDt <= $now) {
$reportSchedule->message = 'Report schedule end date has passed';
$reportSchedule->isActive = 0;
}
if ($nextRunDt <= $now && $reportSchedule->isActive) {
// random run of report schedules
$skip = $this->skipReportRun($now, $nextRunDt);
if ($skip == true) {
continue;
}
// execute the report
$reportSchedule->previousRunDt = $reportSchedule->lastRunDt;
$reportSchedule->lastRunDt = Carbon::now()->format('U');
$this->log->debug('Last run date is updated to '. $reportSchedule->lastRunDt);
try {
// Get the generated saved as report name
$saveAs = $this->reportService->generateSavedReportName(
$reportSchedule->reportName,
$reportSchedule->filterCriteria
);
// Run the report to get results
// pass in the user who saved the report
$result = $this->reportService->runReport(
$reportSchedule->reportName,
$reportSchedule->filterCriteria,
$this->userFactory->getById($reportSchedule->userId)
);
$this->log->debug(__('Run report results: %s.', json_encode($result, JSON_PRETTY_PRINT)));
// Save the result in a json file
$fileName = tempnam($this->config->getSetting('LIBRARY_LOCATION') . '/temp/', 'reportschedule');
$out = fopen($fileName, 'w');
fwrite($out, json_encode($result));
fclose($out);
$savedReportFileName = 'rs_'.$reportSchedule->reportScheduleId. '_'. Carbon::now()->format('U');
// Create a ZIP file and add our temporary file
$zipName = $this->config->getSetting('LIBRARY_LOCATION') . 'savedreport/'.$savedReportFileName.'.zip';
$zip = new \ZipArchive();
$result = $zip->open($zipName, \ZipArchive::CREATE | \ZipArchive::OVERWRITE);
if ($result !== true) {
throw new InvalidArgumentException(__('Can\'t create ZIP. Error Code: %s', $result));
}
$zip->addFile($fileName, 'reportschedule.json');
$zip->close();
// Remove the JSON file
unlink($fileName);
// Save Saved report
$savedReport = $this->savedReportFactory->create(
$saveAs,
$reportSchedule->reportScheduleId,
Carbon::now()->format('U'),
$reportSchedule->userId,
$savedReportFileName.'.zip',
filesize($zipName),
md5_file($zipName)
);
$savedReport->save();
$this->createPdfAndNotification($reportSchedule, $savedReport);
// Add the last savedreport in Report Schedule
$this->log->debug('Last savedReportId in Report Schedule: '. $savedReport->savedReportId);
$reportSchedule->lastSavedReportId = $savedReport->savedReportId;
$reportSchedule->message = null;
} catch (\Exception $error) {
$reportSchedule->isActive = 0;
$reportSchedule->message = $error->getMessage();
$this->log->error('Error: ' . $error->getMessage());
}
}
// Finally save schedule report
$reportSchedule->save();
}
}
/**
* Create the PDF and save a notification
* @param $reportSchedule
* @param $savedReport
* @throws \Twig\Error\LoaderError
* @throws \Twig\Error\RuntimeError
* @throws \Twig\Error\SyntaxError
* @throws \Xibo\Support\Exception\GeneralException
*/
private function createPdfAndNotification($reportSchedule, $savedReport)
{
/* @var ReportResult $savedReportData */
$savedReportData = $this->reportService->getSavedReportResults(
$savedReport->savedReportId,
$reportSchedule->reportName
);
// Get the report config
$report = $this->reportService->getReportByName($reportSchedule->reportName);
if ($report->output_type == 'both' || $report->output_type == 'chart') {
$quickChartUrl = $this->config->getSetting('QUICK_CHART_URL');
if (!empty($quickChartUrl)) {
$quickChartUrl .= '/chart?width=1000&height=300&c=';
$chartScript = $this->reportService->getReportChartScript(
$savedReport->savedReportId,
$reportSchedule->reportName
);
// Replace " with ' for the quick chart URL
$src = $quickChartUrl . str_replace('"', '\'', $chartScript);
// If multiple charts needs to be displayed
$multipleCharts = [];
$chartScriptArray = json_decode($chartScript, true);
foreach ($chartScriptArray as $key => $chartData) {
$multipleCharts[$key] = $quickChartUrl . str_replace('"', '\'', json_encode($chartData));
}
} else {
$placeholder = __('Chart could not be drawn because the CMS has not been configured with a Quick Chart URL.');
}
}
if ($report->output_type == 'both' || $report->output_type == 'table') {
$tableData = $savedReportData->table;
}
// Get report email template
$emailTemplate = $this->reportService->getReportEmailTemplate($reportSchedule->reportName);
if (!empty($emailTemplate)) {
// Save PDF attachment
ob_start();
echo $this->view->fetch(
$emailTemplate,
[
'header' => $report->description,
'logo' => $this->config->uri('img/xibologo.png', true),
'title' => $savedReport->saveAs,
'metadata' => $savedReportData->metadata,
'tableData' => $tableData ?? null,
'src' => $src ?? null,
'multipleCharts' => $multipleCharts ?? null,
'placeholder' => $placeholder ?? null
]
);
$body = ob_get_contents();
ob_end_clean();
try {
$mpdf = new Mpdf([
'tempDir' => $this->config->getSetting('LIBRARY_LOCATION') . '/temp',
'orientation' => 'L',
'mode' => 'c',
'margin_left' => 20,
'margin_right' => 20,
'margin_top' => 20,
'margin_bottom' => 20,
'margin_header' => 5,
'margin_footer' => 15
]);
$mpdf->setFooter('Page {PAGENO}') ;
$mpdf->SetDisplayMode('fullpage');
$stylesheet = file_get_contents($this->config->uri('css/email-report.css', true));
$mpdf->WriteHTML($stylesheet, 1);
$mpdf->WriteHTML($body);
$mpdf->Output(
$this->config->getSetting('LIBRARY_LOCATION') . 'attachment/filename-'.$savedReport->savedReportId.'.pdf',
Destination::FILE
);
// Create email notification with attachment
$filters = json_decode($reportSchedule->filterCriteria, true);
$sendEmail = $filters['sendEmail'] ?? null;
$nonusers = $filters['nonusers'] ?? null;
if ($sendEmail) {
$notification = $this->notificationFactory->createEmpty();
$notification->subject = $report->description;
$notification->body = __('Attached please find the report for %s', $savedReport->saveAs);
$notification->createDt = Carbon::now()->format('U');
$notification->releaseDt = Carbon::now()->format('U');
$notification->isInterrupt = 0;
$notification->userId = $savedReport->userId; // event owner
$notification->filename = 'filename-'.$savedReport->savedReportId.'.pdf';
$notification->originalFileName = 'saved_report.pdf';
$notification->nonusers = $nonusers;
$notification->type = 'report';
// Get user group to create user notification
$notificationUser = $this->userFactory->getById($savedReport->userId);
$notification->assignUserGroup($this->userGroupFactory->getById($notificationUser->groupId));
$notification->save();
}
} catch (\Exception $error) {
$this->log->error($error->getMessage());
$this->runMessage .= $error->getMessage() . PHP_EOL . PHP_EOL;
}
}
}
private function skipReportRun($now, $nextRunDt)
{
$fourHoursInSeconds = 4 * 3600;
$threeHoursInSeconds = 3 * 3600;
$twoHoursInSeconds = 2 * 3600;
$oneHourInSeconds = 1 * 3600;
$diffFromNow = $now - $nextRunDt;
$range = 100;
$random = rand(1, $range);
if ($diffFromNow < $oneHourInSeconds) {
// don't run the report
if ($random <= 70) { // 70% chance of skipping
return true;
}
} elseif ($diffFromNow < $twoHoursInSeconds) {
// don't run the report
if ($random <= 50) { // 50% chance of skipping
return true;
}
} elseif ($diffFromNow < $threeHoursInSeconds) {
// don't run the report
if ($random <= 40) { // 40% chance of skipping
return true;
}
} elseif ($diffFromNow < $fourHoursInSeconds) {
// don't run the report
if ($random <= 25) { // 25% chance of skipping
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,241 @@
<?php
/*
* Copyright (C) 2023 Xibo Signage Ltd
*
* Xibo - Digital Signage - https://xibosignage.com
*
* This file is part of Xibo.
*
* Xibo is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Xibo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Xibo. If not, see <http://www.gnu.org/licenses/>.
*/
namespace Xibo\XTR;
use Carbon\Carbon;
use Xibo\Entity\ScheduleReminder;
use Xibo\Factory\CampaignFactory;
use Xibo\Factory\NotificationFactory;
use Xibo\Factory\ScheduleFactory;
use Xibo\Factory\ScheduleReminderFactory;
use Xibo\Factory\UserFactory;
use Xibo\Factory\UserGroupFactory;
use Xibo\Support\Exception\NotFoundException;
/**
* Class ScheduleReminderTask
* @package Xibo\XTR
*/
class ScheduleReminderTask implements TaskInterface
{
use TaskTrait;
/** @var UserFactory */
private $userFactory;
/** @var ScheduleFactory */
private $scheduleFactory;
/** @var CampaignFactory */
private $campaignFactory;
/** @var ScheduleReminderFactory */
private $scheduleReminderFactory;
/** @var NotificationFactory */
private $notificationFactory;
/** @var UserGroupFactory */
private $userGroupFactory;
/** @inheritdoc */
public function setFactories($container)
{
$this->userFactory = $container->get('userFactory');
$this->scheduleFactory = $container->get('scheduleFactory');
$this->campaignFactory = $container->get('campaignFactory');
$this->scheduleReminderFactory = $container->get('scheduleReminderFactory');
$this->notificationFactory = $container->get('notificationFactory');
$this->userGroupFactory = $container->get('userGroupFactory');
return $this;
}
/** @inheritdoc */
public function run()
{
$this->runMessage = '# ' . __('Schedule reminder') . PHP_EOL . PHP_EOL;
$this->runScheduleReminder();
}
/**
*
*/
private function runScheduleReminder()
{
$task = $this->getTask();
$nextRunDate = $task->nextRunDate();
$task->lastRunDt = Carbon::now()->format('U');
$task->save();
// Get those reminders that have reminderDt <= nextRunDate && reminderDt > lastReminderDt
// Those which have reminderDt < lastReminderDt exclude them
$reminders = $this->scheduleReminderFactory->getDueReminders($nextRunDate);
foreach($reminders as $reminder) {
// Get the schedule
$schedule = $this->scheduleFactory->getById($reminder->eventId);
$schedule->setCampaignFactory($this->campaignFactory);
$title = $schedule->getEventTitle();
switch ($reminder->type) {
case ScheduleReminder::$TYPE_MINUTE:
$type = ScheduleReminder::$MINUTE;
$typeText = 'Minute(s)';
break;
case ScheduleReminder::$TYPE_HOUR:
$type = ScheduleReminder::$HOUR;
$typeText = 'Hour(s)';
break;
case ScheduleReminder::$TYPE_DAY:
$type = ScheduleReminder::$DAY;
$typeText = 'Day(s)';
break;
case ScheduleReminder::$TYPE_WEEK:
$type = ScheduleReminder::$WEEK;
$typeText = 'Week(s)';
break;
case ScheduleReminder::$TYPE_MONTH:
$type = ScheduleReminder::$MONTH;
$typeText = 'Month(s)';
break;
default:
$this->log->error('Unknown schedule reminder type has been provided');
continue 2;
}
switch ($reminder->option) {
case ScheduleReminder::$OPTION_BEFORE_START:
$typeOptionText = 'starting';
break;
case ScheduleReminder::$OPTION_AFTER_START:
$typeOptionText = 'started';
break;
case ScheduleReminder::$OPTION_BEFORE_END:
$typeOptionText = 'ending';
break;
case ScheduleReminder::$OPTION_AFTER_END:
$typeOptionText = 'ended';
break;
default:
$this->log->error('Unknown schedule reminder option has been provided');
continue 2;
}
// Create a notification
$subject = sprintf(__("Reminder for %s"), $title);
if ($reminder->option == ScheduleReminder::$OPTION_BEFORE_START || $reminder->option == ScheduleReminder::$OPTION_BEFORE_END) {
$body = sprintf(__("The event (%s) is %s in %d %s"), $title, $typeOptionText, $reminder->value, $typeText);
} elseif ($reminder->option == ScheduleReminder::$OPTION_AFTER_START || $reminder->option == ScheduleReminder::$OPTION_AFTER_END) {
$body = sprintf(__("The event (%s) has %s %d %s ago"), $title, $typeOptionText, $reminder->value, $typeText);
}
// Is this schedule a recurring event?
if ($schedule->recurrenceType != '') {
$now = Carbon::now();
$remindSeconds = $reminder->value * $type;
// Get the next reminder date
$nextReminderDate = 0;
try {
$nextReminderDate = $schedule->getNextReminderDate($now, $reminder, $remindSeconds);
} catch (NotFoundException $error) {
$this->log->error('No next occurrence of reminderDt found.');
}
$i = 0;
$lastReminderDate = $reminder->reminderDt;
while ($nextReminderDate != 0 && $nextReminderDate < $nextRunDate) {
// Keep the last reminder date
$lastReminderDate = $nextReminderDate;
$now = Carbon::createFromTimestamp($nextReminderDate + 1);
try {
$nextReminderDate = $schedule->getNextReminderDate($now, $reminder, $remindSeconds);
} catch (NotFoundException $error) {
$nextReminderDate = 0;
$this->log->debug('No next occurrence of reminderDt found. ReminderDt set to 0.');
}
$this->createNotification($subject, $body, $reminder, $schedule, $lastReminderDate);
$i++;
}
if ($i == 0) {
// Create only 1 notification as the next event is outside the nextRunDt
$this->createNotification($subject, $body, $reminder, $schedule, $reminder->reminderDt);
$this->log->debug('Create only 1 notification as the next event is outside the nextRunDt.');
} else {
$this->log->debug($i. ' notifications created.');
}
$reminder->reminderDt = $nextReminderDate;
$reminder->lastReminderDt = $lastReminderDate;
$reminder->save();
} else { // one-off event
$this->createNotification($subject, $body, $reminder, $schedule, $reminder->reminderDt);
// Current reminderDt will be used as lastReminderDt
$reminder->lastReminderDt = $reminder->reminderDt;
}
// Save
$reminder->save();
}
}
/**
* @param $subject
* @param $body
* @param $reminder
* @param $schedule
* @param null $releaseDt
* @throws NotFoundException
* @throws \Xibo\Support\Exception\InvalidArgumentException
*/
private function createNotification($subject, $body, $reminder, $schedule, $releaseDt = null) {
$notification = $this->notificationFactory->createEmpty();
$notification->subject = $subject;
$notification->body = $body;
$notification->createDt = Carbon::now()->format('U');
$notification->releaseDt = $releaseDt;
$notification->isInterrupt = 0;
$notification->userId = $schedule->userId; // event owner
$notification->type = 'schedule';
// Get user group to create user notification
$notificationUser = $this->userFactory->getById($schedule->userId);
$notification->assignUserGroup($this->userGroupFactory->getById($notificationUser->groupId));
$notification->save();
}
}

View File

@@ -0,0 +1,923 @@
<?php
/*
* Copyright (C) 2023 Xibo Signage Ltd
*
* Xibo - Digital Signage - https://xibosignage.com
*
* This file is part of Xibo.
*
* Xibo is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Xibo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Xibo. If not, see <http://www.gnu.org/licenses/>.
*/
namespace Xibo\XTR;
use Carbon\Carbon;
use Exception;
use Xibo\Entity\Display;
use Xibo\Entity\Schedule;
use Xibo\Factory\CampaignFactory;
use Xibo\Factory\CommandFactory;
use Xibo\Factory\DataSetColumnFactory;
use Xibo\Factory\DataSetFactory;
use Xibo\Factory\DisplayFactory;
use Xibo\Factory\DisplayGroupFactory;
use Xibo\Factory\FolderFactory;
use Xibo\Factory\FontFactory;
use Xibo\Factory\LayoutFactory;
use Xibo\Factory\MediaFactory;
use Xibo\Factory\ModuleFactory;
use Xibo\Factory\ScheduleFactory;
use Xibo\Factory\SyncGroupFactory;
use Xibo\Factory\TaskFactory;
use Xibo\Factory\UserFactory;
use Xibo\Factory\UserGroupFactory;
use Xibo\Factory\WidgetFactory;
use Xibo\Helper\Random;
use Xibo\Service\MediaServiceInterface;
use Xibo\Support\Exception\DuplicateEntityException;
use Xibo\Support\Exception\GeneralException;
use Xibo\Support\Exception\InvalidArgumentException;
use Xibo\Support\Exception\NotFoundException;
/**
* Class SeedDatabaseTask
* Run only once, by default disabled
* @package Xibo\XTR
*/
class SeedDatabaseTask implements TaskInterface
{
use TaskTrait;
private ModuleFactory $moduleFactory;
private WidgetFactory $widgetFactory;
private LayoutFactory $layoutFactory;
private CampaignFactory $campaignFactory;
private TaskFactory $taskFactory;
private DisplayFactory $displayFactory;
private DataSetFactory $dataSetFactory;
private DataSetColumnFactory $dataSetColumnFactory;
private SyncGroupFactory $syncGroupFactory;
private ScheduleFactory $scheduleFactory;
private UserFactory $userFactory;
private UserGroupFactory $userGroupFactory;
private FontFactory $fontFactory;
private MediaServiceInterface $mediaService;
/** @var array The cache for layout */
private array $layoutCache = [];
private FolderFactory $folderFactory;
private CommandFactory $commandFactory;
private DisplayGroupFactory $displayGroupFactory;
private MediaFactory $mediaFactory;
private array $displayGroups;
private array $displays;
private array $layouts;
private array $parentCampaigns = [];
private array $syncGroups;
/** @inheritdoc */
public function setFactories($container)
{
$this->moduleFactory = $container->get('moduleFactory');
$this->widgetFactory = $container->get('widgetFactory');
$this->layoutFactory = $container->get('layoutFactory');
$this->campaignFactory = $container->get('campaignFactory');
$this->taskFactory = $container->get('taskFactory');
$this->displayFactory = $container->get('displayFactory');
$this->displayGroupFactory = $container->get('displayGroupFactory');
$this->mediaService = $container->get('mediaService');
$this->userFactory = $container->get('userFactory');
$this->userGroupFactory = $container->get('userGroupFactory');
$this->fontFactory = $container->get('fontFactory');
$this->dataSetFactory = $container->get('dataSetFactory');
$this->dataSetColumnFactory = $container->get('dataSetColumnFactory');
$this->syncGroupFactory = $container->get('syncGroupFactory');
$this->scheduleFactory = $container->get('scheduleFactory');
$this->folderFactory = $container->get('folderFactory');
$this->commandFactory = $container->get('commandFactory');
$this->mediaFactory = $container->get('mediaFactory');
return $this;
}
/** @inheritdoc
* @throws Exception
*/
public function run()
{
// This task should only be run once
$this->runMessage = '# ' . __('Seeding Database') . PHP_EOL . PHP_EOL;
// Create display groups
$this->createDisplayGroups();
// Create displays
$this->createDisplays();
// Assign displays to display groups
$this->assignDisplaysToDisplayGroups();
// Import layouts
$this->importLayouts();
// Create campaign
$this->createAdCampaigns();
$this->createListCampaigns();
// Create stats
$this->createStats();
// Create Schedules
$this->createSchedules();
// Create Sync Groups
$this->createSyncGroups();
$this->createSynchronizedSchedules();
// Create User
$this->createUsers();
// Create Folders
$this->createFolders();
$this->createCommands();
// Create bandwidth data display 1
$this->createBandwidthReportData();
// Create disconnected display event for yesterday for 10 minutes for display 1
$this->createDisconnectedDisplayEvent();
$this->runMessage .= ' - ' . __('Done.') . PHP_EOL . PHP_EOL;
$this->log->info('Task completed');
$this->appendRunMessage('Task completed');
}
/**
*/
private function createDisplayGroups(): void
{
$displayGroups = [
'POP Display Group',
'Display Group 1',
'Display Group 2',
// Display groups for displaygroups.cy.js test
'disp5_dispgrp',
];
foreach ($displayGroups as $displayGroupName) {
try {
// Don't create if the display group exists
$groups = $this->displayGroupFactory->query(null, ['displayGroup' => $displayGroupName]);
if (count($groups) > 0) {
foreach ($groups as $displayGroup) {
$this->displayGroups[$displayGroup->displayGroup] = $displayGroup->getId();
}
} else {
$displayGroup = $this->displayGroupFactory->createEmpty();
$displayGroup->displayGroup = $displayGroupName;
$displayGroup->userId = $this->userFactory->getSystemUser()->getId();
$displayGroup->save();
$this->store->commitIfNecessary();
// Cache
$this->displayGroups[$displayGroup->displayGroup] = $displayGroup->getId();
}
} catch (GeneralException $e) {
$this->log->error('Error creating display group: '. $e->getMessage());
}
}
}
/**
* @throws Exception
*/
private function createDisplays(): void
{
// Create Displays
$displays = [
'POP Display 1' => ['license' => Random::generateString(12, 'seed'), 'licensed' => false,
'clientType' => 'android', 'clientCode' => 400, 'clientVersion' => 4],
'POP Display 2' => ['license' => Random::generateString(12, 'seed'), 'licensed' => false,
'clientType' => 'android', 'clientCode' => 400, 'clientVersion' => 4],
'List Campaign Display 1' => ['license' => Random::generateString(12, 'seed'), 'licensed' => true,
'clientType' => 'android', 'clientCode' => 400, 'clientVersion' => 4],
'List Campaign Display 2' => ['license' => Random::generateString(12, 'seed'), 'licensed' => true,
'clientType' => 'android', 'clientCode' => 400, 'clientVersion' => 4],
// Displays for displays.cy.js test
'dis_disp1' => ['license' => 'dis_disp1', 'licensed' => true, 'clientType' => 'android', 'clientCode' => 400, 'clientVersion' => 4],
'dis_disp2' => ['license' => 'dis_disp2', 'licensed' => true, 'clientType' => 'android', 'clientCode' => 400, 'clientVersion' => 4],
'dis_disp3' => ['license' => 'dis_disp3', 'licensed' => false, 'clientType' => 'android', 'clientCode' => 400, 'clientVersion' => 4],
'dis_disp4' => ['license' => 'dis_disp4', 'licensed' => true, 'clientType' => 'android', 'clientCode' => 400, 'clientVersion' => 4],
'dis_disp5' => ['license' => 'dis_disp5', 'licensed' => true, 'clientType' => 'android', 'clientCode' => 400, 'clientVersion' => 4],
// Displays for displaygroups.cy.js test
'dispgrp_disp1' => ['license' => 'dispgrp_disp1', 'licensed' => true, 'clientType' => 'android', 'clientCode' => 400, 'clientVersion' => 4],
'dispgrp_disp2' => ['license' => 'dispgrp_disp2', 'licensed' => true, 'clientType' => 'android', 'clientCode' => 400, 'clientVersion' => 4],
'dispgrp_disp_dynamic1' => ['license' => 'dispgrp_disp_dynamic1', 'licensed' => true, 'clientType' => 'android', 'clientCode' => 400, 'clientVersion' => 4],
'dispgrp_disp_dynamic2' => ['license' => 'dispgrp_disp_dynamic2', 'licensed' => true, 'clientType' => 'android', 'clientCode' => 400, 'clientVersion' => 4],
// 6 displays for xmds
'phpunitv7' => ['license' => 'PHPUnit7', 'licensed' => true, 'clientType' => 'android', 'clientCode' => 400, 'clientVersion' => 4],
'phpunitwaiting' => ['license' => 'PHPUnitWaiting', 'licensed' => false, 'clientType' => 'android', 'clientCode' => 400, 'clientVersion' => 4],
'phpunitv6' => ['license' => 'PHPUnit6', 'licensed' => true, 'clientType' => 'windows', 'clientCode' => 304, 'clientVersion' => 3],
'phpunitv5' => ['license' => 'PHPUnit5', 'licensed' => true, 'clientType' => 'windows', 'clientCode' => 304, 'clientVersion' => 3],
'phpunitv4' => ['license' => 'PHPUnit4', 'licensed' => true, 'clientType' => 'android', 'clientCode' => 217, 'clientVersion' => 2],
'phpunitv3' => ['license' => 'PHPUnit3', 'licensed' => true, 'clientType' => 'android', 'clientCode' => 217, 'clientVersion' => 2],
];
foreach ($displays as $displayName => $displayData) {
try {
// Don't create if the display exists
$disps = $this->displayFactory->query(null, ['display' => $displayName]);
if (count($disps) > 0) {
foreach ($disps as $display) {
// Cache
$this->displays[$display->display] = $display->displayId;
}
} else {
$display = $this->displayFactory->createEmpty();
$display->display = $displayName;
$display->auditingUntil = 0;
$display->defaultLayoutId = $this->getConfig()->getSetting('DEFAULT_LAYOUT');
$display->license = $displayData['license'];
$display->licensed = $displayData['licensed'] ? 1 : 0; // Authorised?
$display->clientType = $displayData['clientType'];
$display->clientCode = $displayData['clientCode'];
$display->clientVersion = $displayData['clientVersion'];
$display->incSchedule = 0;
$display->clientAddress = '';
if (!$display->isDisplaySlotAvailable()) {
$display->licensed = 0;
}
$display->lastAccessed = Carbon::now()->format('U');
$display->loggedIn = 1;
$display->save(Display::$saveOptionsMinimum);
$this->store->commitIfNecessary();
// Cache
$this->displays[$display->display] = $display->displayId;
}
} catch (GeneralException $e) {
$this->log->error('Error creating display: ' . $e->getMessage());
}
}
}
/**
* @throws NotFoundException
*/
private function assignDisplaysToDisplayGroups(): void
{
$displayGroup = $this->displayGroupFactory->getById($this->displayGroups['POP Display Group']);
$displayGroup->load();
$display = $this->displayFactory->getById($this->displays['POP Display 1']);
try {
$displayGroup->assignDisplay($display);
$displayGroup->save();
} catch (GeneralException $e) {
$this->log->error('Error assign display to display group: '. $e->getMessage());
}
$this->store->commitIfNecessary();
}
/**
* Import Layouts
* @throws GeneralException
*/
private function importLayouts(): void
{
$this->runMessage .= '## ' . __('Import Layout To Seed Database') . PHP_EOL;
// Make sure the library exists
$this->mediaService->initLibrary();
// all layouts name and file name
$layoutNames = [
'dataset test ' => 'export-dataset-test.zip',
'layout_with_8_items_dataset' => 'export-layout-with-8-items-dataset.zip',
'Image test' => 'export-image-test.zip',
'Layout for Schedule 1' => 'export-layout-for-schedule-1.zip',
'List Campaign Layout 1' => 'export-list-campaign-layout-1.zip',
'List Campaign Layout 2' => 'export-list-campaign-layout-2.zip',
'POP Layout 1' => 'export-pop-layout-1.zip',
// Layout for displaygroups.cy.js test
'disp4_default_layout' => 'export-disp4-default-layout.zip',
// Layout editor tests
'Audio-Video-PDF' => 'export-audio-video-pdf.zip'
];
// Get all layouts
$importedLayouts = [];
foreach ($this->layoutFactory->query() as $layout) {
// cache
if (array_key_exists($layout->layout, $layoutNames)) {
$importedLayouts[] = $layoutNames[$layout->layout];
}
// Cache
$this->layouts[trim($layout->layout)] = $layout->layoutId;
}
// Import a layout
$folder = PROJECT_ROOT . '/tests/resources/seeds/layouts/';
foreach (array_diff(scandir($folder), array('..', '.')) as $file) {
// Check if the layout file has already been imported
if (!in_array($file, $importedLayouts)) {
if (stripos($file, '.zip')) {
try {
$layout = $this->layoutFactory->createFromZip(
$folder . '/' . $file,
null,
$this->userFactory->getSystemUser()->getId(),
false,
false,
true,
false,
true,
$this->dataSetFactory,
null,
$this->mediaService,
1
);
$layout->save([
'audit' => false,
'import' => true
]);
if (!empty($layout->getUnmatchedProperty('thumbnail'))) {
rename($layout->getUnmatchedProperty('thumbnail'), $layout->getThumbnailUri());
}
$this->store->commitIfNecessary();
// Update Cache
$this->layouts[trim($layout->layout)] = $layout->layoutId;
} catch (Exception $exception) {
$this->log->error('Seed Database: Unable to import layout: ' . $file . '. E = ' . $exception->getMessage());
$this->log->debug($exception->getTraceAsString());
}
}
}
}
}
/**
* @throws InvalidArgumentException
* @throws NotFoundException
* @throws DuplicateEntityException
*/
private function createAdCampaigns(): void
{
$layoutId = $this->layouts['POP Layout 1'];
// Get All Ad Campaigns
$campaigns = $this->campaignFactory->query(null, ['type' => 'ad']);
foreach ($campaigns as $campaign) {
$this->parentCampaigns[$campaign->campaign] = $campaign->getId();
}
if (!array_key_exists('POP Ad Campaign 1', $this->parentCampaigns)) {
$campaign = $this->campaignFactory->create(
'ad',
'POP Ad Campaign 1',
$this->userFactory->getSystemUser()->getId(),
1
);
$campaign->targetType = 'plays';
$campaign->target = 100;
$campaign->listPlayOrder = 'round';
try {
// Assign the layout
$campaign->assignLayout($layoutId);
$campaign->save(['validate' => false, 'saveTags' => false]);
$this->store->commitIfNecessary();
// Cache
$this->parentCampaigns[$campaign->campaign] = $campaign->getId();
} catch (GeneralException $e) {
$this->getLogger()->error('Save: ' . $e->getMessage());
}
}
}
/**
* @throws InvalidArgumentException
* @throws NotFoundException
* @throws DuplicateEntityException
*/
private function createListCampaigns(): void
{
$campaignName = 'Campaign for Schedule 1';
// Get All List Campaigns
$campaigns = $this->campaignFactory->query(null, ['type' => 'list']);
foreach ($campaigns as $campaign) {
$this->parentCampaigns[$campaign->campaign] = $campaign->getId();
}
if (!array_key_exists($campaignName, $this->parentCampaigns)) {
$campaign = $this->campaignFactory->create(
'list',
$campaignName,
$this->userFactory->getSystemUser()->getId(),
1
);
$campaign->listPlayOrder = 'round';
try {
// Assign the layout
$campaign->save(['validate' => false, 'saveTags' => false]);
$this->store->commitIfNecessary();
// Cache
$this->parentCampaigns[$campaign->campaign] = $campaign->getId();
} catch (GeneralException $e) {
$this->getLogger()->error('Save: ' . $e->getMessage());
}
}
}
/**
* @throws NotFoundException
*/
private function createStats(): void
{
// Delete Stats
$this->store->update('DELETE FROM stat WHERE displayId = :displayId', [
'displayId' => $this->displays['POP Display 1']
]);
// Get layout campaign Id
$campaignId = $this->layoutFactory->getById($this->layouts['POP Layout 1'])->campaignId;
$columns = 'type, statDate, scheduleId, displayId, campaignId, parentCampaignId, layoutId, mediaId, widgetId, `start`, `end`, tag, duration, `count`';
$values = ':type, :statDate, :scheduleId, :displayId, :campaignId, :parentCampaignId, :layoutId, :mediaId, :widgetId, :start, :end, :tag, :duration, :count';
// a layout stat for today
try {
$params = [
'type' => 'layout',
'statDate' => Carbon::now()->hour(12)->format('U'),
'scheduleId' => 0,
'displayId' => $this->displays['POP Display 1'],
'campaignId' => $campaignId,
'parentCampaignId' => $this->parentCampaigns['POP Ad Campaign 1'],
'layoutId' => $this->layouts['POP Layout 1'],
'mediaId' => null,
'widgetId' => 0,
'start' => Carbon::now()->hour(12)->format('U'),
'end' => Carbon::now()->hour(12)->addSeconds(60)->format('U'),
'tag' => null,
'duration' => 60,
'count' => 1,
];
$this->store->insert('INSERT INTO `stat` (' . $columns . ') VALUES (' . $values . ')', $params);
// a layout stat for lastweek
$params = [
'type' => 'layout',
'statDate' => Carbon::now()->subWeek()->hour(12)->format('U'),
'scheduleId' => 0,
'displayId' => $this->displays['POP Display 1'],
'campaignId' => $campaignId,
'parentCampaignId' => $this->parentCampaigns['POP Ad Campaign 1'],
'layoutId' => $this->layouts['POP Layout 1'],
'mediaId' => null,
'widgetId' => 0,
'start' => Carbon::now()->subWeek()->hour(12)->format('U'),
'end' => Carbon::now()->subWeek()->hour(12)->addSeconds(60)->format('U'),
'tag' => null,
'duration' => 60,
'count' => 1,
];
$this->store->insert('INSERT INTO `stat` (' . $columns . ') VALUES (' . $values . ')', $params);
// Media stats
$columns = 'type, statDate, scheduleId, displayId, campaignId, layoutId, mediaId, widgetId, `start`, `end`, tag, duration, `count`';
$values = ':type, :statDate, :scheduleId, :displayId, :campaignId, :layoutId, :mediaId, :widgetId, :start, :end, :tag, :duration, :count';
// Get Layout
$layout = $this->layoutFactory->getById($this->layouts['POP Layout 1']);
$layout->load();
// Take a mediaId and widgetId of the layout
foreach ($layout->getAllWidgets() as $widget) {
$widgetId = $widget->widgetId;
$mediaId = $widget->mediaIds[0];
break;
}
// Get Media
$media = $this->mediaFactory->getById($mediaId);
// a media stat for today
$params = [
'type' => 'media',
'statDate' => Carbon::now()->hour(12)->format('U'),
'scheduleId' => 0,
'displayId' => $this->displays['POP Display 1'],
'campaignId' => $campaignId,
'layoutId' => $this->layouts['POP Layout 1'],
'mediaId' => $media->mediaId,
'widgetId' => $widgetId,
'start' => Carbon::now()->hour(12)->format('U'),
'end' => Carbon::now()->hour(12)->addSeconds(60)->format('U'),
'tag' => null,
'duration' => 60,
'count' => 1,
];
$this->store->insert('INSERT INTO `stat` (' . $columns . ') VALUES (' . $values . ')', $params);
// another media stat for today
$params = [
'type' => 'media',
'statDate' => Carbon::now()->hour(12)->addSeconds(60)->format('U'),
'scheduleId' => 0,
'displayId' => $this->displays['POP Display 1'],
'campaignId' => $campaignId,
'layoutId' => $this->layouts['POP Layout 1'],
'mediaId' => $media->mediaId,
'widgetId' => $widgetId,
'start' => Carbon::now()->hour(12)->addSeconds(60)->format('U'),
'end' => Carbon::now()->hour(12)->addSeconds(120)->format('U'),
'tag' => null,
'duration' => 60,
'count' => 1,
];
$this->store->insert('INSERT INTO `stat` (' . $columns . ') VALUES (' . $values . ')', $params);
// a media stat for lastweek
// Last week stats -
$params = [
'type' => 'media',
'statDate' => Carbon::now()->subWeek()->addDays(2)->hour(12)->format('U'),
'scheduleId' => 0,
'displayId' => $this->displays['POP Display 1'],
'campaignId' => $campaignId,
'layoutId' => $this->layouts['POP Layout 1'],
'mediaId' => $media->mediaId,
'widgetId' => $widgetId,
'start' => Carbon::now()->subWeek()->hour(12)->format('U'),
'end' => Carbon::now()->subWeek()->hour(12)->addSeconds(60)->format('U'),
'tag' => null,
'duration' => 60,
'count' => 1,
];
$this->store->insert('INSERT INTO `stat` (' . $columns . ') VALUES (' . $values . ')', $params);
// another media stat for lastweek
$params = [
'type' => 'media',
'statDate' => Carbon::now()->subWeek()->addDays(2)->hour(12)->addSeconds(60)->format('U'),
'scheduleId' => 0,
'displayId' => $this->displays['POP Display 1'],
'campaignId' => $campaignId,
'layoutId' => $this->layouts['POP Layout 1'],
'mediaId' => $media->mediaId,
'widgetId' => $widgetId,
'start' => Carbon::now()->subWeek()->hour(12)->addSeconds(60)->format('U'),
'end' => Carbon::now()->subWeek()->hour(12)->addSeconds(120)->format('U'),
'tag' => null,
'duration' => 60,
'count' => 1,
];
$this->store->insert('INSERT INTO `stat` (' . $columns . ') VALUES (' . $values . ')', $params);
// an widget stat for today
$params = [
'type' => 'widget',
'statDate' => Carbon::now()->hour(12)->format('U'),
'scheduleId' => 0,
'displayId' => $this->displays['POP Display 1'],
'campaignId' => $campaignId,
'layoutId' => $this->layouts['POP Layout 1'],
'mediaId' => $media->mediaId,
'widgetId' => $widgetId,
'start' => Carbon::now()->hour(12)->format('U'),
'end' => Carbon::now()->hour(12)->addSeconds(60)->format('U'),
'tag' => null,
'duration' => 60,
'count' => 1,
];
$this->store->insert('INSERT INTO `stat` (' . $columns . ') VALUES (' . $values . ')', $params);
// a event stat for today
$params = [
'type' => 'event',
'statDate' => Carbon::now()->hour(12)->format('U'),
'scheduleId' => 0,
'displayId' => $this->displays['POP Display 1'],
'campaignId' => 0,
'layoutId' => 0,
'mediaId' => null,
'widgetId' => 0,
'start' => Carbon::now()->hour(12)->format('U'),
'end' => Carbon::now()->hour(12)->addSeconds(60)->format('U'),
'tag' => 'Event123',
'duration' => 60,
'count' => 1,
];
$this->store->insert('INSERT INTO `stat` (' . $columns . ') VALUES (' . $values . ')', $params);
} catch (GeneralException $e) {
$this->getLogger()->error('Error inserting stats: '. $e->getMessage());
}
$this->store->commitIfNecessary();
}
private function createSchedules(): void
{
// Don't create if the schedule exists
$schedules = $this->scheduleFactory->query(null, [
'eventTypeId' => Schedule::$LAYOUT_EVENT,
'campaignId' => $this->layouts['dataset test']
]);
if (count($schedules) <= 0) {
try {
$schedule = $this->scheduleFactory->createEmpty();
$schedule->userId = $this->userFactory->getSystemUser()->getId();
$schedule->eventTypeId = Schedule::$LAYOUT_EVENT;
$schedule->dayPartId = 2;
$schedule->displayOrder = 0;
$schedule->isPriority = 0;
// Campaign Id
$schedule->campaignId = $this->layouts['dataset test'];
$schedule->syncTimezone = 0;
$schedule->syncEvent = 0;
$schedule->isGeoAware = 0;
$schedule->maxPlaysPerHour = 0;
$displays = $this->displayFactory->query(null, ['display' => 'phpunitv']);
foreach ($displays as $display) {
$displayGroupId = $display->displayGroupId;
$schedule->assignDisplayGroup($this->displayGroupFactory->getById($displayGroupId));
}
$schedule->save(['notify' => false]);
$this->store->commitIfNecessary();
} catch (GeneralException $e) {
$this->log->error('Error creating schedule : '. $e->getMessage());
}
}
}
private function createSyncGroups(): void
{
// Don't create if the sync group exists
$syncGroups = $this->syncGroupFactory->query(null, [
'eventTypeId' => Schedule::$LAYOUT_EVENT,
'campaignId' => $this->layouts['dataset test']
]);
if (count($syncGroups) > 0) {
foreach ($syncGroups as $syncGroup) {
// Cache
$this->syncGroups[$syncGroup->name] = $syncGroup->getId();
}
} else {
// Create a SyncGroup - SyncGroup name `Simple Sync Group`
try {
$syncGroup = $this->syncGroupFactory->createEmpty();
$syncGroup->name = 'Simple Sync Group';
$syncGroup->ownerId = $this->userFactory->getSystemUser()->getId();
$syncGroup->syncPublisherPort = 9590;
$syncGroup->folderId = 1;
$syncGroup->permissionsFolderId = 1;
$syncGroup->save();
$this->store->update('UPDATE `display` SET `display`.syncGroupId = :syncGroupId WHERE `display`.displayId = :displayId', [
'syncGroupId' => $syncGroup->syncGroupId,
'displayId' => $this->displays['phpunitv6']
]);
$this->store->update('UPDATE `display` SET `display`.syncGroupId = :syncGroupId WHERE `display`.displayId = :displayId', [
'syncGroupId' => $syncGroup->syncGroupId,
'displayId' => $this->displays['phpunitv7']
]);
$syncGroup->leadDisplayId = $this->displays['phpunitv7'];
$syncGroup->save();
$this->store->commitIfNecessary();
// Cache
$this->syncGroups[$syncGroup->name] = $syncGroup->getId();
} catch (GeneralException $e) {
$this->log->error('Error creating sync group: '. $e->getMessage());
}
}
}
private function createSynchronizedSchedules(): void
{
// Don't create if the schedule exists
$schedules = $this->scheduleFactory->query(null, [
'eventTypeId' => Schedule::$SYNC_EVENT,
'syncGroupId' => $this->syncGroups['Simple Sync Group']
]);
if (count($schedules) <= 0) {
try {
$schedule = $this->scheduleFactory->createEmpty();
$schedule->userId = $this->userFactory->getSystemUser()->getId();
$schedule->eventTypeId = Schedule::$SYNC_EVENT;
$schedule->dayPartId = 2;
$schedule->displayOrder = 0;
$schedule->isPriority = 0;
// Campaign Id
$schedule->campaignId = null;
$schedule->syncTimezone = 0;
$schedule->syncEvent = 1;
$schedule->isGeoAware = 0;
$schedule->maxPlaysPerHour = 0;
$schedule->syncGroupId = $this->syncGroups['Simple Sync Group'];
$displayV7 = $this->displayFactory->getById($this->displays['phpunitv7']);
$schedule->assignDisplayGroup($this->displayGroupFactory->getById($displayV7->displayGroupId));
$displayV6 = $this->displayFactory->getById($this->displays['phpunitv6']);
$schedule->assignDisplayGroup($this->displayGroupFactory->getById($displayV6->displayGroupId));
$schedule->save(['notify' => false]);
$this->store->commitIfNecessary();
// Update Sync Links
$this->store->insert('INSERT INTO `schedule_sync` (`eventId`, `displayId`, `layoutId`)
VALUES(:eventId, :displayId, :layoutId) ON DUPLICATE KEY UPDATE layoutId = :layoutId', [
'eventId' => $schedule->eventId,
'displayId' => $this->displays['phpunitv7'],
'layoutId' => $this->layouts['Image test']
]);
$this->store->insert('INSERT INTO `schedule_sync` (`eventId`, `displayId`, `layoutId`)
VALUES(:eventId, :displayId, :layoutId) ON DUPLICATE KEY UPDATE layoutId = :layoutId', [
'eventId' => $schedule->eventId,
'displayId' => $this->displays['phpunitv6'],
'layoutId' => $this->layouts['Image test']
]);
$this->store->commitIfNecessary();
} catch (GeneralException $e) {
$this->log->error('Error creating sync schedule: '. $e->getMessage());
}
}
}
private function createUsers(): void
{
// Don't create if exists
$users = $this->userFactory->query(null, [
'exactUserName' => 'folder_user'
]);
if (count($users) <= 0) {
// Create a user - user name `Simple User`
try {
$user = $this->userFactory->create();
$user->setChildAclDependencies($this->userGroupFactory);
$user->userName = 'folder_user';
$user->email = '';
$user->homePageId = 'icondashboard.view';
$user->libraryQuota = 20;
$user->setNewPassword('password');
$user->homeFolderId = 1;
$user->userTypeId = 3;
$user->isSystemNotification = 0;
$user->isDisplayNotification = 0;
$user->isPasswordChangeRequired = 0;
$user->firstName = 'test';
$user->lastName = 'user';
$user->save();
$this->store->commitIfNecessary();
} catch (GeneralException $e) {
$this->log->error('Error creating user: '. $e->getMessage());
}
}
}
private function createFolders(): void
{
$folders = [
'ChildFolder', 'FolderHome', 'EmptyFolder', 'ShareFolder', 'FolderWithContent', 'FolderWithImage', 'MoveToFolder', 'MoveFromFolder'
];
foreach ($folders as $folderName) {
try {
// Don't create if the folder exists
$folds = $this->folderFactory->query(null, ['folderName' => $folderName]);
if (count($folds) <= 0) {
$folder = $this->folderFactory->createEmpty();
$folder->text = $folderName;
$folder->parentId = 1;
$folder->children = '';
$folder->save();
$this->store->commitIfNecessary();
}
} catch (GeneralException $e) {
$this->log->error('Error creating folder: '. $e->getMessage());
}
}
// Place the media in folders
$folderWithImages = [
'MoveToFolder' => 'test12',
'MoveFromFolder' => 'test34',
'FolderWithContent' => 'media_for_not_empty_folder',
'FolderWithImage' => 'media_for_search_in_folder'
];
foreach ($folderWithImages as $folderName => $mediaName) {
try {
$folders = $this->folderFactory->query(null, ['folderName' => $folderName]);
if (count($folders) == 1) {
$test12 = $this->mediaFactory->getByName($mediaName);
$test12->folderId = $folders[0]->getId(); // Get the folder id of FolderHome
$test12->save();
$this->store->commitIfNecessary();
}
} catch (GeneralException $e) {
$this->log->error('Error moving media ' . $mediaName . ' to the folder: ' . $folderName . ' ' . $e->getMessage());
}
}
}
private function createBandwidthReportData(): void
{
// Check if the record exists
$monthU = Carbon::now()->startOfDay()->hour(12)->format('U');
$record = $this->store->select('SELECT * FROM bandwidth WHERE type = 8 AND displayId = :displayId AND month = :month', [
'displayId' => $this->displays['POP Display 1'],
'month' => $monthU
]);
if (count($record) <= 0) {
$this->store->insert('INSERT INTO `bandwidth` (Month, Type, DisplayID, Size) VALUES (:month, :type, :displayId, :size)', [
'month' => $monthU,
'type' => 8,
'displayId' => $this->displays['POP Display 1'],
'size' => 200
]);
$this->store->commitIfNecessary();
}
}
private function createDisconnectedDisplayEvent(): void
{
// Delete if the record exists
$date = Carbon::now()->subDay()->format('U');
$this->store->update('DELETE FROM displayevent WHERE displayId = :displayId', [
'displayId' => $this->displays['POP Display 1']
]);
$this->store->insert('INSERT INTO `displayevent` (eventDate, start, end, displayID) VALUES (:eventDate, :start, :end, :displayId)', [
'eventDate' => $date,
'start' => $date,
'end' => Carbon::now()->subDay()->addSeconds(600)->format('U'),
'displayId' => $this->displays['POP Display 1']
]);
$this->store->commitIfNecessary();
}
private function createCommands()
{
$commandName = 'Set Timezone';
// Don't create if exists
$commands = $this->commandFactory->query(null, [
'command' => $commandName
]);
if (count($commands) <= 0) {
// Create a user - user name `Simple User`
try {
$command = $this->commandFactory->create();
$command->command = $commandName;
$command->description = 'a command to test schedule';
$command->code = 'TIMEZONE';
$command->userId = $this->userFactory->getSystemUser()->getId();
$command->createAlertOn = 'never';
$command->save();
$this->store->commitIfNecessary();
} catch (GeneralException $e) {
$this->log->error('Error creating command: '. $e->getMessage());
}
}
}
}

View File

@@ -0,0 +1,337 @@
<?php
/*
* Copyright (c) 2022 Xibo Signage Ltd
*
* Xibo - Digital Signage - http://www.xibo.org.uk
*
* This file is part of Xibo.
*
* Xibo is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Xibo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Xibo. If not, see <http://www.gnu.org/licenses/>.
*/
namespace Xibo\XTR;
use Carbon\Carbon;
use Xibo\Entity\User;
use Xibo\Factory\MediaFactory;
use Xibo\Factory\UserFactory;
use Xibo\Helper\DateFormatHelper;
use Xibo\Helper\Random;
use Xibo\Support\Exception\InvalidArgumentException;
use Xibo\Support\Exception\NotFoundException;
use Xibo\Support\Exception\TaskRunException;
/**
* Class StatsArchiveTask
* @package Xibo\XTR
*/
class StatsArchiveTask implements TaskInterface
{
use TaskTrait;
/** @var User */
private $archiveOwner;
/** @var MediaFactory */
private $mediaFactory;
/** @var UserFactory */
private $userFactory;
/** @var Carbon */
private $lastArchiveDate = null;
/** @var \Xibo\Helper\SanitizerService */
private $sanitizerService;
/** @inheritdoc */
public function setFactories($container)
{
$this->userFactory = $container->get('userFactory');
$this->mediaFactory = $container->get('mediaFactory');
$this->sanitizerService = $container->get('sanitizerService');
return $this;
}
/** @inheritdoc */
public function run()
{
$this->archiveStats();
$this->tidyStats();
}
public function archiveStats()
{
$this->log->debug('Archive Stats');
$this->runMessage = '# ' . __('Stats Archive') . PHP_EOL . PHP_EOL;
if ($this->getOption('archiveStats', 'Off') == 'On') {
$this->log->debug('Archive Enabled');
// Archive tasks by week.
$periodSizeInDays = $this->getOption('periodSizeInDays', 7);
$maxPeriods = $this->getOption('maxPeriods', 4);
$periodsToKeep = $this->getOption('periodsToKeep', 1);
$this->setArchiveOwner();
// Get the earliest
$earliestDate = $this->timeSeriesStore->getEarliestDate();
if ($earliestDate === null) {
$this->log->debug('Earliest date is null, nothing to archive.');
$this->runMessage = __('Nothing to archive');
return;
}
// Wind back to the start of the day
$earliestDate = $earliestDate->copy()->setTime(0, 0, 0);
// Take the earliest date and roll forward until the current time
$now = Carbon::now()->subDays($periodSizeInDays * $periodsToKeep)->setTime(0, 0, 0);
$i = 0;
while ($earliestDate < $now && $i < $maxPeriods) {
$i++;
// Push forward
$fromDt = $earliestDate->copy();
$earliestDate->addDays($periodSizeInDays);
$this->log->debug('Running archive number ' . $i
. 'for ' . $fromDt->toAtomString() . ' - ' . $earliestDate->toAtomString());
try {
$this->exportStatsToLibrary($fromDt, $earliestDate);
} catch (\Exception $exception) {
$this->log->error('Export error for Archive Number ' . $i . ', e = ' . $exception->getMessage());
// Throw out to the task handler to record the error.
throw $exception;
}
$this->store->commitIfNecessary();
$this->log->debug('Export success for Archive Number ' . $i);
// Grab the last from date for use in tidy stats
$this->lastArchiveDate = $fromDt;
}
$this->runMessage .= ' - ' . __('Done') . PHP_EOL . PHP_EOL;
} else {
$this->log->debug('Archive not enabled');
$this->runMessage .= ' - ' . __('Disabled') . PHP_EOL . PHP_EOL;
}
$this->log->debug('Finished archive stats, last archive date is '
. ($this->lastArchiveDate == null ? 'null' : $this->lastArchiveDate->toAtomString()));
}
/**
* Export stats to the library
* @param Carbon $fromDt
* @param Carbon $toDt
* @throws \Xibo\Support\Exception\GeneralException
*/
private function exportStatsToLibrary($fromDt, $toDt)
{
$this->log->debug('Export period: ' . $fromDt->toAtomString() . ' - ' . $toDt->toAtomString());
$this->runMessage .= ' - ' . $fromDt->format(DateFormatHelper::getSystemFormat()) . ' / ' . $toDt->format(DateFormatHelper::getSystemFormat()) . PHP_EOL;
$resultSet = $this->timeSeriesStore->getStats([
'fromDt'=> $fromDt,
'toDt'=> $toDt,
]);
$this->log->debug('Get stats');
// Create a temporary file for this
$fileName = tempnam(sys_get_temp_dir(), 'stats');
$out = fopen($fileName, 'w');
fputcsv($out, ['Stat Date', 'Type', 'FromDT', 'ToDT', 'Layout', 'Display', 'Media', 'Tag', 'Duration', 'Count', 'DisplayId', 'LayoutId', 'WidgetId', 'MediaId', 'Engagements']);
$hasStatsToArchive = false;
while ($row = $resultSet->getNextRow()) {
$hasStatsToArchive = true;
$sanitizedRow = $this->getSanitizer($row);
if ($this->timeSeriesStore->getEngine() == 'mongodb') {
$statDate = isset($row['statDate']) ? Carbon::createFromTimestamp($row['statDate']->toDateTime()->format('U'))->format(DateFormatHelper::getSystemFormat()) : null;
$start = Carbon::createFromTimestamp($row['start']->toDateTime()->format('U'))->format(DateFormatHelper::getSystemFormat());
$end = Carbon::createFromTimestamp($row['end']->toDateTime()->format('U'))->format(DateFormatHelper::getSystemFormat());
$engagements = isset($row['engagements']) ? json_encode($row['engagements']) : '[]';
} else {
$statDate = isset($row['statDate']) ? Carbon::createFromTimestamp($row['statDate'])->format(DateFormatHelper::getSystemFormat()) : null;
$start = Carbon::createFromTimestamp($row['start'])->format(DateFormatHelper::getSystemFormat());
$end = Carbon::createFromTimestamp($row['end'])->format(DateFormatHelper::getSystemFormat());
$engagements = isset($row['engagements']) ? $row['engagements'] : '[]';
}
// Read the columns
fputcsv($out, [
$statDate,
$sanitizedRow->getString('type'),
$start,
$end,
isset($row['layout']) ? $sanitizedRow->getString('layout') :'',
isset($row['display']) ? $sanitizedRow->getString('display') :'',
isset($row['media']) ? $sanitizedRow->getString('media') :'',
isset($row['tag']) ? $sanitizedRow->getString('tag') :'',
$sanitizedRow->getInt('duration'),
$sanitizedRow->getInt('count'),
$sanitizedRow->getInt('displayId'),
isset($row['layoutId']) ? $sanitizedRow->getInt('layoutId') :'',
isset($row['widgetId']) ? $sanitizedRow->getInt('widgetId') :'',
isset($row['mediaId']) ? $sanitizedRow->getInt('mediaId') :'',
$engagements
]);
}
fclose($out);
if ($hasStatsToArchive) {
$this->log->debug('Temporary file written, zipping');
// Create a ZIP file and add our temporary file
$zipName = $this->config->getSetting('LIBRARY_LOCATION') . 'temp/stats.csv.zip';
$zip = new \ZipArchive();
$result = $zip->open($zipName, \ZipArchive::CREATE | \ZipArchive::OVERWRITE);
if ($result !== true) {
throw new InvalidArgumentException(__('Can\'t create ZIP. Error Code: %s', $result));
}
$zip->addFile($fileName, 'stats.csv');
$zip->close();
$this->log->debug('Zipped to ' . $zipName);
// This all might have taken a long time indeed, so lets see if we need to reconnect MySQL
$this->store->select('SELECT 1', [], 'default', true);
$this->log->debug('MySQL connection refreshed if necessary');
// Upload to the library
$media = $this->mediaFactory->create(
__('Stats Export %s to %s - %s', $fromDt->format('Y-m-d'), $toDt->format('Y-m-d'), Random::generateString(5)),
'stats.csv.zip',
'genericfile',
$this->archiveOwner->getId()
);
$media->save();
$this->log->debug('Media saved as ' . $media->name);
// Commit before the delete (the delete might take a long time)
$this->store->commitIfNecessary();
// Set max attempts to -1 so that we continue deleting until we've removed all of the stats that we've exported
$options = [
'maxAttempts' => -1,
'statsDeleteSleep' => 1,
'limit' => 1000
];
$this->log->debug('Delete stats for period: ' . $fromDt->toAtomString() . ' - ' . $toDt->toAtomString());
// Delete the stats, incrementally
$this->timeSeriesStore->deleteStats($toDt, $fromDt, $options);
// This all might have taken a long time indeed, so lets see if we need to reconnect MySQL
$this->store->select('SELECT 1', [], 'default', true);
$this->log->debug('MySQL connection refreshed if necessary');
$this->log->debug('Delete stats completed, export period completed.');
} else {
$this->log->debug('There are no stats to archive');
}
// Remove the CSV file
unlink($fileName);
}
/**
* Set the archive owner
* @throws TaskRunException
*/
private function setArchiveOwner()
{
$archiveOwner = $this->getOption('archiveOwner', null);
if ($archiveOwner == null) {
$admins = $this->userFactory->getSuperAdmins();
if (count($admins) <= 0) {
throw new TaskRunException(__('No super admins to use as the archive owner, please set one in the configuration.'));
}
$this->archiveOwner = $admins[0];
} else {
try {
$this->archiveOwner = $this->userFactory->getByName($archiveOwner);
} catch (NotFoundException $e) {
throw new TaskRunException(__('Archive Owner not found'));
}
}
}
/**
* Tidy Stats
*/
private function tidyStats()
{
$this->log->debug('Tidy stats');
$this->runMessage .= '## ' . __('Tidy Stats') . PHP_EOL;
$maxAge = intval($this->config->getSetting('MAINTENANCE_STAT_MAXAGE'));
if ($maxAge != 0) {
$this->log->debug('Max Age is ' . $maxAge);
// Set the max age to maxAgeDays from now, or if we've archived, from the archive date
$maxAgeDate = ($this->lastArchiveDate === null)
? Carbon::now()->subDays($maxAge)
: $this->lastArchiveDate;
// Control the flow of the deletion
$options = [
'maxAttempts' => $this->getOption('statsDeleteMaxAttempts', 10),
'statsDeleteSleep' => $this->getOption('statsDeleteSleep', 3),
'limit' => $this->getOption('limit', 10000) // Note: for mongo we dont use $options['limit'] anymore
];
try {
$this->log->debug('Calling delete stats with max age: ' . $maxAgeDate->toAtomString());
$countDeleted = $this->timeSeriesStore->deleteStats($maxAgeDate, null, $options);
$this->log->debug('Delete Stats complete');
$this->runMessage .= ' - ' . sprintf(__('Done - %d deleted.'), $countDeleted) . PHP_EOL . PHP_EOL;
} catch (\Exception $exception) {
$this->log->error('Unexpected error running stats tidy. e = ' . $exception->getMessage());
$this->runMessage .= ' - ' . __('Error.') . PHP_EOL . PHP_EOL;
}
} else {
$this->runMessage .= ' - ' . __('Disabled') . PHP_EOL . PHP_EOL;
}
$this->log->debug('Tidy stats complete');
}
}

View File

@@ -0,0 +1,679 @@
<?php
/*
* Copyright (C) 2023 Xibo Signage Ltd
*
* Xibo - Digital Signage - https://xibosignage.com
*
* This file is part of Xibo.
*
* Xibo is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Xibo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Xibo. If not, see <http://www.gnu.org/licenses/>.
*/
namespace Xibo\XTR;
use Carbon\Carbon;
use Xibo\Entity\Task;
use Xibo\Factory\DisplayFactory;
use Xibo\Factory\LayoutFactory;
use Xibo\Factory\TaskFactory;
use Xibo\Support\Exception\NotFoundException;
/**
* Class StatsMigrationTask
* @package Xibo\XTR
*/
class StatsMigrationTask implements TaskInterface
{
use TaskTrait;
/** @var TaskFactory */
private $taskFactory;
/** @var DisplayFactory */
private $displayFactory;
/** @var LayoutFactory */
private $layoutFactory;
/** @var bool Does the Archive Table exist? */
private $archiveExist;
/** @var array Cache of displayIds found */
private $displays = [];
/** @var array Cache of displayIds not found */
private $displaysNotFound = [];
/** @var Task The Stats Archive Task */
private $archiveTask;
/** @inheritdoc */
public function setFactories($container)
{
$this->taskFactory = $container->get('taskFactory');
$this->layoutFactory = $container->get('layoutFactory');
$this->displayFactory = $container->get('displayFactory');
return $this;
}
/** @inheritdoc */
public function run()
{
$this->migrateStats();
}
/**
* Migrate Stats
* @throws \Xibo\Support\Exception\NotFoundException
*/
public function migrateStats()
{
// Config options
$options = [
'killSwitch' => (int)$this->getOption('killSwitch', 0),
'numberOfRecords' => (int)$this->getOption('numberOfRecords', 10000),
'numberOfLoops' => (int)$this->getOption('numberOfLoops', 1000),
'pauseBetweenLoops' => (int)$this->getOption('pauseBetweenLoops', 10),
'optimiseOnComplete' => (int)$this->getOption('optimiseOnComplete', 1),
];
// read configOverride
$configOverrideFile = $this->getOption('configOverride', '');
if (!empty($configOverrideFile) && file_exists($configOverrideFile)) {
$options = array_merge($options, json_decode(file_get_contents($configOverrideFile), true));
}
if ($options['killSwitch'] == 0) {
// Stat Archive Task
$this->archiveTask = $this->taskFactory->getByClass('\Xibo\XTR\\StatsArchiveTask');
// Check stat_archive table exists
$this->archiveExist = $this->store->exists('SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = :name', [
'name' => 'stat_archive'
]);
// Get timestore engine
$timeSeriesStore = $this->timeSeriesStore->getEngine();
if ($timeSeriesStore == 'mongodb') {
// If no records in both the stat and stat_archive then disable the task
$statSql = $this->store->getConnection()->prepare('SELECT statId FROM stat LIMIT 1');
$statSql->execute();
$statArchiveSqlCount = 0;
if ($this->archiveExist) {
/** @noinspection SqlResolve */
$statArchiveSql = $this->store->getConnection()->prepare('SELECT statId FROM stat_archive LIMIT 1');
$statArchiveSql->execute();
$statArchiveSqlCount = $statArchiveSql->rowCount();
}
if (($statSql->rowCount() == 0) && ($statArchiveSqlCount == 0)) {
$this->runMessage = '## Stat migration to Mongo' . PHP_EOL ;
$this->appendRunMessage('- Both stat_archive and stat is empty. '. PHP_EOL);
// Disable the task and Enable the StatsArchiver task
$this->log->debug('Stats migration task is disabled as stat_archive and stat is empty');
$this->disableTask();
if ($this->archiveTask->isActive == 0) {
$this->archiveTask->isActive = 1;
$this->archiveTask->save();
$this->store->commitIfNecessary();
$this->appendRunMessage('Enabling Stats Archive Task.');
$this->log->debug('Enabling Stats Archive Task.');
}
} else {
// if any of the two tables contain any records
$this->quitMigrationTaskOrDisableStatArchiveTask();
}
$this->moveStatsToMongoDb($options);
}
// If when the task runs it finds that MongoDB is disabled,
// and there isn't a stat_archive table, then it should disable itself and not run again
// (work is considered to be done at that point).
else {
if ($this->archiveExist) {
$this->runMessage = '## Moving from stat_archive to stat (MySQL)' . PHP_EOL;
$this->quitMigrationTaskOrDisableStatArchiveTask();
// Start migration
$this->moveStatsFromStatArchiveToStatMysql($options);
} else {
// Disable the task
$this->runMessage = '## Moving from stat_archive to stat (MySQL)' . PHP_EOL ;
$this->appendRunMessage('- Table stat_archive does not exist.' . PHP_EOL);
$this->log->debug('Table stat_archive does not exist.');
$this->disableTask();
// Enable the StatsArchiver task
if ($this->archiveTask->isActive == 0) {
$this->archiveTask->isActive = 1;
$this->archiveTask->save();
$this->store->commitIfNecessary();
$this->appendRunMessage('Enabling Stats Archive Task.');
$this->log->debug('Enabling Stats Archive Task.');
}
}
}
}
}
public function moveStatsFromStatArchiveToStatMysql($options)
{
$fileName = $this->config->getSetting('LIBRARY_LOCATION') . '.watermark_stat_archive_mysql.txt';
// Get low watermark from file
$watermark = $this->getWatermarkFromFile($fileName, 'stat_archive');
$numberOfLoops = 0;
while ($watermark > 0) {
$count = 0;
/** @noinspection SqlResolve */
$stats = $this->store->getConnection()->prepare('
SELECT statId, type, statDate, scheduleId, displayId, layoutId, mediaId, widgetId, start, `end`, tag
FROM stat_archive
WHERE statId < :watermark
ORDER BY statId DESC LIMIT :limit
');
$stats->bindParam(':watermark', $watermark, \PDO::PARAM_INT);
$stats->bindParam(':limit', $options['numberOfRecords'], \PDO::PARAM_INT);
// Run the select
$stats->execute();
// Keep count how many stats we've inserted
$recordCount = $stats->rowCount();
$count+= $recordCount;
// End of records
if ($this->checkEndOfRecords($recordCount, $fileName) === true) {
$this->appendRunMessage(PHP_EOL. '# End of records.' . PHP_EOL. '- Dropping stat_archive.');
$this->log->debug('End of records in stat_archive (migration to MYSQL). Dropping table.');
// Drop the stat_archive table
/** @noinspection SqlResolve */
$this->store->update('DROP TABLE `stat_archive`;', []);
$this->appendRunMessage(__('Done.'. PHP_EOL));
// Disable the task
$this->disableTask();
// Enable the StatsArchiver task
if ($this->archiveTask->isActive == 0) {
$this->archiveTask->isActive = 1;
$this->archiveTask->save();
$this->store->commitIfNecessary();
$this->appendRunMessage('Enabling Stats Archive Task.');
$this->log->debug('Enabling Stats Archive Task.');
}
break;
}
// Loops limit end - task will need to rerun again to start from the saved watermark
if ($this->checkLoopLimits($numberOfLoops, $options['numberOfLoops'], $fileName, $watermark) === true) {
break;
}
$numberOfLoops++;
$temp = [];
$statIgnoredCount = 0;
foreach ($stats->fetchAll() as $stat) {
$watermark = $stat['statId'];
$columns = 'type, statDate, scheduleId, displayId, campaignId, layoutId, mediaId, widgetId, `start`, `end`, tag, duration, `count`';
$values = ':type, :statDate, :scheduleId, :displayId, :campaignId, :layoutId, :mediaId, :widgetId, :start, :end, :tag, :duration, :count';
// Get campaignId
if (($stat['type'] != 'event') && ($stat['layoutId'] != null)) {
try {
// Search the campaignId in the temp array first to reduce query in layouthistory
if (array_key_exists($stat['layoutId'], $temp)) {
$campaignId = $temp[$stat['layoutId']];
} else {
$campaignId = $this->layoutFactory->getCampaignIdFromLayoutHistory($stat['layoutId']);
$temp[$stat['layoutId']] = $campaignId;
}
} catch (NotFoundException $error) {
$statIgnoredCount+= 1;
$count = $count - 1;
continue;
}
} else {
$campaignId = 0;
}
$params = [
'type' => $stat['type'],
'statDate' => Carbon::createFromTimestamp($stat['statDate'])->format('U'),
'scheduleId' => (int) $stat['scheduleId'],
'displayId' => (int) $stat['displayId'],
'campaignId' => $campaignId,
'layoutId' => (int) $stat['layoutId'],
'mediaId' => (int) $stat['mediaId'],
'widgetId' => (int) $stat['widgetId'],
'start' => Carbon::createFromTimestamp($stat['start'])->format('U'),
'end' => Carbon::createFromTimestamp($stat['end'])->format('U'),
'tag' => $stat['tag'],
'duration' => isset($stat['duration']) ? (int) $stat['duration'] : Carbon::createFromTimestamp($stat['end'])->format('U') - Carbon::createFromTimestamp($stat['start'])->format('U'),
'count' => isset($stat['count']) ? (int) $stat['count'] : 1,
];
// Do the insert
$this->store->insert('INSERT INTO `stat` (' . $columns . ') VALUES (' . $values . ')', $params);
$this->store->commitIfNecessary();
}
if ($statIgnoredCount > 0) {
$this->appendRunMessage($statIgnoredCount. ' stat(s) were ignored while migrating');
}
// Give SQL time to recover
if ($watermark > 0) {
$this->appendRunMessage('- '. $count. ' rows migrated.');
$this->log->debug('MYSQL stats migration from stat_archive to stat. '.$count.' rows effected, sleeping.');
sleep($options['pauseBetweenLoops']);
}
}
}
public function moveStatsToMongoDb($options)
{
// Migration from stat table to Mongo
$this->migrationStatToMongo($options);
// Migration from stat_archive table to Mongo
// After migration delete only stat_archive
if ($this->archiveExist) {
$this->migrationStatArchiveToMongo($options);
}
}
function migrationStatToMongo($options)
{
$this->appendRunMessage('## Moving from stat to Mongo');
$fileName = $this->config->getSetting('LIBRARY_LOCATION') . '.watermark_stat_mongo.txt';
// Get low watermark from file
$watermark = $this->getWatermarkFromFile($fileName, 'stat');
$numberOfLoops = 0;
while ($watermark > 0) {
$count = 0;
$stats = $this->store->getConnection()
->prepare('SELECT * FROM stat WHERE statId < :watermark ORDER BY statId DESC LIMIT :limit');
$stats->bindParam(':watermark', $watermark, \PDO::PARAM_INT);
$stats->bindParam(':limit', $options['numberOfRecords'], \PDO::PARAM_INT);
// Run the select
$stats->execute();
// Keep count how many stats we've inserted
$recordCount = $stats->rowCount();
$count+= $recordCount;
// End of records
if ($this->checkEndOfRecords($recordCount, $fileName) === true) {
$this->appendRunMessage(PHP_EOL. '# End of records.' . PHP_EOL. '- Truncating and Optimising stat.');
$this->log->debug('End of records in stat table. Truncate and Optimise.');
// Truncate stat table
$this->store->update('TRUNCATE TABLE stat', []);
// Optimize stat table
if ($options['optimiseOnComplete'] == 1) {
$this->store->update('OPTIMIZE TABLE stat', []);
}
$this->appendRunMessage(__('Done.'. PHP_EOL));
break;
}
// Loops limit end - task will need to rerun again to start from the saved watermark
if ($this->checkLoopLimits($numberOfLoops, $options['numberOfLoops'], $fileName, $watermark) === true) {
break;
}
$numberOfLoops++;
$statCount = 0;
foreach ($stats->fetchAll() as $stat) {
// We get the watermark now because if we skip the records our watermark will never reach 0
$watermark = $stat['statId'];
// Get display
$display = $this->getDisplay((int) $stat['displayId']);
if (empty($display)) {
$this->log->error('Display not found. Display Id: '. $stat['displayId']);
continue;
}
$entry = [];
$entry['statDate'] = Carbon::createFromTimestamp($stat['statDate']);
$entry['type'] = $stat['type'];
$entry['fromDt'] = Carbon::createFromTimestamp($stat['start']);
$entry['toDt'] = Carbon::createFromTimestamp($stat['end']);
$entry['scheduleId'] = (int) $stat['scheduleId'];
$entry['mediaId'] = (int) $stat['mediaId'];
$entry['layoutId'] = (int) $stat['layoutId'];
$entry['display'] = $display;
$entry['campaignId'] = (int) $stat['campaignId'];
$entry['tag'] = $stat['tag'];
$entry['widgetId'] = (int) $stat['widgetId'];
$entry['duration'] = (int) $stat['duration'];
$entry['count'] = (int) $stat['count'];
// Add stats in store $this->stats
$this->timeSeriesStore->addStat($entry);
$statCount++;
}
// Write stats
if ($statCount > 0) {
$this->timeSeriesStore->addStatFinalize();
} else {
$this->appendRunMessage('No stat to migrate from stat to mongo');
$this->log->debug('No stat to migrate from stat to mongo');
}
// Give Mongo time to recover
if ($watermark > 0) {
$this->appendRunMessage('- '. $count. ' rows migrated.');
$this->log->debug('Mongo stats migration from stat. '.$count.' rows effected, sleeping.');
sleep($options['pauseBetweenLoops']);
}
}
}
function migrationStatArchiveToMongo($options)
{
$this->appendRunMessage(PHP_EOL. '## Moving from stat_archive to Mongo');
$fileName = $this->config->getSetting('LIBRARY_LOCATION') . '.watermark_stat_archive_mongo.txt';
// Get low watermark from file
$watermark = $this->getWatermarkFromFile($fileName, 'stat_archive');
$numberOfLoops = 0;
while ($watermark > 0) {
$count = 0;
/** @noinspection SqlResolve */
$stats = $this->store->getConnection()->prepare('
SELECT statId, type, statDate, scheduleId, displayId, layoutId, mediaId, widgetId, start, `end`, tag
FROM stat_archive
WHERE statId < :watermark
ORDER BY statId DESC LIMIT :limit
');
$stats->bindParam(':watermark', $watermark, \PDO::PARAM_INT);
$stats->bindParam(':limit', $options['numberOfRecords'], \PDO::PARAM_INT);
// Run the select
$stats->execute();
// Keep count how many stats we've processed
$recordCount = $stats->rowCount();
$count+= $recordCount;
// End of records
if ($this->checkEndOfRecords($recordCount, $fileName) === true) {
$this->appendRunMessage(PHP_EOL. '# End of records.' . PHP_EOL. '- Dropping stat_archive.');
$this->log->debug('End of records in stat_archive (migration to Mongo). Dropping table.');
// Drop the stat_archive table
/** @noinspection SqlResolve */
$this->store->update('DROP TABLE `stat_archive`;', []);
$this->appendRunMessage(__('Done.'. PHP_EOL));
break;
}
// Loops limit end - task will need to rerun again to start from the saved watermark
if ($this->checkLoopLimits($numberOfLoops, $options['numberOfLoops'], $fileName, $watermark) === true) {
break;
}
$numberOfLoops++;
$temp = [];
$statIgnoredCount = 0;
$statCount = 0;
foreach ($stats->fetchAll() as $stat) {
// We get the watermark now because if we skip the records our watermark will never reach 0
$watermark = $stat['statId'];
// Get display
$display = $this->getDisplay((int) $stat['displayId']);
if (empty($display)) {
$this->log->error('Display not found. Display Id: '. $stat['displayId']);
$statIgnoredCount+= 1;
continue;
}
$entry = [];
// Get campaignId
if (($stat['type'] != 'event') && ($stat['layoutId'] != null)) {
try {
// Search the campaignId in the temp array first to reduce query in layouthistory
if (array_key_exists($stat['layoutId'], $temp)) {
$campaignId = $temp[$stat['layoutId']];
} else {
$campaignId = $this->layoutFactory->getCampaignIdFromLayoutHistory($stat['layoutId']);
$temp[$stat['layoutId']] = $campaignId;
}
} catch (NotFoundException $error) {
$statIgnoredCount+= 1;
continue;
}
} else {
$campaignId = 0;
}
$statDate = Carbon::createFromTimestamp($stat['statDate']);
$start = Carbon::createFromTimestamp($stat['start']);
$end = Carbon::createFromTimestamp($stat['end']);
$entry['statDate'] = $statDate;
$entry['type'] = $stat['type'];
$entry['fromDt'] = $start;
$entry['toDt'] = $end;
$entry['scheduleId'] = (int) $stat['scheduleId'];
$entry['display'] = $display;
$entry['campaignId'] = (int) $campaignId;
$entry['layoutId'] = (int) $stat['layoutId'];
$entry['mediaId'] = (int) $stat['mediaId'];
$entry['tag'] = $stat['tag'];
$entry['widgetId'] = (int) $stat['widgetId'];
$entry['duration'] = (int) $end->diffInSeconds($start);
$entry['count'] = isset($stat['count']) ? (int) $stat['count'] : 1;
// Add stats in store $this->stats
$this->timeSeriesStore->addStat($entry);
$statCount++;
}
if ($statIgnoredCount > 0) {
$this->appendRunMessage($statIgnoredCount. ' stat(s) were ignored while migrating');
}
// Write stats
if ($statCount > 0) {
$this->timeSeriesStore->addStatFinalize();
} else {
$this->appendRunMessage('No stat to migrate from stat archive to mongo');
$this->log->debug('No stat to migrate from stat archive to mongo');
}
// Give Mongo time to recover
if ($watermark > 0) {
if ($statCount > 0) {
$this->appendRunMessage('- '. $count. ' rows processed. ' . $statCount. ' rows migrated');
$this->log->debug('Mongo stats migration from stat_archive. '.$count.' rows effected, sleeping.');
}
sleep($options['pauseBetweenLoops']);
}
}
}
// Get low watermark from file
function getWatermarkFromFile($fileName, $tableName)
{
if (file_exists($fileName)) {
$file = fopen($fileName, 'r');
$line = fgets($file);
fclose($file);
$watermark = (int) $line;
} else {
// Save mysql low watermark in file if .watermark.txt file is not found
/** @noinspection SqlResolve */
$statId = $this->store->select('SELECT MAX(statId) as statId FROM '.$tableName, []);
$watermark = (int) $statId[0]['statId'];
$out = fopen($fileName, 'w');
fwrite($out, $watermark);
fclose($out);
}
// We need to increase it
$watermark+= 1;
$this->appendRunMessage('- Initial watermark is '.$watermark);
return $watermark;
}
// Check if end of records
function checkEndOfRecords($recordCount, $fileName)
{
if ($recordCount == 0) {
// No records in stat, save watermark in file
$watermark = -1;
$out = fopen($fileName, 'w');
fwrite($out, $watermark);
fclose($out);
return true;
}
return false;
}
// Check loop limits
function checkLoopLimits($numberOfLoops, $optionsNumberOfLoops, $fileName, $watermark)
{
if ($numberOfLoops == $optionsNumberOfLoops) {
// Save watermark in file
$watermark = $watermark - 1;
$this->log->debug(' Loop reached limit. Watermark is now '.$watermark);
$out = fopen($fileName, 'w');
fwrite($out, $watermark);
fclose($out);
return true;
}
return false;
}
// Disable the task
function disableTask()
{
$this->appendRunMessage('# Disabling task.');
$this->log->debug('Disabling task.');
$this->getTask()->isActive = 0;
$this->getTask()->save();
$this->appendRunMessage(__('Done.'. PHP_EOL));
return;
}
// Disable the task
function quitMigrationTaskOrDisableStatArchiveTask()
{
// Quit the migration task if stat archive task is running
if ($this->archiveTask->status == Task::$STATUS_RUNNING) {
$this->appendRunMessage('Quitting the stat migration task as stat archive task is running');
$this->log->debug('Quitting the stat migration task as stat archive task is running.');
return;
}
// Mark the Stats Archiver as disabled if it is active
if ($this->archiveTask->isActive == 1) {
$this->archiveTask->isActive = 0;
$this->archiveTask->save();
$this->store->commitIfNecessary();
$this->appendRunMessage('Disabling Stats Archive Task.');
$this->log->debug('Disabling Stats Archive Task.');
}
return;
}
// Cahce/Get display
function getDisplay($displayId)
{
// Get display if in memory
if (array_key_exists($displayId, $this->displays)) {
$display = $this->displays[$displayId];
} elseif (array_key_exists($displayId, $this->displaysNotFound)) {
// Display not found
return false;
} else {
try {
$display = $this->displayFactory->getById($displayId);
// Cache display
$this->displays[$displayId] = $display;
} catch (NotFoundException $error) {
// Cache display not found
$this->displaysNotFound[$displayId] = $displayId;
return false;
}
}
return $display;
}
}

119
lib/XTR/TaskInterface.php Normal file
View File

@@ -0,0 +1,119 @@
<?php
/**
* Copyright (C) 2020 Xibo Signage Ltd
*
* Xibo - Digital Signage - http://www.xibo.org.uk
*
* This file is part of Xibo.
*
* Xibo is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Xibo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Xibo. If not, see <http://www.gnu.org/licenses/>.
*/
namespace Xibo\XTR;
use Psr\Container\ContainerInterface;
use Stash\Interfaces\PoolInterface;
use Xibo\Entity\Task;
use Xibo\Entity\User;
use Xibo\Service\ConfigServiceInterface;
use Xibo\Service\LogServiceInterface;
use Xibo\Storage\StorageServiceInterface;
use Xibo\Storage\TimeSeriesStoreInterface;
use Xibo\Support\Sanitizer\SanitizerInterface;
/**
* Interface TaskInterface
* @package Xibo\XTR
*/
interface TaskInterface
{
/**
* Set the app options
* @param ConfigServiceInterface $config
* @return $this
*/
public function setConfig($config);
/**
* @param LogServiceInterface $logger
* @return $this
*/
public function setLogger($logger);
/**
* @param SanitizerInterface $sanitizer
* @return $this
*/
public function setSanitizer($sanitizer);
/**
* @param $array
* @return SanitizerInterface
*/
public function getSanitizer($array);
/**
* Set the task
* @param Task $task
* @return $this
*/
public function setTask($task);
/**
* @param StorageServiceInterface $store
* @return $this
*/
public function setStore($store);
/**
* @param TimeSeriesStoreInterface $timeSeriesStore
* @return $this
*/
public function setTimeSeriesStore($timeSeriesStore);
/**
* @param PoolInterface $pool
* @return $this
*/
public function setPool($pool);
/**
* @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $dispatcher
* @return $this
*/
public function setDispatcher($dispatcher);
/**
* @param User $user
* @return $this
*/
public function setUser($user);
/**
* @param ContainerInterface $container
* @return $this
*/
public function setFactories($container);
/**
* @return $this
*/
public function run();
/**
* Get the run message
* @return string
*/
public function getRunMessage();
}

216
lib/XTR/TaskTrait.php Normal file
View File

@@ -0,0 +1,216 @@
<?php
/*
* Copyright (c) 2022 Xibo Signage Ltd
*
* Xibo - Digital Signage - http://www.xibo.org.uk
*
* This file is part of Xibo.
*
* Xibo is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Xibo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Xibo. If not, see <http://www.gnu.org/licenses/>.
*/
namespace Xibo\XTR;
use Psr\Log\LoggerInterface;
use Stash\Interfaces\PoolInterface;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Xibo\Entity\Task;
use Xibo\Entity\User;
use Xibo\Helper\SanitizerService;
use Xibo\Service\ConfigServiceInterface;
use Xibo\Service\LogServiceInterface;
use Xibo\Storage\StorageServiceInterface;
use Xibo\Storage\TimeSeriesStoreInterface;
/**
* Class TaskTrait
* @package Xibo\XTR
*/
trait TaskTrait
{
/** @var LogServiceInterface */
private $log;
/** @var ConfigServiceInterface */
private $config;
/** @var SanitizerService */
private $sanitizerService;
/** @var StorageServiceInterface */
private $store;
/** @var TimeSeriesStoreInterface */
private $timeSeriesStore;
/** @var PoolInterface */
private $pool;
/** @var \Symfony\Component\EventDispatcher\EventDispatcherInterface */
private $dispatcher;
/** @var User */
private $user;
/** @var Task */
private $task;
/** @var array */
private $options;
/** @var string */
private $runMessage;
/** @inheritdoc */
public function setConfig($config)
{
$this->config = $config;
return $this;
}
/**
* @return \Xibo\Service\ConfigServiceInterface
*/
protected function getConfig(): ConfigServiceInterface
{
return $this->config;
}
/** @inheritdoc */
public function setLogger($logger)
{
$this->log = $logger;
return $this;
}
/**
* @return \Psr\Log\LoggerInterface
*/
private function getLogger(): LoggerInterface
{
return $this->log->getLoggerInterface();
}
/**
* @param $array
* @return \Xibo\Support\Sanitizer\SanitizerInterface
*/
public function getSanitizer($array)
{
return $this->sanitizerService->getSanitizer($array);
}
/** @inheritdoc */
public function setSanitizer($sanitizer)
{
$this->sanitizerService = $sanitizer;
return $this;
}
/** @inheritdoc */
public function setTask($task)
{
$options = $task->options;
if (property_exists($this, 'defaultConfig'))
$options = array_merge($this->defaultConfig, $options);
$this->task = $task;
$this->options = $options;
return $this;
}
/** @inheritdoc */
public function setStore($store)
{
$this->store = $store;
return $this;
}
/** @inheritdoc */
public function setTimeSeriesStore($timeSeriesStore)
{
$this->timeSeriesStore = $timeSeriesStore;
return $this;
}
/** @inheritdoc */
public function setPool($pool)
{
$this->pool = $pool;
return $this;
}
/**
* @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $dispatcher
* @return $this
*/
public function setDispatcher($dispatcher)
{
$this->dispatcher = $dispatcher;
return $this;
}
/**
* @return \Symfony\Component\EventDispatcher\EventDispatcherInterface
*/
protected function getDispatcher(): EventDispatcherInterface
{
return $this->dispatcher;
}
/** @inheritdoc */
public function setUser($user)
{
$this->user = $user;
return $this;
}
/** @inheritdoc */
public function getRunMessage()
{
return $this->runMessage;
}
/**
* Get task
* @return Task
*/
private function getTask()
{
return $this->task;
}
/**
* @param $option
* @param $default
* @return mixed
*/
private function getOption($option, $default)
{
return $this->options[$option] ?? $default;
}
/**
* Append Run Message
* @param $message
*/
private function appendRunMessage($message)
{
if ($this->runMessage === null)
$this->runMessage = '';
$this->runMessage .= $message . PHP_EOL;
}
}

View File

@@ -0,0 +1,52 @@
<?php
/*
* Spring Signage Ltd - http://www.springsignage.com
* Copyright (C) 2018 Spring Signage Ltd
* (UpdateEmptyVideoDurations.php)
*/
namespace Xibo\XTR;
use Xibo\Factory\MediaFactory;
use Xibo\Factory\ModuleFactory;
/**
* Class UpdateEmptyVideoDurations
* @package Xibo\XTR
*
* update video durations
*/
class UpdateEmptyVideoDurations implements TaskInterface
{
use TaskTrait;
/** @var MediaFactory */
private $mediaFactory;
/** @var ModuleFactory */
private $moduleFactory;
/** @inheritdoc */
public function setFactories($container)
{
$this->mediaFactory = $container->get('mediaFactory');
$this->moduleFactory = $container->get('moduleFactory');
return $this;
}
/** @inheritdoc */
public function run()
{
$libraryLocation = $this->config->getSetting('LIBRARY_LOCATION');
$module = $this->moduleFactory->getByType('video');
$videos = $this->mediaFactory->getByMediaType($module->type);
foreach ($videos as $video) {
if ($video->duration == 0) {
// Update
$video->duration = $module->fetchDurationOrDefaultFromFile($libraryLocation . $video->storedAs);
$video->save(['validate' => false]);
}
}
}
}

View File

@@ -0,0 +1,248 @@
<?php
/*
* Copyright (C) 2024 Xibo Signage Ltd
*
* Xibo - Digital Signage - https://xibosignage.com
*
* This file is part of Xibo.
*
* Xibo is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Xibo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Xibo. If not, see <http://www.gnu.org/licenses/>.
*/
namespace Xibo\XTR;
use Xibo\Support\Exception\GeneralException;
use Xibo\Support\Exception\NotFoundException;
/**
* Class WidgetCompatibilityTask
* Run only once when upgrading widget from v3 to v4
* @package Xibo\XTR
*/
class WidgetCompatibilityTask implements TaskInterface
{
use TaskTrait;
/** @var \Xibo\Factory\ModuleFactory */
private $moduleFactory;
/** @var \Xibo\Factory\WidgetFactory */
private $widgetFactory;
/** @var \Xibo\Factory\LayoutFactory */
private $layoutFactory;
/** @var \Xibo\Factory\playlistFactory */
private $playlistFactory;
/** @var \Xibo\Factory\TaskFactory */
private $taskFactory;
/** @var \Xibo\Factory\RegionFactory */
private $regionFactory;
/** @var array The cache for layout */
private $layoutCache = [];
/** @inheritdoc */
public function setFactories($container)
{
$this->moduleFactory = $container->get('moduleFactory');
$this->widgetFactory = $container->get('widgetFactory');
$this->layoutFactory = $container->get('layoutFactory');
$this->playlistFactory = $container->get('playlistFactory');
$this->taskFactory = $container->get('taskFactory');
$this->regionFactory = $container->get('regionFactory');
return $this;
}
/** @inheritdoc
*/
public function run()
{
// This task should only be run once when upgrading for the first time from v3 to v4.
// If the Widget Compatibility class is defined, it needs to be executed to upgrade the widgets.
$this->runMessage = '# ' . __('Widget Compatibility') . PHP_EOL . PHP_EOL;
// Get all modules
$modules = $this->moduleFactory->getAll();
// For each module we should get all widgets which are < the schema version of the module installed, and
// upgrade them to the schema version of the module installed
foreach ($modules as $module) {
// Run upgrade - Part 1
// Upgrade a widget having the same module type
$this->getLogger()->debug('run: finding widgets for ' . $module->type
. ' with schema version less than ' . $module->schemaVersion);
$statement = $this->executeStatement($module->type, $module->schemaVersion);
$this->upgradeWidget($statement);
// Run upgrade - Part 2
// Upgrade a widget having the old style module type/legacy type
$legacyTypes = [];
if (count($module->legacyTypes) > 0) {
// Get the name of the module legacy types
$legacyTypes = array_column($module->legacyTypes, 'name'); // TODO Make this efficient
}
// Get module legacy type and update matched widgets
foreach ($legacyTypes as $legacyType) {
$statement = $this->executeStatement($legacyType, $module->schemaVersion);
$this->upgradeWidget($statement);
}
}
// Get Widget Compatibility Task
$compatibilityTask = $this->taskFactory->getByClass('\Xibo\XTR\\WidgetCompatibilityTask');
// Mark the task as disabled if it is active
if ($compatibilityTask->isActive == 1) {
$compatibilityTask->isActive = 0;
$compatibilityTask->save();
$this->store->commitIfNecessary();
$this->appendRunMessage('Disabling widget compatibility task.');
$this->log->debug('Disabling widget compatibility task.');
}
$this->appendRunMessage(__('Done.'. PHP_EOL));
}
/**
*
* @param string $type
* @param int $version
* @return false|\PDOStatement
*/
private function executeStatement(string $type, int $version): bool|\PDOStatement
{
$sql = '
SELECT widget.widgetId
FROM `widget`
WHERE `widget`.`type` = :type
and `widget`.schemaVersion < :version
';
$connection = $this->store->getConnection();
$connection->setAttribute(\PDO::MYSQL_ATTR_USE_BUFFERED_QUERY, true);
// Prepare the statement
$statement = $connection->prepare($sql);
// Execute
$statement->execute([
'type' => $type,
'version' => $version
]);
return $statement;
}
private function upgradeWidget(\PDOStatement $statement): void
{
// Load each widget and its options
// Then run upgrade
while ($row = $statement->fetch(\PDO::FETCH_ASSOC)) {
try {
$widget = $this->widgetFactory->getById((int) $row['widgetId']);
$widget->loadMinimum();
$this->log->debug('WidgetCompatibilityTask: Get Widget: ' . $row['widgetId']);
// Form conditions from the widget's option and value, e.g, templateId==worldclock1
$widgetConditionMatch = [];
foreach ($widget->widgetOptions as $option) {
$widgetConditionMatch[] = $option->option . '==' . $option->value;
}
// Get module
try {
$module = $this->moduleFactory->getByType($widget->type, $widgetConditionMatch);
} catch (NotFoundException $e) {
$this->log->error('Module not found for widget: ' . $widget->type);
$this->appendRunMessage('Module not found for widget: '. $widget->widgetId);
continue;
}
// Run upgrade
if ($module->isWidgetCompatibilityAvailable()) {
// Grab a widget compatibility interface, if there is one
$widgetCompatibilityInterface = $module->getWidgetCompatibilityOrNull();
if ($widgetCompatibilityInterface !== null) {
try {
// Pass the widget through the compatibility interface.
$upgraded = $widgetCompatibilityInterface->upgradeWidget(
$widget,
$widget->schemaVersion,
$module->schemaVersion
);
// Save widget version
if ($upgraded) {
$widget->schemaVersion = $module->schemaVersion;
// Assert the module type, unless the widget has already changed it.
if (!$widget->hasPropertyChanged('type')) {
$widget->type = $module->type;
}
$widget->save(['alwaysUpdate' => true, 'upgrade' => true]);
$this->log->debug('WidgetCompatibilityTask: Upgraded');
}
} catch (\Exception $e) {
$this->log->error('Failed to upgrade for widgetId: ' . $widget->widgetId .
', message: ' . $e->getMessage());
$this->appendRunMessage('Failed to upgrade for widgetId: : '. $widget->widgetId);
}
}
try {
// Get the layout of the widget and set it to rebuild.
$playlist = $this->playlistFactory->getById($widget->playlistId);
// check if the Widget was assigned to a region playlist
if ($playlist->isRegionPlaylist()) {
$playlist->load();
$region = $this->regionFactory->getById($playlist->regionId);
// set the region type accordingly
if ($region->isDrawer === 1) {
$regionType = 'drawer';
} else if (count($playlist->widgets) === 1 && $widget->type !== 'subplaylist') {
$regionType = 'frame';
} else if (count($playlist->widgets) === 0) {
$regionType = 'zone';
} else {
$regionType = 'playlist';
}
$region->type = $regionType;
$region->save(['notify' => false]);
}
$playlist->notifyLayouts();
} catch (\Exception $e) {
$this->log->error('Failed to set layout rebuild for widgetId: ' . $widget->widgetId .
', message: ' . $e->getMessage());
$this->appendRunMessage('Layout rebuild error for widgetId: : '. $widget->widgetId);
}
} else {
$this->getLogger()->debug('upgradeWidget: no compatibility task available for ' . $widget->type);
}
} catch (GeneralException $e) {
$this->log->debug($e->getTraceAsString());
$this->log->error('WidgetCompatibilityTask: Cannot process widget');
}
}
$this->store->commitIfNecessary();
}
}

493
lib/XTR/WidgetSyncTask.php Normal file
View File

@@ -0,0 +1,493 @@
<?php
/*
* Copyright (C) 2024 Xibo Signage Ltd
*
* Xibo - Digital Signage - https://xibosignage.com
*
* This file is part of Xibo.
*
* Xibo is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Xibo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Xibo. If not, see <http://www.gnu.org/licenses/>.
*/
namespace Xibo\XTR;
use Carbon\Carbon;
use Xibo\Entity\Display;
use Xibo\Entity\Module;
use Xibo\Entity\Widget;
use Xibo\Event\WidgetDataRequestEvent;
use Xibo\Factory\WidgetDataFactory;
use Xibo\Helper\DateFormatHelper;
use Xibo\Support\Exception\GeneralException;
use Xibo\Support\Exception\NotFoundException;
use Xibo\Widget\Provider\WidgetProviderInterface;
/**
* Class WidgetSyncTask
* Keep all widgets which have data up to date
* @package Xibo\XTR
*/
class WidgetSyncTask implements TaskInterface
{
use TaskTrait;
/** @var \Xibo\Factory\ModuleFactory */
private $moduleFactory;
/** @var \Xibo\Factory\WidgetFactory */
private $widgetFactory;
/** @var \Xibo\Factory\WidgetDataFactory */
private WidgetDataFactory $widgetDataFactory;
/** @var \Xibo\Factory\MediaFactory */
private $mediaFactory;
/** @var \Xibo\Factory\DisplayFactory */
private $displayFactory;
/** @var \Symfony\Component\EventDispatcher\EventDispatcher */
private $eventDispatcher;
/** @inheritdoc */
public function setFactories($container)
{
$this->moduleFactory = $container->get('moduleFactory');
$this->widgetFactory = $container->get('widgetFactory');
$this->widgetDataFactory = $container->get('widgetDataFactory');
$this->mediaFactory = $container->get('mediaFactory');
$this->displayFactory = $container->get('displayFactory');
$this->eventDispatcher = $container->get('dispatcher');
return $this;
}
/** @inheritdoc */
public function run()
{
// Track the total time we've spent caching
$timeCaching = 0.0;
$countWidgets = 0;
$cutOff = Carbon::now()->subHours(2);
// Update for widgets which are active on displays, or for displays which have been active recently.
$sql = '
SELECT DISTINCT `requiredfile`.itemId, `requiredfile`.complete
FROM `requiredfile`
INNER JOIN `display`
ON `display`.displayId = `requiredfile`.displayId
WHERE `requiredfile`.type = \'D\'
AND (`display`.loggedIn = 1 OR `display`.lastAccessed > :lastAccessed)
ORDER BY `requiredfile`.complete DESC, `requiredfile`.itemId
';
$smt = $this->store->getConnection()->prepare($sql);
$smt->execute(['lastAccessed' => $cutOff->unix()]);
$row = true;
while ($row) {
$row = $smt->fetch(\PDO::FETCH_ASSOC);
try {
if ($row !== false) {
$widgetId = (int)$row['itemId'];
$this->getLogger()->debug('widgetSyncTask: processing itemId ' . $widgetId);
// What type of widget do we have here.
$widget = $this->widgetFactory->getById($widgetId)->load();
// Get the module
$module = $this->moduleFactory->getByType($widget->type);
// If this widget's module expects data to be provided (i.e. has a datatype) then make sure that
// data is cached ahead of time here.
// This also refreshes any library or external images referenced by the data so that they aren't
// considered for removal.
if ($module->isDataProviderExpected()) {
$this->getLogger()->debug('widgetSyncTask: data provider expected.');
// Record start time
$countWidgets++;
$startTime = microtime(true);
// Grab a widget interface, if there is one
$widgetInterface = $module->getWidgetProviderOrNull();
// Is the cache key display specific?
$dataProvider = $module->createDataProvider($widget);
$cacheKey = $widgetInterface?->getDataCacheKey($dataProvider);
if ($cacheKey === null) {
$cacheKey = $module->dataCacheKey;
}
// Refresh the cache if needed.
$isDisplaySpecific = str_contains($cacheKey, '%displayId%')
|| (str_contains($cacheKey, '%useDisplayLocation%')
&& $dataProvider->getProperty('useDisplayLocation') == 1);
// We're either assigning all media to all displays, or we're assigning them one by one
if ($isDisplaySpecific) {
$this->getLogger()->debug('widgetSyncTask: cache is display specific');
// We need to run the cache for every display this widget is assigned to.
foreach ($this->getDisplays($widget) as $display) {
$cacheData = $this->cache(
$module,
$widget,
$widgetInterface,
$display,
);
$this->linkDisplays(
$widget->widgetId,
[$display],
$cacheData['mediaIds'],
$cacheData['fromCache']
);
}
} else {
$this->getLogger()->debug('widgetSyncTask: cache is not display specific');
// Just a single run will do it.
$cacheData = $this->cache($module, $widget, $widgetInterface, null);
$this->linkDisplays(
$widget->widgetId,
$this->getDisplays($widget),
$cacheData['mediaIds'],
$cacheData['fromCache']
);
}
// Record end time and aggregate for final total
$duration = (microtime(true) - $startTime);
$timeCaching = $timeCaching + $duration;
$this->log->debug('widgetSyncTask: Took ' . $duration
. ' seconds to check and/or cache widgetId ' . $widget->widgetId);
// Commit so that any images we've downloaded have their cache times updated for the
// next request, this makes sense because we've got a file cache that is already written
// out.
$this->store->commitIfNecessary();
}
}
} catch (GeneralException $xiboException) {
$this->log->debug($xiboException->getTraceAsString());
$this->log->error('widgetSyncTask: Cannot process widget ' . $widgetId
. ', E = ' . $xiboException->getMessage());
}
}
// Remove display_media records which have not been touched for a defined period of time.
$this->removeOldDisplayLinks($cutOff);
$this->log->info('Total time spent caching is ' . $timeCaching . ', synced ' . $countWidgets . ' widgets');
$this->appendRunMessage('Synced ' . $countWidgets . ' widgets');
}
/**
* @param Module $module
* @param Widget $widget
* @param WidgetProviderInterface|null $widgetInterface
* @param Display|null $display
* @return array
* @throws GeneralException
*/
private function cache(
Module $module,
Widget $widget,
?WidgetProviderInterface $widgetInterface,
?Display $display
): array {
$this->getLogger()->debug('cache: ' . $widget->widgetId . ' for display: ' . ($display?->displayId ?? 0));
// Each time we call this we use a new provider
$dataProvider = $module->createDataProvider($widget);
$dataProvider->setMediaFactory($this->mediaFactory);
// Set our provider up for the display
$dataProvider->setDisplayProperties(
$display?->latitude ?: $this->getConfig()->getSetting('DEFAULT_LAT'),
$display?->longitude ?: $this->getConfig()->getSetting('DEFAULT_LONG'),
$display?->displayId ?? 0
);
$widgetDataProviderCache = $this->moduleFactory->createWidgetDataProviderCache();
// Get the cache key
$cacheKey = $this->moduleFactory->determineCacheKey(
$module,
$widget,
$display?->displayId ?? 0,
$dataProvider,
$widgetInterface
);
// Get the data modified date
$dataModifiedDt = null;
if ($widgetInterface !== null) {
$dataModifiedDt = $widgetInterface->getDataModifiedDt($dataProvider);
if ($dataModifiedDt !== null) {
$this->getLogger()->debug('cache: data modifiedDt is ' . $dataModifiedDt->toAtomString());
}
}
// Will we use fallback data if available?
$showFallback = $widget->getOptionValue('showFallback', 'never');
if ($showFallback !== 'never') {
// What data type are we dealing with?
try {
$dataTypeFields = [];
foreach ($this->moduleFactory->getDataTypeById($module->dataType)->fields as $field) {
$dataTypeFields[$field->id] = $field->type;
}
// Potentially we will, so get the modifiedDt of this fallback data.
$fallbackModifiedDt = $this->widgetDataFactory->getModifiedDtForWidget($widget->widgetId);
if ($fallbackModifiedDt !== null) {
$this->getLogger()->debug('cache: fallback modifiedDt is ' . $fallbackModifiedDt->toAtomString());
$dataModifiedDt = max($dataModifiedDt, $fallbackModifiedDt);
}
} catch (NotFoundException) {
$this->getLogger()->info('cache: widget will fallback set where the module does not support it');
$dataTypeFields = null;
}
} else {
$dataTypeFields = null;
}
// Is this data from cache?
$fromCache = false;
if (!$widgetDataProviderCache->decorateWithCache($dataProvider, $cacheKey, $dataModifiedDt)
|| $widgetDataProviderCache->isCacheMissOrOld()
) {
$this->getLogger()->debug('cache: Cache expired, pulling fresh: key: ' . $cacheKey);
$dataProvider->clearData();
$dataProvider->clearMeta();
$dataProvider->addOrUpdateMeta('showFallback', $showFallback);
try {
if ($widgetInterface !== null) {
$widgetInterface->fetchData($dataProvider);
} else {
$dataProvider->setIsUseEvent();
}
if ($dataProvider->isUseEvent()) {
$this->getDispatcher()->dispatch(
new WidgetDataRequestEvent($dataProvider),
WidgetDataRequestEvent::$NAME
);
}
// Before caching images, check to see if the data provider is handled
$isFallback = false;
if ($showFallback !== 'never'
&& $dataTypeFields !== null
&& (
count($dataProvider->getErrors()) > 0
|| count($dataProvider->getData()) <= 0
|| $showFallback === 'always'
)
) {
// Error or no data.
// Pull in the fallback data
foreach ($this->widgetDataFactory->getByWidgetId($dataProvider->getWidgetId()) as $item) {
// Handle any special data types in the fallback data
foreach ($item->data as $itemId => $itemData) {
if (!empty($itemData)
&& array_key_exists($itemId, $dataTypeFields)
&& $dataTypeFields[$itemId] === 'image'
) {
$item->data[$itemId] = $dataProvider->addLibraryFile($itemData);
}
}
$dataProvider->addItem($item->data);
// Indicate we've been handled by fallback data
$isFallback = true;
}
if ($isFallback) {
$dataProvider->addOrUpdateMeta('includesFallback', true);
}
}
// Remove fallback data from the cache if no-longer needed
if (!$isFallback) {
$dataProvider->addOrUpdateMeta('includesFallback', false);
}
// Do we have images?
// They could be library images (i.e. they already exist) or downloads
$mediaIds = $dataProvider->getImageIds();
if (count($mediaIds) > 0) {
// Process the downloads.
$this->mediaFactory->processDownloads(function ($media) {
/** @var \Xibo\Entity\Media $media */
// Success
// We don't need to do anything else, references to mediaId will be built when we decorate
// the HTML.
$this->getLogger()->debug('cache: Successfully downloaded ' . $media->mediaId);
}, function ($media) use (&$mediaIds) {
/** @var \Xibo\Entity\Media $media */
// Error
// Remove it
unset($mediaIds[$media->mediaId]);
});
}
// Save to cache
if ($dataProvider->isHandled() || $isFallback) {
$widgetDataProviderCache->saveToCache($dataProvider);
}
} finally {
$widgetDataProviderCache->finaliseCache();
}
} else {
$this->getLogger()->debug('cache: Cache still valid, key: ' . $cacheKey);
$fromCache = true;
// Get the existing mediaIds so that we can maintain the links to displays.
$mediaIds = $widgetDataProviderCache->getCachedMediaIds();
}
return [
'mediaIds' => $mediaIds,
'fromCache' => $fromCache
];
}
/**
* @param \Xibo\Entity\Widget $widget
* @return Display[]
*/
private function getDisplays(Widget $widget): array
{
$sql = '
SELECT DISTINCT `requiredfile`.`displayId`
FROM `requiredfile`
WHERE itemId = :widgetId
AND type = \'D\'
';
$displayIds = [];
foreach ($this->store->select($sql, ['widgetId' => $widget->widgetId]) as $record) {
$displayId = intval($record['displayId']);
try {
$displayIds[] = $this->displayFactory->getById($displayId);
} catch (NotFoundException) {
$this->getLogger()->error('getDisplayIds: unknown displayId: ' . $displayId);
}
}
return $displayIds;
}
/**
* Link an array of displays with an array of media
* @param int $widgetId
* @param Display[] $displays
* @param int[] $mediaIds
* @param bool $fromCache
* @return void
*/
private function linkDisplays(int $widgetId, array $displays, array $mediaIds, bool $fromCache): void
{
$this->getLogger()->debug('linkDisplays: ' . count($displays) . ' displays, ' . count($mediaIds) . ' media');
$sql = '
INSERT INTO `display_media` (`displayId`, `mediaId`, `modifiedAt`)
VALUES (:displayId, :mediaId, CURRENT_TIMESTAMP)
ON DUPLICATE KEY UPDATE `modifiedAt` = CURRENT_TIMESTAMP
';
// Run invididual updates so that we can see if we've made a change.
// With ON DUPLICATE KEY UPDATE, the affected-rows value per row is
// 1 if the row is inserted as a new row,
// 2 if an existing row is updated and
// 0 if the existing row is set to its current values.
foreach ($displays as $display) {
$shouldNotify = false;
foreach ($mediaIds as $mediaId) {
try {
$affected = $this->store->update($sql, [
'displayId' => $display->displayId,
'mediaId' => $mediaId
]);
if ($affected == 1) {
$shouldNotify = true;
}
} catch (\PDOException) {
// We link what we can, and log any failures.
$this->getLogger()->error('linkDisplays: unable to link displayId: ' . $display->displayId
. ' to mediaId: ' . $mediaId . ', most likely the media has since gone');
}
}
// When should we notify?
// ----------------------
// Newer displays (>= v4) should clear their cache only if linked media has changed
// Older displays (< v4) should check in immediately on change
if ($display->clientCode >= 400) {
if ($shouldNotify) {
$this->displayFactory->getDisplayNotifyService()->collectLater()
->notifyByDisplayId($display->displayId);
}
if (!$fromCache) {
$this->displayFactory->getDisplayNotifyService()
->notifyDataUpdate($display, $widgetId);
}
} else {
$this->displayFactory->getDisplayNotifyService()->collectNow()
->notifyByDisplayId($display->displayId);
}
}
}
/**
* Remove any display/media links which have expired.
* @param Carbon $cutOff
* @return void
*/
private function removeOldDisplayLinks(Carbon $cutOff): void
{
$sql = '
DELETE
FROM `display_media`
WHERE `modifiedAt` < :modifiedAt
AND `display_media`.`displayId` IN (
SELECT `displayId`
FROM `display`
WHERE `display`.`loggedIn` = 1
OR `display`.`lastAccessed` > :lastAccessed
)
';
$this->store->update($sql, [
'modifiedAt' => Carbon::now()->subDay()->format(DateFormatHelper::getSystemFormat()),
'lastAccessed' => $cutOff->unix(),
]);
}
}