dataManager = $dataManager;
$this->config = $dataManager->getAllConfig();
$this->baseUrl = $baseUrl ?: $this->config['rss_base_url'] ?? 'http://localhost/';
// Ensure base URL ends with slash
$this->baseUrl = rtrim($this->baseUrl, '/').'/';
// Initialize caching
$this->cacheDir = __DIR__.'/../data/cache';
$this->cacheFile = $this->cacheDir.'/rss_feed.xml';
$this->cacheLifetime = 300; // 5 minutes default cache lifetime
// Ensure cache directory exists
if (!is_dir($this->cacheDir)) {
mkdir($this->cacheDir, 0755, true);
}
}
/**
* Generate complete RSS feed XML with caching support
* @param bool $forceRegenerate Force regeneration ignoring cache
* @return string RSS XML content
*/
public function generateFeed($forceRegenerate = false)
{
try {
// Check if cached version exists and is still valid
if (!$forceRegenerate && $this->isCacheValid()) {
return file_get_contents($this->cacheFile);
}
$enabledAlbums = $this->getEnabledAlbums();
$rssItems = [];
// Collect photos from all RSS-enabled albums
foreach ($enabledAlbums as $album) {
$photos = $this->dataManager->getPhotos($album['id']);
foreach ($photos as $photo) {
$rssItems[] = $this->formatPhotoForRSS($photo, $album);
}
}
// Sort items by upload date (newest first)
usort($rssItems, function ($a, $b) {
return strtotime($b['pubDate']) - strtotime($a['pubDate']);
});
// Limit items based on configuration
$maxItems = $this->config['rss_max_items'] ?? 50;
$rssItems = array_slice($rssItems, 0, $maxItems);
// Generate RSS XML
$xml = $this->generateRSSHeader();
foreach ($rssItems as $item) {
$xml .= $this->generateRSSItem($item);
}
$xml .= $this->generateRSSFooter();
// Validate the generated XML
if (!$this->validateRSSXML($xml)) {
throw new Exception('Generated RSS XML is invalid');
}
// Cache the generated feed
$this->cacheRSSFeed($xml);
return $xml;
} catch (Exception $e) {
throw new Exception('Failed to generate RSS feed: '.$e->getMessage());
}
}
/**
* Get albums that are enabled for RSS
* @return array Array of RSS-enabled albums
*/
public function getEnabledAlbums()
{
try {
$allAlbums = $this->dataManager->getAlbums(true);
return array_filter($allAlbums, function ($album) {
return !empty($album['is_rss_enabled']) && $album['photo_count'] > 0;
});
} catch (Exception $e) {
throw new Exception('Failed to get enabled albums: '.$e->getMessage());
}
}
/**
* Format photo data for RSS item
* @param array $photo Photo data
* @param array $album Album data
* @return array RSS item data
*/
private function formatPhotoForRSS($photo, $album)
{
$photoUrl = $this->baseUrl."albums/{$album['id']}/{$photo['filename']}";
$albumUrl = $this->baseUrl."?album={$album['id']}";
// Create enhanced description with photo metadata and direct album link
$description = "
Photo from album: {$album['title']}
";
if (!empty($album['description'])) {
$description .= "{$album['description']}
";
}
$description .= "
";
// Add direct link to view full album
$description .= "📸 View Full Album
";
// Add photo metadata
if (!empty($photo['width']) && !empty($photo['height'])) {
$description .= "📐 Dimensions: {$photo['width']} × {$photo['height']} pixels
";
}
if (!empty($photo['file_size'])) {
$fileSize = $this->formatBytes($photo['file_size']);
$description .= "💾 File size: {$fileSize}
";
}
if (!empty($photo['uploaded_at'])) {
$uploadDate = date('F j, Y g:i A', strtotime($photo['uploaded_at']));
$description .= "📅 Uploaded: {$uploadDate}
";
}
// Create enhanced title with album name
$title = $photo['original_filename'] ?? $photo['filename'];
if (!empty($album['title'])) {
$title .= " | {$album['title']}";
}
return [
'title' => $this->escapeXml($title),
'link' => $this->escapeXml($albumUrl),
'description' => $this->escapeXml($description),
'pubDate' => $this->formatRSSDate($photo['uploaded_at']),
'guid' => $this->escapeXml($photoUrl),
'enclosure' => [
'url' => $this->escapeXml($photoUrl),
'length' => $photo['file_size'] ?? 0,
'type' => 'image/webp'
]
];
}
/**
* Generate RSS header
* @return string RSS header XML
*/
private function generateRSSHeader()
{
$title = $this->escapeXml($this->config['rss_title'] ?? 'Photo Gallery RSS Feed');
$description = $this->escapeXml($this->config['rss_description'] ?? 'Latest photos from selected albums');
$link = $this->escapeXml($this->baseUrl);
$buildDate = $this->formatRSSDate(date('c'));
return ''."\n".
''."\n".
''."\n".
"{$title}\n".
"{$link}\n".
"{$description}\n".
"en-us\n".
"{$buildDate}\n".
"Photo Gallery Backend\n".
"\n";
}
/**
* Generate RSS footer
* @return string RSS footer XML
*/
private function generateRSSFooter()
{
return "\n\n";
}
/**
* Generate single RSS item
* @param array $item RSS item data
* @return string RSS item XML
*/
private function generateRSSItem($item)
{
$xml = "- \n";
$xml .= "{$item['title']}\n";
$xml .= "{$item['link']}\n";
$xml .= "\n";
$xml .= "{$item['pubDate']}\n";
$xml .= "{$item['guid']}\n";
if (!empty($item['enclosure'])) {
$xml .= "\n";
}
$xml .= "
\n";
return $xml;
}
/**
* Format date for RSS (RFC 2822 format)
* @param string $date ISO date string
* @return string RFC 2822 formatted date
*/
private function formatRSSDate($date)
{
if (empty($date)) {
return date('r');
}
return date('r', strtotime($date));
}
/**
* Escape XML special characters
* @param string $text Text to escape
* @return string Escaped text
*/
private function escapeXml($text)
{
return htmlspecialchars($text, ENT_XML1 | ENT_COMPAT, 'UTF-8');
}
/**
* Format bytes to human readable format
* @param int $bytes Number of bytes
* @return string Formatted string
*/
private function formatBytes($bytes)
{
if ($bytes == 0) {
return '0 B';
}
$units = ['B', 'KB', 'MB', 'GB'];
$factor = floor(log($bytes, 1024));
return sprintf('%.1f %s', $bytes / pow(1024, $factor), $units[$factor]);
}
/**
* Check if cached RSS feed is still valid
* @return bool True if cache is valid
*/
private function isCacheValid()
{
if (!file_exists($this->cacheFile)) {
return false;
}
$cacheTime = filemtime($this->cacheFile);
$currentTime = time();
// Check if cache has expired
if (($currentTime - $cacheTime) > $this->cacheLifetime) {
return false;
}
// Check if any RSS-enabled albums have been modified since cache was created
$enabledAlbums = $this->getEnabledAlbums();
foreach ($enabledAlbums as $album) {
if (strtotime($album['updated_at']) > $cacheTime) {
return false;
}
}
return true;
}
/**
* Cache the RSS feed content
* @param string $xml RSS XML content
* @return bool Success status
*/
private function cacheRSSFeed($xml)
{
try {
return file_put_contents($this->cacheFile, $xml) !== false;
} catch (Exception $e) {
error_log('Failed to cache RSS feed: '.$e->getMessage());
return false;
}
}
/**
* Validate RSS XML format
* @param string $xml RSS XML content
* @return bool True if valid
*/
private function validateRSSXML($xml)
{
try {
// Enable user error handling
libxml_use_internal_errors(true);
libxml_clear_errors();
// Create DOMDocument and load XML
$dom = new DOMDocument();
$dom->loadXML($xml);
// Check for XML errors
$errors = libxml_get_errors();
if (!empty($errors)) {
error_log('RSS XML validation errors: '.print_r($errors, true));
return false;
}
// Validate RSS structure
$rssElements = $dom->getElementsByTagName('rss');
if ($rssElements->length !== 1) {
error_log('RSS XML validation error: Missing or multiple RSS elements');
return false;
}
$channelElements = $dom->getElementsByTagName('channel');
if ($channelElements->length !== 1) {
error_log('RSS XML validation error: Missing or multiple channel elements');
return false;
}
// Check required channel elements
$requiredElements = ['title', 'link', 'description'];
$channel = $channelElements->item(0);
foreach ($requiredElements as $element) {
$elements = $channel->getElementsByTagName($element);
if ($elements->length === 0) {
error_log("RSS XML validation error: Missing required element: {$element}");
return false;
}
}
return true;
} catch (Exception $e) {
error_log('RSS XML validation exception: '.$e->getMessage());
return false;
} finally {
// Restore error handling
libxml_use_internal_errors(false);
}
}
/**
* Invalidate RSS cache (force regeneration on next request)
* @return bool Success status
*/
public function invalidateCache()
{
if (file_exists($this->cacheFile)) {
return unlink($this->cacheFile);
}
return true;
}
/**
* Trigger RSS feed regeneration (auto-update mechanism)
* Called when photos are added or albums are modified
* @return bool Success status
*/
public function triggerAutoUpdate()
{
try {
// Invalidate cache first
$this->invalidateCache();
// Generate new feed (this will create new cache)
$this->generateFeed(true);
return true;
} catch (Exception $e) {
error_log('RSS auto-update failed: '.$e->getMessage());
return false;
}
}
/**
* Get RSS feed discovery meta tags for HTML head
* @return string HTML meta tags
*/
public function getRSSDiscoveryTags()
{
$rssUrl = $this->baseUrl.'api.php?action=getRSSFeed';
$title = $this->config['rss_title'] ?? 'Photo Gallery RSS Feed';
return '';
}
/**
* Get cache statistics
* @return array Cache information
*/
public function getCacheStats()
{
$stats = [
'cache_exists' => file_exists($this->cacheFile),
'cache_valid' => false,
'cache_size' => 0,
'cache_age' => 0,
'cache_lifetime' => $this->cacheLifetime
];
if ($stats['cache_exists']) {
$stats['cache_size'] = filesize($this->cacheFile);
$stats['cache_age'] = time() - filemtime($this->cacheFile);
$stats['cache_valid'] = $this->isCacheValid();
}
return $stats;
}
}