Browse Source

Merge branch 'feature/jellystat-popup-metadata' into v2-master: JellyStat frontend/backend improvements (source inference, users aggregation, uniform UI, full-width users)

mgomon 8 tháng trước cách đây
mục cha
commit
60225f2148

+ 13 - 3
api/classes/organizr.class.php

@@ -66,6 +66,7 @@ class Organizr
 	use WeatherHomepageItem;
 	use uTorrentHomepageItem;
 	use UptimeKumaHomepageItem;
+	use JellyStatHomepageItem;
 
 	// ===================================
 	// Organizr Version
@@ -757,7 +758,7 @@ class Organizr
 		}
 	}
 
-	public function setResponse(int $responseCode = 200, string $message = null, $data = null)
+	public function setResponse(int $responseCode = 200, ?string $message = null, $data = null)
 	{
 		switch ($responseCode) {
 			case 200:
@@ -4627,6 +4628,13 @@ class Organizr
 					$class .= ' faded';
 				}
 				break;
+			case 'homepageOrderJellyStat':
+				$class = 'bg-info';
+				$image = 'plugins/images/homepage/jellystat.png';
+				if (!$this->config['homepageJellyStatEnabled']) {
+					$class .= ' faded';
+				}
+				break;
 				case 'homepageOrderjellyfinnowplaying':
 				case 'homepageOrderjellyfinrecent':
 					$class = 'bg-jellyfin';
@@ -7289,7 +7297,7 @@ class Organizr
 		return $this->processQueries($response);
 	}
 
