Files
Cloud-CMS/lib/XTR/WidgetSyncTask.php

494 lines
19 KiB
PHP
Raw Permalink Normal View History

2025-12-02 10:32:59 -05:00
<?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(),
]);
}
}