-	public function youtubeSearch($query)
+public function youtubeSearch($query)
 	{
 		if (!$query) {
 			$this->setAPIResponse('error', 'No query supplied', 422);
@@ -7305,7 +7313,9 @@ class Organizr
 		$key = $keys[$randomKeyIndex];
 		$apikey = ($this->config['youtubeAPI'] !== '') ? $this->config['youtubeAPI'] : $key;
 		$results = false;
-		$url = "https://www.googleapis.com/youtube/v3/search?part=snippet&q=$query+official+trailer&part=snippet&maxResults=1&type=video&videoDuration=short&key=$apikey";
+		// Ensure query is URL-encoded to avoid API errors
+		$safeQuery = urlencode($query . ' official trailer');
+		$url = "https://www.googleapis.com/youtube/v3/search?part=snippet&q={$safeQuery}&maxResults=1&type=video&videoDuration=short&key={$apikey}";
 		$response = Requests::get($url);
 		if ($response->success) {
 			$results = json_decode($response->body, true);

+ 39 - 0
api/config/default.php

@@ -378,6 +378,8 @@ return [
 	'homepageOrderProwlarr' => '43',
 	'homepageOrderUptimeKuma' => '44',
 	'homepageOrderEmbyLiveTVTracker' => '45',
+	'homepageOrderUserWatchStats' => '46',
+	'homepageOrderJellyStat' => '47',
 	'homepageEmbyLiveTVTrackerEnabled' => false,
 	'homepageEmbyLiveTVTrackerAuth' => '1',
 	'homepageEmbyLiveTVTrackerRefresh' => '5',
@@ -389,6 +391,43 @@ return [
 	'homepageEmbyLiveTVTrackerMaxItems' => '10',
 	'homepageEmbyLiveTVTrackerShowCompleted' => true,
 	'homepageEmbyLiveTVTrackerMaxCompletedItems' => '5',
+	'homepageUserWatchStatsEnabled' => false,
+	'homepageUserWatchStatsAuth' => '1',
+	'homepageUserWatchStatsRefresh' => '30',
+	'homepageUserWatchStatsService' => 'plex',
+	'homepageUserWatchStatsURL' => '',
+	'homepageUserWatchStatsToken' => '',
+	'homepageUserWatchStatsDisableCertCheck' => false,
+	'homepageUserWatchStatsUseCustomCertificate' => false,
+	'homepageUserWatchStatsDays' => '30',
+	'homepageUserWatchStatsCompactView' => false,
+	'homepageUserWatchStatsShowTopUsers' => true,
+	'homepageUserWatchStatsShowMostWatched' => true,
+	'homepageUserWatchStatsShowRecentActivity' => true,
+	'homepageUserWatchStatsMaxItems' => '10',
+	'homepageUserWatchStatsHeader' => 'User Watch Statistics',
+	'homepageUserWatchStatsHeaderToggle' => true,
+	'homepageJellyStatEnabled' => false,
+	'homepageJellyStatAuth' => '1',
+	'homepageJellyStatDisplayMode' => 'native',
+	'jellyStatURL' => '',
+	'jellyStatInternalURL' => '',
+	'jellyStatApikey' => '',
+	'jellyStatDisableCertCheck' => false,
+	'jellyStatUseCustomCertificate' => false,
+	'homepageJellyStatRefresh' => '5',
+	'homepageJellyStatDays' => '30',
+	'homepageJellyStatShowLibraries' => true,
+	'homepageJellyStatShowUsers' => true,
+	'homepageJellyStatShowMostWatched' => true,
+	'homepageJellyStatShowRecentActivity' => true,
+	'homepageJellyStatMaxItems' => '10',
+	'homepageJellyStatShowMostWatchedMovies' => true,
+	'homepageJellyStatShowMostWatchedShows' => true,
+	'homepageJellyStatShowMostListenedMusic' => true,
+	'homepageJellyStatMostWatchedCount' => '10',
+	'homepageJellyStatIframeHeight' => '800',
+	'homepageJellyStatIframeScrolling' => true,
 	'homepageShowStreamNames' => false,
 	'homepageShowStreamNamesAuth' => '1',
 	'homepageShowStreamNamesWithoutIp' => false,

+ 1881 - 0
api/homepage/jellystat.php

@@ -0,0 +1,1881 @@
+<?php
+
+/**
+ * JellyStat Homepage Plugin for Organizr
+ * Supports both Emby and Jellyfin servers via JellyStat API or embedded interface
+ */
+
+trait JellyStatHomepageItem
+{
+    public function jellystatSettingsArray($infoOnly = false)
+    {
+        $homepageInformation = [
+            'name' => 'JellyStat',
+            'enabled' => true,
+            'image' => 'plugins/images/homepage/jellystat.png',
+            'category' => 'Media Server',
+            'settingsArray' => __FUNCTION__
+        ];
+        if ($infoOnly) {
+            return $homepageInformation;
+        }
+        $homepageSettings = [
+            'debug' => true,
+            'settings' => [
+                'Enable' => [
+                    $this->settingsOption('enable', 'homepageJellyStatEnabled'),
+                    $this->settingsOption('auth', 'homepageJellyStatAuth'),
+                ],
+                'Display Mode' => [
+                    $this->settingsOption('select', 'homepageJellyStatDisplayMode', ['label' => 'Display Mode', 'options' => [
+                        ['name' => 'Native Statistics View', 'value' => 'native'],
+                        ['name' => 'Embedded JellyStat Interface', 'value' => 'iframe']
+                    ]]),
+                ],
+                'Connection' => [
+                    $this->settingsOption('url', 'jellyStatURL', ['label' => 'JellyStat URL', 'help' => 'URL to your JellyStat instance']),
+                    $this->settingsOption('url', 'jellyStatInternalURL', ['label' => 'Internal JellyStat URL (optional)', 'help' => 'Internal URL for server-side API calls (e.g., http://192.168.80.77:3000). If not set, uses main URL.']),
+                    $this->settingsOption('token', 'jellyStatApikey', ['label' => 'JellyStat API Key', 'help' => 'API key for JellyStat (required for native mode)']),
+                    $this->settingsOption('disable-cert-check', 'jellyStatDisableCertCheck'),
+                    $this->settingsOption('use-custom-certificate', 'jellyStatUseCustomCertificate'),
+                ],
+                'Native Mode Options' => [
+                    $this->settingsOption('number', 'homepageJellyStatRefresh', ['label' => 'Auto-refresh Interval (minutes)', 'min' => 1, 'max' => 60]),
+                    $this->settingsOption('number', 'homepageJellyStatDays', ['label' => 'Statistics Period (days)', 'min' => 1, 'max' => 365]),
+                    $this->settingsOption('switch', 'homepageJellyStatShowLibraries', ['label' => 'Show Library Statistics']),
+                    $this->settingsOption('switch', 'homepageJellyStatShowUsers', ['label' => 'Show User Statistics']),
+                    $this->settingsOption('switch', 'homepageJellyStatShowMostWatched', ['label' => 'Show Most Watched Content']),
+                    $this->settingsOption('switch', 'homepageJellyStatShowRecentActivity', ['label' => 'Show Recent Activity']),
+                    $this->settingsOption('number', 'homepageJellyStatMaxItems', ['label' => 'Maximum Items to Display', 'min' => 5, 'max' => 50]),
+                ],
+                'Most Watched Content' => [
+                    $this->settingsOption('switch', 'homepageJellyStatShowMostWatchedMovies', ['label' => 'Show Most Watched Movies with Posters']),
+                    $this->settingsOption('switch', 'homepageJellyStatShowMostWatchedShows', ['label' => 'Show Most Watched TV Shows with Posters']),
+                    $this->settingsOption('switch', 'homepageJellyStatShowMostListenedMusic', ['label' => 'Show Most Listened Music with Cover Art']),
+                    $this->settingsOption('number', 'homepageJellyStatMostWatchedCount', ['label' => 'Number of Most Watched Items to Display', 'min' => 1, 'max' => 50]),
+                ],
+                'Iframe Mode Options' => [
+                    $this->settingsOption('number', 'homepageJellyStatIframeHeight', ['label' => 'Iframe Height (pixels)', 'min' => 300, 'max' => 2000]),
+                    $this->settingsOption('switch', 'homepageJellyStatIframeScrolling', ['label' => 'Allow Scrolling in Iframe']),
+                ],
+                'Test Connection' => [
+                    $this->settingsOption('blank', null, ['label' => 'Please Save before Testing']),
+                    $this->settingsOption('test', 'jellystat'),
+                ]
+            ]
+        ];
+        return array_merge($homepageInformation, $homepageSettings);
+    }
+
+    public function testConnectionJellyStat()
+    {
+        if (!$this->homepageItemPermissions($this->jellystatHomepagePermissions('test'), true)) {
+            return false;
+        }
+        
+        $url = $this->config['jellyStatURL'] ?? '';
+        $token = $this->config['jellyStatApikey'] ?? '';
+        $disableCert = $this->config['jellyStatDisableCertCheck'] ?? false;
+        $customCert = $this->config['jellyStatUseCustomCertificate'] ?? false;
+        
+        if (empty($url)) {
+            $this->setAPIResponse('error', 'JellyStat URL not configured', 500);
+            return false;
+        }
+        
+        $displayMode = $this->config['homepageJellyStatDisplayMode'] ?? 'native';
+        
+        if ($displayMode === 'iframe') {
+            // For iframe mode, just test if the URL is reachable (use main URL for frontend)
+            try {
+                $options = $this->requestOptions($url, null, $disableCert, $customCert);
+                $response = Requests::get($this->qualifyURL($url), [], $options);
+                if ($response->success) {
+                    $this->setAPIResponse('success', 'Successfully connected to JellyStat', 200);
+                    return true;
+                } else {
+                    $this->setAPIResponse('error', 'Failed to connect to JellyStat URL', 500);
+                    return false;
+                }
+            } catch (Exception $e) {
+                $this->setAPIResponse('error', 'Connection test failed: ' . $e->getMessage(), 500);
+                return false;
+            }
+        } else {
+            // For native mode, test API connection
+            if (empty($token)) {
+                $this->setAPIResponse('error', 'JellyStat API key not configured for native mode', 500);
+                return false;
+            }
+            
+            try {
+                // Use internal URL for server-side API calls if configured, otherwise use main URL
+                $internalUrl = $this->config['jellyStatInternalURL'] ?? '';
+                $apiUrl = !empty($internalUrl) ? $internalUrl : $url;
+                
+                $options = $this->requestOptions($apiUrl, null, $disableCert, $customCert);
+                
+                // Test JellyStat API - use query parameter authentication
+                $testUrl = $this->qualifyURL($apiUrl) . '/api/getLibraries?apiKey=' . urlencode($token);
+                
+                $response = Requests::get($testUrl, [], $options);
+                if ($response->success) {
+                    $data = json_decode($response->body, true);
+                    if (isset($data) && is_array($data) && !isset($data['error'])) {
+                        $this->setAPIResponse('success', 'Successfully connected to JellyStat API', 200);
+                        return true;
+                    }
+                    // Check if there's an error message in the response
+                    if (isset($data['error'])) {
+                        $this->error('JellyStat API test error: ' . $data['error']);
+                        $this->setAPIResponse('error', 'JellyStat API error: ' . $data['error'], 500);
+                        return false;
+                    }
+                    // Log the actual response for debugging
+                    $this->error('JellyStat API test: Valid HTTP response but invalid data format. Response: ' . substr($response->body, 0, 500));
+                }
+                
+                // Log first endpoint failure details
+                $firstError = "HTTP {$response->status_code}: " . substr($response->body, 0, 200);
+                $this->error("JellyStat API test failed on /api/getLibraries: {$firstError}");
+                
+                // If libraries test failed, the API key is likely invalid
+                $this->error('JellyStat API key appears to be invalid or JellyStat API is not responding');
+                
+                // Try basic connection test to see if JellyStat is even running
+                $response = Requests::get($this->qualifyURL($apiUrl), [], $options);
+                if ($response->success) {
+                    $this->setAPIResponse('error', 'JellyStat is reachable but API key is invalid or API endpoints are not responding correctly.', 500);
+                } else {
+                    $this->setAPIResponse('error', 'Cannot connect to JellyStat URL. Check URL and network connectivity.', 500);
+                }
+                return false;
+                
+            } catch (Exception $e) {
+                $this->error('JellyStat API test exception: ' . $e->getMessage());
+                $this->setAPIResponse('error', 'Connection test failed: ' . $e->getMessage(), 500);
+                return false;
+            }
+        }
+    }
+    
+    public function jellystatHomepagePermissions($key = null)
+    {
+        $displayMode = $this->config['homepageJellyStatDisplayMode'] ?? 'native';
+        
+        // For iframe mode, only URL is required; for native mode, both URL and API key are required
+        $requiredFields = ['jellyStatURL'];
+        if ($displayMode === 'native') {
+            $requiredFields[] = 'jellyStatApikey';
+        }
+        
+        $permissions = [
+            'test' => [
+                'enabled' => [
+                    'homepageJellyStatEnabled',
+                ],
+                'auth' => [
+                    'homepageJellyStatAuth',
+                ],
+                'not_empty' => $requiredFields
+            ],
+            'main' => [
+                'enabled' => [
+                    'homepageJellyStatEnabled'
+                ],
+                'auth' => [
+                    'homepageJellyStatAuth'
+                ],
+                'not_empty' => $requiredFields
+            ],
+            'metadata' => [
+                'enabled' => [
+                    'homepageJellyStatEnabled'
+                ],
+                'auth' => [
+                    'homepageJellyStatAuth'
+                ],
+                'not_empty' => [
+                    'jellyStatURL'
+                ]
+            ]
+        ];
+        return $this->homepageCheckKeyPermissions($key, $permissions);
+    }
+    
+    public function getJellyStatMetadata($array)
+    {
+        $this->info('JellyStat getJellyStatMetadata called with: ' . json_encode($array));
+        try {
+            // Use dedicated 'metadata' permission (lighter requirements than 'main')
+            if (!$this->homepageItemPermissions($this->jellystatHomepagePermissions('metadata'), true)) {
+                $this->error('JellyStat metadata: Permission check failed');
+                $this->setAPIResponse('error', 'Not authorized for JellyStat metadata', 401);
+                return false;
+            }
+
+            $key = $array['key'] ?? null;
+            if (!$key) {
+                $this->error('JellyStat metadata: No key provided');
+                $this->setAPIResponse('error', 'JellyStat metadata key is not defined', 422);
+                return false;
+            }
+
+            // Always get JellyStat URL for image generation (needed regardless of metadata source)
+            $jellyStatUrl = $this->config['jellyStatURL'] ?? '';
+            $jellyStatInternalUrl = $this->config['jellyStatInternalURL'] ?? '';
+            // Use external URL for image URLs (to avoid mixed content issues)
+            $jellyStatImageBaseUrl = $this->qualifyURL($jellyStatUrl);
+            
+            // Initialize details variable
+            $details = null;
+
+            // First, try to use Emby/Jellyfin if configured
+            // JellyStat tracks Jellyfin/Emby servers, so we can use their metadata
+            $useEmbyMetadata = false;
+            $useJellyfinMetadata = false;
+            
+            // Check if Emby is configured and enabled
+            if ($this->config['homepageEmbyEnabled'] && !empty($this->config['embyURL']) && !empty($this->config['embyToken'])) {
+                $this->info('JellyStat metadata: Emby is configured, will try to use it for metadata');
+                $useEmbyMetadata = true;
+            }
+            
+            // Check if Jellyfin is configured and enabled (Jellyfin uses same backend as Emby)
+            if ($this->config['homepageJellyfinEnabled'] && !empty($this->config['jellyfinURL']) && !empty($this->config['jellyfinToken'])) {
+                $this->info('JellyStat metadata: Jellyfin is configured, will try to use it for metadata');
+                $useJellyfinMetadata = true;
+            }
+            
+            // Track where metadata comes from to generate correct image URLs
+            $metadataSource = null;
+            $mediaServerUrl = null;
+            $mediaServerToken = null;
+            
+            // Try to get metadata from Emby/Jellyfin first
+            if ($useEmbyMetadata || $useJellyfinMetadata) {
+                $this->info('JellyStat metadata: Attempting to fetch metadata from configured media server');
+                
+                // Use Jellyfin preferentially if both are configured (since JellyStat is for Jellyfin)
+                if ($useJellyfinMetadata) {
+                    // Jellyfin uses the same Emby trait, just with different config keys
+                    $mediaServerUrl = $this->qualifyURL($this->config['jellyfinURL']);
+                    $mediaServerToken = $this->config['jellyfinToken'];
+                    $disableCert = $this->config['jellyfinDisableCertCheck'] ?? false;
+                    $customCert = $this->config['jellyfinUseCustomCertificate'] ?? false;
+                    $serverType = 'jellyfin';
+                    $metadataSource = 'jellyfin';
+                } else {
+                    $mediaServerUrl = $this->qualifyURL($this->config['embyURL']);
+                    $mediaServerToken = $this->config['embyToken'];
+                    $disableCert = $this->config['embyDisableCertCheck'] ?? false;
+                    $customCert = $this->config['embyUseCustomCertificate'] ?? false;
+                    $serverType = 'emby';
+                    $metadataSource = 'emby';
+                }
+                
+                // Try to fetch metadata directly from Emby/Jellyfin
+                try {
+                    $this->info("JellyStat metadata: Trying to fetch from {$serverType} server");
+                    
+                    // Get the item metadata directly from Emby/Jellyfin API
+                    $options = $this->requestOptions($mediaServerUrl, 60, $disableCert, $customCert);
+                    
+                    // First, get a user ID (preferably admin)
+                    $userIds = $mediaServerUrl . "/Users?api_key=" . $mediaServerToken;
+                    $response = Requests::get($userIds, [], $options);
+                    
+                    if ($response->success) {
+                        $users = json_decode($response->body, true);
+                        $userId = null;
+                        
+                        // Find an admin user
+                        foreach ($users as $user) {
+                            if (isset($user['Policy']) && isset($user['Policy']['IsAdministrator']) && $user['Policy']['IsAdministrator']) {
+                                $userId = $user['Id'];
+                                break;
+                            }
+                        }
+                        
+                        // If no admin found, use first user
+                        if (!$userId && !empty($users)) {
+                            $userId = $users[0]['Id'];
+                        }
+                        
+                        if ($userId) {
+                            // Fetch the item metadata
+                            $metadataUrl = $mediaServerUrl . '/Users/' . $userId . '/Items/' . $key . '?EnableImages=true&api_key=' . $mediaServerToken . '&Fields=Overview,People,Genres,CommunityRating,CriticRating,Studios,Taglines,ProductionYear,PremiereDate,RunTimeTicks';
+                            $metadataResponse = Requests::get($metadataUrl, [], $options);
+                            
+                            if ($metadataResponse->success) {
+                                $details = json_decode($metadataResponse->body, true);
+                                if (is_array($details) && !empty($details)) {
+                                    $this->info('JellyStat metadata: Successfully fetched metadata from ' . $serverType);
+                                    // Keep track that we got metadata from media server
+                                    // This determines which URL to use for images
+                                }
+                            } else {
+                                $this->info('JellyStat metadata: Failed to fetch item from ' . $serverType . ' - Status: ' . $metadataResponse->status_code);
+                                // Do not clear $metadataSource; keep configured server so we can still build a link
+                            }
+                        }
+                    }
+                } catch (\Throwable $e) {
+                    $this->info('JellyStat metadata: Exception while fetching from media server: ' . $e->getMessage());
+                }
+            }
+            
+            // If we don't have details from Emby/Jellyfin, try JellyStat's own endpoints (legacy fallback)
+            if (!$details) {
+                $this->info('JellyStat metadata: No metadata from media servers, trying JellyStat endpoints');
+                
+                // Prepare URLs and options for JellyStat
+                $apiUrl = !empty($jellyStatInternalUrl) ? $jellyStatInternalUrl : $jellyStatUrl;
+                $token = $this->config['jellyStatApikey'] ?? '';
+                $disableCert = $this->config['jellyStatDisableCertCheck'] ?? false;
+                $customCert = $this->config['jellyStatUseCustomCertificate'] ?? false;
+                $options = $this->requestOptions($apiUrl, null, $disableCert, $customCert);
+                
+                // Try multiple JellyStat/proxy endpoints to get detailed item information
+                $tryEndpoints = [];
+                if ($token !== '') {
+                    // Try JellyStat's native item detail endpoints first
+                    $tryEndpoints[] = rtrim($this->qualifyURL($apiUrl), '/') . '/api/getItem?apiKey=' . urlencode($token) . '&id=' . urlencode($key);
+                    $tryEndpoints[] = rtrim($this->qualifyURL($apiUrl), '/') . '/api/getItemById?apiKey=' . urlencode($token) . '&id=' . urlencode($key);
+                }
+                // Try proxying directly to Jellyfin/Emby items endpoint
+                $tryEndpoints[] = rtrim($this->qualifyURL($apiUrl), '/') . '/proxy/Items/' . rawurlencode($key);
+                // Also try with Fields parameter to get comprehensive metadata
+                $tryEndpoints[] = rtrim($this->qualifyURL($apiUrl), '/') . '/proxy/Items/' . rawurlencode($key) . '?Fields=Overview,People,Genres,CommunityRating,CriticRating,Studios,Taglines,ProductionYear,PremiereDate';
+                
+                foreach ($tryEndpoints as $index => $endpoint) {
+                    try {
+                        $this->info("JellyStat metadata: Trying endpoint {$index}: {$endpoint}");
+                        $resp = Requests::get($endpoint, [], $options);
+                        if ($resp->success) {
+                            $json = json_decode($resp->body, true);
+                            if (is_array($json) && !isset($json['error']) && !empty($json)) {
+                                $this->info("JellyStat metadata: Successfully fetched data from endpoint {$index}");
+                                $details = $json;
+                                break;
+                            } else {
+                                $this->info("JellyStat metadata: Endpoint {$index} returned invalid or empty data");
+                            }
+                        } else {
+                            $this->info("JellyStat metadata: Endpoint {$index} failed with status {$resp->status_code}");
+                        }
+                    } catch (\Throwable $e) {
+                        $this->info("JellyStat metadata: Endpoint {$index} threw exception: " . $e->getMessage());
+                    }
+                }
+            }
+
+            // Initialize default values that match Emby structure
+            $title = 'Unknown Item';
+            $type = 'movie'; // Default to movie for better icon display
+            $year = '';
+            $summary = '';
+            $tagline = '';
+            $genres = [];
+            $actors = [];
+            $rating = '0';
+            $durationMs = '0';
+            $imageUrl = 'plugins/images/homepage/no-np.png';
+            
+            // Determine which base URL to use for images
+            // If we got metadata from Emby/Jellyfin, use their URL for images
+            // Otherwise, fall back to JellyStat proxy (which likely won't work)
+            $imageBaseUrl = $jellyStatImageBaseUrl; // Default to JellyStat
+            if ($metadataSource && $mediaServerUrl) {
+                // Use the media server URL directly for images
+                $imageBaseUrl = rtrim($mediaServerUrl, '/');
+                $this->info("JellyStat metadata: Using {$metadataSource} server for image URLs: {$imageBaseUrl}");
+            } else {
+                $this->info("JellyStat metadata: Using JellyStat URL for image URLs (may not work): {$imageBaseUrl}");
+            }
+
+            if (is_array($details) && !empty($details)) {
+                $this->info('JellyStat metadata: Processing fetched details: ' . json_encode(array_keys($details)));
+                
+                // Extract basic information
+                $title = $details['Name'] ?? $details['OriginalTitle'] ?? $title;
+                $summary = $details['Overview'] ?? $summary;
+                
+                // Handle taglines (can be array or string)
+                if (isset($details['Taglines'])) {
+                    if (is_array($details['Taglines']) && !empty($details['Taglines'])) {
+                        $tagline = $details['Taglines'][0];
+                    }
+                } else {
+                    $tagline = $details['Tagline'] ?? $tagline;
+                }
+                
+                // Extract year from multiple possible sources
+                if (isset($details['ProductionYear'])) {
+                    $year = (string)$details['ProductionYear'];
+                } elseif (isset($details['PremiereDate'])) {
+                    $premiereDateStr = $details['PremiereDate'];
+                    if ($premiereDateStr && preg_match('/^\d{4}/', $premiereDateStr, $matches)) {
+                        $year = $matches[0];
+                    }
+                }
+                
+                // Extract genres
+                if (isset($details['Genres']) && is_array($details['Genres'])) {
+                    $genres = $details['Genres'];
+                }
+                
+                // Extract rating
+                $ratingVal = $details['CommunityRating'] ?? $details['CriticRating'] ?? null;
+                if ($ratingVal !== null && is_numeric($ratingVal)) {
+                    $rating = (string)$ratingVal;
+                }
+                
+                // Extract duration (convert from Jellyfin ticks to milliseconds)
+                if (isset($details['RunTimeTicks']) && is_numeric($details['RunTimeTicks'])) {
+                    // Jellyfin/Emby ticks are 100-nanosecond intervals
+                    // Convert to milliseconds: ticks / 10000000 * 1000 = ticks / 10000
+                    $durationMs = (string)floor($details['RunTimeTicks'] / 10000);
+                }
+                
+                // Determine content type based on Jellyfin/Emby Type field
+                $jellyfinType = strtolower($details['Type'] ?? '');
+                switch ($jellyfinType) {
+                    case 'movie':
+                        $type = 'movie';
+                        break;
+                    case 'series':
+                        $type = 'tv';
+                        break;
+                    case 'episode':
+                        $type = 'tv';
+                        // For episodes, use series name as title if available
+                        if (isset($details['SeriesName'])) {
+                            $title = $details['SeriesName'];
+                        }
+                        break;
+                    case 'audio':
+                    case 'musicalbum':
+                    case 'musicvideo':
+                        $type = 'music';
+                        break;
+                    case 'video':
+                    default:
+                        $type = 'movie'; // Default to movie for better display
+                        break;
+                }
+                
+                // Extract cast/actors information
+                if (isset($details['People']) && is_array($details['People'])) {
+                    $actors = [];
+                    foreach ($details['People'] as $person) {
+                        if (isset($person['Name']) && isset($person['Role']) && !empty($person['Role'])) {
+                            // Generate actor image URL using appropriate server
+                            $actorImageUrl = 'plugins/images/homepage/no-list.png';
+                            if (isset($person['Id']) && !empty($person['Id'])) {
+                                if ($metadataSource && $mediaServerToken) {
+                                    // Use Emby/Jellyfin URL with authentication
+                                    $actorImageUrl = $imageBaseUrl . '/Items/' . rawurlencode($person['Id']) . '/Images/Primary?fillWidth=300&quality=90&api_key=' . $mediaServerToken;
+                                } else {
+                                    // Fallback to JellyStat proxy (probably won't work)
+                                    $actorImageUrl = $imageBaseUrl . '/proxy/Items/' . rawurlencode($person['Id']) . '/Images/Primary?fillWidth=300&quality=90';
+                                }
+                            }
+                            
+                            $actors[] = [
+                                'name' => (string)$person['Name'],
+                                'role' => (string)$person['Role'],
+                                'thumb' => $actorImageUrl
+                            ];
+                        }
+                    }
+                }
+                
+                // Generate image URL for the item
+                $itemId = $details['Id'] ?? $key;
+                $serverId = $details['ServerId'] ?? null;
+                
+                // Generate image URLs based on metadata source
+                if ($metadataSource && $mediaServerToken) {
+                    // Use Emby/Jellyfin URLs with authentication
+                    if (isset($details['ImageTags']['Primary'])) {
+                        $primaryTag = $details['ImageTags']['Primary'];
+                        $imageUrl = $imageBaseUrl . '/Items/' . rawurlencode($itemId) . '/Images/Primary?tag=' . urlencode($primaryTag) . '&fillWidth=400&quality=90&api_key=' . $mediaServerToken;
+                    } elseif (isset($details['ImageTags']['Thumb'])) {
+                        // Fallback to Thumb image
+                        $thumbTag = $details['ImageTags']['Thumb'];
+                        $imageUrl = $imageBaseUrl . '/Items/' . rawurlencode($itemId) . '/Images/Thumb?tag=' . urlencode($thumbTag) . '&fillWidth=400&quality=90&api_key=' . $mediaServerToken;
+                    } elseif (isset($details['BackdropImageTags'][0])) {
+                        // Fallback to Backdrop image
+                        $backdropTag = $details['BackdropImageTags'][0];
+                        $imageUrl = $imageBaseUrl . '/Items/' . rawurlencode($itemId) . '/Images/Backdrop?tag=' . urlencode($backdropTag) . '&fillWidth=400&quality=90&api_key=' . $mediaServerToken;
+                    } else {
+                        // Final fallback: try generic Primary image
+                        $imageUrl = $imageBaseUrl . '/Items/' . rawurlencode($itemId) . '/Images/Primary?fillWidth=400&quality=90&api_key=' . $mediaServerToken;
+                    }
+                } else {
+                    // Fallback to JellyStat proxy URLs (probably won't work)
+                    if (isset($details['ImageTags']['Primary'])) {
+                        $primaryTag = $details['ImageTags']['Primary'];
+                        $imageUrl = $imageBaseUrl . '/proxy/Items/' . rawurlencode($itemId) . '/Images/Primary?tag=' . urlencode($primaryTag) . '&fillWidth=400&quality=90';
+                    } elseif (isset($details['ImageTags']['Thumb'])) {
+                        // Fallback to Thumb image
+                        $thumbTag = $details['ImageTags']['Thumb'];
+                        $imageUrl = $imageBaseUrl . '/proxy/Items/' . rawurlencode($itemId) . '/Images/Thumb?tag=' . urlencode($thumbTag) . '&fillWidth=400&quality=90';
+                    } elseif (isset($details['BackdropImageTags'][0])) {
+                        // Fallback to Backdrop image
+                        $backdropTag = $details['BackdropImageTags'][0];
+                        $imageUrl = $imageBaseUrl . '/proxy/Items/' . rawurlencode($itemId) . '/Images/Backdrop?tag=' . urlencode($backdropTag) . '&fillWidth=400&quality=90';
+                    } else {
+                        // Final fallback: try generic Primary image proxy
+                        $imageUrl = $this->getPosterUrl(null, $itemId, $serverId) ?: $imageUrl;
+                    }
+                }
+                
+                $this->info("JellyStat metadata: Processed item - Title: {$title}, Type: {$type}, Year: {$year}, Rating: {$rating}");
+            } else {
+                $this->info('JellyStat metadata: No detailed metadata found, using basic fallback');
+                // Minimal fallback when no detailed data is available
+                $imageUrl = $this->getPosterUrl(null, $key, null) ?: $imageUrl;
+                $tagline = 'View in JellyStat';
+                $summary = 'This item is available in your media library. Click to view more details in JellyStat.';
+            }
+
+            // Build the item structure that matches what buildMetadata() expects
+            $item = [
+                'uid' => (string)$key,
+                'title' => $title,
+                'secondaryTitle' => '',  // Match Emby structure
+                'type' => $type,
+                'ratingKey' => (string)$key,
+                'thumb' => (string)$key,
+                'key' => (string)$key . '-list',
+                'nowPlayingThumb' => (string)$key,
+                'nowPlayingKey' => (string)$key . '-np',
+                'metadataKey' => (string)$key,
+                'nowPlayingImageURL' => $imageUrl,
+                'imageURL' => $imageUrl,
+                'originalImage' => $imageUrl,
+                'nowPlayingOriginalImage' => $imageUrl,
+                'address' => $this->generateMediaServerLink($metadataSource, $mediaServerUrl, $key, $serverId),
+                'tabName' => $this->getMediaServerTabName($metadataSource),
+                'openTab' => $this->shouldOpenMediaServerTab($metadataSource),
+                'metadata' => [
+                    'guid' => (string)$key,
+                    'summary' => $summary,
+                    'rating' => $rating,
+                    'duration' => $durationMs,
+                    'originallyAvailableAt' => '',
+                    'year' => $year,
+                    'tagline' => $tagline,
+                    'genres' => $genres,
+                    'actors' => $actors
+                ]
+            ];
+
+            $api = ['content' => [$item]];
+            $this->setAPIResponse('success', null, 200, $api);
+            return $api;
+            
+        } catch (\Throwable $e) {
+            // Fail gracefully with a meaningful fallback response
+            $this->error('JellyStat metadata exception: ' . $e->getMessage());
+            
+            $fallbackKey = (string)($array['key'] ?? 'unknown');
+            // Even on failure, if a media server is configured, build a link so the Emby/Jellyfin button works
+            $effectiveSource = $metadataSource;
+            $effectiveUrl = $mediaServerUrl;
+            if (!$effectiveSource) {
+                if ($useJellyfinMetadata) {
+                    $effectiveSource = 'jellyfin';
+                    $effectiveUrl = $this->qualifyURL($this->config['jellyfinURL'] ?? '');
+                } elseif ($useEmbyMetadata) {
+                    $effectiveSource = 'emby';
+                    $effectiveUrl = $this->qualifyURL($this->config['embyURL'] ?? '');
+                }
+            }
+
+            $fallback = [
+                'uid' => $fallbackKey,
+                'title' => 'Media Item',
+                'secondaryTitle' => '',
+                'type' => 'movie',  // Default to movie for better icon
+                'ratingKey' => $fallbackKey,
+                'thumb' => $fallbackKey,
+                'key' => $fallbackKey . '-list',
+                'nowPlayingThumb' => $fallbackKey,
+                'nowPlayingKey' => $fallbackKey . '-np',
+                'metadataKey' => $fallbackKey,
+                'nowPlayingImageURL' => 'plugins/images/homepage/no-np.png',
+                'imageURL' => 'plugins/images/homepage/no-list.png',
+                'originalImage' => 'plugins/images/homepage/no-list.png',
+                'nowPlayingOriginalImage' => 'plugins/images/homepage/no-np.png',
+                'address' => $this->generateMediaServerLink($effectiveSource, $effectiveUrl, $fallbackKey),
+                'tabName' => $this->getMediaServerTabName($effectiveSource),
+                'openTab' => $this->shouldOpenMediaServerTab($effectiveSource),
+                'metadata' => [
+                    'guid' => $fallbackKey,
+                    'summary' => 'This item is available in your media library. Unable to load detailed metadata at this time.',
+                    'rating' => '0',
+                    'duration' => '0',
+                    'originallyAvailableAt' => '',
+                    'year' => '',
+                    'tagline' => 'Media Library Item',
+                    'genres' => [],
+                    'actors' => []
+                ]
+            ];
+            
+            $api = ['content' => [$fallback]];
+            $this->setAPIResponse('success', null, 200, $api);
+            return $api;
+        }
+    }
+
+    public function homepageOrderJellyStat()
+    {
+        if ($this->homepageItemPermissions($this->jellystatHomepagePermissions('main'))) {
+            $displayMode = $this->config['homepageJellyStatDisplayMode'] ?? 'native';
+            
+            if ($displayMode === 'iframe') {
+                return $this->renderJellyStatIframe();
+            } else {
+                return $this->renderJellyStatNative();
+            }
+        }
+    }
+
+    private function renderJellyStatIframe()
+    {
+        $url = $this->config['jellyStatURL'] ?? '';
+        $height = $this->config['homepageJellyStatIframeHeight'] ?? 800;
+        $scrolling = ($this->config['homepageJellyStatIframeScrolling'] ?? true) ? 'auto' : 'no';
+        
+        return '
+        <div id="' . __FUNCTION__ . '">
+            <div class="white-box">
+                <div class="white-box-header">
+                    <i class="fa fa-bar-chart"></i> JellyStat Dashboard
+                </div>
+                <div class="white-box-content" style="padding: 0;">
+                    <iframe 
+                        src="' . htmlspecialchars($this->qualifyURL($url)) . '" 
+                        width="100%" 
+                        height="' . intval($height) . 'px"
+                        style="border: none; border-radius: 0 0 4px 4px;"
+                        scrolling="' . $scrolling . '"
+                        frameborder="0">
+                        <p>Your browser does not support iframes. Please visit <a href="' . htmlspecialchars($url) . '" target="_blank">JellyStat</a> directly.</p>
+                    </iframe>
+                </div>
+            </div>
+        </div>';
+    }
+
+    private function renderJellyStatNative()
+    {
+        $refreshInterval = ($this->config['homepageJellyStatRefresh'] ?? 5) * 60000; // Convert minutes to milliseconds
+        $days = $this->config['homepageJellyStatDays'] ?? 30;
+        $maxItems = $this->config['homepageJellyStatMaxItems'] ?? 10;
+        $showLibraries = ($this->config['homepageJellyStatShowLibraries'] ?? true) ? 'true' : 'false';
+        $showUsers = ($this->config['homepageJellyStatShowUsers'] ?? true) ? 'true' : 'false';
+        $showMostWatched = ($this->config['homepageJellyStatShowMostWatched'] ?? true) ? 'true' : 'false';
+        $showRecentActivity = ($this->config['homepageJellyStatShowRecentActivity'] ?? true) ? 'true' : 'false';
+        $showMostWatchedMovies = ($this->config['homepageJellyStatShowMostWatchedMovies'] ?? true) ? 'true' : 'false';
+        $showMostWatchedShows = ($this->config['homepageJellyStatShowMostWatchedShows'] ?? true) ? 'true' : 'false';
+        $showMostListenedMusic = ($this->config['homepageJellyStatShowMostListenedMusic'] ?? true) ? 'true' : 'false';
+        $mostWatchedCount = $this->config['homepageJellyStatMostWatchedCount'] ?? 10;
+        $jellyStatUrl = htmlspecialchars($this->qualifyURL($this->config['jellyStatURL'] ?? ''), ENT_QUOTES, 'UTF-8');
+
+        return '
+        <div id="' . __FUNCTION__ . '" style="background: transparent; border: none; padding: 20px;">
+            <style>
+                /* JellyStat native view cleanup for uniform look */
+                #' . __FUNCTION__ . ' { color: #e9edf2; }
+                #' . __FUNCTION__ . ' .js-card { background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.08); border-radius: 10px; padding: 15px; margin-bottom: 20px; }
+                #' . __FUNCTION__ . ' .js-header { margin-bottom: 15px; border-bottom: 1px solid rgba(255,255,255,0.08); padding-bottom: 10px; }
+                #' . __FUNCTION__ . ' .js-header h3 { margin: 0; font-weight: 600; font-size: 18px; }
+                #' . __FUNCTION__ . ' h5 { margin: 0 0 10px; font-size: 14px; font-weight: 600; color: #cfd8e3; }
+                #' . __FUNCTION__ . ' .small-box { background: rgba(255,255,255,0.06)!important; border: 1px solid rgba(255,255,255,0.08); border-radius: 10px; padding: 12px; min-height: 90px; }
+                #' . __FUNCTION__ . ' .small-box .inner h3 { margin: 0; font-size: 20px; font-weight: 700; }
+                #' . __FUNCTION__ . ' .small-box .inner p { margin: 4px 0 0; font-size: 12px; opacity: 0.85; }
+                #' . __FUNCTION__ . ' .table { margin-bottom: 0; }
+                #' . __FUNCTION__ . ' .table > thead > tr > th { border-color: rgba(255,255,255,0.08); color: #cfd8e3; font-size: 12px; font-weight: 600; }
+                #' . __FUNCTION__ . ' .table > tbody > tr > td { border-color: rgba(255,255,255,0.06); font-size: 12px; }
+                #' . __FUNCTION__ . ' .media { background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.08); border-radius: 10px; padding: 10px; }
+                #' . __FUNCTION__ . ' .media .media-left i { width: 36px; height: 36px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; background: rgba(255,255,255,0.08); }
+                #' . __FUNCTION__ . ' .media .media-heading { font-size: 13px; margin: 0 0 4px; }
+                #' . __FUNCTION__ . ' .media small { font-size: 11px; opacity: 0.8; }
+                #' . __FUNCTION__ . ' .poster-card { background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.06); border-radius: 10px; }
+                #' . __FUNCTION__ . ' .poster-image { border-radius: 8px; }
+                #' . __FUNCTION__ . ' .play-count-badge { background: rgba(0,0,0,0.65)!important; }
+            </style>
+            <div class="js-card">
+                <div class="js-header">
+                    <h3><i class="fa fa-bar-chart"></i> JellyStat Analytics</h3>
+                    <span class="pull-right">
+                        <small id="jellystat-last-update" style="color: rgba(255,255,255,0.7);"></small>
+                        <button class="btn btn-xs btn-primary" onclick="refreshJellyStatData()" title="Refresh Data" style="margin-left: 10px;">
+                            <i class="fa fa-refresh" id="jellystat-refresh-icon"></i>
+                        </button>
+                    </span>
+                </div>
+                <div>
+                    <div class="row" id="jellystat-content">
+                        <div class="col-lg-12 text-center">
+                            <i class="fa fa-spinner fa-spin" style="color: white;"></i> <span style="color: white;">Loading JellyStat data...</span>
+                        </div>
+                    </div>
+                </div>
+            </div>
+        </div>
+
+        <script>
+        var jellyStatRefreshTimer;
+        var jellyStatLastRefresh = 0;
+
+        function refreshJellyStatData() {
+            var refreshIcon = $("#jellystat-refresh-icon");
+            refreshIcon.addClass("fa-spin");
+
+            // Show loading state
+            $("#jellystat-content").html("<div class=\"col-lg-12 text-center\"><i class=\"fa fa-spinner fa-spin\"></i> Loading JellyStat data...</div>");
+
+            // Load JellyStat data
+            getJellyStatData()
+            .always(function() {
+                refreshIcon.removeClass("fa-spin");
+                jellyStatLastRefresh = Date.now();
+                updateJellyStatLastRefreshTime();
+            });
+        }
+
+        function updateJellyStatLastRefreshTime() {
+            if (jellyStatLastRefresh > 0) {
+                var ago = Math.floor((Date.now() - jellyStatLastRefresh) / 1000);
+                var timeText = ago < 60 ? ago + "s ago" : Math.floor(ago / 60) + "m ago";
+                $("#jellystat-last-update").text("Updated " + timeText);
+            }
+        }
+
+        // Helper function to get icon for content type
+        function getTypeIcon(collectionType) {
+            switch(collectionType) {
+                case "movies": return "fa-film";
+                case "tvshows": return "fa-television";
+                case "music": return "fa-music";
+                case "mixed": return "fa-folder-open";
+                default: return "fa-folder";
+            }
+        }
+        
+        // Helper function to format duration from ticks
+        function formatJellyStatDuration(ticks) {
+            if (!ticks || ticks === 0) return "0 min";
+            
+            // Convert ticks to seconds (ticks are in 100-nanosecond intervals)
+            var seconds = ticks / 10000000;
+            
+            if (seconds < 60) {
+                return Math.round(seconds) + " sec";
+            } else if (seconds < 3600) {
+                return Math.round(seconds / 60) + " min";
+            } else if (seconds < 86400) {
+                var hours = Math.floor(seconds / 3600);
+                var minutes = Math.floor((seconds % 3600) / 60);
+                return hours + "h " + minutes + "m";
+            } else {
+                var days = Math.floor(seconds / 86400);
+                var hours = Math.floor((seconds % 86400) / 3600);
+                return days + "d " + hours + "h";
+            }
+        }
+        
+        // Helper function to sanitize IDs for use in HTML attributes and CSS selectors
+        function sanitizeId(id) {
+            if (!id) return \'jellystat-unknown\';
+            // Convert to string and replace problematic characters
+            return String(id)
+                .replace(/[^a-zA-Z0-9\\-_]/g, \'_\') // Replace special chars with underscores
+                .replace(/^[0-9]/, \'_\' + String(id).charAt(0)) // Prefix with underscore if starts with number
+                .toLowerCase();
+        }
+        
+        // Helper function to generate poster URLs from JellyStat/Jellyfin
+        function getPosterUrl(posterPath, itemId, serverId) {
+            console.log("getPosterUrl called with:", {posterPath, itemId, serverId});
+            // Use external URL for frontend poster display to avoid mixed content issues
+            var jellyStatUrl = ' . json_encode($jellyStatUrl) . ';
+            console.log("JellyStat URL from config:", jellyStatUrl);
+            
+            if (!posterPath && !itemId) {
+                console.log("No poster path or item ID provided");
+                return null;
+            }
+            
+            // If we have a poster path, process it
+            if (posterPath) {
+                console.log("Processing poster path:", posterPath);
+                // If its already an absolute URL, use it directly
+                if (posterPath.indexOf("http://") === 0 || posterPath.indexOf("https://") === 0) {
+                    console.log("Poster path is absolute URL:", posterPath);
+                    return posterPath;
+                }
+                // If its a relative path starting with /, prepend the JellyStat URL
+                if (jellyStatUrl && posterPath.indexOf("/") === 0) {
+                    var fullUrl = jellyStatUrl + posterPath;
+                    console.log("Generated full URL from relative path:", fullUrl);
+                    return fullUrl;
+                }
+            }
+            
+            // If we have itemId, try to generate JellyStat image proxy URL
+            if (itemId && jellyStatUrl) {
+                // JellyStat uses /proxy/Items/Images/Primary endpoint
+                // Format: /proxy/Items/Images/Primary?id={itemId}&fillWidth=200&quality=90
+                var baseUrl = jellyStatUrl.replace(/\/+$/, ""); // Remove trailing slashes
+                
+                var apiUrl = baseUrl + "/proxy/Items/Images/Primary?id=" + encodeURIComponent(itemId) + "&fillWidth=200&quality=90";
+                console.log("Generated JellyStat proxy image URL:", apiUrl);
+                return apiUrl;
+            }
+            
+            console.log("No valid poster URL could be generated");
+            return null;
+        }
+
+        function getJellyStatData() {
+            return organizrAPI2("GET", "api/v2/homepage/jellystat")
+            .done(function(data) {
+                console.log("JellyStat API Response:", data);
+                if (data && data.response && data.response.result === "success" && data.response.data) {
+                    console.log("JellyStat Data:", data.response.data);
+                    renderJellyStatData(data.response.data);
+                } else {
+                    console.error("JellyStat API Error:", data);
+                    var errorMsg = "Failed to load JellyStat data";
+                    if (data && data.response && data.response.message) {
+                        errorMsg += ": " + data.response.message;
+                    }
+                    $("#jellystat-content").html("<div class=\"col-lg-12 text-center text-danger\">" + errorMsg + "</div>");
+                }
+            })
+            .fail(function(xhr, status, error) {
+                console.error("JellyStat API Request Failed:", xhr, status, error);
+                $("#jellystat-content").html("<div class=\"col-lg-12 text-center text-danger\">Error loading JellyStat data: " + error + "</div>");
+            });
+        }
+        
+        function renderJellyStatData(stats) {
+            console.log("Rendering JellyStat data:", stats);
+            var html = "";
+            
+            // Server Overview - Summary Stats
+            if (stats.library_totals) {
+                console.log("Library totals found:", stats.library_totals);
+                html += "<div class=\"col-lg-12\" style=\"margin-bottom: 20px;\">";
+                html += "<div class=\"row\">";
+                
+                // Total Libraries
+                html += "<div class=\"col-sm-3\">";
+                html += "<div class=\"small-box bg-blue\">";
+                html += "<div class=\"inner\">";
+                html += "<h3>" + (stats.library_totals.total_libraries || 0) + "</h3>";
+                html += "<p>Libraries</p>";
+                html += "</div>";
+                html += "<div class=\"icon\"><i class=\"fa fa-folder\"></i></div>";
+                html += "</div></div>";
+                
+                // Total Items
+                html += "<div class=\"col-sm-3\">";
+                html += "<div class=\"small-box bg-green\">";
+                html += "<div class=\"inner\">";
+                html += "<h3>" + (stats.library_totals.total_items || 0).toLocaleString() + "</h3>";
+                html += "<p>Total Items</p>";
+                html += "</div>";
+                html += "<div class=\"icon\"><i class=\"fa fa-film\"></i></div>";
+                html += "</div></div>";
+                
+                // Total Episodes (if any)
+                if (stats.library_totals.total_episodes > 0) {
+                    html += "<div class=\"col-sm-3\">";
+                    html += "<div class=\"small-box bg-yellow\">";
+                    html += "<div class=\"inner\">";
+                    html += "<h3>" + (stats.library_totals.total_episodes || 0).toLocaleString() + "</h3>";
+                    html += "<p>Episodes</p>";
+                    html += "</div>";
+                    html += "<div class=\"icon\"><i class=\"fa fa-television\"></i></div>";
+                    html += "</div></div>";
+                }
+                
+                // Total Play Time
+                html += "<div class=\"col-sm-3\">";
+                html += "<div class=\"small-box bg-red\">";
+                html += "<div class=\"inner\">";
+                html += "<h3 style=\"font-size: 18px;\">" + (stats.library_totals.total_play_time || "0 min") + "</h3>";
+                html += "<p>Total Watched</p>";
+                html += "</div>";
+                html += "<div class=\"icon\"><i class=\"fa fa-clock-o\"></i></div>";
+                html += "</div></div>";
+                
+                html += "</div></div>";
+            }
+            
+            // Content Type Breakdown
+            if (stats.library_totals && stats.library_totals.type_breakdown) {
+                html += "<div class=\"col-lg-6\">";
+                html += "<h5><i class=\"fa fa-pie-chart text-primary\"></i> Content Breakdown</h5>";
+                html += "<div class=\"table-responsive\">";
+                html += "<table class=\"table table-striped table-condensed\">";
+                html += "<thead><tr><th>Type</th><th>Libraries</th><th>Items</th><th>Watch Time</th></tr></thead>";
+                html += "<tbody>";
+                
+                Object.keys(stats.library_totals.type_breakdown).forEach(function(type) {
+                    var breakdown = stats.library_totals.type_breakdown[type];
+                    var playTimeFormatted = breakdown.play_time > 0 ? formatJellyStatDuration(breakdown.play_time) : "0 min";
+                    
+                    html += "<tr>";
+                    html += "<td><strong>" + breakdown.label + "</strong></td>";
+                    html += "<td><strong style=\"color: #5bc0de;\">" + breakdown.count + "</strong></td>";
+                    html += "<td><strong style=\"color: #5cb85c;\">" + breakdown.items.toLocaleString() + "</strong></td>";
+                    html += "<td><small>" + playTimeFormatted + "</small></td>";
+                    html += "</tr>";
+                });
+                
+                html += "</tbody></table></div></div>";
+            }
+            
+            // Detailed Library Statistics
+            if (' . $showLibraries . ' && stats.libraries && stats.libraries.length > 0) {
+                html += "<div class=\"col-lg-6\">";
+                html += "<h5><i class=\"fa fa-folder text-info\"></i> Library Details</h5>";
+                html += "<div class=\"table-responsive\">";
+                html += "<table class=\"table table-striped table-condensed\">";
+                html += "<thead><tr><th>Library</th><th>Type</th><th>Items</th><th>Watch Time</th></tr></thead>";
+                html += "<tbody>";
+                
+                stats.libraries.slice(0, ' . $maxItems . ').forEach(function(lib) {
+                    var typeIcon = getTypeIcon(lib.collection_type);
+                    html += "<tr>";
+                    html += "<td><i class=\"fa " + typeIcon + " text-muted\"></i> <strong>" + (lib.name || "Unknown Library") + "</strong></td>";
+                    html += "<td><small>" + (lib.type || "Unknown") + "</small></td>";
+                    html += "<td><strong style=\"color: #337ab7;\">" + (lib.item_count || 0).toLocaleString() + "</strong>";
+                    
+                    // Show additional counts for TV libraries
+                    if (lib.episode_count > 0) {
+                        html += "<br><small class=\"text-muted\">Episodes: <strong style=\"color: #337ab7;\">" + lib.episode_count.toLocaleString() + "</strong></small>";
+                    }
+                    if (lib.season_count > 0) {
+                        html += "<br><small class=\"text-muted\">Seasons: <strong style=\"color: #337ab7;\">" + lib.season_count.toLocaleString() + "</strong></small>";
+                    }
+                    
+                    html += "</td>";
+                    html += "<td><small>" + (lib.total_play_time || "0 min") + "</small></td>";
+                    html += "</tr>";
+                });
+                
+                html += "</tbody></table></div></div>";
+            }
+            
+            // User Statistics  
+            if (' . $showUsers . ' && stats.users && stats.users.length > 0) {
+                html += "<div class=\"col-lg-12\">";
+                html += "<h5><i class=\"fa fa-users\"></i> Active Users (" + stats.users.length + " total)</h5>";
+                html += "<div class=\"row\">";
+                
+                stats.users.slice(0, 12).forEach(function(user) {
+                    var lastActivity = "Never";
+                    if (user.last_activity && user.last_activity !== "0001-01-01T00:00:00.0000000Z") {
+                        var activityDate = new Date(user.last_activity);
+                        lastActivity = activityDate.toLocaleDateString();
+                    }
+                    var playCount = user.play_count || 0;
+                    
+                    html += "<div class=\"col-lg-3 col-md-4 col-sm-6\" style=\"margin-bottom: 15px;\">";
+                    html += "<div class=\"media\">";
+                    html += "<div class=\"media-left\"><i class=\"fa fa-user fa-2x text-muted\"></i></div>";
+                    html += "<div class=\"media-body\">";
+                    html += "<h6 class=\"media-heading\">" + (user.name || "Unknown User") + " <strong style=\"color: #5bc0de;\">" + playCount + " plays</strong></h6>";
+                    html += "<small class=\"text-muted\">Last Activity: " + lastActivity + "</small>";
+                    html += "</div></div></div>";
+                });
+                
+                html += "</div></div>";
+            }
+            
+            // Most Watched Content
+            if (' . $showMostWatched . ' && stats.most_watched && stats.most_watched.length > 0) {
+                html += "<div class=\"col-lg-12\" style=\"margin-top: 20px;\">";
+                html += "<h5><i class=\"fa fa-star text-warning\"></i> Most Watched Content</h5>";
+                html += "<div class=\"table-responsive\">";
+                html += "<table class=\"table table-striped table-condensed\">";
+                html += "<thead><tr><th>Title</th><th>Type</th><th>Plays</th><th>Runtime</th><th>Year</th></tr></thead>";
+                html += "<tbody>";
+                
+                stats.most_watched.slice(0, ' . $maxItems . ').forEach(function(item) {
+                    html += "<tr>";
+                    html += "<td><strong>" + (item.title || "Unknown Title") + "</strong></td>";
+                    html += "<td>" + (item.type || "Unknown") + "</td>";
+                    html += "<td><strong style=\"color: #337ab7;\">" + (item.play_count || 0) + "</strong></td>";
+                    html += "<td>" + (item.runtime || "Unknown") + "</td>";
+                    html += "<td>" + (item.year && item.year !== "N/A" ? item.year : "") + "</td>";
+                    html += "</tr>";
+                });
+                
+                html += "</tbody></table></div></div>";
+            }
+            
+            // Recent Activity
+            if (' . $showRecentActivity . ' && stats.recent_activity && stats.recent_activity.length > 0) {
+                html += "<div class=\"col-lg-12\" style=\"margin-top: 20px;\">";
+                html += "<h5><i class=\"fa fa-clock-o text-success\"></i> Recent Activity</h5>";
+                html += "<div class=\"table-responsive\">";
+                html += "<table class=\"table table-striped table-condensed\">";
+                html += "<thead><tr><th>Date</th><th>User</th><th>Title</th><th>Type</th></tr></thead>";
+                html += "<tbody>";
+                
+                stats.recent_activity.slice(0, ' . $maxItems . ').forEach(function(activity) {
+                    var date = new Date(activity.date);
+                    var formattedDate = date.toLocaleDateString() + " " + date.toLocaleTimeString([], {hour: "2-digit", minute:"2-digit"});
+                    
+                    html += "<tr>";
+                    html += "<td><small>" + formattedDate + "</small></td>";
+                    html += "<td>" + (activity.user || "Unknown User") + "</td>";
+                    html += "<td><strong>" + (activity.title || "Unknown Title") + "</strong></td>";
+                    html += "<td>" + (activity.type || "Unknown") + "</td>";
+                    html += "</tr>";
+                });
+                
+                html += "</tbody></table></div></div>";
+            }
+            
+            // Debug data availability
+            console.log("Full stats object:", stats);
+            console.log("Movie settings enabled:", ' . $showMostWatchedMovies . ');
+            console.log("Movies data:", stats.most_watched_movies);
+            console.log("Shows data:", stats.most_watched_shows);
+            console.log("Music data:", stats.most_listened_music);
+            
+            // Most Watched Movies with Posters
+            if (' . $showMostWatchedMovies . ' && stats.most_watched_movies && stats.most_watched_movies.length > 0) {
+                console.log("Rendering most watched movies:", stats.most_watched_movies);
+                html += "<div class=\"col-lg-12\" style=\"margin-top: 30px;\">";
+                html += "<h5><i class=\"fa fa-film text-primary\"></i> Most Watched Movies</h5>";
+                html += "<div style=\"margin-top: 15px; overflow-x: auto; overflow-y: hidden; white-space: nowrap; padding-bottom: 10px; scrollbar-width: thin; scrollbar-color: rgba(255,255,255,0.3) transparent;\">";
+                html += "<style>div::-webkit-scrollbar { height: 8px; } div::-webkit-scrollbar-track { background: rgba(255,255,255,0.1); border-radius: 4px; } div::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.3); border-radius: 4px; } div::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.5); }</style>";
+                
+                stats.most_watched_movies.forEach(function(movie) {
+                    console.log("Processing movie:", movie);
+                    console.log("Movie poster_path:", movie.poster_path);
+                    console.log("Movie id:", movie.id);
+                    console.log("Movie server_id:", movie.server_id);
+                    var posterUrl = getPosterUrl(movie.poster_path, movie.id, movie.server_id);
+                    console.log("Generated posterUrl:", posterUrl);
+                    var playCount = movie.play_count || 0;
+                    var year = movie.year && movie.year !== "N/A" ? movie.year : "";
+                    var title = movie.title || "Unknown Movie";
+                    
+                    // Use sanitized ID for DOM elements but original ID for data attributes
+                    var sanitizedId = sanitizeId(movie.id);
+                    console.log("Using sanitized ID:", sanitizedId, "for original ID:", movie.id);
+                    
+                    html += "<div style=\"display: inline-block; margin: 0 10px 0 0; width: 150px; vertical-align: top;\">";
+                    html += "<div class=\"poster-card metadata-get\" style=\"position: relative; width: 150px; height: 225px; transition: transform 0.2s ease; cursor: pointer;\" data-source=\"jellystat\" data-key=\"" + movie.id + "\" data-uid=\"" + sanitizedId + "\">";
+                    
+                    // Poster image container
+                    html += "<div class=\"poster-image\" style=\"position: relative; width: 150px; height: 225px; border-radius: 8px; overflow: hidden; box-shadow: 0 4px 12px rgba(0,0,0,0.3);\">";
+                    
+                    // Hover overlay with title and year - initially hidden
+                    html += "<div class=\"poster-overlay\" style=\"position: absolute; bottom: 0; left: 0; right: 0; background: linear-gradient(transparent, rgba(0,0,0,0.8)); color: white; padding: 20px 10px 10px; opacity: 0; transition: opacity 0.3s ease; pointer-events: none;\">";
+                    html += "<div style=\"font-size: 12px; font-weight: bold; line-height: 1.3; margin-bottom: 4px; text-shadow: 1px 1px 2px rgba(0,0,0,0.9); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;\" title=\"" + title + "\">" + title + "</div>";
+                    if (year && year !== "N/A") {
+                        html += "<small style=\"color: rgba(255,255,255,0.8); text-shadow: 1px 1px 2px rgba(0,0,0,0.9); line-height: 1.2;\">" + year + "</small>";
+                    }
+                    html += "</div>";
+                    if (posterUrl) {
+                        html += "<img src=\"" + posterUrl + "\" alt=\"" + title + "\" style=\"width: 150px; height: 225px; object-fit: cover;\" onerror=\"this.style.display=\"none\"; this.nextElementSibling.style.display=\"flex\";\">";
+                    }
+                    html += "<div class=\"poster-placeholder\" style=\"position: absolute; top: 0; left: 0; width: 150px; height: 225px; display: " + (posterUrl ? "none" : "flex") + "; align-items: center; justify-content: center; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white;\">";
+                    html += "<i class=\"fa fa-film fa-3x\"></i>";
+                    html += "</div>";
+                    
+                    // Play count badge
+                    html += "<div class=\"play-count-badge\" style=\"position: absolute; top: 8px; right: 8px; background: rgba(0,0,0,0.8); color: white; padding: 4px 8px; border-radius: 12px; font-size: 11px; backdrop-filter: blur(4px);\">";
+                    html += "<i class=\"fa fa-play\"></i> " + playCount;
+                    html += "</div>";
+                    html += "</div>";
+                    
+                    // Add CSS for hover effect - this will be applied once when the first poster is rendered
+                    if (movie === stats.most_watched_movies[0]) {
+                        html += "<style>";
+                        html += ".poster-card:hover .poster-overlay { opacity: 1 !important; }";
+                        html += ".poster-card:hover { transform: scale(1.05); z-index: 10; }";
+                        html += "</style>";
+                    }
+                    
+                    html += "</div>";
+                    
+                    // Add metadata popup elements (Organizr style) using sanitized ID
+                    // Include a hidden anchor to trigger Magnific Popup, matching Emby/Jellyfin implementation
+                    html += "\u003ca class=\\"inline-popups " + sanitizedId + " hidden\\" href=\\"#" + sanitizedId + "-metadata-div\\" data-effect=\\"mfp-zoom-out\\"\u003e\u003c/a\u003e";
+                    html += "\u003cdiv id=\\"" + sanitizedId + "-metadata-div\\" class=\\"white-popup mfp-with-anim mfp-hide\\"\u003e";
+                    html += "\u003cdiv class=\\"col-md-8 col-md-offset-2 " + sanitizedId + "-metadata-info\\"\u003e\u003c/div\u003e";
+                    html += "\u003c/div\u003e";
+                    
+                    html += "\u003c/div\u003e";
+                });
+                
+                html += "</div></div>";
+            } else {
+                console.log("Movies not showing because:");
+                console.log("- Setting enabled:", ' . $showMostWatchedMovies . ');
+                console.log("- Has data:", stats.most_watched_movies && stats.most_watched_movies.length > 0);
+                console.log("- Data:", stats.most_watched_movies);
+            }
+            
+            // Most Watched TV Shows with Posters
+            if (' . $showMostWatchedShows . ' && stats.most_watched_shows && stats.most_watched_shows.length > 0) {
+                html += "<div class=\"col-lg-12\" style=\"margin-top: 30px;\">";
+                html += "<h5><i class=\"fa fa-television text-info\"></i> Most Watched TV Shows</h5>";
+                html += "<div style=\"margin-top: 15px; overflow-x: auto; overflow-y: hidden; white-space: nowrap; padding-bottom: 10px; scrollbar-width: thin; scrollbar-color: rgba(255,255,255,0.3) transparent;\">";
+                html += "<style>div::-webkit-scrollbar { height: 8px; } div::-webkit-scrollbar-track { background: rgba(255,255,255,0.1); border-radius: 4px; } div::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.3); border-radius: 4px; } div::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.5); }</style>";
+                
+                stats.most_watched_shows.forEach(function(show) {
+                    var posterUrl = getPosterUrl(show.poster_path, show.id, show.server_id);
+                    var playCount = show.play_count || 0;
+                    var year = show.year && show.year !== "N/A" ? show.year : "";
+                    var title = show.title || "Unknown Show";
+                    
+                    // Use sanitized ID for DOM elements but original ID for data attributes
+                    var sanitizedId = sanitizeId(show.id);
+                    console.log("Using sanitized ID:", sanitizedId, "for original ID:", show.id);
+                    
+                    html += "<div style=\"display: inline-block; margin: 0 10px 0 0; width: 150px; vertical-align: top;\">";
+                    html += "<div class=\"poster-card metadata-get\" style=\"position: relative; width: 150px; height: 225px; transition: transform 0.2s ease; cursor: pointer;\" data-source=\"jellystat\" data-key=\"" + show.id + "\" data-uid=\"" + sanitizedId + "\">";
+                    
+                    // Poster image container
+                    html += "<div class=\"poster-image\" style=\"position: relative; width: 150px; height: 225px; border-radius: 8px; overflow: hidden; box-shadow: 0 4px 12px rgba(0,0,0,0.3);\">";
+                    
+                    // Hover overlay with title and year - initially hidden
+                    html += "<div class=\"poster-overlay\" style=\"position: absolute; bottom: 0; left: 0; right: 0; background: linear-gradient(transparent, rgba(0,0,0,0.8)); color: white; padding: 20px 10px 10px; opacity: 0; transition: opacity 0.3s ease; pointer-events: none;\">";
+                    html += "<div style=\"font-size: 12px; font-weight: bold; line-height: 1.3; margin-bottom: 4px; text-shadow: 1px 1px 2px rgba(0,0,0,0.9); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;\" title=\"" + title + "\">" + title + "</div>";
+                    if (year && year !== "N/A") {
+                        html += "<small style=\"color: rgba(255,255,255,0.8); text-shadow: 1px 1px 2px rgba(0,0,0,0.9); line-height: 1.2;\">" + year + "</small>";
+                    }
+                    html += "</div>";
+                    if (posterUrl) {
+                        html += "<img src=\"" + posterUrl + "\" alt=\"" + title + "\" style=\"width: 150px; height: 225px; object-fit: cover;\" onerror=\"this.style.display=\"none\"; this.nextElementSibling.style.display=\"flex\";\">";
+                    }
+                    html += "<div class=\"poster-placeholder\" style=\"position: absolute; top: 0; left: 0; width: 150px; height: 225px; display: " + (posterUrl ? "none" : "flex") + "; align-items: center; justify-content: center; background: linear-gradient(135deg, #74b9ff 0%, #0984e3 100%); color: white;\">";
+                    html += "<i class=\"fa fa-television fa-3x\"></i>";
+                    html += "</div>";
+                    
+                    // Play count badge
+                    html += "<div class=\"play-count-badge\" style=\"position: absolute; top: 8px; right: 8px; background: rgba(0,0,0,0.8); color: white; padding: 4px 8px; border-radius: 12px; font-size: 11px; backdrop-filter: blur(4px);\">";
+                    html += "<i class=\"fa fa-play\"></i> " + playCount;
+                    html += "</div>";
+                    html += "</div>";
+                    
+                    // Add CSS for hover effect - this will be applied once when the first poster is rendered
+                    if (show === stats.most_watched_shows[0]) {
+                        html += "<style>";
+                        html += ".poster-card:hover .poster-overlay { opacity: 1 !important; }";
+                        html += ".poster-card:hover { transform: scale(1.05); z-index: 10; }";
+                        html += "</style>";
+                    }
+                    
+                    html += "</div>";
+                    
+                    // Add metadata popup elements (Organizr style) using sanitized ID
+                    // Include a hidden anchor to trigger Magnific Popup, matching Emby/Jellyfin implementation
+                    html += "\u003ca class=\\"inline-popups " + sanitizedId + " hidden\\" href=\\"#" + sanitizedId + "-metadata-div\\" data-effect=\\"mfp-zoom-out\\"\u003e\u003c/a\u003e";
+                    html += "\u003cdiv id=\\"" + sanitizedId + "-metadata-div\\" class=\\"white-popup mfp-with-anim mfp-hide\\"\u003e";
+                    html += "\u003cdiv class=\\"col-md-8 col-md-offset-2 " + sanitizedId + "-metadata-info\\"\u003e\u003c/div\u003e";
+                    html += "\u003c/div\u003e";
+                    
+                    html += "\u003c/div\u003e";
+                });
+                
+                html += "</div></div>";
+            }
+            
+            // Most Listened Music with Cover Art
+            if (' . $showMostListenedMusic . ' && stats.most_listened_music && stats.most_listened_music.length > 0) {
+                html += "<div class=\"col-lg-12\" style=\"margin-top: 30px;\">";
+                html += "<h5><i class=\"fa fa-music text-success\"></i> Most Listened Music</h5>";
+                html += "<div class=\"row\" style=\"margin-top: 15px;\">";
+                
+                stats.most_listened_music.forEach(function(music) {
+                    var posterUrl = getPosterUrl(music.poster_path || music.cover_art, music.id, music.server_id);
+                    var playCount = music.play_count || 0;
+                    var artist = music.artist || "Unknown Artist";
+                    var title = music.title || music.album || "Unknown";
+                    
+                    html += "<div class=\"col-lg-2 col-md-3 col-sm-4 col-xs-6\" style=\"margin-bottom: 20px;\">";
+                    html += "<div class=\"poster-card\" style=\"position: relative; transition: transform 0.2s ease;\">";
+                    
+                    // Cover art
+                    html += "<div class=\"poster-image\" style=\"position: relative; padding-top: 100%; border-radius: 8px; overflow: hidden; box-shadow: 0 4px 12px rgba(0,0,0,0.3);\">";
+                    if (posterUrl) {
+                        html += "<img src=\"" + posterUrl + "\" alt=\"" + title + "\" style=\"position: absolute; top: 0; left: 0; width: 100%; height: 100%; object-fit: cover;\" onerror=\"this.style.display=\"none\"; this.nextElementSibling.style.display=\"flex\";\">";
+                    }
+                    html += "<div class=\"poster-placeholder\" style=\"position: absolute; top: 0; left: 0; width: 100%; height: 100%; display: " + (posterUrl ? "none" : "flex") + "; align-items: center; justify-content: center; background: linear-gradient(135deg, #fd79a8 0%, #e84393 100%); color: white;\">";
+                    html += "<i class=\"fa fa-music fa-3x\"></i>";
+                    html += "</div>";
+                    
+                    // Play count badge
+                    html += "<div class=\"play-count-badge\" style=\"position: absolute; top: 8px; right: 8px; background: rgba(0,0,0,0.8); color: white; padding: 4px 8px; border-radius: 12px; font-size: 11px; backdrop-filter: blur(4px);\">";
+                    html += "<i class=\"fa fa-play\"></i> " + playCount;
+                    html += "</div>";
+                    html += "</div>";
+                    
+                    // Music info with transparent background and white text
+                    html += "<div class=\"poster-info\" style=\"padding: 12px 8px; text-align: center;\">";
+                    html += "<h6 style=\"margin: 0 0 4px 0; font-size: 13px; font-weight: bold; line-height: 1.2; height: 32px; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; color: white; text-shadow: 1px 1px 2px rgba(0,0,0,0.8);\" title=\"" + title + "\">" + title + "</h6>";
+                    html += "<small style=\"color: rgba(255,255,255,0.8); text-shadow: 1px 1px 2px rgba(0,0,0,0.8);\">" + artist + "</small>";
+                    html += "</div>";
+                    
+                    html += "</div></div>";
+                });
+                
+                html += "</div></div>";
+            }
+            
+            if (!html) {
+                html = "<div class=\"col-lg-12 text-center text-muted\">";
+                html += "<i class=\"fa fa-exclamation-circle fa-3x\" style=\"margin-bottom: 10px;\"></i>";
+                html += "<h4>No JellyStat data available</h4>";
+                html += "<p>Check your JellyStat connection and API configuration.</p>";
+                html += "</div>";
+            }
+            
+            $("#jellystat-content").html(html);
+        }
+
+        // Auto-refresh setup
+        var refreshInterval = ' . $refreshInterval . ';
+        if (refreshInterval > 0) {
+            jellyStatRefreshTimer = setInterval(function() {
+                refreshJellyStatData();
+            }, refreshInterval);
+        }
+
+        // Update time display every 30 seconds
+        setInterval(updateJellyStatLastRefreshTime, 30000);
+
+        // Initial load
+        $(document).ready(function() {
+            refreshJellyStatData();
+        });
+
+        // Cleanup timer when page unloads
+        $(window).on("beforeunload", function() {
+            if (jellyStatRefreshTimer) {
+                clearInterval(jellyStatRefreshTimer);
+            }
+        });
+        
+        // JellyStat metadata popups are handled by Organizr\'s built-in metadata-get click handler
+        // The handler will call api/v2/homepage/jellystat/metadata with the data-key value
+        
+        </script>
+        ';
+    }
+
+    /**
+     * Main function to get JellyStat data
+     */
+    public function getJellyStatData($options = null)
+    {
+        if (!$this->homepageItemPermissions($this->jellystatHomepagePermissions('main'), true)) {
+            $this->setAPIResponse('error', 'User not approved to view this homepage item - check plugin configuration', 401);
+            return false;
+        }
+
+        try {
+            $url = $this->config['jellyStatURL'] ?? '';
+            $token = $this->config['jellyStatApikey'] ?? '';
+            $days = intval($this->config['homepageJellyStatDays'] ?? 30);
+            
+            if (empty($url) || empty($token)) {
+                $this->setAPIResponse('error', 'JellyStat URL or API key not configured', 500);
+                return false;
+            }
+            
+            $stats = $this->fetchJellyStatStats($url, $token, $days);
+            
+            if (isset($stats['error']) && $stats['error']) {
+                $this->setAPIResponse('error', $stats['message'], 500);
+                return false;
+            }
+            
+            $this->setAPIResponse('success', 'JellyStat data retrieved successfully', 200, $stats);
+            return true;
+            
+        } catch (Exception $e) {
+            $this->setAPIResponse('error', 'Failed to retrieve JellyStat data: ' . $e->getMessage(), 500);
+            return false;
+        }
+    }
+
+    /**
+     * Fetch statistics from JellyStat API
+     */
+    private function fetchJellyStatStats($url, $token, $days = 30)
+    {
+        $disableCert = $this->config['jellyStatDisableCertCheck'] ?? false;
+        $customCert = $this->config['jellyStatUseCustomCertificate'] ?? false;
+        
+        // Use internal URL for server-side API calls if configured, otherwise use main URL
+        $internalUrl = $this->config['jellyStatInternalURL'] ?? '';
+        $apiUrl = !empty($internalUrl) ? $internalUrl : $url;
+        
+        $options = $this->requestOptions($apiUrl, null, $disableCert, $customCert);
+        $baseUrl = $this->qualifyURL($apiUrl);
+        
+        $stats = [
+            'period' => "{$days} days",
+            'libraries' => [],
+            'library_totals' => [],
+            'server_info' => [],
+            'users' => [],
+            'most_watched_movies' => [],
+            'most_watched_shows' => [],
+            'most_listened_music' => []
+        ];
+        
+        $mostWatchedCount = $this->config['homepageJellyStatMostWatchedCount'] ?? 10;
+        
+        try {
+            // Get Library Statistics - use query parameter authentication
+            $librariesUrl = $baseUrl . '/api/getLibraries?apiKey=' . urlencode($token);
+            $response = Requests::get($librariesUrl, [], $options);
+            if ($response->success) {
+                $data = json_decode($response->body, true);
+                if (is_array($data) && !isset($data['error'])) {
+                    // Process individual libraries
+                    $stats['libraries'] = array_map(function($lib) {
+                        return [
+                            'name' => $lib['Name'] ?? 'Unknown Library',
+                            'type' => $this->getCollectionTypeLabel($lib['CollectionType'] ?? 'unknown'),
+                            'item_count' => $lib['item_count'] ?? 0,
+                            'season_count' => $lib['season_count'] ?? 0,
+                            'episode_count' => $lib['episode_count'] ?? 0,
+                            'total_play_time' => $lib['total_play_time'] ? $this->formatJellyStatDuration($lib['total_play_time']) : '0 min',
+                            'play_time_raw' => $lib['total_play_time'] ?? 0,
+                            'collection_type' => $lib['CollectionType'] ?? 'unknown'
+                        ];
+                    }, $data);
+                    
+                    // Calculate totals across all libraries
+                    $totalItems = array_sum(array_column($data, 'item_count'));
+                    $totalSeasons = array_sum(array_column($data, 'season_count'));
+                    $totalEpisodes = array_sum(array_column($data, 'episode_count'));
+                    $totalPlayTime = array_sum(array_column($data, 'total_play_time'));
+                    
+                    // Calculate library type breakdowns
+                    $typeBreakdown = [];
+                    foreach ($data as $lib) {
+                        $type = $lib['CollectionType'] ?? 'unknown';
+                        if (!isset($typeBreakdown[$type])) {
+                            $typeBreakdown[$type] = [
+                                'count' => 0,
+                                'items' => 0,
+                                'play_time' => 0,
+                                'label' => $this->getCollectionTypeLabel($type)
+                            ];
+                        }
+                        $typeBreakdown[$type]['count']++;
+                        $typeBreakdown[$type]['items'] += $lib['item_count'] ?? 0;
+                        $typeBreakdown[$type]['play_time'] += $lib['total_play_time'] ?? 0;
+                    }
+                    
+                    $stats['library_totals'] = [
+                        'total_libraries' => count($data),
+                        'total_items' => $totalItems,
+                        'total_seasons' => $totalSeasons,
+                        'total_episodes' => $totalEpisodes,
+                        'total_play_time' => $this->formatJellyStatDuration($totalPlayTime),
+                        'total_play_time_raw' => $totalPlayTime,
+                        'type_breakdown' => $typeBreakdown
+                    ];
+                    
+                    // Server information
+                    $stats['server_info'] = [
+                        'server_id' => $data[0]['ServerId'] ?? 'Unknown',
+                        'last_updated' => date('c')
+                    ];
+                }
+            }
+            
+            // Get History data and process to extract most watched content
+            // Calculate the start date based on the configured days period
+            $startDate = date('Y-m-d', strtotime("-{$days} days"));
+            
+            // Fetch ALL history data using pagination to ensure complete play counts
+            $allHistoryResults = $this->fetchAllJellyStatHistory($baseUrl, $token, $startDate, $options);
+            
+            if (!empty($allHistoryResults)) {
+                // Process history to get most watched content
+                $processedData = $this->processJellyStatHistory($allHistoryResults);
+                
+                // Extract most watched items based on user settings
+                if ($this->config['homepageJellyStatShowMostWatchedMovies'] ?? false) {
+                    $stats['most_watched_movies'] = array_slice($processedData['movies'], 0, $mostWatchedCount);
+                }
+                
+                if ($this->config['homepageJellyStatShowMostWatchedShows'] ?? false) {
+                    $stats['most_watched_shows'] = array_slice($processedData['shows'], 0, $mostWatchedCount);
+                }
+                
+                if ($this->config['homepageJellyStatShowMostListenedMusic'] ?? false) {
+                    $stats['most_listened_music'] = array_slice($processedData['music'], 0, $mostWatchedCount);
+                }
+
+                // Aggregate user activity statistics for frontend Active Users section
+                $stats['users'] = $this->aggregateJellyStatUsers($allHistoryResults);
+            }
+            
+        } catch (Exception $e) {
+            return ['error' => true, 'message' => 'Failed to fetch JellyStat data: ' . $e->getMessage()];
+        }
+        
+        return $stats;
+    }
+
+    /**
+     * Fetch all history from JellyStat using pagination
+     */
+    private function fetchAllJellyStatHistory($baseUrl, $token, $startDate, $options)
+    {
+        $allResults = [];
+        $page = 1;
+        $pageSize = 1000; // API page size limit
+
+        do {
+            $historyUrl = $baseUrl . '/api/getHistory?apiKey=' . urlencode($token) . 
+                         '&page=' . $page . 
+                         '&size=' . $pageSize . 
+                         '&startDate=' . urlencode($startDate);
+
+            $response = Requests::get($historyUrl, [], $options);
+            if (!$response->success) {
+                // Stop if there is an error
+                break;
+            }
+
+            $data = json_decode($response->body, true);
+            if (!isset($data['results']) || !is_array($data['results']) || empty($data['results'])) {
+                // No more results, break the loop
+                break;
+            }
+
+            $allResults = array_merge($allResults, $data['results']);
+            $page++;
+
+        } while (count($data['results']) == $pageSize);
+
+        return $allResults;
+    }
+    
+    /**
+     * Generate poster URL from JellyStat API
+     */
+    private function getPosterUrl($posterPath, $itemId, $serverId)
+    {
+        // Use main URL for poster display (not internal URL)
+        $jellyStatUrl = $this->qualifyURL($this->config['jellyStatURL'] ?? '');
+        
+        if (!$jellyStatUrl) {
+            return null;
+        }
+        
+        // If we have a poster path, process it
+        if ($posterPath) {
+            // If its already an absolute URL, use it directly
+            if (strpos($posterPath, 'http://') === 0 || strpos($posterPath, 'https://') === 0) {
+                return $posterPath;
+            }
+            // If its a relative path starting with /, prepend the JellyStat URL
+            if (strpos($posterPath, '/') === 0) {
+                return rtrim($jellyStatUrl, '/') . $posterPath;
+            }
+        }
+        
+        // If we have itemId, try to generate JellyStat image proxy URL
+        if ($itemId) {
+            // JellyStat uses /proxy/Items/Images/Primary endpoint
+            // Format: /proxy/Items/Images/Primary?id={itemId}&fillWidth=200&quality=90
+            $baseUrl = rtrim($jellyStatUrl, '/');
+            return $baseUrl . '/proxy/Items/Images/Primary?id=' . urlencode($itemId) . '&fillWidth=200&quality=90';
+        }
+        
+        return null;
+    }
+    
+    /**
+     * Get human-readable label for collection type
+     */
+    private function getCollectionTypeLabel($type)
+    {
+        $labels = [
+            'movies' => 'Movies',
+            'tvshows' => 'TV Shows',
+            'music' => 'Music',
+            'mixed' => 'Mixed Content',
+            'unknown' => 'Other'
+        ];
+        
+        return $labels[$type] ?? ucfirst($type);
+    }
+
+    /**
+     * Format bytes to human readable format
+     */
+    private function formatBytes($size, $precision = 2)
+    {
+        if ($size == 0) return '0 B';
+        
+        $base = log($size, 1024);
+        $suffixes = ['B', 'KB', 'MB', 'GB', 'TB'];
+        
+        return round(pow(1024, $base - floor($base)), $precision) . ' ' . $suffixes[floor($base)];
+    }
+
+    /**
+     * Format duration for display (JellyStat specific)
+     */
+    private function formatJellyStatDuration($ticks)
+    {
+        if ($ticks == 0) return 'Unknown';
+        
+        // Convert ticks to seconds (ticks are in 100-nanosecond intervals)
+        $seconds = $ticks / 10000000;
+        
+        if ($seconds < 3600) {
+            return gmdate('i:s', $seconds);
+        } else {
+            return gmdate('H:i:s', $seconds);
+        }
+    }
+    
+    /**
+     * Generate media server link for metadata popup
+     */
+    private function generateMediaServerLink($metadataSource, $mediaServerUrl, $itemId, $serverId = null)
+    {
+        if (!$metadataSource || !$mediaServerUrl) {
+            // If we don't know the source, return empty (no link)
+            return '';
+        }
+        
+        // Check if we have proper Emby/Jellyfin configuration
+        if ($metadataSource === 'jellyfin' && $this->config['homepageJellyfinLink']) {
+            $variablesForLink = [
+                '{id}' => $itemId,
+                '{serverId}' => $serverId ?? ''
+            ];
+            return $this->userDefinedIdReplacementLink($this->config['homepageJellyfinLink'], $variablesForLink);
+        } elseif ($metadataSource === 'emby' && $this->config['homepageEmbyLink']) {
+            $variablesForLink = [
+                '{id}' => $itemId,
+                '{serverId}' => $serverId ?? ''
+            ];
+            return $this->userDefinedIdReplacementLink($this->config['homepageEmbyLink'], $variablesForLink);
+        }
+        
+        // Fallback to direct URL if no custom link configured
+        $baseUrl = rtrim($mediaServerUrl, '/');
+        return $baseUrl . '/web/index.html#!/item?id=' . $itemId . '&serverId=' . ($serverId ?? '');
+    }
+    
+    /**
+     * Get the tab name for the media server
+     */
+    private function getMediaServerTabName($metadataSource)
+    {
+        if (!$metadataSource) {
+            return '';
+        }
+        
+        if ($metadataSource === 'jellyfin') {
+            return $this->config['jellyfinTabName'] ?? '';
+        } elseif ($metadataSource === 'emby') {
+            return $this->config['embyTabName'] ?? '';
+        }
+        
+        return '';
+    }
+    
+    /**
+     * Check if we should open media server tab
+     */
+    private function shouldOpenMediaServerTab($metadataSource)
+    {
+        if (!$metadataSource) {
+            return false;
+        }
+        
+        if ($metadataSource === 'jellyfin') {
+            return ($this->config['jellyfinTabURL'] && $this->config['jellyfinTabName']) ? true : false;
+        } elseif ($metadataSource === 'emby') {
+            return ($this->config['embyTabURL'] && $this->config['embyTabName']) ? true : false;
+        }
+        
+        return false;
+    }
+    
+    /**
+     * Process JellyStat history data to extract most watched content
+     */
+    private function processJellyStatHistory($historyResults)
+    {
+        $processed = [
+            'movies' => [],
+            'shows' => [],
+            'music' => []
+        ];
+        
+        // Group items by ID and count plays
+        $itemStats = [];
+        
+        // Debug: Log sample of first few results to understand data structure
+        $this->setLoggerChannel('JellyStat')->info('JellyStat History Debug: Processing ' . count($historyResults) . ' history records');
+        if (count($historyResults) > 0) {
+            $this->setLoggerChannel('JellyStat')->info('JellyStat Sample Record: ' . json_encode(array_slice($historyResults, 0, 3), JSON_PRETTY_PRINT));
+        }
+        
+        foreach ($historyResults as $index => $result) {
+            // Determine content type based on available data
+            $contentType = 'unknown';
+            $itemId = null;
+            $title = 'Unknown';
+            $year = null;
+            $serverId = $result['ServerId'] ?? null;
+            
+            // Check if it's a TV show (has SeriesName)
+            if (!empty($result['SeriesName'])) {
+                $contentType = 'show';
+                $itemId = $result['SeriesName']; // Use series name as unique identifier
+                $title = $result['SeriesName'];
+                
+                // Try to extract year from multiple possible sources for TV shows
+                // 1. Check for SeriesProductionYear or ProductionYear fields
+                if (!empty($result['SeriesProductionYear'])) {
+                    $year = (string)$result['SeriesProductionYear'];
+                } elseif (!empty($result['ProductionYear'])) {
+                    $year = (string)$result['ProductionYear'];
+                } elseif (!empty($result['PremiereDate'])) {
+                    // Extract year from premiere date
+                    $year = date('Y', strtotime($result['PremiereDate']));
+                } else {
+                    // 2. Try to extract year from series name (e.g., "Show Name (2019)")
+                    if (preg_match('/\((19|20)\d{2}\)/', $title, $matches)) {
+                        $year = trim($matches[0], '()');
+                        $title = trim(str_replace($matches[0], '', $title));
+                    } elseif (!empty($result['EpisodeName'])) {
+                        // 3. As a last resort, try to extract year from episode name
+                        $episodeTitle = $result['EpisodeName'];
+                        if (preg_match('/\b(19|20)\d{2}\b/', $episodeTitle, $matches)) {
+                            $year = $matches[0];
+                        }
+                    }
+                }
+            }
+            // Check if it's a movie (has NowPlayingItemName but no SeriesName)
+            elseif (!empty($result['NowPlayingItemName']) && empty($result['SeriesName'])) {
+                // Determine if it's likely a movie or music based on duration or other hints
+                $itemName = $result['NowPlayingItemName'];
+                $duration = $result['PlaybackDuration'] ?? 0;
+                
+                // If duration is very short (< 10 minutes) and no video streams, likely music
+                $hasVideo = false;
+                if (isset($result['MediaStreams']) && is_array($result['MediaStreams'])) {
+                    foreach ($result['MediaStreams'] as $stream) {
+                        if (($stream['Type'] ?? '') === 'Video') {
+                            $hasVideo = true;
+                            break;
+                        }
+                    }
+                }
+                
+                if (!$hasVideo || $duration < 600) { // Less than 10 minutes and no video = likely music
+                    $contentType = 'music';
+                    $title = $itemName;
+                    // For music, try to extract artist info
+                    // Music tracks might have format like "Artist - Song" or just "Song"
+                } else {
+                    $contentType = 'movie';
+                    $title = $itemName;
+                    
+                    // Try to extract year from multiple possible sources for movies
+                    // 1. Check for ProductionYear field first
+                    if (!empty($result['ProductionYear'])) {
+                        $year = (string)$result['ProductionYear'];
+                    } elseif (!empty($result['PremiereDate'])) {
+                        // Extract year from premiere date
+                        $year = date('Y', strtotime($result['PremiereDate']));
+                    } else {
+                        // 2. Try to extract year from movie title (e.g., "Movie Title (2019)")
+                        if (preg_match('/\((19|20)\d{2}\)/', $title, $matches)) {
+                            $year = trim($matches[0], '()');
+                            $title = trim(str_replace($matches[0], '', $title));
+                        }
+                    }
+                }
+                
+                $itemId = $result['NowPlayingItemId'] ?? $itemName;
+            }
+            
+            if ($itemId && $contentType !== 'unknown') {
+                $key = $contentType . '_' . $itemId;
+                
+                if (!isset($itemStats[$key])) {
+                    // Extract poster/image information from JellyStat API response
+                    $posterPath = null;
+                    $actualItemId = null;
+                    
+                    // Get the actual Jellyfin/Emby item ID for poster generation
+                    // Note: JellyStat history API doesn't provide poster paths directly,
+                    // so we'll use item IDs with JellyStat's image proxy API
+                    if ($contentType === 'movie') {
+                        // For movies, use the NowPlayingItemId
+                        $actualItemId = $result['NowPlayingItemId'] ?? null;
+                    } elseif ($contentType === 'show') {
+                        // Debug: Log all available IDs for TV shows to understand data structure
+                        $this->setLoggerChannel('JellyStat')->info("JellyStat TV Show Debug - Series: {$result['SeriesName']}");
+                        $this->setLoggerChannel('JellyStat')->info("Available IDs: SeriesId=" . ($result['SeriesId'] ?? 'null') . 
+                                 ", ShowId=" . ($result['ShowId'] ?? 'null') . 
+                                 ", ParentId=" . ($result['ParentId'] ?? 'null') . 
+                                 ", NowPlayingItemId=" . ($result['NowPlayingItemId'] ?? 'null'));
+                        
+                        // For TV shows, be more selective about ID selection to ensure we get series posters
+                        // Priority: SeriesId (if exists) > ShowId > NowPlayingItemId (only if it looks like series) > ParentId
+                        $actualItemId = null;
+                        
+                        if (!empty($result['SeriesId'])) {
+                            // SeriesId is the most reliable for series posters
+                            $actualItemId = $result['SeriesId'];
+                            $this->setLoggerChannel('JellyStat')->info("Using SeriesId: {$actualItemId}");
+                        } elseif (!empty($result['ShowId'])) {
+                            // ShowId is also series-specific
+                            $actualItemId = $result['ShowId'];
+                            $this->setLoggerChannel('JellyStat')->info("Using ShowId: {$actualItemId}");
+                        } elseif (!empty($result['NowPlayingItemId'])) {
+                            // Try NowPlayingItemId - it might be the series ID if we're looking at series-level data
+                            $actualItemId = $result['NowPlayingItemId'];
+                            $this->setLoggerChannel('JellyStat')->info("Using NowPlayingItemId: {$actualItemId}");
+                        } elseif (!empty($result['ParentId'])) {
+                            // Last resort: ParentId (might be series, season, or library)
+                            $actualItemId = $result['ParentId'];
+                            $this->setLoggerChannel('JellyStat')->info("Using ParentId: {$actualItemId}");
+                        }
+                        
+                        if (!$actualItemId) {
+                            $this->setLoggerChannel('JellyStat')->info("No suitable ID found for TV show: {$result['SeriesName']}");
+                        }
+                    } elseif ($contentType === 'music') {
+                        // For music, use NowPlayingItemId (album/track)
+                        $actualItemId = $result['NowPlayingItemId'] ?? null;
+                    }
+                    
+                    $itemStats[$key] = [
+                        'id' => $actualItemId ?? $itemId,  // Use actual item ID if available, fallback to name-based ID
+                        'title' => $title,
+                        'type' => $contentType,
+                        'play_count' => 0,
+                        'total_duration' => 0,
+                        'year' => $year,
+                        'server_id' => $serverId,
+                        'poster_path' => $posterPath,
+                        'first_played' => $result['ActivityDateInserted'] ?? null,
+                        'last_played' => $result['ActivityDateInserted'] ?? null
+                    ];
+                }
+                
+                $itemStats[$key]['play_count']++;
+                $itemStats[$key]['total_duration'] += $result['PlaybackDuration'] ?? 0;
+                
+                // Debug: Log each play count increment
+                if ($contentType === 'show') {
+                    $this->setLoggerChannel('JellyStat')->info("Play count increment for {$title}: now {$itemStats[$key]['play_count']} (Episode: {$result['EpisodeName']}, User: {$result['UserName']}, Date: {$result['ActivityDateInserted']})");
+                }
+                
+                // Update last played time
+                $currentTime = $result['ActivityDateInserted'] ?? null;
+                if ($currentTime && (!$itemStats[$key]['last_played'] || $currentTime > $itemStats[$key]['last_played'])) {
+                    $itemStats[$key]['last_played'] = $currentTime;
+                }
+                
+                // Update first played time  
+                if ($currentTime && (!$itemStats[$key]['first_played'] || $currentTime < $itemStats[$key]['first_played'])) {
+                    $itemStats[$key]['first_played'] = $currentTime;
+                }
+            }
+        }
+        
+        // Separate by content type and sort by play count
+        foreach ($itemStats as $item) {
+            switch ($item['type']) {
+                case 'movie':
+                    $processed['movies'][] = $item;
+                    break;
+                case 'show':
+                    $processed['shows'][] = $item;
+                    break;
+                case 'music':
+                    $processed['music'][] = $item;
+                    break;
+            }
+        }
+        
+        // Sort each category by play count (descending)
+        usort($processed['movies'], function($a, $b) {
+            return $b['play_count'] - $a['play_count'];
+        });
+        
+        usort($processed['shows'], function($a, $b) {
+            return $b['play_count'] - $a['play_count'];
+        });
+        
+        usort($processed['music'], function($a, $b) {
+            return $b['play_count'] - $a['play_count'];
+        });
+        
+        return $processed;
+    }
+
+    /**
+     * Aggregate user statistics (plays and last activity) from JellyStat history
+     */
+    private function aggregateJellyStatUsers($historyResults)
+    {
+        $users = [];
+        foreach ($historyResults as $row) {
+            $name = $row['UserName'] ?? ($row['User'] ?? 'Unknown User');
+            if (!isset($users[$name])) {
+                $users[$name] = [
+                    'name' => $name,
+                    'play_count' => 0,
+                    'last_activity' => null,
+                ];
+            }
+            $users[$name]['play_count']++;
+            $activity = $row['ActivityDateInserted'] ?? ($row['Date'] ?? null);
+            if ($activity) {
+                if ($users[$name]['last_activity'] === null || $activity > $users[$name]['last_activity']) {
+                    $users[$name]['last_activity'] = $activity;
+                }
+            }
+        }
+        // Sort by play_count desc, then by last_activity desc
+        usort($users, function($a, $b) {
+            if ($b['play_count'] === $a['play_count']) {
+                return strcmp($b['last_activity'] ?? '', $a['last_activity'] ?? '');
+            }
+            return $b['play_count'] <=> $a['play_count'];
+        });
+        // Return as a list
+        return array_values($users);
+    }
+}

+ 5 - 4
api/pages/settings-settings-logs.php

@@ -15,10 +15,11 @@ function get_page_settings_settings_logs($Organizr)
 	$filterDropdown = $Organizr->buildFilterDropdown();
 	return '
 	<div class="btn-group m-b-20 pull-left">' . $logsDropdown . '</div>
-	<button class="btn btn-danger waves-effect waves-light pull-right purgeLog" type="button" data-toggle="tooltip" data-placement="bottom" title="" data-original-title="Purge Log"><i class="fa fa-trash"></i></span></button>
-	<button onclick="shortcut(\'log-settings\')" class="btn btn-inverse waves-effect waves-light pull-right m-r-5" type="button" data-toggle="tooltip" data-placement="bottom" title="" data-original-title="Log Settings"><i class="fa fa-cog"></i></span></button>
-	<button onclick="organizrLogTable.clear().draw().ajax.reload(null, false)" class="btn btn-info waves-effect waves-light pull-right reloadLog m-r-5" type="button" data-toggle="tooltip" data-placement="bottom" title="" data-original-title="Reload Log"><i class="fa fa-refresh"></i></span></button>
-	<button onclick="toggleKillOrganizrLiveUpdate(' . $Organizr->config['logLiveUpdateRefresh'] . ');" class="btn btn-primary waves-effect waves-light pull-right organizr-log-live-update m-r-5" type="button" data-toggle="tooltip" data-placement="bottom" title="" data-original-title="Live Update"><i class="fa fa-clock-o"></i></span></button>
+	<button class="btn btn-danger waves-effect waves-light pull-right purgeLog" type="button" data-toggle="tooltip" data-placement="bottom" title="" data-original-title="Purge Log"><i class="fa fa-trash"></i></button>
+	<button onclick="shortcut(\'log-settings\')" class="btn btn-inverse waves-effect waves-light pull-right m-r-5" type="button" data-toggle="tooltip" data-placement="bottom" title="" data-original-title="Log Settings"><i class="fa fa-cog"></i></button>
+	<button onclick="exportLogs()" class="btn btn-success waves-effect waves-light pull-right m-r-5" type="button" data-toggle="tooltip" data-placement="bottom" title="" data-original-title="Export Logs"><i class="fa fa-download"></i></button>
+	<button onclick="organizrLogTable.clear().draw().ajax.reload(null, false)" class="btn btn-info waves-effect waves-light pull-right reloadLog m-r-5" type="button" data-toggle="tooltip" data-placement="bottom" title="" data-original-title="Reload Log"><i class="fa fa-refresh"></i></button>
+	<button onclick="toggleKillOrganizrLiveUpdate(' . $Organizr->config['logLiveUpdateRefresh'] . ');" class="btn btn-primary waves-effect waves-light pull-right organizr-log-live-update m-r-5" type="button" data-toggle="tooltip" data-placement="bottom" title="" data-original-title="Live Update"><i class="fa fa-clock-o"></i></button>
 	' . $filterDropdown . '
 	<div class="clearfix"></div>
 	<div class="white-box bg-org logTable orgLogDiv">

+ 22 - 0
api/v2/routes/connectionTester.php

@@ -743,3 +743,25 @@ $app->post('/test/slack-logs', function ($request, $response, $args) {
 		->withHeader('Content-Type', 'application/json;charset=UTF-8')
 		->withStatus($GLOBALS['responseCode']);
 });
+$app->post('/test/jellystat', function ($request, $response, $args) {
+	/**
+	 * @OA\Post(
+	 *     security={{ "api_key":{} }},
+	 *     tags={"test connection"},
+	 *     path="/api/v2/test/jellystat",
+	 *     summary="Test connection to JellyStat",
+	 *     @OA\Response(response="200",description="Success",@OA\JsonContent(ref="#/components/schemas/success-message")),
+	 *     @OA\Response(response="401",description="Unauthorized",@OA\JsonContent(ref="#/components/schemas/unauthorized-message")),
+	 *     @OA\Response(response="422",description="Error",@OA\JsonContent(ref="#/components/schemas/error-message")),
+	 *     @OA\Response(response="500",description="Error",@OA\JsonContent(ref="#/components/schemas/error-message")),
+	 * )
+	 */
+	$Organizr = ($request->getAttribute('Organizr')) ?? new Organizr();
+	if ($Organizr->qualifyRequest(1, true)) {
+		$Organizr->testConnectionJellyStat();
+	}
+	$response->getBody()->write(jsonE($GLOBALS['api']));
+	return $response
+		->withHeader('Content-Type', 'application/json;charset=UTF-8')
+		->withStatus($GLOBALS['responseCode']);
+});

+ 19 - 0
api/v2/routes/homepage.php

@@ -621,3 +621,22 @@ $app->get('/homepage/embyLiveTVTracker/activity', function ($request, $response,
 		->withHeader('Content-Type', 'application/json;charset=UTF-8')
 		->withStatus($GLOBALS['responseCode']);
 });
+$app->get('/homepage/jellystat', function ($request, $response, $args) {
+	$Organizr = ($request->getAttribute('Organizr')) ?? new Organizr();
+	$Organizr->getJellyStatData();
+	$response->getBody()->write(jsonE($GLOBALS['api']));
+	return $response
+		->withHeader('Content-Type', 'application/json;charset=UTF-8')
+		->withStatus($GLOBALS['responseCode']);
+});
+$app->post('/homepage/jellystat/metadata', function ($request, $response, $args) {
+	$Organizr = ($request->getAttribute('Organizr')) ?? new Organizr();
+	$Organizr->info('JellyStat metadata route called');
+	$apiData = $Organizr->apiData($request);
+	$Organizr->info('API data: ' . json_encode($apiData));
+	$Organizr->getJellyStatMetadata($apiData);
+	$response->getBody()->write(jsonE($GLOBALS['api']));
+	return $response
+		->withHeader('Content-Type', 'application/json;charset=UTF-8')
+		->withStatus($GLOBALS['responseCode']);
+});

+ 3 - 0
css/organizr.css

@@ -1066,6 +1066,9 @@ input#inviteCodeInput {
 .bg-jellyfin {
     background: #a15dc3;
 }
+.bg-jellystat {
+    background: #00a4dc;  /* JellyStat blue color */
+}
 .bg-healthchecks {
     background: #56b059;
 }

Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 0 - 0
css/organizr.min.css


+ 139 - 0
debug_jellystat_metadata.php

@@ -0,0 +1,139 @@
+<?php
+/**
+ * Debug script to test JellyStat metadata functionality
+ * Run this to troubleshoot why metadata returns "Unknown Item"
+ */
+
+require_once 'api/config/config.php';
+require_once 'api/classes/organizr.php';
+
+// Create Organizr instance (this will load config)
+$organizr = new Organizr();
+
+echo "=== JellyStat Metadata Debug Tool ===\n\n";
+
+// Check configuration
+$jellyStatURL = $organizr->config['jellyStatURL'] ?? '';
+$jellyStatInternalURL = $organizr->config['jellyStatInternalURL'] ?? '';
+$jellyStatApikey = $organizr->config['jellyStatApikey'] ?? '';
+$enabled = $organizr->config['homepageJellyStatEnabled'] ?? false;
+
+echo "Configuration:\n";
+echo "- JellyStat URL: " . ($jellyStatURL ?: 'NOT SET') . "\n";
+echo "- Internal URL: " . ($jellyStatInternalURL ?: 'NOT SET') . "\n";  
+echo "- API Key: " . ($jellyStatApikey ? 'SET (****)' : 'NOT SET') . "\n";
+echo "- Plugin Enabled: " . ($enabled ? 'YES' : 'NO') . "\n\n";
+
+if (!$enabled) {
+    echo "❌ JellyStat plugin is not enabled!\n";
+    echo "Enable it in Organizr settings first.\n";
+    exit(1);
+}
+
+if (!$jellyStatURL) {
+    echo "❌ JellyStat URL is not configured!\n";
+    echo "Set jellyStatURL in Organizr settings first.\n";
+    exit(1);
+}
+
+// Test basic connectivity
+$testUrl = !empty($jellyStatInternalURL) ? $jellyStatInternalURL : $jellyStatURL;
+$testUrl = rtrim($organizr->qualifyURL($testUrl), '/');
+
+echo "Testing connectivity to: {$testUrl}\n\n";
+
+// Test endpoints that the metadata function would try
+$testKey = 'test-item-id';
+$endpoints = [];
+
+if ($jellyStatApikey) {
+    $endpoints['JellyStat API (getItem)'] = $testUrl . '/api/getItem?apiKey=' . urlencode($jellyStatApikey) . '&id=' . urlencode($testKey);
+    $endpoints['JellyStat API (getItemById)'] = $testUrl . '/api/getItemById?apiKey=' . urlencode($jellyStatApikey) . '&id=' . urlencode($testKey);
+}
+
+$endpoints['Jellyfin Proxy (basic)'] = $testUrl . '/proxy/Items/' . rawurlencode($testKey);
+$endpoints['Jellyfin Proxy (with fields)'] = $testUrl . '/proxy/Items/' . rawurlencode($testKey) . '?Fields=Overview,People,Genres,CommunityRating,CriticRating,Studios,Taglines,ProductionYear,PremiereDate';
+
+// Also test a real item ID if provided via command line
+if (isset($argv[1])) {
+    $realKey = $argv[1];
+    echo "Testing with real item ID: {$realKey}\n\n";
+    
+    if ($jellyStatApikey) {
+        $endpoints['Real Item - JellyStat API'] = $testUrl . '/api/getItem?apiKey=' . urlencode($jellyStatApikey) . '&id=' . urlencode($realKey);
+    }
+    $endpoints['Real Item - Jellyfin Proxy'] = $testUrl . '/proxy/Items/' . rawurlencode($realKey) . '?Fields=Overview,People,Genres,CommunityRating,CriticRating,Studios,Taglines,ProductionYear,PremiereDate';
+}
+
+foreach ($endpoints as $name => $url) {
+    echo "Testing {$name}:\n";
+    echo "URL: {$url}\n";
+    
+    try {
+        $options = $organizr->requestOptions($url, null, 
+            $organizr->config['jellyStatDisableCertCheck'] ?? false,
+            $organizr->config['jellyStatUseCustomCertificate'] ?? false
+        );
+        
+        $response = Requests::get($url, [], $options);
+        
+        if ($response->success) {
+            $data = json_decode($response->body, true);
+            
+            if (json_last_error() === JSON_ERROR_NONE) {
+                echo "✅ SUCCESS - Status: {$response->status_code}\n";
+                
+                if (is_array($data)) {
+                    $keys = array_keys($data);
+                    echo "Response has keys: " . implode(', ', array_slice($keys, 0, 10)) . 
+                         (count($keys) > 10 ? '... (' . count($keys) . ' total)' : '') . "\n";
+                    
+                    // Look for typical media metadata fields
+                    $mediaFields = ['Name', 'Id', 'Type', 'Overview', 'Genres', 'People', 'RunTimeTicks', 'ProductionYear'];
+                    $foundFields = array_intersect($keys, $mediaFields);
+                    if (!empty($foundFields)) {
+                        echo "Media fields found: " . implode(', ', $foundFields) . "\n";
+                        
+                        // Show sample values
+                        foreach (['Name', 'Type', 'Overview'] as $field) {
+                            if (isset($data[$field])) {
+                                $value = $data[$field];
+                                if (is_string($value) && strlen($value) > 50) {
+                                    $value = substr($value, 0, 47) . '...';
+                                }
+                                echo "{$field}: {$value}\n";
+                            }
+                        }
+                    }
+                } else {
+                    echo "Response is not an array: " . gettype($data) . "\n";
+                }
+            } else {
+                echo "❌ Invalid JSON response\n";
+                echo "Raw response: " . substr($response->body, 0, 200) . "...\n";
+            }
+        } else {
+            echo "❌ HTTP Error: {$response->status_code}\n";
+            if (!empty($response->body)) {
+                echo "Error body: " . substr($response->body, 0, 200) . "...\n";
+            }
+        }
+        
+    } catch (Exception $e) {
+        echo "❌ Exception: " . $e->getMessage() . "\n";
+    }
+    
+    echo "\n" . str_repeat('-', 60) . "\n\n";
+}
+
+echo "=== Debug Complete ===\n\n";
+
+echo "Next steps if endpoints are failing:\n";
+echo "1. Verify JellyStat is running and accessible\n";  
+echo "2. Check if API key is correct and has permissions\n";
+echo "3. Test URLs directly in browser/curl\n";
+echo "4. Check JellyStat logs for errors\n";
+echo "5. Verify firewall/network connectivity\n\n";
+
+echo "If you have a working item ID from JellyStat, run:\n";
+echo "php debug_jellystat_metadata.php YOUR-ITEM-ID\n";

+ 41 - 1
js/custom.js

@@ -1160,6 +1160,19 @@ $(document).on('change keydown', '.addFormTick :input', function(e) {
         activeInfo.settings.misc.authDebug = value;
     }
 });
+
+// Additional handler for Switchery switches that don't trigger standard change events
+$(document).on('click', '.addFormTick .js-switch', function(e) {
+    var checkbox = this;
+    // Wait for Switchery to update the checkbox state
+    setTimeout(function() {
+        $(checkbox).attr('data-changed', true);
+        $(checkbox).closest('.form-group').addClass('has-success');
+        var formID = $(checkbox).closest('form').attr('id');
+        $('#'+formID+'-save').removeClass('hidden');
+        $('#'+formID+'-reset').removeClass('hidden');
+    }, 100);
+});
 //DELETE IMAGE
 $(document).on("click", ".deleteImage", function () {
     var image = $(this);
@@ -1399,14 +1412,41 @@ $(document).on("click", ".metadata-get", function(e) {
         case 'jellyfin':
             var action = 'getEmbyMetadata';
             break;
+        case 'jellystat':
+            var action = 'getJellyStatMetadata';
+            break;
         default:
 
     }
     ajaxloader(".content-wrap","in");
     organizrAPI2('POST','api/v2/homepage/'+source+'/metadata',{key:key}).success(function(data) {
         let response = data.response;
+        // Determine effective source for icon/button (e.g., emby/jellyfin) when coming from jellystat
+        let effectiveSource = source;
+        try {
+            if (source === 'jellystat' && response && response.data && response.data.content && response.data.content[0]) {
+                const c = response.data.content[0];
+                if (c.tabName) {
+                    const name = String(c.tabName).toLowerCase();
+                    if (name.indexOf('emby') !== -1) {
+                        effectiveSource = 'emby';
+                    } else if (name.indexOf('jellyfin') !== -1) {
+                        effectiveSource = 'jellyfin';
+                    }
+                }
+                // Fallback inference from address if tabName did not resolve
+                if ((effectiveSource === 'jellystat' || effectiveSource === source) && c.address) {
+                    const addr = String(c.address).toLowerCase();
+                    if (addr.indexOf('jellyfin') !== -1) {
+                        effectiveSource = 'jellyfin';
+                    } else if (addr.indexOf('emby') !== -1) {
+                        effectiveSource = 'emby';
+                    }
+                }
+            }
+        } catch (e) { /* no-op */ }
         $('.'+uid+'-metadata-info').html('');
-        $('.'+uid+'-metadata-info').html(buildMetadata(response.data, source));
+        $('.'+uid+'-metadata-info').html(buildMetadata(response.data, effectiveSource));
         $('.'+uid).trigger('click');
         $(".metadata-actors").owlCarousel({
             autoplay: true,

Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 0 - 0
js/custom.min.js


Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 0 - 0
js/custom.min.js.bak


+ 62 - 5
js/functions.js

@@ -1253,7 +1253,7 @@ function buildFormItem(item){
 			return '<input data-changed="false" lang="en" type="hidden" class="form-control'+extraClass+'"'+placeholder+value+id+name+disabled+type+label+attr+' />';
 			break;
 		case 'select':
-			return smallLabel+'<select class="form-control'+extraClass+'"'+placeholder+value+id+name+disabled+type+label+attr+'>'+selectOptions(item.options, item.value)+'</select>';
+			return smallLabel+'<select data-changed="false" class="form-control'+extraClass+'"'+placeholder+value+id+name+disabled+type+label+attr+'>'+selectOptions(item.options, item.value)+'</select>';
 			break;
 		case 'select2':
             var select2ID = (item.id) ? '#'+item.id : '.'+item.name;
@@ -2255,7 +2255,7 @@ function buildImageManagerView(){
 		        }else{
 			        $container.isotope({itemSelector : "img"});
 		        }
-	        }catch{
+	        }catch(e){
 		        $container.isotope('destroy');
 		        $container.isotope({itemSelector : "img"});
 	        }
@@ -7127,6 +7127,25 @@ function buildMetadata(array, source){
 	var rating = '<div class="col-xs-2 p-10"></div>';
     var sourceIcon = (source === 'jellyfin') ? 'fish' : source;
 	$.each(array.content, function(i,v) {
+        // Normalize per-item source when coming from JellyStat or unknown
+        var itemSource = source;
+        try {
+            if ((source === 'jellystat') || (source !== 'emby' && source !== 'jellyfin')) {
+                if (v.tabName) {
+                    var tn = String(v.tabName).toLowerCase();
+                    if (tn.indexOf('emby') !== -1) { itemSource = 'emby'; }
+                    else if (tn.indexOf('jellyfin') !== -1) { itemSource = 'jellyfin'; }
+                }
+                // Fallback inference from address if tabName did not resolve
+                if ((itemSource === source || itemSource === 'jellystat') && v.address) {
+                    var addr = String(v.address).toLowerCase();
+                    if (addr.indexOf('jellyfin') !== -1) { itemSource = 'jellyfin'; }
+                    else if (addr.indexOf('emby') !== -1) { itemSource = 'emby'; }
+                }
+            }
+        } catch(e) {}
+        // Normalize to lowercase to avoid casing issues like 'Emby'
+        itemSource = (itemSource || '').toString().toLowerCase();
 		var hasActor = (typeof v.metadata.actors !== 'string') ? true : false;
 		var hasGenre = (typeof v.metadata.genres !== 'string') ? true : false;
 		if(hasActor){
@@ -7146,6 +7165,14 @@ function buildMetadata(array, source){
 		var seconds = v.metadata.duration / 1000 ; // or "2000"
         seconds = parseInt(seconds); //because moment js dont know to handle number in string format
 		var format =  Math.floor(moment.duration(seconds,'seconds').asHours()) + ':' + moment.duration(seconds,'seconds').minutes() + ':' + moment.duration(seconds,'seconds').seconds();
+        // Build icon HTML: use image for Emby to avoid missing MDI glyphs; keep MDI for others
+        var sourceIconHtml = '';
+        var iconChoice = (itemSource === 'jellyfin') ? 'fish' : itemSource;
+        if (itemSource === 'emby') {
+            sourceIconHtml = '<img src="plugins/images/tabs/emby.png" class="metadata-source-image" style="height:24px;width:24px;" />';
+        } else {
+            sourceIconHtml = '<i class="fa mdi mdi-'+iconChoice+' fa-2x"></i>';
+        }
 		metadata = `
 		<div class="white-box m-b-0">
 			<div class="user-bg lazyload" data-src="`+v.nowPlayingImageURL+`">
@@ -7154,7 +7181,7 @@ function buildMetadata(array, source){
 	                <h2 class="m-b-0 font-medium pull-right text-right">
 						`+v.title+`<button type="button" class="btn bg-org btn-circle close-popup m-l-10"><i class="fa fa-times"></i> </button><br>
 						<small class="m-t-0 text-white">`+v.metadata.tagline+`</small><br>
-						<button class="btn waves-effect waves-light openTab bg-`+source+`" type="button" data-tab-name="`+cleanClass(v.tabName)+`" data-type="`+v.type+`" data-open-tab="`+v.openTab+`" data-url="`+v.address+`" href="javascript:void(0);"> <i class="fa mdi mdi-`+sourceIcon+` fa-2x"></i> </button>
+						<button class="btn waves-effect waves-light openTab bg-`+itemSource+`" type="button" data-tab-name="`+cleanClass(v.tabName)+`" data-type="`+v.type+`" data-open-tab="`+v.openTab+`" data-url="`+v.address+`" href="javascript:void(0);"> `+sourceIconHtml+` </button>
 						`+buildYoutubeLink(v.title+' '+v.metadata.year+' '+v.type)+`
 					</h2>
 	            </div>
@@ -10655,7 +10682,8 @@ function youtubeCheck(title,link){
 			inlineLoad();
 			var id = response.data.items["0"].id.videoId;
 			var div = `
-		<div id="player-`+link+`" data-plyr-provider="youtube" data-plyr-embed-id="`+id+`"></div>
+		<div id="player-`+link+`" data-plyr-provider="youtube" data-plyr-embed-id="`+id+`"
+		></div>
 		<div class="clearfix"></div>
 		`;
 			$('.youtube-div').html(div);
@@ -10665,9 +10693,13 @@ function youtubeCheck(title,link){
 
 	}).fail(function(xhr) {
 		OrganizrApiError(xhr, 'YouTube API Error');
+        // Fallback: open YouTube search in a new tab/window
+        var q = '';
+        try { q = decodeURIComponent(title); } catch(e1) { try { q = unescape(title); } catch(e2) { q = title; } }
+        var url = 'https://www.youtube.com/results?search_query=' + encodeURIComponent(q + ' trailer');
+        window.open(url, '_blank');
 	});
 }
-//request search
 function requestSearch(title,page=1) {
 	return $.ajax({
 		url: "https://api.themoviedb.org/3/search/multi?api_key=83cf4ee97bb728eeaf9d4a54e64356a1&language="+activeInfo.language+"&query="+title+"&page="+page+"&include_adult=false",
@@ -12026,6 +12058,31 @@ function jsFriendlyJSONStringify (s) {
 	replace(/\u2028/g, '\\u2028').
 	replace(/\u2029/g, '\\u2029');
 }
+function exportLogs() {
+    const query = "api/v2/log/0?filter=NONE&pageSize=1000&offset=0";
+    $.get(query, function (data) {
+        const logs = data.response.data.results;
+        let csvContent = "data:text/csv;charset=utf-8,Date,Severity,Function,Message,IP Address,User\n";
+        logs.forEach(function (log) {
+            const row = [
+                log.datetime,
+                log.log_level,
+                log.channel,
+                log.message,
+                log.remote_ip_address,
+                log.username
+            ].join(",");
+            csvContent += row + "\n";
+        });
+        const encodedUri = encodeURI(csvContent);
+        const link = document.createElement("a");
+        link.setAttribute("href", encodedUri);
+        link.setAttribute("download", "organizr_logs.csv");
+        document.body.appendChild(link); 
+        link.click();
+        document.body.removeChild(link);
+    });
+}
 function logContext(row){
 	let buttons = '';
 	buttons += (Object.keys(row).length > 0) ? '<button data-toggle="tooltip" title="" data-original-title="View Details" class="btn btn-xs btn-primary waves-effect waves-light log-details m-r-5" data-trace="'+row.trace_id+'"><i class="mdi mdi-file-find"></i></button>' : '';

BIN
plugins/images/homepage/jellystat.png


BIN
plugins/images/homepage/userWatchStats.png


+ 132 - 0
poster_updates.js

@@ -0,0 +1,132 @@
+            // Most Watched Movies with Posters
+            if (' . $showMostWatchedMovies . ' && stats.most_watched_movies && stats.most_watched_movies.length > 0) {
+                console.log("Rendering most watched movies:", stats.most_watched_movies);
+                html += "<div class=\"col-lg-12\" style=\"margin-top: 30px;\">";
+                html += "<h5><i class=\"fa fa-film text-primary\"></i> Most Watched Movies</h5>";
+                html += "<div style=\"margin-top: 15px; overflow-x: auto; white-space: nowrap; padding: 10px 0;\">";
+                
+                stats.most_watched_movies.forEach(function(movie) {
+                    console.log("Processing movie:", movie);
+                    var posterUrl = getPosterUrl(movie.poster_path, movie.id, movie.server_id);
+                    var playCount = movie.play_count || 0;
+                    var year = movie.year || "N/A";
+                    var title = movie.title || "Unknown Movie";
+                    
+                    html += "<div style=\"display: inline-block; margin: 10px; width: 150px; vertical-align: top;\">";
+                    html += "<div class=\"poster-card\" style=\"position: relative; background: #f8f9fa; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.1); width: 150px; height: 290px;\">";
+                    
+                    // Poster image container
+                    html += "<div class=\"poster-image\" style=\"position: relative; width: 150px; height: 225px; background: #e9ecef;\">";
+                    if (posterUrl) {
+                        html += "<img src=\"" + posterUrl + "\" alt=\"" + title + "\" style=\"width: 150px; height: 225px; object-fit: cover;\" onerror=\"this.style.display='none'; this.nextElementSibling.style.display='flex';\">";
+                    }
+                    html += "<div class=\"poster-placeholder\" style=\"position: absolute; top: 0; left: 0; width: 150px; height: 225px; display: " + (posterUrl ? "none" : "flex") + "; align-items: center; justify-content: center; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white;\">";
+                    html += "<i class=\"fa fa-film fa-3x\"></i>";
+                    html += "</div>";
+                    
+                    // Play count badge
+                    html += "<div class=\"play-count-badge\" style=\"position: absolute; top: 8px; right: 8px; background: rgba(0,0,0,0.8); color: white; padding: 4px 8px; border-radius: 12px; font-size: 11px;\">";
+                    html += "<i class=\"fa fa-play\"></i> " + playCount;
+                    html += "</div>";
+                    html += "</div>";
+                    
+                    // Movie info with improved height and text clipping
+                    html += "<div class=\"poster-info\" style=\"padding: 8px; text-align: center; height: 65px; display: flex; flex-direction: column; justify-content: space-between;\">";
+                    html += "<div style=\"font-size: 12px; font-weight: bold; line-height: 1.2; height: 36px; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;\" title=\"" + title + "\">" + title + "</div>";
+                    html += "<small class=\"text-muted\">" + year + "</small>";
+                    html += "</div>";
+                    
+                    html += "</div></div>";
+                });
+                
+                html += "</div></div>";
+            } else {
+                console.log("Movies not showing because:");
+                console.log("- Setting enabled:", ' . $showMostWatchedMovies . ');
+                console.log("- Has data:", stats.most_watched_movies && stats.most_watched_movies.length > 0);
+                console.log("- Data:", stats.most_watched_movies);
+            }
+            
+            // Most Watched TV Shows with Posters (updated to match movies)
+            if (' . $showMostWatchedShows . ' && stats.most_watched_shows && stats.most_watched_shows.length > 0) {
+                html += "<div class=\"col-lg-12\" style=\"margin-top: 30px;\">";
+                html += "<h5><i class=\"fa fa-television text-info\"></i> Most Watched TV Shows</h5>";
+                html += "<div style=\"margin-top: 15px; overflow-x: auto; white-space: nowrap; padding: 10px 0;\">";
+                
+                stats.most_watched_shows.forEach(function(show) {
+                    var posterUrl = getPosterUrl(show.poster_path, show.id, show.server_id);
+                    var playCount = show.play_count || 0;
+                    var year = show.year || "N/A";
+                    var title = show.title || "Unknown Show";
+                    
+                    html += "<div style=\"display: inline-block; margin: 10px; width: 150px; vertical-align: top;\">";
+                    html += "<div class=\"poster-card\" style=\"position: relative; background: #f8f9fa; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.1); width: 150px; height: 290px;\">";
+                    
+                    // Poster image with fixed dimensions like movies
+                    html += "<div class=\"poster-image\" style=\"position: relative; width: 150px; height: 225px; background: #e9ecef;\">";
+                    if (posterUrl) {
+                        html += "<img src=\"" + posterUrl + "\" alt=\"" + title + "\" style=\"width: 150px; height: 225px; object-fit: cover;\" onerror=\"this.style.display='none'; this.nextElementSibling.style.display='flex';\">";
+                    }
+                    html += "<div class=\"poster-placeholder\" style=\"position: absolute; top: 0; left: 0; width: 150px; height: 225px; display: " + (posterUrl ? "none" : "flex") + "; align-items: center; justify-content: center; background: linear-gradient(135deg, #74b9ff 0%, #0984e3 100%); color: white;\">";
+                    html += "<i class=\"fa fa-television fa-3x\"></i>";
+                    html += "</div>";
+                    
+                    // Play count badge
+                    html += "<div class=\"play-count-badge\" style=\"position: absolute; top: 8px; right: 8px; background: rgba(0,0,0,0.8); color: white; padding: 4px 8px; border-radius: 12px; font-size: 11px;\">";
+                    html += "<i class=\"fa fa-play\"></i> " + playCount;
+                    html += "</div>";
+                    html += "</div>";
+                    
+                    // Show info matching movies format
+                    html += "<div class=\"poster-info\" style=\"padding: 8px; text-align: center; height: 65px; display: flex; flex-direction: column; justify-content: space-between;\">";
+                    html += "<div style=\"font-size: 12px; font-weight: bold; line-height: 1.2; height: 36px; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;\" title=\"" + title + "\">" + title + "</div>";
+                    html += "<small class=\"text-muted\">" + year + "</small>";
+                    html += "</div>";
+                    
+                    html += "</div></div>";
+                });
+                
+                html += "</div></div>";
+            }
+            
+            // Most Listened Music with Cover Art (updated to match movies)
+            if (' . $showMostListenedMusic . ' && stats.most_listened_music && stats.most_listened_music.length > 0) {
+                html += "<div class=\"col-lg-12\" style=\"margin-top: 30px;\">";
+                html += "<h5><i class=\"fa fa-music text-success\"></i> Most Listened Music</h5>";
+                html += "<div style=\"margin-top: 15px; overflow-x: auto; white-space: nowrap; padding: 10px 0;\">";
+                
+                stats.most_listened_music.forEach(function(music) {
+                    var posterUrl = getPosterUrl(music.poster_path || music.cover_art, music.id, music.server_id);
+                    var playCount = music.play_count || 0;
+                    var artist = music.artist || "Unknown Artist";
+                    var title = music.title || music.album || "Unknown";
+                    
+                    html += "<div style=\"display: inline-block; margin: 10px; width: 150px; vertical-align: top;\">";
+                    html += "<div class=\"poster-card\" style=\"position: relative; background: #f8f9fa; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.1); width: 150px; height: 290px;\">";
+                    
+                    // Cover art with fixed dimensions like movies (square for music)
+                    html += "<div class=\"poster-image\" style=\"position: relative; width: 150px; height: 150px; background: #e9ecef;\">";
+                    if (posterUrl) {
+                        html += "<img src=\"" + posterUrl + "\" alt=\"" + title + "\" style=\"width: 150px; height: 150px; object-fit: cover;\" onerror=\"this.style.display='none'; this.nextElementSibling.style.display='flex';\">";
+                    }
+                    html += "<div class=\"poster-placeholder\" style=\"position: absolute; top: 0; left: 0; width: 150px; height: 150px; display: " + (posterUrl ? "none" : "flex") + "; align-items: center; justify-content: center; background: linear-gradient(135deg, #fd79a8 0%, #e84393 100%); color: white;\">";
+                    html += "<i class=\"fa fa-music fa-3x\"></i>";
+                    html += "</div>";
+                    
+                    // Play count badge
+                    html += "<div class=\"play-count-badge\" style=\"position: absolute; top: 8px; right: 8px; background: rgba(0,0,0,0.8); color: white; padding: 4px 8px; border-radius: 12px; font-size: 11px;\">";
+                    html += "<i class=\"fa fa-play\"></i> " + playCount;
+                    html += "</div>";
+                    html += "</div>";
+                    
+                    // Music info with proper space for title and artist
+                    html += "<div class=\"poster-info\" style=\"padding: 8px; text-align: center; height: 140px; display: flex; flex-direction: column; justify-content: space-between;\">";
+                    html += "<div style=\"font-size: 12px; font-weight: bold; line-height: 1.2; height: 36px; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;\" title=\"" + title + "\">" + title + "</div>";
+                    html += "<small class=\"text-muted\">" + artist + "</small>";
+                    html += "</div>";
+                    
+                    html += "</div></div>";
+                });
+                
+                html += "</div></div>";
+            }

+ 86 - 0
server.log

@@ -0,0 +1,86 @@
+[Sun Aug  3 21:10:29 2025] PHP 8.4.10 Development Server (http://localhost:8000) started
+[Sun Aug  3 21:10:30 2025] [::1]:60223 Accepted
+[Sun Aug  3 21:10:30 2025] PHP Deprecated:  GuzzleHttp\Promise\queue(): Implicitly marking parameter $assign as nullable is deprecated, the explicit nullable type must be used instead in /Users/mgomon/Documents/Code/organizr/api/vendor/guzzlehttp/promises/src/functions.php on line 24
+[Sun Aug  3 21:10:30 2025] PHP Deprecated:  GuzzleHttp\Promise\each(): Implicitly marking parameter $onFulfilled as nullable is deprecated, the explicit nullable type must be used instead in /Users/mgomon/Documents/Code/organizr/api/vendor/guzzlehttp/promises/src/functions.php on line 260
+[Sun Aug  3 21:10:30 2025] PHP Deprecated:  GuzzleHttp\Promise\each(): Implicitly marking parameter $onRejected as nullable is deprecated, the explicit nullable type must be used instead in /Users/mgomon/Documents/Code/organizr/api/vendor/guzzlehttp/promises/src/functions.php on line 260
+[Sun Aug  3 21:10:30 2025] PHP Deprecated:  GuzzleHttp\Promise\each_limit(): Implicitly marking parameter $onFulfilled as nullable is deprecated, the explicit nullable type must be used instead in /Users/mgomon/Documents/Code/organizr/api/vendor/guzzlehttp/promises/src/functions.php on line 285
+[Sun Aug  3 21:10:30 2025] PHP Deprecated:  GuzzleHttp\Promise\each_limit(): Implicitly marking parameter $onRejected as nullable is deprecated, the explicit nullable type must be used instead in /Users/mgomon/Documents/Code/organizr/api/vendor/guzzlehttp/promises/src/functions.php on line 285
+[Sun Aug  3 21:10:30 2025] PHP Deprecated:  GuzzleHttp\Promise\each_limit_all(): Implicitly marking parameter $onFulfilled as nullable is deprecated, the explicit nullable type must be used instead in /Users/mgomon/Documents/Code/organizr/api/vendor/guzzlehttp/promises/src/functions.php on line 307
+[Sun Aug  3 21:10:30 2025] PHP Deprecated:  Tightenco\Collect\Support\Collection::times(): Implicitly marking parameter $callback as nullable is deprecated, the explicit nullable type must be used instead in /Users/mgomon/Documents/Code/organizr/api/vendor/tightenco/collect/src/Collect/Support/Collection.php on line 117
+[Sun Aug  3 21:10:30 2025] PHP Deprecated:  Tightenco\Collect\Support\Collection::filter(): Implicitly marking parameter $callback as nullable is deprecated, the explicit nullable type must be used instead in /Users/mgomon/Documents/Code/organizr/api/vendor/tightenco/collect/src/Collect/Support/Collection.php on line 490
+[Sun Aug  3 21:10:30 2025] PHP Deprecated:  Tightenco\Collect\Support\Collection::when(): Implicitly marking parameter $default as nullable is deprecated, the explicit nullable type must be used instead in /Users/mgomon/Documents/Code/organizr/api/vendor/tightenco/collect/src/Collect/Support/Collection.php on line 507
+[Sun Aug  3 21:10:30 2025] PHP Deprecated:  Tightenco\Collect\Support\Collection::whenEmpty(): Implicitly marking parameter $default as nullable is deprecated, the explicit nullable type must be used instead in /Users/mgomon/Documents/Code/organizr/api/vendor/tightenco/collect/src/Collect/Support/Collection.php on line 525
+[Sun Aug  3 21:10:30 2025] PHP Deprecated:  Tightenco\Collect\Support\Collection::whenNotEmpty(): Implicitly marking parameter $default as nullable is deprecated, the explicit nullable type must be used instead in /Users/mgomon/Documents/Code/organizr/api/vendor/tightenco/collect/src/Collect/Support/Collection.php on line 537
+[Sun Aug  3 21:10:30 2025] PHP Deprecated:  Tightenco\Collect\Support\Collection::unless(): Implicitly marking parameter $default as nullable is deprecated, the explicit nullable type must be used instead in /Users/mgomon/Documents/Code/organizr/api/vendor/tightenco/collect/src/Collect/Support/Collection.php on line 550
+[Sun Aug  3 21:10:30 2025] PHP Deprecated:  Tightenco\Collect\Support\Collection::unlessEmpty(): Implicitly marking parameter $default as nullable is deprecated, the explicit nullable type must be used instead in /Users/mgomon/Documents/Code/organizr/api/vendor/tightenco/collect/src/Collect/Support/Collection.php on line 562
+[Sun Aug  3 21:10:30 2025] PHP Deprecated:  Tightenco\Collect\Support\Collection::unlessNotEmpty(): Implicitly marking parameter $default as nullable is deprecated, the explicit nullable type must be used instead in /Users/mgomon/Documents/Code/organizr/api/vendor/tightenco/collect/src/Collect/Support/Collection.php on line 574
+[Sun Aug  3 21:10:30 2025] PHP Deprecated:  Tightenco\Collect\Support\Collection::first(): Implicitly marking parameter $callback as nullable is deprecated, the explicit nullable type must be used instead in /Users/mgomon/Documents/Code/organizr/api/vendor/tightenco/collect/src/Collect/Support/Collection.php on line 757
+[Sun Aug  3 21:10:30 2025] PHP Deprecated:  Tightenco\Collect\Support\Collection::last(): Implicitly marking parameter $callback as nullable is deprecated, the explicit nullable type must be used instead in /Users/mgomon/Documents/Code/organizr/api/vendor/tightenco/collect/src/Collect/Support/Collection.php on line 1007
+[Sun Aug  3 21:10:30 2025] PHP Deprecated:  Tightenco\Collect\Support\Collection::sort(): Implicitly marking parameter $callback as nullable is deprecated, the explicit nullable type must be used instead in /Users/mgomon/Documents/Code/organizr/api/vendor/tightenco/collect/src/Collect/Support/Collection.php on line 1566
+[Sun Aug  3 21:10:30 2025] PHP Deprecated:  Return type of Tightenco\Collect\Support\Collection::offsetExists($key) should either be compatible with ArrayAccess::offsetExists(mixed $offset): bool, or the #[\ReturnTypeWillChange] attribute should be used to temporarily suppress the notice in /Users/mgomon/Documents/Code/organizr/api/vendor/tightenco/collect/src/Collect/Support/Collection.php on line 1911
+[Sun Aug  3 21:10:30 2025] PHP Deprecated:  Return type of Tightenco\Collect\Support\Collection::offsetGet($key) should either be compatible with ArrayAccess::offsetGet(mixed $offset): mixed, or the #[\ReturnTypeWillChange] attribute should be used to temporarily suppress the notice in /Users/mgomon/Documents/Code/organizr/api/vendor/tightenco/collect/src/Collect/Support/Collection.php on line 1922
+[Sun Aug  3 21:10:30 2025] PHP Deprecated:  Return type of Tightenco\Collect\Support\Collection::offsetSet($key, $value) should either be compatible with ArrayAccess::offsetSet(mixed $offset, mixed $value): void, or the #[\ReturnTypeWillChange] attribute should be used to temporarily suppress the notice in /Users/mgomon/Documents/Code/organizr/api/vendor/tightenco/collect/src/Collect/Support/Collection.php on line 1934
+[Sun Aug  3 21:10:30 2025] PHP Deprecated:  Return type of Tightenco\Collect\Support\Collection::offsetUnset($key) should either be compatible with ArrayAccess::offsetUnset(mixed $offset): void, or the #[\ReturnTypeWillChange] attribute should be used to temporarily suppress the notice in /Users/mgomon/Documents/Code/organizr/api/vendor/tightenco/collect/src/Collect/Support/Collection.php on line 1949
+[Sun Aug  3 21:10:30 2025] PHP Deprecated:  Return type of Tightenco\Collect\Support\Collection::count() should either be compatible with Countable::count(): int, or the #[\ReturnTypeWillChange] attribute should be used to temporarily suppress the notice in /Users/mgomon/Documents/Code/organizr/api/vendor/tightenco/collect/src/Collect/Support/Collection.php on line 1890
+[Sun Aug  3 21:10:30 2025] PHP Deprecated:  Return type of Tightenco\Collect\Support\Collection::getIterator() should either be compatible with IteratorAggregate::getIterator(): Traversable, or the #[\ReturnTypeWillChange] attribute should be used to temporarily suppress the notice in /Users/mgomon/Documents/Code/organizr/api/vendor/tightenco/collect/src/Collect/Support/Collection.php on line 1869
+[Sun Aug  3 21:10:30 2025] PHP Deprecated:  Return type of Tightenco\Collect\Support\Collection::jsonSerialize() should either be compatible with JsonSerializable::jsonSerialize(): mixed, or the #[\ReturnTypeWillChange] attribute should be used to temporarily suppress the notice in /Users/mgomon/Documents/Code/organizr/api/vendor/tightenco/collect/src/Collect/Support/Collection.php on line 1838
+[Sun Aug  3 21:10:30 2025] PHP Deprecated:  Tightenco\Collect\Support\Arr::first(): Implicitly marking parameter $callback as nullable is deprecated, the explicit nullable type must be used instead in /Users/mgomon/Documents/Code/organizr/api/vendor/tightenco/collect/src/Collect/Support/Arr.php on line 162
+[Sun Aug  3 21:10:30 2025] PHP Deprecated:  Tightenco\Collect\Support\Arr::last(): Implicitly marking parameter $callback as nullable is deprecated, the explicit nullable type must be used instead in /Users/mgomon/Documents/Code/organizr/api/vendor/tightenco/collect/src/Collect/Support/Arr.php on line 191
+[Sun Aug  3 21:10:30 2025] PHP Deprecated:  Organizr::setResponse(): Implicitly marking parameter $message as nullable is deprecated, the explicit nullable type must be used instead in /Users/mgomon/Documents/Code/organizr/api/classes/organizr.class.php on line 762
+[Sun Aug  3 21:10:30 2025] PHP Fatal error:  Trait method JellyStatHomepageItem::formatDuration has not been applied as Organizr::formatDuration, because of collision with HomepageUserWatchStats::formatDuration in /Users/mgomon/Documents/Code/organizr/api/classes/organizr.class.php on line 5
+[Sun Aug  3 21:10:30 2025] [::1]:60223 [200]: GET /api/v2/homepage/jellystat - Trait method JellyStatHomepageItem::formatDuration has not been applied as Organizr::formatDuration, because of collision with HomepageUserWatchStats::formatDuration in /Users/mgomon/Documents/Code/organizr/api/classes/organizr.class.php on line 5
+[Sun Aug  3 21:10:30 2025] [::1]:60223 Closing
+[Sun Aug  3 21:30:41 2025] [::1]:62129 Accepted
+[Sun Aug  3 21:30:41 2025] PHP Deprecated:  Return type of Tightenco\Collect\Support\Collection::offsetExists($key) should either be compatible with ArrayAccess::offsetExists(mixed $offset): bool, or the #[\ReturnTypeWillChange] attribute should be used to temporarily suppress the notice in /Users/mgomon/Documents/Code/organizr/api/vendor/tightenco/collect/src/Collect/Support/Collection.php on line 1911
+[Sun Aug  3 21:30:41 2025] PHP Deprecated:  Return type of Tightenco\Collect\Support\Collection::offsetGet($key) should either be compatible with ArrayAccess::offsetGet(mixed $offset): mixed, or the #[\ReturnTypeWillChange] attribute should be used to temporarily suppress the notice in /Users/mgomon/Documents/Code/organizr/api/vendor/tightenco/collect/src/Collect/Support/Collection.php on line 1922
+[Sun Aug  3 21:30:41 2025] PHP Deprecated:  Return type of Tightenco\Collect\Support\Collection::offsetSet($key, $value) should either be compatible with ArrayAccess::offsetSet(mixed $offset, mixed $value): void, or the #[\ReturnTypeWillChange] attribute should be used to temporarily suppress the notice in /Users/mgomon/Documents/Code/organizr/api/vendor/tightenco/collect/src/Collect/Support/Collection.php on line 1934
+[Sun Aug  3 21:30:41 2025] PHP Deprecated:  Return type of Tightenco\Collect\Support\Collection::offsetUnset($key) should either be compatible with ArrayAccess::offsetUnset(mixed $offset): void, or the #[\ReturnTypeWillChange] attribute should be used to temporarily suppress the notice in /Users/mgomon/Documents/Code/organizr/api/vendor/tightenco/collect/src/Collect/Support/Collection.php on line 1949
+[Sun Aug  3 21:30:41 2025] PHP Deprecated:  Return type of Tightenco\Collect\Support\Collection::count() should either be compatible with Countable::count(): int, or the #[\ReturnTypeWillChange] attribute should be used to temporarily suppress the notice in /Users/mgomon/Documents/Code/organizr/api/vendor/tightenco/collect/src/Collect/Support/Collection.php on line 1890
+[Sun Aug  3 21:30:41 2025] PHP Deprecated:  Return type of Tightenco\Collect\Support\Collection::getIterator() should either be compatible with IteratorAggregate::getIterator(): Traversable, or the #[\ReturnTypeWillChange] attribute should be used to temporarily suppress the notice in /Users/mgomon/Documents/Code/organizr/api/vendor/tightenco/collect/src/Collect/Support/Collection.php on line 1869
+[Sun Aug  3 21:30:41 2025] PHP Deprecated:  Return type of Tightenco\Collect\Support\Collection::jsonSerialize() should either be compatible with JsonSerializable::jsonSerialize(): mixed, or the #[\ReturnTypeWillChange] attribute should be used to temporarily suppress the notice in /Users/mgomon/Documents/Code/organizr/api/vendor/tightenco/collect/src/Collect/Support/Collection.php on line 1838
+[Sun Aug  3 21:30:41 2025] PHP Deprecated:  Organizr::setResponse(): Implicitly marking parameter $message as nullable is deprecated, the explicit nullable type must be used instead in /Users/mgomon/Documents/Code/organizr/api/classes/organizr.class.php on line 764
+[Sun Aug  3 21:30:41 2025] PHP Warning:  Undefined array key "QUERY_STRING" in /Users/mgomon/Documents/Code/organizr/api/v2/index.php on line 72
+[Sun Aug  3 21:30:41 2025] PHP Deprecated:  stripos(): Passing null to parameter #1 ($haystack) of type string is deprecated in /Users/mgomon/Documents/Code/organizr/api/v2/index.php on line 73
+[Sun Aug  3 21:30:41 2025] PHP Deprecated:  Slim\Psr7\Factory\StreamFactory::createStreamFromFile(): Implicitly marking parameter $cache as nullable is deprecated, the explicit nullable type must be used instead in /Users/mgomon/Documents/Code/organizr/api/vendor/slim/psr7/src/Factory/StreamFactory.php on line 52
+[Sun Aug  3 21:30:41 2025] PHP Deprecated:  Slim\Psr7\Factory\StreamFactory::createStreamFromResource(): Implicitly marking parameter $cache as nullable is deprecated, the explicit nullable type must be used instead in /Users/mgomon/Documents/Code/organizr/api/vendor/slim/psr7/src/Factory/StreamFactory.php on line 105
+[Sun Aug  3 21:30:42 2025] PHP Deprecated:  Slim\Psr7\Stream::__construct(): Implicitly marking parameter $cache as nullable is deprecated, the explicit nullable type must be used instead in /Users/mgomon/Documents/Code/organizr/api/vendor/slim/psr7/src/Stream.php on line 97
+[Sun Aug  3 21:30:42 2025] PHP Warning:  Cannot modify header information - headers already sent by (output started at /Users/mgomon/Documents/Code/organizr/api/vendor/slim/psr7/src/Stream.php:97) in /Users/mgomon/Documents/Code/organizr/api/functions/normal-functions.php on line 343
+[Sun Aug  3 21:30:42 2025] PHP Warning:  Cannot modify header information - headers already sent by (output started at /Users/mgomon/Documents/Code/organizr/api/vendor/slim/psr7/src/Stream.php:97) in /Users/mgomon/Documents/Code/organizr/api/functions/normal-functions.php on line 349
+[Sun Aug  3 21:30:42 2025] [::1]:62129 [200]: GET /api/v2/homepage
+[Sun Aug  3 21:30:42 2025] [::1]:62129 Closing
+[Sun Aug  3 21:31:02 2025] [::1]:62158 Accepted
+[Sun Aug  3 21:31:02 2025] PHP Deprecated:  Return type of Tightenco\Collect\Support\Collection::offsetExists($key) should either be compatible with ArrayAccess::offsetExists(mixed $offset): bool, or the #[\ReturnTypeWillChange] attribute should be used to temporarily suppress the notice in /Users/mgomon/Documents/Code/organizr/api/vendor/tightenco/collect/src/Collect/Support/Collection.php on line 1911
+[Sun Aug  3 21:31:02 2025] PHP Deprecated:  Return type of Tightenco\Collect\Support\Collection::offsetGet($key) should either be compatible with ArrayAccess::offsetGet(mixed $offset): mixed, or the #[\ReturnTypeWillChange] attribute should be used to temporarily suppress the notice in /Users/mgomon/Documents/Code/organizr/api/vendor/tightenco/collect/src/Collect/Support/Collection.php on line 1922
+[Sun Aug  3 21:31:02 2025] PHP Deprecated:  Return type of Tightenco\Collect\Support\Collection::offsetSet($key, $value) should either be compatible with ArrayAccess::offsetSet(mixed $offset, mixed $value): void, or the #[\ReturnTypeWillChange] attribute should be used to temporarily suppress the notice in /Users/mgomon/Documents/Code/organizr/api/vendor/tightenco/collect/src/Collect/Support/Collection.php on line 1934
+[Sun Aug  3 21:31:02 2025] PHP Deprecated:  Return type of Tightenco\Collect\Support\Collection::offsetUnset($key) should either be compatible with ArrayAccess::offsetUnset(mixed $offset): void, or the #[\ReturnTypeWillChange] attribute should be used to temporarily suppress the notice in /Users/mgomon/Documents/Code/organizr/api/vendor/tightenco/collect/src/Collect/Support/Collection.php on line 1949
+[Sun Aug  3 21:31:02 2025] PHP Deprecated:  Return type of Tightenco\Collect\Support\Collection::count() should either be compatible with Countable::count(): int, or the #[\ReturnTypeWillChange] attribute should be used to temporarily suppress the notice in /Users/mgomon/Documents/Code/organizr/api/vendor/tightenco/collect/src/Collect/Support/Collection.php on line 1890
+[Sun Aug  3 21:31:02 2025] PHP Deprecated:  Return type of Tightenco\Collect\Support\Collection::getIterator() should either be compatible with IteratorAggregate::getIterator(): Traversable, or the #[\ReturnTypeWillChange] attribute should be used to temporarily suppress the notice in /Users/mgomon/Documents/Code/organizr/api/vendor/tightenco/collect/src/Collect/Support/Collection.php on line 1869
+[Sun Aug  3 21:31:02 2025] PHP Deprecated:  Return type of Tightenco\Collect\Support\Collection::jsonSerialize() should either be compatible with JsonSerializable::jsonSerialize(): mixed, or the #[\ReturnTypeWillChange] attribute should be used to temporarily suppress the notice in /Users/mgomon/Documents/Code/organizr/api/vendor/tightenco/collect/src/Collect/Support/Collection.php on line 1838
+[Sun Aug  3 21:31:02 2025] PHP Warning:  Undefined array key "QUERY_STRING" in /Users/mgomon/Documents/Code/organizr/api/v2/index.php on line 72
+[Sun Aug  3 21:31:02 2025] PHP Deprecated:  stripos(): Passing null to parameter #1 ($haystack) of type string is deprecated in /Users/mgomon/Documents/Code/organizr/api/v2/index.php on line 73
+[Sun Aug  3 21:31:02 2025] [::1]:62158 [401]: GET /api/v2/homepage/jellystat
+[Sun Aug  3 21:31:02 2025] [::1]:62158 Closing
+[Sun Aug  3 21:40:09 2025] [::1]:63221 Accepted
+[Sun Aug  3 21:40:09 2025] PHP Deprecated:  Return type of Tightenco\Collect\Support\Collection::offsetExists($key) should either be compatible with ArrayAccess::offsetExists(mixed $offset): bool, or the #[\ReturnTypeWillChange] attribute should be used to temporarily suppress the notice in /Users/mgomon/Documents/Code/organizr/api/vendor/tightenco/collect/src/Collect/Support/Collection.php on line 1911
+[Sun Aug  3 21:40:09 2025] PHP Deprecated:  Return type of Tightenco\Collect\Support\Collection::offsetGet($key) should either be compatible with ArrayAccess::offsetGet(mixed $offset): mixed, or the #[\ReturnTypeWillChange] attribute should be used to temporarily suppress the notice in /Users/mgomon/Documents/Code/organizr/api/vendor/tightenco/collect/src/Collect/Support/Collection.php on line 1922
+[Sun Aug  3 21:40:09 2025] PHP Deprecated:  Return type of Tightenco\Collect\Support\Collection::offsetSet($key, $value) should either be compatible with ArrayAccess::offsetSet(mixed $offset, mixed $value): void, or the #[\ReturnTypeWillChange] attribute should be used to temporarily suppress the notice in /Users/mgomon/Documents/Code/organizr/api/vendor/tightenco/collect/src/Collect/Support/Collection.php on line 1934
+[Sun Aug  3 21:40:09 2025] PHP Deprecated:  Return type of Tightenco\Collect\Support\Collection::offsetUnset($key) should either be compatible with ArrayAccess::offsetUnset(mixed $offset): void, or the #[\ReturnTypeWillChange] attribute should be used to temporarily suppress the notice in /Users/mgomon/Documents/Code/organizr/api/vendor/tightenco/collect/src/Collect/Support/Collection.php on line 1949
+[Sun Aug  3 21:40:09 2025] PHP Deprecated:  Return type of Tightenco\Collect\Support\Collection::count() should either be compatible with Countable::count(): int, or the #[\ReturnTypeWillChange] attribute should be used to temporarily suppress the notice in /Users/mgomon/Documents/Code/organizr/api/vendor/tightenco/collect/src/Collect/Support/Collection.php on line 1890
+[Sun Aug  3 21:40:09 2025] PHP Deprecated:  Return type of Tightenco\Collect\Support\Collection::getIterator() should either be compatible with IteratorAggregate::getIterator(): Traversable, or the #[\ReturnTypeWillChange] attribute should be used to temporarily suppress the notice in /Users/mgomon/Documents/Code/organizr/api/vendor/tightenco/collect/src/Collect/Support/Collection.php on line 1869
+[Sun Aug  3 21:40:09 2025] PHP Deprecated:  Return type of Tightenco\Collect\Support\Collection::jsonSerialize() should either be compatible with JsonSerializable::jsonSerialize(): mixed, or the #[\ReturnTypeWillChange] attribute should be used to temporarily suppress the notice in /Users/mgomon/Documents/Code/organizr/api/vendor/tightenco/collect/src/Collect/Support/Collection.php on line 1838
+[Sun Aug  3 21:40:09 2025] PHP Warning:  Undefined array key "QUERY_STRING" in /Users/mgomon/Documents/Code/organizr/api/v2/index.php on line 72
+[Sun Aug  3 21:40:09 2025] PHP Deprecated:  stripos(): Passing null to parameter #1 ($haystack) of type string is deprecated in /Users/mgomon/Documents/Code/organizr/api/v2/index.php on line 73
+[Sun Aug  3 21:40:09 2025] [::1]:63221 [401]: GET /api/v2/homepage/jellystat
+[Sun Aug  3 21:40:09 2025] [::1]:63221 Closing
+[Sun Aug  3 21:40:14 2025] [::1]:63222 Accepted
+[Sun Aug  3 21:40:14 2025] PHP Deprecated:  Return type of Tightenco\Collect\Support\Collection::offsetExists($key) should either be compatible with ArrayAccess::offsetExists(mixed $offset): bool, or the #[\ReturnTypeWillChange] attribute should be used to temporarily suppress the notice in /Users/mgomon/Documents/Code/organizr/api/vendor/tightenco/collect/src/Collect/Support/Collection.php on line 1911
+[Sun Aug  3 21:40:14 2025] PHP Deprecated:  Return type of Tightenco\Collect\Support\Collection::offsetGet($key) should either be compatible with ArrayAccess::offsetGet(mixed $offset): mixed, or the #[\ReturnTypeWillChange] attribute should be used to temporarily suppress the notice in /Users/mgomon/Documents/Code/organizr/api/vendor/tightenco/collect/src/Collect/Support/Collection.php on line 1922
+[Sun Aug  3 21:40:14 2025] PHP Deprecated:  Return type of Tightenco\Collect\Support\Collection::offsetSet($key, $value) should either be compatible with ArrayAccess::offsetSet(mixed $offset, mixed $value): void, or the #[\ReturnTypeWillChange] attribute should be used to temporarily suppress the notice in /Users/mgomon/Documents/Code/organizr/api/vendor/tightenco/collect/src/Collect/Support/Collection.php on line 1934
+[Sun Aug  3 21:40:14 2025] PHP Deprecated:  Return type of Tightenco\Collect\Support\Collection::offsetUnset($key) should either be compatible with ArrayAccess::offsetUnset(mixed $offset): void, or the #[\ReturnTypeWillChange] attribute should be used to temporarily suppress the notice in /Users/mgomon/Documents/Code/organizr/api/vendor/tightenco/collect/src/Collect/Support/Collection.php on line 1949
+[Sun Aug  3 21:40:14 2025] PHP Deprecated:  Return type of Tightenco\Collect\Support\Collection::count() should either be compatible with Countable::count(): int, or the #[\ReturnTypeWillChange] attribute should be used to temporarily suppress the notice in /Users/mgomon/Documents/Code/organizr/api/vendor/tightenco/collect/src/Collect/Support/Collection.php on line 1890
+[Sun Aug  3 21:40:14 2025] PHP Deprecated:  Return type of Tightenco\Collect\Support\Collection::getIterator() should either be compatible with IteratorAggregate::getIterator(): Traversable, or the #[\ReturnTypeWillChange] attribute should be used to temporarily suppress the notice in /Users/mgomon/Documents/Code/organizr/api/vendor/tightenco/collect/src/Collect/Support/Collection.php on line 1869
+[Sun Aug  3 21:40:14 2025] PHP Deprecated:  Return type of Tightenco\Collect\Support\Collection::jsonSerialize() should either be compatible with JsonSerializable::jsonSerialize(): mixed, or the #[\ReturnTypeWillChange] attribute should be used to temporarily suppress the notice in /Users/mgomon/Documents/Code/organizr/api/vendor/tightenco/collect/src/Collect/Support/Collection.php on line 1838
+[Sun Aug  3 21:40:14 2025] PHP Warning:  Undefined array key "QUERY_STRING" in /Users/mgomon/Documents/Code/organizr/api/v2/index.php on line 72
+[Sun Aug  3 21:40:14 2025] PHP Deprecated:  stripos(): Passing null to parameter #1 ($haystack) of type string is deprecated in /Users/mgomon/Documents/Code/organizr/api/v2/index.php on line 73
+[Sun Aug  3 21:40:14 2025] [::1]:63222 [401]: GET /api/v2/homepage/jellystat
+[Sun Aug  3 21:40:14 2025] [::1]:63222 Closing

+ 39 - 0
test_debug.php

@@ -0,0 +1,39 @@
+<?php
+error_reporting(E_ALL);
+ini_set('display_errors', 1);
+
+echo "Starting test...\n";
+
+// Include all necessary files
+$traitsPath = __DIR__ . '/api/functions/';
+$homepagePath = __DIR__ . '/api/homepage/';
+
+// Get all trait files
+$traitFiles = glob($traitsPath . '*.php');
+$homepageFiles = glob($homepagePath . '*.php');
+
+echo "Loading trait files...\n";
+foreach ($traitFiles as $file) {
+    echo "Including: " . basename($file) . "\n";
+    include_once $file;
+}
+
+echo "Loading homepage files...\n";
+foreach ($homepageFiles as $file) {
+    echo "Including: " . basename($file) . "\n";
+    include_once $file;
+}
+
+echo "Loading main class...\n";
+include_once __DIR__ . '/api/classes/organizr.class.php';
+
+echo "Creating instance...\n";
+try {
+    $organizr = new Organizr();
+    echo "SUCCESS: Class instantiated successfully!\n";
+} catch (Throwable $e) {
+    echo "ERROR: " . $e->getMessage() . "\n";
+    echo "File: " . $e->getFile() . "\n";
+    echo "Line: " . $e->getLine() . "\n";
+    echo "Trace:\n" . $e->getTraceAsString() . "\n";
+}

+ 37 - 0
test_jellystat_api.html

@@ -0,0 +1,37 @@
+<!DOCTYPE html>
+<html>
+<head>
+    <title>JellyStat API Test</title>
+</head>
+<body>
+    <h1>JellyStat Metadata API Test</h1>
+    <button onclick="testMetadata()">Test Metadata API</button>
+    <pre id="result"></pre>
+    
+    <script>
+    async function testMetadata() {
+        const token = '4wr9yn1z30k57hnsczpu';
+        const testKey = '123456'; // Replace with actual item ID
+        
+        try {
+            const response = await fetch('https://media.glassnetworks.net/api/v2/homepage/jellystat/metadata', {
+                method: 'POST',
+                headers: {
+                    'Content-Type': 'application/json',
+                    'Token': token
+                },
+                body: JSON.stringify({ key: testKey })
+            });
+            
+            const data = await response.json();
+            document.getElementById('result').textContent = JSON.stringify(data, null, 2);
+        } catch (error) {
+            document.getElementById('result').textContent = 'Error: ' + error.message;
+        }
+    }
+    
+    // Auto-run on load
+    window.onload = testMetadata;
+    </script>
+</body>
+</html>

Một số tệp đã không được hiển thị bởi vì quá nhiều tập tin thay đổi trong này khác